create-pmos 0.511.0 → 0.513.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 (114) hide show
  1. package/package.json +27 -31
  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-adddoc-test.js +3 -3
  7. package/workspace/briefing-premium-board-test.js +2 -2
  8. package/workspace/briefing-premium-initiative-test.js +3 -3
  9. package/workspace/briefing-premium-insights-test.js +2 -2
  10. package/workspace/briefing-premium-sources-test.js +4 -5
  11. package/workspace/briefing-premium-test.js +2 -2
  12. package/workspace/changelog.json +26 -0
  13. package/workspace/fixtures/next-prompt-replay.json +40 -0
  14. package/workspace/guide/home.md +1 -1
  15. package/workspace/guide/index.md +1 -0
  16. package/workspace/guide/next-prompt.md +62 -0
  17. package/workspace/guide/settings.md +3 -1
  18. package/workspace/intel-report-ui-test.js +1 -1
  19. package/workspace/intelligence-gaps-ui-test.js +1 -1
  20. package/workspace/lib/next-prompt.js +199 -0
  21. package/workspace/lib/premium-console-type.js +15 -12
  22. package/workspace/lib/route-modules.js +1 -0
  23. package/workspace/lib/routes-next-prompt.js +248 -0
  24. package/workspace/metric-drafts-ui-test.js +2 -2
  25. package/workspace/package-lock.json +2 -2
  26. package/workspace/package.json +1 -1
  27. package/workspace/premium-menu-rows-test.js +1 -1
  28. package/workspace/premium-vw-doc-test.js +1 -1
  29. package/workspace/public/pm/bd-pin-card.css +4 -4
  30. package/workspace/public/pm/bd-publish.css +11 -11
  31. package/workspace/public/pm/bd-shared.css +17 -17
  32. package/workspace/public/pm/c3-capture.css +9 -9
  33. package/workspace/public/pm/c3-initiatives.css +23 -23
  34. package/workspace/public/pm/c3-listen.css +1 -1
  35. package/workspace/public/pm/components.css +572 -572
  36. package/workspace/public/pm/console3.css +374 -373
  37. package/workspace/public/pm/cozy-adler.css +4 -4
  38. package/workspace/public/pm/cozy-aud.css +1 -1
  39. package/workspace/public/pm/cozy-board.css +328 -328
  40. package/workspace/public/pm/cozy-ev.css +80 -78
  41. package/workspace/public/pm/cozy-home-rows.css +32 -32
  42. package/workspace/public/pm/cozy-home.css +285 -241
  43. package/workspace/public/pm/cozy-insights.css +200 -200
  44. package/workspace/public/pm/cozy-live-pill.css +5 -5
  45. package/workspace/public/pm/cozy-mtg-present.css +28 -28
  46. package/workspace/public/pm/cozy-onboard.css +15 -15
  47. package/workspace/public/pm/cozy-panel.css +39 -39
  48. package/workspace/public/pm/cozy-pick.css +5 -5
  49. package/workspace/public/pm/cozy-rec-now.css +23 -23
  50. package/workspace/public/pm/cozy-rec-present.css +11 -11
  51. package/workspace/public/pm/cozy-rec-tab.css +13 -13
  52. package/workspace/public/pm/cozy-settings.js +37 -2
  53. package/workspace/public/pm/cozy-steps.css +14 -14
  54. package/workspace/public/pm/cozy-today.css +95 -95
  55. package/workspace/public/pm/cozy-viewer.css +27 -27
  56. package/workspace/public/pm/cozy-vw-mtg.css +113 -113
  57. package/workspace/public/pm/cozy-vw-rp.css +79 -79
  58. package/workspace/public/pm/cozy-vw-shell.css +43 -43
  59. package/workspace/public/pm/cozy-vw.css +129 -129
  60. package/workspace/public/pm/cozy-xv.css +15 -15
  61. package/workspace/public/pm/cozy.css +53 -21
  62. package/workspace/public/pm/dash-view.css +27 -27
  63. package/workspace/public/pm/hi-pop.css +26 -26
  64. package/workspace/public/pm/home-chat.js +21 -4
  65. package/workspace/public/pm/home-impact.css +8 -8
  66. package/workspace/public/pm/home-ini.css +36 -36
  67. package/workspace/public/pm/home-line.css +7 -7
  68. package/workspace/public/pm/impact.css +9 -9
  69. package/workspace/public/pm/ini-view.css +243 -243
  70. package/workspace/public/pm/load-shapes.css +1 -1
  71. package/workspace/public/pm/loose-props.css +14 -14
  72. package/workspace/public/pm/metric-drafts.css +20 -20
  73. package/workspace/public/pm/ms-suggest.css +9 -9
  74. package/workspace/public/pm/next-prompt-ui.js +117 -0
  75. package/workspace/public/pm/ov-view.css +14 -14
  76. package/workspace/public/pm/premium-insights.js +3 -3
  77. package/workspace/public/pm/premium-report-frame.css +1 -1
  78. package/workspace/public/pm/premium-report-intel.css +17 -17
  79. package/workspace/public/pm/premium-report-print.css +1 -1
  80. package/workspace/public/pm/premium-vw-backlog.css +13 -13
  81. package/workspace/public/pm/premium-vw-cal.css +32 -32
  82. package/workspace/public/pm/premium-vw-copy.css +7 -7
  83. package/workspace/public/pm/premium-vw-doc.css +69 -69
  84. package/workspace/public/pm/premium-vw-item.css +12 -12
  85. package/workspace/public/pm/premium-vw-jira.css +39 -39
  86. package/workspace/public/pm/premium-vw-meet.css +28 -28
  87. package/workspace/public/pm/premium-vw-notepad.css +13 -13
  88. package/workspace/public/pm/premium-vw-notes.css +11 -11
  89. package/workspace/public/pm/premium-vw-rec.css +27 -27
  90. package/workspace/public/pm/premium-vw-report.css +21 -21
  91. package/workspace/public/pm/premium-vw-slides.css +28 -28
  92. package/workspace/public/pm/premium.css +618 -618
  93. package/workspace/public/pm/prog-order.css +6 -6
  94. package/workspace/public/pm/prog-page.css +17 -17
  95. package/workspace/public/pm/program-charter.css +33 -33
  96. package/workspace/public/pm/ready-lib.css +6 -6
  97. package/workspace/public/pm/sort-order.css +1 -1
  98. package/workspace/public/pm/split-merge.css +18 -18
  99. package/workspace/public/pm/task-card.css +24 -24
  100. package/workspace/public/pm/tk-tasks.css +42 -42
  101. package/workspace/public/pm/tk-view.css +6 -6
  102. package/workspace/public/pm/wf-page.css +31 -31
  103. package/workspace/public/premium-aud.html +1 -1
  104. package/workspace/public/premium-board.html +1 -1
  105. package/workspace/public/premium-intelligence.html +1 -1
  106. package/workspace/public/premium-rec.html +1 -1
  107. package/workspace/public/premium-viewer.html +1 -1
  108. package/workspace/public/premium-workflows.html +1 -1
  109. package/workspace/public/premium.html +1 -1
  110. package/workspace/public/vendor/plus-jakarta-sans-var.woff2 +0 -0
  111. package/workspace/server.js +1 -0
  112. package/workspace/test-lists/next-prompt.json +5 -0
  113. package/workspace/third-party/README.md +2 -1
  114. package/workspace/third-party/plus-jakarta-sans-OFL.LICENSE +93 -0
