Jev picks a Signal's icon, on the server's key, and each Signal counts its items (#263)

Jev chooses among options and cannot name one, so the page searches Font Awesome's index for each
word of the Signal's name, words and sentence, the best six each, and offers the likeliest 40.
POST /api/signals/icon asks a choice question on the server's TYPESAFE_KEY and answers with one
of those offered, never another. "Let Jev pick" in the icon picker, and a Signal made without an
icon gets one a moment after. Tried against TypeSafe: for AI Development, laptop-code 0.45 just
ahead of robot 0.40.

Each Signal's row says "12 items · 3 unread", as a feed's row counts its own, in place of its
words; /api/signals gives `items` beside `unread`, by the same rules as its list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-16 19:09:26 +00:00
parent d0ed953e84
commit 2ed8781604
9 changed files with 231 additions and 17 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). Each can have an icon of its own, searched for among all of Font Awesome Free, in place of its initials (#261).
- 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), or Jev's pick of one, which a Signal made without an icon gets by itself; each Signal's row counts its items and unread as a feed's does (#263).
- 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

@@ -60,7 +60,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">${signalTile(sg)}</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>${signalCounts(sg)}</small></div>`+badgeHTML(sg.unread),
()=>selectFeed(':signal:'+sg.id));
el.onpointerenter=el.ontouchstart=()=>prefetch(':signal:'+sg.id);
list.appendChild(el);
@@ -97,7 +97,7 @@ function renderSignals(){
const el=document.createElement('div');
el.className='childrow';
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>`;
`<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);
}
@@ -141,6 +141,7 @@ function signalModal(sg?: Signal){
<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="sgjev">Let Jev pick</button>
<button type="button" class="dlink" id="sgnoicon">Initials</button></div>
<div class="icongrid" id="sgicons" role="listbox" aria-label="Icons"></div>
</div></div>
@@ -179,6 +180,16 @@ function signalModal(sg?: Signal){
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()=>{
const b=$<HTMLButtonElement>('#sgjev'); b.disabled=true; b.textContent='Asking Jev…';
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.textContent='Let Jev pick'; }
};
$<HTMLInputElement>('#sgname').focus();
$<HTMLButtonElement>('#sgsave').onclick=async()=>{
const name=$<HTMLInputElement>('#sgname').value.trim(), keywords=chipWords($('#sgkw')).filter((w): w is string=>!!w);
@@ -191,6 +202,10 @@ function signalModal(sg?: Signal){
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. Jev reads everything again':'Listening. What Jev picks out shows up here');
// Open, so the new one is there to see.
if(!expanded.has(SIGNALS)) toggleGroup(SIGNALS);
@@ -233,3 +248,24 @@ function iconMatches(all: [string,string,string][], q: string, max: number){
};
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>();
for(const w of [...new Set(words)])
for(const [id,label,terms] of iconMatches(all,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;
}

View File

@@ -95,6 +95,8 @@ interface Signal {
description: string | null;
/// A Font Awesome Free icon, `solid/robot`, in place of its initials (#261).
icon: string | null;
/// The items it holds, read or not, and of those the unread.
items: number;
unread: number;
}