@vimoxshah/tokenflow 1.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +228 -0
  2. package/Dockerfile.team +20 -0
  3. package/README.md +30 -11
  4. package/bin/tokenflow.js +147 -12
  5. package/design/tokens.yaml +330 -0
  6. package/docs/architecture.md +5 -4
  7. package/docs/cli.md +204 -0
  8. package/docs/configuration.md +117 -2
  9. package/docs/design-system.md +187 -0
  10. package/docs/exports-and-budgets.md +85 -0
  11. package/docs/guard-codex.md +132 -0
  12. package/docs/ledger.md +144 -0
  13. package/docs/live-mode.md +40 -0
  14. package/docs/media/overview-aurora-dark.png +0 -0
  15. package/docs/media/receipts-aurora-dark.png +0 -0
  16. package/docs/providers-otel.md +179 -0
  17. package/docs/providers.md +54 -1
  18. package/docs/receipt-schema.md +74 -0
  19. package/docs/roadmap.md +182 -0
  20. package/docs/team-server.md +170 -0
  21. package/docs/ui-views.md +322 -0
  22. package/package.json +7 -2
  23. package/schemas/receipt.v0.json +160 -0
  24. package/scripts/build-dmg.sh +11 -2
  25. package/scripts/build-menubar-app.sh +58 -7
  26. package/scripts/design-build.js +475 -0
  27. package/src/analytics/anatomy.js +467 -0
  28. package/src/analytics/branch-compare.js +159 -0
  29. package/src/analytics/cache-health.js +141 -0
  30. package/src/analytics/live-view.js +266 -0
  31. package/src/analytics/receipt-schema.js +214 -0
  32. package/src/analytics/receipt.js +709 -0
  33. package/src/analytics/rhythm.js +184 -0
  34. package/src/analytics/whatif.js +263 -0
  35. package/src/commands/budget-scopes.js +133 -0
  36. package/src/commands/doctor-checks.js +400 -0
  37. package/src/commands/guard.js +531 -0
  38. package/src/commands/hooks.js +238 -0
  39. package/src/commands/pricing-diff.js +316 -0
  40. package/src/commands/receipt.js +226 -0
  41. package/src/commands/team-serve.js +407 -0
  42. package/src/commands/week.js +86 -0
  43. package/src/core/annotations.js +97 -0
  44. package/src/core/budget.js +33 -0
  45. package/src/core/bundle.js +45 -2
  46. package/src/core/ingest.js +33 -0
  47. package/src/core/live-status.js +227 -2
  48. package/src/core/policy.js +103 -0
  49. package/src/core/receipt-note.js +123 -0
  50. package/src/core/repo.js +64 -0
  51. package/src/core/sync.js +163 -26
  52. package/src/core/team.js +0 -0
  53. package/src/export/html-snapshot.js +28 -1
  54. package/src/export/menubar.js +21 -0
  55. package/src/export/receipt-card.js +210 -0
  56. package/src/export/week-card.js +185 -0
  57. package/src/providers/mock/index.js +383 -52
  58. package/src/providers/openai/index.js +31 -1
  59. package/src/providers/otel/index.js +656 -0
  60. package/src/server/routes/annotations.js +42 -0
  61. package/src/server/routes/cache-health.js +95 -0
  62. package/src/server/routes/index.js +54 -0
  63. package/src/server/routes/session.js +157 -0
  64. package/src/server/server.js +47 -1
  65. package/src/ui/app.js +541 -308
  66. package/src/ui/charts.js +95 -0
  67. package/src/ui/first-run.js +144 -0
  68. package/src/ui/index.html +4 -1
  69. package/src/ui/palette.js +335 -0
  70. package/src/ui/styles/anatomy.css +117 -0
  71. package/src/ui/styles/annotations.css +40 -0
  72. package/src/ui/styles/branches.css +99 -0
  73. package/src/ui/styles/cache.css +6 -0
  74. package/src/ui/styles/first-run.css +31 -0
  75. package/src/ui/styles/live.css +100 -0
  76. package/src/ui/styles/palette.css +85 -0
  77. package/src/ui/styles/rhythm.css +8 -0
  78. package/src/ui/styles/whatif.css +55 -0
  79. package/src/ui/styles.css +303 -196
  80. package/src/ui/views/anatomy.js +567 -0
  81. package/src/ui/views/annotations.js +121 -0
  82. package/src/ui/views/branches.js +304 -0
  83. package/src/ui/views/cache.js +232 -0
  84. package/src/ui/views/index.js +85 -0
  85. package/src/ui/views/live.js +683 -0
  86. package/src/ui/views/rhythm.js +206 -0
  87. package/src/ui/views/whatif.js +196 -0