@@ -0,0 +1,199 @@
1
+ 'use strict';
2
+ /* next-prompt.js — Claude suggests the PM's next prompt, already sharpened (batch 26 item 2, design B on the main canvas: NpB, NpBw).
3
+ *
4
+ * Two jobs, both short Claude calls whose answer is a suggestion and never an act:
5
+ * next after a reply ends: up to two prompts the PM could send next, the first one recommended. A tap puts the text in the box; nothing sends.
6
+ * sharpen while the PM types a rough prompt: one sharper version in the PM's chosen style (Polish, Prompt engineer, Your own).
7
+ *
8
+ * PROVEN ONLY. A `next` card is one of MOVES, and a move is offered only while the code that makes it work is on disk: each move names the file
9
+ * and the text that prove it (lib/pmos-verbs.js for rehearse, ask the past and the drift replay; lib/routes-briefing-premium.js for "grill
10
+ * this"). movesOf(root) finds that line, and the card cites it, so lib/ai-guard.js check() verifies the quote at its line before the card shows.
11
+ * A `follow` card answers the question or the offer the reply ends with; it cites the line of the reply it answers (state:next-prompt/…).
12
+ * A sharpened prompt cites the PM's own words. A card with no verified source is dropped and never shown.
13
+ *
14
+ * MOVES · STYLES · JOB_NEXT · JOB_SHARPEN
15
+ * movesOf(root) → [{ id, where, use, ref, quote }] the moves whose proving line is on disk
16
+ * nextPrompt({ pm, reply, place, style, rules, moves }) → string the prompt for the `next` call
17
+ * sharpenPrompt({ text, reply, place, style, rules }) → string the prompt for the `sharpen` call
18
+ * parseNext(text, { moves, place }) → [{ move, text, from? }] the cards the answer holds, unknown moves and bad shapes left out (≤ 2)
19
+ * parseSharpen(text, rough) → string | null the sharper prompt, or null (no answer, or it says the same)
20
+ * same(a, b) → bool two prompts that say the same thing (≥ 90% the same words)
21
+ * proposalsOfNext(cards, { moves, replyRef }) → [ai-guard proposal] replyRef(from) → { ref, quote } | null
22
+ * proposalOfSharpen(text, roughRef) → ai-guard proposal
23
+ * rough(text) → bool 3 or more words and 12 or more characters, not a slash command
24
+ *
25
+ * Pure: no I/O apart from movesOf reading the proving files. lib/routes-next-prompt.js makes the call and files the cards through the guard. */
26
+ const fs = require('fs');
27
+ const path = require('path');
28
+ const crypto = require('crypto');
29
+
30
+ const JOB_NEXT = 'next-prompt';
31
+ const JOB_SHARPEN = 'sharpen-prompt';
32
+ const MAX_CARDS = 2;
33
+ const MAX_TEXT = 400;
34
+ const REPLY_MAX = 3000;
35
+ const GRILL_RE = /^\s*(please\s+)?(grill|re-?grill)\b/i; // the same test lib/routes-briefing-premium.js grillAsk makes
36
+
37
+ /* where: 'any' chat, or only an initiative's chat (its key starts init:). `find` is the text that proves the move works today. */
38
+ const MOVES = Object.freeze([
39
+ { id: 'follow', where: 'any', file: null,
40
+ use: 'Answer the question the reply asks, or take up the offer it makes, in the PM\'s words (for example "Yes, swap it for the period-end KR debrief."). Only when the reply asks or offers something. Put the exact line of the reply you answer in "from".' },
41
+ { id: 'grill', where: 'initiative', file: 'lib/routes-briefing-premium.js', find: 'const GRILL_RE = ',
42
+ use: 'Ask Claude to grill this initiative\'s plan. The text MUST start with "Grill this" and then say what to test it against (earlier decisions, scope, dates). Only when the reply is about this initiative\'s plan, scope, dates or a decision on it.' },
43
+ { id: 'past', where: 'initiative', file: 'lib/pmos-verbs.js', find: "past: Object.freeze({ module: 'past-cli.js'",
44
+ use: 'Ask why something was decided or dropped: "Why did we …?" or "Did we ever decide …?", answered from the records with file:line sources or "No record found". Only when the reply names an earlier decision, or something dropped, retired or replaced.' },
45
+ { id: 'rehearse', where: 'any', file: 'lib/pmos-verbs.js', find: "rehearse: Object.freeze({ module: 'rehearse-cli.js'",
46
+ use: 'Ask Claude to rehearse a change before making it: moving a task, changing an initiative\'s end date, or cutting a task or an initiative. It shows what breaks, helps and moves, then waits for the PM\'s yes. Only when the reply or the PM\'s message talks about such a change, naming the real task or initiative.' },
47
+ { id: 'drift', where: 'any', file: 'lib/pmos-verbs.js', find: "drift: Object.freeze({ module: 'drift-cli.js'",
48
+ use: 'Ask Claude to run the weekly drift check\'s replay on the PM\'s records ("Run the drift replay"). Only when the reply says the weekly "what you might have missed" check waits for its replay.' },
49
+ ]);
50
+
51
+ const STYLES = Object.freeze({
52
+ polish: 'Polish: make it clearer and shorter. Keep the PM\'s meaning, words and order where they work. Add nothing the PM did not ask for.',
53
+ engineer: 'Prompt engineer: build the best prompt for the job. Say the goal in the first words, name the real things (initiative, task, product, PR) from the context, and say what a good answer holds. Add no new request, step or condition the PM did not ask for. One or two short sentences, plain words, no role-play preamble, no markdown, no lists.',
54
+ own: 'The PM\'s own rules, below. Follow them; where they say nothing, keep the PM\'s meaning and make it clear.',
55
+ });
56
+
57
+ const one = (s, n) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n);
58
+ const norm = (s) => String(s == null ? '' : s).toLowerCase().replace(/[^\p{L}\p{N}\s]/gu, ' ').replace(/\s+/g, ' ').trim();
59
+ const words = (s) => norm(s).split(' ').filter(Boolean);
60
+ const rough = (t) => { const s = String(t || '').trim(); return !s.startsWith('/') && s.length >= 12 && words(s).length >= 3; };
61
+
62
+ /** The moves whose proving line is on disk now, each with its ref (path:line under root) and the line as its quote. */
63
+ function movesOf(root) {
64
+ const out = [], cache = new Map();
65
+ for (const m of MOVES) {
66
+ if (!m.file) { out.push({ id: m.id, where: m.where, use: m.use, ref: null, quote: null }); continue; }
67
+ if (!cache.has(m.file)) { let L = null; try { L = fs.readFileSync(path.join(root, m.file), 'utf8').split('\n'); } catch { L = null; } cache.set(m.file, L); }
68
+ const L = cache.get(m.file); if (!L) continue;
69
+ const i = L.findIndex((l) => l.includes(m.find)); if (i < 0) continue;
70
+ out.push({ id: m.id, where: m.where, use: m.use, ref: `${m.file}:${i + 1}`, quote: one(L[i], 200) });
71
+ }
72
+ return out;
73
+ }
74
+ const inInitiative = (place) => !!(place && place.initiative);
75
+ const usable = (moves, place) => (moves || []).filter((m) => m.where === 'any' || inInitiative(place));
76
+
77
+ function placeLines(place) {
78
+ if (!place) return 'The chat: Home (not tied to one initiative).';
79
+ const bits = [];
80
+ if (place.initiative) bits.push(`The chat: the initiative "${one(place.initiative, 120)}"${place.stage ? `, stage ${one(place.stage, 40)}` : ''}${place.product ? `, product ${one(place.product, 80)}` : ''}.`);
81
+ else bits.push(`The chat: ${one(place.title || 'Home', 120)} (not tied to one initiative).`);
82
+ return bits.join('\n');
83
+ }
84
+ const styleLines = (style, rules) => (STYLES[style] || STYLES.engineer) + (style === 'own' && rules ? '\nThe PM\'s rules:\n' + one(rules, 1000) : '');
85
+ const clip = (s, n) => { const t = String(s == null ? '' : s).trim(); return t.length > n ? t.slice(0, n) + ' …' : t; };
86
+
87
+ function nextPrompt({ pm, reply, place, style = 'engineer', rules = '', moves = [] } = {}) {
88
+ const ok = usable(moves, place);
89
+ return [
90
+ 'You suggest the next message a product manager (the PM) could send in a PMOS chat with Claude. You write it in the PM\'s voice, ready to send. You do not answer the PM, and you run nothing.',
91
+ '',
92
+ placeLines(place),
93
+ '',
94
+ 'The PM\'s last message:', '<pm>', clip(pm, 1200) || '(none)', '</pm>',
95
+ '',
96
+ 'Claude\'s reply:', '<reply>', clip(reply, REPLY_MAX), '</reply>',
97
+ '',
98
+ 'You may suggest only these moves. Each one works in PMOS today:',
99
+ ...ok.map((m) => `- ${m.id}: ${m.use}`),
100
+ '',
101
+ 'How to write each suggestion:',
102
+ styleLines(style, rules),
103
+ '',
104
+ 'Rules:',
105
+ '- At most 2 suggestions, the best first. Usually 1. Return none when the reply needs nothing from the PM or no move clearly fits: an empty list is a good answer.',
106
+ '- Use only names, numbers and dates that appear above. Never invent them.',
107
+ '- Never suggest something the reply says is already done, or that only repeats the reply.',
108
+ `- Each text is at most ${MAX_TEXT} characters.`,
109
+ '',
110
+ 'Answer with JSON only, no other text: {"cards":[{"move":"<id>","text":"<the prompt>","from":"<follow only: the exact line of the reply it answers>"}]}',
111
+ ].join('\n');
112
+ }
113
+
114
+ function sharpenPrompt({ text, reply, place, style = 'engineer', rules = '' } = {}) {
115
+ return [
116
+ 'A product manager (the PM) is typing a message to Claude in a PMOS chat. Rewrite it into a sharper prompt, ready to send. You do not answer it, and you run nothing.',
117
+ '',
118
+ placeLines(place),
119
+ '',
120
+ reply ? 'Claude\'s last reply, for context:\n<reply>\n' + clip(reply, 1500) + '\n</reply>\n' : '',
121
+ 'What the PM typed:', '<draft>', clip(text, 1500), '</draft>',
122
+ '',
123
+ 'Style:',
124
+ styleLines(style, rules),
125
+ '',
126
+ 'Rules:',
127
+ '- Keep what the PM asks for. Do not add a new request or step, and do not answer it. Say nothing about how Claude should read or check data.',
128
+ '- When the draft asks to move a task, change an end date or cut work, ask Claude to rehearse it first ("Rehearse moving …") and wait for the PM\'s yes: PMOS does that today.',
129
+ '- Use only names, numbers and dates from the draft and the context above. Never invent them.',
130
+ '- Write in the PM\'s voice, as the message the PM sends. Do not guess what "here" or "this" points at when the context does not say.',
131
+ `- At most ${MAX_TEXT} characters.`,
132
+ '- If the draft is already as sharp as it can be in this style, say so.',
133
+ '',
134
+ 'Answer with JSON only, no other text: {"text":"<the sharper prompt>"} or {"same":true}',
135
+ ].join('\n');
136
+ }
137
+
138
+ /** The first JSON object in a model's answer (it may wrap it in a code fence or a sentence). */
139
+ function jsonOf(text) {
140
+ const s = String(text == null ? '' : text);
141
+ const a = s.indexOf('{'), b = s.lastIndexOf('}');
142
+ if (a < 0 || b <= a) return null;
143
+ try { return JSON.parse(s.slice(a, b + 1)); } catch { return null; }
144
+ }
145
+
146
+ function parseNext(text, { moves = [], place = null } = {}) {
147
+ const o = jsonOf(text); if (!o || !Array.isArray(o.cards)) return [];
148
+ const ok = new Map(usable(moves, place).map((m) => [m.id, m]));
149
+ const out = [];
150
+ for (const c of o.cards) {
151
+ if (out.length >= MAX_CARDS) break;
152
+ if (!c || typeof c !== 'object') continue;
153
+ const move = String(c.move || ''), t = one(c.text, MAX_TEXT + 1);
154
+ if (!ok.has(move) || !t || t.length > MAX_TEXT) continue;
155
+ if (move === 'grill' && !GRILL_RE.test(t)) continue; // the chat starts a grill run only on a message that starts with grill
156
+ if (move === 'follow' && !one(c.from, 400)) continue;
157
+ if (out.some((x) => same(x.text, t))) continue;
158
+ out.push({ move, text: t, ...(move === 'follow' ? { from: one(c.from, 400) } : {}) });
159
+ }
160
+ return out;
161
+ }
162
+
163
+ /** Two prompts that say the same: the same words, or at least 90% of the words of both shared. */
164
+ function same(a, b) {
165
+ const x = words(a), y = words(b);
166
+ if (!x.length || !y.length) return x.length === y.length;
167
+ if (x.join(' ') === y.join(' ')) return true;
168
+ const X = new Set(x), Y = new Set(y);
169
+ let both = 0; for (const w of X) if (Y.has(w)) both++;
170
+ return both / Math.max(X.size, Y.size) >= 0.9;
171
+ }
172
+
173
+ function parseSharpen(text, roughText) {
174
+ const o = jsonOf(text); if (!o || o.same === true) return null;
175
+ const t = one(o.text, MAX_TEXT + 1);
176
+ if (!t || t.length > MAX_TEXT || same(t, roughText)) return null;
177
+ return t;
178
+ }
179
+
180
+ const idOf = (prefix, s) => prefix + ':' + crypto.createHash('sha1').update(String(s)).digest('hex').slice(0, 12);
181
+
182
+ function proposalsOfNext(cards, { moves = [], replyRef = () => null } = {}) {
183
+ const by = new Map(moves.map((m) => [m.id, m]));
184
+ return (cards || []).map((c, i) => {
185
+ const m = by.get(c.move) || {};
186
+ const src = c.move === 'follow' ? replyRef(c.from) : (m.ref ? { ref: m.ref, quote: m.quote } : null);
187
+ return {
188
+ id: idOf('np', c.move + '\n' + c.text), title: c.move, say: c.text, confidence: 'medium',
189
+ claims: [{ text: c.move === 'follow' ? 'It answers the reply' : 'This move works in PMOS today', sources: src ? [{ label: c.move === 'follow' ? 'the reply' : m.file || c.move, ref: src.ref, quote: src.quote }] : [] }],
190
+ data: { move: c.move, rec: i === 0 },
191
+ };
192
+ });
193
+ }
194
+ function proposalOfSharpen(text, src) {
195
+ return { id: idOf('sh', text), title: 'sharper', say: text, confidence: 'medium',
196
+ claims: [{ text: 'It keeps what the PM typed', sources: src ? [{ label: 'what you typed', ref: src.ref, quote: src.quote }] : [] }], data: { kind: 'sharper' } };
197
+ }
198
+
199
+ module.exports = { JOB_NEXT, JOB_SHARPEN, MAX_CARDS, MAX_TEXT, MOVES, STYLES, GRILL_RE, movesOf, nextPrompt, sharpenPrompt, parseNext, parseSharpen, same, rough, jsonOf, proposalsOfNext, proposalOfSharpen };
@@ -3,25 +3,25 @@
3
3
  * The premium stylesheets were cut from the approved mockup, which set its own type: Geist and Geist Mono, a 15 px body and sizes from 10 to
