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
@@ -150,20 +150,20 @@ const put = (root, rel, text) => { fs.mkdirSync(path.dirname(path.join(root, rel
150
150
  const hits = { check: 0, update: 0, upgrade: 0 };
151
151
  const day = (n) => new Date(Date.now() - n * 864e5).toISOString();
152
152
  await pg.route('**/api/kit/status*', (r) => r.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok: true, app: { version: '0.442.0' }, kit: { present: true, version: '0.442.0' }, latest: '0.451.0', appBehind: true, kitBehind: true, checkDaily: true,
153
- changes: [{ v: '0.451.0', at: day(0), prs: [{ n: 1098, t: 'Claude settings', s: 'Settings has a Claude section.', k: 'new' }, { n: 1100, t: 'Hide linked worktrees', s: 'They no longer show as repositories.', k: 'fix' }] }, { v: '0.441.0', at: day(8), prs: [{ n: 1078, t: 'Ready-made dashboards', s: 'Every program has one.', k: 'new' }] }] }) }));
153
+ changes: [{ v: '0.451.0', at: day(0), prs: [{ n: 1098, t: 'Claude settings', s: 'Settings has a Claude section.', k: 'imp', h: 'Claude in Settings', d: 'Claude had no settings of its own. Now Settings has a Claude section.', top: 'Claude has its own place in Settings.' }, { n: 1100, t: 'Hide linked worktrees', s: 'They no longer show as repositories.', k: 'fix' }] }, { v: '0.441.0', at: day(8), prs: [{ n: 1078, t: 'Ready-made dashboards', s: 'Every program has one.', k: 'new' }] }] }) }));
154
154
  await pg.route('**/api/kit/check', (r) => { hits.check++; r.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok: true, latest: '0.451.0', appBehind: true, kitBehind: true, latestChanges: [] }) }); });
155
155
  await pg.route('**/api/kit/update', (r) => { hits.update++; r.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok: true }) }); });
156
156
  await pg.route('**/api/kit/upgrade-app', (r) => { hits.upgrade++; r.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok: false, error: 'stubbed: not installing' }) }); });
157
157
  await pg.keyboard.press('Escape'); await until(() => pg.evaluate(() => !document.querySelector('#evSettings:not([hidden])')));
158
158
  await menu(pg); await pg.click('#czMenu [data-cz-settings]'); await pg.waitForSelector('#evSettings:not([hidden])'); // reopen: the routes above answer the status read
159
159
  await pg.click('#evSettings [data-ev-sec="updates"]');
160
- const wk = await until(() => pg.evaluate(() => { const d = document.querySelector('#evSettings'); const h = d.querySelector('.ev-wkh'); return h ? { head: h.textContent, groups: [...d.querySelectorAll('.ev-wk > .ev-wkm .ev-wgl')].map((x) => x.textContent), t: d.querySelector('.ev-wk > .ev-wkm').textContent, arc: !!d.querySelector('[data-ev-arc][aria-expanded="false"]'), rows: d.querySelectorAll('[data-ev-wk]').length } : null; }), 20000);
160
+ const wk = await until(() => pg.evaluate(() => { const d = document.querySelector('#evSettings'); const h = d.querySelector('.ev-wkh'); return h ? { head: h.textContent, sum: [...d.querySelectorAll('.ev-wk > .ev-sum p')].map((x) => x.textContent), kit: (d.querySelector('.ev-wk .ev-rv') || {}).textContent || '', dots: [...d.querySelectorAll('.ev-wk > .ev-rel .ev-dot')].map((x) => x.title), t: d.querySelector('.ev-wk').textContent, arc: !!d.querySelector('[data-ev-arc][aria-expanded="false"]'), rows: d.querySelectorAll('[data-ev-wk]').length } : null; }), 20000);
161
161
  const first = await (async () => { await sleep(800); return hits.check; })();
162
162
  await pg.click('#evSettings [data-ev-sec="general"]'); await pg.click('#evSettings [data-ev-sec="updates"]'); await sleep(500);
163
163
  ok('W2 opening Updates asks for the latest kit by itself (one read for each workspace), and only once while Settings stays open', first >= 1 && hits.check === first, JSON.stringify(hits));
