create-pmos 0.495.0 → 0.496.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 (45) hide show
  1. package/package.json +38 -14
  2. package/payload/AGENTS.md +2 -0
  3. package/payload/KIT-VERSION +2 -2
  4. package/payload/planning/evals/eval-calibration.md +1 -1
  5. package/payload/planning/okrs/health-budgets.md +1 -1
  6. package/payload/state/discoveries.md +1 -1
  7. package/payload/state/log.md +1 -1
  8. package/workspace/adler-moments-test.js +3 -1
  9. package/workspace/board-programs-test.js +6 -6
  10. package/workspace/board-shared-ui-test.js +2 -2
  11. package/workspace/briefing-premium-adddoc-test.js +1 -48
  12. package/workspace/briefing-premium-board-test.js +18 -19
  13. package/workspace/changelog.json +37 -0
  14. package/workspace/guide/adler.md +1 -1
  15. package/workspace/guide/briefing-premium.md +1 -0
  16. package/workspace/guide/claude-proposes.md +6 -2
  17. package/workspace/guide/data-freshness.md +2 -0
  18. package/workspace/guide/home.md +1 -1
  19. package/workspace/guide/published-board.md +1 -1
  20. package/workspace/guide/workflows.md +4 -3
  21. package/workspace/lib/board-premium.js +17 -2
  22. package/workspace/lib/initiative-chat.js +4 -1
  23. package/workspace/lib/proposals.js +18 -4
  24. package/workspace/lib/routes-board-premium.js +2 -2
  25. package/workspace/lib/routes-proposals.js +14 -2
  26. package/workspace/lib/split-merge-suggest.js +37 -3
  27. package/workspace/lib/split-merge.js +10 -4
  28. package/workspace/lib/task-cli.js +5 -1
  29. package/workspace/lib/workflow-cli.js +70 -15
  30. package/workspace/package-lock.json +2 -2
  31. package/workspace/package.json +1 -1
  32. package/workspace/premium-board-actions-test.js +7 -9
  33. package/workspace/premium-core-doc-issue-test.js +9 -38
  34. package/workspace/public/pm/cozy-board.css +17 -5
  35. package/workspace/public/pm/home-app.js +12 -4
  36. package/workspace/public/pm/home-today.js +11 -4
  37. package/workspace/public/pm/load-shapes.css +57 -0
  38. package/workspace/public/pm/load-shapes.js +57 -0
  39. package/workspace/public/pm/premium-board.js +82 -57
  40. package/workspace/public/pm/readonly.js +3 -3
  41. package/workspace/public/pm/split-merge.js +3 -2
  42. package/workspace/public/premium-board.html +7 -10
  43. package/workspace/public/premium.html +1 -0
  44. package/workspace/split-merge-ui-test.js +1 -1
  45. package/workspace/test-lists/page-open-speed.json +1 -1
@@ -87,28 +87,14 @@ const BODY = '## Purpose\n\nWhy this board exists.\n\n## Scope\n\nWhat it covers
87
87
  const H = await T.pressHeaders(BASE);
88
88
  const nonce = H['x-pmos-nonce'];
89
89
 
90
- /* B the Board reader (premium-uncovered-features, BdDocs: the Documents tab is a table; a row's title opens the document in the side panel,
91
- with its health, edit log, the file's text and the Issue it bar) */
92
- const bToast = (p, re) => until(() => p.evaluate((s) => { const t = document.querySelector('#bdToast'); return t && !t.hidden && new RegExp(s).test(t.textContent) ? t.textContent : null; }, re.source));
93
- const P = await open('/premium-board.html'), p = P.page;
94
- await until(() => p.evaluate(() => document.querySelectorAll('#ibBody .ini2').length > 0), 60000);
95
- await p.click('#tabDocs');
96
- ok('B1 a board document is a row whose title opens the reader', await until(() => p.evaluate((k) => !!document.querySelector(`#docBody [data-opendoc="core:${k}"]`), bkey), 60000));
97
- ok('B1 every document row has a title that opens it', await p.evaluate(() => [...document.querySelectorAll('#docBody .bd-dt[data-docrow]')].every((r) => !!r.querySelector('[data-opendoc]'))));
98
- await p.click(`#docBody [data-opendoc="core:${bkey}"]`);
99
- ok('B2 the reader shows the file\'s text and path, and the bar says nothing is sent', await until(() => p.evaluate((pt) => { const o = document.querySelector('#czViewer'); return !o.hidden && /Why this board exists/.test(o.textContent) && o.textContent.includes(pt) && !!o.querySelector('[data-issue-bar="draft"]') && /Nothing is sent/.test(o.querySelector('[data-issue-bar]').textContent) && !!o.querySelector('[data-act="issue"]'); }, d1.path)), await p.evaluate(() => document.querySelector('#czViewer').textContent));
100
- ok('B2 no old dialog opens', await p.evaluate(() => !!!document.querySelector('#dlg.on')));
90
+ /* B a board's own core document. Batch 24 item 7 took the Documents tab off the Board (Search's Documents filter reaches the file), so no Board press
91
+ issues it; the issue route still takes a board scope */
101
92
  const n0 = ledger().filter((l) => l.ev === 'issue').length;