4
4
  * 44 px written as plain pixels, and weights from 550 to 700. The console (pm/console3.css + pm/tokens.css) sets Space Grotesk and IBM Plex
5
5
  * Mono, a 14 px / 1.5 body, a six-step scale (--fs-1 to --fs-6: 11, 12, 14, 16, 20, 28 px) and three weights (400, 500, 600).
6
+ * Since feedback batch 26 item 6 (2026-11-03) the steps are the typography roles (cozy.css): sizes go to a role token (13, 15, 18 or 26 px)
7
+ * and weights to the two weight tokens (400, 600).
6
8
  * transform(css) maps a premium stylesheet onto the console's type and changes nothing else: every font size goes to the nearest console
7
9
  * step, every heavy weight to 600 (550 to 500), the two font families to the console's, and the body line height to 1.5. It is idempotent,
8
10
  * so a stylesheet already on console type comes back unchanged (premium-console-type.test.js holds the committed stylesheets to that).
9
11
  * The browser suites also run it over the mockup before they compare computed styles, so the mockup stays the reference for the look and
10
12
  * the console is the reference for the type. */
11
13
 
12
- const FS = '--fs-1:11px;--fs-2:12px;--fs-3:14px;--fs-4:16px;--fs-5:20px;--fs-6:28px';
14
+ const FS = '--fw-regular:400;--fw-strong:600;--fs-field:16px;--fs-display:26px;--fs-page:26px;--fs-panel:18px;--fs-section:15px;--fs-row:15px;--fs-body:15px;--fs-meta:13px;--fs-label:13px;--fs-badge:13px;--lh-display:1.2;--lh-page:1.2;--lh-panel:1.3;--lh-section:1.35;--lh-row:1.4;--lh-body:1.6;--lh-meta:1.5;--lh-label:1.35;--lh-badge:1.2';
13
15
 
