create-pmos 0.86.0 → 0.88.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -29,8 +29,8 @@ import { currentProduct } from './board-read.js';
29
29
  import { pmGet } from './http.js';
30
30
  import { postEvents } from './breakdown-api.js';
31
31
  import { watchSig } from './sig-watch.js';
32
- import { ensureComponentsCss, layer, tabs, field, button, sheet, toast, empty, menu } from './primitives.js';
33
- import { ensureSprite } from './icons.js';
32
+ import { ensureComponentsCss, layer, tabs, field, button, sheet, toast, empty, menu, badge } from './primitives.js';
33
+ import { ensureSprite, icon } from './icons.js';
34
34
  import { COPY as BD } from './breakdown-copy.js';
35
35
  import { COPY } from './board-copy.js';
36
36
  import { hold, cancel, isHeld, flush, overlay, anyHeld } from './undo-hold.js';
@@ -119,6 +119,9 @@ function focusKeyIn(P) {
119
119
  function headOf(P) {
120
120
  const out = P.out;
121
121
  const inits = (out && out.initiatives) || [];
122
+ const n = out ? (out.counts.todo + out.counts.doing + out.counts.review + out.counts.done) : 0;
123
+ const productName = currentProduct();
124
+
122
125
  const f = tabs({
123
126
  variant: 'segmented', ariaLabel: COPY.filter, value: P.filter === 'init' ? null : P.filter,
124
127
  items: [{ id: 'active', label: COPY.filters.active }, { id: 'mine', label: COPY.filters.mine }],
@@ -134,10 +137,13 @@ function headOf(P) {
134
137
  label: COPY.addItem, variant: 'primary', icon: 'plus', onClick: () => P.actions.openAdd(),
135
138
  attrs: { 'data-bo-add': '', 'data-fk': 'add' },
136
139
  });
137
- const n = out ? (out.counts.todo + out.counts.doing + out.counts.review + out.counts.done) : 0;
140
+
141
+ const title = el('h2', { class: 'td-count', 'data-bo-count': String(n) }, COPY.title(n, productName));
142
+
138
143
  return [
139
- el('div', { class: 'bo-head__filters' }, f, el('div', { class: 'bo-head__pick' }, pick)),
140
- el('div', { class: 'bo-head__end' }, el('span', { class: 'bo-head__n', 'data-bo-count': String(n) }, COPY.summary(n)), add),
144
+ el('div', { class: 'td-head' },
145
+ title,
146
+ el('div', { class: 'bo-head__end' }, f, el('div', { class: 'bo-head__pick' }, pick), add)),
141
147
  ];
142
148
  }
143
149
 
@@ -148,14 +154,14 @@ function render(P) {
148
154
  P.drags.destroy();
149
155
  const L = view(P);
150
156
  P.wide = (P.root.getBoundingClientRect().width || P.lastWidth || 0) >= WIDE_BOARD;
151
- // as the Breakdown's Board: a wide Board with a fine pointer draws cards without the ⋯ button (the 33-control budget)
152
- P.cardMenus = !P.wide || P.coarse;
157
+ // Always render menu buttons; they will be hidden with CSS opacity until hover
158
+ P.cardMenus = true;
153
159
  setChildren(P.headEl, headOf(P));
154
160
  let kids;
155
161
  if (!P.loaded) kids = [el('div', { class: 'bd-note' }, COPY.reading)];
156
162
  else if (!L) kids = [el('div', { class: 'bd-note bd-note--error', role: 'alert' }, COPY.readFailed(P.error || ''))];
157
163
  else {
158
- kids = renderBoard({ P, L, A: P.actions, wide: P.wide, cardMenus: P.cardMenus });
164
+ kids = renderBoard({ P, L, A: P.actions, wide: P.wide, cardMenus: true, agenda: true });
159
165
  const rel = (P.out && P.out.releases) || [];
160
166
  if (rel.length) {
161
167
  grantScopes(() => bump(P));
@@ -381,7 +387,7 @@ function createPane(host, ws) {
381
387
  const P = {
382
388
  host, ws, root, headEl, bodyEl, slug: 'board', filter: 'active', init: null, out: null, raw: null, older: 0, error: null, loaded: false,
383
389
  loading: false, again: false, boardCol: 'todo', ver: 0, renderedVer: -1, drags: dragSet(), dragging: false, rendering: false,
384
- disposed: false, sheet: null, wide: false, cardMenus: true, lastWidth: 0,
390
+ disposed: false, sheet: null, wide: false, cardMenus: true, lastWidth: 0, expandedCols: new Set(),
385
391
  coarse: typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches,
386
392
  };
387
393
  P.actions = makeActions(P);
@@ -178,7 +178,7 @@ export function mountDestination(host, ctx) {
178
178
  const ws = repo ? wsOf(repo) : null;
179
179
  if (ws !== inst.ws) {
180
180
  stopPoll(inst);
181
- Object.assign(inst, { ws, loaded: false, error: null, view: null, last: '', open: null, adding: null, files: [], lib: null, libError: null, graph: null });
181
+ Object.assign(inst, { ws, repo, loaded: false, error: null, view: null, last: '', open: null, adding: null, files: [], lib: null, libError: null, graph: null });
182
182
  inst.openCats.clear(); inst.full.clear();
183
183
  if (ws) { ensureSprite(); refresh(inst); }
184
184
  draw(inst);
@@ -362,7 +362,7 @@ function fileLine(inst, f, q = '') {
362
362
  title: marked(f.title, q),
363
363
  meta: [ini ? el('span', { class: 'rp-docs-ini', title: ini.title }, marked(ini.short, q)) : el('span', { class: 'rp-docs-path' }, marked(f.path, q)), day(f.date)],
364
364
  lead: [el('span', { class: 'rp-docs-dot rp-docs-dot--none', 'aria-hidden': 'true' })],
365
- trail: adding ? [] : [button({ label: COPY.addCopy, variant: 'outline', size: 'sm', attrs: { class: 'rp-docs-addcopy', 'data-docs-act': 'add-copy', 'data-docs-key': f.path }, onClick: start })],
365
+ trail: adding ? [] : [button({ label: COPY.addCopy, variant: 'outline', size: 'sm', icon: 'plus', attrs: { class: 'rp-docs-addcopy', 'data-docs-act': 'add-copy', 'data-docs-key': f.path }, onClick: start })],
366
366
  onOpen: inst.lib ? toggle : null,
367
367
  openLabel: `${f.title}: show the files it links to`,
368
368
  openAttrs: { 'aria-expanded': open ? 'true' : 'false', title: f.path, 'data-docs-file-toggle': f.path },
@@ -388,17 +388,48 @@ function fileLine(inst, f, q = '') {
388
388
  /* ── the page ────────────────────────────────────────────────────────────────────────────────────────────────────── */
389
389
 
390
390
  function header(inst) {
391
- if (inst.head) return inst.head;
392
- const find = field({ label: COPY.find, hideLabel: true, placeholder: COPY.find, attrs: { 'data-docs-find': '' } });
393
- const q = find.control;
394
- inst.findControl = q;
395
- q.addEventListener('input', () => { inst.q = q.value; inst.open = null; inst.adding = null; inst.graph = null; inst.full.clear(); searchFiles(inst); draw(inst); });
396
- // The field restores its last committed text on Escape; here Escape clears the search instead.
397
- q.addEventListener('keydown', (e) => { if (e.key === 'Escape') { q.resetCommitted(''); inst.q = ''; inst.graph = null; searchFiles(inst); draw(inst); } });
398
- inst.head = el('div', { class: 'rp-docs-top' }, el('h2', { class: 'rp-head__title' }, COPY.title), el('div', { class: 'rp-docs-find' }, find));
391
+ if (!inst.head) {
392
+ const find = field({ label: COPY.find, hideLabel: true, placeholder: COPY.find, attrs: { 'data-docs-find': '' } });
393
+ const q = find.control;
394
+ inst.findControl = q;
395
+ q.addEventListener('input', () => { inst.q = q.value; inst.open = null; inst.adding = null; inst.graph = null; inst.full.clear(); searchFiles(inst); draw(inst); });
396
+ // The field restores its last committed text on Escape; here Escape clears the search instead.
397
+ q.addEventListener('keydown', (e) => { if (e.key === 'Escape') { q.resetCommitted(''); inst.q = ''; inst.graph = null; searchFiles(inst); draw(inst); } });
398
+ inst.titleEl = el('h2', { class: 'rp-head__title' }, COPY.title);
399
+ inst.tabsSlot = el('div', { class: 'rp-docs-views' });
400
+ inst.whenEl = el('span', { class: 'rp-docs-when', 'data-docs-summary': '' });
401
+ inst.compareBtn = button({ label: COPY.compareAll, variant: 'primary', size: 'sm', icon: 'refresh-cw', attrs: { 'data-docs-act': 'check' }, onClick: () => compareAll(inst) });
402
+ inst.head = el('div', { class: 'rp-docs-head' },
403
+ el('div', { class: 'rp-docs-top' }, inst.titleEl, inst.tabsSlot),
404
+ el('div', { class: 'rp-docs-bar' }, inst.whenEl, el('div', { class: 'rp-docs-find' }, find), inst.compareBtn));
405
+ }
399
406
  return inst.head;
400
407
  }
401
408
 
409
+ /** The parts of the header that follow the data: the title, the view tabs with their counts, when the copies were compared. */
410
+ function updateHead(inst) {
411
+ const v = inst.view;
412
+ const lib = inst.lib;
413
+ const all = v ? v.docs.reduce((n, d) => n + d.links.length, 0) : 0;
414
+ const needs = v ? v.docs.reduce((n, d) => n + d.links.filter((l) => needsYou(inst, d, l)).length, 0) : 0;
415
+ const running = !!v && (!!(v.open || v.session) || inst.busy);
416
+ const repoName = inst.repo ? String(inst.repo).split('/').pop() : '';
417
+ inst.titleEl.textContent = needs > 0 ? COPY.needYou(needs) + (repoName ? ` in ${repoName}` : '') : COPY.title;
418
+ inst.tabsSlot.replaceChildren(tabs({ variant: 'segmented', items: [{ id: 'needs', label: COPY.needs, count: needs }, { id: 'copies', label: COPY.copies, count: all },
419
+ { id: 'files', label: COPY.files, count: lib ? lib.files.length.toLocaleString('en-US') : null }],
420
+ value: inst.filter, ariaLabel: 'Filter', attrs: { 'data-docs-filter': '' }, onChange: (val) => { inst.filter = val; inst.graph = null; draw(inst); } }));
421
+ inst.whenEl.replaceChildren(icon('refresh-cw'), el('span', null, !v ? '' : running ? COPY.comparing : (v.last ? COPY.compared(v.last.finished_at) : COPY.never)));
422
+ inst.compareBtn.disabled = running || !all;
423
+ inst.compareBtn.setAttribute('aria-label', COPY.compareAll);
424
+ inst.compareBtn.title = COPY.compareAll;
425
+ }
426
+
427
+ /** The body inside one card with a small label, like an Agenda panel. */
428
+ function panel(inst, nodes) {
429
+ const label = inst.q.trim() ? 'Results' : inst.filter === 'files' ? COPY.files : inst.filter === 'copies' ? COPY.copies : COPY.needs;
430
+ return el('section', { class: 'rp-docs-panel' }, el('div', { class: 'rp-docs-panel__eyebrow' }, label), ...nodes);
431
+ }
432
+
402
433
  const needsYou = (inst, d, l) => l.signal !== 'same' || !!l.syncing || inst.pending.has(keyOf(d, l));
403
434
 
404
435
  /**
@@ -467,14 +498,7 @@ function body(inst) {
467
498
  const all = v.docs.reduce((n, d) => n + d.links.length, 0);
468
499
  const needs = v.docs.reduce((n, d) => n + d.links.filter((l) => needsYou(inst, d, l)).length, 0);
469
500
  const running = !!(v.open || v.session) || inst.busy;
470
- const out = [el('div', { class: 'rp-docs-views' },
471
- tabs({ variant: 'line', items: [{ id: 'needs', label: COPY.needs, count: needs }, { id: 'copies', label: COPY.copies, count: all },
472
- { id: 'files', label: COPY.files, count: lib ? lib.files.length.toLocaleString('en-US') : null }],
473
- value: inst.filter, ariaLabel: 'Filter', attrs: { 'data-docs-filter': '' }, onChange: (val) => { inst.filter = val; inst.graph = null; draw(inst); } }),
474
- el('span', { class: 'rp-docs-when', 'data-docs-summary': '' },
475
- running ? COPY.comparing : (v.last ? COPY.compared(v.last.finished_at) : COPY.never),
476
- button({ label: '↻', variant: 'ghost', size: 'sm', disabled: running || !all,
477
- attrs: { 'data-docs-act': 'check', 'aria-label': COPY.compareAll, title: COPY.compareAll }, onClick: () => compareAll(inst) })))];
501
+ const out = [];
478
502
  if (inst.filter === 'files') {
479
503
  if (!lib) { out.push(el('p', { class: 'rp-section__empty' + (inst.libError ? ' rp-section__empty--bad' : '') }, inst.libError || COPY.readingFiles)); return out; }
480
504
  out.push(el('div', { class: 'rp-docs-folds' }, byCategory(allEntries(inst)).map(([k, es]) => section(inst, k, es, { foldable: true }))));
@@ -498,8 +522,9 @@ function draw(inst) {
498
522
  if (fp === inst.last && root.childNodes.length && root.contains(inst.body)) return;
499
523
  inst.last = fp;
500
524
  const head = header(inst);
525
+ updateHead(inst);
501
526
  if (head.parentNode !== root || inst.body.parentNode !== root) root.replaceChildren(head, inst.body);
502
- if (!inst.loaded) { inst.body.replaceChildren(el('p', { class: 'rp-section__empty' }, COPY.loading)); return; }
503
- if (inst.error) { inst.body.replaceChildren(el('p', { class: 'rp-section__empty rp-section__empty--bad', role: 'alert' }, inst.error)); return; }
504
- inst.body.replaceChildren(...body(inst));
527
+ if (!inst.loaded) { inst.body.replaceChildren(panel(inst, [el('p', { class: 'rp-section__empty' }, COPY.loading)])); return; }
528
+ if (inst.error) { inst.body.replaceChildren(panel(inst, [el('p', { class: 'rp-section__empty rp-section__empty--bad', role: 'alert' }, inst.error)])); return; }
529
+ inst.body.replaceChildren(panel(inst, body(inst)));
505
530
  }
@@ -2,24 +2,22 @@
2
2
  * tone, notes and shared work (initiative people-directory, PRD R5 to R11, proposed D90).
3
3
  *
4
4
  * A DESTINATION MODULE (registry.js): mountDestination(host, ctx) on every render with the same host, disposeDestination(host).
5
- * It keeps ONE persistent root per host; the header (title and search) is built once, so typing never loses focus, and
6
- * only the body is rebuilt when its own data changed.
5
+ * It keeps ONE persistent root per host; the header is built once (h2 title on left, group switch on right), so typing never
6
+ * loses focus. The search field lives in the left card and is built once. Only the body is rebuilt when its own data changed.
7
7
  *
8
- * What it shows, for the repository the tab names (or the open one):
9
- * - Waiting for you: possible duplicates (Merge · Keep separate) and how many tones wait for a confirm. A new person
10
- * is added, not listed here (the PM, 2026-10-06): Remove from People on their page keeps them out;
11
- * - the list: search over names, handles and initiatives, All · Team · External, sort by latest, most or name;
12
- * - the person's page: who, interactions with where each came from, tone (Confirm · Set another · Change), shared
13
- * work (initiatives, items they own with Blocked shown, coming meetings), notes (Add · Remove), and identity and
14
- * sources (handles, how many interactions each source gave, the file the person lives in).
15
- * Every press is a page press to POST /api/people/decide. Nothing here reads a source: GET /api/people records the names
16
- * the workspace's own records hold, and agent sessions record what they saw through `pmos people record`. A link from another place opens a person through the tab's slug (people-link.js).
17
- * In fixture mode it reads nothing.
8
+ * Layout (desktop): header row, "Waiting for you" merge candidates as bordered rows, then two cards side-by-side (minmax(300px,2fr) 3fr,
9
+ * gap 16px, fixed height calc(100vh - 220px) min-height 420px, internal scrolling). Left card: "PEOPLE" label + count, search field,
10
+ * full-width sort tabs, list of people. Right card: person detail with avatar, name, meta, Confirm / ⋯ menu, 3 tiles (Interactions,
11
+ * Tone, Shared work), scrollable sections (Interactions, Shared work, Notes, Identity and sources).
12
+ *
13
+ * What it shows: waiting for you (possible merge candidates), the people list (search, filter, sort), and person's page
14
+ * (who, interactions, tone, shared work, notes, identity). Every press is a page press to POST /api/people/decide.
15
+ * A link from another place opens a person through the tab's slug (people-link.js). In fixture mode it reads nothing.
18
16
  */
19
17
 
20
18
  import { el } from './dom.js';
21
- import { button, row, field, toast, empty, tabs, badge, menu, ensureComponentsCss } from './primitives.js';
22
- import { ensureSprite } from './icons.js';
19
+ import { button, iconButton, row, field, toast, empty, tabs, badge, menu, ensureComponentsCss } from './primitives.js';
20
+ import { ensureSprite, icon } from './icons.js';
23
21
  import { pmGet, pmPost } from './http.js';
24
22
  import { wsOf } from './data.js';
25
23
  import { registerCommand, openDestination } from './registry.js';
@@ -174,22 +172,18 @@ function review(inst) {
174
172
  const rows = [];
175
173
  for (const m of r.merges) {
176
174
  const key = m.handle.kind + ':' + m.handle.value;
177
- rows.push(el('div', { class: 'rp-people-row', role: 'listitem', 'data-people-merge': key },
178
- row({
179
- title: COPY.same(m.handle.value, m.personName),
180
- meta: [m.why],
181
- trail: [
182
- button({ label: COPY.merge, variant: 'outline', size: 'sm', attrs: { 'data-people-act': 'merge', 'data-people-key': key },
183
- onClick: () => decide(inst, { act: 'merge', person: m.person, handle: m.handle }, COPY.merged) }),
184
- button({ label: COPY.keep, variant: 'ghost', size: 'sm', attrs: { 'data-people-act': 'keep-separate', 'data-people-key': key },
185
- onClick: () => decide(inst, { act: 'keep-separate', a: m.a, b: m.b }, COPY.kept) }),
186
- ],
187
- })));
175
+ rows.push(el('div', { class: 'rp-people-merge', role: 'listitem', 'data-people-merge': key },
176
+ el('div', { class: 'rp-people-merge__dot' }),
177
+ el('div', { class: 'rp-people-merge__text' },
178
+ el('div', { class: 'rp-people-merge__title' }, COPY.same(m.handle.value, m.personName)),
179
+ el('div', { class: 'rp-people-merge__why' }, m.why)),
180
+ button({ label: COPY.merge, icon: 'git-merge', variant: 'outline', size: 'sm', attrs: { 'data-people-act': 'merge', 'data-people-key': key },
181
+ onClick: () => decide(inst, { act: 'merge', person: m.person, handle: m.handle }, COPY.merged) }),
182
+ button({ label: COPY.keep, variant: 'ghost', size: 'sm', attrs: { 'data-people-act': 'keep-separate', 'data-people-key': key },
183
+ onClick: () => decide(inst, { act: 'keep-separate', a: m.a, b: m.b }, COPY.kept) })));
188
184
  }
189
185
  if (r.tones.length) rows.push(el('p', { class: 'rp-people-hint', 'data-people-tones': String(r.tones.length) }, COPY.tonesWait(r.tones.length)));
190
- return el('section', { class: 'rp-people-review', 'data-people-review': '' },
191
- el('h3', { class: 'rp-people-sec__title' }, `${COPY.waiting} · ${r.count}`),
192
- el('div', { role: 'list' }, rows));
186
+ return el('div', { class: 'rp-people-review', 'data-people-review': '' }, ...rows);
193
187
  }
194
188
 
195
189
  /* ── the list ────────────────────────────────────────────────────────────────────────────────────────────────────── */
@@ -212,11 +206,15 @@ function shownPeople(inst) {
212
206
  function personLine(inst, p) {
213
207
  const open = inst.open === p.id;
214
208
  const t = toneOf(p);
215
- return el('div', { class: 'rp-people-row' + (open ? ' rp-people-row--open' : ''), role: 'listitem', 'data-people-person': p.id },
209
+ const toneClass = t === 'tense' || t === 'cautious' || t === 'warm' ? t : '';
210
+ const initials = p.name.split(/\s+/).map((w) => w[0]).join('').toUpperCase();
211
+ return el('div', { class: 'rp-people-item', role: 'listitem', 'data-people-person': p.id },
216
212
  row({
217
213
  title: p.name,
218
- meta: [who(p), p.last ? `${p.count} · ${day(p.last)}` : String(p.count)],
219
- trail: t ? [badge({ text: p.tone.confirmed ? t : t + '?', tone: t === 'tense' ? 'destructive' : t === 'cautious' ? 'warning' : 'outline', attrs: { 'data-people-tone': t } })] : [],
214
+ meta: [[who(p), p.external ? COPY.external : null].filter(Boolean).join(' · ')],
215
+ lead: [el('span', { class: 'rp-people-row__avatar' }, initials)],
216
+ trail: t ? [el('span', { class: 'rp-people-row__tone' + (p.tone.confirmed ? '' : ' rp-people-row__tone--proposed'), 'data-people-tone': t },
217
+ el('i', { class: 'rp-people-row__tone-dot ' + toneClass }), p.tone.confirmed ? t : t + '?')] : [],
220
218
  onOpen: () => { inst.open = p.id; draw(inst); },
221
219
  openLabel: `${p.name}, ${who(p)}`,
222
220
  openAttrs: { 'aria-current': open ? 'true' : null, 'data-people-open': p.id },
@@ -226,20 +224,19 @@ function personLine(inst, p) {
226
224
  function listPane(inst) {
227
225
  const v = inst.view;
228
226
  const all = v.people.filter((p) => matches(p, inst.q.trim().toLowerCase()));
229
- const controls = el('div', { class: 'rp-people-views' },
230
- tabs({ variant: 'line', value: inst.group, ariaLabel: 'Group', attrs: { 'data-people-group': '' },
231
- items: [{ id: 'all', label: COPY.all, count: all.length }, { id: 'team', label: COPY.team, count: all.filter((p) => !p.external).length },
232
- { id: 'external', label: COPY.external, count: all.filter((p) => p.external).length }],
233
- onChange: (val) => { inst.group = val; draw(inst); } }),
234
- tabs({ variant: 'segmented', value: inst.sort, ariaLabel: 'Sort', attrs: { 'data-people-sort': '' },
235
- items: [{ id: 'latest', label: COPY.latest }, { id: 'most', label: COPY.most }, { id: 'name', label: COPY.name }],
236
- onChange: (val) => { inst.sort = val; draw(inst); } }));
237
227
  const list = shownPeople(inst);
238
228
  let rows;
239
229
  if (!v.people.length) rows = [el('p', { class: 'rp-section__empty' }, COPY.none)];
240
230
  else if (!list.length) rows = [el('p', { class: 'rp-section__empty' }, COPY.noMatch(inst.q.trim()))];
241
231
  else rows = [el('div', { role: 'list', 'data-people-list': '' }, list.map((p) => personLine(inst, p)))];
242
- return el('div', { class: 'rp-people-list' }, controls, ...rows);
232
+ return el('div', { class: 'rp-people-card' },
233
+ el('div', { class: 'rp-people-card__header' },
234
+ el('p', { class: 'rp-people-card__label' }, COPY.title, el('span', { class: 'rp-people-card__count' }, all.length))),
235
+ inst.head.find,
236
+ tabs({ variant: 'segmented', full: true, value: inst.sort, ariaLabel: 'Sort', attrs: { 'data-people-sort': '' },
237
+ items: [{ id: 'latest', label: COPY.latest }, { id: 'most', label: COPY.most }, { id: 'name', label: COPY.name }],
238
+ onChange: (val) => { inst.sort = val; draw(inst); } }),
239
+ el('div', { class: 'rp-people-card__list' }, ...rows));
243
240
  }
244
241
 
245
242
  /* ── the person's page ───────────────────────────────────────────────────────────────────────────────────────────── */
@@ -250,11 +247,16 @@ function section(title, kids, attrs) {
250
247
 
251
248
  function interactionsSec(p) {
252
249
  if (!p.interactions.length) return section(COPY.interactions, [el('p', { class: 'rp-people-hint' }, COPY.noInteractions)], { 'data-people-sec': 'interactions' });
253
- return section(`${COPY.interactions} · ${p.count}`, [el('ol', { class: 'rp-people-ints' }, p.interactions.slice(0, 50).map((i) =>
254
- el('li', { class: 'rp-people-int', 'data-people-int': i.date },
255
- el('span', { class: 'rp-people-int__when' }, `${day(i.date)} · ${i.kind}`),
256
- el('span', { class: 'rp-people-int__text' }, i.text),
257
- el('span', { class: 'rp-people-int__src' }, [SOURCE[i.source] || i.source, i.pointer, i.tone].filter(Boolean).join(' · ')))))],
250
+ const icons = { mail: 'mail', live: 'message-square', calendar: 'calendar', jira: 'ticket', gather: 'file-text', plan: 'file-text', notes: 'file-text' };
251
+ return section(`${COPY.interactions} · ${p.count}`, [el('div', { class: 'rp-people-ints' }, p.interactions.slice(0, 50).map((i) => {
252
+ const iconName = icons[i.source] || 'file-text';
253
+ return el('div', { class: 'rp-people-int', 'data-people-int': i.date },
254
+ el('span', { class: 'rp-people-int__icon' }, icon(iconName)),
255
+ el('div', {},
256
+ el('div', { class: 'rp-people-int__text' }, i.text),
257
+ el('small', {}, `${day(i.date)} · ${i.kind}`),
258
+ el('small', { class: 'rp-people-int__src' }, [SOURCE[i.source] || i.source, i.pointer, i.tone].filter(Boolean).join(' · '))));
259
+ }))],
258
260
  { 'data-people-sec': 'interactions' });
259
261
  }
260
262
 
@@ -267,8 +269,6 @@ function toneSec(inst, p) {
267
269
  else if (t.proposed) kids.push(el('p', { class: 'rp-people-tone', 'data-people-proposed': t.proposed }, COPY.proposed(t.proposed)));
268
270
  if (mix) kids.push(el('p', { class: 'rp-people-hint', 'data-people-mix': '' }, mix));
269
271
  const acts = [];
270
- if (!t.confirmed && t.proposed) acts.push(button({ label: COPY.confirm, variant: 'primary', size: 'sm', attrs: { 'data-people-act': 'tone-confirm' },
271
- onClick: () => decide(inst, { act: 'tone', person: p.id, tone: t.proposed }, COPY.toneSaved) }));
272
272
  const another = button({ label: COPY.setAnother, variant: 'outline', size: 'sm', iconEnd: 'chevron-down', attrs: { 'data-people-act': 'tone-set', 'aria-haspopup': 'menu' },
273
273
  onClick: () => menu({ anchor: another, ariaLabel: COPY.setAnother,
274
274
  items: TONES.map((x) => ({ label: x, onSelect: () => decide(inst, { act: 'tone', person: p.id, tone: x }, COPY.toneSaved) })) }) });
@@ -281,25 +281,24 @@ function toneSec(inst, p) {
281
281
 
282
282
  function sharedSec(p) {
283
283
  const s = p.shared;
284
- const lines = [];
285
- for (const g of s.initiatives) lines.push(el('li', { class: 'rp-people-shared', 'data-people-shared': 'initiative' }, `${g.id} · ${g.count} interaction${g.count === 1 ? '' : 's'}`));
284
+ const chips = [];
285
+ for (const g of s.initiatives) chips.push(el('span', { class: 'rp-people-chip', 'data-people-shared': 'initiative' }, icon('target'), `${g.id} · ${g.count} interaction${g.count === 1 ? '' : 's'}`));
286
286
  for (const it of s.items) {
287
- lines.push(el('li', { class: 'rp-people-shared', 'data-people-shared': 'item', 'data-people-item': it.id },
288
- `${COPY.owns} ${it.title || it.id}${it.initiative ? ' · ' + it.initiative : ''} · ${it.state}`,
289
- it.blocked ? badge({ text: COPY.blocked, tone: 'warning', attrs: { title: it.reason || COPY.blocked } }) : null));
287
+ const chip = el('span', { class: 'rp-people-chip', 'data-people-shared': 'item', 'data-people-item': it.id }, icon(it.blocked ? 'ban' : 'target'), `${COPY.owns} ${it.title || it.id}${it.blocked ? ' · ' + COPY.blocked : ''}`);
288
+ if (it.blocked) chip.classList.add('warn');
289
+ chips.push(chip);
290
290
  }
291
291
  for (const m of s.meetings) {
292
292
  const when = new Date(m.start);
293
- lines.push(el('li', { class: 'rp-people-shared', 'data-people-shared': 'meeting' },
294
- `${COPY.meeting} · ${when.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })} ${when.toTimeString().slice(0, 5)} · ${m.title}`));
293
+ chips.push(el('span', { class: 'rp-people-chip', 'data-people-shared': 'meeting' }, icon('calendar'), `${when.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })} · ${m.title}`));
295
294
  }
296
- return section(COPY.shared, lines.length ? [el('ul', { class: 'rp-people-list-plain' }, lines)] : [el('p', { class: 'rp-people-hint' }, COPY.noShared)], { 'data-people-sec': 'shared' });
295
+ return section(COPY.shared, chips.length ? [el('div', { class: 'rp-people-chips' }, chips)] : [el('p', { class: 'rp-people-hint' }, COPY.noShared)], { 'data-people-sec': 'shared' });
297
296
  }
298
297
 
299
298
  function notesSec(inst, p) {
300
- const list = p.notes.map((n) => el('li', { class: 'rp-people-note', 'data-people-note': n.id },
299
+ const list = p.notes.map((n) => el('div', { class: 'rp-people-note', 'data-people-note': n.id },
301
300
  el('span', { class: 'rp-people-note__text' }, n.text),
302
- button({ label: COPY.remove, variant: 'ghost', size: 'sm', attrs: { 'data-people-act': 'note-remove', 'data-people-key': n.id },
301
+ iconButton({ icon: 'x', ariaLabel: COPY.remove, size: 'sm', attrs: { 'data-people-act': 'note-remove', 'data-people-key': n.id },
303
302
  onClick: () => decide(inst, { act: 'note-remove', person: p.id, id: n.id }, COPY.noteRemoved) })));
304
303
  const f = field({ label: COPY.addNote, kind: 'textarea', hideLabel: true, placeholder: COPY.notePlaceholder, attrs: { 'data-people-field': 'note' } });
305
304
  const save = async () => {
@@ -310,8 +309,8 @@ function notesSec(inst, p) {
310
309
  if (r) f.control.value = '';
311
310
  };
312
311
  return section(COPY.notes, [
313
- list.length ? el('ul', { class: 'rp-people-list-plain' }, list) : el('p', { class: 'rp-people-hint' }, COPY.noNotes),
314
- el('div', { class: 'rp-people-noteadd' }, f, el('div', { class: 'rp-people-acts' }, button({ label: COPY.addNote, variant: 'outline', size: 'sm', attrs: { 'data-people-act': 'note-add' }, onClick: save }))),
312
+ list.length ? el('div', { class: 'rp-people-list-plain' }, list) : el('p', { class: 'rp-people-hint' }, COPY.noNotes),
313
+ el('div', { class: 'rp-people-noteadd' }, f, iconButton({ icon: 'plus', ariaLabel: COPY.addNote, size: 'sm', attrs: { 'data-people-act': 'note-add' }, onClick: save })),
315
314
  ], { 'data-people-sec': 'notes' });
316
315
  }
317
316
 
@@ -319,25 +318,52 @@ function identitySec(inst, p) {
319
318
  const by = {};
320
319
  for (const i of p.interactions) by[i.source] = (by[i.source] || 0) + 1;
321
320
  const file = inst.view.file ? String(inst.view.file).split('/').slice(-3).join('/') : 'people.jsonl';
322
- return section(COPY.identity, [
323
- el('ul', { class: 'rp-people-list-plain' },
324
- p.handles.map((h) => el('li', { class: 'rp-people-handle', 'data-people-handle': h.kind + ':' + h.value }, handleText(h))),
325
- Object.entries(by).map(([s, n]) => el('li', { class: 'rp-people-hint' }, `${SOURCE[s] || s}: ${n} interaction${n === 1 ? '' : 's'}`))),
326
- (p.sources || []).length ? el('p', { class: 'rp-people-hint', 'data-people-sources': p.sources.join(',') }, COPY.foundIn(p.sources.map((s) => SOURCE[s] || s).join(', '))) : null,
327
- el('p', { class: 'rp-people-hint rp-people-file' }, COPY.lives(file)),
328
- ], { 'data-people-sec': 'identity' });
321
+ const details = el('details', { class: 'rp-people-sec', 'data-people-sec': 'identity' },
322
+ el('summary', {},
323
+ icon('chevron-down'),
324
+ COPY.identity),
325
+ el('div', { class: 'rp-people-list-plain' },
326
+ p.handles.map((h) => el('div', { class: 'rp-people-handle', 'data-people-handle': h.kind + ':' + h.value }, handleText(h))),
327
+ Object.entries(by).map(([s, n]) => el('div', { class: 'rp-people-hint' }, `${SOURCE[s] || s}: ${n} interaction${n === 1 ? '' : 's'}`)),
328
+ (p.sources || []).length ? el('div', { class: 'rp-people-hint', 'data-people-sources': p.sources.join(',') }, COPY.foundIn(p.sources.map((s) => SOURCE[s] || s).join(', '))) : null,
329
+ el('div', { class: 'rp-people-hint rp-people-file' }, COPY.lives(file))));
330
+ return details;
329
331
  }
330
332
 
331
333
  function personPane(inst) {
332
334
  const p = inst.view.people.find((x) => x.id === inst.open);
333
- if (!p) return el('div', { class: 'rp-people-page' }, el('p', { class: 'rp-section__empty' }, COPY.pick));
334
- return el('article', { class: 'rp-people-page', 'data-people-page': p.id },
335
- el('header', { class: 'rp-people-who' },
336
- el('h3', { class: 'rp-people-who__name' }, p.name),
337
- el('p', { class: 'rp-people-hint' }, [who(p), p.external && (p.role || p.org) ? COPY.external : null].filter(Boolean).join(' · ')),
338
- el('div', { class: 'rp-people-acts' }, button({ label: COPY.removeFromPeople, variant: 'ghost', size: 'sm', attrs: { 'data-people-act': 'remove' },
339
- onClick: async () => { const r = await decide(inst, { act: 'remove', person: p.id }, COPY.removed(p.name)); if (r) { inst.open = null; draw(inst); } } }))),
340
- interactionsSec(p), toneSec(inst, p), sharedSec(p), notesSec(inst, p), identitySec(inst, p));
335
+ if (!p) return el('div', { class: 'rp-people-card' }, el('p', { class: 'rp-section__empty' }, COPY.pick));
336
+ const t = p.tone;
337
+ const confirmBtn = !t.confirmed && t.proposed ? button({ label: COPY.confirm + ' ' + t.proposed, icon: 'check', variant: 'primary', size: 'sm',
338
+ attrs: { 'data-people-act': 'tone-confirm' },
339
+ onClick: () => decide(inst, { act: 'tone', person: p.id, tone: t.proposed }, COPY.toneSaved) }) : null;
340
+ const moreBtn = iconButton({ icon: 'ellipsis', ariaLabel: 'More', attrs: { 'data-people-act': 'more' },
341
+ onClick: () => menu({ anchor: moreBtn, ariaLabel: 'Actions', items: [
342
+ { label: COPY.removeFromPeople, icon: 'x', attrs: { 'data-people-act': 'remove' }, onSelect: async () => { const r = await decide(inst, { act: 'remove', person: p.id }, COPY.removed(p.name)); if (r) { inst.open = null; draw(inst); } } }
343
+ ] }) });
344
+ return el('article', { class: 'rp-people-card', 'data-people-page': p.id },
345
+ el('div', { class: 'rp-people-page__header' },
346
+ el('div', { class: 'rp-people-page__info' },
347
+ el('span', { class: 'rp-people-page__avatar' }, p.name.split(/\s+/).map((w) => w[0]).join('').toUpperCase()),
348
+ el('div', { class: 'rp-people-page__text' },
349
+ el('h3', { class: 'rp-people-page__name' }, p.name),
350
+ el('p', { class: 'rp-people-hint' }, [who(p), p.external && (p.role || p.org) ? COPY.external : null].filter(Boolean).join(' · ')))),
351
+ el('div', { class: 'rp-people-page__actions' }, confirmBtn, moreBtn)),
352
+ el('div', { class: 'rp-people-page__tiles' },
353
+ el('div', { class: 'rp-people-tile' },
354
+ el('p', { class: 'rp-people-tile__label' }, COPY.interactions),
355
+ el('b', {}, p.count && p.last ? `${p.count} · ${day(p.last)}` : String(p.count || 0))),
356
+ el('div', { class: 'rp-people-tile' },
357
+ el('p', { class: 'rp-people-tile__label' }, COPY.tone),
358
+ el('b', {}, t.confirmed || t.proposed ? [
359
+ el('i', { class: 'rp-people-tile__dot ' + (t.confirmed ? 'rp-people-tile__dot--confirmed' : 'rp-people-tile__dot--proposed') }),
360
+ ' ', t.confirmed ? t.confirmed : (t.proposed + '?')
361
+ ] : 'None')),
362
+ el('div', { class: 'rp-people-tile' },
363
+ el('p', { class: 'rp-people-tile__label' }, COPY.shared),
364
+ el('b', {}, p.shared.initiatives.length ? `${p.shared.initiatives.length} ${p.shared.initiatives.length === 1 ? 'initiative' : 'initiatives'}` : 'None'))),
365
+ el('div', { class: 'rp-people-page__scroll' },
366
+ interactionsSec(p), toneSec(inst, p), sharedSec(p), notesSec(inst, p), identitySec(inst, p)));
341
367
  }
342
368
 
343
369
  /* ── the page ────────────────────────────────────────────────────────────────────────────────────────────────────── */
@@ -348,7 +374,15 @@ function header(inst) {
348
374
  const q = find.control;
349
375
  q.addEventListener('input', () => { inst.q = q.value; draw(inst); });
350
376
  q.addEventListener('keydown', (e) => { if (e.key === 'Escape') { q.resetCommitted(''); inst.q = ''; draw(inst); } });
351
- inst.head = el('div', { class: 'rp-people-top' }, el('h2', { class: 'rp-head__title' }, COPY.title), el('div', { class: 'rp-people-find' }, find));
377
+ const v = inst.view || { people: [] };
378
+ const all = v.people ? v.people.filter((p) => matches(p, inst.q.trim().toLowerCase())) : [];
379
+ const titleText = v.people && v.people.length ? `${v.people.length} ${v.people.length === 1 ? 'person' : 'people'}` : COPY.title;
380
+ inst.titleEl = el('h2', { class: 'rp-head__title' }, titleText);
381
+ inst.head = el('div', { class: 'rp-people-top' },
382
+ inst.titleEl,
383
+ tabs({ variant: 'segmented', value: inst.group, ariaLabel: 'Group', attrs: { 'data-people-group': '' },
384
+ items: [{ id: 'all', label: COPY.all }, { id: 'team', label: COPY.team }, { id: 'external', label: COPY.external }],
385
+ onChange: (val) => { inst.group = val; draw(inst); } }));
352
386
  inst.head.find = find;
353
387
  return inst.head;
354
388
  }
@@ -367,5 +401,9 @@ function draw(inst) {
367
401
  // Focus stays in a note being typed: a redraw of the page would drop it, so it waits until the field is left.
368
402
  const typing = document.activeElement && inst.body.contains(document.activeElement) && document.activeElement.matches('textarea');
369
403
  if (typing) { inst.last = ''; return; }
370
- inst.body.replaceChildren(...[review(inst), el('div', { class: 'rp-people-grid' }, listPane(inst), personPane(inst))].filter(Boolean));
404
+ const n = inst.view.people.length;
405
+ inst.titleEl.textContent = n ? `${n} ${n === 1 ? 'person' : 'people'}` : COPY.title;
406
+ const rev = review(inst);
407
+ const grid = el('div', { class: 'rp-people-grid' }, listPane(inst), personPane(inst));
408
+ inst.body.replaceChildren(...[rev, grid].filter(Boolean));
371
409
  }
@@ -89,9 +89,10 @@ export function groupsOf(repo, { all = false } = {}) {
89
89
  const out = [...by.keys()].sort((a, b) => rank(a) - rank(b) || a.localeCompare(b))
90
90
  .map((i) => ({ key: 'i:' + i, title: titleOf(i), initiative: i, automatic: false, sessions: by.get(i) }));
91
91
  if (none.length) out.push({ key: 'none', title: C().notLinked, initiative: null, automatic: false, sessions: none });
92
- // hidden by default; the session in front is never hidden, and neither is one that is waiting on the PM
92
+ // hidden by default; the session in front is never hidden, and neither is one held on a permission call (a finished agent reads
93
+ // 'needs you' too, so that state alone does not show it)
93
94
  const front = frontSessionId();
94
- const show = all || state.dsShowAuto || sched.some((s) => s.id === front || s.shown === 'needs' || s.shown === 'blocked');
95
+ const show = all || state.dsShowAuto || sched.some((s) => s.id === front || s.shown === 'blocked');
95
96
  if (sched.length && show) out.push({ key: 'auto', title: C().automatic, initiative: null, automatic: true, sessions: sched });
96
97
  return out;
97
98
  }
@@ -264,7 +264,7 @@
264
264
  body > [data-pm-theme] [data-dest="initiatives"] [data-in-nowline],
265
265
  body > [data-pm-theme] [data-dest="initiatives"] [data-in-hint],
266
266
  body > [data-pm-theme] [data-dest="initiatives"] .in-grip,
267
- body > [data-pm-theme] [data-dest="board"] .bo-head__n,
267
+ body > [data-pm-theme] [data-dest="board"] .bo-head .td-count,
268
268
  body > [data-pm-theme] [data-dest="docs"] .rp-docs-top .rp-head__title,
269
269
  body[data-ph-view="cards"] > [data-pm-theme] [data-dest="docs"] .rp-docs-top,
270
270
  body[data-ph-view="cards"] > [data-pm-theme] [data-dest="docs"] .rp-docs-views,
@@ -274,12 +274,12 @@ export function row({ title, titleIcon, meta, signals, lead, trail, onOpen, open
274
274
  trail && trail.length ? el('span', { class: 'pmc-row__trail' }, trail) : null);
275
275
  }
276
276
 
277
- /** card({ title, titleIcon?, meta?, signals?, lead?, trail?, onOpen, openLabel?, attrs? }): a raised card with one open button. */
278
- export function card({ title, titleIcon, meta, signals, lead, trail, onOpen, openLabel, muted, attrs, openAttrs } = {}) {
277
+ /** card({ title, titleIcon?, meta?, signals?, lead?, trail?, onOpen, openLabel?, center?, attrs? }): a raised card with one open button. */
278
+ export function card({ title, titleIcon, meta, signals, lead, trail, onOpen, openLabel, muted, center, attrs, openAttrs } = {}) {
279
279
  // the open button holds the title and the meta line, as a row's does: one column, and one tall target on a phone
280
280
  const open = el('button', { type: 'button', class: 'pmc-card__open', 'aria-label': openLabel || null, onClick: onOpen, ...(openAttrs || {}) },
281
281
  titleLine('pmc-card__title', title, titleIcon), metaLine('pmc-card__meta', meta, signals));
282
- return el('div', withClass(cx('pmc-card', muted && 'pmc-card--muted'), attrs),
282
+ return el('div', withClass(cx('pmc-card', muted && 'pmc-card--muted', center && 'pmc-card--center'), attrs),
283
283
  el('div', { class: 'pmc-card__top' },
284
284
  lead && lead.length ? el('span', { class: 'pmc-card__lead' }, lead) : null,
285
285
  open,
@@ -104,13 +104,15 @@ export const COPY = {
104
104
  toggle: (need) => (need ? `Your day · ${need} ${need === 1 ? 'needs' : 'need'} you` : 'Your day: calendar, jobs, goals and mentions'),
105
105
  fold: 'Fold your day',
106
106
  hello: { morning: 'Good morning.', afternoon: 'Good afternoon.', evening: 'Good evening.' },
107
+ unfinished: (open, all) => (!all ? 'No goals yet' : open ? `${open} unfinished ${open === 1 ? 'goal' : 'goals'}` : 'All goals done'),
107
108
  free: (min) => `${Math.floor(min / 60)} h ${min % 60} min free`,
108
109
  steps: ['Read', 'Sort', 'Agents work', 'You approve'],
109
110
  stepTitle: ['Read your sources', 'Make the jobs', 'Agents do the work', 'You approve'],
110
111
  stepHint: ['The session reads your calendar and your mentions.', 'Each job is tied to a goal or a meeting.', 'One session per job, at most 5 at once.', 'Only what sends or decides waits for you.'],
111
112
  stepOf: (n) => `Prepare my day · step ${n} of 4`,
112
113
  status: {
113
- idle: 'One press: the agent reads, makes the jobs and does them. You approve only what sends or decides.',
114
+ idle: 'Not prepared yet',
115
+ last: (at, n) => (at ? `Last prepared ${at}` : 'Prepared today') + (n ? ` · ${n} ${n === 1 ? 'needs' : 'need'} you` : ''),
114
116
  reading: 'Reading your calendar and mentions…',
115
117
  readFailed: (why) => `The read stopped: ${why}`,
116
118
  working: (w, n) => `${w} ${w === 1 ? 'agent' : 'agents'} working · ${n} ${n === 1 ? 'needs' : 'need'} you`,
@@ -389,34 +389,38 @@ function select(type, id) {
389
389
  function topBar(ctx) {
390
390
  const { m, ws } = ctx;
391
391
  const si = stepIndex(m.stage);
392
- const steps = el('ol', { class: 'td-day__steps', 'aria-label': D.prepare }, ...D.steps.map((t, i) => {
392
+ const press = runs.get(ws);
393
+ const busy = !!press && press.state === 'busy';
394
+ const bs = m.run && m.run.brief_session;
395
+ const reading = !!bs && !bs.ended_at && m.stage === 'reading' && !m.readFailed;
396
+ // the four steps show as a small progress bar only while Prepare my day is running; otherwise the bar says when it last ran
397
+ const inProgress = busy || reading || (!!m.run && m.stage === 'working');
398
+ const steps = inProgress ? el('ol', { class: 'td-day__steps', 'aria-label': D.prepare }, ...D.steps.map((t, i) => {
393
399
  const st = si < 0 ? 'todo' : i < si || si === 4 ? 'done' : i === si ? 'now' : 'todo';
394
- return el('li', null, button({ label: [el('span', { class: 'td-day__tsdot', 'aria-hidden': 'true' }), t], variant: 'ghost', size: 'sm', onClick: () => select('step', i),
395
- attrs: { class: 'td-day__ts', 'data-step-state': st, 'data-day-step': String(i), 'data-day-sel': 'step:' + i, 'aria-current': ctx.cur('step', String(i)), title: D.stepHint[i] } }));
396
- }));
400
+ return el('li', null, button({ label: [el('span', { class: 'td-day__seg', 'aria-hidden': 'true' })], variant: 'ghost', size: 'sm', onClick: () => select('step', i),
401
+ attrs: { class: 'td-day__ts', 'data-step-state': st, 'data-day-step': String(i), 'data-day-sel': 'step:' + i, 'aria-current': ctx.cur('step', String(i)), 'aria-label': t + ': ' + D.stepHint[i], title: t + ': ' + D.stepHint[i] } }));
402
+ })) : null;
397
403
  const working = m.tasks.filter((t) => t.status === 'working' || t.status === 'sending').length;
398
- const status = !m.run ? D.status.idle : m.readFailed ? D.status.readFailed(m.readFailed.why) : m.stage === 'reading' ? D.status.reading : m.stage === 'working' ? D.status.working(working, m.needs)
399
- : m.stage === 'review' ? D.status.review(m.needs, m.done) : D.status.done(m.done);
404
+ const started = m.run && m.run.started_at ? hhmm(m.run.started_at) : '';
405
+ const status = !m.run ? (busy ? D.starting : D.status.idle) : m.readFailed ? D.status.readFailed(m.readFailed.why) : m.stage === 'reading' ? D.status.reading : m.stage === 'working' ? D.status.working(working, m.needs)
406
+ : D.status.last(started, m.needs);
400
407
  let check = null;
401
408
  if (m.run && m.check) {
402
409
  const every = m.settings && m.settings.dayCheck && typeof m.settings.dayCheck.every === 'number' ? m.settings.dayCheck.every : m.check.every;
403
410
  const running = !!m.check.running || ui.busy.has('check');
404
- check = every === 0 ? el('span', { class: 'td-day__check', 'data-day': 'check' }, D.checkOff)
405
- : el('span', { class: 'td-day__check', 'data-day': 'check' }, el('span', null, running ? D.checking : D.checked(hhmm(m.check.last), hhmm(m.check.next))),
406
- button({ label: D.checkNow, size: 'sm', disabled: running, attrs: { 'data-day': 'check-now' }, onClick: () => checkNow(ws) }));
411
+ check = every === 0 ? el('span', { class: 'td-day__check', 'data-day': 'check', title: D.checkOff }, D.checkOff)
412
+ : el('span', { class: 'td-day__check', 'data-day': 'check', title: running ? D.checking : D.checked(hhmm(m.check.last), hhmm(m.check.next)) },
413
+ iconButton({ icon: 'refresh-cw', ariaLabel: D.checkNow, size: 'sm', attrs: { 'data-day': 'check-now', disabled: running ? 'true' : null }, onClick: () => checkNow(ws) }));
407
414
  }
408
- const press = runs.get(ws);
409
- const busy = !!press && press.state === 'busy';
410
- const bs = m.run && m.run.brief_session;
411
- const reading = !!bs && !bs.ended_at && m.stage === 'reading' && !m.readFailed;
412
415
  const label = busy ? D.starting : reading ? D.stillReading : m.run ? D.again : D.prepare;
416
+ const open = m.allGoals.filter((g) => g.status === 'open').length;
413
417
  return el('header', { class: 'td-day__bar' },
414
- (() => { const when = [dateLabel(m.now), m.free !== null && m.free !== undefined ? D.free(m.free) : null].filter(Boolean).join(' · ');
418
+ (() => { const when = [dateLabel(m.now), D.unfinished(open, m.allGoals.length)].filter(Boolean).join(' · ');
415
419
  return el('div', { class: 'td-day__hello', title: greet(m.now) + ' ' + when }, el('b', null, greet(m.now)), ' ', el('span', { class: 'td-day__dim' }, when)); })(),
416
420
  steps,
417
421
  el('span', { class: 'td-day__status', role: 'status', 'data-day': 'status', title: status }, status),
418
422
  check,
419
- button({ label: D.sources, size: 'sm', attrs: { 'data-day': 'sources', 'data-day-sel': 'sources:all', 'aria-current': ctx.cur('sources', 'all') }, onClick: () => select('sources', 'all') }),
423
+ iconButton({ icon: 'settings', ariaLabel: D.sources, size: 'sm', attrs: { 'data-day': 'sources', 'data-day-sel': 'sources:all', 'aria-current': ctx.cur('sources', 'all') }, onClick: () => select('sources', 'all') }),
420
424
  button({ label, variant: 'primary', size: 'sm', disabled: busy || reading, attrs: { 'data-day': 'prepare', 'data-goals': 'prepare-day', title: D.prepareHint, 'aria-busy': busy ? 'true' : 'false' }, onClick: () => prepareDay(ws) }),
421
425
  iconButton({ icon: 'chevron-down', ariaLabel: D.fold, size: 'sm', expanded: true, attrs: { 'data-day': 'toggle' }, onClick: () => fold(false) }));
422
426
  }