102
- await p.click('#czViewer [data-act="issue"]');
103
- ok('B3 Issue it says it sent nothing', !!(await bToast(p, /Issued\. Nothing was sent to anyone/)));
104
- const sent = P.posts.filter((x) => x.path === '/api/core-docs/issue');
105
- ok('B4 it posted the workspace, the scope, the key and the slot, and nothing else, with the launch nonce', sent.length === 1 && Object.keys(sent[0].body).sort().join() === 'key,scope,slot,ws' && sent[0].body.scope === bscope && sent[0].body.key === bkey && sent[0].body.slot === d1.slot && sent[0].body.ws === B.ws && sent[0].headers['x-pmos-nonce'] === nonce && !!nonce, JSON.stringify(sent));
93
+ const bres = await fetch(BASE + '/api/core-docs/issue', { method: 'POST', headers: H, body: JSON.stringify({ ws: B.ws, scope: bscope, key: bkey, slot: d1.slot }) });
94
+ ok('B4 the issue route takes a board scope with the launch nonce', bres.status === 200, String(bres.status));
106
95
  const il = ledger().filter((l) => l.ev === 'issue');
107
- ok('B5 the ledger has one new issue line for that draft, written by the PM', il.length === n0 + 1 && il[il.length - 1].slot === d1.slot && il[il.length - 1].scope === bscope && /pm/.test(String(il[il.length - 1].by)), JSON.stringify(il));
96
+ ok('B5 the ledger has one new issue line for that draft', il.length === n0 + 1 && il[il.length - 1].slot === d1.slot && il[il.length - 1].scope === bscope, JSON.stringify(il));
108
97
  ok('B5 the file now says state: issued', /^state: issued$/m.test(fs.readFileSync(path.join(team, d1.path), 'utf8')));
109
- ok('B6 the reader says it is issued, the Issue it press is gone, and the row reads Up to date', await until(() => p.evaluate(() => { const o = document.querySelector('#czViewer'); return !o.hidden && !!o.querySelector('[data-issue-bar="done"]') && !o.querySelector('[data-act="issue"]'); }), 30000) && await until(() => p.evaluate((k) => { const r = document.querySelector(`#docBody [data-docrow="core:${k}"]`); return r && /Up to date/.test(r.textContent); }, bkey), 30000), await p.evaluate(() => document.querySelector('#czViewer').textContent + ' // ' + document.querySelector('#docBody').textContent));
110
- await p.screenshot({ path: path.join(R.base, 'board-issued.png') });
111
- await P.ctx.close();
112
98
 
113
99
  /* I the initiative: a layout inside Home (premium-agreed-design). The Board's expand link lands there; the card's Overview lists the core documents (its Documents view),
114
100
  and a draft opens a minimal reader in the Viewer (Waiting for design) with the Issue it bar, Read and Edit */
@@ -163,33 +149,18 @@ const BODY = '## Purpose\n\nWhy this board exists.\n\n## Scope\n\nWhat it covers
163
149
  await jp.screenshot({ path: path.join(R.base, 'ini-issued.png') });
164
150
 
165
151
  /* P the POST allow-list and the refusals */
166
- const all = P.posts.concat(I.posts, J.posts);
152
+ const all = I.posts.concat(J.posts);
167
153
  const other = all.filter((x) => !ALLOWED.test(x.path) && x.path !== '/api/loose-props/rows'); // what Home shows loose, sent when it changed so Claude can propose a clean-up (batch 13 item 3); it changes no work
168
- ok('P1 the pages post only to the issue route (and the conversation note)', all.length >= 3 && !other.length, JSON.stringify(other));
154
+ ok('P1 the pages post only to the issue route (and the conversation note)', all.length >= 2 && !other.length, JSON.stringify(other));
169
155
  const sentAll = all.filter((x) => x.path === '/api/core-docs/issue');
170
- ok('P2 every issue press carried the nonce and no writer field', sentAll.length >= 3 && sentAll.every((x) => x.headers['x-pmos-nonce'] === nonce && !['by', 'via', 'at', 'actor', 'pm', 'nonce'].some((k) => k in x.body)));
156
+ ok('P2 every issue press carried the nonce and no writer field', sentAll.length >= 2 && sentAll.every((x) => x.headers['x-pmos-nonce'] === nonce && !['by', 'via', 'at', 'actor', 'pm', 'nonce'].some((k) => k in x.body)));
171
157
  const bare = await fetch(BASE + '/api/core-docs/issue', { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: BASE }, body: JSON.stringify({ ws: B.ws, scope: bscope, key: bkey, slot: d1.slot }) });
172
158
  ok('P3 a press without the launch nonce is refused', bare.status === 403);
173
159
  const agent = await fetch(BASE + '/api/core-docs/issue', { method: 'POST', headers: { ...H, 'x-pmos-session': 's1' }, body: JSON.stringify({ ws: B.ws, scope: bscope, key: bkey, slot: d1.slot }) });
174
160
  ok('P4 D98: an agent session is not refused as an agent (the press is checked like the PM\'s)', agent.status !== 403, String(agent.status));
175
- ok('P5 no script error on any page', !P.errors.length && !I.errors.length && !J.errors.length, P.errors.concat(I.errors, J.errors).join(' | '));
161
+ ok('P5 no script error on any page', !I.errors.length && !J.errors.length, I.errors.concat(J.errors).join(' | '));
176
162
  await J.ctx.close();
