create-pmos 0.510.0 → 0.511.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 (34) hide show
  1. package/package.json +231 -124
  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 +336 -912
  8. package/workspace/ev-everywhere-test.js +4 -4
  9. package/workspace/guide/home.md +2 -1
  10. package/workspace/guide/initiative-boards.md +3 -1
  11. package/workspace/guide/settings.md +9 -5
  12. package/workspace/lib/board-premium.js +3 -2
  13. package/workspace/lib/kit-update.js +5 -2
  14. package/workspace/lib/routes-boards.js +15 -1
  15. package/workspace/package-lock.json +2 -2
  16. package/workspace/package.json +1 -1
  17. package/workspace/panels-test.js +68 -1
  18. package/workspace/public/pm/bd-cols.js +2 -1
  19. package/workspace/public/pm/bd-deliveries.js +1 -1
  20. package/workspace/public/pm/bd-span.js +15 -10
  21. package/workspace/public/pm/cozy-ev.css +24 -10
  22. package/workspace/public/pm/cozy-live-pill.css +9 -0
  23. package/workspace/public/pm/cozy-live-pill.js +50 -3
  24. package/workspace/public/pm/cozy-panels.css +9 -0
  25. package/workspace/public/pm/cozy-panels.js +99 -16
  26. package/workspace/public/pm/cozy-settings.js +34 -15
  27. package/workspace/public/pm/cozy-viewer.js +15 -3
  28. package/workspace/public/pm/home-chat.js +4 -0
  29. package/workspace/public/pm/page-chat.css +9 -0
  30. package/workspace/public/pm/page-chat.js +90 -0
  31. package/workspace/public/pm/premium-board.js +5 -0
  32. package/workspace/public/pm/upd-weeks.js +21 -7
  33. package/workspace/public/premium-board.html +2 -2
  34. package/workspace/test-lists/closed-not-late.json +5 -0
@@ -27,6 +27,12 @@
27
27
  * Remembered Home: each initiative reopens with its panels and shares (localStorage 'cz-pn:<initiative id>', like Android app pairs).
28
28
  * A page: only the divider share (localStorage 'cz-pn:<key>'); the open Viewers are not restored. A private window
29
29
  * forgets it, and the page falls back to today's split.
30
+ * Dock side a page (Board, Intelligence): each Viewer docks on the right of the page (as today) or on its left. Drag the Viewer's
31
+ * header (not a press on it) over the left or the right half: that half turns blue, and the drop docks it there. The ⧉
32
+ * menu has the same two places as icons (Dock on the left, Dock on the right), so a keyboard reaches it. A Viewer that
33
+ * slides over slides over on its side. The side is kept per kind of item (localStorage 'cz-pn-side': { kind: 'left' }),
34
+ * so the initiative panel or the chat comes back on the side it was left on after a reload. The divider resizes it on
35
+ * either side (its drag, its keys, a drag to the edge).
30
36
  * Present Home: while a recording presents (BroadcastChannel 'pmos-present': present or show), the chat slides away by itself; on
31
37
  * Stop (off, or the recording ends) it comes back.
32
38
  *
@@ -40,7 +46,7 @@
40
46
  * (560 px at first) but never so wide that the page goes under fit(). key: the name the share is kept under ('board', 'intel').
41
47
  * desk defaults to the page's parent; a Viewer host (data-cz-viewer-host) is added to it when it has none.
42
48
  * window.czPanels = { list(), place(id, 'full'|'split'|'over'), slideOver(item?), openBeside(row|item), putAway(), away(), onChange(fn),
43
- * scope(id) }
49
+ * scope(id), dock(id, 'left'|'right') → true when it docked (a page only), side(id) → 'left'|'right' }
44
50
  * panel ids: 'chat' or 'page' (the first panel), 'v0', 'v1', 'v2' (v0 is #czViewer). list() → [{ id, kind: 'chat'|'page'|'viewer',
45
51
  * mode: 'split'|'over'|'away', full, active, title }]. slideOver(item): a Live pill row (item.sid, a session) opens in the chat, and the
46
52
  * chat floats over when it is not in the row; a Viewer item ({ kind, title, render | html }) opens in a Viewer that floats over; no
@@ -59,8 +65,9 @@ const HOME_SRC = '#hmList button[data-hm-sel], #czLiveList a[data-lv], #hmChat [
59
65
  const IC = {
60
66
  full: 'M3.5 5h17v14h-17z', split: 'M3.5 5h17v14h-17zM12 5v14', over: 'M3.5 5h17v14h-17zM14 8h4v8h-4z',
61
67
  place: 'M3.5 5h17v14h-17zM12 5v14', close: 'M6 6l12 12M18 6L6 18',
68
+ left: 'M3.5 5h17v14h-17zM10 5v14M5.5 9h2M5.5 12h2', right: 'M3.5 5h17v14h-17zM14 5v14M16.5 9h2M16.5 12h2',
62
69
  };
63
- const NAME = { full: 'Full screen', split: 'Split', over: 'Slide over' };
70
+ const NAME = { full: 'Full screen', split: 'Split', over: 'Slide over', left: 'Dock on the left', right: 'Dock on the right' };
64
71
  const svg = (d, w = 18) => `<svg aria-hidden="true" width="${w}" height="${w}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="${d}"/></svg>`;