14
- /* a pixel size to the console step it reads as */
16
+ /* a pixel size to the role size it reads as: 13 meta, 15 body, 18 panel, 26 display */
15
17
  function step(px) {
16
18
  const n = parseFloat(px);
17
- if (n <= 11.5) return 'var(--fs-1)';
18
- if (n <= 13) return 'var(--fs-2)';
19
- if (n <= 15) return 'var(--fs-3)';
20
- if (n <= 20) return 'var(--fs-4)';
21
- if (n <= 30) return 'var(--fs-5)';
22
- return 'var(--fs-6)';
19
+ if (n < 13.5) return 'var(--fs-meta)';
20
+ if (n < 17.5) return 'var(--fs-body)';
21
+ if (n < 23) return 'var(--fs-panel)';
22
+ return 'var(--fs-display)';
23
23
  }
24
- const weight = (w) => (w === '550' ? '500' : (+w > 600 ? '600' : w));
24
+ const weight = (w) => (+w >= 600 ? 'var(--fw-strong)' : 'var(--fw-regular)');
25
25
 
26
26
  /* the console's font files; base is where /vendor/ is served ('/vendor/' on the server, a file:// path for the mockup) */
27
27
  function fontFaces(base = '/vendor/') {
@@ -32,7 +32,9 @@ function fontFaces(base = '/vendor/') {
32
32
  }
33
33
 
34
34
  function transform(css, { base = '/vendor/', faces = true } = {}) {
35
- let out = String(css)
35
+ // an @font-face's weight is a descriptor of the file (400, 300 700), not a style: set aside, then put back unchanged
36
+ const kept = [];
37
+ let out = String(css).replace(/@font-face\s*\{[^}]*\}/g, (m) => (/Geist/.test(m) ? m : '\u0000' + (kept.push(m) - 1) + '\u0000'))
36
38
  .replace(/@font-face\s*\{[^}]*Geist[^}]*\}\s*/g, '')
37
39
  .replace(/--sans:[^;}]*/g, '--sans:"Space Grotesk",ui-sans-serif,system-ui,sans-serif')
38
40
  .replace(/--mono:[^;}]*/g, '--mono:"IBM Plex Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace')
@@ -49,8 +51,9 @@ function transform(css, { base = '/vendor/', faces = true } = {}) {
49
51
  .replace(/font:\s*((?:italic\s+)?)(\d{3})(\s)/g, (m, it, w, sp) => 'font:' + it + weight(w) + sp)
50
52
  // the body reads at the console's line height; the Briefing paragraph gets the width its smaller type needs
51
53
  .replace(/(\bbody\{[^}]*?)line-height:1\.45/g, '$1line-height:1.5')
52
- .replace(/(\.brief\{[^}]*?)max-width:42ch/g, '$1max-width:64ch');
53
- if (!/--fs-1:/.test(out)) out = ':root{' + FS + '}\n' + out;
54
+ .replace(/(\.brief\{[^}]*?)max-width:42ch/g, '$1max-width:64ch')
55
+ .replace(/\u0000(\d+)\u0000/g, (m, k) => kept[+k]);
56
+ if (!/--fs-body:/.test(out)) out = ':root{' + FS + '}\n' + out;
54
57
  if (faces && !/space-grotesk-var\.woff2/.test(out)) out = fontFaces(base) + out;
55
58
  return out;
56
59
  }
