create-pmos 0.52.0 → 0.53.1
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 +1 -1
- package/payload/KIT-VERSION +2 -2
- package/payload/mods/pmos-cockpit/README.md +13 -0
- package/payload/mods/pmos-cockpit/hooks/lib/commands.mjs +2 -1
- package/payload/mods/pmos-cockpit/hooks/lib/grants.mjs +41 -11
- package/payload/mods/pmos-cockpit/hooks/lib/guard.mjs +228 -8
- package/payload/mods/pmos-cockpit/hooks/register.tsx +11 -3
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/scripts/plan-items-stage.js +271 -0
- package/payload/scripts/pmos-log.sh +18 -7
- package/payload/scripts/workflow-state.js +28 -2
- package/payload/state/discoveries.md +1 -1
- package/payload/state/log.md +1 -1
- package/workspace/README.md +2 -0
- package/workspace/REMOTE.md +24 -63
- package/workspace/bin/pmos.js +19 -3
- package/workspace/cut-stub.js +40 -0
- package/workspace/first-paint-audit.js +145 -0
- package/workspace/fixtures/capture/requirements/capture-2026-10-04-1.md +10 -0
- package/workspace/fixtures/initiatives-api/list.json +176 -0
- package/workspace/fixtures/initiatives-api/post-403.json +5 -0
- package/workspace/fixtures/initiatives-api/post-ok.json +10 -0
- package/workspace/fixtures/initiatives-api/roadmap.json +208 -0
- package/workspace/fixtures/initiatives-api/sheet.json +112 -0
- package/workspace/fixtures/report-api/draft.json +177 -0
- package/workspace/fixtures/reports-page/draft-biweekly-2026-10-16.md +97 -0
- package/workspace/fixtures/today-api/provider-rows.json +204 -0
- package/workspace/fixtures/today-api/seam-probe-kinds.json +5 -0
- package/workspace/guide/board.md +81 -0
- package/workspace/guide/breakdown.md +154 -0
- package/workspace/guide/command-palette.md +75 -0
- package/workspace/guide/index.md +28 -17
- package/workspace/guide/initiatives-roadmap.md +130 -0
- package/workspace/guide/initiatives-screen.md +89 -0
- package/workspace/guide/initiatives-timeline.md +57 -0
- package/workspace/guide/integrated-work.md +94 -0
- package/workspace/guide/knowledge-graph.md +3 -68
- package/workspace/guide/launcher.md +51 -0
- package/workspace/guide/layout.md +102 -32
- package/workspace/guide/live-notes.md +3 -3
- package/workspace/guide/pm-only-acts.md +89 -0
- package/workspace/guide/pm-only-lines.md +61 -0
- package/workspace/guide/proactive-engine.md +21 -5
- package/workspace/guide/reports-page.md +80 -0
- package/workspace/guide/reports.md +73 -0
- package/workspace/guide/timeline.md +70 -0
- package/workspace/guide/today.md +85 -0
- package/workspace/guide/validate-with-typesafe.md +1 -1
- package/workspace/lib/actor.js +91 -0
- package/workspace/lib/board-cache.js +32 -2
- package/workspace/lib/board-cli.js +108 -0
- package/workspace/lib/board-fold.js +232 -0
- package/workspace/lib/breakdown-export.js +75 -0
- package/workspace/lib/brief.js +20 -2
- package/workspace/lib/capture.js +98 -0
- package/workspace/lib/engine/plan-items-stage.js +271 -0
- package/workspace/lib/engine/workflow-state.js +28 -2
- package/workspace/lib/factory-join.js +158 -0
- package/workspace/lib/goals.js +165 -0
- package/workspace/lib/grant-mint.js +113 -0
- package/workspace/lib/health-rules-factory.js +92 -0
- package/workspace/lib/health-rules-plan.js +164 -0
- package/workspace/lib/health.js +79 -0
- package/workspace/lib/initiative-cli.js +222 -0
- package/workspace/lib/initiative-display.js +183 -0
- package/workspace/lib/initiative-needs.js +127 -0
- package/workspace/lib/initiative-rows.js +210 -0
- package/workspace/lib/initiative-writer.js +141 -0
- package/workspace/lib/issues.js +206 -0
- package/workspace/lib/item-cli.js +195 -0
- package/workspace/lib/item-spans.js +137 -0
- package/workspace/lib/launcher-cli.js +207 -0
- package/workspace/lib/measure-cli.js +100 -0
- package/workspace/lib/measurements.js +189 -0
- package/workspace/lib/mobile-nav.js +57 -53
- package/workspace/lib/needs-providers.js +120 -0
- package/workspace/lib/placements.js +440 -0
- package/workspace/lib/plan-items.js +922 -0
- package/workspace/lib/plan-ledgers.js +85 -0
- package/workspace/lib/plan-rules.js +72 -0
- package/workspace/lib/plan-tree.js +380 -0
- package/workspace/lib/pm-guard.js +174 -0
- package/workspace/lib/pm-lines-needs.js +331 -0
- package/workspace/lib/pmos-verbs.js +69 -0
- package/workspace/lib/proactive-cli.js +58 -2
- package/workspace/lib/proactive.js +9 -6
- package/workspace/lib/qr.js +293 -0
- package/workspace/lib/remote-access.js +93 -1
- package/workspace/lib/report-cli.js +152 -0
- package/workspace/lib/report-draft.js +578 -0
- package/workspace/lib/report-needs.js +213 -0
- package/workspace/lib/route-modules.js +128 -0
- package/workspace/lib/routes-board.js +91 -0
- package/workspace/lib/routes-capture.js +52 -0
- package/workspace/lib/routes-initiatives.js +371 -0
- package/workspace/lib/routes-reports.js +163 -0
- package/workspace/lib/routes-timeline.js +197 -0
- package/workspace/lib/routes-today.js +32 -0
- package/workspace/lib/today-cli.js +99 -0
- package/workspace/lib/today.js +333 -0
- package/workspace/lib/triage.js +163 -0
- package/workspace/lib/verify-work-cli.js +81 -0
- package/workspace/lib/work-check-needs.js +99 -0
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +4 -5
- package/workspace/public/icons/apple-touch-icon.png +0 -0
- package/workspace/public/icons/icon-192.png +0 -0
- package/workspace/public/icons/icon-512.png +0 -0
- package/workspace/public/index.html +40 -19
- package/workspace/public/pm/app.js +330 -30
- package/workspace/public/pm/bd-board.js +113 -0
- package/workspace/public/pm/bd-drag.js +59 -0
- package/workspace/public/pm/bd-model.js +152 -0
- package/workspace/public/pm/bd-timeline.js +233 -0
- package/workspace/public/pm/bd-tree.js +244 -0
- package/workspace/public/pm/board-copy.js +57 -0
- package/workspace/public/pm/breakdown-api.js +58 -0
- package/workspace/public/pm/breakdown-copy.js +138 -0
- package/workspace/public/pm/components.css +3135 -0
- package/workspace/public/pm/cycle-review.js +125 -0
- package/workspace/public/pm/data.js +40 -187
- package/workspace/public/pm/dest-board.js +438 -0
- package/workspace/public/pm/dest-initiatives.js +134 -0
- package/workspace/public/pm/dest-reports.js +270 -0
- package/workspace/public/pm/dest-settings.js +217 -0
- package/workspace/public/pm/dest-today.js +182 -0
- package/workspace/public/pm/dialogs.js +88 -0
- package/workspace/public/pm/display-word.js +56 -0
- package/workspace/public/pm/dock.js +175 -0
- package/workspace/public/pm/fixture.js +8 -227
- package/workspace/public/pm/frame-copy.js +99 -0
- package/workspace/public/pm/handoff-box.js +82 -0
- package/workspace/public/pm/http.js +88 -0
- package/workspace/public/pm/icons.js +53 -0
- package/workspace/public/pm/initiative-list.js +218 -0
- package/workspace/public/pm/initiative-sheet.js +261 -0
- package/workspace/public/pm/initiatives-api.js +59 -0
- package/workspace/public/pm/initiatives-copy.js +215 -0
- package/workspace/public/pm/initiatives-timeline-copy.js +59 -0
- package/workspace/public/pm/initiatives-timeline.js +410 -0
- package/workspace/public/pm/initiatives-ui.js +123 -0
- package/workspace/public/pm/item-sheet.js +251 -0
- package/workspace/public/pm/keep-card.js +204 -0
- package/workspace/public/pm/lifecycle.js +7 -1
- package/workspace/public/pm/mobile.css +49 -20
- package/workspace/public/pm/mobile.js +157 -29
- package/workspace/public/pm/modes-live.js +7 -883
- package/workspace/public/pm/modes.js +32 -1024
- package/workspace/public/pm/new-initiative-sheet.js +74 -0
- package/workspace/public/pm/next-step-ui.js +3 -2
- package/workspace/public/pm/palette-copy.js +51 -0
- package/workspace/public/pm/palette.js +312 -0
- package/workspace/public/pm/panel-breakdown.js +702 -0
- package/workspace/public/pm/panel-cockpit.js +63 -295
- package/workspace/public/pm/panel-detail.js +25 -172
- package/workspace/public/pm/panel-initiatives.js +1 -19
- package/workspace/public/pm/panel-repos.js +31 -81
- package/workspace/public/pm/panel-tabs.js +200 -111
- package/workspace/public/pm/panel-trees.js +5 -2
- package/workspace/public/pm/portfolio-report.js +61 -0
- package/workspace/public/pm/primitives.js +656 -0
- package/workspace/public/pm/print.css +33 -0
- package/workspace/public/pm/provider-cards.js +136 -0
- package/workspace/public/pm/registry.js +194 -0
- package/workspace/public/pm/release-card.js +110 -0
- package/workspace/public/pm/report-view.js +309 -0
- package/workspace/public/pm/reports-copy.js +114 -0
- package/workspace/public/pm/roadmap.js +271 -0
- package/workspace/public/pm/sig-watch.js +41 -0
- package/workspace/public/pm/state.js +8 -4
- package/workspace/public/pm/status-banner.js +13 -5
- package/workspace/public/pm/tab-doc.js +2 -4
- package/workspace/public/pm/tab-term.js +12 -2
- package/workspace/public/pm/tab-web.js +3 -2
- package/workspace/public/pm/timeline-copy.js +48 -0
- package/workspace/public/pm/today-cards.js +244 -0
- package/workspace/public/pm/today-copy.js +126 -0
- package/workspace/public/pm/today-goals.js +172 -0
- package/workspace/public/pm/tokens.css +16 -18
- package/workspace/public/pm/topbar.js +102 -0
- package/workspace/public/pm/triage-card.js +60 -0
- package/workspace/public/pm/undo-hold.js +103 -0
- package/workspace/public/pm/view-addrepo.js +2 -1
- package/workspace/public/pm/view-livenotes.js +3 -1
- package/workspace/public/pm/view-today.js +7 -162
- package/workspace/public/vendor/Sortable.min.js +2 -0
- package/workspace/public/vendor/lucide-sprite.js +79 -0
- package/workspace/replaced-tests.json +2877 -0
- package/workspace/run-tests.js +56 -5
- package/workspace/server.js +446 -40
- package/workspace/sheet-answer.js +61 -0
- package/workspace/skills/measure.skill +41 -0
- package/workspace/skills/morning-brief.skill +15 -9
- package/workspace/skills/report-biweekly.skill +23 -25
- package/workspace/skills/tracker-refresh.skill +50 -0
- package/workspace/test-files.json +21 -3
- package/workspace/test-lists/base.json +6 -0
- package/workspace/test-lists/run2-a.json +6 -0
- package/workspace/test-lists/run2-b.json +13 -0
- package/workspace/test-lists/run2-c.json +19 -0
- package/workspace/test-lists/run2-d.json +22 -0
- package/workspace/test-lists/run3-0.json +5 -0
- package/workspace/test-lists/run3-a.json +5 -0
- package/workspace/test-lists/run3-b.json +6 -0
- package/workspace/test-lists/run3-c.json +15 -0
- package/workspace/test-lists/run3-d.json +6 -0
- package/workspace/test-lists/run4-a.json +8 -0
- package/workspace/test-lists/run4-b.json +6 -0
- package/workspace/test-lists/run4-c.json +13 -0
- package/workspace/test-lists/run4-d.json +9 -0
- package/workspace/test-lists/run5-a.json +6 -0
- package/workspace/test-lists/run5-b.json +6 -0
- package/workspace/test-lists/run5-c.json +15 -0
- package/workspace/test-lists/workspace-launcher.json +5 -0
- package/workspace/test-lists/workspace-phone-app.json +5 -0
- package/workspace/test-run.js +27 -1
- package/workspace/third-party/README.md +44 -0
- package/workspace/third-party/lucide-static-1.52.0.LICENSE +43 -0
- package/workspace/third-party/sortablejs-1.15.7.LICENSE +21 -0
- package/workspace/design-parity.js +0 -673
- package/workspace/public/pm/tab-task.js +0 -86
- package/workspace/public/pm/view-guide.js +0 -141
- package/workspace/public/pm/view-knowledge.js +0 -225
- package/workspace/public/pm/view-memory.js +0 -122
- package/workspace/public/pm/view-portfolio.js +0 -153
- package/workspace/public/pm/view-reports.js +0 -190
- package/workspace/public/pm/view-scheduled.js +0 -161
- package/workspace/public/pm/view-skills.js +0 -216
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/* pm/roadmap.js — the Roadmap: Now · Next · Later, ranked (pm-os-absorb Run 2, slice 2D; PRD R20 A, R15, R26, R27).
|
|
2
|
+
*
|
|
3
|
+
* Specimen A: Initiatives opens here. Three ranked columns, each a list of cards. Next and Later carry order only, never a
|
|
4
|
+
* date; a Now card shows its title, stage, "n of m", its health reason when it is not on track, and the check-by date. The
|
|
5
|
+
* header reads "Now · 5 KR · 3 health" and counts against the advisory cap.
|
|
6
|
+
*
|
|
7
|
+
* Ways to move a card, all through one write (POST /api/placements, a `placed` line with `after`):
|
|
8
|
+
* - drag the grip: to rank within a column, or into another (Sortable, bd-drag.js: a 150 ms hold on a touch screen);
|
|
9
|
+
* - Alt+ArrowUp / Alt+ArrowDown on the card;
|
|
10
|
+
* - the ⋯ menu, which is the phone's path: To top, Up, Down, Move to Now, Move to Next, Move to Later, Set check-by. The
|
|
11
|
+
* button shows where a touch screen is (any-pointer: coarse); on a desk the same menu opens from a right click or the menu
|
|
12
|
+
* key, so a card carries one control there, and first paint stays inside the budget.
|
|
13
|
+
* Only the PM's press moves a card into Now (R25): the page just posts, and the server's guard answers. A refusal shows in a
|
|
14
|
+
* toast and the card goes back where it was.
|
|
15
|
+
*
|
|
16
|
+
* The data is one GET (roadmap.json). Roadmap watches that GET's `sig` with sig-watch.js, so a placement written from the
|
|
17
|
+
* CLI or another tab shows within seconds, and never while a drag is live (the answer waits until the drop).
|
|
18
|
+
*
|
|
19
|
+
* mountRoadmap(host, env) → { dispose(), reload() }
|
|
20
|
+
* env = { repo, ws, openInitiative(repo, slug), onData?(answer) }
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { el } from './dom.js';
|
|
24
|
+
import { card, iconButton, dragHandle, button, menu, empty, toast, SEL } from './primitives.js';
|
|
25
|
+
import { watchSig } from './sig-watch.js';
|
|
26
|
+
import { loadSortable, baseOptions, dragSet } from './bd-drag.js';
|
|
27
|
+
import { COPY, shortDate } from './initiatives-copy.js';
|
|
28
|
+
import { getRoadmap, sigKey, place, placed, leaveNow } from './initiatives-api.js';
|
|
29
|
+
import { healthSignal, say, checkBySheet, onContextMenu } from './initiatives-ui.js';
|
|
30
|
+
|
|
31
|
+
const HORIZONS = ['now', 'next', 'later'];
|
|
32
|
+
|
|
33
|
+
export function mountRoadmap(host, env) {
|
|
34
|
+
const root = el('div', { class: 'in-roadmap', 'data-in-view': 'roadmap' });
|
|
35
|
+
host.replaceChildren(root);
|
|
36
|
+
const drags = dragSet();
|
|
37
|
+
let data = null;
|
|
38
|
+
let error = null;
|
|
39
|
+
let showDormant = false;
|
|
40
|
+
let dragging = false;
|
|
41
|
+
let deferred = null;
|
|
42
|
+
let focusId = null;
|
|
43
|
+
let disposed = false;
|
|
44
|
+
let stop = null;
|
|
45
|
+
let latest = null; // the answer the sig watcher read last, drawn when its sig changed
|
|
46
|
+
|
|
47
|
+
const ids = (h) => ((data && data.horizons && data.horizons[h]) || []).map((r) => r.id);
|
|
48
|
+
const rowOf = (id) => {
|
|
49
|
+
for (const h of HORIZONS) { const r = ((data && data.horizons[h]) || []).find((x) => x.id === id); if (r) return r; }
|
|
50
|
+
return ((data && data.dormant) || []).find((x) => x.id === id) || null;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/* ── writes ───────────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
54
|
+
|
|
55
|
+
/** The model after a move, so the card is where the PM put it before the server answers. */
|
|
56
|
+
function applyLocal(id, horizon, after, extra) {
|
|
57
|
+
const row = rowOf(id);
|
|
58
|
+
if (!row || !data) return;
|
|
59
|
+
for (const h of HORIZONS) data.horizons[h] = data.horizons[h].filter((x) => x.id !== id);
|
|
60
|
+
data.dormant = (data.dormant || []).filter((x) => x.id !== id);
|
|
61
|
+
row.horizon = horizon; row.dormant = false;
|
|
62
|
+
if (horizon === 'now') row.confirmed = true;
|
|
63
|
+
if (extra && 'check_by' in extra) row.check_by = extra.check_by;
|
|
64
|
+
const list = data.horizons[horizon];
|
|
65
|
+
const k = after ? list.findIndex((x) => x.id === after) : -1;
|
|
66
|
+
if (!after) list.unshift(row); else if (k === -1) list.push(row); else list.splice(k + 1, 0, row);
|
|
67
|
+
for (const h of HORIZONS) data.horizons[h].forEach((x, i) => { x.rank = i; });
|
|
68
|
+
const now = data.horizons.now;
|
|
69
|
+
data.header = {
|
|
70
|
+
now: { ...data.header.now, count: now.length, kr: now.filter((x) => x.anchor_kind === 'kr').length, health: now.filter((x) => x.anchor_kind === 'health').length },
|
|
71
|
+
next: { count: data.horizons.next.length },
|
|
72
|
+
later: { count: data.horizons.later.length, dormant: data.dormant.length },
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function move(id, horizon, after, extra, okText) {
|
|
77
|
+
extra = extra || leaveNow(rowOf(id), horizon); // a card that leaves Now takes its check-by off the ledger too
|
|
78
|
+
applyLocal(id, horizon, after, extra);
|
|
79
|
+
render();
|
|
80
|
+
const out = await place(env.ws, [placed(id, horizon, after, extra)]);
|
|
81
|
+
if (say(out, okText)) { if (out.sig && data) data.sig = out.sig; }
|
|
82
|
+
await reload();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const afterOfRank = (h, id) => {
|
|
86
|
+
const list = ids(h);
|
|
87
|
+
const i = list.indexOf(id);
|
|
88
|
+
return { list, i };
|
|
89
|
+
};
|
|
90
|
+
const toTop = (id) => { const r = rowOf(id); if (r) move(id, r.horizon, null, null, COPY.moved.top(r.title)); };
|
|
91
|
+
function up(id) {
|
|
92
|
+
const r = rowOf(id); if (!r || r.dormant) return;
|
|
93
|
+
const { list, i } = afterOfRank(r.horizon, id);
|
|
94
|
+
if (i <= 0) return;
|
|
95
|
+
move(id, r.horizon, i >= 2 ? list[i - 2] : null);
|
|
96
|
+
}
|
|
97
|
+
function down(id) {
|
|
98
|
+
const r = rowOf(id); if (!r || r.dormant) return;
|
|
99
|
+
const { list, i } = afterOfRank(r.horizon, id);
|
|
100
|
+
if (i < 0 || i >= list.length - 1) return;
|
|
101
|
+
move(id, r.horizon, list[i + 1]);
|
|
102
|
+
}
|
|
103
|
+
const toHorizon = (id, h) => { const r = rowOf(id); if (r) move(id, h, null, null, COPY.moved[h](r.title)); };
|
|
104
|
+
function setCheckBy(id) {
|
|
105
|
+
const r = rowOf(id); if (!r) return;
|
|
106
|
+
const { list, i } = afterOfRank(r.horizon, id);
|
|
107
|
+
const after = i > 0 ? list[i - 1] : null;
|
|
108
|
+
checkBySheet({
|
|
109
|
+
title: COPY.menu.setCheckBy, value: r.check_by,
|
|
110
|
+
onSave: async (date) => {
|
|
111
|
+
const out = await place(env.ws, [placed(id, r.horizon, after, { check_by: date })]);
|
|
112
|
+
if (out && out.ok) { toast({ text: COPY.moved.checkBy(r.title, date ? shortDate(date) : ''), timeout: 4000 }); reload(); }
|
|
113
|
+
return out;
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function openMenu(anchor, id) {
|
|
119
|
+
const r = rowOf(id); if (!r) return;
|
|
120
|
+
const list = ids(r.horizon);
|
|
121
|
+
const i = list.indexOf(id);
|
|
122
|
+
const items = [{ label: COPY.menu.open, onSelect: () => env.openInitiative(env.repo, id) }];
|
|
123
|
+
const order = [];
|
|
124
|
+
if (!r.dormant && i > 0) order.push({ label: COPY.menu.toTop, onSelect: () => toTop(id) }, { label: COPY.menu.up, onSelect: () => up(id) });
|
|
125
|
+
if (!r.dormant && i >= 0 && i < list.length - 1) order.push({ label: COPY.menu.down, onSelect: () => down(id) });
|
|
126
|
+
if (order.length) items.push({ sep: true }, ...order);
|
|
127
|
+
items.push({ sep: true });
|
|
128
|
+
for (const h of HORIZONS) if (r.horizon !== h || r.dormant) items.push({ label: COPY.menu['move' + h[0].toUpperCase() + h.slice(1)], onSelect: () => toHorizon(id, h) });
|
|
129
|
+
if (r.horizon === 'now') items.push({ sep: true }, { label: COPY.menu.setCheckBy, onSelect: () => setCheckBy(id) });
|
|
130
|
+
menu({ anchor, items, ariaLabel: COPY.aria.more(r.title) });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/* ── drawing ──────────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
134
|
+
|
|
135
|
+
function cardOf(r, { grip }) {
|
|
136
|
+
const meta = [r.display && r.display.word, r.progress && r.progress.label];
|
|
137
|
+
if (r.horizon === 'now' && !r.confirmed) meta.push(COPY.unconfirmed);
|
|
138
|
+
if (r.horizon === 'now' && r.check_by) meta.push(COPY.checkBy(shortDate(r.check_by)));
|
|
139
|
+
const hs = healthSignal(r.health);
|
|
140
|
+
const more = iconButton({
|
|
141
|
+
icon: 'ellipsis', size: 'sm', ariaLabel: COPY.aria.more(r.title), expanded: false,
|
|
142
|
+
onClick: (e) => { e.stopPropagation(); openMenu(e.currentTarget, r.id); },
|
|
143
|
+
attrs: { class: 'in-more', 'aria-haspopup': 'menu', 'data-in-more': r.id },
|
|
144
|
+
});
|
|
145
|
+
return card({
|
|
146
|
+
title: r.title, meta, signals: hs ? [hs] : [], lead: grip ? [el('span', { class: 'in-grip' }, dragHandle({ label: COPY.aria.reorder(r.title) }))] : [], trail: [more],
|
|
147
|
+
onOpen: () => env.openInitiative(env.repo, r.id), openLabel: COPY.aria.open(r.title),
|
|
148
|
+
attrs: { class: 'in-card', 'data-in-card': r.id, 'data-horizon': r.horizon, 'data-health': r.health ? r.health.level : 'unknown' },
|
|
149
|
+
openAttrs: { 'data-in-open': r.id },
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function column(h) {
|
|
154
|
+
const rows = data.horizons[h] || [];
|
|
155
|
+
const list = el('div', { class: 'in-list', 'data-in-list': h, role: 'list', 'aria-label': COPY.aria.column(COPY.horizons[h]) }, rows.map((r) => cardOf(r, { grip: true })));
|
|
156
|
+
const kids = [
|
|
157
|
+
el('div', { class: 'in-col__head' }, el('span', { class: 'in-col__name' }, COPY.horizons[h]), el('span', { class: 'in-col__count', 'data-in-count': h }, String(rows.length))),
|
|
158
|
+
];
|
|
159
|
+
if (!rows.length) kids.push(el('p', { class: 'in-col__empty' }, COPY.emptyColumn[h]));
|
|
160
|
+
kids.push(list);
|
|
161
|
+
if (h === 'later' && data.dormant && data.dormant.length) {
|
|
162
|
+
kids.push(button({
|
|
163
|
+
label: showDormant ? COPY.hideDormant(data.dormant.length) : COPY.dormant(data.dormant.length), variant: 'ghost', size: 'sm',
|
|
164
|
+
icon: showDormant ? 'chevron-down' : 'chevron-right', attrs: { 'data-in-dormant-toggle': '', 'aria-expanded': showDormant ? 'true' : 'false' },
|
|
165
|
+
onClick: () => { showDormant = !showDormant; render(); },
|
|
166
|
+
}));
|
|
167
|
+
if (showDormant) kids.push(el('div', { class: 'in-list in-list--dormant', 'data-in-dormant': '' }, data.dormant.map((r) => cardOf(r, { grip: false }))));
|
|
168
|
+
}
|
|
169
|
+
return el('section', { class: 'in-col', 'data-in-col': h, 'aria-label': COPY.horizons[h] }, kids);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function headLine() {
|
|
173
|
+
const n = data.header.now;
|
|
174
|
+
const over = n.cap && n.count > n.cap;
|
|
175
|
+
return el('div', { class: 'in-nowline' + (over ? ' in-nowline--over' : ''), 'data-in-nowline': '', title: over ? COPY.overCap(n.count, n.cap) : null }, COPY.nowLine(n));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function render() {
|
|
179
|
+
if (disposed) return;
|
|
180
|
+
const active = document.activeElement && document.activeElement.getAttribute ? document.activeElement.getAttribute('data-in-open') : null;
|
|
181
|
+
const refocus = focusId || active;
|
|
182
|
+
focusId = null;
|
|
183
|
+
drags.destroy();
|
|
184
|
+
if (!data) {
|
|
185
|
+
root.replaceChildren(error ? el('p', { class: 'in-note in-note--error', role: 'alert' }, COPY.readFailed(error)) : empty({ text: COPY.reading }));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const kids = [headLine(), el('p', { class: 'in-note in-hint', 'data-in-hint': '' }, COPY.menuHintCard)];
|
|
189
|
+
if (data.flags && data.flags.length) kids.push(el('p', { class: 'in-note', 'data-in-flags': '' }, COPY.flags(data.flags.length)));
|
|
190
|
+
kids.push(el('div', { class: 'in-cols', 'data-in-cols': '' }, HORIZONS.map(column)));
|
|
191
|
+
root.replaceChildren(...kids);
|
|
192
|
+
if (refocus) { const t = root.querySelector('[data-in-open="' + CSS.escape(refocus) + '"]'); if (t) t.focus({ preventScroll: true }); }
|
|
193
|
+
bindDrag();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function bindDrag() {
|
|
197
|
+
loadSortable().then((S) => {
|
|
198
|
+
if (!S || disposed) return;
|
|
199
|
+
drags.destroy();
|
|
200
|
+
for (const list of root.querySelectorAll('.in-list[data-in-list]')) {
|
|
201
|
+
drags.add(new S(list, baseOptions({
|
|
202
|
+
group: 'in-roadmap', handle: SEL.drag, draggable: '.in-card',
|
|
203
|
+
onStart: () => { dragging = true; },
|
|
204
|
+
onEnd: (evt) => {
|
|
205
|
+
dragging = false;
|
|
206
|
+
const id = evt.item && evt.item.getAttribute('data-in-card');
|
|
207
|
+
const to = evt.to && evt.to.getAttribute('data-in-list');
|
|
208
|
+
if (!id || !to) { render(); return; }
|
|
209
|
+
const others = ids(to).filter((x) => x !== id);
|
|
210
|
+
const k = Math.max(0, Math.min(evt.newIndex, others.length));
|
|
211
|
+
const after = k > 0 ? others[k - 1] : null;
|
|
212
|
+
const r = rowOf(id);
|
|
213
|
+
if (r && r.horizon === to && ids(to).indexOf(id) === k) { render(); return; }
|
|
214
|
+
move(id, to, after);
|
|
215
|
+
if (deferred) { const d = deferred; deferred = null; adopt(d); }
|
|
216
|
+
},
|
|
217
|
+
})));
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/* The card menu is the phone's path (the ⋯ button shows where a touch screen is). On a desk it opens from the context menu:
|
|
223
|
+
* a right click, or the menu key on a focused card. */
|
|
224
|
+
onContextMenu(root, (t) => {
|
|
225
|
+
const node = t && t.closest ? t.closest('[data-in-card]') : null;
|
|
226
|
+
const open = node && node.querySelector('[data-in-open]');
|
|
227
|
+
return open ? { anchor: open, id: node.getAttribute('data-in-card') } : null;
|
|
228
|
+
}, openMenu);
|
|
229
|
+
|
|
230
|
+
/* ── keyboard: Alt+ArrowUp / Alt+ArrowDown on a card reorder it ──────────────────────────────────────────────── */
|
|
231
|
+
root.addEventListener('keydown', (e) => {
|
|
232
|
+
if (!e.altKey || e.ctrlKey || e.metaKey || (e.key !== 'ArrowUp' && e.key !== 'ArrowDown')) return;
|
|
233
|
+
const open = e.target && e.target.closest ? e.target.closest('[data-in-open]') : null;
|
|
234
|
+
if (!open) return;
|
|
235
|
+
e.preventDefault(); e.stopPropagation();
|
|
236
|
+
const id = open.getAttribute('data-in-open');
|
|
237
|
+
focusId = id;
|
|
238
|
+
if (e.key === 'ArrowUp') up(id); else down(id);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
/* ── reading ──────────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
242
|
+
|
|
243
|
+
function adopt(answer) {
|
|
244
|
+
if (disposed) return;
|
|
245
|
+
if (dragging) { deferred = answer; return; }
|
|
246
|
+
data = answer; error = null;
|
|
247
|
+
if (env.onData) env.onData(answer);
|
|
248
|
+
render();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
let reads = 0; // only the newest read is drawn: an older answer that comes back late is dropped
|
|
252
|
+
async function reload() {
|
|
253
|
+
const mine = ++reads;
|
|
254
|
+
const a = await getRoadmap(env.ws);
|
|
255
|
+
if (disposed || mine !== reads) return;
|
|
256
|
+
if (a && a.ok) adopt(a);
|
|
257
|
+
else if (!data) { error = (a && a.error) || 'the server did not answer'; render(); }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
render();
|
|
261
|
+
reload().then(() => {
|
|
262
|
+
if (disposed) return;
|
|
263
|
+
stop = watchSig(async () => { const a = await getRoadmap(env.ws); if (a && a.ok) { latest = a; return sigKey(a); } return null; },
|
|
264
|
+
() => { if (latest) adopt(latest); }, { everyMs: 10000 });
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
reload,
|
|
269
|
+
dispose() { disposed = true; if (stop) stop(); drags.destroy(); },
|
|
270
|
+
};
|
|
271
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/* pm/sig-watch.js — how a D86 destination learns that its ledgers changed (pm-os-absorb seams §4.9).
|
|
2
|
+
*
|
|
3
|
+
* watchSig(readSig, onChange, { everyMs = 10000 }) → stop()
|
|
4
|
+
*
|
|
5
|
+
* The /api/sessions `ledgers` signatures cover plan-items only (a golden pins that shape), so pmos:ledgers never fires
|
|
6
|
+
* for a placement, a measurement or an issue written from the CLI or another tab. Every destination and extension that
|
|
7
|
+
* shows those (Roadmap 2D, Timeline 4C, Board 4D, Reports 5C, and the Today cards of initiative-needs and report-needs
|
|
8
|
+
* through provider-cards.js) watches its OWN GET `sig` with this one helper:
|
|
9
|
+
* - `readSig()` → Promise<string|null>: the route's current `sig` (a cheap GET; null when unreachable);
|
|
10
|
+
* - it is read on window focus, on visibilitychange to visible, and every `everyMs` while the page is visible;
|
|
11
|
+
* - `onChange(sig)` runs only when the value differs from the last one seen (the first read only records it).
|
|
12
|
+
* No import, no timer while hidden, nothing in fixture mode (the caller does not start it there).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export function watchSig(readSig, onChange, { everyMs = 10000 } = {}) {
|
|
16
|
+
let last;
|
|
17
|
+
let seen = false;
|
|
18
|
+
let busy = false;
|
|
19
|
+
let stopped = false;
|
|
20
|
+
const tick = async () => {
|
|
21
|
+
if (stopped || busy || (typeof document !== 'undefined' && document.visibilityState === 'hidden')) return;
|
|
22
|
+
busy = true;
|
|
23
|
+
let sig = null;
|
|
24
|
+
try { sig = await readSig(); } catch { sig = null; }
|
|
25
|
+
busy = false;
|
|
26
|
+
if (stopped || sig == null) return;
|
|
27
|
+
if (!seen) { seen = true; last = sig; return; }
|
|
28
|
+
if (sig !== last) { last = sig; try { onChange(sig); } catch (e) { console.error('[sig-watch]', e); } }
|
|
29
|
+
};
|
|
30
|
+
const onVis = () => { if (document.visibilityState === 'visible') tick(); };
|
|
31
|
+
const timer = setInterval(tick, Math.max(1000, Number(everyMs) || 10000));
|
|
32
|
+
window.addEventListener('focus', tick);
|
|
33
|
+
document.addEventListener('visibilitychange', onVis);
|
|
34
|
+
tick();
|
|
35
|
+
return function stop() {
|
|
36
|
+
stopped = true;
|
|
37
|
+
clearInterval(timer);
|
|
38
|
+
window.removeEventListener('focus', tick);
|
|
39
|
+
document.removeEventListener('visibilitychange', onVis);
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -42,7 +42,7 @@ const COCKPIT_START = {
|
|
|
42
42
|
tabs: [
|
|
43
43
|
{ id: 'term:ooh-weather-expansion:claude · merge PR 41', kind: 'term', title: 'claude · merge PR 41', slug: 'ooh-weather-expansion', repo: 'microsites', sess: 'claude · merge PR 41', state: 'working', role: 'TERM' },
|
|
44
44
|
{ id: 'doc:planning/prd/ooh-weather-expansion-prd.md', kind: 'doc', title: '…-prd.md', slug: 'ooh-weather-expansion', path: 'planning/prd/ooh-weather-expansion-prd.md', role: 'DOC' },
|
|
45
|
-
{ id: 'view:
|
|
45
|
+
{ id: 'view:validate:microsites', kind: 'view', view: 'validate', title: 'tool · validate', slug: 'microsites', repo: 'microsites' },
|
|
46
46
|
],
|
|
47
47
|
active: 'term:ooh-weather-expansion:claude · merge PR 41',
|
|
48
48
|
};
|
|
@@ -56,7 +56,7 @@ function initial() {
|
|
|
56
56
|
}
|
|
57
57
|
function initialBase() {
|
|
58
58
|
return {
|
|
59
|
-
theme: 'dark', sel: null, q: '', mode: 'product', collapsedGroups: {}, groupBy: 'objective', activeOnly: false,
|
|
59
|
+
theme: 'dark', sel: null, q: '', mode: 'product', collapsedGroups: {}, groupBy: 'objective', activeOnly: false,
|
|
60
60
|
product: null, intgSynced: {},
|
|
61
61
|
addSrc: 'github', addRepo: 'github.com/acme/atlas-mobile', addPath: '~/code/notes-cli',
|
|
62
62
|
addLog: [], addedProducts: [], extraKnow: {},
|
|
@@ -70,7 +70,6 @@ function initialBase() {
|
|
|
70
70
|
// the rail's menu (workspace-cockpit-nav): open or shut, never persisted
|
|
71
71
|
menuOpen: false,
|
|
72
72
|
paletteOpen: false, // Run 2: the ⌘K palette — never persisted
|
|
73
|
-
layoutFocus: false, // Run 2: the Focus layout — never persisted (distinct from `focus`, the focus-mode data)
|
|
74
73
|
todayScope: 'repo',
|
|
75
74
|
filesOpen: false, // Run 3: the Work list shows the repository's files — never persisted
|
|
76
75
|
todayDismissed: {},
|
|
@@ -199,6 +198,9 @@ const TAB_FIELDS = ['id', 'kind', 'title', 'slug', 'repo', 'sess', 'sessionId',
|
|
|
199
198
|
// a view tab's view key (workspace-cockpit-nav) — a reload reopens the same tool
|
|
200
199
|
'view'];
|
|
201
200
|
const MAX_TABS = 40;
|
|
201
|
+
const CUT_VIEWS = new Set(['focus', 'portfolio', 'memory', 'knowledge', 'guide', 'skills', 'scheduled', 'reports', 'agent', 'connect']);
|
|
202
|
+
/** A stored tab of a surface that was cut (R29): the Task tab, or a view tab for one of the removed views. */
|
|
203
|
+
const CUT_TABS = (t) => !!t && (t.kind === 'task' || (t.kind === 'view' && CUT_VIEWS.has(t.view)));
|
|
202
204
|
|
|
203
205
|
function packTab(t) {
|
|
204
206
|
if (!t || typeof t !== 'object' || typeof t.id !== 'string' || typeof t.kind !== 'string') return null;
|
|
@@ -221,7 +223,6 @@ const PERSIST = {
|
|
|
221
223
|
// the dragged width of the navigation column (clamped again by app.js on the way out). `col1` now means "the list under the
|
|
222
224
|
// repositories is open"; the old second-column width (col1W) is no longer stored and an old value is ignored on load.
|
|
223
225
|
col0W: (v) => typeof v === 'number' && Number.isFinite(v) && v >= 100 && v <= 1200,
|
|
224
|
-
settingsOpen: isBool,
|
|
225
226
|
product: isSlug,
|
|
226
227
|
sel: isSlug,
|
|
227
228
|
collapsedGroups: isFlagMap,
|
|
@@ -267,6 +268,9 @@ function restore(target) {
|
|
|
267
268
|
try { ok = PERSIST[k](saved[k]); } catch { ok = false; }
|
|
268
269
|
if (ok) target[k] = saved[k];
|
|
269
270
|
}
|
|
271
|
+
/* A tab of a surface the R29 cuts removed (the Task tab, the Focus, Portfolio, Memory, Knowledge, guide, Skills, Scheduled,
|
|
272
|
+
* Agents and Connect views) is dropped on load, so an old record cannot reopen a pane that no longer exists. */
|
|
273
|
+
if (Array.isArray(target.tabs)) target.tabs = target.tabs.filter((t) => !CUT_TABS(t));
|
|
270
274
|
/* `active` and `splitId` name tabs. A record whose tab list was dropped (or that names a tab the
|
|
271
275
|
* list no longer holds) must not restore a pointer into nothing — panel 4 would render an empty
|
|
272
276
|
* pane and the PM would see a window with no tab selected and no way to guess why. */
|
|
@@ -12,10 +12,12 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { el } from './dom.js';
|
|
14
14
|
import { state, setState, FIXTURE } from './state.js';
|
|
15
|
+
import { wordOf } from './display-word.js';
|
|
16
|
+
import { button } from './primitives.js';
|
|
15
17
|
|
|
16
18
|
const SG = () => (typeof window !== 'undefined' ? window.PMOSStatusGroups : null);
|
|
17
19
|
const LAB = 'font-family:var(--mono);font-size:9.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--muted-foreground)';
|
|
18
|
-
const TONE = { 'needs-you': 'var(--destructive)', 'in-progress': 'var(--
|
|
20
|
+
const TONE = { 'needs-you': 'var(--destructive)', 'in-progress': 'var(--foreground)', 'awaiting-acceptance': 'var(--foreground)', unknown: 'var(--destructive)' };
|
|
19
21
|
const WHO = { pm: 'you', agent: 'the agent', system: 'nobody — it is derived' };
|
|
20
22
|
const iv = (t) => el('span', null, t == null ? '' : String(t));
|
|
21
23
|
|
|
@@ -25,6 +27,13 @@ function line(label, value, attr) {
|
|
|
25
27
|
el('span', { 'data-r18': attr, style: 'font-size:12px;line-height:1.5;color:var(--foreground);overflow-wrap:anywhere' }, iv(value)));
|
|
26
28
|
}
|
|
27
29
|
|
|
30
|
+
/** "See everything waiting on you" opens Today: the frame's Today place, or Today as a tab in the old shells. */
|
|
31
|
+
function seeWaiting() {
|
|
32
|
+
const f = typeof window !== 'undefined' && window.pmApp ? window.pmApp.frame : null;
|
|
33
|
+
if (f && f.on) f.go('today');
|
|
34
|
+
else setState({ mode: 'today', col1: true });
|
|
35
|
+
}
|
|
36
|
+
|
|
28
37
|
/** @returns {HTMLElement|null} null in fixture mode (the prototype has no such block). */
|
|
29
38
|
export function statusBanner(product, slug, rec) {
|
|
30
39
|
const sg = SG();
|
|
@@ -38,7 +47,8 @@ export function statusBanner(product, slug, rec) {
|
|
|
38
47
|
'The status could not be read — ' + (err || 'the server returned no status for this initiative') + '. No group is shown because none can be derived.'));
|
|
39
48
|
}
|
|
40
49
|
const key = sg.keyOf(row);
|
|
41
|
-
|
|
50
|
+
// R3: the word is the one every surface reads (display-word.js → lib/initiative-display.js); the group text below is the evidence
|
|
51
|
+
const g = { ...sg.BY_KEY[key], label: wordOf(row, rec) || sg.BY_KEY[key].label };
|
|
42
52
|
const items = (rec && rec.nextSteps && rec.nextSteps.items) || [];
|
|
43
53
|
const waiting = items.filter((i) => i.kind === 'needs' || i.kind === 'returned');
|
|
44
54
|
// The one thing to do next: the engine's first item, unless it is not yours and something is waiting on you.
|
|
@@ -66,9 +76,7 @@ export function statusBanner(product, slug, rec) {
|
|
|
66
76
|
if (key === 'awaiting-acceptance' || key === 'needs-you') {
|
|
67
77
|
kids.push(el('div', { 'data-r18': 'acceptance-note', style: 'font-size:11px;line-height:1.5;color:var(--muted-foreground);padding-top:4px' },
|
|
68
78
|
'Acceptance is yours and cannot be delegated (D12). Tell the session that holds this initiative to record it, or run scripts/record-acceptance.js. The workspace never records one for you.'),
|
|
69
|
-
|
|
70
|
-
style: 'align-self:flex-start;font-family:var(--mono);font-size:10px;padding:2px 7px;background:var(--secondary);border:1px solid var(--border);color:var(--foreground);margin-top:4px' },
|
|
71
|
-
'see everything waiting on you'));
|
|
79
|
+
button({ label: 'see everything waiting on you', variant: 'outline', size: 'sm', onClick: seeWaiting, attrs: { 'data-r18': 'see-waiting' } }));
|
|
72
80
|
}
|
|
73
81
|
return el('div', { 'data-r18': 'status-banner', 'data-key': key, style: 'display:flex;flex-direction:column;gap:2px;padding:8px 14px;border-bottom:1px solid var(--border)' }, kids);
|
|
74
82
|
}
|
|
@@ -43,6 +43,7 @@ import { state, setState, FIXTURE } from './state.js';
|
|
|
43
43
|
import * as FX from './fixture.js';
|
|
44
44
|
import { mdRender } from './markdown.js';
|
|
45
45
|
import { openDoc } from './panel-initiatives.js';
|
|
46
|
+
import { button } from './primitives.js';
|
|
46
47
|
|
|
47
48
|
/** `{{ expr }}` — one interpolated value in its own span, as the prototype's runtime emits it. */
|
|
48
49
|
const iv = (v) => el('span', null, v === null || v === undefined ? '' : String(v));
|
|
@@ -738,10 +739,7 @@ export function mountDoc(host, tab) {
|
|
|
738
739
|
hover: 'border-color:var(--ring)',
|
|
739
740
|
}, iv(mm)));
|
|
740
741
|
|
|
741
|
-
const saveBtn = dirty ?
|
|
742
|
-
onClick: () => save(tab, text),
|
|
743
|
-
style: 'margin-left:8px;font-family:var(--mono);font-size:10.5px;padding:4px 10px;min-height:26px;background:var(--brand);color:oklch(0.987 0.002 197.1);font-weight:500',
|
|
744
|
-
}, 'save') : null;
|
|
742
|
+
const saveBtn = dirty ? button({ label: 'save', variant: 'primary', size: 'sm', onClick: () => save(tab, text) }) : null;
|
|
745
743
|
|
|
746
744
|
/* The one control that grants a privilege, and the only place the PM learns the page CAN run.
|
|
747
745
|
* Offered only where it means something — a document actually being rendered as a page, in the
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
import { el, setChildren } from './dom.js';
|
|
48
48
|
import { state, setState, subscribe, FIXTURE } from './state.js';
|
|
49
49
|
import * as FX from './fixture.js';
|
|
50
|
+
import { pageNonce } from './http.js'; // R25: a session started with an initiative binds only with the nonce
|
|
50
51
|
|
|
51
52
|
/* ── the design's terminal colours ───────────────────────────────────────────────────────────
|
|
52
53
|
* `--term-bg` is oklch(0.148 0.004 228.8) in BOTH themes. Resolved through a canvas at runtime so
|
|
@@ -118,6 +119,7 @@ const LINKS = () => (typeof window !== 'undefined' && window.WebLinksAddon ? win
|
|
|
118
119
|
const SOCK = {
|
|
119
120
|
ws: null, open: false, tries: 0,
|
|
120
121
|
sessions: [], // the server's session list, module-local on purpose (see below)
|
|
122
|
+
listed: false, // THIS connection's `hello` has arrived, so `sessions` is current — see bind()
|
|
121
123
|
attached: new Set(), // session ids this connection has attached
|
|
122
124
|
spawning: new Map(), // tab id → true, while a `new` is in flight
|
|
123
125
|
spawnQueue: [], // the SAME requests, in order — `created` carries no correlation id
|
|
@@ -292,7 +294,8 @@ function connect() {
|
|
|
292
294
|
let ws;
|
|
293
295
|
try {
|
|
294
296
|
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
295
|
-
|
|
297
|
+
const n = pageNonce();
|
|
298
|
+
ws = new WebSocket(`${proto}://${location.host}/` + (n ? '?pmos-nonce=' + encodeURIComponent(n) : ''));
|
|
296
299
|
} catch { return; }
|
|
297
300
|
SOCK.ws = ws;
|
|
298
301
|
ws.onopen = () => {
|
|
@@ -305,7 +308,7 @@ function connect() {
|
|
|
305
308
|
for (const rec of panes.values()) if (rec.kind === 'real') bind(rec);
|
|
306
309
|
};
|
|
307
310
|
ws.onclose = () => {
|
|
308
|
-
SOCK.open = false; SOCK.ws = null; SOCK.attached.clear();
|
|
311
|
+
SOCK.open = false; SOCK.listed = false; SOCK.ws = null; SOCK.attached.clear();
|
|
309
312
|
/* An in-flight `new` is abandoned rather than retried: the server may well have started the
|
|
310
313
|
* session before the socket dropped, and the reconnect's `hello` will find it by name. Retrying
|
|
311
314
|
* blind is how a PM ends up with two shells and one tab.
|
|
@@ -340,6 +343,7 @@ function send(obj) {
|
|
|
340
343
|
function handle(m) {
|
|
341
344
|
if (m.t === 'hello' || m.t === 'sessions') {
|
|
342
345
|
SOCK.sessions = Array.isArray(m.sessions) ? m.sessions : [];
|
|
346
|
+
SOCK.listed = true;
|
|
343
347
|
if (m.t === 'hello' && Array.isArray(m.workspaces)) SOCK.workspaces = m.workspaces;
|
|
344
348
|
if (m.t === 'hello' && m.home) SOCK.home = m.home;
|
|
345
349
|
for (const rec of panes.values()) if (rec.kind === 'real') bind(rec);
|
|
@@ -565,6 +569,12 @@ function bind(rec) {
|
|
|
565
569
|
return;
|
|
566
570
|
}
|
|
567
571
|
if (!SOCK.open) { note(rec, 'pmos: connecting to the workspace server…'); return; }
|
|
572
|
+
/* NOT BEFORE THE LIST. `onopen` fires one frame before `hello`, and on a fresh page the pool is then empty (or
|
|
573
|
+
* `state.sessions`, which the 4 s poll may not have filled yet), so every name join below answers "not found". A tab
|
|
574
|
+
* that may spawn then starts a SECOND session under a name that is running — the reload case pm-term-test.js
|
|
575
|
+
* asserts. Starting a session or saying one is gone is decided only against the list this connection sent; `hello`
|
|
576
|
+
* is the very next frame and re-binds every pane. An id that IS known attached above, without waiting. */
|
|
577
|
+
if (!SOCK.listed) return;
|
|
568
578
|
if (SOCK.spawning.get(rec.tab.id)) return;
|
|
569
579
|
if (wantsSpawn(rec.tab)) {
|
|
570
580
|
/* THE FALLBACK SPAWN — no longer the PM's path, and no longer pretending to be.
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { el, setChildren } from './dom.js';
|
|
9
9
|
import { state, setState } from './state.js';
|
|
10
|
+
import { askText } from './dialogs.js';
|
|
10
11
|
|
|
11
12
|
const iv = (v) => el('span', null, v === null || v === undefined ? '' : String(v));
|
|
12
13
|
const BTN = 'font-family:var(--mono);font-size:10.5px;padding:3px 9px;border:1px solid var(--border);color:var(--muted-foreground);min-height:26px';
|
|
@@ -46,8 +47,8 @@ export async function openWeb(raw, opts) {
|
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
/** The 🌐 control's action: ask for an address, then open it. */
|
|
49
|
-
export function promptWeb() {
|
|
50
|
-
const raw =
|
|
50
|
+
export async function promptWeb() {
|
|
51
|
+
const raw = await askText({ title: 'Open a web page in a tab', label: 'The full address, starting with http or https.', placeholder: 'https://', confirmLabel: 'Open' });
|
|
51
52
|
if (raw === null || !String(raw).trim()) return;
|
|
52
53
|
openWeb(raw);
|
|
53
54
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/* pm/timeline-copy.js — every word the Timelines show (pm-os-absorb Run 4: slice 4B writes it, 4C imports it).
|
|
2
|
+
*
|
|
3
|
+
* Pure data and pure functions. No DOM and no import, so `glossary.test.js` can load this file in Node and check every
|
|
4
|
+
* string against the glossary rules (GLOSSARY below also holds guide/timeline.md to them). A string that a person reads
|
|
5
|
+
* on Breakdown › Timeline is written here and nowhere else. Dates arrive already short ("20 Oct").
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
9
|
+
|
|
10
|
+
/** "2026-10-20" -> "20 Oct". A date-only value is read as a calendar day, never shifted by the time zone. */
|
|
11
|
+
export function shortDate(ymd) {
|
|
12
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(ymd || ''));
|
|
13
|
+
if (!m) return '';
|
|
14
|
+
return Number(m[3]) + ' ' + MONTHS[Number(m[2]) - 1];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** "2026-10" -> "Oct", and "Jan 2027" for a January, so the year shows where it changes. */
|
|
18
|
+
export function monthLabel(ym) {
|
|
19
|
+
const m = /^(\d{4})-(\d{2})/.exec(String(ym || ''));
|
|
20
|
+
if (!m) return '';
|
|
21
|
+
return Number(m[2]) === 1 ? 'Jan ' + m[1] : MONTHS[Number(m[2]) - 1];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const COPY = {
|
|
25
|
+
view: 'Timeline',
|
|
26
|
+
chart: 'Timeline of the items',
|
|
27
|
+
today: 'Today',
|
|
28
|
+
all: 'All items',
|
|
29
|
+
hint: 'Drag a bar to move its dates. Drag the end of a bar to change the due date.',
|
|
30
|
+
noDates: 'No item has a start or a due date. Open an item in the Tree and set its dates. Its bar then shows here.',
|
|
31
|
+
undated: (n) => (Number(n) === 1 ? '1 item has no dates. Set its dates in the item sheet.' : `${n} items have no dates. Set their dates in the item sheet.`),
|
|
32
|
+
range: (from, to) => (!to || from === to ? String(from) : `${from} – ${to}`),
|
|
33
|
+
stageRow: (stage, n) => (Number(n) === 1 ? `${stage}, 1 dated item` : `${stage}, ${n} dated items`),
|
|
34
|
+
/** One stage's part of the first row, in that row's accessible name. */
|
|
35
|
+
stageSpan: (stage, range) => `${stage}: ${range}.`,
|
|
36
|
+
/** The bar's accessible name: what it is, its dates, its health and milestone, and how to move it without a pointer.
|
|
37
|
+
* `notes` are full sentences without the full stop (the health text, `milestone(date)`). */
|
|
38
|
+
bar: (title, range, notes = []) => [`${title}, ${range}.`, ...notes.filter(Boolean).map((n) => n + '.'),
|
|
39
|
+
'Press to open the item.', 'Use the arrow keys to move the dates by one day.',
|
|
40
|
+
'Use Shift and an arrow key to change the due date.', 'Press Enter to save.'].join(' '),
|
|
41
|
+
milestone: (date) => `Milestone on ${date}`,
|
|
42
|
+
/** R8 reasons. Each one is the text beside the dot. */
|
|
43
|
+
overlap: (title, date) => `At risk: starts before ${title} ends on ${date}`,
|
|
44
|
+
overdue: (date) => `At risk: due on ${date}, not done`,
|
|
45
|
+
milestoneOverdue: (date) => `Off track: milestone due on ${date}, not done`,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const GLOSSARY = { guide: 'timeline.md' };
|