create-pmos 0.509.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 (39) hide show
  1. package/package.json +247 -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/board-programs-test.js +3 -0
  7. package/workspace/briefing-premium-board-test.js +6 -7
  8. package/workspace/changelog.json +352 -909
  9. package/workspace/ev-everywhere-test.js +4 -4
  10. package/workspace/guide/home.md +2 -1
  11. package/workspace/guide/initiative-boards.md +3 -1
  12. package/workspace/guide/programs.md +2 -1
  13. package/workspace/guide/settings.md +9 -5
  14. package/workspace/lib/board-premium.js +3 -2
  15. package/workspace/lib/kit-update.js +19 -4
  16. package/workspace/lib/routes-boards.js +15 -1
  17. package/workspace/package-lock.json +2 -2
  18. package/workspace/package.json +1 -1
  19. package/workspace/panels-test.js +68 -1
  20. package/workspace/public/pm/bd-cols.js +2 -1
  21. package/workspace/public/pm/bd-deliveries.js +1 -1
  22. package/workspace/public/pm/bd-prog.js +10 -5
  23. package/workspace/public/pm/bd-span.js +15 -10
  24. package/workspace/public/pm/cozy-board.css +8 -2
  25. package/workspace/public/pm/cozy-ev.css +24 -10
  26. package/workspace/public/pm/cozy-live-pill.css +9 -0
  27. package/workspace/public/pm/cozy-live-pill.js +50 -3
  28. package/workspace/public/pm/cozy-panels.css +9 -0
  29. package/workspace/public/pm/cozy-panels.js +99 -16
  30. package/workspace/public/pm/cozy-settings.js +34 -15
  31. package/workspace/public/pm/cozy-viewer.js +15 -3
  32. package/workspace/public/pm/home-chat.js +4 -0
  33. package/workspace/public/pm/page-chat.css +9 -0
  34. package/workspace/public/pm/page-chat.js +90 -0
  35. package/workspace/public/pm/premium-board.js +9 -0
  36. package/workspace/public/pm/readonly.js +3 -1
  37. package/workspace/public/pm/upd-weeks.js +27 -9
  38. package/workspace/public/premium-board.html +2 -2
  39. package/workspace/test-lists/closed-not-late.json +5 -0
@@ -15,6 +15,9 @@
15
15
  * mountLivePill(doc = document, opts) once per page, after mountNav: the pill lives inside #czNav, so it hides whenever the menu hides
16
16
  * opts.go(item) → true the page opens the item itself (Home: the session's chat, the meeting); else the row's link is followed
17
17
  * opts.open() → id the session open on this page now: its row is tinted
18
+ * Docking (batch 26 item 4): on a desktop the list has a grab bar on top. Drag it to the left or the right half of the window and the
19
+ * list docks on that side (above the pill, or at the window's right edge at the same height); a press or Enter on the bar moves it to the
20
+ * other side. The side is kept (localStorage 'cz-live-side'). A phone's sheet has none.
18
21
  * Reads GET /api/live every 10 s while the page is shown, and on the cz:live event (Home sends it after the PM opens or answers a
19
22
  * session); the recording comes from pm/rec-state.js. The recording's row has one icon-only Stop beside it (Wawan, 2026-11-03: finish
20
23
  * from anywhere): the first press asks "Press again", the second ends the meeting (rec-state.js stopPress). Nothing else is written.
@@ -32,13 +35,16 @@ const STOP = '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="7" y="7" widt
32
35
  const CHECK = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg>';
33
36
  const PHONE = '(max-width: 760px)';
34
37
  const mac = () => { try { return /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent || ''); } catch { return false; } };
38
+ const SIDE = 'cz-live-side';
39
+ const sideGet = () => { try { return localStorage.getItem(SIDE) === 'right' ? 'right' : 'left'; } catch { return 'left'; } };
40
+ const sideSet = (v) => { try { if (v === 'right') localStorage.setItem(SIDE, 'right'); else localStorage.removeItem(SIDE); } catch { /* a private window */ } };
35
41
  const markHtml = (m, once) => (m === 'done' ? `<i class="lv-m done${once ? ' once' : ''}">${CHECK}</i>` : `<i class="lv-m ${m}${once ? ' once' : ''}"></i>`);
36
42
 
