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>
295 lines
17 KiB
TypeScript
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));
|
|
});
|