create-pmos 0.494.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.
- package/package.json +75 -27
- package/payload/AGENTS.md +2 -0
- package/payload/KIT-VERSION +2 -2
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/payload/state/log.md +1 -1
- package/workspace/adler-moments-test.js +3 -1
- package/workspace/board-programs-test.js +6 -6
- package/workspace/board-shared-ui-test.js +2 -2
- package/workspace/briefing-premium-adddoc-test.js +3 -49
- package/workspace/briefing-premium-board-test.js +23 -23
- package/workspace/briefing-premium-initiative-test.js +2 -1
- package/workspace/changelog.json +74 -0
- package/workspace/guide/adler.md +1 -1
- package/workspace/guide/briefing-premium.md +3 -1
- package/workspace/guide/claude-proposes.md +6 -2
- package/workspace/guide/data-freshness.md +2 -0
- package/workspace/guide/home.md +1 -1
- package/workspace/guide/initiatives-screen.md +1 -1
- package/workspace/guide/published-board.md +1 -1
- package/workspace/guide/report-slides.md +6 -6
- package/workspace/guide/reports.md +1 -1
- package/workspace/guide/workflows.md +18 -5
- package/workspace/ini-workflow-test.js +2 -1
- package/workspace/lib/board-premium.js +17 -2
- package/workspace/lib/engine/workflows.js +19 -6
- package/workspace/lib/initiative-chat.js +4 -1
- package/workspace/lib/initiative-cli.js +15 -1
- package/workspace/lib/open-mission.js +14 -0
- package/workspace/lib/proposals.js +18 -4
- package/workspace/lib/report-export.js +5 -3
- package/workspace/lib/report-pyramid.js +134 -33
- package/workspace/lib/report-templates.js +33 -3
- package/workspace/lib/routes-board-premium.js +2 -2
- package/workspace/lib/routes-boards.js +35 -2
- package/workspace/lib/routes-brief-page.js +2 -1
- package/workspace/lib/routes-initiatives.js +1 -0
- package/workspace/lib/routes-proposals.js +14 -2
- package/workspace/lib/routes-report-design.js +1 -1
- package/workspace/lib/routes-report-flow.js +11 -4
- package/workspace/lib/routes-report-setups.js +3 -2
- package/workspace/lib/routes-workflows.js +6 -1
- package/workspace/lib/split-merge-suggest.js +37 -3
- package/workspace/lib/split-merge.js +10 -4
- package/workspace/lib/task-cli.js +5 -1
- package/workspace/lib/workflow-cli.js +70 -15
- package/workspace/lib/workflows-store.js +15 -1
- package/workspace/metric-drafts-ui-test.js +2 -1
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/premium-board-actions-test.js +9 -10
- package/workspace/premium-core-doc-issue-test.js +9 -38
- package/workspace/premium-vw-backlog-test.js +3 -2
- package/workspace/premium-vw-item-test.js +3 -2
- package/workspace/premium-vw-jira-test.js +3 -2
- package/workspace/program-panel-test.js +2 -1
- package/workspace/public/pm/cozy-board.css +17 -5
- package/workspace/public/pm/cozy-home.css +1 -0
- package/workspace/public/pm/home-app.js +15 -5
- package/workspace/public/pm/home-chat.js +49 -8
- package/workspace/public/pm/home-today.js +11 -4
- package/workspace/public/pm/ini-view.js +2 -2
- package/workspace/public/pm/load-shapes.css +57 -0
- package/workspace/public/pm/load-shapes.js +57 -0
- package/workspace/public/pm/premium-board.js +82 -57
- package/workspace/public/pm/premium-shell.js +57 -6
- package/workspace/public/pm/premium.css +1 -0
- package/workspace/public/pm/readonly.js +3 -3
- package/workspace/public/pm/split-merge.js +3 -2
- package/workspace/public/pm/wf-page.css +8 -0
- package/workspace/public/pm/wf-page.js +33 -5
- package/workspace/public/premium-board.html +7 -10
- package/workspace/public/premium.html +1 -0
- package/workspace/skills/report-template.skill +4 -2
- package/workspace/split-merge-ui-test.js +4 -3
- package/workspace/stage-card-test.js +2 -1
- package/workspace/task-press-test.js +2 -1
- package/workspace/test-lists/page-open-speed.json +1 -1
- package/workspace/tk-tasks-test.js +2 -1
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
const path = require('path');
|
|
16
16
|
const { spawn } = require('child_process');
|
|
17
17
|
const { findBrowser } = require('./lib/find-browser');
|
|
18
|
+
const { openMission } = require('./lib/open-mission');
|
|
18
19
|
const T = require('./test-run').configure({ name: 'program-panel' });
|
|
19
20
|
const BP = require('./lib/board-premium.js');
|
|
20
21
|
const F = require('./fixtures/board-premium-facts.js');
|
|
@@ -46,7 +47,7 @@ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
|
|
|
46
47
|
browser = await chromium.launch({ executablePath: exe });
|
|
47
48
|
|
|
48
49
|
const open = async ({ w = 1440, h = 900, qs = '?ws=idpf&program=project-ascent', ctx = null, programs = F.programs() } = {}) => {
|
|
49
|
-
const c = ctx || await browser.newContext({ viewport: { width: w, height: h } });
|
|
50
|
+
const c = ctx || await openMission(await browser.newContext({ viewport: { width: w, height: h } }));
|
|
50
51
|
const page = await c.newPage();
|
|
51
52
|
const errors = [], posts = [];
|
|
52
53
|
page.on('pageerror', (e) => errors.push(String((e && e.stack) || e)));
|
|
@@ -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
|
-
|
|
119
|
-
.bd-
|
|
120
|
-
.bd-
|
|
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; }
|
|
@@ -232,6 +232,7 @@ body.cz-home { min-height: 100vh; overflow: hidden; }
|
|
|
232
232
|
.hm-u { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; }
|
|
233
233
|
.hm-bub { max-width: 80%; padding: 10px 16px; border-radius: 18px 18px 8px 18px; background: var(--cz-bubble); font-size: var(--fs-3); line-height: 1.5; color: var(--cz-ink); }
|
|
234
234
|
.hm-you { font-size: var(--fs-1); color: var(--cz-ink3); }
|
|
235
|
+
.hm-pend.sending .hm-bub { opacity: .72; } /* batch 24 item 2: the bubble shows at once, a touch lighter until PMOS takes it */
|
|
235
236
|
.hm-worked { padding-left: 38px; }
|
|
236
237
|
.hm-wk { display: flex; align-items: center; gap: 8px; max-width: 100%; min-height: 32px; padding: 0 4px; border: 0; border-radius: 8px; background: none; color: var(--cz-ink3); font-size: var(--fs-2); cursor: pointer; text-align: left; } /* a plain grey line (AgChatAsk) */
|
|
237
238
|
.hm-wk:hover { color: var(--cz-ink2); }
|
|
@@ -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';
|
|
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
|
}
|
|
@@ -843,7 +846,9 @@ function sessionTarget(s) {
|
|
|
843
846
|
// a program's or a product's own chat says so in its scope, never "Loose" (batch 22 item 17)
|
|
844
847
|
const pm = /^chat:program:([^:]+)/.exec(key || ''), pr = pm ? (S.D.programs || []).find((p) => p && p.id === pm[1]) : null;
|
|
845
848
|
const prod = !ini && !pr && /^product(:|$)/.test(key || '') ? H.productName(S.D) : '';
|
|
846
|
-
|
|
849
|
+
// a New workflow chat (Settings › Workflows) says so, never "Loose" (batch 24 item 4); its draft stays on the Workflows list
|
|
850
|
+
const wfNew = !ini && !pr && !prod && /^pop:workflow:/.test(key || '');
|
|
851
|
+
const home = pr ? { text: pr.title, icon: TD.P.layers } : prod ? { text: prod, icon: I.home } : wfNew ? { text: 'New workflow', icon: I.chat } : null;
|
|
847
852
|
const t = { title: H.nameOf(s), ses: s.id, sub: [role.name, ini ? iniTitle(ini) : home ? home.text : 'Loose'].join(' · '), ini, role, scope: ini ? scopeOf(ini) : home || { text: 'Loose', icon: I.chat, loose: true }, rich: rich(), to: rich() ? role : null, also: alsoOf(ini) };
|
|
848
853
|
if (key && /^(decision|blocked|waited|src|ev|work|board|init|doc|ins|chat|pop):/.test(key)) { t.key = key; t.sid = undefined; } else t.sid = s.id;
|
|
849
854
|
if (!t.key) t.sid = s.id;
|
|
@@ -1310,7 +1315,10 @@ addEventListener('pp:changed', (e) => { if (WS) { location.assign('/premium.html
|
|
|
1310
1315
|
|
|
1311
1316
|
/* ── the Viewer: needs rows, the weekly update, the overview, files, people, routines ── */
|
|
1312
1317
|
let lastViewer = null;
|
|
1313
|
-
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);
|
|
1314
1322
|
function reopenViewer() {
|
|
1315
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
|
|
1316
1324
|
const r = S.sel ? rowById(S.sel) : null;
|
|
@@ -1989,6 +1997,8 @@ load().then(() => {
|
|
|
1989
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);
|
|
1990
1998
|
if (mt) openMeeting({ note: mt });
|
|
1991
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);
|
|
1992
2002
|
poll();
|
|
1993
2003
|
});
|
|
1994
2004
|
addEventListener('resize', () => { if (!isPhone() && S.sheet) sheet(false); drawList(); });
|
|
@@ -58,7 +58,7 @@ export const adlerAvHtml = (mood) => `<span class="hm-av hm-adler" aria-hidden="
|
|
|
58
58
|
|
|
59
59
|
export function mountChat(el, host) {
|
|
60
60
|
const esc = host.esc;
|
|
61
|
-
const C = { t: null, th: null, conv: null, timer: null, show: 12, plOpen: false, plAll: false, open: new Set(), seq: 0, sending: false, files: [], seenAt: null, lastKey: null, picked: {}, auto: { key: null, ctx: new Set(), rep: null, goal: null }, slashAt: 0 };
|
|
61
|
+
const C = { t: null, th: null, conv: null, timer: null, show: 12, plOpen: false, plAll: false, open: new Set(), seq: 0, sending: false, files: [], seenAt: null, lastKey: null, picked: {}, pend: [], auto: { key: null, ctx: new Set(), rep: null, goal: null }, slashAt: 0 };
|
|
62
62
|
el.innerHTML = `<button type="button" class="hm-reopen" id="hmReopen" aria-label="Open the Viewer" title="Open the Viewer" hidden>${PANE}</button>`
|
|
63
63
|
+ '<div class="hm-chd" id="hmCh" hidden></div><div class="hm-sug" id="hmSug" hidden></div><div class="hm-plw" id="hmPl" hidden></div><div class="hm-log" id="hmLog" role="log" aria-live="polite"></div><div class="hm-foot" id="hmFoot"></div>';
|
|
64
64
|
const log = el.querySelector('#hmLog'), foot = el.querySelector('#hmFoot'), reopen = el.querySelector('#hmReopen'), head = el.querySelector('#hmCh'), sug = el.querySelector('#hmSug'), pl = el.querySelector('#hmPl');
|
|
@@ -175,6 +175,32 @@ export function mountChat(el, host) {
|
|
|
175
175
|
return q.filter((x) => !read(x)).map((x) => `<div class="hm-q"><div class="hm-qb">${userHtml(x.text)}</div><span class="hm-qn">${esc(role().name)} reads it after this tool call, in seconds<button type="button" class="hm-qx" data-hm-qdel="${esc(x.at)}" aria-label="Take it back" title="Take it back">${SVG(XX, 13)}</button></span></div>`).join('');
|
|
176
176
|
}
|
|
177
177
|
const sid = () => (C.t && C.t.sid) || (C.th && C.th.session) || null;
|
|
178
|
+
/* A sent message shows at once (batch 24 item 2: "enter the prompt is so long to appear on the chat"). Its bubble draws the moment the PM
|
|
179
|
+
* presses Enter, says "Sending…" until PMOS takes it, then "You · HH:MM". It leaves when the transcript holds the PM's turn (the same words,
|
|
180
|
+
* or any PM turn from that moment on), and after 3 minutes at most, so a bubble never stays as a ghost. A message that was not sent leaves at
|
|
181
|
+
* once and its words go back in the box. */
|
|
182
|
+
const PEND_MAX = 180000;
|
|
183
|
+
function settlePend() {
|
|
184
|
+
if (!C.pend.length) return;
|
|
185
|
+
const key = (t) => splitFiles(shownUser(t)).text.replace(/\s+/g, ' ').trim();
|
|
186
|
+
const users = ((C.conv && C.conv.messages) || []).filter((m) => m.role === 'user');
|
|
187
|
+
const lastUser = users.reduce((n, m) => Math.max(n, Date.parse(m.at) || 0), 0), now = Date.now();
|
|
188
|
+
// a message for a working session is queued by the host and drawn as "reads it after this tool call": the bubble gives way to it
|
|
189
|
+
const queued = new Set((host.queued ? host.queued(sid()) : []).map((x) => x.text));
|
|
190
|
+
C.pend = C.pend.filter((p) => {
|
|
191
|
+
if (!p.sent) return true;
|
|
192
|
+
if (queued.has(p.text)) return false;
|
|
193
|
+
const t0 = Date.parse(p.at);
|
|
194
|
+
if (now - t0 > PEND_MAX) return false;
|
|
195
|
+
if (lastUser && lastUser >= t0 - 2000) return false;
|
|
196
|
+
return !users.slice(p.n).some((m) => key(m.text) === key(p.text));
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
function pendHtml() {
|
|
200
|
+
settlePend();
|
|
201
|
+
return C.pend.map((p) => `<div class="hm-u hm-pend${p.sent ? '' : ' sending'}"><div class="hm-bub">${filesHtml(p.files)}${p.text ? userHtml(p.text) : ''}</div>`
|
|
202
|
+
+ `<span class="hm-you" role="status">${p.sent ? 'You · ' + esc(hm(p.at)) : 'Sending…'}</span></div>`).join('');
|
|
203
|
+
}
|
|
178
204
|
// A loose chat that reads like one initiative gets one quiet chip at its top: "Looks like <initiative> · Link it ×" (XvALinkSuggest).
|
|
179
205
|
function drawSug() {
|
|
180
206
|
const t = C.t, g = t && t.scope && t.scope.loose && t.sid && host.suggestLink ? host.suggestLink(t, firstUser()) : null;
|
|
@@ -205,7 +231,7 @@ export function mountChat(el, host) {
|
|
|
205
231
|
const goal = C.th && C.th.goal;
|
|
206
232
|
const parts = items.map((it, i) => msgHtml(it, i, R));
|
|
207
233
|
placeDivider(parts, items, goal);
|
|
208
|
-
let html = (t.intro && !(t.intro.onlyEmpty && items.length) ? introHtml(t.intro, !items.length) : '') + parts.join('') + askHtml(R) + (R.stop && R.lastAgent < 0 ? dividerHtml(R.stop) : '') + queuedHtml();
|
|
234
|
+
let html = (t.intro && !(t.intro.onlyEmpty && items.length) ? introHtml(t.intro, !items.length) : '') + parts.join('') + askHtml(R) + (R.stop && R.lastAgent < 0 ? dividerHtml(R.stop) : '') + pendHtml() + queuedHtml();
|
|
209
235
|
html += GU.choiceHtml(); // /goal while a goal works here: In a new chat, or Here instead
|
|
210
236
|
if (C.th && C.th.working) html += `<p class="hm-note" role="status">The agent is answering. You can write again when it has.</p>`;
|
|
211
237
|
if (C.conv && C.conv.state === 'ended') html += '<p class="hm-note">This session has ended. Your conversation is kept here, and your next message picks it up.</p>';
|
|
@@ -418,20 +444,31 @@ export function mountChat(el, host) {
|
|
|
418
444
|
async function send(text) {
|
|
419
445
|
const t = C.t; text = String(text || '').trim();
|
|
420
446
|
if (!t || (!text && !C.files.length) || C.sending) return false;
|
|
447
|
+
if (t.key && C.files.some((f) => f.state === 'saving')) { host.toast('A file is still being saved. Send again in a moment.'); return false; }
|
|
421
448
|
C.sending = true;
|
|
422
449
|
let r;
|
|
423
450
|
const was = running();
|
|
451
|
+
// the bubble first, then the server (batch 24 item 2): the files leave the box with the words, and come back if the send fails
|
|
452
|
+
const sentFiles = C.files;
|
|
453
|
+
const p = { text, files: sentFiles.map((f) => f.path || f.saved).filter(Boolean), at: new Date().toISOString(), sent: false, n: ((C.conv && C.conv.messages) || []).filter((m) => m.role === 'user').length };
|
|
454
|
+
C.pend.push(p); C.files = []; C.jump = true; draw();
|
|
424
455
|
if (t.key) {
|
|
425
|
-
|
|
426
|
-
const files = C.files.map((f) => f.saved);
|
|
456
|
+
const files = sentFiles.map((f) => f.saved);
|
|
427
457
|
r = await host.pmPost('/api/briefing-premium/ask' + (t.ws ? '?ws=' + encodeURIComponent(t.ws) : ''), files.length ? { key: t.key, text, files, ...(t.context ? { context: t.context } : {}) } : { key: t.key, text, ...(t.context ? { context: t.context } : {}) }).catch(() => null);
|
|
428
458
|
} else r = await host.pmPost('/api/session/say', { id: t.sid, text }).catch(() => null);
|
|
429
459
|
C.sending = false;
|
|
430
|
-
if (!r || r.ok !== true) {
|
|
460
|
+
if (!r || r.ok !== true) {
|
|
461
|
+
C.pend = C.pend.filter((x) => x !== p);
|
|
462
|
+
if (C.t === t && !C.files.length) C.files = sentFiles;
|
|
463
|
+
if (C.t === t) draw();
|
|
464
|
+
host.toast((r && r.error) || 'The message was not sent.'); return false;
|
|
465
|
+
}
|
|
431
466
|
const s = sid() || r.session;
|
|
432
467
|
if (host.touch) host.touch(s);
|
|
433
468
|
if (was && s && host.queue) host.queue(s, text);
|
|
434
|
-
|
|
469
|
+
p.sent = true;
|
|
470
|
+
sentFiles.forEach((f) => { if (f.url) URL.revokeObjectURL(f.url); }); C.jump = true;
|
|
471
|
+
if (C.t === t) draw();
|
|
435
472
|
if (t.onSent) t.onSent(r);
|
|
436
473
|
read();
|
|
437
474
|
return true;
|
|
@@ -527,7 +564,11 @@ export function mountChat(el, host) {
|
|
|
527
564
|
drawFoot({ role: role() });
|
|
528
565
|
}
|
|
529
566
|
};
|
|
530
|
-
el.addEventListener('submit', (e) => { e.preventDefault(); const ta = foot.querySelector('textarea'); const v = ta.value; if (GU.submit(v)) { ta.value = ''; grow(ta); return; }
|
|
567
|
+
el.addEventListener('submit', (e) => { e.preventDefault(); const ta = foot.querySelector('textarea'); const v = ta.value; if (GU.submit(v)) { ta.value = ''; grow(ta); return; }
|
|
568
|
+
// the box empties at once and the bubble shows (batch 24 item 2); a send that fails puts the words back, unless the PM typed again
|
|
569
|
+
if (!v.trim() && !C.files.length) return;
|
|
570
|
+
ta.value = ''; grow(ta);
|
|
571
|
+
send(v).then((ok) => { const box = foot.querySelector('textarea'); if (!ok && box && !box.value) { box.value = v; grow(box); } }); });
|
|
531
572
|
const shutAdd = (focus) => { if (!C.addOpen) return; C.addOpen = false; drawFoot({ role: role() }); if (focus) { const a = foot.querySelector('#hmAdd'); if (a) a.focus(); } };
|
|
532
573
|
document.addEventListener('click', (e) => { if (C.addOpen && e.target.isConnected && !e.target.closest('.hm-addw')) shutAdd(false); }, true);
|
|
533
574
|
el.addEventListener('keydown', (e) => {
|
|
@@ -623,7 +664,7 @@ export function mountChat(el, host) {
|
|
|
623
664
|
if (notesM) notesM.then((M) => setTimeout(() => M.syncNotes(), 0)); // the Notes open in the Viewer belonged to the conversation before
|
|
624
665
|
const same = C.t && t && C.t.key === t.key && C.t.sid === t.sid;
|
|
625
666
|
C.t = t;
|
|
626
|
-
if (!same) { C.addOpen = false; C.th = null; C.conv = null; C.show = 12; C.open = new Set(); C.files = []; C.marked = null; C.touched = null; C.picked = {}; C.plOpen = false; C.plAll = false; C.seenAt = t.key ? host.seen(t.key) : null; put(log, introHtml(t.intro)); put(pl, ''); pl.hidden = true; C.jump = true; peek(null); }
|
|
667
|
+
if (!same) { C.addOpen = false; C.th = null; C.conv = null; C.show = 12; C.open = new Set(); C.files = []; C.marked = null; C.touched = null; C.picked = {}; C.pend = []; C.plOpen = false; C.plAll = false; C.seenAt = t.key ? host.seen(t.key) : null; put(log, introHtml(t.intro)); put(pl, ''); pl.hidden = true; C.jump = true; peek(null); }
|
|
627
668
|
draw(); read();
|
|
628
669
|
},
|
|
629
670
|
refresh() { if (C.t) draw(); },
|
|
@@ -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';
|
|
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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
|
-
|
|
325
|
-
|
|
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
|
-
|
|
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,
|
|
@@ -75,11 +75,11 @@ const SHOWN = new Map(); // id → the view last drawn, for the Kanban's stage
|
|
|
75
75
|
const MCS = new Map(); // id → the PM's own changes to { mc, gx, ab } (true = open), kept in the browser
|
|
76
76
|
const MCK = 'pmos.iv.mc.';
|
|
77
77
|
const store = () => { try { return typeof localStorage !== 'undefined' ? localStorage : null; } catch { return null; } };
|
|
78
|
-
/** The three folds of one initiative: the PM's own, else the defaults (the way to finish is open on a wide screen, About is folded; on a phone both are folded; About opens by itself while a success measure waits on a ✓). */
|
|
78
|
+
/** The three folds of one initiative: the PM's own, else the defaults (the telemetry card starts hidden in its banner; the way to finish is open on a wide screen, About is folded; on a phone both are folded; About opens by itself while a success measure waits on a ✓). */
|
|
79
79
|
export function mcState(id, o = {}) {
|
|
80
80
|
let mine = MCS.get(id);
|
|
81
81
|
if (!mine) { mine = {}; try { const s = store(), j = s && JSON.parse(s.getItem(MCK + id) || 'null'); if (j && typeof j === 'object') for (const k of ['mc', 'gx', 'ab']) if (typeof j[k] === 'boolean') mine[k] = j[k]; } catch { mine = {}; } MCS.set(id, mine); }
|
|
82
|
-
const dflt = { mc:
|
|
82
|
+
const dflt = { mc: false, gx: !phone(), ab: !!o.proposal }; // the telemetry starts hidden in its banner (Wawan, batch 24 item 1)
|
|
83
83
|
return { mc: 'mc' in mine ? mine.mc : dflt.mc, gx: 'gx' in mine ? mine.gx : dflt.gx, ab: 'ab' in mine ? mine.ab : dflt.ab };
|
|
84
84
|
}
|
|
85
85
|
export function mcToggle(id, key, o) {
|
|
@@ -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
|
+
}
|