@@ -114,6 +114,7 @@ const MODULES = Object.freeze([
114
114
  'routes-goal-status', // batch 16 item 6: the goals' one status from readings (product page), notify choice, recipe, Read now, test read, CSV readings
115
115
  'routes-refresh', // data-refresh-settings: Settings › Data refresh: what refreshes, when, the refresh log, Refresh now
116
116
  'routes-impact-views', // batch 16 item 7 (Your impact, Phase 4): the chapter card's read, a stage that closed this week (boards.jsonl; read-only)
117
+ 'routes-next-prompt', // batch 26 item 2: the chat's suggested next prompt and Sharper card (lib/next-prompt.js, through lib/ai-guard.js)
117
118
  'routes-drift', // weekly-drift-check: What would I have missed, Home's weekly ✦ and its ⋯ drop and Undo (runtime drift/drift.jsonl; page-press POST)
118
119
  ]);
119
120
  const PM_DIR = path.join(__dirname, '..', 'public', 'pm');
@@ -0,0 +1,248 @@
1
+ 'use strict';
2
+ /* routes-next-prompt.js — the chat's suggested next prompt and its Sharper card over HTTP (batch 26 item 2, design B; lib/next-prompt.js).
3
+ *
4
+ * GET /api/next-prompt/prefs → { ok, next, sharpen, sharpenSet, style, rules, api }
5
+ * next: suggestions after a reply are on. sharpen: Sharpen as I type is on now ('auto' is on when Claude's runs go to the API, where a
6
+ * call takes about 2 s; a background `claude -p` run takes 10 s or more, too slow to follow typing). sharpenSet: 'auto'|'on'|'off'.
7
+ * style: 'polish'|'engineer'|'own'. rules: whether Your own rules are written. api: a key for the Claude API is set.
8
+ * POST /api/next-prompt/prefs { key: 'next'|'sharpen'|'style'|'rules', value } → { ok } (saved in <runtime>/next-prompt/prefs.json)
9
+ * POST /api/next-prompt/next { key, asked, reply, initiative?, stage?, product?, title? } → { ok, on, cards: [{ text, move, rec }] }
10
+ * POST /api/next-prompt/sharpen { key, text, reply?, initiative?, title? } → { ok, on, text | null }
11
+ *
12
+ * Both POSTs are page presses (lib/pm-guard.js requirePagePress; an agent session header is refused) and run ONE short Claude call:
13
+ * on the API with the PM's key when one is set (lib/anthropic-key.js, model PMOS_NEXT_PROMPT_MODEL or claude-sonnet-5-5), else a headless
14
+ * read-only run (lib/headless.js kind 'ask', origin briefing-ask:next-prompt:…). The answer goes through the shared door, lib/ai-guard.js file():
15
+ * the kill switch (`pmos ai off next-prompt`, `pmos ai off sharpen-prompt`, PMOS_AI=off), the replay gate (fixtures/next-prompt-replay.json,
16
+ * recorded answers, never a model), the citation check and the audit (<runtime>/ai-audit.jsonl). A card with no verified source is never
17
+ * shown. The reply and the typed text a card cites are kept in <runtime>/next-prompt/ (the newest 30 files), outside every repository.
18
+ * Nothing here writes to a session, a record or a repository: a card is text the PM may put in the box.
19
+ *
20
+ * Tests inject ctx.askClaude(prompt, { job, key }) → Promise<string|null> instead of a real call. */
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+ const crypto = require('crypto');
24
+ const guard = require('./pm-guard.js');
25
+ const AG = require('./ai-guard.js');
26
+ const NP = require('./next-prompt.js');
27
+
28
+ const VERSION = '1';
29
+ const ROOT = path.join(__dirname, '..'); // the workspace package: the files that prove a move works today
30
+ const FIXTURE = path.join(ROOT, 'fixtures/next-prompt-replay.json');
31
+ const KEEP = 30;
32
+ const TIMEOUT_MS = 90000;
33
+ const DEFAULT_MODEL = 'claude-sonnet-5-5';
34
+
35
+ const text = (v, n) => (typeof v === 'string' ? v.slice(0, n) : '');
36
+ const bad = (status, error) => ({ status, body: { ok: false, error } });
37
+ const dirOf = (runtimeDir) => path.join(runtimeDir, 'next-prompt');
38
+
39
+ /* The PM's choices live in <runtime>/next-prompt/prefs.json, owned by this route (Settings › Claude and the sliders menu write them here). */
40
+ const PREFS = {
41
+ next: (v) => typeof v === 'boolean',
42
+ sharpen: (v) => v === 'auto' || v === 'on' || v === 'off',
43
+ style: (v) => Object.prototype.hasOwnProperty.call(NP.STYLES, v),
44
+ rules: (v) => typeof v === 'string' && v.length <= 1000,
45
+ };
46
+ const prefsFile = (runtimeDir) => path.join(dirOf(runtimeDir), 'prefs.json');
47
+ function readPrefs(runtimeDir) {
48
+ try { const o = JSON.parse(fs.readFileSync(prefsFile(runtimeDir), 'utf8')); return o && typeof o === 'object' ? o : {}; } catch { return {}; }
49
+ }
50
+ /** Save one choice → { ok } or { ok:false, error }. */
51
+ function setPref(runtimeDir, key, value) {
52
+ if (!Object.prototype.hasOwnProperty.call(PREFS, key)) return { ok: false, error: 'no such choice: ' + String(key).slice(0, 40) };
53
+ if (!PREFS[key](value)) return { ok: false, error: 'that value does not fit ' + key };
54
+ const o = readPrefs(runtimeDir); o[key] = value;
55
+ fs.mkdirSync(dirOf(runtimeDir), { recursive: true, mode: 0o700 });
56
+ const f = prefsFile(runtimeDir), tmp = f + '.' + process.pid + '.tmp';
57
+ fs.writeFileSync(tmp, JSON.stringify(o, null, 2) + '\n', { mode: 0o600 });
58
+ fs.renameSync(tmp, f);
59
+ return { ok: true };
60
+ }
61
+ const get = (ctx, k) => { const v = readPrefs(ctx.runtimeDir)[k]; return PREFS[k](v) ? v : undefined; };
62
+ const envOf = (ctx) => ctx.env || process.env;
63
+
64
+ function apiKey(ctx) {
65
+ const env = envOf(ctx);
66
+ if (/^(off|0|false|no)$/i.test(String(env.PMOS_NEXT_PROMPT_API || ''))) return null;
67
+ try { return require('./anthropic-key.js').getKey(ctx.runtimeDir, env); } catch { return null; }
68
+ }
69
+ function prefs(ctx) {
70
+ const set = get(ctx, 'sharpen') || 'auto', api = !!apiKey(ctx), style = get(ctx, 'style') || 'engineer', rules = String(get(ctx, 'rules') || '');
71
+ return { next: get(ctx, 'next') !== false, sharpen: set === 'on' || (set === 'auto' && api), sharpenSet: set, style, rules: !!rules.trim(), rulesText: rules, api };
72
+ }
73
+ function offWhy(ctx, job) {
74
+ const why = AG.off(job, { env: envOf(ctx), settingsDir: ctx.runtimeDir });
75
+ if (why) return why;
76
+ const P = prefs(ctx);
77
+ if (job === NP.JOB_NEXT && !P.next) return 'Suggestions after a reply are off (Settings › Claude)';
78
+ if (job === NP.JOB_SHARPEN && !P.sharpen) return 'Sharpen as I type is off (Settings › Claude)';
79
+ return null;
80
+ }
81
+
82
+ /* ── the call ────────────────────────────────────────────────────────────────────────────── */
83
+
84
+ async function viaApi(key, prompt, env) {
85
+ const Anthropic = require('@anthropic-ai/sdk');
86
+ const api = new Anthropic({ apiKey: key });
87
+ const res = await api.messages.create({ model: String(env.PMOS_NEXT_PROMPT_MODEL || '').trim() || DEFAULT_MODEL, max_tokens: 800, messages: [{ role: 'user', content: prompt }] });
88
+ return (res.content || []).filter((b) => b.type === 'text').map((b) => b.text).join('\n');
89
+ }
90
+ function viaHeadless(ctx, prompt, origin) {
91
+ const headless = require('./headless.js');
92
+ if (typeof ctx.spawnSession !== 'function' || headless.kindFor({ agent: 'claude', origin }) !== 'ask') return Promise.resolve(null);
93
+ return new Promise((resolve) => {
94
+ let settled = false; const done = (v) => { if (!settled) { settled = true; clearTimeout(t); resolve(v); } };
95
+ const t = setTimeout(() => done(null), TIMEOUT_MS);
96
+ let s = null;
97
+ try {
98
+ s = ctx.spawnSession({ name: 'chat: next prompt', agent: 'claude', origin, initialInput: prompt,
99
+ onDone: (d) => { const r = d && d.result; done(r && r.ok && !r.isError ? String(r.text || '') : null); } });
100
+ } catch { s = null; }
101
+ if (!s || s.state === 'exited') done(null);
102
+ });
103
+ }
104
+ async function ask(ctx, prompt, { job, key }) {
105
+ if (typeof ctx.askClaude === 'function') return ctx.askClaude(prompt, { job, key });
106
+ const k = apiKey(ctx);
107
+ if (k) { try { return await viaApi(k, prompt, envOf(ctx)); } catch { return null; } }
108
+ return viaHeadless(ctx, prompt, `briefing-ask:next-prompt:${job}:${crypto.createHash('sha1').update(String(key)).digest('hex').slice(0, 10)}`);
109
+ }
110
+
111
+ /* ── what a card cites ───────────────────────────────────────────────────────────────────── */
112
+
113
+ function prune(dir) {
114
+ let fsx = []; try { fsx = fs.readdirSync(dir).filter((f) => f.endsWith('.txt')).map((f) => ({ f, t: fs.statSync(path.join(dir, f)).mtimeMs })); } catch { return; }
115
+ fsx.sort((a, b) => b.t - a.t);
116
+ for (const x of fsx.slice(KEEP)) { try { fs.unlinkSync(path.join(dir, x.f)); } catch { /* gone already */ } }
117
+ }
118
+ /** Keep a text where the guard can read it at its lines; → its name under the state folder. */
119
+ function keep(runtimeDir, prefix, body) {
120
+ const dir = dirOf(runtimeDir);
121
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
122
+ const name = prefix + '-' + crypto.createHash('sha1').update(body).digest('hex').slice(0, 16) + '.txt';
123
+ fs.writeFileSync(path.join(dir, name), body, { mode: 0o600 });
124
+ prune(dir);
125
+ return 'next-prompt/' + name;
126
+ }
127
+ const flat = (s) => String(s == null ? '' : s).toLowerCase().replace(/\s+/g, ' ').trim();
128
+ /** The line of the kept reply that holds `from` (spacing and case ignored) → { ref, quote } for the guard, or null. */
129
+ function lineRef(rel, body, from) {
130
+ const want = flat(from); if (want.length < AG.QUOTE_MIN) return null;
131
+ const L = body.split('\n');
132
+ for (let i = 0; i < L.length; i++) if (flat(L.slice(Math.max(0, i - 2), i + 3).join(' ')).includes(want)) return { ref: `state:${rel}:${i + 1}`, quote: from };
133
+ return null;
134
+ }
135
+
136
+ /* ── the guard ───────────────────────────────────────────────────────────────────────────── */
137
+
138
+ const MOVES = () => NP.movesOf(ROOT);
139
+ function replayCases(runtimeDir, job = null) {
140
+ let F; try { F = JSON.parse(fs.readFileSync(FIXTURE, 'utf8')); } catch { return []; }
141
+ const moves = MOVES();
142
+ const N = job === NP.JOB_SHARPEN ? [] : F.next || [], H = job === NP.JOB_NEXT ? [] : F.sharpen || [];
143
+ return N.map((c) => ({ name: 'next: ' + c.name, expect: c.expect, run: async () => {
144
+ const reply = String(c.reply || ''), rel = keep(runtimeDir, 'replay', reply);
145
+ return NP.proposalsOfNext(NP.parseNext(JSON.stringify(c.answer), { moves, place: c.place || null }), { moves, replyRef: (from) => lineRef(rel, reply, from) });
146
+ } })).concat(H.map((c) => ({ name: 'sharpen: ' + c.name, expect: c.expect, run: async () => {
147
+ const out = NP.parseSharpen(JSON.stringify(c.answer), c.text);
148
+ if (!out) return [];
149
+ const rel = keep(runtimeDir, 'replay-typed', String(c.text));
150
+ return [NP.proposalOfSharpen(out, c.cite === false ? null : { ref: `state:${rel}:1`, quote: String(c.text).split('\n')[0] })];
151
+ } })));
152
+ }
153
+ async function gate(ctx, job) {
154
+ if (AG.ready(ctx.runtimeDir, job, VERSION)) return true;
155
+ const rp = await AG.replay(job, VERSION, replayCases(ctx.runtimeDir, job), { root: ROOT, stateDir: ctx.runtimeDir, max: NP.MAX_CARDS });
156
+ return rp.ok;
157
+ }
158
+ async function through(ctx, job, run, proposals) {
159
+ let shown = [];
160
+ const got = await AG.file({ job, version: VERSION, run, root: ROOT, stateDir: ctx.runtimeDir, settingsDir: ctx.runtimeDir, env: envOf(ctx), max: NP.MAX_CARDS }, proposals, async (filed) => { shown = filed; });
161
+ return got.off || got.held ? [] : shown;
162
+ }
163
+
164
+ /* ── the two calls ───────────────────────────────────────────────────────────────────────── */
165
+
166
+ const placeOf = (b) => ({ initiative: text(b.initiative, 160) || null, stage: text(b.stage, 60) || null, product: text(b.product, 120) || null, title: text(b.title, 160) || null });
167
+ const inflight = new Map(); // the same reply asked twice (two tabs, a re-read) shares one call
168
+ function once(k, fn) {
169
+ if (inflight.has(k)) return inflight.get(k);
170
+ const p = fn().finally(() => setTimeout(() => inflight.delete(k), 60000));
171
+ inflight.set(k, p);
172
+ if (inflight.size > 100) inflight.delete(inflight.keys().next().value);
173
+ return p;
174
+ }
175
+
176
+ async function next(ctx, b) {
177
+ const reply = text(b.reply, 6000).trim(), key = text(b.key, 200);
178
+ if (!reply) return { ok: true, on: true, cards: [] };
179
+ const job = NP.JOB_NEXT, run = 'np-' + crypto.randomBytes(4).toString('hex');
180
+ const P = prefs(ctx), place = placeOf(b), moves = MOVES();
181
+ if (!(await gate(ctx, job))) return { ok: true, on: true, held: true, cards: [] };
182
+ const k = 'n:' + key + ':' + crypto.createHash('sha1').update(reply + '\n' + P.style + '\n' + P.rulesText).digest('hex');
183
+ return once(k, async () => {
184
+ AG.audit(ctx.runtimeDir, { job, version: VERSION, run, event: 'start' });
185
+ const answer = await ask(ctx, NP.nextPrompt({ pm: text(b.asked, 2000), reply, place, style: P.style, rules: P.rulesText, moves }), { job, key });
186
+ const cards = NP.parseNext(answer, { moves, place });
187
+ if (!cards.length) { AG.audit(ctx.runtimeDir, { job, version: VERSION, run, event: 'end', filed: 0, dropped: 0, ...(answer == null ? { error: 'no answer' } : {}) }); return { ok: true, on: true, cards: [] }; }
188
+ const rel = keep(ctx.runtimeDir, 'reply', reply);
189
+ const shown = await through(ctx, job, run, NP.proposalsOfNext(cards, { moves, replyRef: (from) => lineRef(rel, reply, from) }));
190
+ return { ok: true, on: true, cards: shown.map((p, i) => ({ text: p.say, move: p.data.move, rec: i === 0 })) };
191
+ });
192
+ }
193
+
194
+ async function sharpen(ctx, b) {
195
+ const typed = text(b.text, 2000).trim(), key = text(b.key, 200);
196
+ if (!NP.rough(typed)) return { ok: true, on: true, text: null };
197
+ const job = NP.JOB_SHARPEN, run = 'sh-' + crypto.randomBytes(4).toString('hex');
198
+ const P = prefs(ctx);
199
+ if (!(await gate(ctx, job))) return { ok: true, on: true, held: true, text: null };
200
+ const k = 's:' + key + ':' + crypto.createHash('sha1').update(typed + '\n' + P.style + '\n' + P.rulesText).digest('hex');
201
+ return once(k, async () => {
202
+ AG.audit(ctx.runtimeDir, { job, version: VERSION, run, event: 'start' });
203
+ const answer = await ask(ctx, NP.sharpenPrompt({ text: typed, reply: text(b.reply, 3000), place: placeOf(b), style: P.style, rules: P.rulesText }), { job, key });
204
+ const out = NP.parseSharpen(answer, typed);
205
+ if (!out) { AG.audit(ctx.runtimeDir, { job, version: VERSION, run, event: 'end', filed: 0, dropped: 0, ...(answer == null ? { error: 'no answer' } : { same: true }) }); return { ok: true, on: true, text: null }; }
206
+ const first = typed.split('\n')[0], rel = keep(ctx.runtimeDir, 'typed', typed);
207
+ const shown = await through(ctx, job, run, [NP.proposalOfSharpen(out, { ref: `state:${rel}:1`, quote: first })]);
208
+ return { ok: true, on: true, text: shown.length ? shown[0].say : null };
209
+ });
210
+ }
211
+
212
+ async function handle(req, res, url, ctx) {
213
+ const send = (r) => { ctx.json(res, r.body, r.status); return true; };
214
+ const what = url.pathname.replace(/^\/api\/next-prompt\/?/, '');
215
+ if (what === 'prefs' && req.method === 'POST') {
216
+ if (req.headers && req.headers['x-pmos-session']) return send(bad(403, 'Only the page changes these choices.'));
217
+ const rd = await ctx.readJson(req);
218
+ if (!rd.ok) return send(bad(400, 'the body is not JSON'));
219
+ const body = rd.body || {};
220
+ const g = guard.requirePagePress(req, body);
221
+ if (!g.ok) return send(bad(g.status, g.error));
222
+ if (!ctx.runtimeDir) return send(bad(409, 'There is no runtime folder.'));
223
+ const r = setPref(ctx.runtimeDir, body.key, body.value);
224
+ return send(r.ok ? { status: 200, body: { ok: true } } : bad(400, r.error));
225
+ }
226
+ if (what === 'prefs') {
227
+ if (req.method !== 'GET') return send(bad(405, 'GET or POST only'));
228
+ const P = prefs(ctx), env = envOf(ctx);
229
+ const next = P.next && !AG.off(NP.JOB_NEXT, { env, settingsDir: ctx.runtimeDir }), sh = P.sharpen && !AG.off(NP.JOB_SHARPEN, { env, settingsDir: ctx.runtimeDir });
230
+ return send({ status: 200, body: { ok: true, next, nextSet: P.next, sharpen: sh, sharpenSet: P.sharpenSet, style: P.style, rules: P.rules, rulesText: P.rulesText, api: P.api } });
231
+ }
232
+ if (what !== 'next' && what !== 'sharpen') return send(bad(404, 'no such next-prompt route'));
233
+ if (req.method !== 'POST') return send(bad(405, 'POST only'));
234
+ if (req.headers && req.headers['x-pmos-session']) return send(bad(403, 'Only the page asks for a suggestion.'));
235
+ const rd = await ctx.readJson(req);
236
+ if (!rd.ok) return send(bad(400, 'the body is not JSON'));
237
+ const body = rd.body || {};
238
+ const g = guard.requirePagePress(req, body);
239
+ if (!g.ok) return send(bad(g.status, g.error));
240
+ if (!ctx.runtimeDir) return send(bad(409, 'There is no runtime folder.'));
241
+ const job = what === 'next' ? NP.JOB_NEXT : NP.JOB_SHARPEN;
242
+ const why = offWhy(ctx, job);
243
+ if (why) return send({ status: 200, body: { ok: true, on: false, why, ...(what === 'next' ? { cards: [] } : { text: null }) } });
244
+ try { return send({ status: 200, body: what === 'next' ? await next(ctx, body) : await sharpen(ctx, body) }); }
245
+ catch (e) { return send(bad(500, 'The suggestion failed: ' + String((e && e.message) || e).slice(0, 200))); }
246
+ }
247
+
248
+ module.exports = { prefixes: ['/api/next-prompt'], handle, VERSION, FIXTURE, prefs, setPref, lineRef, replayCases, next, sharpen };
@@ -108,9 +108,9 @@ const INITIATIVE = () => ({
108
108
  why: c.querySelector('.md-why').textContent,
109
109
  presses: [...c.querySelectorAll('.md-ft .md-ib')].map((b) => ({ label: b.getAttribute('aria-label'), title: b.getAttribute('title'), text: b.textContent.trim(), svg: !!b.querySelector('svg[aria-hidden="true"]'), solid: b.classList.contains('solid'), href: b.getAttribute('href') || '' })) };
110
110
  });
