create-pmos 0.172.0 → 0.173.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.172.0",
3
+ "version": "0.173.0",
4
4
  "description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@90450cc
1
+ kernel-source: pmos@702f714
2
2
  assembled: 2026-10-12
3
3
  mode: vendor
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Eval calibration log"
4
4
  description: "Append-only record of every PM override of an evaluator verdict."
5
- generated: {by: process:create-pmos, at: "2026-10-12T23:07:20Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-12T23:46:24Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Health budgets"
4
4
  description: "Closed registry of health-budget anchors (D59), edited only by the PM."
5
- generated: {by: process:create-pmos, at: "2026-10-12T23:07:20Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-12T23:46:24Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Discoveries and friction log"
4
4
  description: "Append-only record of what runs discovered and where the process rubbed."
5
- generated: {by: process:create-pmos, at: "2026-10-12T23:07:20Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-12T23:46:24Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -58,6 +58,7 @@ const { harness } = require('./c3-ini-harness');
58
58
  await page.click('[data-wk-src="pmos"]');
59
59
  await until(() => st(() => document.querySelector('[data-wk-src="pmos"]').getAttribute('aria-pressed') === 'true'));
60
60
  ok('F2 the Source filter keeps PMOS tasks only', await st(() => { const s = [...document.querySelectorAll('[data-ini-card]')].map((c) => c.dataset.src); return s.length > 0 && s.every((x) => x === 'pmos'); }));
61
+ await page.click('[data-ini="board-tabs"] .qt-more'); // Backlog sits behind More (c3-quiet-pages)
61
62
  await page.click('[data-ini-tab="backlog"]');
62
63
  await until(() => st(() => !!document.querySelector('[data-wk-tab="backlog"]')));
63
64
  ok('F3 the filters stay on when the tab changes to Backlog', await st(() => document.querySelector('[data-wk-src="pmos"]').getAttribute('aria-pressed') === 'true' && [...document.querySelectorAll('[data-ini-item]')].every((n) => n.dataset.src === 'pmos')));
@@ -0,0 +1,142 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /* c3-quiet-test.js — the quieter console3 pages (initiative c3-quiet-pages; PRD done-criteria 1 to 4 and 6).
4
+ * A real server and a real Chromium drive /c3.html: live mode for the header and a board, demo mode for Calendar and Reports.
5
+ *
6
+ * board at 1280 wide no page title, freshness line or sunrise text shows; the breadcrumb, the board tabs and Board settings
7
+ * share one row, and the content starts right under it; Dashboard is not shown
8
+ * fresh the freshness line is one dot beside the date; pressing it shows the full line
9
+ * more Backlog, Releases, Docs and Sprints show only after More; each still opens
10
+ * calendar one Layers button shows and hides the layer chips
11
+ * reports no deck is drawn
12
+ * phone at 400 wide nothing changes: the title shows and no dot is drawn (the phone gets its own companion design)
13
+ * No script error may be raised.
14
+ */
15
+ const path = require('path');
16
+ const { spawn } = require('child_process');
17
+ const { findBrowser } = require('./lib/find-browser');
18
+ const T = require('./test-run').configure({ name: 'c3-quiet' });
19
+
20
+ const BASE = T.BASE;
21
+ const results = [];
22
+ const ok = (name, cond, extra = '') => { results.push({ name, cond: !!cond }); console.log(`${cond ? '✓' : '✗'} ${name}${!cond && extra !== '' ? ' — ' + String(extra).slice(0, 400) : ''}`); };
23
+ let srv = null; let browser = null;
24
+ const finish = async (code) => {
25
+ if (browser) await browser.close().catch(() => {});
26
+ if (srv) { try { srv.kill('SIGKILL'); } catch { /* gone */ } }
27
+ T.killTmux();
28
+ const bad = results.filter((x) => !x.cond).length;
29
+ console.log(`\n${results.length - bad}/${results.length} c3-quiet assertions passed`);
30
+ process.exit(code !== undefined ? code : (bad ? 1 : 0));
31
+ };
32
+ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
33
+
34
+ async function waitFor(page, fn, arg, ms = 30000) {
35
+ try { await page.waitForFunction(fn, arg, { timeout: ms }); return true; } catch { return false; }
36
+ }
37
+ const shown = (s) => { const n = document.querySelector(s); return !!n && getComputedStyle(n).display !== 'none' && n.getBoundingClientRect().height > 0; };
38
+
39
+ (async () => {
40
+ const exe = findBrowser();
41
+ if (!exe) { console.log('✗ no Chrome/Chromium on this machine — the c3-quiet suite cannot run'); return finish(1); }
42
+ let chromium;
43
+ try { ({ chromium } = require('playwright-core')); } catch { console.log('✗ playwright-core is not installed — the c3-quiet suite cannot run'); return finish(1); }
44
+
45
+ srv = spawn(process.execPath, [path.join(__dirname, 'server.js')], { cwd: __dirname, stdio: 'ignore', env: T.env({ PMOS_WS_PORT: String(T.PORT) }) });
46
+ await T.waitForServer(BASE);
47
+ browser = await chromium.launch({ executablePath: exe });
48
+ const errors = [];
49
+ const open = async (w, url) => {
50
+ const ctx = await browser.newContext({ viewport: { width: w, height: 860 } });
51
+ const page = await ctx.newPage();
52
+ page.on('pageerror', (e) => errors.push(String((e && e.message) || e)));
53
+ await page.goto(BASE + url, { waitUntil: 'load' });
54
+ await waitFor(page, () => document.querySelector('.c3')?.dataset.quiet === '1' && document.querySelector('nav.c3tabs [role="tab"]'));
55
+ return page;
56
+ };
57
+
58
+ /* ── board and header, live mode, 1280 ── */
59
+ let page = await open(1280, '/c3.html?tab=init');
60
+ const opened = await waitFor(page, () => !!document.querySelector('[data-ini-open-board]'));
61
+ ok('B0 the portfolio lists a board to open', opened);
62
+ if (opened) {
63
+ await page.evaluate(() => document.querySelector('[data-ini-open-board]').click());
64
+ const moved = await waitFor(page, () => { const t = document.querySelector('[data-ini-view="board"] [data-ini="board-tabs"]'), c = document.querySelector('[data-ini-view="board"] [data-ini="crumbs"]'); return t && c && t.parentElement === c.parentElement; });
65
+ ok('B1 the board tabs sit on the breadcrumb row', moved);
66
+ await page.waitForTimeout(300);
67
+ const g = await page.evaluate((shownSrc) => {
68
+ const vis = new Function('s', 'return (' + shownSrc + ')(s)');
69
+ const t = document.querySelector('[data-ini-view="board"] [data-ini="board-tabs"]').getBoundingClientRect(), c = document.querySelector('[data-ini-view="board"] [data-ini="crumbs"]').getBoundingClientRect();
70
+ const nav = document.querySelector('nav.c3tabs').getBoundingClientRect();
71
+ const body = document.querySelector('[data-ini-view="board"] .bcols, [data-ini-view="board"] .calbody');
72
+ return {
73
+ title: vis('.c3top h1'), freshLine: vis('.c3top .fr-fresh'), daylight: vis('.c3pg-daylight'),
74
+ sameRow: Math.abs((t.top + t.bottom) / 2 - (c.top + c.bottom) / 2) < 12, ctxGap: Math.round(c.top - nav.bottom),
75
+ contentGap: body ? Math.round(body.getBoundingClientRect().top - Math.max(t.bottom, c.bottom)) : null,
76
+ dash: [...document.querySelectorAll('[data-ini-view="board"] button')].some((b) => b.textContent.trim() === 'Dashboard' && b.getBoundingClientRect().height > 0),
77
+ gear: (() => { const b = document.querySelector('[data-ini="board-settings"]'), row = document.querySelector('[data-ini-view="board"] [data-ini="crumbs"]').parentElement; return !!b && row.contains(b) && b.getBoundingClientRect().height > 0; })(),
78
+ status: (document.querySelector('[data-ini-view="board"] [data-ini="crumbs"] .qt-status') || {}).textContent || '',
79
+ };
80
+ }, shown.toString());
81
+ ok('B2 no page title, freshness line or sunrise text shows', !g.title && !g.freshLine && !g.daylight, JSON.stringify(g));
82
+ ok('B3 the breadcrumb and the tabs share one row, right under the page tabs', g.sameRow && g.ctxGap >= 0 && g.ctxGap < 40, JSON.stringify(g));
83
+ ok('B4 the content starts right under that row', g.contentGap !== null && g.contentGap >= 0 && g.contentGap < 48, JSON.stringify(g));
84
+ ok('B5 the board status reads on the breadcrumb row', g.status.length > 0, JSON.stringify(g));
85
+ ok('B6 Board settings sits on the same row; Dashboard is not shown', g.gear && !g.dash, JSON.stringify(g));
86
+
87
+ /* More */
88
+ const tabVis = (k) => page.evaluate((k) => { const n = document.querySelector(`[data-ini="board-tabs"] [data-ini-tab="${k}"]`); return !!n && getComputedStyle(n).display !== 'none'; }, k);
89
+ const hidden = [];
90
+ for (const k of ['backlog', 'releases', 'docs', 'sprints']) if (await page.$(`[data-ini="board-tabs"] [data-ini-tab="${k}"]`)) hidden.push(!(await tabVis(k)));
91
+ ok('M1 Backlog, Releases, Docs and Sprints are behind More', hidden.length > 0 && hidden.every(Boolean), JSON.stringify(hidden));
92
+ ok('M2 Summary still shows on the row', await tabVis('summary'));
93
+ await page.click('[data-ini="board-tabs"] .qt-more');
94
+ ok('M3 More shows them', await tabVis('backlog') && await page.getAttribute('[data-ini="board-tabs"] .qt-more', 'aria-expanded') === 'true');
95
+ await page.click('[data-ini="board-tabs"] [data-ini-tab="backlog"]');
96
+ ok('M4 Backlog opens and stays shown while it is the open tab',
97
+ await waitFor(page, () => document.querySelector('[data-ini="board-tabs"] [data-ini-tab="backlog"]')?.getAttribute('aria-pressed') === 'true') && await tabVis('backlog'));
98
+ }
99
+
100
+ /* the freshness dot */
101
+ const hasLine = await page.$('.c3top .fr-fresh');
102
+ if (hasLine) {
103
+ const dot = await waitFor(page, () => !!document.querySelector('.c3top [data-c3pg="fresh-dot"]'));
104
+ const label = dot ? await page.getAttribute('[data-c3pg="fresh-dot"]', 'aria-label') : '';
105
+ ok('F1 the freshness line is one dot beside the date, named by what it says', dot && /^(\d+ sources? not read|Sources read)\./.test(label || ''), label);
106
+ await page.click('[data-c3pg="fresh-dot"]');
107
+ ok('F2 pressing the dot shows the full line', await page.evaluate(shown, '.c3top .fr-fresh') && await page.getAttribute('[data-c3pg="fresh-dot"]', 'aria-expanded') === 'true');
108
+ await page.click('[data-c3pg="fresh-dot"]');
109
+ ok('F3 pressing it again hides the line', !(await page.evaluate(shown, '.c3top .fr-fresh')));
110
+ } else ok('F0 this workspace draws no freshness line, so no dot is needed', !(await page.$('[data-c3pg="fresh-dot"]')));
111
+ await page.context().close();
112
+
113
+ /* ── Calendar, demo mode ── */
114
+ page = await open(1280, '/c3.html?demo=1&tab=cal');
115
+ const lay = await waitFor(page, () => !!document.querySelector('[data-c3pg="cal-layers"]'));
116
+ ok('C1 Calendar has one Layers button', lay);
117
+ if (lay) {
118
+ const chips = () => page.evaluate(() => { const n = document.querySelector('[data-cal3="chips"]'); return !!n && getComputedStyle(n).display !== 'none'; });
119
+ ok('C2 the layer chips are folded', !(await chips()));
120
+ await page.click('[data-c3pg="cal-layers"]');
121
+ ok('C3 Layers shows the chips', await chips() && await page.getAttribute('[data-c3pg="cal-layers"]', 'aria-expanded') === 'true');
122
+ await page.click('[data-c3pg="cal-layers"]');
123
+ ok('C4 pressing it again folds them', !(await chips()));
124
+ }
125
+ await page.context().close();
126
+
127
+ /* ── Reports, demo mode ── */
128
+ page = await open(1280, '/c3.html?demo=1&tab=rep');
129
+ await waitFor(page, () => !!document.querySelector('[data-rp3]'));
130
+ ok('R1 Reports draws no deck', await page.evaluate(() => [...document.querySelectorAll('[data-rp3-deck]')].every((n) => getComputedStyle(n).display === 'none')));
131
+ await page.context().close();
132
+
133
+ /* ── phone: unchanged ── */
134
+ page = await open(400, '/c3.html?tab=init');
135
+ await page.waitForTimeout(400);
136
+ const ph = await page.evaluate((shownSrc) => { const vis = new Function('s', 'return (' + shownSrc + ')(s)'); return { title: vis('.c3top h1'), dot: !!document.querySelector('[data-c3pg="fresh-dot"]') }; }, shown.toString());
137
+ ok('P1 at 400 wide the page keeps today\'s layout: the title shows and no dot is drawn', ph.title && !ph.dot, JSON.stringify(ph));
138
+ await page.context().close();
139
+
140
+ ok('E1 no script error', errors.length === 0, errors.join(' | '));
141
+ finish();
142
+ })();
@@ -78,6 +78,7 @@ document.getElementById('c3-root').setAttribute('data-c3-demo', '1');
78
78
  </script></body></html>`;
79
79
 
80
80
  const D = '[data-rp3-pane="dash"]';
81
+ const ACT = '[data-rp3]'; // Share, Edit dashboard and Reset sit on the tab row at desktop width (c3-quiet-pages), inside the pane on a phone
81
82
  const order = (page) => page.$$eval(D + ' [data-rp3-w]', (a) => a.map((n) => n.getAttribute('data-rp3-w')));
82
83
  const DEF_P = ['north', 'outcomes', 'signals', 'delivery', 'bets', 'synergy', 'maturity'];
83
84
 
@@ -184,18 +185,18 @@ const DEF_P = ['north', 'outcomes', 'signals', 'delivery', 'bets', 'synergy', 'm
184
185
  await page.click('[data-rp3-scope="board"]'); await loaded(page, 5);
185
186
  const reads = await page.evaluate(() => window.__rp3spy.reads);
186
187
  ok('the data route is read once per scope and id, and not again within the minute', JSON.stringify(reads) === JSON.stringify(['board|shop', 'board|enterprise', 'initiative|checkout', 'initiative|sso', 'initiative|onb']), JSON.stringify(reads));
187
- await page.click(D + ' [data-rp3-edit]');
188
+ await page.click(ACT + ' [data-rp3-edit]');
188
189
  const freeB = await page.$$eval(D + ' [data-rp3-free]', (a) => a.map((n) => n.getAttribute('data-rp3-free')));
189
190
  ok('the Board add list holds only reports that fit a board (no north star, no maturity)', !freeB.includes('north') && !freeB.includes('maturity'), JSON.stringify(freeB));
190
- await page.click(D + ' [data-rp3-edit]');
191
+ await page.click(ACT + ' [data-rp3-edit]');
191
192
  await page.click('[data-rp3-scope="portfolio"]'); await loaded(page, 7);
192
193
 
193
194
  /* ── edit ── */
194
195
  await spy(page);
195
- await page.click(D + ' [data-rp3-edit]');
196
- ok('Edit dashboard turns edit mode on, with arrows and Remove on each widget', await page.getAttribute(D + ' [data-rp3-edit]', 'aria-pressed') === 'true' && (await page.$$(D + ' [data-rp3-rm]')).length === 7 && (await page.$$(D + ' [data-rp3-up]')).length === 7);
196
+ await page.click(ACT + ' [data-rp3-edit]');
197
+ ok('Edit dashboard turns edit mode on, with arrows and Remove on each widget', await page.getAttribute(ACT + ' [data-rp3-edit]', 'aria-pressed') === 'true' && (await page.$$(D + ' [data-rp3-rm]')).length === 7 && (await page.$$(D + ' [data-rp3-up]')).length === 7);
197
198
  ok('the first widget cannot move up, the last cannot move down', await page.$eval(D + ' [data-rp3-up="north"]', (b) => b.disabled) && await page.$eval(D + ' [data-rp3-down="maturity"]', (b) => b.disabled));
198
- ok('Reset to default is off while the scope shows its default', await page.$eval(D + ' [data-rp3-reset]', (b) => b.disabled));
199
+ ok('Reset to default is off while the scope shows its default', await page.$eval(ACT + ' [data-rp3-reset]', (b) => b.disabled));
199
200
  await page.click(D + ' [data-rp3-up="outcomes"]');
200
201
  ok('up moves a report up', !!(await until(async () => JSON.stringify(await order(page)) === JSON.stringify(['outcomes', 'north', 'signals', 'delivery', 'bets', 'synergy', 'maturity']))), JSON.stringify(await order(page)));
201
202
  let P = await posts(page, '/api/report-dashboards/layout');
@@ -216,7 +217,7 @@ const DEF_P = ['north', 'outcomes', 'signals', 'delivery', 'bets', 'synergy', 'm
216
217
  ok('Add puts a library report at the end of the dashboard', !!(await until(async () => (await order(page)).slice(-1)[0] === 'maturity')));
217
218
  P = await posts(page, '/api/report-dashboards/layout');
218
219
  ok('every change POSTed its layout (move, move, remove, undo, remove, add)', P.length === 6 && P.every((q) => q.body.key === 'portfolio' && Array.isArray(q.body.reports)), String(P.length));
219
- ok('Reset to default is on once the scope has its own layout', !(await page.$eval(D + ' [data-rp3-reset]', (b) => b.disabled)));
220
+ ok('Reset to default is on once the scope has its own layout', !(await page.$eval(ACT + ' [data-rp3-reset]', (b) => b.disabled)));
220
221
  // a failed POST: the page keeps the change and says why
221
222
  await page.evaluate(() => { window.__rp3spy.fail = '/api/report-dashboards/layout'; });
222
223
  await page.click(D + ' [data-rp3-down="outcomes"]');
@@ -225,22 +226,22 @@ const DEF_P = ['north', 'outcomes', 'signals', 'delivery', 'bets', 'synergy', 'm
225
226
  await page.evaluate(() => { window.__rp3spy.fail = null; });
226
227
  await page.click(D + ' [data-rp3-up="outcomes"]');
227
228
  await until(async () => (await order(page)).join(',') === 'outcomes,signals,north,delivery,bets,synergy,maturity');
228
- await page.click(D + ' [data-rp3-edit]');
229
- ok('Done editing leaves edit mode', await page.getAttribute(D + ' [data-rp3-edit]', 'aria-pressed') === 'false' && !(await page.$(D + ' [data-rp3-rm]')));
229
+ await page.click(ACT + ' [data-rp3-edit]');
230
+ ok('Done editing leaves edit mode', await page.getAttribute(ACT + ' [data-rp3-edit]', 'aria-pressed') === 'false' && !(await page.$(D + ' [data-rp3-rm]')));
230
231
 
231
232
  /* ── the layout survives a reload ── */
232
233
  await open(page); await loaded(page, 7);
233
234
  ok('a stored layout is there after a reload', (await order(page)).join(',') === 'outcomes,signals,north,delivery,bets,synergy,maturity', JSON.stringify(await order(page)));
234
235
  await spy(page);
235
- await page.click(D + ' [data-rp3-edit]'); await page.click(D + ' [data-rp3-reset]');
236
+ await page.click(ACT + ' [data-rp3-edit]'); await page.click(ACT + ' [data-rp3-reset]');
236
237
  ok('Reset to default returns the default order', !!(await until(async () => JSON.stringify(await order(page)) === JSON.stringify(DEF_P))), JSON.stringify(await order(page)));
237
238
  P = await posts(page, '/api/report-dashboards/layout');
238
239
  ok('Reset POSTs reports: null', P.length === 1 && P[0].body.reports === null && P[0].body.key === 'portfolio', JSON.stringify(P));
239
- await page.click(D + ' [data-rp3-edit]');
240
+ await page.click(ACT + ' [data-rp3-edit]');
240
241
  // a board layout is kept per key
241
242
  await page.click('[data-rp3-scope="board"]'); await loaded(page, 5);
242
- await page.click(D + ' [data-rp3-edit]'); await page.click(D + ' [data-rp3-rm="synergy"]'); await until(async () => (await order(page)).length === 4);
243
- await page.click(D + ' [data-rp3-edit]');
243
+ await page.click(ACT + ' [data-rp3-edit]'); await page.click(D + ' [data-rp3-rm="synergy"]'); await until(async () => (await order(page)).length === 4);
244
+ await page.click(ACT + ' [data-rp3-edit]');
244
245
  P = await posts(page, '/api/report-dashboards/layout');
245
246
  ok('a board layout is POSTed under board:<id>', P.length === 2 && P[1].body.key === 'board:shop', JSON.stringify(P.map((q) => q.body.key)));
246
247
  await page.click('[data-rp3-scope="portfolio"]'); await loaded(page, 7);
@@ -249,7 +250,7 @@ const DEF_P = ['north', 'outcomes', 'signals', 'delivery', 'bets', 'synergy', 'm
249
250
  /* ── share ── */
250
251
  ok('the head says the portfolio is scheduled, draft only (the demo seeds a monthly schedule)', /Scheduled: Monthly, 1st at 08:00, draft only/.test(await page.textContent(D + ' [data-rp3-schedline]')));
251
252
  await spy(page);
252
- await page.click(D + ' [data-rp3-share]');
253
+ await page.click(ACT + ' [data-rp3-share]');
253
254
  const sheet = await until(() => page.$('[data-rp3-sheet] [data-rp3-frame="Headline"]'));
254
255
  ok('Share opens a sheet with the report frame', !!sheet);
255
256
  const frames = await page.$$eval('[data-rp3-sheet] [data-rp3-frame] h2', (a) => a.map((h) => h.textContent.replace(/\d+$/, '')));
@@ -336,10 +337,10 @@ const DEF_P = ['north', 'outcomes', 'signals', 'delivery', 'bets', 'synergy', 'm
336
337
  const lefts = await pp.$$eval(D + ' [data-rp3-w]', (a) => [...new Set(a.map((w) => Math.round(w.getBoundingClientRect().left)))]);
337
338
  ok('400px: the widgets stack in one column', lefts.length === 1, JSON.stringify(lefts));
338
339
  await pp.click('[data-rp3-scope="initiative"]'); await loaded(pp, 4);
339
- await pp.click(D + ' [data-rp3-edit]');
340
+ await pp.click(ACT + ' [data-rp3-edit]');
340
341
  ok('400px: Initiative scope in edit mode has no horizontal scroll', await noScroll(pp));
341
- await pp.click(D + ' [data-rp3-edit]');
342
- await pp.click(D + ' [data-rp3-share]'); await until(() => pp.$('[data-rp3-sheet] [data-rp3-frame]'));
342
+ await pp.click(ACT + ' [data-rp3-edit]');
343
+ await pp.click(ACT + ' [data-rp3-share]'); await until(() => pp.$('[data-rp3-sheet] [data-rp3-frame]'));
343
344
  ok('400px: the share sheet has no horizontal scroll', await noScroll(pp));
344
345
  await pp.keyboard.press('Escape');
345
346
  await pp.click('[data-rp3-tab="lib"]'); await pp.click('[data-rp3-pane="lib"] [data-home="delivery"]');
@@ -35,7 +35,7 @@ The keys work too. Press `+` to zoom in and `-` to zoom out. Press the left or t
35
35
 
36
36
  ## Layers
37
37
 
38
- The chips under the zoom bar are the layers. Each chip shows the number of items in the range. Press a chip to hide its items, and press it again to show them.
38
+ The chips under the zoom bar are the layers. On a screen wider than 600 pixels they are folded: press **Layers** in the zoom bar to show them, and press it again to fold them. They stay shown until you fold them or reload the page. Each chip shows the number of items in the range. Press a chip to hide its items, and press it again to show them.
39
39
 
40
40
  | Chip | What it shows |
41
41
  |---|---|
@@ -11,7 +11,7 @@ Press the **Sessions** button in the console3 header to open the panel. It is th
11
11
 
12
12
  ## The dock
13
13
 
14
- The dock is open on the Briefing. On the other tabs it is folded away, and no rail of buttons is left at the right edge. To open the dock again, press Ctrl J (⌘J on a Mac), or use **Open the dock** in the **More** menu.
14
+ The dock is folded when you first open the console. To open it, press Ctrl J (⌘J on a Mac), or use **Open the dock** in the **More** menu. The browser keeps your choice: when you leave the dock open, it opens on the Briefing next time. On the other tabs it is folded away, and no rail of buttons is left at the right edge.
15
15
 
16
16
  ## Start a session
17
17
 
@@ -44,7 +44,7 @@ The record is kept in your own runtime folder on this machine, never in a reposi
44
44
 
45
45
  ## Where you see it
46
46
 
47
- Each page shows a short line under its title that says when each source was last read, for example **Initiatives read 08:02 · Calendar read 07:15 · Jira status read 06:31**. The line never says that the data is up to date, because the agents read these sources on a schedule and not live.
47
+ Each page shows a short line that says when each source was last read, for example **Initiatives read 08:02 · Calendar read 07:15 · Jira status read 06:31**. On a screen wider than 600 pixels, the line is folded into one dot next to the date. The dot is amber and says how many sources are not read, for example **2 sources not read**, or it says **Sources read**. Press the dot to show the full line, and press it again to fold it. The line never says that the data is up to date, because the agents read these sources on a schedule and not live.
48
48
 
49
49
  - A source older than its limit is amber.
50
50
  - A source that was never read says **not read yet**, in amber.
@@ -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-10-11
5
+ timestamp: 2026-10-12
6
6
  ---
7
7
 
8
8
  # Initiatives on the console: Portfolio, boards and workflows
@@ -24,6 +24,8 @@ Under the rows are a timeline of all boards (board › initiative › epic) and
24
24
 
25
25
  The tabs are **Summary**, **Initiatives**, **Triage**, **Backlog**, **Board**, **Timeline**, **Releases**, **Docs** and **Sprints**. A board with Build and deliver off has no Releases and no Sprints tab.
26
26
 
27
+ On a screen wider than 600 pixels, the breadcrumb, the board status, the tabs and the Board settings gear are on one row. **Backlog**, **Releases**, **Docs** and **Sprints** are behind **More**. Press **More** to show them. When one of them is the open tab, it stays shown.
28
+
27
29
  - **Summary** shows the initiatives at a glance and one bar per category. At a glance lists up to six open initiatives. Initiatives with work, and Now initiatives that are not on track, come first. The others follow by end date. Press a bar to open the Initiatives tab with that category.
28
30
  - **Initiatives** lists the initiatives of the board, 25 on each page. Use the saved views (Now and next, Mine, At risk, No owner, All), the filters and the sort. Press **Move** on a row to move the initiative to a stage of its own workflow.
29
31
  - **Triage** lists the new asks. Add an ask, then open it to see its draft. Press **Accept** to start an initiative. An initiative needs an anchor (a KR or a health budget). On a board, **New initiative** lists the anchors that `planning/okrs/` declares. Pick one from the list. Press **Ask for more** to ask a question, or **Decline** and give a reason.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.172.0",
3
+ "version": "0.173.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.172.0",
9
+ "version": "0.173.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.172.0",
3
+ "version": "0.173.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",
@@ -0,0 +1,100 @@
1
+ /* pm/c3-quiet.css — quieter console3 pages (c3-quiet-pages, PM-approved 2026-10-12). Linked once by c3-quiet.js.
2
+ * At most two rows sit above a page's content: row one is the page tabs with the date, the freshness dot and the tools on the
3
+ * right; row two is the page's own context (breadcrumb, status, sub-tabs, actions). Teaching sentences, empty cards and extra
4
+ * chips are not drawn; decks show on Briefing only. Everything is scoped under .c3 and uses tokens only. */
5
+ /* Phone widths keep today's layout: the phone gets its own companion design (Wawan, 2026-10-12), so every rule here is for 601px and up. */
6
+ @media (min-width:601px){
7
+ /* 1. Header: one row. Title and date left, a short tool set right. */
8
+ .c3 .c3top{display:flex !important;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:nowrap}
9
+ .c3 .c3top > div:first-child{min-width:0}
10
+ .c3 .c3top .tools{order:2;margin-left:auto;flex-wrap:nowrap}
11
+ .c3 .c3top .fr-fresh{display:none !important} /* the long freshness line */
12
+ .c3 .c3pg-daylight{display:none !important} /* sunrise and sunset text */
13
+ .c3 .c3top [data-c3pg="calendar"]{display:none !important} /* same as the Calendar tab */
14
+ .c3 .c3top .ib:not([aria-pressed="true"]),.c3 .c3top .psess{border-color:transparent !important;background:transparent !important;box-shadow:none !important}
15
+ .c3 .c3pg-tick{display:none !important} /* the pop-up "Just in" toast: the bell keeps it */
16
+ .qt-fresh{margin-left:4px}
17
+ .qt-fresh .qt-fresh__dot{width:8px;height:8px;border-radius:50%;background:var(--warning);display:inline-block}
18
+ .qt-fresh.ok .qt-fresh__dot{background:var(--muted-foreground)}
19
+ .c3 .c3top .fr-fresh.qt-open{display:flex !important;margin-top:6px}
20
+ /* 2. Tab counts: plain grey numbers, not blue badges */
21
+ .c3 .c3tabs .bdg{background:transparent !important;color:var(--muted-foreground) !important;font-weight:400;padding:0 0 0 2px;min-width:0;box-shadow:none}
22
+ /* 3. Teaching sentences and hints: gone from every page */
23
+ .c3 .ini-note:not(:has([data-ibb="clear"],[data-wk="clear"])),.c3 .ini-lhm .dim,.c3 .cal3-note,.c3 [data-pm-chips],.c3 [data-pm-lens-head],
24
+ .c3 [data-c3pg="foot"],.c3 .pulse-foot .c3s2,.c3 .st-lead,.c3 [data-c3pg="kept-out"],.c3 [data-needs-summary]{display:none !important}
25
+ /* 4. Rows: title plus at most two chips; the rest lives in the row's panel */
26
+ .c3 .nrow .nb > .chips > :nth-child(n+3){display:none !important}
27
+ .c3 [data-c3pg="today"] .nrow .chips [data-prep],.c3 [data-c3pg="today"] .nrow .chips [data-before]{display:none !important}
28
+ .c3 [data-c3pg="today"] .nrow .nb > .chips > [data-people]{display:inline !important}
29
+ /* 5. Decks: only Briefing shows the big cards */
30
+ .c3 .cal3 [data-c3pg="plan"],.c3 [data-rp3-deck]{display:none !important}
31
+ .c3 .stance [data-shell="listen"]{display:none !important}
32
+ .c3 .fc .cfh .kp:nth-child(n+2){display:none !important}
33
+ /* 7. Initiatives */
34
+ .c3 [data-ini-view="portfolio"] .ini-meta{display:none !important}
35
+ .c3 [data-ini-view="portfolio"] .bcol.r > section:first-child{display:none !important} /* repeats each board's own bar */
36
+ .c3 [data-ini="boards"] .nrow .nb > .chips > :nth-child(n+3){display:inline-flex !important}
37
+ .c3 [data-ini="boards"] .nrow .nb > .chips:has(.ini-sw){display:none !important} /* Discover 0 Plan 25 ... legend */
38
+ .c3 [data-ini="boards"] .nrow .chips .tag{display:none !important}
39
+ .c3 .bcols.qt-one{display:block !important}
40
+ .c3 .bcols.qt-one > .bcol.r{display:none !important}
41
+ .c3 .bcols.qt-one > .bcol.l{width:auto;max-width:none}
42
+ .c3 [data-ini="setup"]{display:none !important} /* Build and deliver line: lives in Board settings */
43
+ .c3 [data-ini-view="board"] > .calbody > .lh{display:none !important} /* board name repeats the breadcrumb */
44
+ .c3 [data-ini="board-tabs"] .bdg{display:none !important}
45
+ .c3 [data-ini="board-tabs"] .qt-more-tab{display:none}
46
+ .c3 [data-ini="board-tabs"].qt-show .qt-more-tab{display:inline-flex}
47
+ .c3 .qt-status{color:var(--muted-foreground);font-size:var(--fs-2);margin-left:8px}
48
+ .c3 .qt-status.warn{color:var(--warning)}
49
+ .c3 [data-ini="glance"] .chip2.mono{display:none !important}
50
+ .c3 .qt-fold > summary{cursor:pointer;list-style:none;display:flex;align-items:center;gap:8px;color:var(--muted-foreground)}
51
+ .c3 .qt-fold > summary::-webkit-details-marker{display:none}
52
+ .c3 .qt-hide{display:none !important}
53
+ /* 8. Calendar: layers behind one button */
54
+ .c3 [data-c3-pane="cal"] [data-cal3="chips"]{display:none !important}
55
+ .c3 [data-c3-pane="cal"] [data-cal3="chips"].qt-open{display:flex !important}
56
+ .c3 [data-c3-pane="cal"] [data-cal3="lanes"]{display:none !important}
57
+ /* 9. Work */
58
+ .c3 .wkfc-why,.c3 .wkfr .nb > .chips:nth-of-type(2){display:none !important}
59
+ @media (hover: hover){ /* with a pointer, a focus card's steps and buttons show on pointing at or tabbing into it; a touch screen always shows them */
60
+ .c3 .wkfr:not(:hover):not(:focus-within) .wkfr-b{display:none !important}
61
+ }
62
+ .c3 .wkfr .nb > .chips:has(> .chip2){display:none !important}
63
+ .c3 [data-pm-lenses] .bdg{background:transparent !important;color:var(--muted-foreground) !important}
64
+ /* 10. Reports */
65
+ .c3 .rp3-w > .chips{display:none !important}
66
+ @media (hover: hover){ /* with a pointer, About this report shows on pointing at or tabbing into the report */
67
+ .c3 .rp3-w:not(:hover):not(:focus-within) .rp3-about{opacity:0}
68
+ }
69
+ /* 11. Settings */
70
+ .c3 .st-nav .sh2 .bdg{display:none !important}
71
+
72
+ /* Phone: title and tools on one row, tools icon-only, date line below */
73
+ /* ===== v2: two rows above content ===== */
74
+ /* Row 1: the page tabs carry the page name; date, freshness dot and tools sit on the same row */
75
+ .c3 .c3main{position:relative}
76
+ .c3 .c3top h1{display:none !important}
77
+ .c3 .c3top{position:absolute;top:0;right:0;left:auto;align-items:center !important;gap:10px;z-index:3;padding:0 !important;margin:0 !important;min-height:44px;background:transparent}
78
+ .c3 .c3top > div:first-child{display:flex;align-items:center;gap:8px;order:1}
79
+ .c3 .c3top .sub{font-size:var(--fs-1);margin:0}
80
+ .c3 .c3top .fr-fresh.qt-open{position:absolute;top:44px;right:0;background:var(--card);border:1px solid var(--border);border-radius:8px;padding:8px 10px;flex-wrap:wrap;max-width:560px;box-shadow:0 6px 24px rgb(0 0 0 / .08)}
81
+ .c3 nav.c3tabs{margin-top:0 !important;min-height:44px}
82
+ /* Row 2: one context bar per page */
83
+ .c3 .qt-ctx{display:flex !important;align-items:center;gap:10px;flex-wrap:wrap}
84
+ .c3 .qt-ctx [data-ini="board-tabs"]{margin-left:auto}
85
+ .c3 [data-ini-view="board"] > .calbody > .calbar:has([data-ini="board-tabs"]){display:none !important}
86
+ .c3 [data-ini-view="portfolio"] .calbody > .lh{display:none !important}
87
+ .c3 .stance[data-bf="lens-bar"]{display:none !important}
88
+ .c3 .rp3-head:not(:has(.rp3-pick)){display:none !important} /* Portfolio: the tab row says it all */
89
+ .c3 .rp3-head > .ki,.c3 .rp3-head [data-rp3-title],.c3 .rp3-head .rp3-min0 > .dim{display:none !important} /* a board or an initiative: only its chooser */
90
+ .c3 .c3add{display:flex;align-items:center;gap:10px}
91
+ .c3 .c3add .form{flex:1 1 auto}
92
+ .c3 .wk > .calbar.qt-moved{display:none !important}
93
+ .c3 .qt-ctx{justify-content:flex-start !important;flex-direction:row !important}
94
+ .c3 .qt-ctx > [data-ini="crumbs"]{order:1}
95
+ .c3 .qt-ctx > [data-ini="board-tabs"]{order:2;margin-left:12px !important}
96
+ .c3 .qt-ctx > .chips2:not([data-ini="crumbs"]){order:3;margin-left:auto}
97
+ .c3 [data-ini-view="board"] > .calbody{padding-top:4px}
98
+ .c3.qt-stack .c3top{position:static;justify-content:flex-end !important;min-height:0}
99
+ .c3.qt-stack nav.c3tabs{padding-right:0}
100
+ }
@@ -0,0 +1,164 @@
1
+ /* pm/c3-quiet.js — quieter console3 pages (c3-quiet-pages, PM-approved 2026-10-12: the mockup's
2
+ * version 2, linked from the initiative record).
3
+ *
4
+ * mountQuiet(root) links c3-quiet.css once and keeps the page in its quiet layout as the views draw:
5
+ * - the freshness line folds into one dot next to the date ("4 sources not read"); pressing the dot shows the full line;
6
+ * - a board: its status joins the breadcrumb, the board tabs join that row, Backlog, Releases, Docs and Sprints go
7
+ * behind More, Dashboard and the all-zero Work items card are not shown;
8
+ * - the portfolio: the headline joins the breadcrumb, an empty triage card is not shown, the timeline folds;
9
+ * - empty chips ("No owner", "0 epics", ...) are not drawn;
10
+ * - Calendar: the layer chips sit behind one Layers button, the Detail card shows once a meeting is picked;
11
+ * - Work: the lenses sit beside Add work; Reports: Share and Edit dashboard join the tab row.
12
+ * Desktop only: below 601px the page keeps today's layout.
13
+ * The views draw their own markup; this module only moves and hides it, so every press still goes to the view's own handler.
14
+ * It runs again, debounced, whenever the views redraw (one MutationObserver on the root). */
15
+
16
+ import { button } from './primitives.js';
17
+
18
+ const EMPTY_CHIP = /^(No owner|No dates yet|0 epics|0%|No release planned|Build and deliver)$/;
19
+ const MORE_TABS = /^(backlog|releases|docs|sprints)$/;
20
+ let layersOpen = false; // the Calendar's Layers button, for this page load
21
+ const BAD_FRESH = '.fr-fresh__state--never,.fr-fresh__state--stale,.fr-fresh__state--failed';
22
+
23
+ /** Link c3-quiet.css once, from this module's own folder (the way c3-scan.js links its stylesheet). */
24
+ export function ensureQuietCss() {
25
+ if (typeof document === 'undefined' || document.querySelector('link[data-c3-quiet]')) return;
26
+ const l = document.createElement('link');
27
+ l.rel = 'stylesheet'; l.href = new URL('./c3-quiet.css', import.meta.url).href; l.setAttribute('data-c3-quiet', '');
28
+ document.head.appendChild(l);
29
+ }
30
+
31
+ const mk = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
32
+ const warnWords = (t) => /attention|off track|at risk|late/i.test(t);
33
+
34
+ /* Header: the freshness dot. The Sessions button stays: it is the one way to the running sessions. */
35
+ function header(root) {
36
+ const top = root.querySelector('.c3top');
37
+ if (!top) return;
38
+ const fr = top.querySelector('.fr-fresh');
39
+ const date = top.querySelector('[data-c3pg="date"]');
40
+ if (fr && date) {
41
+ let dot = top.querySelector('.qt-fresh');
42
+ if (!dot) {
43
+ const press = () => { const on = fr.classList.toggle('qt-open'); dot.setAttribute('aria-expanded', String(on)); };
44
+ dot = button({ label: '', variant: 'ghost', size: 'sm', onClick: press, attrs: { class: 'qt-fresh', 'data-c3pg': 'fresh-dot', 'aria-expanded': 'false' } });
45
+ dot.prepend(mk('span', 'qt-fresh__dot'));
46
+ date.after(dot);
47
+ }
48
+ const bad = fr.querySelectorAll(BAD_FRESH).length;
49
+ const words = bad ? bad + (bad === 1 ? ' source' : ' sources') + ' not read' : 'Sources read';
50
+ dot.classList.toggle('ok', !bad);
51
+ if (dot.title !== words) { dot.title = words; dot.setAttribute('aria-label', words + '. Show when each source was read'); }
52
+ }
53
+ // The tools sit on the tab row when both fit; when they do not (a narrow window, the demo banner), they take their own row.
54
+ const nav = root.querySelector('nav.c3tabs');
55
+ const w = Math.ceil(top.getBoundingClientRect().width);
56
+ if (nav && w) {
57
+ const tabs = [...nav.querySelectorAll('[role="tab"]')];
58
+ const need = tabs.length ? tabs[tabs.length - 1].getBoundingClientRect().right - nav.getBoundingClientRect().left : 0;
59
+ root.classList.toggle('qt-stack', need + w + 32 > (nav.parentElement || nav).getBoundingClientRect().width);
60
+ }
61
+ }
62
+
63
+ /* A board: one context row, five tabs and More, a gear for Board settings. */
64
+ function board(v) {
65
+ const crumbs = v.querySelector('[data-ini="crumbs"]');
66
+ const st = v.querySelector('.ini-lhm .dim');
67
+ if (crumbs && st) {
68
+ let s = crumbs.querySelector('.qt-status');
69
+ const t = st.textContent.split('.')[0];
70
+ if (!s) { s = mk('span', 'qt-status'); crumbs.appendChild(s); }
71
+ if (s.textContent !== t) { s.textContent = t; s.classList.toggle('warn', warnWords(t)); }
72
+ }
73
+ // Dashboard lives in Reports, which already holds the board dashboard.
74
+ for (const b of v.querySelectorAll(':scope > .calbar button')) if (b.textContent.trim() === 'Dashboard') b.classList.add('qt-hide');
75
+ const tabs = v.querySelector('[data-ini="board-tabs"]');
76
+ if (tabs) {
77
+ for (const t of tabs.querySelectorAll('[data-ini-tab]')) if (MORE_TABS.test(t.dataset.iniTab)) t.classList.add('qt-more-tab');
78
+ if (!tabs.querySelector('.qt-more')) {
79
+ const m = button({ label: 'More', variant: 'ghost', size: 'sm', attrs: { class: 'qt-more', 'aria-expanded': 'false' },
80
+ onClick: () => { const on = tabs.classList.toggle('qt-show'); m.setAttribute('aria-expanded', String(on)); } });
81
+ tabs.appendChild(m);
82
+ }
83
+ // a tab behind More that is the open one keeps the row shown, so the pressed tab never disappears
84
+ if (tabs.querySelector('.qt-more-tab[aria-pressed="true"]')) tabs.classList.add('qt-show');
85
+ const row = crumbs && crumbs.parentElement;
86
+ if (row && tabs.parentElement !== row) { row.classList.add('qt-ctx'); row.insertBefore(tabs, row.querySelector(':scope > .chips2:not([data-ini="crumbs"])')); }
87
+ }
88
+ for (const s of v.querySelectorAll('.bcol.r > section')) {
89
+ if (/Work items/i.test(s.textContent) && (s.textContent.match(/\b\d+\b/g) || []).every((x) => x === '0')) s.classList.add('qt-hide');
90
+ }
91
+ }
92
+
93
+ /* The portfolio: the headline beside the breadcrumb, no empty triage card, the timeline folded. */
94
+ function portfolio(v) {
95
+ const c = v.querySelector('[data-ini="crumbs"]'), h = v.querySelector('.ini-lhm b');
96
+ if (c && h) {
97
+ let s = c.querySelector('.qt-status');
98
+ if (!s) { s = mk('span', 'qt-status'); c.appendChild(s); }
99
+ if (s.textContent !== h.textContent) { s.textContent = h.textContent; s.classList.toggle('warn', warnWords(h.textContent)); }
100
+ }
101
+ for (const s of v.querySelectorAll('.bcol.r section')) s.classList.toggle('qt-hide', /No new asks/.test(s.textContent));
102
+ const tl = [...v.querySelectorAll('section.card.sect')].find((s) => /Timeline across boards/.test((s.querySelector('h2') || {}).textContent || ''));
103
+ if (tl && !tl.closest('.qt-fold')) {
104
+ const d = mk('details', 'qt-fold card'); d.setAttribute('data-c3pg', 'pf-timeline-fold');
105
+ d.appendChild(mk('summary', null, 'Timeline across boards'));
106
+ tl.before(d); d.appendChild(tl);
107
+ }
108
+ }
109
+
110
+ /* Phone widths keep today's layout (the phone gets its own companion design), so nothing moves below 601px. */
111
+ const wide = () => typeof matchMedia !== 'function' || matchMedia('(min-width: 601px)').matches;
112
+
113
+ function apply(root) {
114
+ if (!wide()) return;
115
+ header(root);
116
+ for (const v of root.querySelectorAll('[data-ini-view="board"]')) board(v);
117
+ for (const v of root.querySelectorAll('[data-ini-view="portfolio"]')) portfolio(v);
118
+ // Two columns become one when the right column has nothing left to show.
119
+ for (const c of root.querySelectorAll('[data-ini-view] .bcols')) {
120
+ const r = c.querySelector(':scope > .bcol.r');
121
+ if (r) c.classList.toggle('qt-one', ![...r.children].some((x) => getComputedStyle(x).display !== 'none'));
122
+ }
123
+ for (const c of root.querySelectorAll('.nrow .chips > .chip2')) if (EMPTY_CHIP.test(c.textContent.trim())) c.classList.add('qt-hide');
124
+ for (const c of root.querySelectorAll('.chips2, .chips')) if (/^Open the Board\s*Open the Timeline$/.test(c.textContent.trim())) c.classList.add('qt-hide');
125
+ // Settings: the "This computer" tag under each field and the Welcome note are not drawn.
126
+ for (const t of root.querySelectorAll('.c3set .tag')) if (t.textContent.trim() === 'This computer') t.classList.add('qt-hide');
127
+ for (const f of root.querySelectorAll('.c3set .fold')) if (/^The Welcome page asks/.test(f.textContent.trim())) f.classList.add('qt-hide');
128
+ // Calendar: one Layers button for the layer chips. Open or folded holds across the calendar's redraws (a chip press redraws it).
129
+ for (const c of root.querySelectorAll('[data-c3-pane="cal"] [data-cal3="page"]')) {
130
+ const lens = c.querySelector(':scope > .calbar .lens'), chips = c.querySelector('[data-cal3="chips"]');
131
+ if (!lens || !chips) continue;
132
+ let b = lens.querySelector('.qt-layers');
133
+ if (!b) {
134
+ b = button({ label: 'Layers', variant: 'ghost', size: 'sm', attrs: { class: 'qt-layers', 'data-c3pg': 'cal-layers' }, onClick: () => { layersOpen = !layersOpen; apply(root); } });
135
+ lens.appendChild(b);
136
+ }
137
+ b.setAttribute('aria-expanded', String(layersOpen));
138
+ chips.classList.toggle('qt-open', layersOpen);
139
+ }
140
+ // Calendar: the Detail card shows once a meeting or a marker is picked (not its "Select a meeting" note).
141
+ for (const d of root.querySelectorAll('[data-cal3="detail"]')) d.classList.toggle('qt-hide', /^Select a meeting to see/.test((d.querySelector('.cal3-note') || {}).textContent || '') && !d.querySelector('button, a'));
142
+ // Work: the lenses beside Add work.
143
+ for (const w of root.querySelectorAll('.wk')) {
144
+ const add = w.querySelector(':scope > .c3add'), bar = w.querySelector(':scope > .calbar'), l = bar && bar.querySelector('[data-pm-lenses]');
145
+ if (add && l && l.parentElement === bar) { for (const o of add.querySelectorAll('[data-pm-lenses]')) o.remove(); add.appendChild(l); bar.classList.add('qt-moved'); }
146
+ }
147
+ // Reports: Share and Edit dashboard on the tab row.
148
+ for (const r of root.querySelectorAll('[data-rp3]')) {
149
+ const scope = r.querySelector(':scope > .calbar .rp3-scope'), acts = r.querySelector('.rp3-head > .chips2');
150
+ if (scope && acts) { for (const o of scope.querySelectorAll('.qt-acts')) o.remove(); acts.classList.add('qt-acts'); scope.appendChild(acts); }
151
+ }
152
+ }
153
+
154
+ /** Keep `root` (the console3 root) in the quiet layout. Safe to call once; later calls do nothing. */
155
+ export function mountQuiet(root) {
156
+ if (!root || root.dataset.quiet) return;
157
+ root.dataset.quiet = '1';
158
+ ensureQuietCss();
159
+ let t = null;
160
+ const run = () => { t = null; try { apply(root); } catch { /* a view mid-draw; the next mutation runs it again */ } };
161
+ run();
162
+ new MutationObserver(() => { if (!t) t = setTimeout(run, 50); }).observe(root, { subtree: true, childList: true });
163
+ if (typeof matchMedia === 'function') matchMedia('(min-width: 601px)').addEventListener('change', () => { if (!t) t = setTimeout(run, 50); });
164
+ }
@@ -49,6 +49,7 @@ import { registry, filterPalette, rowStep } from './briefing-model.js';
49
49
  import { freshnessLine } from './freshness-line.js';
50
50
  import { mountInitiatives } from './c3-initiatives.js';
51
51
  import { mountSettings, gearIcon, applyLook } from './c3-settings.js';
52
+ import { mountQuiet } from './c3-quiet.js';
52
53
 
53
54
  /* In demo mode each tab's deck asks for its own cards (the mockup draws a different deck per tab); real mode sends no scope. Reports always sends its own (repTab). */
54
55
  const sc = (name) => (DEMO ? name : undefined);
@@ -63,6 +64,9 @@ const THEME_KEY = 'pmos.c3.theme';
63
64
  const q = new URLSearchParams(location.search);
64
65
  const store = { get(k) { try { return localStorage.getItem(k); } catch { return null; } }, set(k, v) { try { localStorage.setItem(k, v); } catch { /* no storage */ } } };
65
66
  const DEMO = q.get('demo') === '1'; // demo mode: c3-demo.js answers the page's /api reads with the mockup's sample content
67
+ /* The dock's open or folded choice, kept on this device. No choice yet: folded. */
68
+ const dockPref = () => store.get('pmos.c3.dock') === 'open';
69
+ const keepDock = () => store.set('pmos.c3.dock', S.dockOpen ? 'open' : 'folded');
66
70
  const S = { demo: DEMO, ws: q.get('ws') || '', workspaces: [], tab: 'brief', theme: 'light', panes: {}, mounted: {}, init: 'lifecycle', initRows: null, dockOpen: true, wide: true };
67
71
 
68
72
  /* A view reads a route with no ws and the server answers for its home workspace. This page names one, so a GET to /api/ with no ws gets it. */
@@ -136,7 +140,7 @@ function toggleMore() {
136
140
  item('phone', 'Phone preview', () => togglePreview(), !!S.preview, { 'data-a': 'phone' }),
137
141
  item(S.theme === 'dark' ? 'sun' : 'moon', S.theme === 'dark' ? 'Light theme' : 'Dark theme', () => setTheme(S.theme === 'dark' ? 'light' : 'dark')),
138
142
  ((b) => { b.firstChild.replaceWith(gearIcon('sm')); return b; })(item('info', 'Settings', () => showTab('settings', true), false, { 'data-a': 'settings' })),
139
- item('term', S.dockOpen ? 'Fold the dock' : 'Open the dock', () => { S.dockOpen = !S.dockOpen; syncDock(); }, false, { 'data-a': 'dockt' }, 'Ctrl J'),
143
+ item('term', S.dockOpen ? 'Fold the dock' : 'Open the dock', () => { S.dockOpen = !S.dockOpen; keepDock(); syncDock(); }, false, { 'data-a': 'dockt' }, 'Ctrl J'),
140
144
  el('a', { class: 'c3pg-mi', role: 'menuitem', href: '/classic.html' }, c3ic('term', 'sm'), el('span', null, 'Open the classic app')));
141
145
  S.root.append(S.menu); // on the page root with position:fixed, so no ancestor's overflow, transform or stacking order can hide it
142
146
  S.placeMenu = () => { const r = S.moreBtn.getBoundingClientRect(); const w = S.menu.offsetWidth || 220; S.menu.style.top = Math.round(r.bottom + 6) + 'px'; S.menu.style.left = Math.round(Math.max(8, Math.min(r.right - w, innerWidth - w - 8))) + 'px'; S.menu.style.maxHeight = Math.max(160, innerHeight - r.bottom - 14) + 'px'; };
@@ -389,7 +393,7 @@ const sheetOpen = () => !!document.querySelector('[data-shell="sheet"],[data-she
389
393
  function onKey(e) {
390
394
  const ae = document.activeElement;
391
395
  if ((e.metaKey || e.ctrlKey) && !e.altKey) {
392
- if (e.key === 'j' || e.key === 'J') { e.preventDefault(); e.stopPropagation(); if (!e.repeat) { S.dockOpen = !S.dockOpen; syncDock(); } } // stopPropagation: the dock has its own Ctrl J handler on the bubble phase, and two toggles cancel out
396
+ if (e.key === 'j' || e.key === 'J') { e.preventDefault(); e.stopPropagation(); if (!e.repeat) { S.dockOpen = !S.dockOpen; keepDock(); syncDock(); } } // stopPropagation: the dock has its own Ctrl J handler on the bubble phase, and two toggles cancel out
393
397
  else if (e.key === 'k' || e.key === 'K') { e.preventDefault(); if (S.pal) closePalette(true); else openPalette(); }
394
398
  return;
395
399
  }
@@ -419,7 +423,7 @@ function onKey(e) {
419
423
  /* ── the dock: the mockup's repository, branch and session tree (c3-dock.js) ── */
420
424
  function dock() {
421
425
  S.dock = createDock({
422
- setOpen: (on) => { S.dockOpen = !!on; syncDock(); },
426
+ setOpen: (on) => { S.dockOpen = !!on; keepDock(); syncDock(); },
423
427
  onWs: (id) => { const u = new URL(location.href); u.searchParams.set('ws', id); location.assign(u.toString()); },
424
428
  /* the needs-you badge on the agent button: runs that ask, hold a permission or need the terminal (not the running ones) */
425
429
  onCount: (live, need) => { S.termBdg.textContent = String(need); S.termBdg.hidden = need === 0; import('./c3-terminal-icons.js').then((m) => { const t = m.TIPS.agentHeader(need); S.termBtn.title = t; S.termBtn.setAttribute('aria-label', t); }); }, // the badge counts runs that need you, never running ones
@@ -544,7 +548,7 @@ function showTab(id, push) {
544
548
  else S.settings.refresh();
545
549
  } else if (!S.mounted[id]) { S.mounted[id] = true; BUILD[id](S.panes[id]); }
546
550
  S.root.dataset.tab = id;
547
- S.dockOpen = S.wide && id === 'brief'; syncDock();
551
+ S.dockOpen = S.wide && id === 'brief' && dockPref(); syncDock(); // the dock starts folded (c3-quiet-pages); Ctrl J or the More menu opens it, and the choice is kept
548
552
  S.pageView.classList.remove('view'); void S.pageView.offsetWidth; S.pageView.classList.add('view');
549
553
  moveInd();
550
554
  if (push) S.userNav = true;
@@ -602,6 +606,7 @@ async function boot() {
602
606
  root.replaceChildren(S.grid);
603
607
  mountDaypart(root, root.querySelector('.c3top'));
604
608
  mountAmbient(root); // the ambient motion canvas, once, behind the grid
609
+ mountQuiet(root); // the quiet layout: two rows above the content, no teaching sentences (c3-quiet-pages)
605
610
 
606
611
  S.dock.setWorkspaces(S.workspaces, S.ws);
607
612
  mountBell(S.bellHost, { go: (_, t) => goTarget(t) });
@@ -611,7 +616,7 @@ async function boot() {
611
616
  mountTicker(S.tickHost, { onOpen: () => openBellList() });
612
617
  import('./shell-recording.js').then((m) => m.mountRecording(S.tools)).catch(() => { /* the recorder is not available on this page */ });
613
618
  S.narrow = matchMedia('(max-width:980px)');
614
- S.narrow.addEventListener('change', () => { S.wide = !S.narrow.matches; S.dockOpen = S.wide && S.tab === 'brief'; syncDock(); });
619
+ S.narrow.addEventListener('change', () => { S.wide = !S.narrow.matches; S.dockOpen = S.wide && S.tab === 'brief' && dockPref(); syncDock(); });
615
620
  S.wide = !S.narrow.matches;
616
621
  /* The saved settings set the start page (look.start) when the address names no tab, and turn the time-of-day tint and strip off or on. */
617
622
  let start = q.get('tab');
@@ -144,7 +144,8 @@
144
144
  "c3-driver-test.js",
145
145
  "c3-scan-test.js",
146
146
  "c3-sessions-panel-test.js",
147
- "c3-updates-test.js"
147
+ "c3-updates-test.js",
148
+ "c3-quiet-test.js"
148
149
  ],
149
150
  "ui": [
150
151
  "ui-test.js"
@@ -285,7 +286,8 @@
285
286
  "settings-store.test.js": 1,
286
287
  "c3-settings-test.js": 25,
287
288
  "c3-sessions-panel-test.js": 20,
288
- "c3-updates-test.js": 20
289
+ "c3-updates-test.js": 20,
290
+ "c3-quiet-test.js": 30
289
291
  },
290
292
  "_comment_seconds": "Measured wall time of each suite run alone (4-CPU sandbox, 2026-10-05). Only the ORDER uses it: the slowest suites start first, and --shard deals suites to the least-loaded shard so the shards finish together. A wrong or missing number never skips a suite; a missing one counts as 10."
291
293
  }