37
43
  export function mountLivePill(doc = document, opts = {}) {
38
44
  if (doc.getElementById('czLive')) return;
39
45
  if (!doc.querySelector(`link[href="${SHEET}"]`)) { const l = doc.createElement('link'); l.rel = 'stylesheet'; l.href = SHEET; doc.head.appendChild(l); }
40
46
  const L = { sessions: [], titles: {}, rec: null, open: false, shown: [], marks: new Map(), tick: null, tries: 0 };
41
- let pill = null, tray = null, scrim = null;
47
+ let pill = null, tray = null, scrim = null, rowsEl = null, grabB = null;
42
48
  const phone = () => typeof matchMedia === 'function' && matchMedia(PHONE).matches;
43
49
  const place = () => {
44
50
  const nav = doc.getElementById('czNav');
@@ -48,6 +54,9 @@ export function mountLivePill(doc = document, opts = {}) {
48
54
  scrim = doc.createElement('div'); scrim.className = 'lv-scrim'; scrim.hidden = true;
49
55
  tray = doc.createElement('div'); tray.id = 'czLiveList'; tray.className = 'lv-tray'; tray.hidden = true;
50
56
  tray.setAttribute('role', 'dialog'); tray.setAttribute('aria-label', 'What you work on now');
57
+ tray.innerHTML = '<button type="button" class="lv-side"><i></i></button><div class="lv-rows"></div>';
58
+ grabB = tray.querySelector('.lv-side'); rowsEl = tray.querySelector('.lv-rows');
59
+ sideWire();
51
60
  nav.append(scrim, tray, pill);
52
61
  pill.addEventListener('click', () => set(!L.open));
53
62
  scrim.addEventListener('click', () => set(false));
@@ -113,7 +122,7 @@ export function mountLivePill(doc = document, opts = {}) {
113
122
  const h = pillHtml(now); if (pill.__h !== h) { pill.__h = h; pill.innerHTML = h; }
114
123
  if (L.open && !tray.querySelector('.lv-stop.armed, .lv-stop:disabled')) { // a Stop waiting for its second press keeps the list as it is
115
124
  const f = doc.activeElement && tray.contains(doc.activeElement) ? doc.activeElement.dataset.lv : null;
116
- tray.innerHTML = rowsHtml(list);
125
+ rowsEl.innerHTML = rowsHtml(list);
117
126
  if (f) { const a = [...tray.querySelectorAll('a[data-lv]')].find((n) => n.dataset.lv === f); if (a) a.focus(); }
118
127
  L.shown = list;
119
128
  }
@@ -127,7 +136,7 @@ export function mountLivePill(doc = document, opts = {}) {
127
136
  pill.setAttribute('aria-expanded', String(open)); pill.classList.toggle('open', open);
128
137
  tray.classList.toggle('sheet', open && phone());
129
138
  tray.hidden = !open; scrim.hidden = !(open && phone());
130
- if (open) { L.shown = items(); L.marks = new Map(L.shown.map((x) => [x.id, x.mark])); tray.innerHTML = rowsHtml(L.shown); const a = tray.querySelector('a'); if (a) a.focus(); read(); }
139
+ if (open) { L.shown = items(); L.marks = new Map(L.shown.map((x) => [x.id, x.mark])); rowsEl.innerHTML = rowsHtml(L.shown); dockTray(); const a = tray.querySelector('a'); if (a) a.focus(); read(); }
131
140
  else { L.shown = []; if (tray.contains(doc.activeElement)) pill.focus(); }
132
141
  draw();
133
142
  }
@@ -137,6 +146,44 @@ export function mountLivePill(doc = document, opts = {}) {
137
146
  if ((down || e.key === 'ArrowUp') && n) { e.preventDefault(); rows[i < 0 ? (down ? 0 : n - 1) : (i + (down ? 1 : n - 1)) % n].focus(); }
138
147
  else if (/^[1-5]$/.test(e.key) && !e.ctrlKey && !e.metaKey && !e.altKey) { const a = rows[Number(e.key) - 1]; if (a) { e.preventDefault(); a.click(); } }
139
148
  }
149
+ /* the side the list docks on (a desktop): left, above the pill (today), or right, at the window's right edge at the same height */
150
+ function dockTray(side = sideGet()) {
151
+ const right = side === 'right' && !phone();
152
+ tray.classList.toggle('lv-r', right);
153
+ tray.style.bottom = right ? Math.round(innerHeight - pill.getBoundingClientRect().top + 8) + 'px' : '';
154
+ grabB.setAttribute('aria-label', right ? 'Move this list to the left side' : 'Move this list to the right side');
155
+ grabB.title = 'Drag to the left or the right side';
156
+ }
157
+ function sideWire() {
158
+ let g = null;
159
+ grabB.addEventListener('pointerdown', (e) => {
160
+ if (e.button !== 0 || phone()) return;
161
+ e.preventDefault(); grabB.setPointerCapture(e.pointerId);
162
+ g = { x0: e.clientX, on: false, r: tray.getBoundingClientRect() };
163
+ });
164
+ grabB.addEventListener('pointermove', (e) => {
165
+ if (!g) return;
166
+ const dx = e.clientX - g.x0;
167
+ if (!g.on && Math.abs(dx) < 8) return;
168
+ g.on = true; tray.classList.add('lv-moving');
169
+ tray.style.transform = `translateX(${Math.round(Math.max(-g.r.left + 8, Math.min(innerWidth - g.r.right - 8, dx)))}px)`;
170
+ });
171
+ const up = (e, ok) => {
172
+ if (!g) return;
173
+ const was = g; g = null;
174
+ tray.classList.remove('lv-moving'); tray.style.transform = '';
175
+ if (!was.on) return;
176
+ grabB.__drag = Date.now(); // the release is not also a press
177
+ if (ok) { const side = e.clientX > innerWidth / 2 ? 'right' : 'left'; sideSet(side); dockTray(side); }
178
+ };
179
+ grabB.addEventListener('pointerup', (e) => up(e, true));
180
+ grabB.addEventListener('pointercancel', (e) => up(e, false));
181
+ // a press (a mouse with no drag, Enter, Space): the other side
182
+ grabB.addEventListener('click', (e) => {
183
+ if (e.detail && grabB.__drag && Date.now() - grabB.__drag < 400) return; // a key press (detail 0) always moves it
184
+ const side = sideGet() === 'right' ? 'left' : 'right'; sideSet(side); dockTray(side); grabB.focus();
185
+ });
186
+ }
140
187
  // the meeting clock ticks once a second while a recording runs; nothing else moves
141
188
  function tickRec(list) {
142
189
  const run = list.some((x) => x.kind === 'rec' && !x.paused);
@@ -47,6 +47,7 @@ body.cz-pn-ctl .hm-chat > .hm-reopen { display: none; }
47
47
  .cz-pn-menu button:hover { background: var(--cz-well); }
48
48
  .cz-pn-menu button[aria-checked="true"] { background: var(--cz-bluebg); color: var(--cz-blueink); }
49
49
  .cz-pn-menu button:focus-visible { outline: 2px solid var(--cz-blue); outline-offset: 2px; }
50
+ .cz-pn-menu .cz-pn-sep { align-self: stretch; width: 1px; margin: 6px 2px; background: var(--cz-hair); }
50
51
 
51
52
  /* open beside (TbT5DragRow): the row follows the pointer, and the half it will take turns blue */
52
53
  .cz-pn-ghost { position: fixed; z-index: 92; pointer-events: none; max-width: 260px; padding: 10px 14px; border-radius: 14px; background: var(--cz-card); color: var(--cz-ink); box-shadow: var(--cz-sheet); font: 600 var(--fs-3, 14px)/1.25 var(--cz-sans); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
@@ -73,7 +74,15 @@ body.cz-pn-dragrow, body.cz-pn-dragrow * { user-select: none !important; cursor:
73
74
  body.cz-pn-page .cz-pn-div { margin: 0 -11px 0 11px; position: sticky; top: 16px; height: calc(100vh - 32px); align-self: flex-start; }
74
75
  body.cz-pn-page .cz-vw.cz-pn-over { position: fixed !important; top: 16px; right: 16px; bottom: 16px; height: auto; z-index: 70; }
75
76
  body.cz-pn-page > main.cz-pn-away { display: none !important; }
77
+ /* docked on the left (dock side): it slides over on the left too */
78
+ body.cz-pn-page .cz-vw.cz-pn-over.cz-pn-left { right: auto; left: 16px; animation-name: cz-pn-inl; }
79
+ /* a Viewer's header is its handle for docking: the header shows it can move */
80
+ body.cz-pn-page .cz-vw .cz-vw-hd { cursor: grab; }
81
+ body.cz-pn-page .cz-vw .cz-vw-hd :is(button, input, a):not([data-cz-vw-rn]) { cursor: pointer; }
82
+ body.cz-pn-page .cz-vw .cz-vw-hd [data-cz-vw-rn] { cursor: text; }
83
+ .cz-vw.cz-pn-docking { opacity: .6; }
76
84
  }
85
+ @keyframes cz-pn-inl { from { transform: translateX(-32px); opacity: 0; } to { transform: none; opacity: 1; } }
77
86
 
78
87
  @media (prefers-reduced-motion: reduce) { .cz-pn-over { animation: none; } .cz-pn-shut { transition: none; } }
79
88
  /* a phone keeps today's one panel: nothing here applies */
@@ -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; }