111
- // every font size the parts draw: the type scale only (11, 12, 14, 16, 20, 28 px)
111
+ // every font size the parts draw: the type roles only (13, 15, 18, 26 px)
112
112
  const sizes = (p, sel) => p.evaluate((s) => [...new Set([...document.querySelectorAll(s + ', ' + s + ' *')].filter((e) => e.childNodes.length && [...e.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim())).map((e) => getComputedStyle(e).fontSize))], sel);
113
- const SCALE = new Set(['11px', '12px', '14px', '16px', '20px', '28px']);
113
+ const SCALE = new Set(['13px', '15px', '18px', '26px']); // the typography roles (batch 26 item 6)
114
114
 
115
115
  /* ── P: the program's proposed goal ── */
116
116
  const D = await open();
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.511.0",
3
+ "version": "0.513.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.511.0",
9
+ "version": "0.513.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.511.0",
3
+ "version": "0.513.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",
@@ -90,7 +90,7 @@ const ONE = { '.crmenu': 42, '.crmenu.tbmenu': 44, '.dsmn': 44 };
90
90
  await page.goto(ORIGIN + '/menu.html');
91
91
  const c1 = await page.evaluate(() => { const t = getComputedStyle(document.querySelector('.tag')), p = getComputedStyle(document.querySelector('.lsn-help')), c = getComputedStyle(document.querySelector('.c3'));
92
92
  return { tag: t.backgroundColor, fs: p.fontSize, muted: c.getPropertyValue('--muted').trim(), fg: c.getPropertyValue('--foreground').trim() }; });
