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
@@ -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:
@@ -2,7 +2,7 @@
2
2
  type: Guide
3
3
  title: "Programs on the Board: All products, groupings and the program panel"
4
4
  description: "A program groups initiatives across products. How it shows on the Board (stage track, Kanban, timeline and List), the All products mode, the By product grouping, the program panel and the side panel width."
5
- timestamp: 2026-11-01
5
+ timestamp: 2026-11-03
6
6
  ---
7
7
 
8
8
  # Programs on the Board
@@ -18,6 +18,7 @@ A cancelled or archived initiative leaves its program's views: the band, the pro
18
18
  - The product picker is the same chip and list as on Home (see [Home](home.md)). It shows the tiles of the ticked products, or **All**. When the list closes, the Board shows the ticked products, merged as on All products. The pick is the saved setting that Home and Insights share, so a reload keeps it, and a product that you untick here goes from Home too.
19
19
  - An address with `?ws=`, `?board=`, `?all=1` or `?pick=` is a one-off view: the Board shows what the address names and does not change the saved pick. A change in the list saves the pick and opens the Board without those parts.
20
20
  - A program comes first. Its band shows the stage spread of its initiatives, the next milestone and how many initiatives are ready for it. Under the band, the initiatives sit under a small heading for each product. Then each product has its own group.
21
+ - A product's group head works like a program's: press the product's tile or name to open its page in Home's Viewer. Press the chevron to fold the group. This works in every view: the stage track, Kanban, the timeline and List. On a shared board, the product's name is plain text.
21
22
  - To group by product only, open **Filter** and choose **By product**. A program member then shows a small layers icon and stays under its product in every view, the timeline included. A chip shows while By product is on.
22
23
  - **Kanban** shows programs as swimlanes, then products. Press a column header to fold the column to a strip with its count. An empty column starts folded. The Board remembers each fold.
23
24
  - The **timeline** draws a program row that brackets its initiatives' bars, on every span. Program milestones are diamonds. Milestones that are close together merge into one diamond with a count.
@@ -2,7 +2,7 @@
2
2
  type: Guide
3
3
  title: "Settings in console3: one home for each setting"
4
4
  description: "How to open Settings in console3, what each section holds, what the scope tags and the environment notes mean, and where the settings are kept."
5
- timestamp: 2026-11-01
5
+ timestamp: 2026-11-03
6
6
  ---
7
7
 
8
8
  # Settings in console3: one home for each setting
@@ -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.
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
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. Work that you cannot see in PMOS (notes, tests, run logs) is left out.
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,
@@ -122,12 +122,27 @@ const npxArgs = (src, version, rest) => (src && src.kind === 'local'
122
122
  ? ['-y', `--package=${src.path}`, '--', PKG_NAME, ...rest]
123
123
  : ['-y', `${PKG_NAME}@${version}`, ...rest]);
124
124
 