177
163
 
178
- /* F floors and fidelity: a fresh draft, at 390 and 1280, light and dark */
179
- const d4 = CD.writeDraft(team, { scope: bscope, key: (board2.model.docs.rows.filter((d) => d.act && d.act.kind === 'draft' && d.st === 'missing' && d.key !== bkey)[0] || {}).key, markdown: BODY, by: 'agent:test', now: Date.now() });
180
- const k4 = d4.path.split('/').slice(-2)[0];
181
- for (const [w, h, theme] of [[390, 844, 'light'], [390, 844, 'dark'], [1280, 900, 'dark']]) {
182
- const S = await open('/premium-board.html', { w, h, theme });
183
- await until(() => S.page.evaluate(() => document.querySelectorAll('#ibBody .ini2').length > 0), 60000);
184
- await S.page.click(w <= 720 ? '#chDocs' : '#tabDocs');
185
- await until(() => S.page.evaluate((k) => !!document.querySelector(`#docBody [data-opendoc="core:${k}"]`), k4), 60000);
186
- await S.page.click(`#docBody [data-opendoc="core:${k4}"]`);
187
- await until(() => S.page.evaluate(() => !!document.querySelector('#czViewer [data-act="issue"]')));
188
- const f = await S.page.evaluate(() => ({ wide: document.documentElement.scrollWidth > innerWidth + 1, small: [...document.querySelectorAll('#czViewer [data-act="issue"]')].filter((b) => b.getBoundingClientRect().height < (innerWidth <= 720 ? 44 : 34)).map((b) => b.textContent) }));
189
- ok(`F1 ${w} px ${theme}: no sideways scroll and the bar's button is 44 px on a phone`, !f.wide && !f.small.length, JSON.stringify(f));
190
- await S.page.screenshot({ path: path.join(R.base, `bar-${w}-${theme}.png`) });
191
- await S.ctx.close();
192
- }
193
164
  if (process.env.KEEP_SHOTS) { for (const f of fs.readdirSync(R.base).filter((x) => /\.png$/.test(x))) fs.copyFileSync(path.join(R.base, f), path.join(process.env.KEEP_SHOTS, f)); }
194
165
  finish();
195
166
  })();