65
72
  const store = {
66
73
  get(k) { try { return JSON.parse(localStorage.getItem(k) || 'null'); } catch { return null; } },
@@ -72,6 +79,27 @@ const maxRow = () => (innerWidth >= WIDE ? 3 : 2);
72
79
  let C = 'chat';
73
80
  const S = { on: false, page: false, desk: null, chat: null, src: HOME_SRC, clicks: '', fitFn: null, key: '', row: ['chat'], over: null, away: [], fr: [], used: {}, subs: [], go: null, reopen: null, pend: null, scope: null, auto: false, restoring: false, keepList: false, noClick: 0, divs: [] };
74
81
 
82
+ /* ── the side a Viewer docks on (a page): kept per kind of item ── */
83
+ const SIDES = 'cz-pn-side';
84
+ const kindOf = (id) => { const v = P.list().find((p) => p.id === id); return String((v && v.item && v.item.kind) || '').trim().toLowerCase(); };
85
+ /** 'left' or 'right' (today's place). Only a page docks: Home keeps the chat first. */
86
+ function sideOf(id) {
87
+ if (!S.page || !isV(id)) return 'right';
88
+ const m = store.get(SIDES) || {}, k = kindOf(id);
89
+ return k && m[k] === 'left' ? 'left' : 'right';
90
+ }
91
+ /** Dock the Viewer id on a side, keep it for its kind, and lay the panels out again. */
92
+ function dock(id, side) {
93
+ if (!S.on || !S.page || isPhone() || !isV(id) || !known(id) || !['left', 'right'].includes(side)) return false;
94
+ const k = kindOf(id), m = { ...(store.get(SIDES) || {}) };
95
+ if (k) { if (side === 'left') m[k] = 'left'; else delete m[k]; store.set(SIDES, Object.keys(m).length ? m : null); }
96
+ mark(id); S.fr = [];
97
+ layout();
98
+ return true;
99
+ }
100
+
101
+ /** The page's own sheet sets its margin with !important (cozy-panels.css), so the gap after a Viewer docked left is set the same way. */
102
+ const gapLeft = (el, on) => { if (on) el.style.setProperty('margin-left', GAP + 'px', 'important'); else el.style.removeProperty('margin-left'); };
75
103
  /* ── the panels and their elements ── */
76
104
  const isV = (id) => /^v\d$/.test(id || '');
77
105
  const vs = () => P.list().filter((p) => p.open);
@@ -83,7 +111,9 @@ const drop = (id) => { S.row = S.row.filter((x) => x !== id); if (S.over === id)
83
111
  const mark = (id) => { if (id) S.used[id] = performance.now(); };
84
112
  const lru = (ids) => ids.slice().sort((a, b) => (S.used[a] || 0) - (S.used[b] || 0))[0];
85
113
  /** Today's split, laid out by cozy-viewer.js: the first panel alone, or it then one Viewer, nothing over and nothing put away. */
86
- const simple = () => (S.page || (!S.over && !S.away.length)) && S.row[0] === C && S.row.length <= 2; // a page: a Viewer over leaves the split as it is
114
+ const simple = () => (S.page ? S.row.includes(C) : !S.over && !S.away.length && S.row[0] === C) && S.row.length <= 2;
115
+ /** Today's split on a page with its one Viewer docked on the left. */
116
+ const leftSplit = () => S.page && !managed() && S.row.length === 2 && S.row[1] === C; // a page: a Viewer over leaves the split as it is
87
117
  const managed = () => S.on && !isPhone() && !simple();
88
118
  const pageW = () => (S.fitFn ? Math.max(0, +S.fitFn() || 0) : 0);
89
119
 
@@ -97,7 +127,10 @@ function sync() {
97
127
  else { const v = [...S.away].reverse().find(isV); if (v) { drop(v); S.row = [v]; } else { drop(C); S.row = [C]; } }
98
128
  }
99
129
  if (!open.length && !S.row.includes(C)) { drop(C); S.row.unshift(C); } // no Viewer: the chat (or the page) is the page, as today
100
- if (S.page && S.row.includes(C) && S.row[0] !== C) { S.row = [C, ...S.row.filter((x) => x !== C)]; S.fr = []; } // the page is first
130
+ if (S.page && S.row.includes(C)) { // the Viewers docked left, the page, then the ones docked right
131
+ const want = [...S.row.filter((x) => x !== C && sideOf(x) === 'left'), C, ...S.row.filter((x) => x !== C && sideOf(x) !== 'left')];
132
+ if (want.join() !== S.row.join()) { S.row = want; S.fr = []; }
133
+ }
101
134
  fit();
102
135
  }
103
136
  /** Over the limit (a new panel, a narrower window): the panel used least recently gives way. The chat slides over (or away when a panel
@@ -138,11 +171,14 @@ function layout() {
138
171
  el.style.order = !phone && man && at >= 0 ? String(at * 2) : '';
139
172
  el.style.flex = !phone && man && at >= 0 ? `${fr[at]} 1 0px` : '';
140
173
  if (S.page && id !== C) el.style.marginLeft = !phone && man && at > 0 ? GAP + 'px' : ''; // a page's row has no flex gap (stray body children)
174
+ if (S.page && id === C) gapLeft(el, !phone && (man ? at > 0 : leftSplit())); // the page after a Viewer docked left
175
+ if (S.page && isV(id)) el.classList.toggle('cz-pn-left', !phone && sideOf(id) === 'left');
141
176
  grab(el, !phone && S.over === id);
142
177
  });
143
178
  const host = S.desk.querySelector('[data-cz-viewer-host]');
144
- if (host) host.style.order = !phone && !man ? '2' : '';
145
- if (host && S.page) host.style.marginLeft = !phone && !man ? GAP + 'px' : '';
179
+ const lft = !phone && leftSplit();
180
+ if (host) host.style.order = !phone && !man ? (lft ? '-2' : '2') : '';
181
+ if (host && S.page) host.style.marginLeft = !phone && !man && !lft ? GAP + 'px' : '';
146
182
  if (S.chat) S.chat.style.order = !phone && !man ? '0' : S.chat.style.order;
147
183
  divs(phone ? 0 : S.row.length - 1, man);
148
184
  if (man && !phone && !S.page) {
@@ -219,7 +255,7 @@ function divs(n, man) {
219
255
  while (S.divs.length < n) S.divs.push(divider());
220
256
  S.divs.forEach((d, k) => {
221
257
  d.dataset.k = String(k);
222
- d.style.order = man ? String(k * 2 + 1) : '1';
258
+ d.style.order = man ? String(k * 2 + 1) : leftSplit() ? '-1' : '1';
223
259
  if (d.__first !== (k === 0)) { d.__first = k === 0; d.querySelector('.cz-pn-in').innerHTML = dividerHtml({ first: k === 0 }); }
224
260
  if (d.parentNode !== S.desk) S.desk.appendChild(d);
225
261
  });
@@ -257,8 +293,8 @@ function snap(x, g) {
257
293
  * the share is what is kept (rule 7), not the kind's width. */
258
294
  function setAt(k, x, g, keep) {
259
295
  if (!managed()) {
260
- const w = g.R - x - g.gap / 2;
261
- if (S.page) { if (keep) store.set('cz-pn:' + S.key, { v: 2, share: Math.min(0.95, Math.max(0.05, w / (g.R - g.L - g.gap))) }); P.size(g.b, w, false); if (keep) layout(); return; }
296
+ const v = isV(g.b) ? g.b : g.a, w = v === g.b ? g.R - x - g.gap / 2 : x - g.L - g.gap / 2; // a Viewer docked left grows to the right
297
+ if (S.page) { if (keep) store.set('cz-pn:' + S.key, { v: 2, share: Math.min(0.95, Math.max(0.05, w / (g.R - g.L - g.gap))) }); P.size(v, w, false); if (keep) layout(); return; }
262
298
  P.size(g.b, w, keep); return;
263
299
  }
264
300
  const wa = Math.max(60, x - g.ra.left - g.gap / 2), wb = Math.max(60, g.rb.right - x - g.gap / 2), fr = shares(), s = fr[k] + fr[k + 1];
@@ -286,7 +322,7 @@ function dragDiv(d, e) {
286
322
  d.classList.remove('drag', 'snap'); document.body.classList.remove('cz-pn-drag', 'cz-vw-drag');
287
323
  [g.a, g.b].forEach((id) => { const el = elOf(id); if (el) el.classList.remove('cz-pn-shut'); });
288
324
  if (!last) return;
289
- if (last.shut && pinned(last.shut)) place(g.b, 'full'); // the page never closes: the Viewer takes the screen
325
+ if (last.shut && pinned(last.shut)) place(last.shut === g.a ? g.b : g.a, 'full'); // the page never closes: the Viewer takes the screen
290
326
  else if (last.shut) shut(last.shut); else setAt(k, last.x, g, true);
291
327
  };
292
328
  d.addEventListener('pointermove', mv); d.addEventListener('pointerup', up); d.addEventListener('pointercancel', up);
@@ -326,8 +362,14 @@ function openMenu(id, anchor) {
326
362
  const now = modeOf(id);
327
363
  const m = document.createElement('div');
328
364
  m.className = 'cz-pn-menu'; m.setAttribute('role', 'menu'); m.setAttribute('aria-label', 'Arrange'); m.dataset.escLayer = '1';
329
- m.innerHTML = ['full', 'split', 'over'].map((k) => `<button type="button" role="menuitemradio" aria-checked="${now === k}" data-cz-pn-place="${k}" aria-label="${NAME[k]}" title="${NAME[k]}">${svg(IC[k], 20)}</button>`).join('');
330
- m.addEventListener('click', (e) => { const b = e.target.closest('[data-cz-pn-place]'); if (!b) return; closeMenu(false); place(id, b.dataset.czPnPlace); });
365
+ const btn = (k, on, at) => `<button type="button" role="menuitemradio" aria-checked="${on}" ${at}="${k}" aria-label="${NAME[k]}" title="${NAME[k]}">${svg(IC[k], 20)}</button>`;
366
+ // a page's Viewer also docks on the left or the right of the page: the keyboard's way to what a drag of its header does
367
+ const sides = S.page && isV(id) ? '<i class="cz-pn-sep" aria-hidden="true"></i>' + ['left', 'right'].map((k) => btn(k, sideOf(id) === k, 'data-cz-pn-dock')).join('') : '';
368
+ m.innerHTML = ['full', 'split', 'over'].map((k) => btn(k, now === k, 'data-cz-pn-place')).join('') + sides;
369
+ m.addEventListener('click', (e) => {
370
+ const d = e.target.closest('[data-cz-pn-dock]'); if (d) { closeMenu(false); dock(id, d.dataset.czPnDock); return; }
371
+ const b = e.target.closest('[data-cz-pn-place]'); if (!b) return; closeMenu(false); place(id, b.dataset.czPnPlace);
372
+ });
331
373
  m.addEventListener('keydown', (e) => {
332
374
  const bs = [...m.querySelectorAll('button')], at = bs.indexOf(document.activeElement);
333
375
  if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closeMenu(true); }
@@ -464,14 +506,14 @@ function grab(el, want) {
464
506
  let g = el.querySelector(':scope > .cz-pn-grab');
465
507
  if (!want) { if (g) g.remove(); el.style.transform = ''; return; }
466
508
  if (g) return;
467
- g = document.createElement('div'); g.className = 'cz-pn-grab'; g.setAttribute('aria-hidden', 'true'); g.title = 'Drag to the right to put it away';
509
+ g = document.createElement('div'); g.className = 'cz-pn-grab'; g.setAttribute('aria-hidden', 'true'); g.title = 'Drag off the edge to put it away';
468
510
  g.innerHTML = '<i></i>';
469
511
  g.addEventListener('pointerdown', (e) => {
470
512
  if (e.button !== 0) return;
471
513
  e.preventDefault(); g.setPointerCapture(e.pointerId);
472
- const x0 = e.clientX; let dx = 0;
514
+ const x0 = e.clientX, f = el.classList.contains('cz-pn-left') ? -1 : 1; let dx = 0; // docked left: off the left edge
473
515
  el.classList.add('cz-pn-moving');
474
- const mv = (ev) => { dx = Math.max(0, ev.clientX - x0); el.style.transform = `translateX(${dx}px)`; };
516
+ const mv = (ev) => { dx = Math.max(0, (ev.clientX - x0) * f); el.style.transform = `translateX(${dx * f}px)`; };
475
517
  const up = () => {
476
518
  g.removeEventListener('pointermove', mv); g.removeEventListener('pointerup', up); g.removeEventListener('pointercancel', up);
477
519
  el.classList.remove('cz-pn-moving'); el.style.transform = '';
@@ -590,6 +632,46 @@ function dragWire() {
590
632
  }
591
633
  const besideSel = () => [S.src, S.clicks].filter(Boolean).join(', ');
592
634
 
635
+ /* ── dock a Viewer on the left or the right (a page): drag its header; the half it will dock on turns blue ── */
636
+ const NODRAG = 'button.cz-vw-ib, .cz-vw-pri, .cz-vw-chip, .cz-vw-chk, input, textarea, select, a[href], .cz-vw-menu, .cz-vw-trail, .cz-pn-grab';
637
+ let K = null;
638
+ function dockBox(side) {
639
+ const a = area(); if (!a) return null;
640
+ const top = Math.max(a.top, 0), h = Math.min(a.bottom, innerHeight) - top, w = (a.right - a.left) / 2;
641
+ return { left: side === 'left' ? a.left : a.left + w, top, width: w, height: h };
642
+ }
643
+ function dockWire() {
644
+ document.addEventListener('pointerdown', (e) => {
645
+ if (!S.on || !S.page || isPhone() || e.button !== 0 || e.pointerType === 'touch') return;
646
+ const hd = e.target.closest && e.target.closest('.cz-vw-hd'); if (!hd || e.target.closest(NODRAG)) return;
647
+ const p = hd.closest('[data-cz-panel]'); if (!p || !isV(p.dataset.czPanel) || !known(p.dataset.czPanel)) return;
648
+ K = { id: p.dataset.czPanel, el: p, x0: e.clientX, y0: e.clientY, pid: e.pointerId, on: false, side: null };
649
+ }, true);
650
+ document.addEventListener('pointermove', (e) => {
651
+ if (!K || e.pointerId !== K.pid) return;
652
+ if (!K.on) {
653
+ if (Math.hypot(e.clientX - K.x0, e.clientY - K.y0) < 8) return;
654
+ K.on = true; K.zone = document.createElement('div'); K.zone.className = 'cz-pn-drop'; K.zone.hidden = true;
655
+ document.body.appendChild(K.zone); document.body.classList.add('cz-pn-dragrow'); K.el.classList.add('cz-pn-docking');
656
+ }
657
+ e.preventDefault();
658
+ const a = area(); if (!a) return;
659
+ K.side = e.clientX < (a.left + a.right) / 2 ? 'left' : 'right';
660
+ const b = dockBox(K.side); K.zone.hidden = !b;
661
+ if (b) { Object.assign(K.zone.style, { left: b.left + 'px', top: b.top + 'px', width: b.width + 'px', height: b.height + 'px' }); K.zone.dataset.side = K.side; }
662
+ }, true);
663
+ const end = (e, ok) => {
664
+ if (!K || (e && e.pointerId !== K.pid)) return;
665
+ const k = K; K = null;
666
+ if (!k.on) return;
667
+ k.zone.remove(); document.body.classList.remove('cz-pn-dragrow'); k.el.classList.remove('cz-pn-docking');
668
+ S.noClick = Date.now(); // the release over the title is not also its rename press
669
+ if (ok && k.side && k.side !== sideOf(k.id)) dock(k.id, k.side);
670
+ };
671
+ document.addEventListener('pointerup', (e) => end(e, true), true);
672
+ document.addEventListener('pointercancel', (e) => end(e, false), true);
673
+ }
674
+
593
675
  /* ── remembered per initiative on Home (rule 7) ── */
594
676
  function snapshot() {
595
677
  return {
@@ -675,6 +757,7 @@ export function mountPanels(o = {}) {
675
757
  if (p) { mark(p.dataset.czPanel); used(p.dataset.czPanel); } else if (S.chat.contains(t)) mark(C);
676
758
  }, { capture: true, passive: true }));
677
759
  dragWire();
760
+ if (S.page) dockWire();
678
761
  let rz = 0;
679
762
  addEventListener('resize', () => { cancelAnimationFrame(rz); rz = requestAnimationFrame(() => { fit(null, true); S.keepList = false; layout(); }); });
680
763
  if (S.page && typeof ResizeObserver !== 'undefined') new ResizeObserver(() => narrow()).observe(S.chat);
@@ -686,7 +769,7 @@ export function mountPanels(o = {}) {
686
769
  onRecording((s) => { if (!s || !s.on) presentOff(); });
687
770
  }
688
771
  window.czPanels = {
689
- list, place, slideOver, putAway, scope, openBeside,
772
+ list, place, slideOver, putAway, scope, openBeside, dock, side: sideOf,
690
773
  away: lastAway,
691
774
  onChange(fn) { if (typeof fn === 'function') S.subs.push(fn); },
692
775
  };
@@ -17,8 +17,9 @@
17
17
  * Workspaces the repositories PMOS works in (GET and POST /api/workspaces, POST /api/workspaces/forget; the folder is never touched)
18
18
  * Workflows a link to its own page, /premium-workflows.html (pm/wf-page.js, D99): the stages each kind of initiative goes through
19
19
  * Updates the versions and Check now, Update, Restart to update (GET /api/kit/status, POST /api/kit/check | update | upgrade-app); opening it
20
- * asks for the latest kit. Under it, what's new: the published kits grouped by week as New, Improved and Fixed, this week open and every earlier
21
- * week folded into an Archive (pm/upd-weeks.js). It was a section of its own, What's new.
20
+ * asks for the latest kit. Under it, what's new (design E, UpE): this week's summary lines, the newest kit at version level,
21
+ * then "Earlier this week" as one flat list of Claude's plain-language stories (a blue dot New, an amber dot Fixed, no dot
22
+ * Improved), and every earlier week folded into an Archive (pm/upd-weeks.js). No PR numbers and no kit ranges.
22
23
  * Adler's moments (batch 17 item 4-B, AdM4): one face per section, where Claude speaks. Updates: glasses while it checks or installs,
23
24
  * holding the card when a version is ready, calm when up to date. General: holding the update card, else the Jira promise (only after
24
25
  * Commit). A failure in the status line takes the honest face, and then the section's face steps back (one Adler per screen).
@@ -33,7 +34,7 @@ import { esc, svg, ICON, toast, wsParam, wsQ } from './cozy-evui.js';
33
34
  import { sourceRows, staleOf, partsOf } from './cozy-evmodel.js';
34
35
  import * as AD from './cozy-adler.js';
35
36
  import * as SE from './cozy-seasons.js';
36
- import { weeksOf, lineOf, KINDS } from './upd-weeks.js';
37
+ import { weeksOf, lineOf, storiesOf } from './upd-weeks.js';
37
38
 
38
39
  /** PUT or DELETE with the page's launch nonce (pressFetch). Only the key row needs them. */
39
40
  async function pmSend(method, path, body) {
@@ -44,7 +45,7 @@ const SECTIONS = [['general', 'General'], ['claude', 'Claude'], ['checks', 'Clau
44
45
  const PAGE_LINKS = { workflows: '/premium-workflows.html' }; // a section that is a page of its own: the row goes there
45
46
  const SRC = { calendar: 'Calendar', wispr: 'Wispr Flow', mail: 'Mail', slack: 'Slack', jira: 'Jira', confluence: 'Confluence', github: 'GitHub', drive: 'Google Drive', figma: 'Figma', website: 'Your websites' };
46
47
  const VIEWER_KEY = 'pmos.cz.viewer';
47
- const T = { el: null, sec: 'general', ws: null, st: {}, src: null, con: {}, conn: [], set: null, prods: null, msg: '', busy: false, phase: '', say: '', back: null, fresh: null, reading: '', note: {}, lines: null, dress: false, more: false, bday: false, dset: {}, wkOpen: new Set(), arc: false };
48
+ const T = { el: null, sec: 'general', ws: null, st: {}, src: null, con: {}, conn: [], set: null, prods: null, msg: '', busy: false, phase: '', say: '', back: null, fresh: null, reading: '', note: {}, lines: null, dress: false, more: false, bday: false, dset: {}, wkOpen: new Set(), wkMore: new Set(), arc: false };
48
49
  const cmpVer = (a, b) => { const x = String(a || '0').split('-')[0].split('.').map(Number), y = String(b || '0').split('-')[0].split('.').map(Number); for (let i = 0; i < 3; i++) if ((x[i] || 0) !== (y[i] || 0)) return (x[i] || 0) < (y[i] || 0) ? -1 : 1; return 0; };
49
50
  const home = () => (T.ws && T.ws[0] ? T.st[T.ws[0].id] : null);
50
51
  const latest = () => { const s = home(); return (s && (s.latest || (s.lastCheck && s.lastCheck.latest))) || null; };
@@ -211,24 +212,41 @@ function updates() {
211
212
  const last = s.lastCheck ? 'Last checked ' + new Date(s.lastCheck.at).toLocaleString() + '. ' : '';
212
213
  return `<div class="ev-card ev-upd first">${mood ? adFace(mood) : ''}<div class="ev-tx ev-grow">${head}</div>${acts}</div>${kits}<p class="ev-small ev-mut">${esc(last)}${s.checkDaily ? 'PMOS checks when you open Updates and once a day while a page is open; Check now asks again.' : 'PMOS checks only when you press Check now.'}</p>${weeks()}`;
213
214
  }
214
- /** One week: its name and dates, then New, Improved and Fixed with a line for each published PR. */
215
- function weekBody(w) {
216
- const dot = { new: 'you', imp: 'ok', fix: 'risk' };
217
- const grp = KINDS.filter(([k]) => w.groups[k].length).map(([k, label]) => `<div class="ev-wg"><div class="ev-wgl"><i class="ev-dot ${dot[k]}"></i>${label}</div><ul class="ev-wi">${w.groups[k].map((p) => { const x = lineOf(p); return `<li>${x.b ? `<b>${esc(x.b)}${/[.!?]$/.test(x.b) ? '' : '.'}</b> ` : ''}${esc(x.s)}${p.n ? ` <span class="ev-mut ev-pr">#${esc(p.n)}</span>` : ''}</li>`; }).join('')}</ul></div>`).join('');
218
- return `<div class="ev-wkm">${grp}</div>`;
215
+ /** Design E (UpE). A story line: a blue dot for New, an amber dot for Fixed, no dot for Improved; Claude's plain title, then the sentence. */
216
+ const SPARK = 'M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z';
217
+ const DOT = { new: ['you', 'New'], fix: ['risk', 'Fixed'] };
218
+ function story(p) {
219
+ const x = lineOf(p), d = DOT[p.k];
220
+ return `<li class="ev-sy"><span class="ev-dc">${d ? `<i class="ev-dot ${d[0]}" title="${d[1]}"></i><span class="ev-vh">${d[1]}: </span>` : ''}</span><span class="ev-sx">${x.b ? `<b>${esc(x.b)}</b>` : ''}${x.b && x.s ? '<br>' : ''}${esc(x.s)}</span></li>`;
219
221
  }
220
- const kitsOf = (w) => (w.kits[0] === w.kits[1] ? w.kits[0] : w.kits[0] + ' – ' + w.kits[1]);
222
+ /** A flat list of stories, the first SHOWN of them, then a "N more" row that opens the rest. */
223
+ const SHOWN = 7;
224
+ function storyList(list, key) {
225
+ const all = T.wkMore.has(key), vis = all ? list : list.slice(0, SHOWN), rest = list.length - vis.length;
226
+ return `<ul class="ev-sl">${vis.map(story).join('')}</ul>${rest > 0 ? `<button type="button" class="ev-more" data-ev-more="${esc(key)}">${rest} more</button>` : ''}`;
227
+ }
228
+ const sumCard = (w) => (w.sum.length ? `<div class="ev-sum">${w.sum.map((l) => `<p>${svg(SPARK, 14)}<span>${esc(l)}</span></p>`).join('')}</div>` : '');
229
+ const changesOf = (w) => w.count + (w.count === 1 ? ' change' : ' changes');
230
+ /** The newest week: summary lines, the newest kit at version level, then "Earlier this week" as one flat list. */
231
+ function weekTop(w) {
232
+ const { kit, latest: top, earlier } = storiesOf(w), dots = w.groups.new.length || w.groups.fix.length;
233
+ const legend = dots ? '<div class="ev-leg" aria-hidden="true"><span><i class="ev-dot you"></i>New</span><span><i class="ev-dot risk"></i>Fixed</span><span>no dot: improved</span></div>' : '';
234
+ const kitRow = top.length ? `<div class="ev-rel"><div class="ev-rv"><b>${esc(kit)}</b>${kit === latest() ? '<span class="ev-small">latest</span>' : ''}</div><ul class="ev-sl">${top.map(story).join('')}</ul></div>` : '';
235
+ const more = earlier.length ? `<div class="ev-grp"><div class="ev-gh"><b>Earlier ${w.name === 'This week' ? 'this' : 'that'} week</b><span class="ev-small ev-mut">${earlier.length}</span></div>${storyList(earlier, w.key)}</div>` : '';
236
+ return sumCard(w) + legend + kitRow + more;
237
+ }
238
+ /** An archived week: its newest kit's stories, then the rest with the highlights first. */
239
+ const archList = (x) => { const o = storiesOf(x); return [...o.latest, ...o.earlier]; };
221
240
  /** What's new, under the update card: this week (the newest week) open, and every earlier week folded into the Archive. */
222
241
  function weeks() {
223
242
  const s = home(), mine = s && s.app && !fromGit() ? s.app.version : null;
224
243
  const W = weeksOf(changes(), Date.now(), mine);
225
244
  if (!W.length) return '<p class="ev-small ev-mut">No kit summaries yet. Each kit published from now on carries one, and Check now reads the newest ones.</p>';
226
245
  const [w, ...old] = W.slice(0, 13);
227
- const head = (x) => `<div class="ev-wkh"><b>${esc(x.name)}</b><span class="ev-small ev-mut">${esc(x.range)} · kits ${esc(kitsOf(x))}</span>${x.newer ? '<span class="ev-pill y">Ready to install</span>' : ''}</div>`;
228
- const tally = (x) => [['new', 'new'], ['imp', 'improved'], ['fix', 'fixed']].filter(([k]) => x.groups[k].length).map(([k, l]) => x.groups[k].length + ' ' + l).join(' · ');
229
- const rows = old.map((x) => { const on = T.wkOpen.has(x.key); return `<div class="ev-wkr"><button type="button" class="ev-wkt" data-ev-wk="${esc(x.key)}" aria-expanded="${on}"><b>${esc(x.name)}</b><span class="ev-small ev-mut">${esc(x.range)}</span><span class="ev-small ev-mut ev-tl">${esc(tally(x))}</span>${svg(ICON.down, 14)}</button>${on ? weekBody(x) : ''}</div>`; }).join('');
246
+ const head = `<div class="ev-wkh"><b>${esc(w.name)}</b><span class="ev-small ev-mut">${esc([w.range, changesOf(w)].filter(Boolean).join(' · '))}</span>${w.newer ? '<span class="ev-pill y">Ready to install</span>' : ''}</div>`;
247
+ const rows = old.map((x) => { const on = T.wkOpen.has(x.key); return `<div class="ev-wkr"><button type="button" class="ev-wkt" data-ev-wk="${esc(x.key)}" aria-expanded="${on}"><b>${esc(x.name)}</b><span class="ev-small ev-mut">${esc(x.range)}</span><span class="ev-small ev-mut ev-tl">${esc(changesOf(x))}</span>${svg(ICON.down, 14)}</button>${on ? sumCard(x) + storyList(archList(x), x.key) : ''}</div>`; }).join('');
230
248
  const arc = old.length ? `<div class="ev-arc"><button type="button" class="ev-arcb" data-ev-arc aria-expanded="${T.arc}"><b>Archive</b><span class="ev-small ev-mut">${old.length} earlier ${old.length === 1 ? 'week' : 'weeks'}</span>${svg(ICON.down, 14)}</button>${T.arc ? rows : ''}</div>` : '';
231
- return `<div class="ev-wk">${head(w)}${weekBody(w)}${arc}</div>`;
249
+ return `<div class="ev-wk">${head}${weekTop(w)}${arc}</div>`;
232
250
  }
233
251
  /* Settings > Claude: which Claude profile PMOS starts Claude sessions with, and the API key for Claude's own runs (D101).
234
252
  * The key is typed here and goes to this computer only; the page never gets it back, only whether one is set and where from. */
@@ -315,6 +333,7 @@ async function press(e) {
315
333
  if ('evClose' in t.dataset) { closeSettings(); return; }
316
334
  if (t.dataset.evWk) { const k = t.dataset.evWk; if (T.wkOpen.has(k)) T.wkOpen.delete(k); else T.wkOpen.add(k); draw(); return; }
317
335
  if ('evArc' in t.dataset) { T.arc = !T.arc; draw(); return; }
336
+ if (t.dataset.evMore) { T.wkMore.add(t.dataset.evMore); draw(); return; }
318
337
  if (t.dataset.evGo) { T.sec = t.dataset.evGo; draw(); return; }
319
338
  if (t.dataset.evSeg === 'theme') { const M = await import('./cozy-nav.js'); M.setTheme(t.dataset.v); draw(); return; }
320
339
  if (t.dataset.evSeg && t.dataset.evSeg.startsWith('grill.')) {
@@ -458,7 +477,7 @@ async function load() {
458
477
  export function openSettings(section) {
459
478
  const el = mount();
460
479
  if (section === 'dress') { T.sec = 'adler'; T.dress = true; } else if (section && BODY[section]) { T.sec = section; T.dress = false; }
461
- if (el.hidden) { const ae = document.activeElement; T.back = ae && ae !== document.body ? ae : null; T.ws = null; T.src = null; T.msg = ''; T.phase = ''; T.auto = false; T.wkOpen = new Set(); T.arc = false; T.fresh = null; T.note = {}; T.dset = {}; T.cl = null; T.gr = null; JL.reset(); }
480
+ if (el.hidden) { const ae = document.activeElement; T.back = ae && ae !== document.body ? ae : null; T.ws = null; T.src = null; T.msg = ''; T.phase = ''; T.auto = false; T.wkOpen = new Set(); T.wkMore = new Set(); T.arc = false; T.fresh = null; T.note = {}; T.dset = {}; T.cl = null; T.gr = null; JL.reset(); }
462
481
  el.hidden = false; document.getElementById('evStScrim').hidden = false; el.setAttribute('data-esc-layer', '');
463
482
  draw();
464
483
  const f = el.querySelector('[aria-current="page"]'); if (f) f.focus();
@@ -44,6 +44,9 @@
44
44
  * nothing leaves until the caller runs go() (canvas MsB5Leave: Save or Discard). false or none: it leaves at once.
45
45
  * render called with the body element to draw into; html is the same as a string. Neither: the body is left empty for the caller.
46
46
  * id optional; current() returns it, so a caller can tell whether its item is the one shown
47
+ * outward true: a press inside this Viewer opens what it names in the other Viewer used last, not here (a chat panel, as Home's chat
48
+ * column opens into the Viewer); with no other Viewer it drills in here
49
+ * into a panel id ('v0'..'v2'): the item goes in that Viewer (the Board's one chat panel changes its conversation in place)
47
50
  * onClose called once when this item leaves the card (Close, Escape, close(), or another open())
48
51
  * close() · back() · forward() · isOpen() · current() → { id, kind, title, url } | null · body() → the body element or null
49
52
  * drill() the next open() keeps the item shown now behind it, as back: true does (a page opens a task or a document from its own
@@ -117,7 +120,7 @@ const kindKey = (k) => String(k || 'item').trim().toLowerCase().replace(/^file
117
120
  * key or the last open() went to, so the exported API always speaks of the Viewer used last. G is what the page has once: its list's rail,
118
121
  * the listeners, the rail's card. Only the first instance (#czViewer) writes the browser history; another keeps its own ← → trail.
119
122
  * Without cozy-panels.js (Board, Intelligence) the limit stays 1 and there is one Viewer, as before. */
120
- const G = { subs: [], card: null, railKey: null, rail: false, lw: 0, hold: 0, tick: 0, limit: 1, beside: null, place: null, managed: null, own: null };
123
+ const G = { chat: null, subs: [], card: null, railKey: null, rail: false, lw: 0, hold: 0, tick: 0, limit: 1, beside: null, place: null, managed: null, own: null };
121
124
  /** The list's fold, when the page owns it (Home, batch 20 item 6): { folded() → bool, show() }. Then only the list's own icon folds or unfolds it:
122
125
  * a Viewer's width, its reset, open wide and its close never do. null: the Viewer folds the list to a rail as it needs (a page without one). */
123
126
  export function listFold(o) { G.own = o && typeof o.folded === 'function' ? o : null; }
@@ -145,7 +148,12 @@ function pick(o) {
145
148
  return ALL.filter(shown).sort((a, c) => a.used - c.used)[0];
146
149
  }
147
150
  const p = H.press && Date.now() - H.press.t < 10000 ? H.press : null;
148
- if (p && p.inst && shown(p.inst) && !o.fresh) return p.inst;
151
+ if (p && p.inst && shown(p.inst) && !o.fresh) {
152
+ // a Viewer that holds a chat (outward: the Board's chat panel) opens what it names in the other Viewer, as Home's chat column does;
153
+ // with no other Viewer it drills in, and ← comes back to the chat
154
+ const other = p.inst.cur && p.inst.cur.args && p.inst.cur.args.outward ? ALL.filter((x) => shown(x) && x !== p.inst).sort((a, c) => c.used - a.used)[0] : null;
155
+ return other || p.inst;
156
+ }
149
157
  return mru() || ALL[0];
150
158
  }
151
159
 
@@ -431,10 +439,14 @@ function moreMenu(b) {
431
439
  export const aboutPrefix = (k) => /initiative/.test(k) ? 'init' : /(^|-)(doc|document|prd|brd|spec|file|page)(-|$)/.test(k) ? 'doc'
432
440
  : /(task|work|epic|ticket|item)/.test(k) ? 'work' : /(insight|goal|metric|measure|report|chart|dashboard)/.test(k) ? 'ins'
433
441
  : /(program|board|stage|lane|release)/.test(k) ? 'board' : 'chat';
442
+ /** A page that opens the chat itself (the Board, batch 26 item 4): fn({ id, kind, title }) → true when it opened the chat about the item
443
+ * on the page, false to go to Home as before. Only for discuss: true, and not on a phone. */
444
+ export function chatHere(fn) { G.chat = typeof fn === 'function' ? fn : null; }
434
445
  function discuss() {
435
446
  const d = V.cur && V.cur.discuss; if (!d) return;
436
447
  if (typeof d === 'function') { d(); return; }
437
448
  if (d && d.href) { location.href = d.href; return; }
449
+ if (G.chat && !isPhone() && G.chat({ id: V.cur.id, kind: V.cur.kind, title: V.cur.title }) !== false) return;
438
450
  // Home opens the chat about it, with the item in the Viewer beside it (XvABoardChat): ?about=<prefix>:<id> is the chat's key, and ?vk/?vid
439
451
  // carry the item's own kind and id, so Home can reopen it (an initiative, a document by its path, a work-item)
440
452
  const k = kindKey(V.cur.kind), raw = String(V.cur.id || V.cur.title);
@@ -572,7 +584,7 @@ function retitle0(id, title) { if (V.cur && V.cur.id === String(id) && title) {
572
584
  /** Show one item in the Viewer, in place of the one shown. Returns the body element the content is in. */
573
585
  export function open(it) {
574
586
  const o = it || {};
575
- use(o._inst || pick(o));
587
+ use(o._inst || (o.into && byP(o.into)) || pick(o)); // into: a panel id ('v1'), the Viewer that shows it (the Board's chat panel)
576
588
  // an item with unsaved changes asks first; the open goes on when it says so (and keeps a drill-in asked for before it)
577
589
  if (!o._asked && V.cur && typeof V.cur.guard === 'function') {
578
590
  const dr = V.drill; let now = true, ran = false;
@@ -576,6 +576,8 @@ export function mountChat(el, host) {
576
576
  send(v).then((ok) => { const box = foot.querySelector('textarea'); if (!ok && box && !box.value) { box.value = v; grow(box); } }); });
577
577
  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
578
  document.addEventListener('click', (e) => { if (C.addOpen && e.target.isConnected && !e.target.closest('.hm-addw')) shutAdd(false); }, true);
579
+ // 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
580
+ 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
581
  el.addEventListener('keydown', (e) => {
580
582
  if (e.key === 'Escape' && C.addOpen) { e.preventDefault(); e.stopPropagation(); shutAdd(true); return; }
581
583
  if (e.key === 'Escape' && GU.escape()) { e.preventDefault(); e.stopPropagation(); return; }
@@ -673,6 +675,8 @@ export function mountChat(el, host) {
673
675
  draw(); read();
674
676
  },
675
677
  refresh() { if (C.t) draw(); },
678
+ /** Stop reading: the chat left the screen (the Board's chat panel closed). open() starts again. */
679
+ stop() { clearTimeout(C.timer); C.seq += 1; },
676
680
  reread() { read(); },
677
681
  focus() { const ta = foot.querySelector('textarea'); if (ta) ta.focus(); },
678
682
  send,
@@ -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" />