create-pmos 0.510.0 → 0.512.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.
Files changed (43) hide show
  1. package/package.json +239 -134
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/briefing-premium-board-test.js +6 -7
  7. package/workspace/changelog.json +349 -912
  8. package/workspace/ev-everywhere-test.js +4 -4
  9. package/workspace/fixtures/next-prompt-replay.json +40 -0
  10. package/workspace/guide/home.md +2 -1
  11. package/workspace/guide/index.md +1 -0
  12. package/workspace/guide/initiative-boards.md +3 -1
  13. package/workspace/guide/next-prompt.md +62 -0
  14. package/workspace/guide/settings.md +11 -5
  15. package/workspace/lib/board-premium.js +3 -2
  16. package/workspace/lib/kit-update.js +5 -2
  17. package/workspace/lib/next-prompt.js +199 -0
  18. package/workspace/lib/route-modules.js +1 -0
  19. package/workspace/lib/routes-boards.js +15 -1
  20. package/workspace/lib/routes-next-prompt.js +248 -0
  21. package/workspace/package-lock.json +2 -2
  22. package/workspace/package.json +1 -1
  23. package/workspace/panels-test.js +68 -1
  24. package/workspace/public/pm/bd-cols.js +2 -1
  25. package/workspace/public/pm/bd-deliveries.js +1 -1
  26. package/workspace/public/pm/bd-span.js +15 -10
  27. package/workspace/public/pm/cozy-ev.css +26 -10
  28. package/workspace/public/pm/cozy-home.css +45 -1
  29. package/workspace/public/pm/cozy-live-pill.css +9 -0
  30. package/workspace/public/pm/cozy-live-pill.js +50 -3
  31. package/workspace/public/pm/cozy-panels.css +9 -0
  32. package/workspace/public/pm/cozy-panels.js +99 -16
  33. package/workspace/public/pm/cozy-settings.js +70 -16
  34. package/workspace/public/pm/cozy-viewer.js +15 -3
  35. package/workspace/public/pm/home-chat.js +25 -4
  36. package/workspace/public/pm/next-prompt-ui.js +117 -0
  37. package/workspace/public/pm/page-chat.css +9 -0
  38. package/workspace/public/pm/page-chat.js +90 -0
  39. package/workspace/public/pm/premium-board.js +5 -0
  40. package/workspace/public/pm/upd-weeks.js +21 -7
  41. package/workspace/public/premium-board.html +2 -2
  42. package/workspace/test-lists/closed-not-late.json +5 -0
  43. package/workspace/test-lists/next-prompt.json +5 -0
@@ -34,6 +34,7 @@ import * as VWC from './cozy-viewer.js';
34
34
  import { canDictate, dictate } from './home-dictate.js';
35
35
  import { adler } from './cozy-adler.js';
36
36
  import { snapOf, turnEnded } from './chat-turn-end.js'; // a finished turn re-reads what the Viewer shows (batch 25 item 3)
37
+ import { nextPromptUi } from './next-prompt-ui.js'; // the suggested next prompt and the Sharper card above the box (batch 26 item 2)
37
38
  /* The views a chat opens go in the Viewer: on a page with no dialog the old viewer's views draw in the cozy card (premium-viewer.js). */