@@ -115,9 +115,22 @@ body.bd-page main.bd-main { position: relative; max-width: none; margin: 16px; p
115
115
  .bd-chip em { font-style: normal; font-weight: 600; color: var(--cz-ink3); margin-left: 2px; }
116
116
  .bd-chip:disabled { opacity: .6; }
117
117
  .bd-sp { margin-left: auto; }
118
- .bd-spans button { height: 28px; padding: 0 11px; border-radius: 999px; border: 0; background: none; font: 600 var(--fs-2) var(--cz-sans); color: var(--cz-ink2); cursor: pointer; }
119
- .bd-spans button[aria-pressed="true"] { background: var(--cz-card); color: var(--cz-ink); font-weight: 800; box-shadow: 0 1px 3px rgba(42,46,53,.12); }
120
- .bd-spans[hidden] { display: none; }
118
+ /* the calmer top bar (batch 24 item 7, canvas BbB): one pill for what needs a look, the legend behind (i), Jira as Adler's face */
119
+ .bd-chip.bd-need { background: var(--cz-well); border-color: transparent; gap: 10px; padding: 0 10px 0 12px; font-weight: 800; color: var(--cz-ink); }
120
+ .bd-chip.bd-need > span { display: inline-flex; align-items: center; gap: 5px; }
121
+ .bd-chip.bd-need.on { background: var(--cz-bluebg); color: var(--cz-blueink); }
122
+ .bd-needm { min-width: 220px; }
123
+ .bd-needm .bd-mlbl, .bd-lgdm .bd-mlbl { font: 800 var(--fs-1) var(--cz-sans); color: var(--cz-ink3); padding: 6px 10px 4px; letter-spacing: .5px; text-transform: uppercase; white-space: nowrap; }
124
+ .bd-needm .bd-nr { display: flex; align-items: center; gap: 10px; }
125
+ .bd-needm .bd-nr .k { margin-left: auto; color: var(--cz-ink3); font-weight: 700; }
126
+ .bd-needm .bd-nr.on { background: var(--cz-bluebg); color: var(--cz-blueink); }
127
+ .bd-needm .cz-dot { width: 7px; height: 7px; }
128
+ .bd-lgdm { min-width: 220px; padding: 6px 12px 10px; }
129
+ .bd-lgdm .tg-lgd { position: static; flex-direction: column; gap: 9px; padding: 4px 0 0; background: none; font-weight: 600; color: var(--cz-ink2); white-space: nowrap; }
130
+ .bd-chip.bd-jf { width: 36px; padding: 0; justify-content: center; border-color: transparent; background: transparent; position: relative; }
131
+ .bd-chip.bd-jf:hover { background: var(--cz-well); }
132
+ .bd-chip.bd-jf svg.ad-svg { width: 24px; height: 24px; }
133
+ .bd-pip { position: absolute; right: 3px; top: 3px; width: 9px; height: 9px; border-radius: 50%; background: var(--cz-risk); border: 2px solid var(--cz-card); }
121
134
 
122
135
  /* ── the "Waiting for design" card, where the shell has none yet; messages ── */
123
136
  .bd-wait { border: 1px dashed var(--cz-hair); border-radius: 18px; padding: 22px; color: var(--cz-ink3); font-size: var(--fs-2); }
@@ -1195,7 +1208,6 @@ html[data-theme="dark"] body.bd-page { --bd-prog: rgb(180,170,240); --bd-progbg:
1195
1208
  .sc .cur .eta { top: 19px; }
1196
1209
  .sc .eta.late { color: var(--cz-late); }
1197
1210
  .bd-kb.lanes .bd-kstrip span { margin-top: 58px; }
1198
- .bd-chipset .tg-lgd { position: static; padding: 0; background: none; font-weight: 600; white-space: nowrap; }
1199
1211
  /* the phone (BdPhone): a program is a head card with its next milestone; its initiatives and the product heads follow as cards */
1200
1212
  @media (max-width: 720px) {
1201
1213
  .bt-pg { display: grid; grid-template-columns: minmax(0, 1fr); border: 0; border-radius: 14px; padding: 8px 6px 10px 4px; min-height: 0; }
@@ -1218,7 +1230,7 @@ html[data-theme="dark"] body.bd-page { --bd-prog: rgb(180,170,240); --bd-progbg:
1218
1230
  .bd-spw .bd-spk svg { width: 14px; height: 14px; }
1219
1231
  body.bd-spick .bd-hd .bd-title { display: none; }
1220
1232
  body.bd-spick .bd-hd .bd-tools { margin-left: auto; }
1221
- @media (max-width: 720px) { .bd-spw .bd-spk, .bd-spw .bd-grpm button { min-height: 44px; } }
1233
+ @media (max-width: 720px) { .bd-spw .bd-spk, .bd-spw .bd-grpm button, .bd-needm .bd-nr { min-height: 44px; } .bd-chip.bd-jf { width: 44px; } .bd-chip.bd-jf::before { display: none; } }
1222
1234
  /* programs first on every span (PgTimeline's rule): the program row brackets its initiatives' bars and carries its milestones */
1223
1235
  .sp-pg { background: var(--bd-progbg); min-height: 56px; }
1224
1236
  .sp-pg .sp-n { flex-direction: row; align-items: center; justify-content: flex-start; }
@@ -48,7 +48,8 @@ import * as AD from './cozy-adler.js'; // Adler, Claude's face (batch 16 item
48
48
  import * as OKL from './okr-list.js'; // the product page's Goals line (batch 16 item 6, GsProduct)
49
49
  import { canDictate, dictate } from './home-dictate.js';
50
50
  import { mountIni, iniList, iniOverview, iniPage, iniAddTask } from './home-ini.js'; // the initiative's page, its stage move, Tasks and Core documents (premium-uncovered-features, HbC)
51
- import * as MEN from './home-mentions.js'; // @Mentions, the permanent row at the top of Home and its Viewer (Viewer kit, XvANew)
51
+ import * as MEN from './home-mentions.js';
52
+ import * as LS from './load-shapes.js'; // the last view, the grey shapes and the held Viewer column while Home reads (page-open-speed) // @Mentions, the permanent row at the top of Home and its Viewer (Viewer kit, XvANew)
52
53
  import { mountRecRow, clockOf as recClock } from './home-rec-row.js';
53
54
  import { mountSesName } from './home-ses-name.js'; // session names A: tap the session's name to rename it (canvas XmsNames)
54
55
  import * as SM from './split-merge.js'; // split one initiative in two, or merge two: Claude suggests, the PM approves (batch 11 item 14)
@@ -173,13 +174,14 @@ async function getRetry(path, tries = 20) {
173
174
  }
174
175
  return { ok: false, error: 'still reading' };
175
176
  }
177
+ const LAST_KEY = (WS || '') + '|' + (BOARD || ''); // the last view Home drew, by its product and board (SpA)
176
178
  let pickGen = 0; // bumped by every saved pick, so a read that started before the save does not put the old pick back
177
179
  async function load() {
178
180
  const gen = pickGen;
179
181
  const [brief, board, ses, desk, today, sch, work, day, rev, progs, , pick] = await Promise.all([
180
182
  getRetry('/api/briefing-premium' + wsQ()), getRetry('/api/board-premium' + wsQ() + boardQ(WS ? '&' : '?')), pmGet('/api/sessions'), pmGet('/api/home-desk' + wsQ()),
181
183
  pmGet('/api/brief-page' + wsQ()), pmGet('/api/schedules' + wsQ()), pmGet('/api/my-work-premium' + wsQ()), pmGet('/api/phone-day'), pmGet('/api/home-review' + wsQ()),
182
- pmGet('/api/programs'), MEN.load(menCtx()), PP.readPick(), SO.load(),
184
+ pmGet('/api/programs'), MEN.load(menCtx()).then((x) => { S.menIn = true; if (!S.ready) drawList(); return x; }), PP.readPick(), SO.load(),
183
185
  ]);
184
186
  const D = S.D;
185
187
  D.brief = brief && brief.ok && brief.model ? brief.model : null;
@@ -345,7 +347,8 @@ function homeListHtml() {
345
347
  S.lo = { sel, ses: S.ses, restOpen: (k) => S.rest.has(k), pin: S.pin, phone: false }; // the folded rail's card draws Home's rows with these
346
348
  return TD.listHtml(M, todayModel(), {
347
349
  sel, selHead: S.head, ses: S.ses, pOpen, here: isPhone() ? null : TD.groupOfOpen(M, sel) || S.head, restOpen: (k) => S.rest.has(k), cap: S.cap, pin: S.pin, avHtml, ready: S.ready, err: S.D.err.length ? S.D.err[0] : '',
348
- pick: S.D.pick, tones: pickTones(), review: R, mic: canDictate(), phone: isPhone(), day: S.day, news: S.news && S.news.ok ? S.news.count : 0, top: S.ready ? RECROW.html() + MEN.rowHtml(S.D, { sel: S.sel }) + (S.impact ? IMP.rowHtml(S.impact.c, { esc, sel: S.sel === 'impact' }) : '') : '',
350
+ pick: S.D.pick, tones: pickTones(), review: R, mic: canDictate(), phone: isPhone(), day: S.day, news: S.news && S.news.ok ? S.news.count : 0, top: S.ready ? RECROW.html() + MEN.rowHtml(S.D, { sel: S.sel }) + (S.impact ? IMP.rowHtml(S.impact.c, { esc, sel: S.sel === 'impact' }) : '') : S.menIn ? '<div class="ls-stale" inert>' + LS.inert(MEN.rowHtml(S.D, {})) + '</div>' : LS.menShape(), // Mentions on its own clock, seen but not pressed until Home is fresh (SpG)
351
+ last: S.ready ? '' : LS.last(LAST_KEY), keep: S.ready && S.filter === 'today' && !S.ini ? (b) => LS.keepLast(LAST_KEY, b) : null,
349
352
  imFace: IM.faceHtml(S.im, { joy: S.imJoy }), grill: S.ready ? grillNews() : null,
350
353
  });
351
354
  }
@@ -1312,7 +1315,10 @@ addEventListener('pp:changed', (e) => { if (WS) { location.assign('/premium.html
1312
1315
 
1313
1316
  /* ── the Viewer: needs rows, the weekly update, the overview, files, people, routines ── */
1314
1317
  let lastViewer = null;
1315
- VW.onChange((d) => { if (d.open) lastViewer = d.id; chat.refresh(); document.body.classList.toggle('hm-vw-open', d.open); });
1318
+ VW.onChange((d) => { if (d.open) { lastViewer = d.id; LS.holdViewer($('.hm-vw'), false); } chat.refresh(); document.body.classList.toggle('hm-vw-open', d.open); });
1319
+ // the Viewer's column held from the first frame when Home opened it last time (SpJ): the chat has its final width at once
1320
+ const VW_HELD = 'pmos.home.vwheld';
1321
+ if (!isPhone() && store.get(VW_HELD) === '1' && !VW.isOpen()) LS.holdViewer($('.hm-vw'), true);
1316
1322
  function reopenViewer() {
1317
1323
  if (/^vw:/.test(lastViewer || '')) { import('./premium-viewer.js').then((M) => { if (!M.vwReopen()) { lastViewer = null; reopenViewer(); } }); return; } // the last file, chart or report shown
1318
1324
  const r = S.sel ? rowById(S.sel) : null;
@@ -1991,6 +1997,8 @@ load().then(() => {
1991
1997
  if (say) setTimeout(() => { const ta = $('#hmIn'); if (ta) { ta.value = say; ta.dispatchEvent(new Event('input', { bubbles: true })); ta.focus(); ta.setSelectionRange(say.length, say.length); } }, 0);
1992
1998
  if (mt) openMeeting({ note: mt });
1993
1999
  if (/^(prop|men|pulse|scan|rep|src)$/.test(P.get('around') || '')) MORE.openAround(P.get('around')); // ?around=<row>: that Around you view (home-around.js)
2000
+ // the Viewer opens now or not at all: the held column goes either way, and the next open holds it only if it opened (SpJ)
2001
+ setTimeout(() => { if (!isPhone()) store.set(VW_HELD, VW.isOpen() ? '1' : null); LS.holdViewer($('.hm-vw'), false); }, 0);
1994
2002
  poll();
1995
2003
  });
1996
2004
  addEventListener('resize', () => { if (!isPhone() && S.sheet) sheet(false); drawList(); });
@@ -18,7 +18,8 @@ import { gripHtml, sugHtml } from './prog-order.js'; // a program's order: the
18
18
  import * as SO from './sort-order.js'; // the sort button and ONE order with the Board (batch 14 item 8, canvas SoAHomePhone)
19
19
  import { prodKey } from './order-model.js';
20
20
  import * as CW from './ctx-wedge.js'; // how full a session's context is: the pie, and Claude's ✦ Compact (batch 13 item 8, XctxA1Desk)
21
- import * as AD from './cozy-adler.js'; // Adler greets on Home (batch 16 item 2, canvas AdH1): the M face, honest when the read failed
21
+ import * as AD from './cozy-adler.js';
22
+ import * as LS from './load-shapes.js'; // the ring, the grey shapes and the last view while Home reads (page-open-speed, SpA SpG SpM) // Adler greets on Home (batch 16 item 2, canvas AdH1): the M face, honest when the read failed
22
23
 
23
24
  export const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
24
25
  export const P = {
@@ -321,8 +322,11 @@ export function listHtml(M, T, o) {
321
322
  // the blue dot after "Good morning": something is new for you, in Your day's New for you fold (feedback batch 16 item 5b, FmA4Desk)
322
323
  const nw = o.news > 0 ? '. ' + o.news + ' new for you' : '';
323
324
  // Your impact (batch 16 item 7, GmA1Home): with it on, the greeting's face is its own press, Adler with the week's leaves (pm/impact.js faceHtml)
324
- const face = o.imFace && !o.err ? '' : AD.adler(o.err ? 'honest' : 'handled', 36, { label: false, cls: 'hv-ad' });
325
- const head = `<div class="hm-hd hv-bar">${o.imFace && !o.err ? o.imFace : ''}<button type="button" class="hv-hi" data-hm-day aria-expanded="${!!o.day}" aria-label="${esc(M.word || 'Today')}${nw}. Open your day">${face}<b>${esc(M.word || 'Today')}</b>${o.news > 0 ? '<i class="hv-nd" aria-hidden="true"></i>' : ''}${svg(P.chevD, 18)}</button><span class="hv-acts">`
325
+ // Adler beside the greeting from the first frame (SpM): thinking while Home reads, handled when it is in, honest if the read failed.
326
+ // While Home reads, a hollow ring follows the greeting (SpA); a fresh page shows no sign of having been checked
327
+ const own = o.imFace && !o.err && o.ready;
328
+ const face = own ? '' : AD.adler(o.err ? 'honest' : o.ready ? 'handled' : 'thinking', 36, { label: false, cls: 'hv-ad' });
329
+ const head = `<div class="hm-hd hv-bar">${own ? o.imFace : ''}<button type="button" class="hv-hi" ${o.ready ? 'data-hm-day' : 'aria-disabled="true"'} aria-expanded="${!!o.day}" aria-label="${esc(M.word || 'Today')}${nw}. Open your day">${face}<b>${esc(M.word || 'Today')}</b>${o.ready ? '' : LS.ringHtml()}${o.news > 0 ? '<i class="hv-nd" aria-hidden="true"></i>' : ''}${svg(P.chevD, 18)}</button><span class="hv-acts">`
326
330
  // the product chip, always (it is also the way to add one): the ticked products' tiles, or All (canvas XhpA2States)
327
331
  + (o.pick ? chipHtml(o.pick, { id: 'hmPick', attrs: 'data-hm-pick', label: 'Products on Home', tones: o.tones }) : '')
328
332
  + (o.phone ? '' : `<button type="button" class="ut-ib hv-ib${flt ? ' on' : ''}" data-hm-flt aria-haspopup="menu" aria-label="Show: Today, Kept or Done${flt ? '. Now ' + esc(flt.label) : ''}" title="Show: Today, Kept or Done">${svg(P.filter, 18)}</button>`)
@@ -336,7 +340,9 @@ export function listHtml(M, T, o) {
336
340
  // the Today card only while the day has a moment; the plain day (its meetings and load) is in Your day
337
341
  const card = MOMENT.has(T.mode) ? todayHtml(T, { greet: M.greet, sub: '', phone: o.phone }) : '';
338
342
  // start-up and errors (canvas AdU9, AdU6): Adler wears his glasses while Home reads, and says a failure plainly; the greeting's face hides
339
- if (!o.ready) return head + card + `<div class="hm-none hm-ad" role="status">${AD.adler('thinking', 'l', { label: false })}<p>Reading your work.</p></div>`;
343
+ // start-up (SpA, SpG): the list Home drew last, drawn at once and inert until the fresh read is in; with none kept, grey rows that claim
344
+ // nothing. o.top is Mentions (or its grey shape) on its own clock. Errors (canvas AdU6): Adler says a failure plainly
345
+ if (!o.ready) return head + card + `<p class="vh" role="status">Reading your work.</p><div class="hv-body">${o.top || ''}${o.last ? `<div class="ls-stale" inert>${o.last}</div>` : LS.listShapes()}</div>`;
340
346
  const errLine = `<div class="hm-none hm-ad" role="status">${AD.adler('honest', 48, { label: false })}<p>Home could not read the repository: ${esc(o.err)}.</p></div>`;
341
347
  if (o.err && !M.groups.some((g) => (g.kind === 'product' && g.count) || (g.rows && g.rows.length))) return head + card + errLine;
342
348
  // the repository's read failed but sessions or notes still answered: say so above what did come back, never an empty product as if calm
@@ -353,6 +359,7 @@ export function listHtml(M, T, o) {
353
359
  if (o.cap) body += `<form class="hm-cap" id="hmCapF"><label class="vh" for="hmCap">Add a task to your own list</label><input id="hmCap" autocomplete="off" maxlength="250" placeholder="A task for your own list. Esc to cancel">`
354
360
  + (o.mic ? `<button type="button" class="ut-ib" data-hm-capmic aria-label="Say it instead" title="Say it instead">${svg(P.mic, 16)}</button>` : '') + '</form>';
355
361
  }
362
+ if (o.keep && !o.err && !M.list && !o.cap) o.keep(body); // the last view, for the next open (SpA)
356
363
  return head + card + `<div class="hv-body">${o.top || ''}${body}</div>` + (o.phone && o.ses && !M.list ? sesSheetHtml(M, o) : ''); // o.top: the pinned recording row and Mentions (home-rec-row.js, home-mentions.js)
357
364
  }
358
365
  /** Your day (HvAOpen): a sheet over the list from "Good morning". The date, the day's meetings and their load (a press opens the calendar,
@@ -0,0 +1,57 @@
1
+ /* Loading shapes (initiative page-open-speed; canvas boards SpA, SpD, SpG, SpJ, SpM, agreed A 2026-11-02).
2
+ * Honest state: grey shapes say only "something goes here" (no names, no counts); a hollow ring says Home is checking; nothing moves;
3
+ * no bar and no percent. Every shape is static (principle 12: no motion on data). */
4
+
5
+ /* the hollow ring beside "Good morning" while Home checks (SpA) */
6
+ .ls-ringdot { display: inline-block; flex: none; width: 9px; height: 9px; margin-left: 6px; border-radius: 50%; border: 1.6px solid var(--cz-blue); box-sizing: border-box; }
7
+
8
+ /* grey shapes */
9
+ .ls-sk { display: block; height: 9px; border-radius: 6px; background: var(--cz-well2); }
10
+ .ls-dot { flex: none; width: 12px; height: 12px; border-radius: 50%; background: var(--cz-well2); }
11
+ .ls-box { flex: none; width: 12px; height: 12px; border-radius: 6px; background: var(--cz-well2); }
12
+ .ls-row { display: flex; align-items: center; gap: 10px; min-height: 44px; padding: 0 12px; box-sizing: border-box; }
13
+ .ls-row + .ls-row { border-top: 1px solid var(--cz-hair); }
14
+ .ls-head { display: flex; align-items: center; min-height: 32px; padding: 0 12px; }
15
+ .ls-group + .ls-group { margin-top: 14px; }
16
+ .ls-men { display: flex; align-items: center; gap: 10px; min-height: 44px; padding: 0 12px; margin-bottom: 12px; border-radius: 8px; background: var(--cz-card); box-sizing: border-box; }
17
+
18
+ /* the last view, drawn at once while the fresh read runs (SpA): it can be read, not acted on; a press that acts is dashed */
19
+ .ls-stale { pointer-events: none; }
20
+ .ls-stale .hm-cta, .ls-stale .hv-cta, .ls-stale .solid, .ls-stale [data-ls-was].solid { background: transparent !important; color: var(--cz-ink3) !important; border: 1px dashed var(--cz-ink3) !important; box-shadow: none !important; }
21
+ .ls-stale .hr-bd { visibility: hidden; }
22
+
23
+ /* the Viewer's column held from frame one (SpJ): a dashed empty panel, the same width the Viewer opens at, no text, no icon */
24
+ .ls-vw { flex: none; border: 1.5px dashed var(--cz-hair); border-radius: 8px; box-sizing: border-box; width: min(500px, max(380px, calc(100vw - 808px))); }
25
+ @media (max-width: 760px) { .ls-vw { display: none; } }
26
+
27
+ /* the Board's skeleton (SpD): the real toolbar and tabs are the page's own; the body is grey rows shaped like programs and initiatives */
28
+ .ls-bd { display: flex; flex-direction: column; }
29
+ .ls-bd .ls-bh { display: grid; grid-template-columns: minmax(160px, 260px) 1fr minmax(120px, 220px); gap: 16px; align-items: center; min-height: 36px; padding: 0 14px; border-radius: 8px; background: var(--cz-well); }
30
+ .ls-bd .ls-stg { display: flex; justify-content: space-between; gap: 6px; }
31
+ .ls-bd .ls-stg .ls-sk { width: 28px; height: 7px; }
32
+ .ls-bd .ls-br { display: grid; grid-template-columns: minmax(160px, 260px) 1fr minmax(120px, 220px); gap: 16px; align-items: center; min-height: 46px; padding: 0 14px; }
33
+ .ls-bd .ls-br.pg { min-height: 40px; margin-top: 8px; border-radius: 8px; background: var(--cz-well); }
34
+ .ls-bd .ls-trk { position: relative; display: flex; align-items: center; justify-content: space-between; height: 12px; }
35
+ .ls-bd .ls-trk::before { content: ""; position: absolute; left: 4px; right: 4px; top: 5px; height: 2px; background: var(--cz-well2); }
36
+ .ls-bd .ls-trk i { position: relative; width: 7px; height: 7px; background: var(--cz-well2); }
37
+ .ls-bd .ls-trk i.ls-tdot { border-radius: 50%; }
38
+ .ls-bd .ls-trk i.on { width: 12px; height: 12px; }
39
+ .ls-slow { margin: 10px 14px 4px; font-size: var(--fs-2); font-weight: 700; color: var(--cz-ink3); }
40
+ @media (max-width: 760px) {
41
+ .ls-bd .ls-bh, .ls-bd .ls-br { grid-template-columns: 1fr; gap: 6px; }
42
+ .ls-bd .ls-bh .ls-stg, .ls-bd .ls-br .ls-trk, .ls-bd .ls-br > .ls-sk:last-child { display: none; }
43
+ }
44
+ .ls-stale > .hm-pg + .hm-pg { margin-top: 12px; }
45
+ .ls-stale > .hm-pg.loose { margin-top: 0; }
46
+ .ls-w30 { width: 30%; }
47
+ .ls-w34 { width: 34%; }
48
+ .ls-w38 { width: 38%; }
49
+ .ls-w40 { width: 40%; }
50
+ .ls-w55 { width: 55%; }
51
+ .ls-w60 { width: 60%; }
52
+ .ls-w70 { width: 70%; }
53
+ .ls-w75 { width: 75%; }
54
+ .ls-w48 { width: 48%; }
55
+ .ls-w54 { width: 54%; }
56
+ .ls-w62 { width: 62%; }
57
+ .ls-w78 { width: 78%; }
@@ -0,0 +1,57 @@
1
+ /* Loading shapes (initiative page-open-speed; canvas SpA, SpD, SpG, SpJ, SpM, agreed A 2026-11-02).
2
+ *
3
+ * ringHtml() the hollow ring beside "Good morning" while Home checks
4
+ * listShapes() Home's list as grey rows, before the first read and with no last view kept
5
+ * menShape() the Mentions row's grey shape, until Mentions is read
6
+ * keepLast(key, html) keep the list Home drew last in this browser (not over 300 KB), for the next open
7
+ * last(key) that list, or ''
8
+ * holdViewer(host, on) the Viewer's column as a dashed empty panel, so the chat never narrows when the Viewer opens
9
+ * slowLine(host, M, ms) 'Still reading the board.' once the wait runs past ms, while there is still no model
10
+ *
11
+ * Grey shapes carry no names and no counts, and nothing moves. The last view is drawn inert: it can be read, not acted on, until the
12
+ * fresh read is in. */
13
+
14
+ const store = {
15
+ get(k) { try { return localStorage.getItem(k); } catch { return null; } },
16
+ set(k, v) { try { if (v == null) localStorage.removeItem(k); else localStorage.setItem(k, v); } catch { /* a private window, or full */ } },
17
+ };
18
+ const LAST = 'pmos.home.last:';
19
+ const MAX = 300000;
20
+
21
+ export const ringHtml = () => '<span class="ls-ringdot" role="img" aria-label="Checking"></span>';
22
+
23
+ const W = [62, 78, 54, 70, 48];
24
+ const row = (w, box) => `<div class="ls-row">${box ? '<i class="ls-box"></i>' : '<i class="ls-dot"></i>'}<i class="ls-sk ls-w${w}"></i></div>`;
25
+ export function listShapes() {
26
+ return '<div class="ls-list" aria-hidden="true">'
27
+ + `<div class="ls-group"><div class="ls-head"><i class="ls-sk ls-w38"></i></div><div class="hv-card">${W.map((w, i) => row(w, i > 0)).join('')}</div></div>`
28
+ + `<div class="ls-group"><div class="ls-head"><i class="ls-sk ls-w30"></i></div><div class="hv-card">${W.slice(0, 3).map((w) => row(w, true)).join('')}</div></div>`
29
+ + '</div>';
30
+ }
31
+ export const menShape = () => '<div class="ls-men" aria-hidden="true"><i class="ls-dot"></i><i class="ls-sk ls-w34"></i></div>';
32
+
33
+ const kept = new Map(); // what this page wrote last, so a redraw that changed nothing writes nothing
34
+ export function keepLast(key, html) { if (kept.get(key) === html) return; kept.set(key, html); store.set(LAST + key, html && html.length <= MAX ? html : null); }
35
+ /** The last view, drawn as a picture of itself: no button, link or field in it, so nothing in it can be pressed or found as a control
36
+ * until the fresh read is in (SpA: it can be read, not acted on). */
37
+ export function last(key) { return inert(store.get(LAST + key) || ''); }
38
+ /** html with its buttons, links and fields drawn as spans, and its press marks renamed: seen, not pressed, until the fresh read is in. */
39
+ export function inert(html) {
40
+ return String(html || '').replace(/<(\/?)(button|a|input|select|textarea)\b/gi, (m, end, tag) => '<' + end + 'span' + (end ? '' : ' data-ls-was="' + tag.toLowerCase() + '"'))
41
+ .replace(/\sdata-(hm|hr)-/g, ' data-ls-'); // and without the presses' own marks, so nothing reads it as the live list
42
+ }
43
+
44
+ /** on: a dashed empty panel in the host (once); off: the panel goes. */
45
+ export function holdViewer(host, on) {
46
+ if (!host) return;
47
+ const was = host.querySelector(':scope > .ls-vw');
48
+ if (on && !was) { const p = document.createElement('div'); p.className = 'ls-vw'; p.setAttribute('aria-hidden', 'true'); host.prepend(p); }
49
+ if (!on && was) was.remove();
50
+ }
51
+
52
+ /** The Board's quiet line past ms of waiting (SpD): it says only what is true, and never shows once the board is in (M() is set). */
53
+ export function slowLine(host, M, ms = 2000) {
54
+ const show = () => { if (M() || !host || !host.querySelector('.ls-bd') || host.querySelector('.ls-slow')) return; host.insertAdjacentHTML('afterbegin', '<p class="ls-slow" role="status">Still reading the board.</p>'); };
55
+ const left = ms - (typeof performance !== 'undefined' ? performance.now() : 0);
56
+ if (left <= 0) show(); else setTimeout(show, left);
57
+ }