package/src/ui/charts.js CHANGED
@@ -19,6 +19,82 @@ export const SERIES_VARS = [
19
19
  export const OTHER_COLOR = 'var(--text-muted)';
20
20
  export const SEQ = ['var(--seq-1)', 'var(--seq-2)', 'var(--seq-3)', 'var(--seq-4)', 'var(--seq-5)', 'var(--seq-6)', 'var(--seq-7)'];
21
21
 
22
+ // ------------------------------------------------------------- annotations --
23
+
24
+ /**
25
+ * Day annotations every daily time-series chart overlays. Views must not
26
+ * import app.js, and app.js never learns about a view's data, so this is a
27
+ * module-level list rather than a per-call prop: the Annotations view calls
28
+ * `setAnnotations` after it loads (or reloads) the file, and every chart
29
+ * rendered afterwards — on any tab — picks up the current list. A saved
30
+ * snapshot seeds it at load time from the embedded bundle, since there is no
31
+ * "after it loads" moment there; a live dashboard starts empty until the
32
+ * Annotations tab has been visited at least once in the session.
33
+ */
34
+ let currentAnnotations = (typeof window !== 'undefined' && Array.isArray(window.__TOKENFLOW_BUNDLE__?.annotations))
35
+ ? window.__TOKENFLOW_BUNDLE__.annotations
36
+ : [];
37
+
38
+ /**
39
+ * Set the annotations drawn on every daily time-series chart from here on.
40
+ * @param {{id:string,date:string,text:string}[]} list
41
+ */
42
+ export function setAnnotations(list) {
43
+ currentAnnotations = Array.isArray(list) ? list : [];
44
+ }
45
+
46
+ const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
47
+
48
+ function daysBetweenIso(a, b) {
49
+ return Math.round((Date.parse(`${b}T00:00:00Z`) - Date.parse(`${a}T00:00:00Z`)) / 86400000);
50
+ }
51
+
52
+ /**
53
+ * True when `data` is a calendar-day series: its first two buckets are
54
+ * exactly one day apart. A week or month bucket also has a date-shaped key
55
+ * (the start of the period), so key format alone cannot tell daily from
56
+ * weekly/monthly — only the spacing between buckets can.
57
+ */
58
+ function isDailyAxis(data) {
59
+ if (!data || !data.length || !ISO_DATE_RE.test(data[0].key)) return false;
60
+ if (data.length === 1) return true;
61
+ return ISO_DATE_RE.test(data[1].key) && daysBetweenIso(data[0].key, data[1].key) === 1;
62
+ }
63
+
64
+ /**
65
+ * Map annotations onto x-axis bucket indices for a daily time-series chart.
66
+ * An annotation whose date falls outside [first bucket, last bucket] is
67
+ * dropped; a chart whose axis is not daily (week/month buckets) yields no
68
+ * markers at all, since there is no single bucket a day maps onto.
69
+ * @param {{key:string}[]} data bucket rows, ascending by key
70
+ * @param {{id:string,date:string,text:string}[]} annotations
71
+ * @returns {{index:number, annotation:object}[]}
72
+ */
73
+ export function annotationMarkers(data, annotations) {
74
+ if (!isDailyAxis(data) || !annotations || !annotations.length) return [];
75
+ const first = data[0].key;
76
+ const last = data[data.length - 1].key;
77
+ const out = [];
78
+ for (const a of annotations) {
79
+ if (!a || !a.date || a.date < first || a.date > last) continue;
80
+ let i = daysBetweenIso(first, a.date);
81
+ if (i < 0 || i >= data.length || data[i].key !== a.date) {
82
+ // The series has a gap for this exact day (e.g. a non-contiguous
83
+ // fill): fall back to the first bucket at or after the date so the
84
+ // marker still lands inside the visible range instead of vanishing.
85
+ i = data.findIndex((d) => d.key >= a.date);
86
+ if (i === -1) i = data.length - 1;
87
+ }
88
+ out.push({ index: i, annotation: a });
89
+ }
90
+ return out;
91
+ }
92
+
93
+ function truncateLabel(s, max) {
94
+ const str = String(s ?? '');
95
+ return str.length > max ? `${str.slice(0, Math.max(0, max - 1))}…` : str;
96
+ }
97
+
22
98
  /**
23
99
  * Stable colour assignment: a key always gets the same slot for the lifetime
24
100
  * of the page, so a filter that removes series 2 leaves series 3's colour
@@ -171,6 +247,8 @@ export function niceTicks(min, max, count = 5) {
171
247
  * @param {string} [o.ariaLabel] accessible name for the <svg>
172
248
  * @param {boolean} [o.fillArea] shade the area under the line
173
249
  * @param {boolean} [o.endLabel] label the final value at the line's end
250
+ * @param {{id:string,date:string,text:string}[]} [o.annotations] overrides the
251
+ * module-level list set by `setAnnotations`; only a test needs this.
174
252
  */
175
253
  export function timeSeries(o) {
176
254
  const H = o.height || 300;
@@ -292,6 +370,23 @@ export function timeSeries(o) {
292
370
  }
293
371
  }
294
372
 
373
+ // marked days: a dashed hairline in --text-muted, never a series colour, so
374
+ // an annotation never reads as "another data series".
375
+ const marks = annotationMarkers(data, o.annotations || currentAnnotations);
376
+ for (const { index, annotation } of marks) {
377
+ const ax = x(index);
378
+ root.appendChild(svg('line', {
379
+ class: 'annot-mark', x1: ax, x2: ax, y1: M.t, y2: M.t + ih,
380
+ stroke: 'var(--text-muted)', 'stroke-width': 1, 'stroke-dasharray': '4 3',
381
+ }));
382
+ const label = svg('text', {
383
+ class: 'annot-label', x: Math.min(ax + 4, W - M.r), y: M.t + 10, 'text-anchor': 'start',
384
+ style: 'fill: var(--text-muted); font-size: 10px',
385
+ }, [txt(truncateLabel(annotation.text, 24))]);
386
+ label.appendChild(svg('title', {}, [txt(annotation.text)]));
387
+ root.appendChild(label);
388
+ }
389
+
295
390
  // ---- hover layer: crosshair snapping to the nearest X -------------------
296
391
  const cross = svg('line', { class: 'crosshair', y1: M.t, y2: M.t + ih, opacity: 0 });
297
392
  const dots = svg('g', { opacity: 0 });
@@ -0,0 +1,144 @@
1
+ /**
2
+ * The first-run screen: a one-time modal explaining what TokenFlow read off
3
+ * this machine, the moment a fresh install (or a fresh version) opens the
4
+ * live dashboard.
5
+ *
6
+ * Never shown in a snapshot — app.js only calls `maybeShowFirstRun` when
7
+ * `!SNAPSHOT`, since a saved file has nothing new to report and no
8
+ * `/api/providers` to ask. `/api/providers` itself is optional here too: a
9
+ * failed or slow fetch must never hold up the dialog, so the source rows
10
+ * (built from `bundle.meta.sources`, already in hand) render immediately and
11
+ * the "detected but no data yet" section is appended only if the fetch
12
+ * succeeds.
13
+ *
14
+ * Like palette.js, this module imports nothing from app.js or charts.js —
15
+ * only the leaf formatter module core/units.js, which has no DOM dependency
16
+ * of its own.
17
+ */
18
+ import { int, shortDate } from '../core/units.js';
19
+
20
+ const SEEN_KEY = 'tokenflow-first-run-seen-version';
21
+ const OPT_OUT_KEY = 'tokenflow-first-run-opt-out';
22
+
23
+ /**
24
+ * Whether the screen should show for `appVersion`.
25
+ *
26
+ * "Open the dashboard" marks only THIS version seen — an upgrade to a new
27
+ * appVersion shows it again, since a new version may read new sources.
28
+ * "Do not show again" is a separate, permanent flag that survives upgrades.
29
+ *
30
+ * @param {string} appVersion
31
+ * @returns {boolean}
32
+ */
33
+ function shouldShow(appVersion) {
34
+ try {
35
+ if (localStorage.getItem(OPT_OUT_KEY) === '1') return false;
36
+ return localStorage.getItem(SEEN_KEY) !== appVersion;
37
+ } catch {
38
+ // Private mode / file:// — the flag cannot be read. Showing the screen
39
+ // once more costs less than silently hiding it forever by assuming "yes,
40
+ // seen" on a store that cannot say either way.
41
+ return true;
42
+ }
43
+ }
44
+
45
+ function markSeen(appVersion) {
46
+ try { localStorage.setItem(SEEN_KEY, appVersion); } catch { /* private mode / file:// — it may show again next time, which is safe */ }
47
+ }
48
+
49
+ function markOptOut() {
50
+ try { localStorage.setItem(OPT_OUT_KEY, '1'); } catch { /* private mode / file:// — it may show again next time, which is safe */ }
51
+ }
52
+
53
+ /** One plain sentence for a source already in `bundle.meta.sources`. */
54
+ function sourceSentence(s) {
55
+ const records = s.records || 0;
56
+ const files = s.files || 0;
57
+ const cov = s.coverage;
58
+ const range = cov && cov.from ? `covering ${shortDate(cov.from)} to ${shortDate(cov.to)}` : 'with no dated coverage yet';
59
+ // The demo generator (and any other source with no backing files on disk)
60
+ // reports records with files:0 — "from 0 files" would read as broken, so
61
+ // the clause is dropped rather than stating a record count with no file.
62
+ const fromFiles = files > 0 ? ` from ${int(files)} file${files === 1 ? '' : 's'}` : '';
63
+ return `${s.id}: ${int(records)} record${records === 1 ? '' : 's'}${fromFiles}, ${range}.`;
64
+ }
65
+
66
+ /** One plain sentence for an adapter /api/providers detected that contributed nothing to `sources`. */
67
+ function providerSentence(p) {
68
+ const label = p.name || p.id;
69
+ if (p.available === false) return `${label}: not found on this machine.${p.detail ? ` ${p.detail}` : ''}`;
70
+ if (p.enabled === false) return `${label}: found, but turned off in the current config.`;
71
+ return `${label}: found, but nothing has been read from it yet.`;
72
+ }
73
+
74
+ /**
75
+ * @typedef {object} FirstRunContext
76
+ * @property {(tag:string, attrs?:object, kids?:any)=>HTMLElement} el
77
+ * @property {string} appVersion
78
+ * @property {{id:string,records:number,files:number,coverage:{from:string|null,to:string|null}|null}[]} sources bundle.meta.sources
79
+ * @property {()=>Promise<{providers:object[]}|null>} fetchProviders resolves to null (never rejects) on any failure.
80
+ */
81
+
82
+ /**
83
+ * Show the first-run modal if it has not been seen for `ctx.appVersion` and
84
+ * the user has not opted out permanently. Call only when `!SNAPSHOT` — this
85
+ * module does not check that itself, so it stays usable from a test with a
86
+ * plain object ctx.
87
+ *
88
+ * @param {FirstRunContext} ctx
89
+ */
90
+ export function maybeShowFirstRun(ctx) {
91
+ if (!shouldShow(ctx.appVersion)) return;
92
+ const { el } = ctx;
93
+
94
+ const dialog = /** @type {HTMLDialogElement} */ (document.createElement('dialog'));
95
+ dialog.className = 'first-run-dialog';
96
+ dialog.setAttribute('aria-label', 'What TokenFlow found on this machine');
97
+
98
+ dialog.appendChild(el('div', { class: 'd-head' }, [el('h2', { text: 'What TokenFlow found on this machine' })]));
99
+
100
+ const body = el('div', { class: 'd-body first-run-body' });
101
+ const list = el('div', { class: 'first-run-list' });
102
+ const sources = ctx.sources || [];
103
+ if (sources.length) {
104
+ for (const s of sources) list.appendChild(el('p', { class: 'first-run-row', text: sourceSentence(s) }));
105
+ } else {
106
+ list.appendChild(el('p', { class: 'first-run-row muted', text: 'No source has read a record yet.' }));
107
+ }
108
+ body.appendChild(list);
109
+ const missingHost = el('div', { class: 'first-run-missing' });
110
+ body.appendChild(missingHost);
111
+ body.appendChild(el('p', { class: 'first-run-closing', text: 'Everything here was read from logs already on this machine. Nothing was sent anywhere.' }));
112
+ dialog.appendChild(body);
113
+
114
+ const foot = el('div', { class: 'd-foot' });
115
+ const dismissBtn = el('button', { class: 'btn ghost', text: 'Do not show again' });
116
+ const openBtn = el('button', { class: 'btn primary', text: 'Open the dashboard' });
117
+ foot.appendChild(dismissBtn);
118
+ foot.appendChild(openBtn);
119
+ dialog.appendChild(foot);
120
+
121
+ const done = (opt) => {
122
+ if (opt) markOptOut(); else markSeen(ctx.appVersion);
123
+ dialog.close();
124
+ };
125
+ openBtn.addEventListener('click', () => done(false));
126
+ dismissBtn.addEventListener('click', () => done(true));
127
+ // Escape and any other native dismissal count as "seen", not a permanent
128
+ // opt-out — same as clicking "Open the dashboard".
129
+ dialog.addEventListener('cancel', () => markSeen(ctx.appVersion));
130
+ dialog.addEventListener('close', () => dialog.remove());
131
+
132
+ document.body.appendChild(dialog);
133
+ dialog.showModal();
134
+ openBtn.focus();
135
+
136
+ const sourceIds = new Set(sources.map((s) => s.id));
137
+ ctx.fetchProviders().then((res) => {
138
+ if (!dialog.isConnected || !res || !Array.isArray(res.providers)) return;
139
+ const missing = res.providers.filter((p) => !sourceIds.has(p.id));
140
+ if (!missing.length) return;
141
+ missingHost.appendChild(el('div', { class: 'sec-title', text: 'Detected, but nothing read yet' }));
142
+ for (const p of missing) missingHost.appendChild(el('p', { class: 'first-run-row', text: providerSentence(p) }));
143
+ }).catch(() => { /* never blocks: the dialog already shows what bundle.meta.sources knows */ });
144
+ }
package/src/ui/index.html CHANGED
@@ -24,7 +24,10 @@
24
24
  <div id="banners"></div>
25
25
  <div id="filters" class="filters"></div>
26
26
  <div id="crumbs" class="crumbs"></div>
27
- <nav class="tabs" id="tabs" role="tablist"></nav>
27
+ <div class="tabbar">
28
+ <nav class="tabs" id="tabs" role="tablist"></nav>
29
+ <button class="chip palette-chip" id="palette-chip" type="button" aria-label="Open command palette (Cmd K or Ctrl K)" title="Command palette (⌘K)">⌘K</button>
30
+ </div>
28
31
  <main id="view" style="padding-top:14px"></main>
29
32
  <footer class="muted" style="padding:32px 0 0;font-size:11.5px;line-height:1.7" id="footer"></footer>
30
33
  </div>
@@ -0,0 +1,335 @@
1
+ /**
2
+ * Command palette: Cmd+K / Ctrl+K, or the "⌘K" chip at the end of the tab bar.
3
+ *
4
+ * Split in two on purpose. `rankCommands` is pure — no DOM, no `window`, no
5
+ * `localStorage` — so test/palette.test.js can cover the matching rules with
6
+ * plain node:test. `mountPalette` is the browser half: it builds a native
7
+ * `<dialog>` (free modality, top-layer stacking, and — per the HTML living
8
+ * standard — focus returns to whatever had it when the dialog opened, the
9
+ * moment `close()` runs) and wires up typing, the arrow keys, Enter and Esc.
10
+ *
11
+ * Everything the palette needs to act — the tab list, the quick ranges, the
12
+ * skins, exporting, refreshing — arrives through the `ctx` app.js builds in
13
+ * `mountPalette(ctx)`. palette.js never imports app.js or charts.js, so it
14
+ * stays importable from a plain node:test run with no DOM at all.
15
+ */
16
+
17
+ const RECENT_KEY = 'tokenflow-palette-recent';
18
+ const RECENT_MAX = 8;
19
+
20
+ /**
21
+ * Rank `commands` against `query`, dropping anything that does not match.
22
+ *
23
+ * Four match tiers, best first:
24
+ * 0. the label equals the query exactly
25
+ * 1. the label starts with the query
26
+ * 2. a word inside the label starts with the query ("word-prefix")
27
+ * 3. the query is a subsequence of the label (each character of the query
28
+ * appears in the label, in order, not necessarily adjacent)
29
+ * A command's optional `keywords` string is searched the same way, but a
30
+ * keyword hit never outranks any label hit. Ties (same tier, same position)
31
+ * keep the order `commands` arrived in, so the caller controls what counts as
32
+ * "first" among equals.
33
+ *
34
+ * An empty (or whitespace-only) query skips matching entirely: it returns the
35
+ * commands whose id appears in `recent`, most-recently-used first, then every
36
+ * other command in its original order. An id in `recent` that names no
37
+ * current command is ignored — the caller is not responsible for pruning a
38
+ * stale recent list (a "Refresh data" run before a snapshot load, say).
39
+ *
40
+ * @param {{id:string,label:string,keywords?:string}[]} commands
41
+ * @param {string} [query]
42
+ * @param {string[]} [recent] command ids, most recent first
43
+ * @returns {object[]} the subset of `commands` that match, in rank order
44
+ */
45
+ export function rankCommands(commands, query, recent = []) {
46
+ const list = Array.isArray(commands) ? commands : [];
47
+ const q = String(query ?? '').trim().toLowerCase();
48
+
49
+ if (!q) {
50
+ const recencyOf = new Map();
51
+ (Array.isArray(recent) ? recent : []).forEach((id, i) => { if (!recencyOf.has(id)) recencyOf.set(id, i); });
52
+ const known = list.filter((c) => recencyOf.has(c.id));
53
+ known.sort((a, b) => recencyOf.get(a.id) - recencyOf.get(b.id));
54
+ const rest = list.filter((c) => !recencyOf.has(c.id));
55
+ return [...known, ...rest];
56
+ }
57
+
58
+ const scored = [];
59
+ list.forEach((c, index) => {
60
+ const score = matchScore(c, q);
61
+ if (score !== null) scored.push({ c, score, index });
62
+ });
63
+ scored.sort((a, b) => a.score - b.score || a.index - b.index);
64
+ return scored.map((s) => s.c);
65
+ }
66
+
67
+ /** Lowest (best) tier score for one command against a lowercased query, or null for no match. */
68
+ function matchScore(command, q) {
69
+ const texts = [String(command.label || '').toLowerCase(), String(command.keywords || '').toLowerCase()];
70
+ let best = null;
71
+ texts.forEach((text, hi) => {
72
+ if (!text) return;
73
+ const tierBase = hi * 4000; // any label match outranks every keyword-only match
74
+ let tier = null;
75
+ if (text === q) tier = 0;
76
+ else if (text.startsWith(q)) tier = 1000;
77
+ else {
78
+ const wp = wordPrefixIndex(text, q);
79
+ if (wp !== -1) tier = 2000 + wp;
80
+ else {
81
+ const sub = subsequenceIndex(text, q);
82
+ if (sub !== -1) tier = 3000 + sub;
83
+ }
84
+ }
85
+ if (tier !== null) {
86
+ const total = tierBase + tier;
87
+ if (best === null || total < best) best = total;
88
+ }
89
+ });
90
+ return best;
91
+ }
92
+
93
+ /** Index (in `text`) of the first word that starts with `q`, or -1. */
94
+ function wordPrefixIndex(text, q) {
95
+ const words = text.split(/[^a-z0-9]+/i);
96
+ let at = 0;
97
+ for (const w of words) {
98
+ if (w && w.startsWith(q)) return at;
99
+ at += w.length + 1;
100
+ }
101
+ return -1;
102
+ }
103
+
104
+ /** Index of the first character of `text`'s earliest in-order match of every character in `q`, or -1. */
105
+ function subsequenceIndex(text, q) {
106
+ let from = 0;
107
+ let first = -1;
108
+ for (let i = 0; i < q.length; i++) {
109
+ const at = text.indexOf(q[i], from);
110
+ if (at === -1) return -1;
111
+ if (first === -1) first = at;
112
+ from = at + 1;
113
+ }
114
+ return first;
115
+ }
116
+
117
+ /** The persisted "recently run" command ids, most recent first. Never throws. */
118
+ function loadRecent() {
119
+ try {
120
+ const raw = localStorage.getItem(RECENT_KEY);
121
+ const arr = raw ? JSON.parse(raw) : [];
122
+ return Array.isArray(arr) ? arr.filter((x) => typeof x === 'string') : [];
123
+ } catch {
124
+ // Private mode, or a saved snapshot opened from file:// — recent commands
125
+ // simply do not persist between visits.
126
+ return [];
127
+ }
128
+ }
129
+
130
+ function saveRecent(ids) {
131
+ try {
132
+ localStorage.setItem(RECENT_KEY, JSON.stringify(ids.slice(0, RECENT_MAX)));
133
+ } catch { /* private mode / file:// — see loadRecent() */ }
134
+ }
135
+
136
+ /** Whether `target` is a field a real keystroke could be editing (input, textarea, contenteditable). */
137
+ function isEditable(target) {
138
+ if (!target || typeof target.tagName !== 'string') return false;
139
+ const tag = target.tagName;
140
+ return tag === 'INPUT' || tag === 'TEXTAREA' || !!target.isContentEditable;
141
+ }
142
+
143
+ /**
144
+ * @typedef {object} PaletteContext
145
+ * @property {(tag:string, attrs?:object, kids?:any)=>HTMLElement} el
146
+ * @property {()=>{id:string,label:string}[]} getTabs the merged tab list, read at open time.
147
+ * @property {(id:string)=>void} goToTab
148
+ * @property {{id:string,label:string}[]} ranges the quick ranges (no "custom").
149
+ * @property {(id:string)=>void} applyRange
150
+ * @property {{id:string,name:string}[]} skins
151
+ * @property {(id:string)=>void} setSkin
152
+ * @property {{id:string,label:string}[]} modes
153
+ * @property {(id:string)=>void} setMode
154
+ * @property {()=>boolean} canRefresh false in a snapshot: there is nothing to refresh.
155
+ * @property {()=>void} refresh
156
+ * @property {()=>void} exportCsv
157
+ * @property {()=>void} exportHtmlInfo
158
+ * @property {()=>void} clearFilters
159
+ * @property {()=>void} copyDeepLink
160
+ * @property {()=>(HTMLElement|null)} [activeTabButton] a focus fallback if the original target is gone.
161
+ */
162
+
163
+ /**
164
+ * Mount the command palette once. Returns `{ open, close, isOpen }` so app.js
165
+ * can drive it from the "⌘K" chip as well as the keyboard shortcut.
166
+ *
167
+ * @param {PaletteContext} ctx
168
+ */
169
+ export function mountPalette(ctx) {
170
+ const { el } = ctx;
171
+ let recent = loadRecent();
172
+ let allCommands = [];
173
+ let items = [];
174
+ let activeIndex = 0;
175
+ let lastFocused = null;
176
+
177
+ const dialog = /** @type {HTMLDialogElement} */ (document.createElement('dialog'));
178
+ dialog.className = 'palette-dialog';
179
+ dialog.setAttribute('aria-label', 'Command palette');
180
+ dialog.setAttribute('role', 'dialog');
181
+
182
+ const input = /** @type {HTMLInputElement} */ (el('input', {
183
+ type: 'text', class: 'palette-input', placeholder: 'Type a command…',
184
+ role: 'combobox', 'aria-expanded': 'true', 'aria-controls': 'palette-list', 'aria-autocomplete': 'list',
185
+ }));
186
+ const list = el('div', { class: 'palette-list', id: 'palette-list', role: 'listbox' });
187
+ const empty = el('div', { class: 'palette-empty', text: 'No matching commands' });
188
+
189
+ dialog.appendChild(input);
190
+ dialog.appendChild(list);
191
+ dialog.appendChild(empty);
192
+ document.body.appendChild(dialog);
193
+
194
+ function buildCommands() {
195
+ const cmds = [];
196
+ for (const t of ctx.getTabs()) {
197
+ cmds.push({ id: `tab:${t.id}`, label: t.label, group: 'Go to tab', keywords: 'tab view', run: () => ctx.goToTab(t.id) });
198
+ }
199
+ for (const r of ctx.ranges) {
200
+ cmds.push({ id: `range:${r.id}`, label: r.label, group: 'Quick range', keywords: 'range date filter', run: () => ctx.applyRange(r.id) });
201
+ }
202
+ for (const s of ctx.skins) {
203
+ cmds.push({ id: `skin:${s.id}`, label: `Skin: ${s.name}`, group: 'Appearance', keywords: 'theme skin appearance colour color', run: () => ctx.setSkin(s.id) });
204
+ }
205
+ for (const m of ctx.modes) {
206
+ cmds.push({ id: `mode:${m.id}`, label: `Mode: ${m.label}`, group: 'Appearance', keywords: 'theme mode appearance', run: () => ctx.setMode(m.id) });
207
+ }
208
+ cmds.push({ id: 'export-csv', label: 'Export CSV', group: 'Export', keywords: 'csv download export', run: () => ctx.exportCsv() });
209
+ cmds.push({ id: 'export-html', label: 'Export HTML snapshot', group: 'Export', keywords: 'html snapshot offline export', run: () => ctx.exportHtmlInfo() });
210
+ if (ctx.canRefresh()) {
211
+ cmds.push({ id: 'refresh', label: 'Refresh data', group: 'Actions', keywords: 'refresh reload rescan', run: () => ctx.refresh() });
212
+ }
213
+ cmds.push({ id: 'clear-filters', label: 'Clear filters', group: 'Actions', keywords: 'reset clear filters', run: () => ctx.clearFilters() });
214
+ cmds.push({ id: 'copy-link', label: 'Copy deep link', group: 'Actions', keywords: 'link share url copy', run: () => ctx.copyDeepLink() });
215
+ return cmds;
216
+ }
217
+
218
+ /** DOM rows in the same order as `items`, so setActive() never has to rebuild the list to move the highlight. */
219
+ let rowEls = [];
220
+
221
+ /** Rebuild the list from the current query. Only this touches `list.textContent`, so the arrow keys and a hover never reset scroll position. */
222
+ function rebuild() {
223
+ items = rankCommands(allCommands, input.value, recent);
224
+ list.textContent = '';
225
+ rowEls = [];
226
+ empty.style.display = items.length ? 'none' : '';
227
+ activeIndex = items.length ? Math.min(activeIndex, items.length - 1) : 0;
228
+ items.forEach((c, i) => {
229
+ const row = el('div', {
230
+ class: 'palette-row', role: 'option', id: `palette-opt-${i}`, 'aria-selected': 'false',
231
+ }, [
232
+ el('span', { class: 'palette-row-label', text: c.label }),
233
+ c.group ? el('span', { class: 'palette-row-group', text: c.group }) : null,
234
+ ]);
235
+ row.addEventListener('mousemove', () => setActive(i));
236
+ row.addEventListener('mousedown', (ev) => { ev.preventDefault(); runIndex(i); });
237
+ list.appendChild(row);
238
+ rowEls.push(row);
239
+ });
240
+ setActive(activeIndex);
241
+ }
242
+
243
+ /**
244
+ * Move the highlight to row `i`, clamped to the list's bounds. Only toggles
245
+ * classes/attributes on the rows rebuild() already built, and scrolls the
246
+ * new row into view — no transition either way, so the highlight jumps
247
+ * under the arrow keys instead of trailing them.
248
+ */
249
+ function setActive(i) {
250
+ if (!items.length) { input.removeAttribute('aria-activedescendant'); return; }
251
+ activeIndex = Math.max(0, Math.min(i, items.length - 1));
252
+ rowEls.forEach((row, ri) => {
253
+ const on = ri === activeIndex;
254
+ row.classList.toggle('active', on);
255
+ row.setAttribute('aria-selected', String(on));
256
+ });
257
+ input.setAttribute('aria-activedescendant', `palette-opt-${activeIndex}`);
258
+ rowEls[activeIndex].scrollIntoView({ block: 'nearest' });
259
+ }
260
+
261
+ function remember(id) {
262
+ recent = [id, ...recent.filter((r) => r !== id)].slice(0, RECENT_MAX);
263
+ saveRecent(recent);
264
+ }
265
+
266
+ /** Refocus `target` if it is still in the document, else the active tab button, so focus never lands on `<body>`. */
267
+ function restoreFocus(target) {
268
+ const t = target && document.body.contains(target) ? target : (ctx.activeTabButton && ctx.activeTabButton());
269
+ if (t && typeof t.focus === 'function') t.focus();
270
+ }
271
+
272
+ function runIndex(i) {
273
+ const c = items[i];
274
+ if (!c) return;
275
+ remember(c.id);
276
+ const before = lastFocused;
277
+ closePalette();
278
+ // A command's own action (switching tabs, applying a range) re-renders
279
+ // the page and can detach whatever `before` pointed at, so the fallback
280
+ // in restoreFocus runs AFTER the action, not before it.
281
+ try { c.run(); } finally { restoreFocus(before); }
282
+ }
283
+
284
+ function openPalette() {
285
+ if (dialog.open) { input.focus(); return; }
286
+ lastFocused = document.activeElement;
287
+ allCommands = buildCommands();
288
+ input.value = '';
289
+ activeIndex = 0;
290
+ rebuild();
291
+ dialog.showModal();
292
+ input.focus();
293
+ }
294
+
295
+ function closePalette() {
296
+ if (!dialog.open) return;
297
+ dialog.close();
298
+ }
299
+
300
+ input.addEventListener('input', () => { activeIndex = 0; rebuild(); });
301
+ input.addEventListener('keydown', (ev) => {
302
+ if (ev.key === 'ArrowDown') { ev.preventDefault(); setActive(activeIndex + 1); }
303
+ else if (ev.key === 'ArrowUp') { ev.preventDefault(); setActive(activeIndex - 1); }
304
+ else if (ev.key === 'Enter') { ev.preventDefault(); runIndex(activeIndex); }
305
+ else if (ev.key === 'Escape') { ev.preventDefault(); const before = lastFocused; closePalette(); restoreFocus(before); }
306
+ });
307
+ // Clicking the backdrop lands a click on the dialog element itself (the
308
+ // backdrop is not part of the interactive tree), never on `input` or `list`.
309
+ dialog.addEventListener('mousedown', (ev) => { if (ev.target === dialog) { const before = lastFocused; closePalette(); restoreFocus(before); } });
310
+
311
+ window.addEventListener('keydown', (ev) => {
312
+ const k = ev.key ? ev.key.toLowerCase() : '';
313
+ if (k !== 'k' || (!ev.metaKey && !ev.ctrlKey) || ev.altKey || ev.repeat) return;
314
+ if (dialog.open) {
315
+ ev.preventDefault();
316
+ const before = lastFocused; closePalette(); restoreFocus(before);
317
+ return;
318
+ }
319
+ // Ctrl+K is a real editing shortcut (macOS "kill to end of line") in any
320
+ // other text field on the page — the explorer search, a filter input.
321
+ // Meta+K carries no such meaning anywhere, so it always opens the
322
+ // palette; a bare Ctrl+K only opens it when focus is not already in a
323
+ // field that wants it. The palette's own input can never be `ev.target`
324
+ // here: it is inert while the dialog is closed.
325
+ if (ev.ctrlKey && !ev.metaKey && isEditable(ev.target)) return;
326
+ // A native <dialog> occupies the browser's top layer; opening a second
327
+ // one on top of an already-open one (the shared export/pricing modal)
328
+ // would render underneath it and look broken, so leave it alone.
329
+ if (document.querySelector('dialog[open]')) return;
330
+ ev.preventDefault();
331
+ openPalette();
332
+ });
333
+
334
+ return { open: openPalette, close: closePalette, isOpen: () => dialog.open };
335
+ }