164
- ok('W2 what\'s new shows this week open: New and Fixed groups, no empty Improved group, the earlier week folded in a closed Archive', !!wk && /^This week/.test(wk.head) && wk.groups.join() === 'New,Fixed' && /Claude settings\./.test(wk.t) && /#1100/.test(wk.t) && wk.arc && wk.rows === 0, JSON.stringify(wk));
164
+ ok('W2 design E: this week opens with its summary line, the newest kit at version level, each change a story with a New or Fixed dot, no PR numbers, the earlier week folded in a closed Archive', !!wk && /^This week/.test(wk.head) && /2 changes/.test(wk.head) && wk.sum.join() === 'Claude has its own place in Settings.' && /^0\.451\.0latest/.test(wk.kit) && wk.dots.join() === 'New,Fixed' && /Claude in Settings/.test(wk.t) && /Now Settings has a Claude section/.test(wk.t) && !/#1100|#1098/.test(wk.t) && wk.arc && wk.rows === 0, JSON.stringify(wk));
165
165
  await pg.click('#evSettings [data-ev-arc]'); await pg.click('#evSettings [data-ev-wk]');
166
- ok('W2 the Archive opens to the earlier weeks, and a week opens on press', !!(await until(() => pg.evaluate(() => /Ready-made dashboards\./.test(document.querySelector('#evSettings').textContent)))));
166
+ ok('W2 the Archive opens to the earlier weeks, and a week opens on press', !!(await until(() => pg.evaluate(() => /Ready-made dashboards/.test(document.querySelector('#evSettings').textContent)))));
167
167
  await snap(pg, 'updates-1440-light');
168
168
  await pg.click('#evSettings [data-ev-upd="update"]');
169
169
  await until(() => hits.upgrade > 0, 8000);
@@ -160,7 +160,8 @@ Some features are still not designed. Home keeps each one usable as a plain card
160
160
 
161
161
  ## The other pages
162
162
 
163
- - **Board.** New initiative takes links and files, starts in Triage and asks only the questions that the material does not answer. **Later** keeps the questions as the next step. The Board also has List, By stage and a span picker. Documents are in each initiative's Documents fold and in Search. On a phone, **Metrics** and **Cancelled and archived** are in every view.
163
+ - **Board.** New initiative takes links and files, starts in Triage and asks only the questions that the material does not answer. **Later** keeps the questions as the next step. The Board also has List, By stage and a span picker. Documents are in each initiative's Documents fold and in Search. On a phone, **Metrics** and **Cancelled and archived** are in every view. On a computer, press **Discuss in chat** on an initiative's or a program's panel to open the chat about it in a panel on the Board. It is the same conversation as on Home. At 1600 px and wider the chat opens beside the panel; on a laptop it slides over it.
164
+ - **Dock a panel left or right (Board and Intelligence).** Drag a panel's header to the left or the right half of the window, and drop it when that half turns blue. With the keyboard, press **Arrange** in the panel's header, then **Dock on the left** or **Dock on the right**. Each kind of panel stays on its side after a reload. The divider resizes the panel on either side. The Live pill's list has a grab bar on top: drag it to the left or the right side, or press it to move the list to the other side.
164
165
  - **Meetings.** Replay with screen captures and kept points, Focus, a presenter view, Add material, the After view, a Day, Week and Month calendar, and Notes.
165
166
  - **Reports.** The Brief, the report states, a document reader and a live page reader.
166
167
  - **Insights.** A goal's detail with its chart and readings, targets on each tile, Library flows and a Portfolio view.
@@ -2,7 +2,7 @@
2
2
  type: Guide
3
3
  title: "Initiatives on the console: Portfolio, boards and workflows"
4
4
  description: "How to use the Initiatives tab of the console page (/): the Portfolio of boards, a board and its nine tabs, an initiative with its own workflow and stage records, and the Build and deliver setting."
5
- timestamp: 2026-11-02
5
+ timestamp: 2026-11-03
6
6
  ---
7
7
 
8
8
  # Initiatives on the console: Portfolio, boards and workflows
@@ -58,6 +58,8 @@ Each initiative has a **stage** and a **status**. The stage is the step in its o
58
58
  | Done | It reached the end of its workflow, or it was accepted or closed. |
59
59
  | Cancelled | You stopped it. It is counted as cancelled, not done. |
60
60
 
61
+ An initiative in the last stage of its workflow (for example **Closed**, or a stage in the Done category) is never Late or At risk. On the Timeline its bar ends at its own end date. It has no line to Today, no late dot and no late milestone.
62
+
61
63
  A cancelled initiative is hidden from every view. The list footer says how many are hidden, for example "1 cancelled hidden". Press it to see them, or pick Cancelled in the Status filter.
62
64
 
63
65
  Press **…** on a row to open its menu. It is the initiative's short menu: Split, Merge into, Program, Copy, The record and **Delete** (see [Home](home.md#an-initiative)). Press the stage name on the initiative's stage card for the stage picker:
@@ -67,12 +67,16 @@ Open **Updates**. PMOS asks the npm registry for the latest version at once, and
67
67
 
68
68
  ### What's new
69
69
 
70
- Under the update card, **Updates** shows what changed in the published kits. **This week** is open: it is the one you need. A week runs Monday to Sunday. Every earlier week, last week included, is folded into **Archive**. Press **Archive** to list the earlier weeks (**Week of** a date), each with its counts, and press a week to read it. When there is nothing for this week yet, the newest week with changes is the open one.
71
-
72
- - Each week has up to three groups: **New** (a feature), **Improved** (a faster or better thing that already existed) and **Fixed** (a problem that is gone). A group with nothing in it is not shown, and a week with nothing to show is not listed.
73
- - Each line is a bold name and one sentence, copied from the pull request that made the change, with its number at the end. When the name and the sentence say the same thing, the line shows it once. Work that you cannot see in PMOS (notes, tests, run logs) is left out, and so are notes for the people who build PMOS, such as "[skip publish]" or "(batch 25 item 11)".
70
+ Under the update card, **Updates** shows what changed in the published kits. **This week** is open: it is the one you need. A week runs Monday to Sunday. The heading gives the dates and the number of changes. When there is nothing for this week yet, the newest week with changes is the open one.
71
+
72
+ - **Summary.** Up to three lines on top say the most important changes of the week. Claude writes them when it makes the change.
73
+ - **The newest kit** comes next, with its version and **latest** when it is the newest published kit.
74
+ - **Earlier this week** lists the other changes of the week as one list, the most important ones first. The list shows seven changes; press **N more** to show the rest.
75
+ - Each change is a short story: a plain name, then what was missing and what is different now. Claude writes it for you. A blue dot marks **New** and an amber dot marks **Fixed**. A change with no dot is **Improved**. The most important changes always show as **New**.
76
+ - There are no pull request numbers and no kit ranges. Work that you cannot see in PMOS (notes, tests, run logs) is left out.
77
+ - **Archive** holds every earlier week, last week included. Press **Archive** to list them, then press a week to read it.
74
78
  - A week shows **Ready to install** when one of its kits is newer than the version you run.
75
- - A kit published before PMOS kept the kind of each change has no kind. Its title decides: a word such as "fix" or "no longer" puts it under **Fixed**, and anything else under **Improved**.
79
+ - A change published before Claude wrote stories shows the pull request's title and its sentence instead.
76
80
  - **What's new** is not a section of its own any more. The button on the update card in **General** opens **Updates**.
77
81
 
78
82
  ## Claude: the profile and the API key
@@ -275,10 +275,11 @@ function rowOf(r, wf, today) {
275
275
  ['Progress', pctOf(r.progress) != null ? pctOf(r.progress) + '%' : ''], ['Starts', r.start ? shortDate(r.start) : ''], ['Ends', r.end ? shortDate(r.end) : ''],
276
276
  ['Last changed', r.updated ? shortDate(String(r.updated).slice(0, 10)) : ''], ['Possible duplicate of', r.dupOf], ['Warnings', (r.warnings || []).map((w) => (typeof w === 'string' ? w : w && (w.text || w.message || w.id)) || '').filter(Boolean).join('; ')],
277
277
  ].filter((x) => x[1]);
278
- const over = r.end && today && days(r.end, today) > 0 && r.status !== 'done';
278
+ // a closed initiative (its workflow's terminal stage, lib/routes-boards.js closedOf) is never over: no line to Today, no days over (batch 26 item 5)
279
+ const over = r.end && today && days(r.end, today) > 0 && r.status !== 'done' && !r.closed;
279
280
  return {
280
281
  key: 'board:' + r.id, id: r.id, title: clip(r.title, 140), sub: [r.owner || 'No owner', r.lane].filter(Boolean).join(' · '),
281
- status: r.status || null, statusLabel: r.status && r.status !== 'on track' ? (STATUS_LABEL[r.status] || r.status) : null, bad, late: r.status === 'late',
282
+ status: r.status || null, statusLabel: r.status && r.status !== 'on track' ? (STATUS_LABEL[r.status] || r.status) : null, bad, late: r.status === 'late', closed: !!r.closed,
282
283
  stage: r.stage || null, cat: r.cat || null, stageNote: r.stageSource === 'derived' ? 'derived' : null,
283
284
  seg: names.map((n, j) => (j < cur ? 'p' : j === cur ? 'n' : '')), progress: pctOf(r.progress),
284
285
  finish: r.end ? shortDate(r.end) : 'Not planned', over: !!over, start: r.start || null, end: r.end || null,
@@ -125,7 +125,8 @@ const npxArgs = (src, version, rest) => (src && src.kind === 'local'
125
125
  /** One changelog entry as the page may show it, or null: a version, a time, and PRs with a number, a summary, from
126
126
  * kit-changelog format 2 a title, and from format 3 a kind (k: new, imp, fix or x), which decides New, Improved or Fixed.
127
127
  * Kits published before kit-changelog dropped "[skip publish]" kept it in the text and lost the PR number to an inline
128
- * "(#123)"; both are repaired here, so every line ends in one muted number. */
128
+ * "(#123)"; both are repaired here, so every line ends in one muted number. From format 4 a PR may carry Claude's story for the
129
+ * PM (h: a plain title, d: what was missing and what is different now, top: a summary line that marks a highlight). */
129
130
  const KINDS = new Set(['new', 'imp', 'fix', 'x']);
130
131
  const SKIP_TAG = /\s*\[(?:skip|no)[ -][a-z]+\]/gi;
131
132
  const PR_REF = /\s*\(#(\d+)\)/;
@@ -135,7 +136,9 @@ function cleanPr(p) {
135
136
  const t = typeof p.t === 'string' ? p.t : '', ref = PR_REF.exec(t) || PR_REF.exec(p.s);
136
137
  const n = Number.isInteger(p.n) ? p.n : ref ? Number(ref[1]) : null;
137
138
  const tt = tidy(t).slice(0, 200), s = tidy(p.s).slice(0, 400);
138
- return { n, ...(tt ? { t: tt } : {}), s: s || tt, ...(KINDS.has(p.k) ? { k: p.k } : {}) };
139
+ const str = (x, max) => (typeof x === 'string' ? x.replace(/\s+/g, ' ').trim().slice(0, max) : '');
140
+ const h = str(p.h, 120), d = h ? str(p.d, 400) : '', top = h ? str(p.top, 200) : '';
141
+ return { n, ...(tt ? { t: tt } : {}), s: s || tt, ...(KINDS.has(p.k) ? { k: p.k } : {}), ...(h ? { h } : {}), ...(d ? { d } : {}), ...(top ? { top } : {}) };
139
142
  }
140
143
  function cleanEntry(e) {
141
144
  if (!e || !validVersion(e.v)) return null;
@@ -477,6 +477,16 @@ const gapFields = (spans) => ({ gaps: holdSpans.planGaps(spans, 14), stageSpans:
477
477
  const minDay = (a, b) => (!a ? b || null : !b ? a : a < b ? a : b);
478
478
  const maxDay = (a, b) => (!a ? b || null : !b ? a : a > b ? a : b);
479
479
 
480
+ /** batch 26 item 5: whether an initiative stands in its workflow's terminal stage, read from the workflow, never from a list of names. On a
481
+ * D99 workflow: the stage is in the Done category, or it is the workflow's last stage. On a board's own column workflow: its last column. */
482
+ function closedOf(row, wx) {
483
+ if (wx.own) {
484
+ const st = wx.stages.find((x) => x.key === wx.stage);
485
+ return !!st && (st.cat === 'done' || (st.of > 1 && st.n === st.of));
486
+ }
487
+ const wf = Array.isArray(row.workflow) ? row.workflow : [];
488
+ return wf.length > 1 && wf.findIndex((x) => x.name === row.stage) === wf.length - 1;
489
+ }
480
490
  function boardRow(board, base, folded, lib) {
481
491
  const s = B.stageOf(board, base.id, base.reading, folded);
482
492
  const ts = taskSpanOf(folded, base.id); // the bar covers the breakdown items and the tasks
@@ -496,6 +506,10 @@ function boardRow(board, base, folded, lib) {
496
506
  const wx = workflowRowOf(board, base, folded, lib || libOf(folded), s.stage);
497
507
  Object.assign(row, wfRowFields(wx));
498
508
  if (wx.own) { row.stage = (wx.stages.find((x) => x.key === wx.stage) || {}).name || row.stage; row.cat = null; row.stageSource = 'move'; }
509
+ // batch 26 item 5: an initiative in its workflow's terminal stage is finished, so it is never late or at risk. Every surface (the Board's
510
+ // tone, dot, late line and edge tabs, Home, Kanban, the program rollups) reads the status this leaves and row.closed.
511
+ row.closed = closedOf(row, wx);
512
+ if (row.closed && (row.status === 'late' || row.status === 'at risk')) row.status = 'on track';
499
513
  const w = [];
500
514
  if (s.source === 'move') {
501
515
  const last = [...folded.moves].reverse().find((m) => m.board === board.id && m.id === base.id);
@@ -1822,4 +1836,4 @@ function meetingBoards(ctx) {
1822
1836
  return { list, detail };
1823
1837
  }
1824
1838
 
1825
- module.exports = { prefixes: ['/api/boards'], handle, tasksOfIni, postFrame, needRows, itemsOf, readLedger, foldOf, epicsOf, rowsOfBoard, invalidateRows: (w) => { rowsLib.invalidate(w.root); rowCache.delete(w.root); }, move, switchWorkflow, taskState, taskAdd, taskEdit, writeAs, taskDelete, taskRestore, initiativeWorkflow, seedInitiative, stageTasks, boardIndex, appendScanAsk, scanDeclineSignal, createAndPlace, startOnDefault, ledgerFile, append, frameFor, boardIdOf, meetingBoards, boardsOf, baseRows, boardRow, taskSpanOf, workflowRowOf, foldOf, portfolio, SELF_REFUSAL, NO_STATE };
1839
+ module.exports = { prefixes: ['/api/boards'], handle, tasksOfIni, postFrame, needRows, itemsOf, readLedger, foldOf, epicsOf, rowsOfBoard, invalidateRows: (w) => { rowsLib.invalidate(w.root); rowCache.delete(w.root); }, move, switchWorkflow, taskState, taskAdd, taskEdit, writeAs, taskDelete, taskRestore, initiativeWorkflow, seedInitiative, stageTasks, boardIndex, appendScanAsk, scanDeclineSignal, createAndPlace, startOnDefault, ledgerFile, append, frameFor, boardIdOf, meetingBoards, boardsOf, baseRows, boardRow, taskSpanOf, workflowRowOf, closedOf, foldOf, portfolio, SELF_REFUSAL, NO_STATE };
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.510.0",
3
+ "version": "0.511.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.510.0",
9
+ "version": "0.511.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@anthropic-ai/sdk": "^1.3.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.510.0",
3
+ "version": "0.511.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -20,6 +20,9 @@
20
20
  * over at 1440), the active edge, a plain click replaces the Viewer used last P5 a drop on a Viewer replaces it P6 the divider to the
21
21
  * left edge: full screen; Split brings the page back; to the right edge: the Viewer closes P7 a reload reopens nothing; the share holds;
22
22
  * Home resets it P8 1920: a drop at the far edge opens beside, three panels P9 Intelligence: split, ⌘-click P10 a phone as today
23
+ * P11 dock a Viewer on the left by its header, the divider on that side, kept after a reload, ⧉'s Dock on the right by the keyboard
24
+ * P12 Discuss in chat opens Home's chat in a panel on the Board (slides over at 1440, beside at 1920), with the same conversation key
25
+ * P13 the Live pill's list docks on the right by its grab bar, and a press moves it back (batch 26 item 4)
23
26
  * Screenshots go to PMOS_SHOTS when it is set. Every wait polls the page. */
24
27
  const fs = require('fs');
25
28
  const path = require('path');
@@ -278,7 +281,7 @@ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
278
281
  await Bd.page.click('#czViewer [data-cz-vw="place"]');
279
282
  await until(() => Bd.page.$('.cz-pn-menu'));
280
283
  const m3 = await Bd.page.evaluate(() => [...document.querySelectorAll('.cz-pn-menu button')].map((b) => b.getAttribute('aria-label') + (b.getAttribute('aria-checked') === 'true' ? '*' : '')).join('|'));
281
- ok('P3 ⧉ opens Full screen, Split, Slide over; Split is checked', m3 === 'Full screen|Split*|Slide over', m3);
284
+ ok('P3 ⧉ opens Full screen, Split, Slide over, then Dock on the left and Dock on the right (right checked); Split is checked', m3 === 'Full screen|Split*|Slide over|Dock on the left|Dock on the right*', m3);
282
285
  await Bd.page.click('.cz-pn-menu [data-cz-pn-place="over"]');
283
286
  const b3 = await until(() => pl(Bd.page).then((l) => (P(l, 'v0').mode === 'over' ? l : null)), 3000);
284
287
  ok('P3 Slide over: the Viewer floats 560 px at the right with a grab bar; the page is whole again, with no divider', !!b3 && P(b3, 'v0').box.w === 560 && P(b3, 'page').box.w > 1300 && b3.divs.length === 0 && !b3.narrow
@@ -347,6 +350,70 @@ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
347
350
  await shot(Bw.page, 'p8-board-three-1920');
348
351
  ok('0 no page error on the Board at 1920', Bw.errors.length === 0, Bw.errors.join(' | '));
349
352
  await Bw.close();
353
+ /* ── batch 26 item 4: dock a panel on the left or the right; the chat as a panel on the Board ── */
354
+ const ctxD = await browser.newContext({ viewport: { width: 1440, height: 900 } });
355
+ const threads = [];
356
+ await ctxD.route(/\/api\/briefing-premium\/thread\?/, (r) => { threads.push(new URL(r.request().url()).searchParams.get('key')); return r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, session: null, history: { messages: [] } }) }); });
357
+ const Dd = await open({ ctx: ctxD, at: '/premium-board.html', ready: boardReady });
358
+ const KD = await keys(Dd.page);
359
+ await Dd.page.click(nm(KD[0]));
360
+ await until(() => pl(Dd.page).then((l) => (P(l, 'v0') && P(l, 'v0').box.shown && l.divs.length === 1 ? l : null)));
361
+ let z11 = null;
362
+ await drag(Dd.page, '#czViewer .cz-vw-tile', 200, 400, { hold: async () => { z11 = await Dd.page.evaluate(() => { const z = document.querySelector('.cz-pn-drop:not([hidden])'); if (!z) return null; const r = z.getBoundingClientRect(); return { side: z.dataset.side, l: Math.round(r.left), w: Math.round(r.width) }; }); await shot(Dd.page, 'p11-dock-drag-1440'); } });
363
+ const d11 = await until(() => pl(Dd.page).then((l) => (P(l, 'v0').box.r < P(l, 'page').box.l ? l : null)), 3000);
364
+ const side11 = await Dd.page.evaluate(() => { try { return JSON.parse(localStorage.getItem('cz-pn-side')); } catch { return null; } });
365
+ ok('P11 a drag of the Viewer\'s header over the left half turns that half blue', !!z11 && z11.side === 'left' && z11.l <= 20 && z11.w > 600, JSON.stringify(z11));
366
+ ok('P11 the drop docks it on the left: the Viewer, the divider, then the page; the title is not renamed', !!d11 && d11.divs.length === 1 && P(d11, 'v0').box.r <= d11.divs[0].l + 12 && d11.divs[0].r <= P(d11, 'page').box.l + 12 && !(await Dd.page.$('#czViewer [data-cz-vw-ren]')), JSON.stringify(d11 || await pl(Dd.page)));
367
+ ok('P11 the side is kept for its kind (cz-pn-side)', !!side11 && Object.values(side11).includes('left'), JSON.stringify(side11));
368
+ await shot(Dd.page, 'p11-docked-left-1440');
369
+ { const d = await divAt(Dd.page); const w0 = P(await pl(Dd.page), 'v0').box.w; await drag(Dd.page, d, d.x + 80, d.y); const w1 = await until(() => pl(Dd.page).then((l) => (P(l, 'v0').box.w > w0 + 40 ? P(l, 'v0').box.w : null)), 3000); ok('P11 the divider resizes it on the left: a drag to the right widens the Viewer', !!w1, JSON.stringify([w0, w1])); }
370
+ await Dd.page.reload();
371
+ await until(() => Dd.page.evaluate(boardReady), 20000);
372
+ await Dd.page.click(nm(KD[1]));
373
+ const d11b = await until(() => pl(Dd.page).then((l) => (P(l, 'v0') && P(l, 'v0').box.shown && l.divs.length === 1 ? l : null)), 4000);
374
+ ok('P11 after a reload an initiative opens on the left again', !!d11b && P(d11b, 'v0').box.r < P(d11b, 'page').box.l, JSON.stringify(d11b));
375
+ await Dd.page.click('#czViewer [data-cz-vw="place"]');
376
+ await until(() => Dd.page.$('.cz-pn-menu'));
377
+ await Dd.page.focus('.cz-pn-menu [data-cz-pn-dock="right"]'); await Dd.page.keyboard.press('Enter');
378
+ const d11c = await until(() => pl(Dd.page).then((l) => (P(l, 'v0').box.l > P(l, 'page').box.r ? l : null)), 3000);
379
+ ok('P11 ⧉ › Dock on the right (an icon with its aria-label, by the keyboard) docks it back on the right', !!d11c && !(await Dd.page.evaluate(() => localStorage.getItem('cz-pn-side'))), JSON.stringify(d11c));
380
+ // the chat
381
+ const dh = await Dd.page.evaluate(() => [...document.querySelectorAll('#czViewer .cz-vw-hd [data-cz-vw]')].map((b) => b.dataset.czVw));
382
+ ok('P12 the initiative panel has Discuss in chat', dh.includes('discuss'), JSON.stringify(dh));
383
+ const url0 = Dd.page.url();
384
+ await Dd.page.click('#czViewer [data-cz-vw="discuss"]');
385
+ const c12 = await until(() => Dd.page.evaluate(() => { const v = [...document.querySelectorAll('.cz-vw')].find((x) => !x.hidden && x.querySelector('.pc-chat')); if (!v) return null; const r = v.getBoundingClientRect(); return { id: v.dataset.czPanel, w: Math.round(r.width), log: !!v.querySelector('.hm-log'), box: !!v.querySelector('.hm-foot textarea'), title: v.querySelector('.cz-vw-title').textContent }; }), 6000);
386
+ const l12 = await pl(Dd.page);
387
+ ok('P12 Discuss in chat opens the chat in a panel on the Board (no page change): the log and the box', !!c12 && c12.log && c12.box && Dd.page.url() === url0, JSON.stringify([c12, Dd.page.url()]));
388
+ ok('P12 at 1440 the chat slides over; the initiative split under it stays', !!c12 && P(l12, c12.id).mode === 'over' && P(l12, 'v0').mode === 'split', JSON.stringify(l12));
389
+ const iniId = await Dd.page.evaluate(() => window.czViewer.current() && window.czViewer.current().id);
390
+ ok('P12 it is Home\'s conversation about the initiative (key init:<id>)', threads.some((k) => /^init:/.test(k || '')), JSON.stringify([threads, iniId]));
391
+ await shot(Dd.page, 'p12-chat-over-1440');
392
+ ok('0 no page error while docking and with the chat on the Board', Dd.errors.length === 0, Dd.errors.join(' | '));
393
+ // the Live pill's list: its grab bar docks it on the right; a press moves it back
394
+ await Dd.page.evaluate(() => window.czPanels.putAway());
395
+ await Dd.page.click('#czLive');
396
+ await until(() => Dd.page.$('#czLiveList:not([hidden]) .lv-side'), 3000);
397
+ const g13 = await Dd.page.evaluate(() => { const r = document.querySelector('#czLiveList .lv-side').getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2, lab: document.querySelector('#czLiveList .lv-side').getAttribute('aria-label') }; });
398
+ await drag(Dd.page, g13, 1300, g13.y);
399
+ const t13 = await until(() => Dd.page.evaluate(() => { const t = document.getElementById('czLiveList'); const r = t.getBoundingClientRect(); return t.classList.contains('lv-r') ? { r: Math.round(r.right), lab: t.querySelector('.lv-side').getAttribute('aria-label'), kept: localStorage.getItem('cz-live-side') } : null; }), 3000);
400
+ ok('P13 the Live pill\'s list docks on the right by its grab bar (an icon with an aria-label), and the side is kept', !!t13 && Math.abs(t13.r - (1440 - 16)) <= 2 && t13.kept === 'right' && g13.lab === 'Move this list to the right side', JSON.stringify([g13, t13]));
401
+ await shot(Dd.page, 'p13-live-right-1440');
402
+ await Dd.page.focus('#czLiveList .lv-side'); await Dd.page.keyboard.press('Enter');
403
+ ok('P13 Enter on the grab bar moves it back to the left', !!(await until(() => Dd.page.evaluate(() => !document.getElementById('czLiveList').classList.contains('lv-r') && !localStorage.getItem('cz-live-side')), 2000)));
404
+ await Dd.close();
405
+ // 1920: the chat opens beside the initiative panel
406
+ const ctxW = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
407
+ await ctxW.route(/\/api\/briefing-premium\/thread\?/, (r) => r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, session: null, history: { messages: [] } }) }));
408
+ const Dw = await open({ ctx: ctxW, at: '/premium-board.html', ready: boardReady });
409
+ await Dw.page.click(nm(KD[0]));
410
+ await until(() => pl(Dw.page).then((l) => (P(l, 'v0') && P(l, 'v0').box.shown ? l : null)));
411
+ await Dw.page.click('#czViewer [data-cz-vw="discuss"]');
412
+ const c12w = await until(() => pl(Dw.page).then((l) => (P(l, 'v1') && P(l, 'v1').box.shown && l.divs.length === 2 ? l : null)), 6000);
413
+ ok('P12 at 1920 the chat opens beside the initiative panel: the page, the panel, the chat, two dividers', !!c12w && P(c12w, 'page').box.r < P(c12w, 'v0').box.l && P(c12w, 'v0').box.r < P(c12w, 'v1').box.l && P(c12w, 'v1').mode === 'split', JSON.stringify(c12w || await pl(Dw.page)));
414
+ await shot(Dw.page, 'p12-chat-beside-1920');
415
+ ok('0 no page error with the chat at 1920', Dw.errors.length === 0, Dw.errors.join(' | '));
416
+ await Dw.close();
350
417
  // Intelligence: a goal row opens a split; ⌘-click opens beside
351
418
  const In = await open({ at: '/premium-intelligence.html', ready: () => !!window.czPanels && document.querySelectorAll('#goalTbl .ins-row[data-goal]').length > 1 });
352
419
  ok('P9 Intelligence draws its goal rows and window.czPanels', !!In.up);
@@ -70,7 +70,8 @@ export function nextMs(r, now = new Date()) {
70
70
  if (!x && es && !(r.milestones || []).some((m) => day(m.date)) && day(r.end) && k < 8 && !(r.track && r.track.prog && r.track.prog.total > 0 && r.track.prog.done >= r.track.prog.total)) x = { name: es, d: day(r.end) };
71
71
  if (!x) return null;
72
72
  const over = Math.round((t - x.d) / 864e5);
73
- return { ...x, late: over > 0, over: Math.max(0, over) };
73
+ // a closed initiative (batch 26 item 5) is never late: its next milestone keeps its date and drops the days late
74
+ return r.closed ? { ...x, late: false, over: 0 } : { ...x, late: over > 0, over: Math.max(0, over) };
74
75
  }
75
76
  const STATUS = { late: 'Late', risk: 'At risk', q: 'On hold' };
76
77
  const nextText = (r) => { const t = r.track || {}; if (typeof t.next === 'string') return t.next; if (t.next && t.next.text) return t.next.text; const l = (t.line || [])[0]; return l ? l.text || '' : ''; };
@@ -76,7 +76,7 @@ export function entriesOf(o) {
76
76
  const slip = r.plan && r.plan.slip > 0 ? r.plan.slip : 0;
77
77
  const atEnd = (d) => !!(e && +d === +e);
78
78
  const one = (id, name, d, done, isEnd) => {
79
- const late = !done && !h && (d < now || (isEnd && r.over && now > e));
79
+ const late = !done && !h && !r.closed && (d < now || (isEnd && r.over && now > e)); // never on a closed initiative (batch 26 item 5)
80
80
  const risk = !done && !late && !h && isEnd && (r.status === 'at risk' || slip > 0);
81
81
  const planned = isEnd && slip ? new Date(d.getFullYear(), d.getMonth(), d.getDate() - slip) : null;
82
82
  out.push({ ...base, id, name, date: d, done: !!done, late, risk, fc: false, planned, slip: isEnd ? slip : 0 });
@@ -103,9 +103,10 @@ export function windowOf(span, now, phone, sprint, dates) {
103
103
  const pctOf = (W) => (d) => Math.max(0, Math.min(100, ((d - W.from) / (W.to - W.from)) * 100));
104
104
  const P = (x) => x.toFixed(2) + '%';
105
105
  const doneOf = (r) => { const p = r.track && r.track.prog; return p ? Math.max(0, Math.min(100, (p.done / Math.max(1, p.total)) * 100)) : 0; };
106
- /** A milestone task past its due date and not done (feedback batch 11 item 3): its ◇ turns orange, and so does the row's dot. */
107
- const msLate = (m, now) => !m.done && day(m.date) && day(m.date) < new Date(now.getFullYear(), now.getMonth(), now.getDate());
108
- export const anyMsLate = (r, now) => !!(now && (r.milestones || []).some((m) => msLate(m, now)));
106
+ /** A milestone task past its due date and not done (feedback batch 11 item 3): its ◇ turns orange, and so does the row's dot. Never on a closed
107
+ * initiative (its workflow's terminal stage, r.closed from lib/routes-boards.js closedOf; batch 26 item 5): a closed initiative is not late. */
108
+ const msLate = (m, now, r) => !(r && r.closed) && !m.done && day(m.date) && day(m.date) < new Date(now.getFullYear(), now.getMonth(), now.getDate());
109
+ export const anyMsLate = (r, now) => !!(now && (r.milestones || []).some((m) => msLate(m, now, r)));
109
110
  /** The forecast range a row draws (FcBoard): { tone: 'risk' | 'miss' | 'held', low, high } past its promised date, else null. */
110
111
  export function fcRange(r) {
111
112
  const F = r && r.forecast, e = r && day(r.end);
@@ -119,8 +120,9 @@ const toneOf = (r, now) => (r.late || anyMsLate(r, now) ? 'late' : r.status ===
119
120
  export const offTone = (r, now) => { const t = toneOf(r, now); return t === 'late' || t === 'risk' ? t : r.status === 'on hold' ? 'q' : null; };
120
121
  const dotHtml = (r, now) => { const t = offTone(r, now); return t ? `<i class="cz-dot ${t}${t === 'q' ? '' : ' m-warn'}" aria-hidden="true"></i>` : ''; };
121
122
  const dated = (r) => day(r.start) || day(r.end) || (r.track && day(r.track.forecast)) || (r.draft && day(r.draft.start));
122
- /** Done work leaves no edge tab (TsCGrill 5): the initiative is past Deliver, or every part it counts is done. */
123
- const isDone = (r) => !!(r.track && (r.track.k >= 6 || (r.track.prog && r.track.prog.total > 0 && r.track.prog.done >= r.track.prog.total)));
123
+ /** Done work leaves no edge tab (TsCGrill 5): the initiative is closed (its workflow's terminal stage, batch 26 item 5), past Deliver, or every
124
+ * part it counts is done. */
125
+ const isDone = (r) => !!r.closed || !!(r.track && (r.track.k >= 6 || (r.track.prog && r.track.prog.total > 0 && r.track.prog.done >= r.track.prog.total)));
124
126
  /** A row's holds as spans on the bar: [{ a, b, kind, h, i }]. kind: past (resumed), open (the return date ahead), over (the return date passed:
125
127
  * dotted on to today), none (no return date: dotted to today). Only the ones that touch the plan (XtiAHard). */
126
128
  export function holdSpans(r, now) {
@@ -231,7 +233,7 @@ function qBar(r, X, now, phone, fmt, pxPct, ms = {}) {
231
233
  if (clash) hover.push(clash);
232
234
  const sw = slipWords(r, e); if (sw) hover.push(sw);
233
235
  // the committed end is the ◇ (PgTimeline); a milestone task in the stage it stands for IS that ◇ (say it once, batch 11 item 3): its name and its done
234
- const isLate = !!(over || clash || r.late || (et && msLate(et, now)));
236
+ const isLate = !!(over || clash || r.late || (et && msLate(et, now, r)));
235
237
  // the ◇ and its label carry the milestone's own date; the initiative's end, when it differs, is the hover (batch 14 item 1)
236
238
  const ed = et ? day(et.date) : e; if (+ed !== +e) hover.push('Initiative ends ' + dy(e, now));
237
239
  const name = landsOf(r, et), eTitle = name + ' · ' + md(ed) + (et && et.stage ? ' · ◆ ' + et.stage : '') + (et && et.done ? ' · done' : isLate ? ' · late' : '');
@@ -316,6 +318,9 @@ function gapsHtml(r, X, now, phone, fmt, pxPct, s, e, tone, note = '', lands = n
316
318
  if (x && x.kind === 'none') faded = true;
317
319
  }
318
320
  if (at < e) pieces.push([at, e, faded]);
321
+ // a plan of one day (its only dated work starts and ends the same day) still draws its bar: before, it drew no piece, and a late one showed
322
+ // only its line to Today (batch 26 item 5, CRA to Vite)
323
+ if (!pieces.length && +s === +e) pieces.push([s, e, false]);
319
324
  const days = (p) => Math.max(0, (p[1] - p[0]) / 86400000), total = pieces.reduce((n, p) => n + days(p), 0);
320
325
  let left = (doneOf(r) / 100) * total;
321
326
  // now is blue (batch 19 item 5, StsA8Board): from where the done part ends to the end of this week, while the work is under way
@@ -327,7 +332,7 @@ function gapsHtml(r, X, now, phone, fmt, pxPct, s, e, tone, note = '', lands = n
327
332
  let html = pieces.map((p, i) => {
328
333
  const d = days(p), f = d ? Math.max(0, Math.min(1, left / d)) : 0; left -= d;
329
334
  const xl = i > 0 || p[0] > s ? ' xl' : '', xr = i < pieces.length - 1 || p[1] < e ? ' xr' : '';
330
- return `<span class="tg-bar${xl}${xr}${p[2] ? ' fd' : ''}" data-key="${esc(r.key)}" style="left:${P(X(p[0]))};width:${P(Math.max(0.15, X(p[1]) - X(p[0])))}" title="${esc('PMOS plan ' + md(s) + ' to ' + md(e) + hold)}"><b style="width:${(f * 100).toFixed(1)}%"></b>${nowIn(p)}</span>`;
335
+ return `<span class="tg-bar${xl}${xr}${p[2] ? ' fd' : ''}" data-key="${esc(r.key)}" style="left:${P(X(p[0]))};width:${P(Math.max(+p[1] === +p[0] && pxPct ? 20 / pxPct : 0.15, X(p[1]) - X(p[0])))}" title="${esc('PMOS plan ' + md(s) + ' to ' + md(e) + hold)}"><b style="width:${(f * 100).toFixed(1)}%"></b>${nowIn(p)}</span>`;
331
336
  }).join('');
332
337
  // each plain gap: the hairline, one press (a 24 px area) with its words; a phone draws the line alone and its row sheet says it
333
338
  plain.forEach(([a, b], gi) => {
@@ -382,7 +387,7 @@ function taskMarks(r, X, now, phone, pxPct, ms) {
382
387
  const M = (r.milestones || []).filter((m) => day(m.date));
383
388
  if (!M.length) return { html: '', end: null };
384
389
  const end = endTask(r, M, ms.reach || 0), show = new Set(shownMs(r, ms.mode || 'pinned').map((m) => m.id));
385
- const L = M.filter((m) => (!end || m.id !== end.id) && show.has(m.id)).map((m) => ({ ...m, late: msLate(m, now), stage: msStage(r, m) }))
390
+ const L = M.filter((m) => (!end || m.id !== end.id) && show.has(m.id)).map((m) => ({ ...m, late: msLate(m, now, r), stage: msStage(r, m) }))
386
391
  .filter((m) => { const x = X(day(m.date)); return x > 0 && x < 100; });
387
392
  const C = mergeClose(L, Math.max(1, ms.reach || 7));
388
393
  const html = C.map((c) => {
@@ -405,9 +410,9 @@ export function laneMilestones(rows, now, reach = 0) {
405
410
  const out = [];
406
411
  for (const r of rows || []) {
407
412
  const M = (r.milestones || []).filter((m) => day(m.date)), s = day(r.start), e = day(r.end), et = endTask(r, M, reach);
408
- for (const m of M) if (!et || m.id !== et.id) out.push({ id: m.id, name: m.name, date: m.date, done: !!m.done, late: msLate(m, now), row: r });
413
+ for (const m of M) if (!et || m.id !== et.id) out.push({ id: m.id, name: m.name, date: m.date, done: !!m.done, late: msLate(m, now, r), row: r });
409
414
  if (!s || !e || (!et && !endStage(r))) continue;
410
- const ob = openBack(r, now), late = (r.over && now > e) || (ob && ob > e) || (et && msLate(et, now));
415
+ const ob = openBack(r, now), late = (r.over && now > e) || (ob && ob > e) || (et && msLate(et, now, r));
411
416
  out.push({ id: et ? et.id : '', name: et ? et.name : endStage(r), date: et ? String(et.date).slice(0, 10) : iso(e), done: !!(et && et.done), late: !!late, row: r });
412
417
  }
413
418
  return out.sort((a, b) => day(a.date) - day(b.date));
@@ -222,23 +222,37 @@
222
222
  .ev-st .ev-sw[aria-checked="true"] { background: var(--cz-bluebg); color: var(--cz-blueink); }
223
223
  .ev-st .ev-add { display: flex; gap: 8px; margin-top: 8px; }
224
224
  .ev-st [role="status"] { min-height: 1.2em; margin: 0; font-size: var(--fs-2); color: var(--cz-ink3); }
225
- /* Updates › what's new: this week open, the earlier weeks folded into an Archive */
225
+ /* Updates › what's new (design E, UpE): summary lines, the newest kit at version level, "Earlier this week" as one flat list of
226
+ stories, the earlier weeks folded into an Archive. A blue dot is New, an amber dot is Fixed, no dot is Improved. */
226
227
  .ev-st .ev-wk { display: flex; flex-direction: column; gap: 14px; margin-top: 10px; padding-top: 14px; border-top: 1px solid var(--cz-hair); }
227
228
  .ev-st .ev-wkh { display: flex; align-items: baseline; gap: 4px 10px; flex-wrap: wrap; }
228
- .ev-st .ev-wkh b { font-size: var(--fs-4); font-weight: 800; }
229
- .ev-st .ev-wkm { display: flex; flex-direction: column; gap: 16px; min-width: 0; }
230
- .ev-st .ev-wg { display: flex; flex-direction: column; gap: 6px; }
231
- .ev-st .ev-wgl { display: flex; align-items: center; gap: 8px; font-size: var(--fs-1); font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--cz-ink2); }
232
- .ev-st .ev-wi { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
233
- .ev-st .ev-wi li { font-size: var(--fs-3); line-height: 1.5; color: var(--cz-ink2); max-width: 68ch; overflow-wrap: anywhere; }
234
- .ev-st .ev-wi li b { color: var(--cz-ink); font-weight: 700; }
235
- .ev-st .ev-pr { font-size: var(--fs-1); font-variant-numeric: tabular-nums; }
229
+ .ev-st .ev-wkh b { font-size: var(--fs-5); font-weight: 800; }
230
+ .ev-st .ev-sum { display: flex; flex-direction: column; gap: 8px; padding: 14px 16px; border-radius: 16px; background: var(--cz-bluebg); }
231
+ .ev-st .ev-sum p { display: flex; gap: 10px; margin: 0; font-size: var(--fs-4); line-height: 1.5; color: var(--cz-ink); max-width: 72ch; }
232
+ .ev-st .ev-sum svg { flex: none; margin-top: 5px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linejoin: round; color: var(--cz-blueink); }
233
+ .ev-st .ev-leg { display: flex; flex-wrap: wrap; gap: 6px 14px; font-size: var(--fs-2); color: var(--cz-ink3); }
234
+ .ev-st .ev-leg span { display: inline-flex; align-items: center; gap: 6px; }
235
+ .ev-st .ev-rel { display: grid; grid-template-columns: 84px minmax(0, 1fr); gap: 0 14px; }
236
+ .ev-st .ev-rv { display: flex; flex-direction: column; padding-top: 6px; color: var(--cz-blueink); font-variant-numeric: tabular-nums; }
237
+ .ev-st .ev-rv b { font-size: var(--fs-4); font-weight: 800; }
238
+ .ev-st .ev-rv span { font-weight: 600; }
239
+ .ev-st .ev-sl { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
240
+ .ev-st .ev-sy { display: flex; gap: 10px; padding: 6px 0; font-size: var(--fs-3); line-height: 1.5; color: var(--cz-ink2); max-width: 72ch; overflow-wrap: anywhere; }
241
+ .ev-st .ev-sy b { color: var(--cz-ink); font-weight: 700; }
242
+ .ev-st .ev-sx { flex: 1; min-width: 0; }
243
+ .ev-st .ev-dc { width: 7px; flex: none; display: inline-flex; justify-content: center; padding-top: 8px; }
244
+ .ev-st .ev-grp { display: flex; flex-direction: column; border-top: 1px solid var(--cz-hair); }
245
+ .ev-st .ev-gh { display: flex; align-items: center; gap: 10px; min-height: 44px; padding-top: 6px; font-size: var(--fs-4); }
246
+ .ev-st .ev-gh b { font-weight: 800; }
247
+ .ev-st .ev-more { align-self: flex-start; min-height: 44px; padding: 0 0 0 17px; border: 0; background: none; font: inherit; font-size: var(--fs-2); color: var(--cz-ink3); cursor: pointer; }
248
+ .ev-st .ev-more:hover { color: var(--cz-ink); }
249
+ .ev-st .ev-wkr > .ev-sum { margin-bottom: 4px; }
250
+ @media (max-width: 560px) { .ev-st .ev-rel { grid-template-columns: minmax(0, 1fr); } .ev-st .ev-rv { flex-direction: row; gap: 8px; align-items: baseline; padding-top: 0; } }
236
251
  .ev-st .ev-arc { display: flex; flex-direction: column; border-top: 1px solid var(--cz-hair); }
237
252
  .ev-st .ev-arcb, .ev-st .ev-wkt { display: flex; align-items: baseline; gap: 4px 10px; flex-wrap: wrap; width: 100%; min-height: 44px; padding: 10px 0; border: 0; background: none; font: inherit; color: var(--cz-ink); text-align: left; cursor: pointer; }
238
253
  .ev-st .ev-arcb svg, .ev-st .ev-wkt svg { margin-left: auto; align-self: center; fill: none; stroke: currentColor; stroke-width: 2; color: var(--cz-ink3); transition: transform .15s; }
239
254
  .ev-st .ev-arcb[aria-expanded="true"] svg, .ev-st .ev-wkt[aria-expanded="true"] svg { transform: rotate(180deg); }
240
255
  .ev-st .ev-wkr { display: flex; flex-direction: column; gap: 12px; padding-bottom: 12px; border-top: 1px solid var(--cz-hair); }
241
- .ev-st .ev-wkr .ev-wkm { padding: 0 0 4px; }
242
256
  .ev-st .ev-wkt .ev-tl { margin-right: 8px; }
243
257
  @media (prefers-reduced-motion: reduce) { .ev-st .ev-arcb svg, .ev-st .ev-wkt svg { transition: none; } }
244
258
  @media (max-width: 760px) {
@@ -56,11 +56,20 @@
56
56
 
57
57
  /* the list opens above the pill */
58
58
  @media (min-width: 761px) { .lv-tray { bottom: 120px; } }
59
+ /* its grab bar (batch 26 item 4): drag it to the left or the right side; a press moves it to the other side. A phone's sheet has none. */
60
+ .lv-rows { display: flex; flex-direction: column; }
61
+ .lv-side { align-self: center; width: 64px; height: 16px; margin: -2px 0 0; padding: 0; border: 0; border-radius: 8px; background: none; display: grid; place-items: center; cursor: grab; touch-action: none; }
62
+ .lv-side i { display: block; width: 36px; height: 4px; border-radius: 2px; background: color-mix(in srgb, var(--cz-ink3) 40%, transparent); }
63
+ .lv-side:hover i { background: var(--cz-ink3); }
64
+ .lv-side:focus-visible { outline: 2px solid var(--cz-blue); outline-offset: 1px; }
65
+ .lv-tray.lv-moving { transition: none; cursor: grabbing; opacity: .92; }
66
+ @media (min-width: 761px) { .lv-tray.lv-r { position: fixed; left: auto; right: 16px; } }
59
67
  /* a phone: the menu button is bottom right, so the pill stacks above it there, and the list is a short sheet */
60
68
  @media (max-width: 760px) {
61
69
  .cz-live { left: auto; right: 6px; }
62
70
  .lv-scrim { position: fixed; inset: 0; background: rgba(42,46,53,.18); z-index: 0; }
63
71
  .lv-tray.sheet { position: fixed; left: 0; right: 0; bottom: 0; width: auto; padding: 18px 8px 28px; border-radius: 26px 26px 0 0; border: 0; }
72
+ .lv-side { display: none; }
64
73
  .lv-tray.sheet::before { content: ''; position: absolute; top: 8px; left: 50%; width: 40px; height: 5px; margin-left: -20px; border-radius: 3px; background: var(--cz-well2); }
65
74
  }
66
75
  /* the phone keyboard is up: no pill and no menu button over the chat box */
@@ -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 */