38
39
  const views = {
39
40
  ctx: () => import('./premium-vw-ctx.js'), file: () => import('./premium-vw-file.js'), attach: () => import('./premium-attach.js'),
@@ -78,6 +79,18 @@ export function mountChat(el, host) {
78
79
  const role = () => (C.t && C.t.role) || roleOf(C.sess || null, host.roles(), firstUser());
79
80
  const firstUser = () => { const m = ((C.conv && C.conv.messages) || []).find((x) => x.role === 'user'); return m ? m.text : ''; };
80
81
  const running = () => !!C.conv && (C.conv.turn === 'agent' || C.conv.turn === 'tool') && C.conv.state !== 'ended';
82
+ // the suggested next prompt (batch 26 item 2): cards above the box after a reply, a Sharper card while the PM types a rough prompt
83
+ const NPU = nextPromptUi({ el, host, box: () => foot.querySelector('textarea'), redrawFoot: () => { if (C.t) drawFoot({ role: role() }); } });
84
+ el.addEventListener('np-grow', (e) => grow(e.target), true);
85
+ const plain = (s) => String(s || '').replace(/```pmos-[\s\S]*?```/g, '').trim();
86
+ function npCtx() {
87
+ const t = C.t, ms = (C.conv && C.conv.messages) || [];
88
+ let ai = -1; for (let i = ms.length - 1; i >= 0; i--) if (ms[i] && ms[i].role !== 'user' && plain(ms[i].text)) { ai = i; break; }
89
+ let pi = -1; for (let i = ai - 1; i >= 0; i--) if (ms[i] && ms[i].role === 'user') { pi = i; break; }
90
+ const ini = /^init:/.test(String(t.key || ''));
91
+ return { key: t.key || t.sid || '', reply: ai >= 0 ? plain(ms[ai].text).slice(0, 6000) : '', asked: pi >= 0 ? shownUser(String(ms[pi].text || '')).slice(0, 2000) : '',
92
+ initiative: ini ? String(t.title || '') : '', stage: ini ? String(t.sub || '') : '', title: String(t.title || '') };
93
+ }
81
94
 
82
95
  /* ── reading ── */
83
96
  async function read() {
@@ -98,6 +111,8 @@ export function mountChat(el, host) {
98
111
  const ended = turnEnded(C.turn, snap); C.turn = snap;
99
112
  draw();
100
113
  if (ended && host.turnEnded) host.turnEnded(t);
114
+ NPU.read(snap.busy);
115
+ if (ended && !(C.conv && C.conv.ask)) NPU.turnEnded(npCtx());
101
116
  clearTimeout(C.timer);
102
117
  const live = (C.th && (C.th.session || C.th.working)) || (t.sid && C.conv && C.conv.state !== 'ended');
103
118
  C.timer = setTimeout(() => { if (!document.hidden) read(); else C.timer = setTimeout(read, IDLE_POLL); }, live ? POLL : IDLE_POLL);
@@ -429,7 +444,7 @@ export function mountChat(el, host) {
429
444
  const html = `${also}<form class="hm-box${on ? ' ut-on' : ''}" id="hmForm">${dot}<textarea id="hmIn" rows="1" aria-label="A message for ${esc(R.role.name)}" placeholder="${esc(ph)}"></textarea>`
430
445
  + `<div class="hm-tb">${add}`
431
446
  + to + wait + scope + `<span class="sp"></span>${host.viewerClosed() ? '<span class="hm-key">Enter sends · Shift+Enter for a new line</span>' : ''}`
432
- + `${mic}<button type="submit" class="hm-send" aria-label="Send" title="Send">${SVG(UP, 18)}</button></div>`
447
+ + `${NPU.undoHtml()}${mic}<button type="submit" class="hm-send" aria-label="Send" title="Send">${SVG(UP, 18)}</button></div>`
433
448
  + filesRow()
434
449
  + '</form>';
435
450
  if (foot.__h !== html) {
@@ -511,6 +526,7 @@ export function mountChat(el, host) {
511
526
  return;
512
527
  }
513
528
  const b = e.target.closest('button, [data-file], [data-att], [data-ctx]'); if (!b || !el.contains(b)) return;
529
+ if (NPU.click(b)) return;
514
530
  // ✓ types /compact (and hides the ✦ until the pie moves); ✕ hides it until the session is 10 points fuller
515
531
  if (b.dataset.hmCpt && C.cpt) { if (b.dataset.hmCpt === 'ok') host.compact(sid()); else host.dismissCompact(sid(), C.cpt.pct); C.cpt = null; sug.hidden = true; put(sug, ''); return; }
516
532
  if (b.dataset.hmSug && C.sug) { const g = C.sug; if (b.dataset.hmSug === 'link') host.linkTo(C.t.sid, g.id); else { host.dismissLink(C.t.sid); drawSug(); } return; }
@@ -572,12 +588,15 @@ export function mountChat(el, host) {
572
588
  el.addEventListener('submit', (e) => { e.preventDefault(); const ta = foot.querySelector('textarea'); const v = ta.value; if (GU.submit(v)) { ta.value = ''; grow(ta); return; }
573
589
  // the box empties at once and the bubble shows (batch 24 item 2); a send that fails puts the words back, unless the PM typed again
574
590
  if (!v.trim() && !C.files.length) return;
575
- ta.value = ''; grow(ta);
591
+ ta.value = ''; grow(ta); NPU.sent();
576
592
  send(v).then((ok) => { const box = foot.querySelector('textarea'); if (!ok && box && !box.value) { box.value = v; grow(box); } }); });
577
593
  const shutAdd = (focus) => { if (!C.addOpen) return; C.addOpen = false; drawFoot({ role: role() }); if (focus) { const a = foot.querySelector('#hmAdd'); if (a) a.focus(); } };
578
594
  document.addEventListener('click', (e) => { if (C.addOpen && e.target.isConnected && !e.target.closest('.hm-addw')) shutAdd(false); }, true);
595
+ // Escape closes the goal choice or method menu wherever the focus is: the redraw that opens the choice can drop the focus to the page
596
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !e.defaultPrevented && el.isConnected && !el.contains(e.target) && !document.querySelector('[data-esc-layer]') && GU.escape()) e.preventDefault(); });
579
597
  el.addEventListener('keydown', (e) => {
580
598
  if (e.key === 'Escape' && C.addOpen) { e.preventDefault(); e.stopPropagation(); shutAdd(true); return; }
599
+ if (e.key === 'Escape' && NPU.escape()) { e.preventDefault(); e.stopPropagation(); return; }
581
600
  if (e.key === 'Escape' && GU.escape()) { e.preventDefault(); e.stopPropagation(); return; }
582
601
  if (e.target.id !== 'hmIn') { if (e.key === 'Escape' && running() && !document.querySelector('[data-esc-layer]') && !(window.czViewer && window.czViewer.isOpen())) press('esc'); return; }
583
602
  if (e.isComposing || e.keyCode === 229) return;
@@ -590,7 +609,7 @@ export function mountChat(el, host) {
590
609
  el.addEventListener('input', (e) => {
591
610
  const card = C.t && C.t.intro && C.t.intro.card;
592
611
  if (card && card.input && e.target.closest('.hm-icard')) { card.input(e.target, e.target.closest('.hm-icard')); return; }
593
- if (e.target.id === 'hmIn') { grow(e.target); C.slashAt = 0; drawSlash(); }
612
+ if (e.target.id === 'hmIn') { grow(e.target); C.slashAt = 0; drawSlash(); NPU.input(e.target.value); }
594
613
  });
595
614
  /* ── the mic ── dictation into the box only (HmMic). A desk shows the blue dot in the box; a phone shows the Speak to Claude sheet. */
596
615
  const box = () => foot.querySelector('textarea');
@@ -669,10 +688,12 @@ export function mountChat(el, host) {
669
688
  if (notesM) notesM.then((M) => setTimeout(() => M.syncNotes(), 0)); // the Notes open in the Viewer belonged to the conversation before
670
689
  const same = C.t && t && C.t.key === t.key && C.t.sid === t.sid;
671
690
  C.t = t;
672
- if (!same) { C.addOpen = false; C.th = null; C.conv = null; C.show = 12; C.open = new Set(); C.files = []; C.marked = null; C.touched = null; C.picked = {}; C.pend = []; C.plOpen = false; C.plAll = false; C.seenAt = t.key ? host.seen(t.key) : null; put(log, introHtml(t.intro)); put(pl, ''); pl.hidden = true; C.jump = true; peek(null); }
691
+ if (!same) { NPU.open(); C.addOpen = false; C.th = null; C.conv = null; C.show = 12; C.open = new Set(); C.files = []; C.marked = null; C.touched = null; C.picked = {}; C.pend = []; C.plOpen = false; C.plAll = false; C.seenAt = t.key ? host.seen(t.key) : null; put(log, introHtml(t.intro)); put(pl, ''); pl.hidden = true; C.jump = true; peek(null); }
673
692
  draw(); read();
674
693
  },
675
694
  refresh() { if (C.t) draw(); },
695
+ /** Stop reading: the chat left the screen (the Board's chat panel closed). open() starts again. */
696
+ stop() { clearTimeout(C.timer); C.seq += 1; },
676
697
  reread() { read(); },
677
698
  focus() { const ta = foot.querySelector('textarea'); if (ta) ta.focus(); },
678
699
  send,
@@ -0,0 +1,117 @@
1
+ /* pm/next-prompt-ui.js — the chat's suggested next prompt and its Sharper card (batch 26 item 2, design B: canvas NpB, NpBw).
2
+ *
3
+ * After a reply ends: up to two cards above the box, the one Claude recommends lit (white, blue ring, blue ✦), the other grey. A tap puts its
4
+ * text in the box; nothing sends, and the PM sends with the box's own ↑. No ↵, edit or dismiss icon. No card when there is no clear next step.
5
+ * The cards hide when the PM types or a new reply starts; they have no timeout.
6
+ * While the PM types a rough prompt (3 or more words), after a 1.5 s pause: a spinner card while Claude sharpens it, then one lit Sharper card.
7
+ * Its sliders icon opens the style menu: Sharpen as Polish, Prompt engineer (the default) or Your own, and the Sharpen as I type switch.
8
+ * After a tap on the Sharper card, ↶ in the box puts back what the PM wrote (NpB 5).
9
+ * Server: GET /api/next-prompt/prefs, POST /api/next-prompt/next and /sharpen (lib/routes-next-prompt.js, through lib/ai-guard.js); the style
10
+ * and the switch are saved with POST /api/next-prompt/prefs (style, sharpen). Settings › Claude › Sharpen prompts holds the same.
11
+ *
12
+ * nextPromptUi({ el, host, box, redrawFoot }) → { turnEnded(ctx), read(busy), input(value), sent(), open(), undoHtml(), click(target) → bool }
13
+ * ctx: { key, asked, reply, initiative?, stage?, title? } box() → the textarea redrawFoot() draws the box again (for ↶) */
14
+ const STAR = '<svg aria-hidden="true" width="15" height="15" viewBox="0 0 24 24" class="np-star"><path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z"/></svg>';
15
+ const SLIDERS = 'M4 7h9M17 7h3M4 17h3M11 17h9M13 4v6M7 14v6', CHECK = 'M5 12l5 5L20 7', UNDO = 'M9 14L4 9l5-5M4 9h11a5 5 0 0 1 0 10h-3', SPIN = 'M12 3a9 9 0 1 1-9 9';
16
+ const ic = (d, w = 16) => `<svg aria-hidden="true" width="${w}" height="${w}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="${d}"/></svg>`;
17
+ const STYLES = [['polish', 'Polish', 'Clearer and shorter, your meaning kept'], ['engineer', 'Prompt engineer', 'Builds the best prompt for the job'], ['own', 'Your own', 'Your wording rules, written once in Settings']];
18
+ const PAUSE = 1500;
19
+ export const roughText = (t) => { const s = String(t || '').trim(); return !s.startsWith('/') && s.length >= 12 && s.split(/\s+/).filter(Boolean).length >= 3; };
20
+
21
+ export function nextPromptUi({ el, host, box, redrawFoot }) {
22
+ const esc = host.esc;
23
+ const N = { mode: null, cards: [], sharp: null, menu: false, prefs: null, seq: 0, timer: null, ctx: null, orig: null, filled: null, sent: null };
24
+ const wrap = document.createElement('div');
25
+ wrap.className = 'np'; wrap.id = 'hmNp'; wrap.hidden = true;
26
+ const foot = el.querySelector('#hmFoot');
27
+ foot.parentNode.insertBefore(wrap, foot);
28
+
29
+ const loadPrefs = () => host.pmGet('/api/next-prompt/prefs').then((r) => { if (r && r.ok) { N.prefs = r; if (!r.sharpen && N.mode !== 'cards') clear(); else draw(); } }).catch(() => {});
30
+ loadPrefs();
31
+ document.addEventListener('pmos:prompt-prefs', loadPrefs); // Settings › Claude changed them
32
+
33
+ function clear() { N.seq++; clearTimeout(N.timer); N.mode = null; N.cards = []; N.sharp = null; N.menu = false; draw(); }
34
+ function draw() {
35
+ let html = '';
36
+ if (N.mode === 'cards' && N.cards.length) {
37
+ html = `<div class="np-list" role="group" aria-label="Suggested next prompts">${N.cards.map((c, i) => `<button type="button" class="np-card${c.rec ? ' rec' : ''}" data-np-use="${i}" aria-label="Use this prompt: ${esc(c.text)}"${c.rec ? ' title="Claude recommends this one"' : ''}>${STAR}<span class="np-t">${esc(c.text)}</span></button>`).join('')}</div>`;
38
+ } else if (N.mode === 'spin') {
39
+ html = `<div class="np-spin" role="status" aria-label="Claude is sharpening your prompt"><span class="np-sp">${ic(SPIN, 20)}</span><span class="np-sk"><i></i><i></i></span></div>`;
40
+ } else if (N.mode === 'sharp' && N.sharp) {
41
+ const P = N.prefs || {}, st = P.style || 'engineer';
42
+ const menu = N.menu ? `<div class="np-menu" role="menu" aria-label="Style of sharper prompts" data-esc-layer><p class="np-mh">Sharpen as</p>${STYLES.map(([k, l, d]) => `<button type="button" role="menuitemradio" aria-checked="${st === k}" class="np-mi" data-np-voice="${k}"><span class="np-ck">${st === k ? ic(CHECK) : ''}</span><span><b>${esc(l)}</b><em>${esc(k === 'own' && !P.rules ? 'Write your rules in Settings first' : d)}</em></span></button>`).join('')}`
43
+ + `<span class="np-sep" role="separator"></span><button type="button" role="menuitemcheckbox" class="np-mi np-swr" aria-checked="${!!P.sharpen}" data-np-sw><b>Sharpen as I type</b><span class="np-sw${P.sharpen ? ' on' : ''}" aria-hidden="true"><i></i></span></button></div>` : '';
44
+ html = `<div class="np-card rec np-sh"><button type="button" class="np-use" data-np-sharp aria-label="Use the sharper prompt: ${esc(N.sharp)}" title="Claude sharpened your prompt">${STAR}<span class="np-t">${esc(N.sharp)}</span></button>`
45
+ + `<button type="button" class="np-ib" data-np-menu aria-haspopup="menu" aria-expanded="${N.menu}" aria-label="Style of sharper prompts" title="Style of sharper prompts">${ic(SLIDERS)}</button>${menu}</div>`;
46
+ }
47
+ if (wrap.__h !== html) { wrap.__h = html; wrap.innerHTML = html; }
48
+ wrap.hidden = !html;
49
+ }
50
+ function fill(text, keepOrig) {
51
+ const ta = box(); if (!ta) return;
52
+ N.orig = keepOrig && ta.value.trim() && ta.value !== text ? ta.value : null;
53
+ N.filled = text; ta.value = text;
54
+ clear(); redrawFoot();
55
+ const t2 = box(); if (t2) { t2.dispatchEvent(new Event('np-grow')); t2.focus(); t2.setSelectionRange(t2.value.length, t2.value.length); }
56
+ }
57
+ async function sharpen(value) {
58
+ const my = ++N.seq;
59
+ N.mode = 'spin'; N.sharp = null; draw();
60
+ const c = N.ctx || {};
61
+ const r = await host.pmPost('/api/next-prompt/sharpen', { key: c.key || '', text: value, reply: c.reply || '', initiative: c.initiative || '', title: c.title || '' }).catch(() => null);
62
+ const ta = box();
63
+ if (my !== N.seq || !ta || ta.value !== value) return; // the PM typed on, or sent: this answer is for words no longer in the box
64
+ if (r && r.ok && r.text) { N.mode = 'sharp'; N.sharp = r.text; } else { N.mode = null; }
65
+ draw();
66
+ }
67
+ async function save(key, value) {
68
+ const r = await host.pmPost('/api/next-prompt/prefs', { key, value }).catch(() => null);
69
+ if (!(r && r.ok)) { host.toast((r && r.error) || 'Not saved.'); return; }
70
+ await loadPrefs();
71
+ document.dispatchEvent(new CustomEvent('pmos:prompt-prefs-saved', { detail: { key, value } }));
72
+ }
73
+
74
+ return {
75
+ /** A reply ended: ask for the next prompts, unless the PM is already typing. */
76
+ async turnEnded(ctx) {
77
+ N.ctx = ctx;
78
+ const ta = box();
79
+ if (!N.prefs || !N.prefs.next || !ctx || !ctx.reply || (ta && ta.value.trim())) return;
80
+ const my = ++N.seq; clearTimeout(N.timer);
81
+ const r = await host.pmPost('/api/next-prompt/next', ctx).catch(() => null);
82
+ const t2 = box();
83
+ if (my !== N.seq || (t2 && t2.value.trim())) return;
84
+ N.cards = r && r.ok && Array.isArray(r.cards) ? r.cards.slice(0, 2) : [];
85
+ N.mode = N.cards.length ? 'cards' : null; N.menu = false; draw();
86
+ },
87
+ /** Each read of the conversation: a new turn at work hides the cards. */
88
+ read(busy, ctx) { if (ctx) N.ctx = { ...(N.ctx || {}), ...ctx }; if (busy && N.mode) clear(); },
89
+ input(value) {
90
+ if (N.orig != null && !value.trim()) { N.orig = null; redrawFoot(); }
91
+ if (N.mode === 'cards' || N.mode === 'sharp' || N.mode === 'spin') { N.seq++; N.mode = null; N.menu = false; draw(); }
92
+ clearTimeout(N.timer);
93
+ if (!N.prefs || !N.prefs.sharpen || value === N.filled || !roughText(value)) return;
94
+ N.timer = setTimeout(() => { const ta = box(); if (ta && ta.value === value) sharpen(value); }, PAUSE);
95
+ },
96
+ sent() { N.orig = null; N.filled = null; clear(); },
97
+ open() { N.ctx = null; N.orig = null; N.filled = null; clear(); },
98
+ undoHtml: () => (N.orig != null ? `<button type="button" class="hm-tool np-undo" data-np-undo aria-label="Back to what I wrote" title="Back to what I wrote">${ic(UNDO)}</button>` : ''),
99
+ /** A click inside the chat: true when it was one of ours. */
100
+ click(b) {
101
+ if (!b) return false;
102
+ if ('npUse' in b.dataset) { const c = N.cards[Number(b.dataset.npUse)]; if (c) fill(c.text, false); return true; }
103
+ if ('npSharp' in b.dataset) { if (N.sharp) fill(N.sharp, true); return true; }
104
+ if ('npUndo' in b.dataset) { const ta = box(); if (ta && N.orig != null) { const o = N.orig; N.orig = null; N.filled = null; ta.value = o; redrawFoot(); const t2 = box(); if (t2) { t2.dispatchEvent(new Event('np-grow')); t2.focus(); } } return true; }
105
+ if ('npMenu' in b.dataset) { N.menu = !N.menu; draw(); if (N.menu) { const f = wrap.querySelector('.np-mi'); if (f) f.focus(); } return true; }
106
+ if (b.dataset.npVoice) {
107
+ const k = b.dataset.npVoice;
108
+ if (k === 'own' && !(N.prefs && N.prefs.rules)) { N.menu = false; draw(); import('./cozy-settings.js').then((M) => M.openSettings('claude')).catch(() => {}); return true; }
109
+ N.menu = false; save('style', k).then(() => { const ta = box(); if (ta && roughText(ta.value)) sharpen(ta.value); });
110
+ return true;
111
+ }
112
+ if ('npSw' in b.dataset) { const on = !(N.prefs && N.prefs.sharpen); save('sharpen', on ? 'on' : 'off').then(() => { if (!on) clear(); else draw(); }); return true; }
113
+ return false;
114
+ },
115
+ escape() { if (!N.menu) return false; N.menu = false; draw(); const m = wrap.querySelector('[data-np-menu]'); if (m) m.focus(); return true; },
116
+ };
117
+ }
@@ -0,0 +1,9 @@
1
+ /* pm/page-chat.css — Home's chat in a Viewer of its own on a page with no chat column (page-chat.js, batch 26 item 4). The chat's own
2
+ * look is cozy-home.css; here only its place: the Viewer's body does not scroll, the conversation does, and the box stays at the foot. */
3
+ .cz-vw .cz-vw-body.pc-body { display: flex; flex-direction: column; overflow: hidden; padding: 0; }
4
+ .pc-body > .pc-chat { flex: 1 1 auto; min-height: 0; padding: 4px 20px 16px; }
5
+ .pc-body > .pc-chat > .hm-reopen { display: none; }
6
+ /* the box shows focus as Home's does (its own well), not with the page's focus ring */
7
+ .pc-chat form.hm-box textarea:focus-visible { outline: none; }
8
+ /* "In:" names the item the chat is about; on the Board it cannot change, so it is not a press */
9
+ .pc-chat [data-hm-scope] { pointer-events: none; }
@@ -0,0 +1,90 @@
1
+ /* pm/page-chat.js — the chat as a panel on a page that has no chat column (the Board; batch 26 item 4, Wawan 2026-11-03: "User should be
2
+ * able to open chat panel on board page").
3
+ *
4
+ * "Discuss in chat" on the Board's initiative or program panel opens the chat about that item in a Viewer of its own, beside the panel
5
+ * (1600 px and wider) or sliding over it (a laptop), through the panels engine (cozy-panels.js openBeside). The page stays where it is.
6
+ * It is Home's chat (home-chat.js mountChat), not a second one: the same conversation key Home opens for that item (cozy-viewer.js
7
+ * aboutPrefix: init:<id> for an initiative, board:<id> for a program), so the words are the same on both pages. One chat panel at a time:
8
+ * Discuss on another item changes the conversation in it. A file or a link the chat opens goes in the other Viewer (outward), as on Home.
9
+ * ⋯ keeps "Open in a new tab": the same conversation on Home. A phone goes to Home as before (cozy-viewer.js chatHere is desktop only).
10
+ *
11
+ * openChat({ id, kind, title }, { ws? }) → true when it opened (false: no item to talk about; the caller goes to Home)
12
+ * The chat's styles are Home's (cozy-home.css, home-line.css); they load once, the first time the chat opens. */
13
+ import * as VW from './cozy-viewer.js';
14
+ import { pmGet, pmPost, pmHeaders } from './http.js';
15
+
16
+ export const CHAT_ID = 'page-chat';
17
+ const CLAUDE = { key: 'claude', name: 'Claude', initial: 'C' };
18
+ const CHAT_ICON = 'M5 6.5A2.5 2.5 0 0 1 7.5 4h9A2.5 2.5 0 0 1 19 6.5v7a2.5 2.5 0 0 1-2.5 2.5H11l-4 3.5V16h.5A2.5 2.5 0 0 1 5 13.5z';
19
+ const FLAG = 'M5 21V4h11l-2 4 2 4H5';
20
+ const SHEETS = ['./cozy-home.css', './home-line.css', './page-chat.css'];
21
+ const esc = (v) => String(v == null ? '' : v).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
22
+ const Q = { el: null, chat: null, t: null, mod: null };
23
+
24
+ /** The conversation key Home opens for the item (cozy-viewer.js discuss: ?about=). */
25
+ export function keyOf(it) {
26
+ const raw = String((it && (it.id || it.title)) || '');
27
+ const k = String((it && it.kind) || 'item').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-');
28
+ const id = raw.replace(/[^A-Za-z0-9._:-]+/g, '-').replace(/^[^A-Za-z0-9]+/, '').slice(0, 100);
29
+ return id ? VW.aboutPrefix(k) + ':' + id : '';
30
+ }
31
+ /** The chat's target (home-chat.js mountChat open): the item's name is its scope; Claude answers. */
32
+ export function targetOf(it, ws) {
33
+ const title = String((it && it.title) || 'this').slice(0, 80);
34
+ return { key: keyOf(it), ws: ws || undefined, title, about: title, scope: { text: title, icon: FLAG }, role: CLAUDE, rich: false,
35
+ placeholder: 'Message Claude about ' + title, empty: 'Ask about ' + title + '. Claude reads its records and works in a real Claude Code session.' };
36
+ }
37
+ /** Home's own address for the same conversation (⋯ › Open in a new tab). */
38
+ const homeHref = (it, key) => '/premium.html?' + new URLSearchParams({ about: key, label: String(it.title || '').slice(0, 80) }).toString();
39
+
40
+ function sheets() {
41
+ for (const s of SHEETS) {
42
+ const href = new URL(s, import.meta.url).href;
43
+ if (![...document.querySelectorAll('link[rel="stylesheet"]')].some((l) => l.href === href)) { const l = document.createElement('link'); l.rel = 'stylesheet'; l.href = href; document.head.appendChild(l); }
44
+ }
45
+ }
46
+ const toast = (m) => { const t = document.getElementById('toast'); if (!t) return; t.textContent = m; t.classList.add('on'); setTimeout(() => t.classList.remove('on'), 4200); };
47
+ function host() {
48
+ return {
49
+ esc, toast, pmGet, pmPost, pressHeaders: () => { const h = pmHeaders(); delete h['Content-Type']; return h; },
50
+ roles: () => [], seen: () => null, markSeen: () => {}, session: () => null, queued: () => [],
51
+ touch: (sid) => { if (sid) pmPost('/api/session/touch', { id: sid }).then(() => document.dispatchEvent(new CustomEvent('cz:live'))).catch(() => {}); },
52
+ onScope: () => {}, onTo: () => {}, onLink: () => {},
53
+ workItem: () => toast('Make a work-item from Home, in the initiative’s own chat.'),
54
+ viewerClosed: () => false, reopenViewer: () => {}, desk: () => true, head: () => '',
55
+ // a finished turn: the page reads its records again, so what Claude changed shows with no refresh (as Home, batch 25 item 3)
56
+ turnEnded: () => document.dispatchEvent(new CustomEvent('cz:chat-turn')),
57
+ };
58
+ }
59
+ async function mod() {
60
+ if (!Q.mod) Q.mod = import('./home-chat.js');
61
+ return Q.mod;
62
+ }
63
+ const shownAt = () => VW.panels.list().find((p) => p.open && p.item && p.item.id === CHAT_ID) || null;
64
+
65
+ export async function openChat(it, o = {}) {
66
+ const key = keyOf(it); if (!key) return false;
67
+ sheets();
68
+ const M = await mod();
69
+ if (!Q.el) {
70
+ Q.el = document.createElement('section'); Q.el.className = 'hm-chat pc-chat'; Q.el.setAttribute('aria-label', 'Conversation');
71
+ Q.chat = M.mountChat(Q.el, host());
72
+ }
73
+ Q.t = targetOf(it, o.ws);
74
+ const item = {
75
+ id: CHAT_ID, kind: 'Chat', title: Q.t.title, icon: CHAT_ICON, url: homeHref(it, key), outward: true, fresh: true,
76
+ render: (body) => { body.classList.add('pc-body'); body.appendChild(Q.el); Q.chat.open(Q.t); },
77
+ onClose: () => { if (Q.chat) Q.chat.stop(); },
78
+ };
79
+ const at = shownAt();
80
+ if (at) { // one chat panel: it changes to this item's conversation in place (its title and its Home address with it)
81
+ VW.open({ ...item, into: at.id });
82
+ if (window.czPanels && (window.czPanels.list().find((p) => p.id === at.id) || {}).mode === 'away') window.czPanels.slideOver();
83
+ setTimeout(() => Q.chat && Q.chat.focus(), 60);
84
+ return true;
85
+ }
86
+ const P = window.czPanels;
87
+ if (!(P && P.openBeside && P.openBeside(item))) VW.open(item);
88
+ setTimeout(() => Q.chat && Q.chat.focus(), 60);
89
+ return true;
90
+ }
@@ -95,6 +95,11 @@ mountLivePill();
95
95
  * name drags beside, or ⌘/Ctrl-click opens it beside (a By stage card's drag moves the stage, so it is no source). fit(): the width the
96
96
  * view needs whole, so the Viewer opens no wider than that leaves. */
97
97
  mountPanels({ page: document.getElementById('stage'), key: 'board', sources: BP2.SOURCES, fit: BP2.fit });
98
+ /* Discuss in chat on an initiative's or a program's panel opens Home's chat about it in a panel of its own, beside the panel or sliding over
99
+ * it (batch 26 item 4, page-chat.js); the Board stays. A finished turn reads the board again. A phone goes to Home as before. */
100
+ VW.chatHere((it) => { const r = M && (M.rows || []).find((x) => x.id === it.id); import('./page-chat.js').then((C) => C.openChat(it, { ws: (r && r.ws) || (M && M.board.ws) || undefined })); return true; });
101
+ document.addEventListener('cz:chat-turn', () => { clearTimeout(chatT); chatT = setTimeout(() => load(), 400); });
102
+ let chatT = null;
98
103
  // a product head's goal dots (PgBoard .gd) count that product's open initiatives, the way Home's product header does
99
104
  PG.goalsFrom((name) => (M ? M.initiatives.list.rows.filter((r) => !M.board.all || r.product === name) : null));
100
105
  const SV = (d) => `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="${d}"/></svg>`;
@@ -1,8 +1,11 @@
1
1
  /* pm/upd-weeks.js — the published kits' change log, grouped by calendar week (Settings › Updates; initiative premium-updates, the weekly log).
2
2
  * Pure: no DOM, no network. The two Settings screens (cozy-settings.js, premium-shell.js) draw what it returns.
3
- * weeksOf(entries, now, mine) → [{ key, name, range, kits: [oldest, newest], newer, groups: { new, imp, fix }, count }] newest week first
4
- * lineOf(item) → { b, s } a bold name and the sentence under it (the PR's title and its summary), or just the sentence
5
- * An entry is { v, at, prs: [{ n, t, s, k }] } as scripts/kit-changelog.js writes it. A week runs Monday to Sunday in this computer's time zone.
3
+ * weeksOf(entries, now, mine) → [{ key, name, range, kits: [oldest, newest], newer, groups: { new, imp, fix }, count, items, sum }] newest week first
4
+ * items: every line of the week as one flat list, the newest kit first; sum: up to three summary lines, from the highlights (top)
5
+ * storiesOf(w) → { kit, latest: [items of the newest kit], earlier: [the rest, highlights first] } design E: the newest kit at version level
6
+ * lineOf(item) → { b, s } a bold name and the sentence under it: Claude's story (h, d) when the kit has one, else the PR's title and
7
+ * its summary, or just the sentence
8
+ * An entry is { v, at, prs: [{ n, t, s, k, h, d, top }] } as scripts/kit-changelog.js writes it. A week runs Monday to Sunday in this computer's time zone.
6
9
  * Each PR becomes one line in New, Improved or Fixed by its kind k; chore, docs, test and CI PRs (k = x) are left out, and a week with no line
7
10
  * is not listed (zero is silence). A kit published before kinds were kept has no k, so its title decides: a fix word means Fixed, else Improved. */
8
11
  export const KINDS = [['new', 'New'], ['imp', 'Improved'], ['fix', 'Fixed']];
@@ -19,6 +22,7 @@ export function kindOf(p) {
19
22
  * ("`pmos x` names y" and "Pmos x names y."): the line is then the title once, without its ticks, so a command keeps its own case. */
20
23
  const same = (a, b) => { const f = (x) => x.replace(/`/g, '').replace(/[.!?]+$/, '').replace(/\s+/g, ' ').trim().toLowerCase(); return f(a) === f(b); };
21
24
  export function lineOf(p) {
25
+ if (p && p.h) { const d = String(p.d || ''), ps = String(p.s || ''); return { b: String(p.h), s: d || (same(p.h, ps) || same(String(p.t || ''), ps) ? '' : ps) }; }
22
26
  const s = String((p && p.s) || ''), t = String((p && p.t) || '').replace(/`([^`]*)`/g, '$1');
23
27
  if (!t) return { b: '', s };
24
28
  return same(t, s) ? { b: '', s: t } : { b: t, s };
@@ -37,15 +41,16 @@ export function weeksOf(entries, now = Date.now(), mine = null) {
37
41
  for (const e of Array.isArray(entries) ? entries : []) {
38
42
  if (!e || !e.v) continue;
39
43
  const d = e.at ? new Date(e.at) : null, dated = d && !isNaN(d), mon = dated ? mondayOf(d) : null, key = dated ? keyOf(mon) : 'undated';
40
- if (!by.has(key)) by.set(key, { key, mon, kits: [], newer: false, groups: { new: [], imp: [], fix: [] }, seen: new Set() });
44
+ if (!by.has(key)) by.set(key, { key, mon, kits: [], newer: false, groups: { new: [], imp: [], fix: [] }, items: [], seen: new Set() });
41
45
  const w = by.get(key);
42
46
  w.kits.push(e.v);
43
47
  if (mine && cmpVer(e.v, mine) > 0) w.newer = true;
44
48
  for (const p of e.prs || []) {
45
- const k = kindOf(p); if (k === 'x') continue;
49
+ const k = p.h && p.top ? 'new' : kindOf(p); if (k === 'x') continue; // a highlight is told as a New entry
46
50
  if (p.n && w.seen.has(p.n)) continue;
47
51
  if (p.n) w.seen.add(p.n);
48
- w.groups[k].push({ n: p.n || null, t: p.t || '', s: p.s || '', v: e.v });
52
+ const it = { n: p.n || null, k, t: p.t || '', s: p.s || '', v: e.v, ...(p.h ? { h: p.h } : {}), ...(p.d ? { d: p.d } : {}), ...(p.h && p.top ? { top: p.top } : {}) };
53
+ w.groups[k].push(it); w.items.push(it);
49
54
  }
50
55
  }
51
56
  const out = [];
@@ -54,7 +59,16 @@ export function weeksOf(entries, now = Date.now(), mine = null) {
54
59
  if (!count) continue;
55
60
  const sorted = [...w.kits].sort(cmpVer);
56
61
  const name = !w.mon ? 'Date not recorded' : keyOf(w.mon) === keyOf(thisMon) ? 'This week' : keyOf(w.mon) === keyOf(lastMon) ? 'Last week' : 'Week of ' + dm(w.mon);
57
- out.push({ key: w.key, name, range: w.mon ? rangeOf(w.mon) : '', kits: [sorted[0], sorted[sorted.length - 1]], newer: w.newer, groups: w.groups, count, mon: w.mon });
62
+ const items = w.items.map((it, i) => [it, i]).sort((a, b) => cmpVer(b[0].v, a[0].v) || a[1] - b[1]).map(([it]) => it);
63
+ const sum = items.filter((it) => it.top).slice(0, 3).map((it) => it.top);
64
+ out.push({ key: w.key, name, range: w.mon ? rangeOf(w.mon) : '', kits: [sorted[0], sorted[sorted.length - 1]], newer: w.newer, groups: w.groups, count, items, sum, mon: w.mon });
58
65
  }
59
66
  return out.sort((a, b) => (a.mon && b.mon ? b.mon - a.mon : a.mon ? -1 : b.mon ? 1 : 0)).map(({ mon, ...w }) => w);
60
67
  }
68
+
69
+ /** Design E: the week's newest kit on its own, at version level, then the rest of the week as one flat list with the highlights first. */
70
+ export function storiesOf(w) {
71
+ const kit = w && w.kits ? w.kits[1] : null, items = (w && w.items) || [];
72
+ const latest = items.filter((it) => it.v === kit), rest = items.filter((it) => it.v !== kit);
73
+ return { kit, latest, earlier: [...rest.filter((it) => it.top), ...rest.filter((it) => !it.top)] };
74
+ }
@@ -7,8 +7,8 @@
7
7
  <title>PMOS · Board</title>
8
8
  <!-- The premium Board in the Agreed design (initiative premium-agreed-design, B1 B2 B3 N1 N2). One toolbar and no page header; each initiative is a row
9
9
  across the six stages and two milestones, with progress toward its current output from Jira. It reads GET /api/board-premium. The floating place menu
10
- (pm/cozy-nav.js) replaces the rail on screen; the rail stays in the markup so premium-shell.js keeps its wiring. The Board has no chat: "Ask about this"
11
- sends the question to Home. -->
10
+ (pm/cozy-nav.js) replaces the rail on screen; the rail stays in the markup so premium-shell.js keeps its wiring. Discuss in chat on a panel opens Home's chat
11
+ about it in a panel on the Board (pm/page-chat.js); a phone goes to Home. -->
12
12
  <link rel="preload" href="/vendor/nunito-var.woff2" as="font" type="font/woff2" crossorigin />
13
13
  <link rel="stylesheet" href="/pm/cozy.css" />
14
14
  <link rel="stylesheet" href="/pm/cozy-pick.css" />
@@ -0,0 +1,5 @@
1
+ {
2
+ "_comment": "Batch 26 item 5 (Fix red light issue): a closed initiative (its workflow's terminal stage, lib/routes-boards.js closedOf) is never late on the Board: no line to Today, no late dot or ◇, no edge tab; a one-day plan draws its bar.",
3
+ "modules": ["closed-not-late.test.js"],
4
+ "seconds": { "closed-not-late.test.js": 2 }
5
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "_comment": "The chat's suggested next prompt and Sharper card (batch 26 item 2): the engine, the route with a recorded answer in place of Claude, the shared guard's replay, citation check and kill switch, and the Settings keys.",
3
+ "modules": ["next-prompt.test.js"],
4
+ "seconds": { "next-prompt.test.js": 2 }
5
+ }