93
- ok(`C1 a console sheet at ${w} ${theme}: its tokens are set, a tag has a fill and the body is 14px text`, c1.muted && c1.fg && !/rgba\(0, 0, 0, 0\)|transparent/.test(c1.tag) && c1.fs === '14px', JSON.stringify(c1));
93
+ ok(`C1 a console sheet at ${w} ${theme}: its tokens are set, a tag has a fill and the body is 15px text (the body role)`, c1.muted && c1.fg && !/rgba\(0, 0, 0, 0\)|transparent/.test(c1.tag) && c1.fs === '15px', JSON.stringify(c1));
94
94
  // H1: a long highlight keeps its tag on one line
95
95
  ctx._html = page0(`<section class="rvsec" style="overflow-wrap:anywhere;max-width:${Math.min(w - 32, 560)}px"><ul class="rvhl"><li><time>0:00</time><span>${LONG.join(' ').repeat(3)}</span><em>decision</em></li></ul></section>`, theme);
96
96
  await page.goto(ORIGIN + '/menu.html');
@@ -232,7 +232,7 @@ const prop = (o) => Object.assign({ id: 'prop-a', state: 'proposed', prompt: 'ad
232
232
  const fs = [...root.querySelectorAll('*')].map((e) => getComputedStyle(e).fontSize).filter((x, i, a) => a.indexOf(x) === i);
233
233
  return { sx: document.documentElement.scrollWidth - innerWidth, bad, out, fs };
234
234
  }, [V, w < 600]);
