A Signal's own icon, from all of Font Awesome Free, searchable (#261)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-16 18:36:08 +00:00
parent 9e5dcbb8c4
commit b18c92d1f5
15 changed files with 269 additions and 31 deletions

View File

@@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Signals pick what matters out of the noise of every feed you subscribe to: New signal takes a name, the words to listen for and, if those are not enough, a sentence saying what you mean, and its page lists the items from your feeds that are about it, read and unread as in their own feeds. Settings takes your TypeSafe key, which Signals need (#254). They are kept in a Signals folder first in the feed list, under All Subscriptions, closed or open as an OPML is, with a + on its row for a new one and its own page listing them (#255, #256).
- Signals pick what matters out of the noise of every feed you subscribe to: New signal takes a name, the words to listen for and, if those are not enough, a sentence saying what you mean, and its page lists the items from your feeds that are about it, read and unread as in their own feeds. Settings takes your TypeSafe key, which Signals need (#254). They are kept in a Signals folder first in the feed list, under All Subscriptions, closed or open as an OPML is, with a + on its row for a new one and its own page listing them (#255, #256). Each can have an icon of its own, searched for among all of Font Awesome Free, in place of its initials (#261).
- A list opens at once when you have seen it before or the page fetched it ahead: All Subscriptions and the feeds with something unread are fetched when the page loads, and a feed or filter as the pointer or a finger reaches it, then refreshed behind once shown. A list that has to be waited for shows a spinner instead of nothing (#247). Currently Listening and the Directory's front page are fetched ahead the same way and open at once (#248).
### Changed

View File

@@ -126,7 +126,7 @@ http://{$WEB_BIND::8080} {
# Scripts and the stylesheet are named by a hash of their contents (web/build.mjs), so a browser
# keeps them a year; the icons a day, under names that do not change.
@hashed path /app.js /admin.js /login.js /app.css /hls.js /boot.js
@hashed path /app.js /admin.js /login.js /app.css /hls.js /boot.js /icons/index.json
header @hashed Cache-Control "public, max-age=31536000, immutable"
# The same PNG as /favicon.png, for a browser that asks for /favicon.ico on its own.
header /favicon.ico Content-Type image/png

View File

@@ -592,6 +592,21 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
.feed.child .art{width:28px;height:28px;font-size:11px}
/* A Signal's tile is its icon, as a place's is: there is no artwork of its own. */
.art.sig .i{width:45%;height:45%}
/* A Signal's own icon (#261): Font Awesome's file as a mask, so it is drawn in the text's colour
and follows the theme as the page's own icons do. */
.ficon{
display:block;width:50%;height:50%;background:currentColor;
-webkit-mask:var(--icon) center/contain no-repeat;mask:var(--icon) center/contain no-repeat;
}
.iconpick{flex:none;border-radius:9px}
.iconpick .art{width:38px;height:38px;font-size:13px}
.icongrid{
display:grid;grid-template-columns:repeat(auto-fill,minmax(40px,1fr));gap:4px;
max-height:220px;overflow-y:auto;margin-top:8px;
}
.iconcell{height:40px;border-radius:8px;display:grid;place-items:center;color:var(--dim)}
.iconcell:hover,.iconcell[aria-selected="true"]{background:var(--raise);color:var(--fg)}
.iconcell .ficon{width:20px;height:20px}
/* New signal, at the end of the folder's row. */
.sigadd{width:26px;height:26px;margin-left:-4px}
.sigadd .i{width:12px;height:12px}

View File

@@ -38,6 +38,23 @@ const hash = s => crypto.createHash('sha256').update(s).digest('hex').slice(0, 1
const HLS = fs.readFileSync(path.join(here, '..', 'node_modules', 'hls.js', 'dist', 'hls.light.min.js'), 'utf8');
const HLS_URL = `/hls.js?v=${hash(HLS)}`;
// Font Awesome Free, the set the page's own icons come from, whole, for a Signal's icon (#261):
// each icon a file of its own, /icons/<style>/<name>.svg, drawn as a mask so it takes the theme's
// colour, and an index of names and search terms the picker fetches when it opens. The page's
// script is given the index's address, named by its hash, in place of __ICONS__.
const FA = path.join(here, '..', 'node_modules', '@fortawesome', 'fontawesome-free');
const FA_STYLES = ['solid', 'regular', 'brands'];
/// [`style/name`, label, search terms] for every icon, solid first.
export function iconIndex() {
const meta = JSON.parse(fs.readFileSync(path.join(FA, 'metadata', 'icon-families.json'), 'utf8'));
return FA_STYLES.flatMap(style => fs.readdirSync(path.join(FA, 'svgs', style)).sort().map(f => {
const name = f.slice(0, -4), m = meta[name] || {};
return [`${style}/${name}`, m.label || name, (m.search?.terms || []).join(' ')];
}));
}
const ICONS = JSON.stringify(iconIndex());
const ICONS_URL = `/icons/index.json?v=${hash(ICONS)}`;
/// The shared stylesheet, minified. @swc/html minifies CSS inside a page, so it goes through as
/// one; the doctype only keeps it from complaining that a fragment has none.
export function buildStyle({ minify = true } = {}) {
@@ -61,6 +78,7 @@ export function buildPage(name, { minify = true } = {}) {
const { script, src: files } = PAGES[name];
const src = files.map(f => fs.readFileSync(path.join(here, 'src', f + '.ts'), 'utf8')).join('\n')
.replaceAll('__HLS_JS__', HLS_URL)
.replaceAll('__ICONS__', ICONS_URL)
.replaceAll('__WEB_VERSION__', WEB_VERSION);
const js = swc.transformSync(src, {
filename: name + '.ts',
@@ -109,6 +127,12 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) {
['apple-touch-icon.png', 'apple-touch-icon.png'], ['apple-touch-icon.png', 'apple-touch-icon-precomposed.png'],
['ipodderx-icon.png', 'icon.png'], ['boot.js', 'boot.js'], ['InterVariable.woff2', 'inter.woff2'], ['Inter-LICENSE.txt', 'Inter-LICENSE.txt'],
]) fs.copyFileSync(path.join(here, from), path.join(out, to));
for (const style of FA_STYLES) {
fs.mkdirSync(path.join(out, 'icons', style), { recursive: true });
for (const f of fs.readdirSync(path.join(FA, 'svgs', style))) fs.copyFileSync(path.join(FA, 'svgs', style, f), path.join(out, 'icons', style, f));
}
fs.writeFileSync(path.join(out, 'icons', 'index.json'), ICONS);
fs.copyFileSync(path.join(FA, 'LICENSE.txt'), path.join(out, 'icons', 'LICENSE.txt'));
for (const name of Object.keys(PAGES)) {
const { html, js, script } = buildPage(name);
fs.writeFileSync(path.join(out, name), html);

View File

@@ -30,6 +30,15 @@ function feedRow(id: string, cls: string, html: string, open: ()=>void){
el.onclick=()=>{ open(); nav(false); };
return el;
}
/// A Signal's icon, drawn from /icons as a mask so it takes the theme's colour, or its initials.
/// The name goes into an address and a style: only a style and a plain name are drawn (the server
/// takes nothing else either).
const ICON_ID=/^(solid|regular|brands)\/[a-z0-9-]{1,64}$/;
const iconHTML=(icon: string)=>`<span class="ficon" style="--icon:url('/icons/${icon}.svg')"></span>`;
function signalTile(sg: {name: string, icon: string|null}){
return sg.icon&&ICON_ID.test(sg.icon)
? `<div class="art ini sig" style="--tint:${tint(sg.name)}">${iconHTML(sg.icon)}</div>` : tileHTML(sg.name);
}
const badgeHTML=(n: number)=>`<span class="badge${n?'':' zero'}" title="${n} unread">${n>999?'999+':n}</span>`;
function signalFolder(list: HTMLElement, q: string){
const mine=S.signals.filter(s=>!q||s.name.toLowerCase().includes(q));
@@ -48,7 +57,7 @@ function signalFolder(list: HTMLElement, q: string){
if(!open) return;
for(const sg of mine){
const el=feedRow(':signal:'+sg.id,'child',
`<span class="fart">${tileHTML(sg.name)}</span><div class="txt"><b>${esc(sg.name)}</b><small>${esc(sg.keywords.join(', '))}</small></div>`+badgeHTML(sg.unread),
`<span class="fart">${signalTile(sg)}</span><div class="txt"><b>${esc(sg.name)}</b><small>${esc(sg.keywords.join(', '))}</small></div>`+badgeHTML(sg.unread),
()=>selectFeed(':signal:'+sg.id));
el.onpointerenter=el.ontouchstart=()=>prefetch(':signal:'+sg.id);
list.appendChild(el);
@@ -84,7 +93,7 @@ function renderSignals(){
for(const sg of S.signals){
const el=document.createElement('div');
el.className='childrow';
el.innerHTML=tileHTML(sg.name)+`<div class="txt"><b>${esc(sg.name)}</b>`+
el.innerHTML=signalTile(sg)+`<div class="txt"><b>${esc(sg.name)}</b>`+
`<small class="meta">${esc(sg.keywords.join(', '))}</small></div><span class="badge${sg.unread?'':' zero'}">${sg.unread}</span>`;
el.onclick=()=>selectFeed(':signal:'+sg.id);
list.appendChild(el);
@@ -95,7 +104,7 @@ function signalHead(sg: Signal){
const nokey=S.me&&!S.me.typesafe_key_set;
return `
<div class="fhead slim">
<div class="art">${ICON.signal}</div>
${signalTile(sg)}
<div class="meta">
<h2>${esc(sg.name)}</h2>
<div class="sub">Picked out of the noise of your ${plural(S.feeds.length,'feed')}: ${esc(sg.description||sg.keywords.join(', '))} · ${sg.unread} unread</div>
@@ -124,7 +133,14 @@ function signalModal(sg?: Signal){
const nokey=S.me&&!S.me.typesafe_key_set;
openModal(`<h3>${sg?'Edit signal':'New signal'}</h3>
<div class="field"><label for="sgname">Name</label>
<input type="text" id="sgname" value="${esc(sg?.name||'')}" placeholder="Formula 1"></div>
<div class="inline"><button type="button" class="iconpick" id="sgicon" title="Choose an icon" aria-label="Choose an icon"
aria-expanded="false"></button>
<input type="text" id="sgname" value="${esc(sg?.name||'')}" placeholder="Formula 1"></div>
<div id="sgpicker" hidden>
<div class="inline"><input type="search" id="sgiconq" placeholder="Search icons: robot, car, music…" autocomplete="off">
<button type="button" class="dlink" id="sgnoicon">Initials</button></div>
<div class="icongrid" id="sgicons" role="listbox" aria-label="Icons"></div>
</div></div>
<div class="field"><label for="sgkwin">Listen for</label>
<div class="chips" id="sgkw">${chipsHTML(sg?.keywords||[],'sgkwin')}</div></div>
<div class="field"><label for="sgdesc">In your own words (optional)</label>
@@ -137,12 +153,38 @@ function signalModal(sg?: Signal){
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
<button class="btn ico primary" id="sgsave" title="${sg?'Save':'Make signal'}" aria-label="${sg?'Save':'Make signal'}">${sg?ICON.check:ICON.plus}</button></div>`);
wireChips($('#sgkw'));
let icon=sg?.icon??null;
const drawPick=()=>{ $('#sgicon').innerHTML=signalTile({name:$<HTMLInputElement>('#sgname').value||'?',icon}); };
drawPick();
$<HTMLInputElement>('#sgname').oninput=drawPick;
const grid=$('#sgicons');
const drawGrid=async()=>{
const all=await iconList();
const shown=iconMatches(all,$<HTMLInputElement>('#sgiconq').value,ICON_CELLS);
grid.innerHTML=shown.length ? shown.map(([id,label])=>`<button type="button" class="iconcell" role="option" data-ic="${id}"
aria-selected="${id===icon}" title="${esc(label)}" aria-label="${esc(label)}">${iconHTML(id)}</button>`).join('')
: '<p class="hint">No icon matches that.</p>';
};
$<HTMLButtonElement>('#sgicon').onclick=()=>{
const p=$('#sgpicker'), open=p.hidden;
p.hidden=!open; $('#sgicon').setAttribute('aria-expanded',String(open));
if(open){ drawGrid().catch(e=>toast(why(e),true)); $<HTMLInputElement>('#sgiconq').focus(); }
};
$<HTMLInputElement>('#sgiconq').oninput=()=>{ drawGrid().catch(()=>{}); };
grid.onclick=(e: MouseEvent)=>{
const b=(e.target as Element).closest<HTMLElement>('[data-ic]'); if(!b) return;
icon=b.dataset.ic!; drawPick(); $('#sgpicker').hidden=true; $('#sgicon').setAttribute('aria-expanded','false');
};
$<HTMLButtonElement>('#sgnoicon').onclick=()=>{ icon=null; drawPick(); $('#sgpicker').hidden=true; };
$<HTMLInputElement>('#sgname').focus();
$<HTMLButtonElement>('#sgsave').onclick=async()=>{
const name=$<HTMLInputElement>('#sgname').value.trim(), keywords=chipWords($('#sgkw'));
const name=$<HTMLInputElement>('#sgname').value.trim(), keywords=chipWords($('#sgkw')).filter((w): w is string=>!!w);
if(!name){ $<HTMLInputElement>('#sgname').focus(); return; }
if(!keywords.length){ $<HTMLInputElement>('#sgkwin').focus(); return; }
const body=JSON.stringify({name,keywords,description:$<HTMLInputElement>('#sgdesc').value.trim()||null});
const description=$<HTMLInputElement>('#sgdesc').value.trim()||null;
// Only what changed: a new name, words or sentence has Jev read everything again, and saving
// a new icon alone did that too while the dialog sent them all back each time.
const body=JSON.stringify(sg ? signalChanges(sg,{name,keywords,description,icon}) : {name,keywords,description,icon});
try{
const r=sg ? await api(`/api/signals/${sg.id}`,{method:'PATCH',body})
: await api('/api/signals',{method:'POST',body});
@@ -153,3 +195,38 @@ function signalModal(sg?: Signal){
}catch(e){ toast(why(e),true); }
};
}
/// What differs between a Signal and the dialog's fields, as PATCH takes it.
function signalChanges(sg: Signal, now: {name: string, keywords: string[], description: string|null, icon: string|null}){
const out: Record<string, unknown>={};
if(now.name!==sg.name) out.name=now.name;
if(now.keywords.join('\n')!==sg.keywords.join('\n')) out.keywords=now.keywords;
if(now.description!==sg.description) out.description=now.description;
if(now.icon!==sg.icon) out.icon=now.icon;
return out;
}
/// Font Awesome Free's index, `[style/name, label, terms]`, fetched once, when the picker first
/// opens: 2,883 icons are about 90 KB compressed, which the page has no other use for.
const ICON_CELLS=120;
let icons: Promise<[string,string,string][]>|null=null;
function iconList(){
if(!icons) icons=api('__ICONS__').catch((e: unknown)=>{ icons=null; throw e; });
return icons!;
}
/// The icons with a word starting with each word typed, in their name, label or search terms. The
/// first word typed ranks them: the icon named it, then one with it as a word of its name, then as
/// a search term, then the rest. Matching anywhere in a word, "car" filled the grid with caret and
/// cart (#261). At most `max`; nothing typed is the first `max`.
function iconMatches(all: [string,string,string][], q: string, max: number){
const words=q.toLowerCase().split(/\s+/).filter(Boolean);
if(!words.length) return all.slice(0,max);
const split=(s: string)=>s.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
const rank=([id,label,terms]: [string,string,string])=>{
const name=id.slice(id.indexOf('/')+1), own=split(name), hay=[...own,...split(label),...split(terms)];
if(!words.every(w=>hay.some(h=>h.startsWith(w)))) return -1;
const w=words[0];
return name===w ? 0 : own.includes(w) ? 1 : hay.includes(w) ? 2 : 3;
};
return all.map(i=>[rank(i),i] as const).filter(([r])=>r>=0).sort((a,b)=>a[0]-b[0]).slice(0,max).map(([,i])=>i);
}

View File

@@ -93,6 +93,8 @@ interface Signal {
keywords: string[];
/// What its owner means by it, in their words (#259).
description: string | null;
/// A Font Awesome Free icon, `solid/robot`, in place of its initials (#261).
icon: string | null;
unread: number;
}