create-pmos 0.47.0 → 0.48.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 +1 -1
- package/payload/AGENTS-HUMAN.md +1 -1
- package/payload/AGENTS.md +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/mods/pmos-cockpit/.claude-plugin/plugin.json +1 -1
- package/payload/mods/pmos-cockpit/README.md +4 -2
- package/payload/mods/pmos-cockpit/hooks/lib/commands.mjs +34 -6
- package/payload/mods/pmos-cockpit/hooks/lib/grants.mjs +22 -3
- package/payload/mods/pmos-cockpit/hooks/lib/guard.mjs +16 -3
- package/payload/mods/pmos-cockpit/hooks/register.tsx +37 -18
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/scripts/pmos-log.sh +18 -8
- package/payload/scripts/pmos-model.js +233 -2
- package/payload/scripts/status-facts.js +204 -0
- package/payload/scripts/workflow-state.js +52 -0
- package/payload/state/discoveries.md +1 -1
- package/workspace/README.md +2 -2
- package/workspace/bin/live-notes.js +133 -0
- package/workspace/design-parity.js +328 -21
- package/workspace/doc-category-bench.js +165 -0
- package/workspace/fixtures/doc-category-labelled.json +167 -0
- package/workspace/guide/initiatives-and-next-steps.md +15 -2
- package/workspace/guide/knowledge-graph.md +83 -1
- package/workspace/guide/layout.md +29 -2
- package/workspace/guide/proactive-engine.md +6 -2
- package/workspace/guide/validate-with-typesafe.md +16 -1
- package/workspace/lib/claims.js +51 -2
- package/workspace/lib/doc-category-corrections.js +113 -0
- package/workspace/lib/doc-category-writeback.js +110 -0
- package/workspace/lib/doc-category.js +225 -0
- package/workspace/lib/engine/pmos-model.js +233 -2
- package/workspace/lib/engine/status-facts.js +204 -0
- package/workspace/lib/engine/workflow-state.js +52 -0
- package/workspace/lib/jev-categorize.js +399 -0
- package/workspace/lib/jev-claims.js +59 -15
- package/workspace/lib/jev-route.js +129 -0
- package/workspace/lib/kg-cli.js +3 -1
- package/workspace/lib/kg.js +6 -1
- package/workspace/lib/learn.js +5 -3
- package/workspace/lib/live-notes.js +890 -0
- package/workspace/lib/typesafe-settings.js +10 -2
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +5 -4
- package/workspace/public/pm/app.js +148 -79
- package/workspace/public/pm/data.js +58 -0
- package/workspace/public/pm/fixture.js +40 -2
- package/workspace/public/pm/modes-live.js +216 -18
- package/workspace/public/pm/panel-cockpit.js +636 -0
- package/workspace/public/pm/panel-detail.js +210 -8
- package/workspace/public/pm/panel-initiatives.js +9 -9
- package/workspace/public/pm/panel-newsess.js +145 -86
- package/workspace/public/pm/panel-repos.js +34 -17
- package/workspace/public/pm/panel-tabs.js +145 -27
- package/workspace/public/pm/state.js +45 -4
- package/workspace/public/pm/tab-doc.js +244 -16
- package/workspace/public/pm/tab-term.js +11 -1
- package/workspace/public/pm/tokens.css +4 -3
- package/workspace/public/pm/view-addrepo.js +207 -0
- package/workspace/public/pm/view-files.js +197 -0
- package/workspace/public/pm/view-guide.js +141 -0
- package/workspace/public/pm/view-knowledge.js +225 -0
- package/workspace/public/pm/view-memory.js +122 -0
- package/workspace/public/pm/view-portfolio.js +153 -0
- package/workspace/public/pm/view-reports.js +190 -0
- package/workspace/public/pm/view-scheduled.js +161 -0
- package/workspace/public/pm/view-skills.js +216 -0
- package/workspace/public/pm/view-today.js +168 -0
- package/workspace/public/pm/view-validate.js +171 -0
- package/workspace/server.js +290 -7
- package/workspace/skills/live-notes.skill +58 -0
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
/* pm/panel-cockpit.js — the cockpit spine: the repository rail, the work list, the menu and the view tabs.
|
|
2
|
+
*
|
|
3
|
+
* This is PRD workspace-cockpit-nav, Run 1, from the PM's mockup (planning/design/cockpit-mockup/, option A). On a desktop the shell
|
|
4
|
+
* is four hosts in a row: a 64px rail of repositories, a list of the selected repository's initiatives, the detail, and the
|
|
5
|
+
* tabs. This module draws the first two, the menu that hangs off the rail, and the `view` tab kind. Nothing here owns the
|
|
6
|
+
* detail or the tabs, and nothing here touches a terminal.
|
|
7
|
+
*
|
|
8
|
+
* ── The one rule ────────────────────────────────────────────────────────────────────────────────
|
|
9
|
+
* A tool never replaces the list. Knowledge, memory, skills, scheduled tasks, validate, the guide, today and all repos
|
|
10
|
+
* open as a `view` tab beside the sessions and are opened once, never twice. The list and the detail stay where they are.
|
|
11
|
+
* `VIEWS` is the table of those tools, and a list mode that is neither in it nor in `NOT_A_VIEW` fails a test, so a new
|
|
12
|
+
* mode has to say where it opens.
|
|
13
|
+
*
|
|
14
|
+
* ── What is not here yet ─────────────────────────────────────────────────────────────────────────
|
|
15
|
+
* The palette and Focus (Run 2), the redrawn views and the in-context items (Run 3), and the phone (Run 4). On a phone the
|
|
16
|
+
* four hosts are still drawn by the older modules, and `state.mode` still drives the phone's lists; only a desktop is
|
|
17
|
+
* handled here. Grants and Jira status are drawn in the mockup and hidden here until each has a backing (assumption 3).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { el, setChildren } from './dom.js';
|
|
21
|
+
import { state, setState, FIXTURE } from './state.js';
|
|
22
|
+
import { currentProduct } from './board-read.js';
|
|
23
|
+
import { repoRow, targetStepIndex } from './fixture.js';
|
|
24
|
+
import {
|
|
25
|
+
repoTuples, activeProduct, selectRepo, onAddProduct, manageParts, repoInitial, treesOf, UNBACKED_MODES, stateHealthBanner, renameRepo,
|
|
26
|
+
} from './panel-repos.js';
|
|
27
|
+
import {
|
|
28
|
+
openTab, groupsFor, selectedSlug, attribution, workingSet, leadOf, orphanRows, searchHits, hitNode, fileHitsBlock,
|
|
29
|
+
} from './panel-initiatives.js';
|
|
30
|
+
import { records } from './board-read.js';
|
|
31
|
+
import { filesToggle, renderFiles } from './view-files.js';
|
|
32
|
+
import { openAddRepo } from './view-addrepo.js';
|
|
33
|
+
|
|
34
|
+
/* ── the views: every tool that is a tab ──────────────────────────────────────────────────────── */
|
|
35
|
+
|
|
36
|
+
/** The label is what the tab says after `view ·`. `scoped` views show one repository's data and are one tab per
|
|
37
|
+
* repository; the others are the same everywhere. `menu` is the group a view is offered in, or null when the menu does not
|
|
38
|
+
* list it (today and all repos are the switch above the list; the focus list is part of Today since Run 2). */
|
|
39
|
+
export const VIEWS = {
|
|
40
|
+
today: { label: 'today', mode: 'today', scoped: false, menu: null },
|
|
41
|
+
portfolio: { label: 'all repos', mode: 'portfolio', scoped: false, menu: null },
|
|
42
|
+
knowledge: { label: 'knowledge', mode: 'knowledge', scoped: true, menu: 'Library', hint: 'decisions, graph' },
|
|
43
|
+
memory: { label: 'memory', mode: 'memory', scoped: true, menu: 'Library', hint: 'learned notes' },
|
|
44
|
+
validate: { label: 'validate', mode: 'validate', scoped: true, menu: 'Library', hint: 'document health' },
|
|
45
|
+
skills: { label: 'skills', mode: 'skills', scoped: false, menu: 'Automate', hint: 'approve, create' },
|
|
46
|
+
scheduled: { label: 'scheduled', mode: 'scheduled', scoped: false, menu: 'Automate', hint: 'cadence tasks' },
|
|
47
|
+
guide: { label: 'guide', mode: 'guide', scoped: false, menu: 'Help and settings', hint: 'the user guide' },
|
|
48
|
+
// Run 3: views with no list mode — they draw only through their board module
|
|
49
|
+
reports: { label: 'reports', mode: null, scoped: true, menu: 'Library', hint: 'run reports' },
|
|
50
|
+
addrepo: { label: 'add repository', mode: null, scoped: false, menu: null },
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** Modes that exist in the code and are deliberately not views, with the reason. `product` is the list itself. */
|
|
54
|
+
export const NOT_A_VIEW = {
|
|
55
|
+
focus: 'folded into Today (Run 2): the mode opens the today view',
|
|
56
|
+
product: 'the work list itself',
|
|
57
|
+
workflow: 'decided by the agent at runtime, never configured by hand',
|
|
58
|
+
agent: 'no server behind it yet, so it is not offered',
|
|
59
|
+
connect: 'no server behind it yet, so it is not offered',
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** The view a list mode opens as, or null when the mode is not a view. */
|
|
63
|
+
export const viewOfMode = (mode) => (mode === 'focus' ? 'today' : Object.prototype.hasOwnProperty.call(VIEWS, mode) ? mode : null);
|
|
64
|
+
|
|
65
|
+
const viewId = (view, repo) => 'view:' + view + (VIEWS[view] && VIEWS[view].scoped && repo ? ':' + repo : '');
|
|
66
|
+
|
|
67
|
+
/** Open a view as a tab, or select it when it is already open. A scoped view is one tab per repository. */
|
|
68
|
+
export function openView(view) {
|
|
69
|
+
const v = VIEWS[view];
|
|
70
|
+
if (!v) return;
|
|
71
|
+
const repo = currentProduct();
|
|
72
|
+
const scoped = v.scoped && repo;
|
|
73
|
+
openTab({
|
|
74
|
+
id: viewId(view, repo), kind: 'view', view,
|
|
75
|
+
title: 'tool · ' + v.label,
|
|
76
|
+
// a scoped view is one tab per repository: the repository rides in `repo` and shows on the tab's second line as its slug
|
|
77
|
+
...(scoped ? { repo, slug: repo } : {}),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Is `view` offered here? In the live app a view whose mode has no server behind it is not. */
|
|
82
|
+
const offered = (view) => FIXTURE || !UNBACKED_MODES.includes(VIEWS[view].mode);
|
|
83
|
+
|
|
84
|
+
/** A desktop never shows a list mode in the list. Code that still says `setState({ mode: 'skills' })` is read here as
|
|
85
|
+
* "open the skills view": the mode is put back to `product` and the view opens. Returns true when it did something. */
|
|
86
|
+
export function absorbMode() {
|
|
87
|
+
const m = state.mode;
|
|
88
|
+
if (!m || m === 'product') return false;
|
|
89
|
+
const view = viewOfMode(m);
|
|
90
|
+
setState({ mode: 'product', settingsOpen: false, menuOpen: false });
|
|
91
|
+
if (view) openView(view);
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* ── lazy modules ─────────────────────────────────────────────────────────────────────────────── */
|
|
96
|
+
|
|
97
|
+
let modesMod; // undefined = not tried · null = loading or unavailable · module = ready
|
|
98
|
+
function modes() {
|
|
99
|
+
if (modesMod !== undefined) return modesMod;
|
|
100
|
+
modesMod = null;
|
|
101
|
+
import('./modes.js').then((m) => { modesMod = m; setState({}); }).catch(() => { modesMod = null; });
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A view tab's pane. The pane is the existing list renderer for the view's mode, drawn full width in the tab. Run 3
|
|
106
|
+
* redraws each of these from its board; Run 1 only moves them out of the list column. */
|
|
107
|
+
/* Run 3: a view whose board has been built draws through its own module, `view-<name>.js`, which exports
|
|
108
|
+
* `renderBoard(inner, ctx)`. `ctx.renderMode(host)` draws the view's existing list into `host`, so a board keeps every live read and
|
|
109
|
+
* every `data-*` hook the suites use, and only adds the board's frame around it. A view with no board module draws as before. */
|
|
110
|
+
export const BOARD_MODULES = {
|
|
111
|
+
today: './view-today.js', portfolio: './view-portfolio.js', knowledge: './view-knowledge.js', memory: './view-memory.js',
|
|
112
|
+
skills: './view-skills.js', scheduled: './view-scheduled.js', validate: './view-validate.js', guide: './view-guide.js',
|
|
113
|
+
reports: './view-reports.js', addrepo: './view-addrepo.js',
|
|
114
|
+
};
|
|
115
|
+
const boardMods = {}; // view -> undefined (not tried) · null (loading or absent) · module
|
|
116
|
+
function boardOf(view) {
|
|
117
|
+
if (!BOARD_MODULES[view]) return null;
|
|
118
|
+
if (boardMods[view] !== undefined) return boardMods[view];
|
|
119
|
+
boardMods[view] = null;
|
|
120
|
+
import(BOARD_MODULES[view]).then((m) => { boardMods[view] = m && typeof m.renderBoard === 'function' ? m : false; setState({}); })
|
|
121
|
+
.catch(() => { boardMods[view] = false; setState({}); });
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function mountView(host, tab) {
|
|
126
|
+
const v = VIEWS[tab.view];
|
|
127
|
+
const body = el('div', { 'data-pm-view': tab.view,
|
|
128
|
+
style: 'flex:1;min-height:0;overflow-y:auto;background:var(--card);padding:18px 22px' });
|
|
129
|
+
const inner = el('div', { style: (tab.view === 'today' ? 'max-width:980px' : 'max-width:760px') + ';display:flex;flex-direction:column;gap:2px' });
|
|
130
|
+
body.appendChild(inner);
|
|
131
|
+
const m = v && v.mode ? modes() : null;
|
|
132
|
+
const board = v ? boardOf(tab.view) : null;
|
|
133
|
+
const renderMode = (into) => {
|
|
134
|
+
if (!v || !v.mode || !m || typeof m.renderMode !== 'function') return;
|
|
135
|
+
try { m.renderMode(into, null, v.mode); } catch (e) { console.error('[pm] view ' + tab.view + ' failed:', e); }
|
|
136
|
+
};
|
|
137
|
+
if (!v) inner.appendChild(el('div', { style: 'color:var(--muted-foreground);font-size:12px' }, 'this view is not known to this version'));
|
|
138
|
+
else if (board) {
|
|
139
|
+
inner.style.maxWidth = 'none';
|
|
140
|
+
try { board.renderBoard(inner, { tab, view: tab.view, renderMode, ready: !v.mode || !!m, el, setChildren, state, setState, FIXTURE, openView, repo: currentProduct() }); }
|
|
141
|
+
catch (e) { console.error('[pm] board ' + tab.view + ' failed:', e); renderMode(inner); }
|
|
142
|
+
} else if (boardMods[tab.view] === null) {
|
|
143
|
+
// the board module is still loading: draw nothing rather than flash the old list
|
|
144
|
+
} else if (m && typeof m.renderMode === 'function') {
|
|
145
|
+
if (tab.view === 'today') inner.appendChild(todayBoard());
|
|
146
|
+
const rest = el('div', { style: 'display:flex;flex-direction:column;gap:2px' });
|
|
147
|
+
inner.appendChild(rest);
|
|
148
|
+
renderMode(rest);
|
|
149
|
+
}
|
|
150
|
+
setChildren(host, [body]);
|
|
151
|
+
return host;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Run 2's Today board, exported so the Run 3 Today module can reuse its derived parts. */
|
|
155
|
+
export { todayBoard };
|
|
156
|
+
|
|
157
|
+
/* ── small shared pieces ──────────────────────────────────────────────────────────────────────── */
|
|
158
|
+
|
|
159
|
+
const BADGE = 'font:500 11px var(--sans);border:1px solid var(--border);border-radius:999px;padding:0 8px;line-height:18px;color:var(--foreground);white-space:nowrap;flex:none';
|
|
160
|
+
const BADGE_TONE = {
|
|
161
|
+
d: 'border-color:transparent;background:color-mix(in oklab, var(--destructive) 18%, transparent);color:var(--destructive)',
|
|
162
|
+
w: 'border-color:transparent;background:color-mix(in oklab, var(--warning) 16%, transparent);color:var(--warning)',
|
|
163
|
+
s: 'border-color:transparent;background:color-mix(in oklab, var(--success) 16%, transparent);color:var(--success)',
|
|
164
|
+
};
|
|
165
|
+
const badge = (text, tone) => el('span', { style: BADGE + (tone ? ';' + BADGE_TONE[tone] : '') }, text);
|
|
166
|
+
const LBL = 'font:500 10px var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--muted-foreground)';
|
|
167
|
+
|
|
168
|
+
const SG = () => (typeof window !== 'undefined' ? window.PMOSStatusGroups : null);
|
|
169
|
+
|
|
170
|
+
/** The derived status group key of one initiative, or `unknown` when nothing has been derived for it. */
|
|
171
|
+
function statusKey(repo, slug) {
|
|
172
|
+
const sg = SG();
|
|
173
|
+
const rows = (state.statuses && state.statuses[repo]) || {};
|
|
174
|
+
return sg ? sg.keyOf(rows[slug]) : 'unknown';
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** What a PM must close: an initiative that is blocked, or delivered and waiting for acceptance. */
|
|
178
|
+
const NEEDS = ['needs-you', 'awaiting-acceptance'];
|
|
179
|
+
|
|
180
|
+
/** How many initiatives, across every repository, are waiting on the person. Derived on each read, never stored. */
|
|
181
|
+
export function needsCount() {
|
|
182
|
+
let n = 0;
|
|
183
|
+
for (const repo of Object.keys(state.statuses || {})) {
|
|
184
|
+
for (const slug of Object.keys(state.statuses[repo] || {})) if (NEEDS.includes(statusKey(repo, slug))) n++;
|
|
185
|
+
}
|
|
186
|
+
return n;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Does anything in `repo` need the person: a blocked or delivered initiative, or a session that asked for them. */
|
|
190
|
+
function repoNeeds(repo) {
|
|
191
|
+
const rows = (state.statuses && state.statuses[repo]) || {};
|
|
192
|
+
if (Object.keys(rows).some((slug) => NEEDS.includes(statusKey(repo, slug)))) return true;
|
|
193
|
+
const sess = Array.isArray(state.sessions) ? state.sessions : [];
|
|
194
|
+
if (!sess.length) return false;
|
|
195
|
+
const wss = new Set(treesOf(repo).map((x) => x.ws));
|
|
196
|
+
return sess.some((x) => x && wss.has(x.ws) && x.shown === 'needs');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/* ── the rail ─────────────────────────────────────────────────────────────────────────────────── */
|
|
200
|
+
|
|
201
|
+
const TILE = 'box-sizing:content-box;position:relative;flex:none;width:36px;height:36px;border-radius:10px;display:grid;grid-template:36px / 36px;place-items:center;font:600 13px var(--sans)';
|
|
202
|
+
const SVG_MENU = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16M4 12h16M4 18h16"/></svg>';
|
|
203
|
+
|
|
204
|
+
/** The 64px column of repositories. One tile per repository, a dashed tile to add one, and the menu at the bottom. */
|
|
205
|
+
export function renderRail(host) {
|
|
206
|
+
const tuples = repoTuples();
|
|
207
|
+
const product = activeProduct(tuples);
|
|
208
|
+
const tiles = tuples.map((t) => {
|
|
209
|
+
const rp = repoRow(t);
|
|
210
|
+
const on = product === rp.name;
|
|
211
|
+
// the dot points at ANOTHER repository: the selected one's needs are in the list beside it (the mockup draws no dot on it)
|
|
212
|
+
const needs = !on && repoNeeds(rp.name);
|
|
213
|
+
const said = rp.name + ' — ' + rp.kindLabel + ' repository' + (needs ? ' · something in it needs you' : '');
|
|
214
|
+
return el('button', {
|
|
215
|
+
onClick: () => selectRepo(rp.name),
|
|
216
|
+
// right-click renames from the rail, where the abbreviation is shown (overhaul-r15 R2)
|
|
217
|
+
onContextmenu: (e) => { if (e && e.preventDefault) e.preventDefault(); renameRepo(rp.name); },
|
|
218
|
+
title: said + ' · right-click to rename', 'aria-label': said, 'aria-pressed': on ? 'true' : 'false',
|
|
219
|
+
'data-rail': 'repo', 'data-r1': 'rail-glyph', 'data-ini': repoInitial(rp.name), 'data-repo': rp.name, 'data-active': on ? '1' : '0',
|
|
220
|
+
style: TILE + ';' + (on ? 'background:var(--brand);color:#fff' : 'background:var(--secondary);color:var(--foreground)'),
|
|
221
|
+
},
|
|
222
|
+
repoInitial(rp.name),
|
|
223
|
+
needs ? el('i', { 'data-rail': 'needs-dot', style: 'position:absolute;right:-3px;top:-3px;width:9px;height:9px;border-radius:50%;background:var(--warning);border:2px solid var(--sidebar)' }) : null,
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
const hb = stateHealthBanner();
|
|
227
|
+
const stateHealth = hb ? el('button', { 'data-rail': 'state-health', title: hb.textContent, 'aria-label': hb.textContent, style: TILE + ';background:var(--destructive);color:#fff' }, '!') : null;
|
|
228
|
+
const add = el('button', {
|
|
229
|
+
onClick: () => openAddRepo(), title: 'add a repository', 'aria-label': 'add a repository', 'data-rail': 'add',
|
|
230
|
+
style: TILE + ';background:transparent;border:1px dashed var(--input);color:var(--muted-foreground)',
|
|
231
|
+
hover: 'color:var(--foreground);background:var(--accent)',
|
|
232
|
+
}, '+');
|
|
233
|
+
const menu = el('button', {
|
|
234
|
+
onClick: () => setState((s) => ({ menuOpen: !s.menuOpen })),
|
|
235
|
+
title: 'menu', 'aria-label': 'menu', 'aria-expanded': state.menuOpen ? 'true' : 'false', 'data-rail': 'menu',
|
|
236
|
+
style: TILE + ';margin-top:auto;background:' + (state.menuOpen ? 'var(--accent)' : 'transparent') + ';color:var(--foreground)',
|
|
237
|
+
hover: 'background:var(--accent)',
|
|
238
|
+
html: SVG_MENU,
|
|
239
|
+
});
|
|
240
|
+
/* The host is the mockup's rail (app.js gives it the centred column, 12px top and bottom, 10px between tiles), with the menu at the
|
|
241
|
+
* foot. The tiles and the add tile scroll in their own column when there are more than fit, so the menu stays put; that column
|
|
242
|
+
* starts 3px higher with 3px of padding, so a needs dot on the first tile is not clipped and the first tile still sits 12px down. */
|
|
243
|
+
setChildren(host, [el('div', { 'data-rail': 'column', style: 'flex:0 1 auto;min-height:0;align-self:stretch;margin-top:-3px;padding-top:3px;display:flex;flex-direction:column;align-items:center;gap:10px;overflow-y:auto' }, stateHealth, tiles, add), el('div', { style: 'flex:none;margin-top:auto;display:flex;justify-content:center' }, menu)]);
|
|
244
|
+
return host;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/* ── the menu ─────────────────────────────────────────────────────────────────────────────────── */
|
|
248
|
+
|
|
249
|
+
const MENU_STYLE = 'position:fixed;left:72px;bottom:40px;width:330px;max-height:calc(100vh - 120px);overflow-y:auto;z-index:50;background:var(--card);border:1px solid var(--input);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,.5);padding:6px';
|
|
250
|
+
const MI = 'display:flex;align-items:center;gap:10px;width:100%;min-height:34px;padding:0 10px;border-radius:6px;font:500 13px var(--sans);text-align:left';
|
|
251
|
+
|
|
252
|
+
/** The entries of the menu, in its three groups. A view with nothing behind it is left out, not greyed. */
|
|
253
|
+
export function menuGroups() {
|
|
254
|
+
const groups = [];
|
|
255
|
+
for (const g of ['Library', 'Automate', 'Help and settings']) {
|
|
256
|
+
const items = Object.keys(VIEWS).filter((k) => VIEWS[k].menu === g && offered(k)).map((k) => ({ view: k, label: VIEWS[k].label, hint: VIEWS[k].hint }));
|
|
257
|
+
if (g === 'Help and settings') { items.push({ action: 'profile', label: 'claude profile', hint: '' }); items.push({ action: 'theme', label: 'theme', hint: state.theme }); }
|
|
258
|
+
if (items.length) groups.push({ label: g + (g === 'Library' ? ' · ' + currentProduct() : g === 'Automate' ? ' · all repos' : ''), items });
|
|
259
|
+
}
|
|
260
|
+
return groups;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** The menu and its click-away layer, drawn into the shell's own overlay host. Empty when the menu is shut. */
|
|
264
|
+
export function renderMenu(host) {
|
|
265
|
+
if (!state.menuOpen) { setChildren(host, []); return host; }
|
|
266
|
+
const close = () => setState({ menuOpen: false });
|
|
267
|
+
const parts = [];
|
|
268
|
+
for (const g of menuGroups()) {
|
|
269
|
+
parts.push(el('div', { style: 'padding:8px 10px 6px;' + LBL }, g.label));
|
|
270
|
+
for (const it of g.items) {
|
|
271
|
+
parts.push(el('button', {
|
|
272
|
+
'data-menu': it.view || it.action,
|
|
273
|
+
onClick: () => {
|
|
274
|
+
if (it.view) { setState({ menuOpen: false }); openView(it.view); }
|
|
275
|
+
else if (it.action === 'theme') setState((s) => ({ theme: s.theme === 'dark' ? 'light' : 'dark', menuOpen: false }));
|
|
276
|
+
else if (it.action === 'profile') setState({ menuOpen: false, detail: { kind: 'prof' }, col2: true });
|
|
277
|
+
},
|
|
278
|
+
style: MI + ';color:var(--foreground)', hover: 'background:var(--accent)',
|
|
279
|
+
}, el('span', null, it.label), it.hint ? el('span', { style: 'margin-left:auto;font:11px var(--mono);color:var(--muted-foreground)' }, it.hint) : null));
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
setChildren(host, [
|
|
283
|
+
el('div', { 'data-menu': 'away', onClick: close, style: 'position:fixed;inset:0;z-index:49' }),
|
|
284
|
+
el('div', { 'data-menu': 'panel', role: 'menu', style: MENU_STYLE }, parts),
|
|
285
|
+
]);
|
|
286
|
+
return host;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/* ── the list ─────────────────────────────────────────────────────────────────────────────────── */
|
|
290
|
+
|
|
291
|
+
/* The filter is built once and re-used, for the reason the older search box is: an input rebuilt on every keystroke loses
|
|
292
|
+
* its caret. ⌘K focuses it through `data-pm-search`. */
|
|
293
|
+
const filterInput = el('input', {
|
|
294
|
+
onInput: (e) => setState({ q: e.target.value }),
|
|
295
|
+
'data-pm-search': '', 'aria-label': 'filter initiatives',
|
|
296
|
+
style: 'position:absolute;inset:0;width:100%;padding:0 10px;box-sizing:border-box;font:13px var(--sans);color:var(--foreground);background:transparent;border:0;outline:0',
|
|
297
|
+
});
|
|
298
|
+
/* One host for Files, kept across repaints so its filter keeps focus. */
|
|
299
|
+
const filesHost = el('div', { 'data-list': 'files', style: 'flex:1;min-height:0;display:flex;flex-direction:column' });
|
|
300
|
+
/* The hint is drawn as text under the transparent input rather than as the input's placeholder, so it is the mockup's text node —
|
|
301
|
+
* what the PM reads, and what the parity check reads. It hides while the box holds anything. */
|
|
302
|
+
const filterHint = el('span', { 'aria-hidden': 'true', 'data-filter-hint': '', style: 'pointer-events:none;white-space:nowrap' }, 'Filter initiatives…');
|
|
303
|
+
const filterBox = el('div', { style: 'position:relative;box-sizing:content-box;display:flex;align-items:center;gap:8px;border:1px solid var(--input);border-radius:8px;height:32px;padding:0 10px;color:var(--muted-foreground)' }, filterHint, filterInput);
|
|
304
|
+
|
|
305
|
+
/** Which group an initiative files under in the list, and the word on its badge. The five derived states the board
|
|
306
|
+
* already knows are folded into the three the PM reads, and nothing is hidden: On hold and Unknown keep their own group. */
|
|
307
|
+
const GROUP_OF = {
|
|
308
|
+
'needs-you': 'needs', 'awaiting-acceptance': 'needs',
|
|
309
|
+
'in-progress': 'progress', planning: 'progress',
|
|
310
|
+
accepted: 'done', closed: 'done', cancelled: 'done',
|
|
311
|
+
'on-hold': 'hold', unknown: 'unknown',
|
|
312
|
+
};
|
|
313
|
+
const GROUPS = [
|
|
314
|
+
['needs', 'Needs you'], ['progress', 'In progress'], ['done', 'Done'], ['hold', 'On hold'], ['unknown', 'Unknown'],
|
|
315
|
+
];
|
|
316
|
+
const WORD = {
|
|
317
|
+
'needs-you': ['needs you', 'd'], 'awaiting-acceptance': ['accept', 'w'], 'in-progress': ['build', null], planning: ['plan', null],
|
|
318
|
+
accepted: [null, null], closed: ['closed', null], cancelled: ['cancelled', null], 'on-hold': ['hold', null], unknown: ['unknown', 'd'],
|
|
319
|
+
};
|
|
320
|
+
/* Run 4: the badge names the RUNG the initiative is at, in the PM's word for it — the mockup's "review" on an initiative blocked in
|
|
321
|
+
* eval, "accept" on one waiting at acceptance, "build" on one building. The rung is the same pointer the breadcrumb and the detail
|
|
322
|
+
* use (`targetStepIndex`: the first blocked step, else the current one). An initiative whose ladder is not read yet keeps the status
|
|
323
|
+
* word above, so a badge never names a rung nobody read. */
|
|
324
|
+
const RUNG_WORD = { eval: 'review', accepted: 'accept' };
|
|
325
|
+
function rungWord(slug) {
|
|
326
|
+
const rec = records()[slug];
|
|
327
|
+
const steps = rec && Array.isArray(rec.steps) ? rec.steps : null;
|
|
328
|
+
const ti = steps ? targetStepIndex(steps) : -1;
|
|
329
|
+
const k = ti >= 0 && steps[ti] ? String(steps[ti].k || '') : '';
|
|
330
|
+
if (!k) return null;
|
|
331
|
+
return RUNG_WORD[k] || (/^[a-z]+$/.test(k) ? k : null);
|
|
332
|
+
}
|
|
333
|
+
const DOT = {
|
|
334
|
+
needs: 'var(--destructive)', progress: 'var(--info)', done: 'var(--success)', hold: 'var(--muted-foreground)', unknown: 'var(--destructive)',
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
const ITEM = 'display:flex;align-items:center;gap:10px;width:100%;min-height:40px;padding:0 10px;border-radius:8px;font:500 13px var(--sans);text-align:left';
|
|
338
|
+
|
|
339
|
+
function row(slug, product, group, live, sel, detKind) {
|
|
340
|
+
const key = statusKey(product, slug);
|
|
341
|
+
const [word0, tone] = WORD[key] || WORD.unknown;
|
|
342
|
+
const word = word0 && (group === 'needs' || group === 'progress') ? (rungWord(slug) || word0) : word0;
|
|
343
|
+
const on = slug === sel && detKind === 'init';
|
|
344
|
+
const [glyph, glyphColor, glyphTitle] = leadOf(slug, live);
|
|
345
|
+
const liveNow = glyph !== '#';
|
|
346
|
+
return el('button', {
|
|
347
|
+
onClick: () => setState({ sel: slug, detail: { kind: 'init' }, col2: true }),
|
|
348
|
+
'data-row': slug, 'data-group': group, 'aria-current': on ? 'true' : null,
|
|
349
|
+
title: liveNow ? glyphTitle : null,
|
|
350
|
+
style: ITEM + ';background:' + (on ? 'var(--accent)' : 'transparent') + ';color:' + (group === 'done' ? 'var(--muted-foreground)' : 'var(--foreground)'),
|
|
351
|
+
hover: 'background:var(--accent)',
|
|
352
|
+
},
|
|
353
|
+
liveNow
|
|
354
|
+
? el('span', { style: 'flex:none;width:8px;text-align:center;font-size:12px;line-height:1;color:' + glyphColor }, glyph)
|
|
355
|
+
: el('span', { style: 'flex:none;width:8px;height:8px;border-radius:50%;background:' + DOT[group] }),
|
|
356
|
+
// the name's box is the mockup's (it grows, it does not clip); a long name is clipped inside it, so a badge is never pushed out
|
|
357
|
+
el('span', { 'data-row-name': '', style: 'flex:1 1 auto;min-width:0' },
|
|
358
|
+
el('div', { style: 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, slug)),
|
|
359
|
+
word && group !== 'done' ? badge(word, tone) : null,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const SEG = 'flex:1 1 0;text-align:center;padding:4px 0;font:13px var(--sans);line-height:1.45;';
|
|
364
|
+
|
|
365
|
+
/** The list column: the repository, the three-way switch, the filter, the initiatives by state, and the way to add one. */
|
|
366
|
+
export function renderWorkList(host) {
|
|
367
|
+
const product = currentProduct();
|
|
368
|
+
const tuples = repoTuples();
|
|
369
|
+
const tuple = tuples.find((t) => t[0] === product);
|
|
370
|
+
const rp = tuple ? repoRow(tuple) : null;
|
|
371
|
+
const DATA = records();
|
|
372
|
+
const q = (state.q || '').toLowerCase();
|
|
373
|
+
const attr = attribution();
|
|
374
|
+
const live = attr.byInitiative;
|
|
375
|
+
const sel = selectedSlug();
|
|
376
|
+
const detKind = (state.detail && state.detail.kind) || '';
|
|
377
|
+
|
|
378
|
+
// every initiative of the repository once, whatever objective it sits under
|
|
379
|
+
const seen = new Set(); const all = [];
|
|
380
|
+
for (const g of groupsFor(product)) for (const s of g.slugs) if (!seen.has(s)) { seen.add(s); all.push(s); }
|
|
381
|
+
const shown = all.filter((s) => !q || s.includes(q));
|
|
382
|
+
const byGroup = Object.fromEntries(GROUPS.map(([k]) => [k, []]));
|
|
383
|
+
for (const s of shown) byGroup[GROUP_OF[statusKey(product, s)] || 'unknown'].push(s);
|
|
384
|
+
|
|
385
|
+
const header = el('div', { style: 'display:flex;align-items:center;justify-content:space-between' },
|
|
386
|
+
el('span', { 'data-list': 'repo', style: 'font-weight:600;font-size:15px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, product || ''),
|
|
387
|
+
rp ? badge(rp.kindLabel + ' · ' + all.length) : null);
|
|
388
|
+
|
|
389
|
+
const act = (state.tabs || []).find((t) => t.id === state.active);
|
|
390
|
+
const activeView = act && act.kind === 'view' && (act.view === 'today' || act.view === 'portfolio') ? act.view : null;
|
|
391
|
+
const segBtn = (label, on, onClick, extra, attrName) => el('button', {
|
|
392
|
+
onClick, 'data-switch': attrName, 'aria-pressed': on ? 'true' : 'false',
|
|
393
|
+
style: SEG + (on ? 'border-radius:6px;background:var(--background);font-weight:500;color:var(--foreground)' : 'color:var(--muted-foreground)'),
|
|
394
|
+
}, label, extra);
|
|
395
|
+
const n = needsCount();
|
|
396
|
+
const switcher = el('div', { 'data-list': 'switch', style: 'display:flex;gap:2px;background:var(--accent);border-radius:8px;padding:3px' },
|
|
397
|
+
segBtn('Work', !activeView, () => { if (activeView) setState({ active: lastNonView() }); }, null, 'work'),
|
|
398
|
+
segBtn(n ? 'Today ' : 'Today', activeView === 'today', () => openView('today'), n ? el('span', { style: 'color:var(--warning)' }, String(n)) : null, 'today'),
|
|
399
|
+
segBtn('All repos', activeView === 'portfolio', () => openView('portfolio'), null, 'all'));
|
|
400
|
+
|
|
401
|
+
const manage = manageParts(product, product);
|
|
402
|
+
const manageRow = manage.tools.length || manage.below.length
|
|
403
|
+
? el('div', { 'data-list': 'manage', style: 'display:flex;flex-direction:column;margin:0 -12px' },
|
|
404
|
+
manage.tools.length ? el('div', { style: 'display:flex;align-items:center;gap:2px;padding:0 12px;flex-wrap:wrap' }, manage.tools) : null, manage.below)
|
|
405
|
+
: null;
|
|
406
|
+
|
|
407
|
+
const groupsNodes = [];
|
|
408
|
+
// what is live now, pinned first, as before: rows are duplicated here, never moved
|
|
409
|
+
const nowRows = FIXTURE ? [] : workingSet(attr, product).filter((e) => !q || e.slug.includes(q));
|
|
410
|
+
if (nowRows.length) {
|
|
411
|
+
groupsNodes.push(el('div', { 'data-group-box': 'now', style: 'display:flex;flex-direction:column;gap:2px' },
|
|
412
|
+
el('div', { style: LBL + ';padding:4px 10px' }, 'Working on · ' + nowRows.length),
|
|
413
|
+
nowRows.map((e) => row(e.slug, product, GROUP_OF[statusKey(product, e.slug)] || 'unknown', live, sel, detKind))));
|
|
414
|
+
}
|
|
415
|
+
for (const [k, label] of GROUPS) {
|
|
416
|
+
const slugs = byGroup[k];
|
|
417
|
+
if (!slugs.length) continue;
|
|
418
|
+
// the mockup's labels: 4px above the first group, 12px above each later one
|
|
419
|
+
groupsNodes.push(el('div', { 'data-group-box': k, style: 'display:flex;flex-direction:column;gap:2px' },
|
|
420
|
+
el('div', { style: LBL + ';padding:' + (groupsNodes.length ? '12px' : '4px') + ' 10px 4px' }, label + ' · ' + slugs.length),
|
|
421
|
+
slugs.map((s) => row(s, product, k, live, sel, detKind))));
|
|
422
|
+
}
|
|
423
|
+
const orphans = q ? [] : (attr.unattributed || []);
|
|
424
|
+
if (orphans.length) groupsNodes.push(orphanRows(orphans));
|
|
425
|
+
|
|
426
|
+
const hits = searchHits(q, DATA, live);
|
|
427
|
+
if (hits.length) groupsNodes.push(el('div', { style: LBL + ';padding:8px 10px 2px' }, 'matches elsewhere'), hits.map(hitNode));
|
|
428
|
+
const files = fileHitsBlock(state.q || '');
|
|
429
|
+
if (files) groupsNodes.push(files);
|
|
430
|
+
if (!shown.length && !hits.length && !(files)) {
|
|
431
|
+
groupsNodes.push(el('div', { style: 'padding:8px 10px;font-size:12px;color:var(--muted-foreground);font-style:italic' },
|
|
432
|
+
q ? 'nothing matches — a successful read of nothing' : 'no initiatives in this repository'));
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const addInit = el('button', {
|
|
436
|
+
onClick: () => setState({ detail: { kind: 'new' }, col2: true, newType: 'init', newAnchor: null }),
|
|
437
|
+
'data-list': 'new-initiative',
|
|
438
|
+
style: ITEM + ';color:var(--brand)', hover: 'background:var(--accent)',
|
|
439
|
+
}, '+ New initiative');
|
|
440
|
+
|
|
441
|
+
if (filterInput.value !== (state.q || '')) filterInput.value = state.q || '';
|
|
442
|
+
filterHint.style.visibility = state.q ? 'hidden' : '';
|
|
443
|
+
// Run 3: Files replaces the initiative rows (and their filter, since Files has its own) while it is open. The fixture reads no
|
|
444
|
+
// repository, so it has no files to list and does not offer the toggle (Files would only say it read nothing).
|
|
445
|
+
const filesOn = !FIXTURE && !!state.filesOpen;
|
|
446
|
+
if (filesOn) renderFiles(filesHost);
|
|
447
|
+
setChildren(host, [
|
|
448
|
+
el('div', { style: 'flex:none;display:flex;flex-direction:column;gap:12px' }, stateHealthBanner(), header, manageRow, switcher, FIXTURE ? null : filesToggle(), filesOn ? null : filterBox),
|
|
449
|
+
// the rows take their own height and scroll only when they outgrow the column; "New initiative" sits at the bottom either way
|
|
450
|
+
filesOn ? filesHost : el('div', { 'data-list': 'rows', style: 'flex:0 1 auto;min-height:0;overflow-y:auto;display:flex;flex-direction:column;gap:2px' }, groupsNodes),
|
|
451
|
+
el('div', { style: 'flex:none;margin-top:auto;display:flex;flex-direction:column;gap:8px' }, el('div', { style: 'height:1px;background:var(--border)' }), addInit),
|
|
452
|
+
]);
|
|
453
|
+
return host;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** The tab to show when the PM goes back to Work from a view: the last one that is not a view, else none. */
|
|
457
|
+
function lastNonView() {
|
|
458
|
+
const t = [...(state.tabs || [])].reverse().find((x) => x.kind !== 'view');
|
|
459
|
+
return t ? t.id : null;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/* ── the breadcrumb ───────────────────────────────────────────────────────────────────────────── */
|
|
463
|
+
|
|
464
|
+
/** repository › initiative › the active tab, as the nodes of the top bar. */
|
|
465
|
+
export function breadcrumb() {
|
|
466
|
+
const parts = [];
|
|
467
|
+
const repo = currentProduct();
|
|
468
|
+
if (repo) parts.push(repo);
|
|
469
|
+
const slug = selectedSlug();
|
|
470
|
+
if (slug && state.detail && state.detail.kind === 'init') parts.push(slug);
|
|
471
|
+
// the step the initiative is at: the first blocked one, else the current one — the same pointer the detail's answer line uses
|
|
472
|
+
const rec = slug && state.detail && state.detail.kind === 'init' ? records()[slug] : null;
|
|
473
|
+
const ti = rec && Array.isArray(rec.steps) ? targetStepIndex(rec.steps) : -1;
|
|
474
|
+
if (ti >= 0 && rec.steps[ti] && rec.steps[ti].k) parts.push(rec.steps[ti].k);
|
|
475
|
+
const act = (state.tabs || []).find((t) => t.id === state.active);
|
|
476
|
+
if (act && act.title) parts.push(act.title);
|
|
477
|
+
const nodes = [];
|
|
478
|
+
parts.forEach((p, i) => {
|
|
479
|
+
if (i) nodes.push(el('span', { 'aria-hidden': 'true' }, '›'));
|
|
480
|
+
const last = i === parts.length - 1;
|
|
481
|
+
nodes.push(el('span', { style: last ? 'color:var(--foreground)' : '', 'data-crumb': last && act && act.title === p ? 'tab' : null }, p));
|
|
482
|
+
});
|
|
483
|
+
if (state.layoutFocus) {
|
|
484
|
+
nodes.push(badge('Focus', 'w'));
|
|
485
|
+
nodes.push(el('button', { 'data-crumb': 'navigate', onClick: leaveFocus, title: 'back to the rail and the list (Esc)',
|
|
486
|
+
style: 'font:500 11px var(--sans);border:1px solid var(--border);border-radius:6px;padding:0 8px;line-height:18px;color:var(--foreground)', hover: 'background:var(--accent)' }, 'Navigate'));
|
|
487
|
+
}
|
|
488
|
+
return nodes;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
/* ── Focus (Run 2, R7) ────────────────────────────────────────────────────────────────────────── */
|
|
493
|
+
|
|
494
|
+
/** Focus is a layout, not a record: it is never stored, and no host is created, moved or re-parented to enter it. */
|
|
495
|
+
export function enterFocus() { if (!state.layoutFocus) setState({ layoutFocus: true, menuOpen: false, paletteOpen: false }); }
|
|
496
|
+
export function leaveFocus() { if (state.layoutFocus) setState({ layoutFocus: false }); }
|
|
497
|
+
export const toggleFocus = () => (state.layoutFocus ? leaveFocus() : enterFocus());
|
|
498
|
+
|
|
499
|
+
/* ── the palette (Run 2, R6) ──────────────────────────────────────────────────────────────────── */
|
|
500
|
+
|
|
501
|
+
let palQ = '';
|
|
502
|
+
let palSel = 0;
|
|
503
|
+
|
|
504
|
+
/** Everything the palette can jump to, in the order it lists them. Derived on each open, never stored. */
|
|
505
|
+
export function paletteItems() {
|
|
506
|
+
const items = [];
|
|
507
|
+
const tuples = repoTuples();
|
|
508
|
+
for (const t of tuples) {
|
|
509
|
+
const repo = t[0];
|
|
510
|
+
for (const g of groupsFor(repo)) for (const slug of g.slugs) {
|
|
511
|
+
if (items.some((x) => x.kind === 'initiative' && x.repo === repo && x.slug === slug)) continue;
|
|
512
|
+
items.push({ kind: 'initiative', label: slug, hint: repo, repo, slug,
|
|
513
|
+
run: () => { selectRepo(repo); setState({ sel: slug, detail: { kind: 'init' }, col2: true }); } });
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
for (const t of tuples) items.push({ kind: 'repository', label: t[0], hint: t[1] || '', run: () => selectRepo(t[0]) });
|
|
517
|
+
for (const t of state.tabs || []) {
|
|
518
|
+
items.push({ kind: t.kind === 'term' ? 'session' : 'tab', label: String(t.title || t.id).replace(/^tool · /, ''), hint: t.kind === 'term' ? 'session' : 'open tab',
|
|
519
|
+
run: () => setState({ active: t.id }) });
|
|
520
|
+
}
|
|
521
|
+
for (const k of Object.keys(VIEWS)) if (offered(k)) items.push({ kind: 'view', label: VIEWS[k].label, hint: VIEWS[k].hint || 'view', run: () => openView(k) });
|
|
522
|
+
return items;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const palFilter = (items, q) => {
|
|
526
|
+
const w = q.toLowerCase().split(/\s+/).filter(Boolean);
|
|
527
|
+
// with nothing typed, every kind shows: a long list of initiatives must not push the views off the list
|
|
528
|
+
if (!w.length) { const seen = {}; return items.filter((it) => (seen[it.kind] = (seen[it.kind] || 0) + 1) <= 6); }
|
|
529
|
+
return items.filter((it) => w.every((x) => (it.label + ' ' + it.hint + ' ' + it.kind).toLowerCase().includes(x))).slice(0, 40);
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
/* The input is made once and kept: a repaint replaces the list under it, never the box being typed in, so focus and the caret survive. */
|
|
533
|
+
let palItems = [];
|
|
534
|
+
const palClose = () => setState({ paletteOpen: false });
|
|
535
|
+
const palChoose = (it) => { if (!it) return; setState({ paletteOpen: false }); it.run(); };
|
|
536
|
+
const palInput = el('input', {
|
|
537
|
+
type: 'text', 'data-palette': 'input', placeholder: 'Jump to anything…', autocomplete: 'off', spellcheck: 'false',
|
|
538
|
+
style: 'width:100%;box-sizing:border-box;font:14px var(--sans);padding:12px 14px;border:0;border-bottom:1px solid var(--border);background:transparent;color:var(--foreground);outline:none',
|
|
539
|
+
onInput: (e) => { palQ = e.target.value; palSel = 0; setState({}); },
|
|
540
|
+
onKeydown: (e) => {
|
|
541
|
+
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); palClose(); }
|
|
542
|
+
else if (e.key === 'ArrowDown') { e.preventDefault(); palSel = Math.min(palItems.length - 1, palSel + 1); setState({}); }
|
|
543
|
+
else if (e.key === 'ArrowUp') { e.preventDefault(); palSel = Math.max(0, palSel - 1); setState({}); }
|
|
544
|
+
else if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); palChoose(palItems[palSel]); }
|
|
545
|
+
},
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
export function renderPalette(host) {
|
|
549
|
+
if (!state.paletteOpen) { palQ = ''; palSel = 0; palInput.value = ''; setChildren(host, []); return host; }
|
|
550
|
+
const items = palItems = palFilter(paletteItems(), palQ);
|
|
551
|
+
if (palSel >= items.length) palSel = Math.max(0, items.length - 1);
|
|
552
|
+
if (palInput.value !== palQ) palInput.value = palQ;
|
|
553
|
+
const rows = items.length ? items.map((it, i) => el('button', {
|
|
554
|
+
'data-palette': 'item', 'data-kind': it.kind, 'aria-selected': i === palSel ? 'true' : 'false', onClick: () => palChoose(it),
|
|
555
|
+
style: 'display:flex;align-items:center;gap:10px;width:100%;text-align:left;padding:7px 14px;font-size:13px;background:' + (i === palSel ? 'var(--accent)' : 'transparent'),
|
|
556
|
+
hover: 'background:var(--accent)',
|
|
557
|
+
}, el('span', { style: LBL + ';width:78px;flex:none' }, it.kind), el('span', { style: 'flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, it.label),
|
|
558
|
+
el('span', { style: 'color:var(--muted-foreground);font-size:11px;white-space:nowrap' }, it.hint)))
|
|
559
|
+
: [el('div', { 'data-palette': 'empty', style: 'padding:14px;color:var(--muted-foreground);font-size:12px' }, 'Nothing matches "' + palQ + '".')];
|
|
560
|
+
const fresh = !host.firstChild;
|
|
561
|
+
setChildren(host, [el('div', { 'data-palette': 'overlay', onClick: (e) => { if (e.target === e.currentTarget) palClose(); },
|
|
562
|
+
style: 'position:fixed;inset:0;z-index:60;background:oklch(0 0 0 / 45%);display:flex;justify-content:center;align-items:flex-start;padding-top:12vh' },
|
|
563
|
+
el('div', { role: 'dialog', 'aria-label': 'Jump to anything', style: 'width:560px;max-width:calc(100vw - 32px);background:var(--popover, var(--card));color:var(--foreground);border:1px solid var(--border);border-radius:10px;overflow:hidden;box-shadow:0 20px 50px oklch(0 0 0 / 50%)' },
|
|
564
|
+
palInput, el('div', { 'data-palette': 'list', style: 'max-height:50vh;overflow-y:auto;padding:4px 0' }, rows)))]);
|
|
565
|
+
// the box is in the document now, so focus it at once: a key pressed straight after ⌘K must land in it
|
|
566
|
+
if (fresh || document.activeElement !== palInput) { try { palInput.focus(); } catch { /* not mounted */ } }
|
|
567
|
+
return host;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/* ── Today's board (Run 2, R9) ────────────────────────────────────────────────────────────────── */
|
|
571
|
+
|
|
572
|
+
const DISMISS_REASONS = ['not now', 'wrong priority', 'already handled'];
|
|
573
|
+
|
|
574
|
+
/** The initiatives waiting on the person, from derived status. `all` widens it from the current repository to every one. */
|
|
575
|
+
export function needsCards(all) {
|
|
576
|
+
const repos = all ? Object.keys(state.statuses || {}) : [currentProduct()].filter(Boolean);
|
|
577
|
+
const out = [];
|
|
578
|
+
for (const repo of repos) {
|
|
579
|
+
for (const slug of Object.keys((state.statuses && state.statuses[repo]) || {})) {
|
|
580
|
+
if (!NEEDS.includes(statusKey(repo, slug))) continue;
|
|
581
|
+
if ((state.todayDismissed || {})[repo + '/' + slug]) continue;
|
|
582
|
+
out.push({ repo, slug, key: statusKey(repo, slug) });
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
return out;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function todayBoard() {
|
|
589
|
+
const all = state.todayScope === 'all';
|
|
590
|
+
const cards = needsCards(all);
|
|
591
|
+
const repos = repoTuples();
|
|
592
|
+
const hour = new Date().getHours();
|
|
593
|
+
const greet = hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening';
|
|
594
|
+
const prepared = [];
|
|
595
|
+
for (const t of repos) {
|
|
596
|
+
if (!all && t[0] !== currentProduct()) continue;
|
|
597
|
+
for (const slug of Object.keys((state.statuses && state.statuses[t[0]]) || {})) {
|
|
598
|
+
if (statusKey(t[0], slug) === 'in-progress') prepared.push({ repo: t[0], slug });
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
const toggle = el('div', { 'data-today': 'scope', style: 'display:flex;gap:2px;background:var(--accent);border-radius:8px;padding:3px' },
|
|
602
|
+
...[['repo', 'Repository'], ['all', 'All']].map(([k, label]) => el('button', {
|
|
603
|
+
'data-today-scope': k, 'aria-pressed': (k === 'all') === all ? 'true' : 'false',
|
|
604
|
+
onClick: () => setState({ todayScope: k === 'all' ? 'all' : 'repo' }),
|
|
605
|
+
style: 'font:12px var(--sans);padding:2px 10px;border-radius:6px;' + ((k === 'all') === all ? 'background:var(--background);font-weight:500;color:var(--foreground)' : 'color:var(--muted-foreground)'),
|
|
606
|
+
}, label)));
|
|
607
|
+
const card = (c) => el('div', { 'data-today': 'card', 'data-slug': c.slug, style: 'border:1px solid var(--border);border-radius:8px;padding:10px 12px;display:flex;flex-direction:column;gap:8px;background:var(--card)' },
|
|
608
|
+
el('div', { style: 'display:flex;gap:8px;align-items:baseline' },
|
|
609
|
+
el('span', { style: 'font-weight:600;font-size:13px;min-width:0;overflow:hidden;text-overflow:ellipsis' }, c.slug),
|
|
610
|
+
el('span', { style: 'margin-left:auto;color:var(--muted-foreground);font-size:11px' }, c.repo)),
|
|
611
|
+
badge(c.key === 'needs-you' ? 'needs you' : 'review', c.key === 'needs-you' ? 'd' : 'w'),
|
|
612
|
+
el('div', { style: 'display:flex;gap:6px;flex-wrap:wrap;align-items:center' },
|
|
613
|
+
el('button', { 'data-today': 'open', onClick: () => { selectRepo(c.repo); setState({ sel: c.slug, detail: { kind: 'init' }, col2: true }); },
|
|
614
|
+
style: 'font:500 12px var(--sans);padding:3px 10px;border-radius:6px;background:var(--primary);color:var(--primary-foreground)' }, c.key === 'needs-you' ? 'Unblock' : 'Review'),
|
|
615
|
+
el('span', { style: 'color:var(--muted-foreground);font-size:11px' }, 'dismiss:'),
|
|
616
|
+
...DISMISS_REASONS.map((r) => el('button', { 'data-today': 'dismiss', 'data-reason': r,
|
|
617
|
+
onClick: () => setState((s) => ({ todayDismissed: { ...(s.todayDismissed || {}), [c.repo + '/' + c.slug]: r } })),
|
|
618
|
+
style: 'font:11px var(--sans);padding:1px 8px;border:1px solid var(--border);border-radius:999px;color:var(--muted-foreground)', hover: 'color:var(--foreground)' }, r))));
|
|
619
|
+
const col = (title, kids, attr) => el('div', { 'data-today': attr, style: 'flex:1;min-width:260px;display:flex;flex-direction:column;gap:8px' },
|
|
620
|
+
el('div', { style: LBL }, title), ...kids);
|
|
621
|
+
const empty = (t) => el('div', { style: 'color:var(--muted-foreground);font-size:12px' }, t);
|
|
622
|
+
return el('div', { 'data-today': 'board', style: 'display:flex;flex-direction:column;gap:14px;padding-bottom:14px;margin-bottom:8px;border-bottom:1px solid var(--border)' },
|
|
623
|
+
el('div', { style: 'display:flex;align-items:center;gap:12px' },
|
|
624
|
+
el('div', { 'data-today': 'greeting', style: 'font-size:18px;font-weight:600' }, greet), toggle),
|
|
625
|
+
el('div', { 'data-today': 'count', style: 'color:var(--muted-foreground);font-size:12px' },
|
|
626
|
+
cards.length + (cards.length === 1 ? ' thing needs you' : ' things need you') + (all ? ' across ' + repos.length + ' repositories' : ' in ' + (currentProduct() || 'this repository'))
|
|
627
|
+
+ ' · ' + prepared.length + ' in progress'),
|
|
628
|
+
el('div', { 'data-today': 'sources', style: 'color:var(--muted-foreground);font-size:11px' },
|
|
629
|
+
'Sources reached: ' + (FIXTURE ? 'the fixture records' : 'derived status of ' + Object.keys(state.statuses || {}).length + ' repositories') + '. No mail or calendar is read here.'),
|
|
630
|
+
el('div', { style: 'display:flex;gap:16px;flex-wrap:wrap;align-items:flex-start' },
|
|
631
|
+
col('Needs you', cards.length ? cards.map(card) : [empty('Nothing is waiting on you.')], 'needs'),
|
|
632
|
+
col('Prepared for you', prepared.length ? prepared.map((c) => el('button', { 'data-today': 'prepared', 'data-slug': c.slug,
|
|
633
|
+
onClick: () => { selectRepo(c.repo); setState({ sel: c.slug, detail: { kind: 'init' }, col2: true }); },
|
|
634
|
+
style: 'text-align:left;border:1px solid var(--border);border-radius:8px;padding:8px 12px;font-size:13px;background:var(--card)', hover: 'background:var(--accent)' }, c.slug + ' · ' + c.repo)) : [empty('Nothing is being prepared.')], 'prepared'),
|
|
635
|
+
col('Today', [empty('Events come from the morning brief; none was read into this view.')], 'events')));
|
|
636
|
+
}
|