235
- ok(`D8 at ${w} px ${theme}: no sideways scroll, no small target, nothing past the edge, console sizes only`, f.sx <= 0 && f.bad.length === 0 && f.out === 0 && f.fs.every((x) => ['11px', '12px', '14px', '16px', '20px', '28px'].includes(x)), JSON.stringify(f));
235
+ ok(`D8 at ${w} px ${theme}: no sideways scroll, no small target, nothing past the edge, role sizes only`, f.sx <= 0 && f.bad.length === 0 && f.out === 0 && f.fs.every((x) => ['13px', '15px', '16px', '18px', '26px'].includes(x)), JSON.stringify(f));
236
236
  const c = await R.page.evaluate((v) => { const e = document.querySelector(v + ' .cf'); return [getComputedStyle(document.querySelector('#czViewer')).backgroundColor, getComputedStyle(e).color]; }, V); // the page sits on the Viewer's card
237
237
  ok(`D8 at ${w} px ${theme}: the page follows the theme`, theme === 'dark' ? c[0] === 'rgb(36, 38, 43)' && c[1] === 'rgb(236, 238, 241)' : c[0] === 'rgb(255, 255, 255)' && c[1] === 'rgb(42, 46, 53)', JSON.stringify(c)); // the initiative page is cozy (premium-agreed-design): the card is the cozy white or dark card
238
238
  await R.ctx.close();
@@ -1,13 +1,13 @@
1
1
  /* pm/bd-pin-card.css — the ◇ card on the Board timeline (feedback batch 13 item 4, XcalmA5Milestones): the initiative's milestones with a pin each.
2
2
  * The card is a pm/bd-pop.js popover; the Filter's "Milestones on the timeline" group uses the Filter's own chips. */
3
3
  .bd-pp.bd-pc { padding: 12px 12px 6px; }
4
- .bd-pc-h { margin: 0; font: 800 var(--fs-3)/1.3 var(--cz-sans); color: var(--cz-ink); }
5
- .bd-pc-sub { margin: 2px 0 6px; font-size: var(--fs-2); font-weight: 600; }
4
+ .bd-pc-h { margin: 0; font: var(--fw-strong) var(--fs-section)/1.3 var(--cz-sans); color: var(--cz-ink); }
5
+ .bd-pc-sub { margin: 2px 0 6px; font-size: var(--fs-label); font-weight: var(--fw-strong); line-height: var(--lh-label); }
6
6
  .bd-pc-r { display: flex; align-items: center; gap: 10px; min-height: 44px; border-top: 1px solid var(--cz-hair); }
7
7
  .bd-pc-r .tg-tm-i { margin-left: 4px; }
8
- .bd-pc-n { flex: 1; min-width: 0; font: 700 var(--fs-2)/1.25 var(--cz-sans); color: var(--cz-ink2); overflow-wrap: anywhere; }
8
+ .bd-pc-n { flex: 1; min-width: 0; font: var(--fw-strong) var(--fs-label)/1.25 var(--cz-sans); color: var(--cz-ink2); overflow-wrap: anywhere; }
9
9
  .bd-pc-r.on .bd-pc-n { color: var(--cz-ink); }
10
- .bd-pc-n small { display: block; font-size: var(--fs-1); font-weight: 600; color: var(--cz-ink3); }
10
+ .bd-pc-n small { display: block; font-size: var(--fs-badge); font-weight: var(--fw-strong); color: var(--cz-ink3); line-height: var(--lh-badge); }
11
11
  .bd-pc-n small.late { color: var(--cz-late); }
12
12
  .bd-pc-pin { all: unset; flex: none; width: 32px; height: 32px; border-radius: 9px; display: inline-flex; align-items: center; justify-content: center; color: var(--cz-ink3); cursor: pointer; }
13
13
  .bd-pc-pin svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }