Files
ipx/web/src/signals.ts
rays 6ec2c35291 Keyboard, screen reader and contrast fixes from the interface review (#298 #299 #300 #301 #302 #303 #304 #305 #306 #307 #308 #309)
Found reviewing the main window, its dialogs and the Directory in all 25 palettes (13 themes,
light and dark), in Playwright against the test daemon.

- #298: an item's title is a button, so Tab and a screen reader can open it; the feed list's
  rows, places, Signals and the Directory's shows are links to a screen reader (asLink), named
  by what they show, the open one aria-current. Not role=button, which would flatten a folder's
  triangle inside it.
- #299: .ep:focus-within shows the row's actions; Tab reached Play and Delete at opacity 0.
- #300: the read toggle is named Mark read / Mark unread, the dot hidden from screen readers.
- #301: the seek slider is named Position, its value read as "12:30 of 45:00".
- #302: ::placeholder in --faint; the browser's grey was 2.7-4.4:1 in 20 palettes. The search
  and filter boxes have names that do not depend on their placeholder.
- #303: #toasts is role=status; an error toast stays until dismissed, at most four at once.
- #304: api() says what to do when iPX cannot be reached or answers 500 (whose text is the
  daemon's internals, already in its log); play() failures are put in words and reported, an
  AbortError ignored; a failed download names its item.
- #305, #308: --field, the theme's --fg mixed into --bg in OKLab (--fieldmix, 60% and up to 80%
  for Catppuccin light), edges text boxes, selects, chips, the OPML box and switch tracks,
  phones included, and rings a light theme's switch knob. tests/contrast.js holds it to 3:1 on
  bg, panel, panel2 and raise in every palette. --line stays for rules. Fields lose outline:0,
  so they take the same focus ring as everything else.
- #306: openModal makes the card role=dialog aria-modal, labelled by its heading, focuses it,
  and makes the rest of the body inert until closeModal, which gives focus back to what opened
  it. Not <dialog>: its top layer would cover the toasts and their Dismiss.
- #307: the labels in Add a feed, Settings, Download latest and OPML point at their boxes.
- #309: Settings shows iPX's and the page's versions at its foot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:12:34 +00:00

295 lines
17 KiB
TypeScript