125
- /** One changelog entry as the page may show it, or null: a version, a time, and PRs with a number, a summary and, from
126
- * kit-changelog format 2, a title. */
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
+ * 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
+ * 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. 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). */
130
+ const KINDS = new Set(['new', 'imp', 'fix', 'x']);
131
+ const SKIP_TAG = /\s*\[(?:skip|no)[ -][a-z]+\]/gi;
132
+ const PR_REF = /\s*\(#(\d+)\)/;
133
+ const WORK_TAG = /\s*\((?:batch \d+|canvas |D\d+\b)[^()]*\)\s*$/i; // "(batch 25 item 11)", "(canvas TplA)", "(D105)": how it was made, not what it does
134
+ const tidy = (t) => t.replace(SKIP_TAG, '').replace(new RegExp(PR_REF.source, 'g'), '').replace(/\s+/g, ' ').trim().replace(WORK_TAG, '').trim();
135
+ function cleanPr(p) {
136
+ const t = typeof p.t === 'string' ? p.t : '', ref = PR_REF.exec(t) || PR_REF.exec(p.s);
137
+ const n = Number.isInteger(p.n) ? p.n : ref ? Number(ref[1]) : null;
138
+ const tt = tidy(t).slice(0, 200), s = tidy(p.s).slice(0, 400);
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 } : {}) };
142
+ }
127
143
  function cleanEntry(e) {
128
144
  if (!e || !validVersion(e.v)) return null;
129
- const prs = (Array.isArray(e.prs) ? e.prs : []).filter((p) => p && typeof p.s === 'string' && p.s.trim())
130
- .map((p) => ({ n: Number.isInteger(p.n) ? p.n : null, ...(typeof p.t === 'string' && p.t.trim() ? { t: p.t.trim().slice(0, 200) } : {}), s: p.s.trim().slice(0, 400) }));
145
+ const prs = (Array.isArray(e.prs) ? e.prs : []).filter((p) => p && typeof p.s === 'string' && p.s.trim()).map(cleanPr).filter((p) => p.s);
131
146
  return { v: e.v, at: typeof e.at === 'string' && !Number.isNaN(Date.parse(e.at)) ? e.at : null, prs };
132
147
  }
133
148
  const cleanList = (list) => (Array.isArray(list) ? list.map(cleanEntry).filter(Boolean) : []);
@@ -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.509.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.509.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.509.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 });
@@ -10,8 +10,8 @@
10
10
  * readyHtml(m, whole) a milestone's readiness as small diamonds, one per initiative it waits on
11
11
  * mergeBoards(list) All products: one Board model from every product's model, each row keeping its own product
12
12
  * bandHtml(g, st, o) the stage track's program row: tile, title, products, your count, its spread, its next milestones
13
- * prodHtml(g, o) · subHtml(name) a product group's head row (tile, name, goal dots, chevron) and a program's product sub-row
14
- * prodCell(g, dots) the head's fold button alone (the timeline's product lane, batch 11 item 17); dots replace the goal dots
13
+ * prodHtml(g, o) · subHtml(name) a product group's head row (tile and name open the product, goal dots, chevron folds) and a program's product sub-row
14
+ * prodCell(g, dots) the head's open (tile, name) and fold buttons alone (the timeline's product lane, batch 11 item 17); dots replace the goal dots
15
15
  * goalsFrom(fn) · goalsHtml(name) the goal dots of a product head, from Home's source (pm/home-list.js goalsOf); fn(name) gives its rows
16
16
  *
17
17
  * A program has no stage of its own (PM, 2026-10-23): the band shows only what its initiatives say. With one product picked, the program shows