/* ---------------- signals ---------------- */
/// Signals (#254): feeds you make from your own subscriptions, holding the items TypeSafe's Jev
/// says are about the keywords you gave. A Signal is a place in the sidebar, `:signal:<id>`.
/// The Signals' counts again, which nothing else brings: reading an item, here or in its feed,
/// and Jev picking one out both change them (#258). Once a burst has passed.
const refreshSignals=(()=>{ let t: number; return ()=>{ clearTimeout(t); t=setTimeout(()=>{
api('/api/signals').then((r: Signal[])=>{ S.signals=r; renderFeeds(); }).catch(()=>{});
},400); }; })();
/// The Signal's id in a place's id, or null for anything else.
function signalId(view: string|null){
const m=/^:signal:(\d+)$/.exec(view||'');
return m ? Number(m[1]) : null;
}
function signalOf(view: string|null){
const id=signalId(view);
return id===null ? undefined : S.signals.find(s=>s.id===id);
}
/// Whether you have Signals (#271): everyone does on the server's key; on your own, once you have
/// turned them on in Settings with a key TypeSafe took.
const signalsOn=()=>S.me?.signals==='server'||!!S.me?.typesafe_key_set;
/// The Signals folder: first in the feed list, under All Subscriptions, open or closed as an OPML's
/// is (#255, #256). As rows under the places, a long list of them pushed the feeds down.
const SIGNALS=':signals';
const sigArt=()=>`<span class="fart"><div class="art">${tile('signals')}</div></span>`;
function feedRow(id: string, cls: string, html: string, open: ()=>void){
const el=document.createElement('div');
el.className='feed '+cls+(S.feed===id?' sel':'');
el.tabIndex=0; el.dataset.id=id; el.innerHTML=html;
asLink(el,S.feed===id);
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>`;
/// Every Signal in the accent tint the folder has, icon or initials: tinted by a hash of its name,
/// as a feed without art is, one came out a colour of its own for no reason anyone could see (#262).
function signalTile(sg: {name: string, icon: string|null}){
const inner=sg.icon&&ICON_ID.test(sg.icon) ? iconHTML(sg.icon) : esc(initials(sg.name));
return `<div class="art ini sig" style="--tint:var(--accent)">${inner}</div>`;
}
const badgeHTML=(n: number)=>`<span class="badge${n?'':' zero'}" title="${n} unread">${n>999?'999+':n}</span>`;
function signalFolder(list: HTMLElement, q: string){
if(!signalsOn()) return;
const mine=S.signals.filter(s=>!q||s.name.toLowerCase().includes(q));
if(q&&!mine.length) return;
const open=expanded.has(SIGNALS)||!!q;
const n=S.signals.length;
list.appendChild(feedRow(SIGNALS,'sigs',
`<button class="chev" aria-expanded="${open}" title="Show or hide your signals" aria-label="Show or hide your signals">${ICON.caret}</button>`+
sigArt()+`<div class="txt"><b>Signals</b><small>${n?plural(n,'signal'):'What matters, out of the noise'}</small></div>`+
// No + of its own (#265): a row like any folder's. New signal is on the folder's page.
badgeHTML(S.signals.reduce((k,s)=>k+s.unread,0)),
()=>selectFeed(SIGNALS)));
const row=list.lastElementChild!;
$('.chev',row).onclick=(ev: Event)=>{ ev.stopPropagation(); toggleGroup(SIGNALS); };
if(!open) return;
for(const sg of mine){
const el=feedRow(':signal:'+sg.id,'child',
`<span class="fart">${signalTile(sg)}</span><div class="txt"><b>${esc(sg.name)}</b><small>${signalCounts(sg)}</small></div>`+badgeHTML(sg.unread),
()=>selectFeed(':signal:'+sg.id));
el.onpointerenter=el.ontouchstart=()=>prefetch(':signal:'+sg.id);
list.appendChild(el);
}
}
/// The folder's page: every Signal, as an OPML's page lists its feeds.
function renderSignals(){
const unread=S.signals.reduce((k,s)=>k+s.unread,0);
$('#count').textContent=`Signals: ${plural(S.signals.length,'signal')}, ${unread} unread`;
$<HTMLButtonElement>('#tbRemove').disabled=true;
syncTools(null);
const box=$('#content');
box.classList.add('plain');
box.innerHTML=`
<div class="fhead slim">
<div class="art">${tile('signals')}</div>
<div class="meta">
<h2>Signals</h2>
<div class="sub">Each one picks what matters out of the noise of your ${plural(S.feeds.length,'feed')} · ${unread} unread</div>
</div>
<div class="acts">
<button class="btn ico" data-a="new" title="New signal" aria-label="New signal">${ICON.plus}</button>
</div>
</div>
<div class="childlist" id="siglist"></div>`;
$<HTMLButtonElement>('#content .acts [data-a="new"]').onclick=()=>signalModal();
const list=$('#siglist');
if(!S.signals.length){
list.innerHTML='<p class="empty">No signals yet. Make one to pull a subject out of every feed you subscribe to.</p>';
return;
}
for(const sg of S.signals){
const el=document.createElement('div');
el.className='childrow';
el.innerHTML=signalTile(sg)+`<div class="txt"><b>${esc(sg.name)}</b>`+
`<small class="meta">${signalCounts(sg)}</small></div><span class="badge${sg.unread?'':' zero'}">${sg.unread}</span>`;
el.onclick=()=>selectFeed(':signal:'+sg.id);
list.appendChild(el);
}
}
function signalHead(sg: Signal){
const nokey=S.me&&!signalsOn();
return `
<div class="fhead slim">
${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>
${nokey?`<div class="sub" style="color:var(--warn)">No TypeSafe key yet, so nothing is being picked out. ${KEY_LINKS}</div>`:''}
</div>
<div class="acts">
<button class="btn ico" data-a="settings" title="Edit this signal" aria-label="Edit this signal">${ICON.settings}</button>
<button class="btn ico danger" data-a="rm" title="Delete this signal" aria-label="Delete this signal">${ICON.trash}</button>
</div>
</div>`;
}
async function signalAction(a: string, sg: Signal){
if(a==='settings') signalModal(sg);
if(a==='rm'){
if(!confirm(`Delete the signal ${sg.name}? The items stay in their own feeds.`)) return;
try{
await api(`/api/signals/${sg.id}`,{method:'DELETE'});
toast('Deleted'); await loadFeeds(true); selectFeed(':all');
}catch(e){ toast(why(e),true); }
}
}
/// Making a Signal, or changing one.
function signalModal(sg?: Signal){
const nokey=S.me&&!signalsOn();
openModal(`<h3>${sg?'Edit signal':'New signal'}</h3>
<div class="field"><label for="sgname">Name</label>
<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">${searchBox('<input type="search" id="sgiconq" placeholder="Search icons: robot, car, music…" autocomplete="off">')}
<button type="button" class="dlink" id="sgjev" title="Pick an icon for me">Auto</button>
<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>
<input type="text" id="sgdesc" value="${esc(sg?.description||'')}" placeholder="AI tools for writing software: coding agents, vibe coding">
<span class="hint">Every item in the feeds you subscribe to is read, the last ${plural(S.me?.signal_days??7,'day')} of them
and each new one, and the ones about this subject are pulled out: its name, these words and
your sentence together. The rest stays noise, in its own feed.${S.me?.signals==='server'?'':' Each item is one call on your TypeSafe key.'}${sg?' A change here reads again what was picked out, and listens for it in new items; what was passed over stays passed over.':''}</span></div>
${nokey?`<p class="hint" style="color:var(--warn)">Without a TypeSafe key, nothing is picked out. ${KEY_LINKS}</p>`:''}
<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; };
$<HTMLButtonElement>('#sgjev').onclick=async()=>{
// A spinner while it is picked: what picks it is nobody's concern.
const b=$<HTMLButtonElement>('#sgjev'); b.disabled=true; b.classList.add('busy'); b.setAttribute('aria-busy','true');
try{
const got=await jevIcon($<HTMLInputElement>('#sgname').value.trim(),chipWords($('#sgkw')).filter((w): w is string=>!!w),
$<HTMLInputElement>('#sgdesc').value.trim()||null);
if(got){ icon=got; drawPick(); $('#sgpicker').hidden=true; $('#sgicon').setAttribute('aria-expanded','false'); }
else toast('No icon matches those words');
}catch(e){ toast(why(e),true); }
finally{ b.disabled=false; b.classList.remove('busy'); b.removeAttribute('aria-busy'); }
};
$<HTMLInputElement>('#sgname').focus();
$<HTMLButtonElement>('#sgsave').onclick=async()=>{
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 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});
// A new one made without an icon gets Jev's pick, behind, so making it does not wait (#263).
if(!sg&&!icon) jevIcon(name,keywords,description)
.then(got=>{ if(got) return api(`/api/signals/${r.id}`,{method:'PATCH',body:JSON.stringify({icon:got})}).then(refreshSignals); })
.catch(()=>{});
closeModal(); toast(sg?'Saved. Everything is read again':'Listening. What comes through shows up here');
// Open, so the new one is there to see.
if(!expanded.has(SIGNALS)) toggleGroup(SIGNALS);
await loadFeeds(true); selectFeed(':signal:'+(sg?sg.id:r.id));
}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);
}
/// "12 items · 3 unread", as a feed's row counts its own.
const signalCounts=(sg: Signal)=>`${plural(sg.items,'item')} · ${sg.unread} unread`;
/// The icons likeliest for a Signal: the best few for each word of its name, words and sentence,
/// at most `max`. Jev picks from these, since it chooses among options and cannot name one.
const ICON_STOP=new Set(['the','and','for','not','with','about','their','own','from','into','that','this','your']);
function iconCandidates(all: [string,string,string][], name: string, keywords: string[], description: string|null, max=40){
const words=[name,...keywords,description||''].join(' ').toLowerCase().split(/[^a-z0-9]+/).filter(w=>w.length>1&&!ICON_STOP.has(w));
const out=new Map<string,string>();
// A brand's icon stands for that brand, so it is offered only when its name is one of the
// Signal's words: by its search terms, "disney" brought the Mandalorian, a Disney+ show (#264).
const brandFor=(id: string, w: string)=>!id.startsWith('brands/')||id.slice(7).split('-').includes(w);
for(const w of [...new Set(words)])
for(const [id,label,terms] of iconMatches(all.filter(([id])=>brandFor(id,w)),w,6))
if(out.size<max) out.set(id,`${label}: ${terms.split(' ').slice(0,8).join(', ')}`);
return [...out];
}
/// Jev's pick of an icon, on the server's TypeSafe key, or null with nothing to choose from.
async function jevIcon(name: string, keywords: string[], description: string|null): Promise<string|null>{
const candidates=iconCandidates(await iconList(),name,keywords,description);
if(!candidates.length) return null;
const r=await api('/api/signals/icon',{method:'POST',body:JSON.stringify({name,keywords,description,candidates})});
return r.icon;
}
/// Where to get a TypeSafe key and where it goes, wherever a Signal says it has none: one click to
/// each, rather than "in Settings" left to be found.
const KEY_LINKS=`<a href="https://typesafe.ai" target="_blank" rel="noopener">Get one at typesafe.ai</a>, then
<button type="button" class="dlink" data-keylink>add it in Settings</button>.`;
/// Settings, open at the key's field.
async function keySetting(){
closeModal();
await prefsModal();
$<HTMLInputElement>('#ssigon').checked=true; $('#ssigkey').hidden=false;
const k=$<HTMLInputElement>('#stskey'); k.scrollIntoView({block:'center'}); k.focus();
}
document.addEventListener('click',(e: MouseEvent)=>{
if(!(e.target as Element).closest?.('[data-keylink]')) return;
e.preventDefault(); keySetting().catch(err=>toast(why(err),true));
});