@@ -173,9 +173,14 @@ export function prodHtml(g, cls = 'bt-prd') {
173
173
  /** The fold button of a product head alone: the timeline's product lane (batch 11 item 17, PlmA1Board) puts it in the name cell, its track beside it. */
174
174
  export function prodCell(g, dots) {
175
175
  // the calm timeline (batch 13 item 4) passes its own dots: none while open, one per initiative off track while folded
176
+ // the tile and name open the product's page in the Viewer (batch 26 item 3, as Home's header and a program's title do); the rest folds
176
177
  const open = !folded(g.key), gl = dots !== undefined ? dots : open ? goalsHtml(g.name) : '';
177
- return `<button type="button" class="bd-prdb" data-fold="${esc(g.key)}" aria-expanded="${open}" aria-label="${esc(g.name)}, ${g.rows.length} ${plural(g.rows.length, 'initiative', 'initiatives')}. ${open ? 'Fold' : 'Show'}">${tileHtml(g.name)}<span>${esc(g.name)}</span>${gl}${open || dots !== undefined ? '' : `<span class="n">${g.rows.length}</span>`}${SV(open ? CHEV_D : CHEV_R)}</button>`;
178
+ return `<span class="bd-prdb">${prodOpen(g, 'bd-prdo')}<button type="button" class="bd-prdf" data-fold="${esc(g.key)}" aria-expanded="${open}" aria-label="${esc(g.name)}, ${g.rows.length} ${plural(g.rows.length, 'initiative', 'initiatives')}. ${open ? 'Fold' : 'Show'}" title="${open ? 'Fold' : 'Show'}">${gl}${open || dots !== undefined ? '' : `<span class="n">${g.rows.length}</span>`}${SV(open ? CHEV_D : CHEV_R)}</button></span>`;
178
179
  }
180
+ /** A product's workspace, from its rows (All products: each row keeps its own); '' when the rows carry none (the page uses its own board's). */
181
+ export const prodWs = (g) => ((g.rows || []).find((r) => r && r.ws) || {}).ws || '';
182
+ /** A product head's tile and name as one button: it opens that product's page in the Viewer (pm/premium-board.js data-prodopen). */
183
+ export const prodOpen = (g, cls) => `<button type="button" class="${cls}" data-prodopen="${esc(g.name)}" data-prodws="${esc(prodWs(g))}" aria-label="Open ${esc(g.name)}, a product">${tileHtml(g.name)}<span>${esc(g.name)}</span></button>`;
179
184
  /** The goal dots of a product's head (PgBoard .gd), from the same source as Home's product header (pm/home-list.js goalsOf): one per KR or
180
185
  * budget its open initiatives anchor to, green on track, amber at risk. With no goal anchored, nothing is drawn. The page sets the rows. */
181
186
  let ROWS_OF = null;
@@ -191,11 +196,11 @@ export const subHtml = (name, cls = 'bt-psb') => `<div class="${cls}" role="row"
191
196
  export const memberIcon = (programs, id) => { const p = (programs || []).find((x) => x.id === id); return p ? `<span class="bd-pgi" role="img" aria-label="In ${esc(p.title)}" title="In ${esc(p.title)}">${SV(PROG)}</span>` : ''; };
192
197
 
193
198
  /** A Kanban swimlane's head (PgKanban .lh): a program's tile, title, your count and its next milestones merged into one counted diamond with
194
- * the readiness of the first; a product's tile and name. Both fold with the chevron; the program's title opens the program. */
199
+ * the readiness of the first; a product's tile and name. Both fold with the chevron; the title opens the program or the product. */
195
200
  export function laneHtml(g, st) {
196
201
  const open = !folded(g.key);
197
202
  const fb = (label) => `<button type="button" class="bd-ib sm bd-fold" data-fold="${esc(g.key)}" aria-expanded="${open}" aria-label="${open ? 'Fold' : 'Show'} ${esc(label)}" title="${open ? 'Fold' : 'Show'}">${SV(open ? CHEV_D : CHEV_R)}</button>`;
198
- if (g.kind !== 'program') return `<button type="button" class="bd-lho" data-fold="${esc(g.key)}" aria-expanded="${open}" aria-label="${esc(g.name)}, ${g.rows.length} ${plural(g.rows.length, 'initiative', 'initiatives')}. ${open ? 'Fold' : 'Show'}">${tileHtml(g.name)}<span>${esc(g.name)}</span></button><span class="bd-sp"></span>${open ? goalsHtml(g.name) : ''}${fb(g.name)}`;
203
+ if (g.kind !== 'program') return `${prodOpen(g, 'bd-lho')}<span class="bd-sp"></span>${open ? goalsHtml(g.name) : ''}${fb(g.name)}`;
199
204
  const p = g.program, c = nextCluster(st);
200
205
  const ms = c ? `<span class="bd-pm2${c.late ? ' late' : ''}" title="${esc(c.items.map((m) => m.name + (m.day ? ' ' + m.day : '')).join(' · '))}"><i class="bd-dm"></i><b>${esc(c.label)}</b>${esc(c.range)}${readyHtml(c.first, whole(st))}</span>` : '';
201
206
  return `<button type="button" class="bd-lho" data-prog="${esc(p.id)}" aria-haspopup="dialog" aria-label="${esc(p.title)}, a program. Open the program">${progTile()}<span>${esc(p.title)}</span></button>${yoursHtml(st)}<span class="bd-sp"></span>${ms}${fb(p.title)}`;
@@ -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));
@@ -1085,7 +1085,7 @@ html[data-theme="dark"] body.bd-page { --bd-prog: rgb(180,170,240); --bd-progbg:
1085
1085
  .bd-rdy i { display: block; width: 6px; height: 6px; border-radius: 1px; transform: rotate(45deg); background: var(--bd-plan); }
1086
1086
  .bd-rdy i.on { background: var(--bd-prog); }
1087
1087
  .bd-fold { color: var(--cz-ink3); }
1088
- .bd-fold:focus-visible, .bt-pgo:focus-visible, .bd-prdb:focus-visible, .bd-lho:focus-visible, .tg-pgo:focus-visible { outline: 2px solid var(--cz-blue); outline-offset: 2px; border-radius: 10px; }
1088
+ .bd-fold:focus-visible, .bt-pgo:focus-visible, .bd-prdo:focus-visible, .bd-prdf:focus-visible, .bd-lho:focus-visible, .tg-pgo:focus-visible { outline: 2px solid var(--cz-blue); outline-offset: 2px; border-radius: 10px; }
1089
1089
  /* the grouping control in the toolbar (PgBoardProduct: "By product" on in violet) */
1090
1090
  .bd-grpw2 { position: relative; display: inline-flex; align-items: center; }
1091
1091
  .bd-grpw2[hidden] { display: none; }
@@ -1119,7 +1119,13 @@ html[data-theme="dark"] body.bd-page { --bd-prog: rgb(180,170,240); --bd-progbg:
1119
1119
  .bt-psb > span, .tg-psb > span { display: inline-flex; align-items: center; gap: 8px; }
1120
1120
  .bt-psb::before, .tg-psb::before { content: ""; position: absolute; left: 26px; top: 0; bottom: 0; width: 2px; background: var(--bd-progln); }
1121
1121
  .bt-prd, .tg-prd { display: flex; align-items: center; padding: 8px 14px; background: var(--bd-row); border-top: 1px solid var(--cz-hair); }
1122
- .bd-prdb { all: unset; box-sizing: border-box; cursor: pointer; flex: 1; display: flex; align-items: center; gap: 10px; font-size: var(--fs-3); font-weight: 800; color: var(--cz-ink); min-height: 28px; }
1122
+ .bd-prdb { all: unset; box-sizing: border-box; flex: 1; display: flex; align-items: center; gap: 10px; font-size: var(--fs-3); font-weight: 800; color: var(--cz-ink); min-height: 28px; }
1123
+ /* the tile and name open the product's page (batch 26 item 3, as Home's header); the rest of the head, its dots, count and chevron, folds it */
1124
+ .bd-prdo, .bd-prdf { all: unset; box-sizing: border-box; cursor: pointer; display: flex; align-items: center; gap: 10px; min-height: 28px; }
1125
+ .bd-prdo { flex: 0 1 auto; min-width: 0; }
1126
+ .bd-prdo > span:last-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1127
+ .bd-prdf { flex: 1; align-self: stretch; justify-content: flex-end; }
1128
+ @media (hover: hover) { .bd-prdo:hover > span:last-child, .bd-lh.pd .bd-lho:hover > span:last-child { color: var(--cz-blueink); } }
1123
1129
  .bd-prdb svg { width: 15px; height: 15px; stroke-width: 2; color: var(--cz-ink3); margin-left: auto; }
1124
1130
  /* a product head's goal dots (PgBoard .gd): Home's source and look (cozy-today.css .hv-gd) */
1125
1131
  .bd-prdb .n { font-size: var(--fs-2); font-weight: 700; color: var(--cz-ink3); margin-left: auto; }
@@ -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 */