@jhizzard/termdeck 0.11.0 → 0.12.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jhizzard/termdeck",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Browser-based terminal multiplexer with metadata overlays, panel flashback memory recall, and AI-aware session management",
5
5
  "bin": {
6
6
  "termdeck": "./packages/cli/src/index.js"
@@ -29,7 +29,8 @@
29
29
  "server": "node packages/server/src/index.js",
30
30
  "start": "NODE_ENV=production node packages/cli/src/index.js",
31
31
  "test": "node --test packages/server/tests/**/*.test.js",
32
- "install:app": "bash install.sh"
32
+ "install:app": "bash install.sh",
33
+ "sync-rumen-functions": "bash scripts/sync-rumen-functions.sh"
33
34
  },
34
35
  "dependencies": {
35
36
  "@homebridge/node-pty-prebuilt-multiarch": "^0.13.1",
@@ -18,10 +18,12 @@
18
18
  // 2. Derive project ref from SUPABASE_URL; confirm with user
19
19
  // 3. supabase link --project-ref <ref>
20
20
  // 4. Apply rumen migration 001 via pg
21
- // 5. supabase functions deploy rumen-tick --no-verify-jwt
21
+ // 5. supabase functions deploy rumen-tick AND graph-inference (Sprint 43 T3)
22
+ // from a single staging dir with multi-function supabase/config.toml
22
23
  // 6. supabase secrets set DATABASE_URL=... ANTHROPIC_API_KEY=... [OPENAI_API_KEY=...]
23
- // 7. Test the function with a manual POST (fetch)
24
- // 8. Apply pg_cron schedule migration (002) with project ref substituted
24
+ // 7. Test rumen-tick with a manual POST (graph-inference is cron-only)
25
+ // 8. Apply pg_cron schedule migrations 002 (rumen-tick) AND 003 (graph-inference)
26
+ // with project ref substituted
25
27
 
26
28
  const path = require('path');
27
29
  const fs = require('fs');
@@ -301,67 +303,98 @@ async function applyRumenTables(secrets, dryRun) {
301
303
  }
302
304
  }
303
305
 
304
- function deployFunction(rumenVersion, dryRun) {
305
- step('Running: supabase functions deploy rumen-tick --no-verify-jwt...');
306
+ // Sprint 43 T3: rumen-tick is the only function with a `__RUMEN_VERSION__`
307
+ // placeholder (its `npm:@jhizzard/rumen@<ver>` import is rewritten at deploy
308
+ // time). graph-inference pins its own deps (`npm:postgres@3.4.4`) and is
309
+ // copied verbatim. If a future function adds a placeholder, list it here.
310
+ const FUNCTIONS_WITH_VERSION_PLACEHOLDER = new Set(['rumen-tick']);
311
+
312
+ function deployFunctions(rumenVersion, dryRun) {
313
+ const fnNames = migrations.listRumenFunctions();
314
+ if (fnNames.length === 0) {
315
+ fail('no Rumen Edge Function source found in bundled setup or @jhizzard/rumen package');
316
+ return false;
317
+ }
318
+
319
+ step(`Staging ${fnNames.length} Edge Function(s) (${fnNames.join(', ')})...`);
306
320
  if (dryRun) { ok('(dry-run)'); return true; }
307
321
 
308
- // We need the supabase command to run against a repo layout with
309
- // `supabase/functions/rumen-tick/`. The TermDeck install does NOT include
310
- // a `supabase/` directory at the project root, so we stage a tiny working
311
- // directory under `os.tmpdir()` that mirrors what the CLI expects.
312
- const stage = stageRumenFunction(rumenVersion);
322
+ // Stage all functions in one directory and one config.toml so a single
323
+ // `supabase functions deploy <name>` invocation per function can share the
324
+ // project root. This mirrors how a real Supabase repo is laid out.
325
+ let stage;
326
+ try {
327
+ stage = stageRumenFunctions(rumenVersion);
328
+ } catch (err) {
329
+ fail(err.message);
330
+ return false;
331
+ }
313
332
  if (!stage) {
314
- fail('could not stage rumen-tick function source');
333
+ fail('could not stage Rumen Edge Function source');
315
334
  return false;
316
335
  }
317
-
318
- const r = runShell('supabase', ['functions', 'deploy', 'rumen-tick', '--no-verify-jwt'], {
319
- cwd: stage
320
- });
321
- if (!r.ok) { fail(`deploy failed (exit ${r.code})`); return false; }
322
336
  ok();
337
+
338
+ for (const name of fnNames) {
339
+ step(`Running: supabase functions deploy ${name} --no-verify-jwt...`);
340
+ const r = runShell('supabase', ['functions', 'deploy', name, '--no-verify-jwt'], {
341
+ cwd: stage
342
+ });
343
+ if (!r.ok) {
344
+ fail(`deploy of ${name} failed (exit ${r.code})`);
345
+ return false;
346
+ }
347
+ ok();
348
+ }
323
349
  return true;
324
350
  }
325
351
 
326
- // Create a staging directory containing:
327
- // <stage>/supabase/functions/rumen-tick/{index.ts, tsconfig.json}
328
- // Also write a minimal `supabase/config.toml` so `supabase functions deploy`
329
- // doesn't complain about a missing project root.
330
- function stageRumenFunction(rumenVersion) {
352
+ // Create a staging directory containing every bundled Rumen Edge Function:
353
+ // <stage>/supabase/functions/<name>/{index.ts, tsconfig.json}
354
+ // <stage>/supabase/config.toml (one [functions.<name>] block per function)
355
+ //
356
+ // `__RUMEN_VERSION__` is substituted only in functions listed in
357
+ // FUNCTIONS_WITH_VERSION_PLACEHOLDER (currently just rumen-tick). Other
358
+ // files are copied verbatim. Returns the staging dir path or null if no
359
+ // function source could be located.
360
+ function stageRumenFunctions(rumenVersion) {
331
361
  if (!rumenVersion || !/^\d+\.\d+\.\d+/.test(rumenVersion)) {
332
- throw new Error(`stageRumenFunction: invalid rumenVersion ${JSON.stringify(rumenVersion)}`);
362
+ throw new Error(`stageRumenFunctions: invalid rumenVersion ${JSON.stringify(rumenVersion)}`);
333
363
  }
364
+ const root = migrations.rumenFunctionsRoot();
365
+ const fnNames = migrations.listRumenFunctions();
366
+ if (fnNames.length === 0) return null;
367
+
334
368
  const stage = fs.mkdtempSync(path.join(os.tmpdir(), 'termdeck-rumen-stage-'));
335
- const functionSrc = migrations.rumenFunctionDir();
336
- if (!fs.existsSync(functionSrc)) return null;
337
-
338
- const dest = path.join(stage, 'supabase', 'functions', 'rumen-tick');
339
- fs.mkdirSync(dest, { recursive: true });
340
- for (const f of fs.readdirSync(functionSrc)) {
341
- const srcPath = path.join(functionSrc, f);
342
- const destPath = path.join(dest, f);
343
- // Substitute the version placeholder in the Deno entry point. Other files
344
- // in the directory (tsconfig.json, etc.) are copied verbatim.
345
- if (f === 'index.ts') {
346
- const raw = fs.readFileSync(srcPath, 'utf-8');
347
- if (!raw.includes('__RUMEN_VERSION__')) {
348
- throw new Error(
349
- `rumen-tick/index.ts is missing the __RUMEN_VERSION__ placeholder — ` +
350
- `has someone reintroduced a hardcoded version?`
351
- );
369
+
370
+ for (const name of fnNames) {
371
+ const fnSrc = path.join(root, name);
372
+ const fnDest = path.join(stage, 'supabase', 'functions', name);
373
+ fs.mkdirSync(fnDest, { recursive: true });
374
+ for (const f of fs.readdirSync(fnSrc)) {
375
+ const srcPath = path.join(fnSrc, f);
376
+ const destPath = path.join(fnDest, f);
377
+ if (f === 'index.ts' && FUNCTIONS_WITH_VERSION_PLACEHOLDER.has(name)) {
378
+ const raw = fs.readFileSync(srcPath, 'utf-8');
379
+ if (!raw.includes('__RUMEN_VERSION__')) {
380
+ throw new Error(
381
+ `${name}/index.ts is missing the __RUMEN_VERSION__ placeholder — ` +
382
+ `has someone reintroduced a hardcoded version? ` +
383
+ `Re-run scripts/sync-rumen-functions.sh to repopulate the placeholder.`
384
+ );
385
+ }
386
+ fs.writeFileSync(destPath, raw.replace(/__RUMEN_VERSION__/g, rumenVersion));
387
+ } else {
388
+ fs.copyFileSync(srcPath, destPath);
352
389
  }
353
- fs.writeFileSync(destPath, raw.replace(/__RUMEN_VERSION__/g, rumenVersion));
354
- } else {
355
- fs.copyFileSync(srcPath, destPath);
356
390
  }
357
391
  }
358
392
 
393
+ const fnBlocks = fnNames.map((name) => `[functions.${name}]\nverify_jwt = false\n`).join('\n');
359
394
  const configToml = `# staged by termdeck init --rumen
360
395
  project_id = "termdeck-rumen-stage"
361
396
 
362
- [functions.rumen-tick]
363
- verify_jwt = false
364
- `;
397
+ ${fnBlocks}`;
365
398
  fs.writeFileSync(path.join(stage, 'supabase', 'config.toml'), configToml);
366
399
 
367
400
  return stage;
@@ -569,7 +602,8 @@ function wireAccessTokenInMcpJson({ token, mcpJsonPath, _testFs } = {}) {
569
602
  }
570
603
 
571
604
  function printNextSteps(projectRef) {
572
- const functionUrl = `https://${projectRef}.supabase.co/functions/v1/rumen-tick`;
605
+ const rumenTickUrl = `https://${projectRef}.supabase.co/functions/v1/rumen-tick`;
606
+ const graphInferenceUrl = `https://${projectRef}.supabase.co/functions/v1/graph-inference`;
573
607
  const now = new Date();
574
608
  // Round up to the next 15-minute mark so the hint is accurate.
575
609
  const next = new Date(now.getTime());
@@ -577,16 +611,20 @@ function printNextSteps(projectRef) {
577
611
  process.stdout.write(`
578
612
  Rumen is deployed.
579
613
 
580
- Schedule: every 15 minutes via pg_cron
581
- First scheduled run: ${next.toISOString().replace(/\.\d+Z$/, 'Z')}
582
- Edge Function URL: ${functionUrl}
614
+ Edge Functions:
615
+ rumen-tick every 15 min — first run: ${next.toISOString().replace(/\.\d+Z$/, 'Z')}
616
+ ${rumenTickUrl}
617
+ graph-inference daily at 03:00 UTC (Sprint 42 cron)
618
+ ${graphInferenceUrl}
583
619
 
584
620
  Next steps:
585
- 1. Monitor: psql "$DATABASE_URL" -c "SELECT * FROM rumen_jobs ORDER BY started_at DESC LIMIT 5"
586
- 2. Store the service_role key in Supabase Vault as \`rumen_service_role_key\`
587
- so the cron call in migrations/002_pg_cron_schedule.sql can authenticate.
621
+ 1. Monitor rumen jobs: psql "$DATABASE_URL" -c "SELECT * FROM rumen_jobs ORDER BY started_at DESC LIMIT 5"
622
+ 2. Store service_role keys in Supabase Vault required for both cron schedules:
623
+ rumen_service_role_key (used by 002_pg_cron_schedule.sql)
624
+ graph_inference_service_role_key (used by 003_graph_inference_schedule.sql)
588
625
  3. Rumen insights flow back into Mnestra's memory_items via rumen_insights.
589
- 4. TermDeck's Flashback will surface cross-project patterns automatically.
626
+ 4. graph-inference fills memory_relationships edges nightly (cosine similarity ≥ 0.85).
627
+ 5. TermDeck's Flashback will surface cross-project patterns automatically.
590
628
  `);
591
629
  }
592
630
 
@@ -664,7 +702,7 @@ async function main(argv) {
664
702
  process.stderr.write(` ! falling back to pinned FALLBACK_RUMEN_VERSION=${FALLBACK_RUMEN_VERSION}\n`);
665
703
  }
666
704
 
667
- if (!deployFunction(resolved.version, flags.dryRun)) return 6;
705
+ if (!deployFunctions(resolved.version, flags.dryRun)) return 6;
668
706
  if (!setFunctionSecrets(secrets, flags.dryRun)) return 7;
669
707
  if (!(await testFunction(projectRef, secrets, flags.dryRun))) return 8;
670
708
  if (!flags.skipSchedule) {
@@ -700,3 +738,6 @@ module.exports = main;
700
738
  // pin the access-token detection without spawning a real `supabase` binary.
701
739
  module.exports._looksLikeMissingAccessToken = looksLikeMissingAccessToken;
702
740
  module.exports._wireAccessTokenInMcpJson = wireAccessTokenInMcpJson;
741
+ // Sprint 43 T3: stage helper exposed so init-rumen-deploy.test.js can pin
742
+ // the multi-function staging contract without shelling out to `supabase`.
743
+ module.exports._stageRumenFunctions = stageRumenFunctions;
@@ -303,7 +303,7 @@
303
303
  updatePanelMeta(id, msg.session.meta);
304
304
  break;
305
305
  case 'proactive_memory':
306
- showProactiveToast(id, msg.hit);
306
+ showProactiveToast(id, msg.hit, msg.flashback_event_id);
307
307
  break;
308
308
  case 'exit':
309
309
  updatePanelMeta(id, {
@@ -583,7 +583,7 @@
583
583
  }
584
584
  }
585
585
 
586
- function showProactiveToast(id, hit) {
586
+ function showProactiveToast(id, hit, flashbackEventId) {
587
587
  const entry = state.sessions.get(id);
588
588
  if (!entry || !entry.el) return;
589
589
 
@@ -606,16 +606,29 @@
606
606
 
607
607
  entry.el.appendChild(toast);
608
608
 
609
+ // Sprint 43 T2: track dismiss/click-through against flashback_events.
610
+ // The id is set server-side in the proactive_memory WS frame; if it's
611
+ // missing (server-side INSERT failed, or older server) the POSTs are
612
+ // skipped and the live toast still works — persistence is best-effort.
609
613
  const dismiss = () => {
610
614
  toast.remove();
611
615
  clearTimeout(toast._autoTimer);
616
+ if (flashbackEventId) {
617
+ fetch(`${API}/api/flashback/${flashbackEventId}/dismissed`, { method: 'POST' })
618
+ .catch((err) => console.warn('[flashback] dismiss POST failed:', err.message));
619
+ }
612
620
  };
613
621
  toast.querySelector('.t-dismiss').addEventListener('click', (e) => {
614
622
  e.stopPropagation();
615
623
  dismiss();
616
624
  });
617
625
  toast.addEventListener('click', () => {
618
- dismiss();
626
+ toast.remove();
627
+ clearTimeout(toast._autoTimer);
628
+ if (flashbackEventId) {
629
+ fetch(`${API}/api/flashback/${flashbackEventId}/clicked`, { method: 'POST' })
630
+ .catch((err) => console.warn('[flashback] clicked POST failed:', err.message));
631
+ }
619
632
  showFlashbackModal(hit, id);
620
633
  });
621
634
 
@@ -1334,7 +1347,7 @@
1334
1347
  updatePanelMeta(id, msg.session.meta);
1335
1348
  break;
1336
1349
  case 'proactive_memory':
1337
- showProactiveToast(id, msg.hit);
1350
+ showProactiveToast(id, msg.hit, msg.flashback_event_id);
1338
1351
  break;
1339
1352
  case 'exit':
1340
1353
  updatePanelMeta(id, { status: 'exited', statusDetail: `Exited (${msg.exitCode})` });
@@ -0,0 +1,331 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>TermDeck · Flashback History</title>
7
+ <link rel="stylesheet" href="style.css">
8
+ <style>
9
+ /* Sprint 43 T2 — flashback-history page styling. Self-contained so the
10
+ dashboard ships independently of style.css; uses the existing --tg-*
11
+ design tokens for theme consistency. */
12
+ body.fb-page {
13
+ margin: 0;
14
+ background: var(--tg-bg);
15
+ color: var(--tg-text);
16
+ font-family: var(--tg-sans);
17
+ min-height: 100vh;
18
+ }
19
+ .fb-topbar {
20
+ display: flex;
21
+ align-items: center;
22
+ gap: 16px;
23
+ padding: 8px 16px;
24
+ background: var(--tg-surface);
25
+ border-bottom: 1px solid var(--tg-border);
26
+ min-height: 44px;
27
+ }
28
+ .fb-tb-back {
29
+ display: inline-flex;
30
+ align-items: center;
31
+ gap: 6px;
32
+ color: var(--tg-text-bright);
33
+ text-decoration: none;
34
+ font-weight: 600;
35
+ padding: 4px 8px;
36
+ border-radius: var(--tg-radius-sm);
37
+ }
38
+ .fb-tb-back:hover { background: var(--tg-surface-hover); }
39
+ .fb-tb-divider { color: var(--tg-text-dim); }
40
+ .fb-tb-title { color: var(--tg-text); font-weight: 500; }
41
+ .fb-tb-spacer { flex: 1; }
42
+ .fb-tb-controls {
43
+ display: inline-flex;
44
+ align-items: center;
45
+ gap: 6px;
46
+ }
47
+ .fb-tb-controls label {
48
+ font-size: 12px;
49
+ color: var(--tg-text-dim);
50
+ }
51
+ .fb-tb-controls select {
52
+ background: var(--tg-bg);
53
+ color: var(--tg-text);
54
+ border: 1px solid var(--tg-border);
55
+ border-radius: var(--tg-radius-sm);
56
+ padding: 5px 8px;
57
+ font-family: var(--tg-mono);
58
+ font-size: 12px;
59
+ }
60
+ .fb-tb-controls select:focus {
61
+ outline: none;
62
+ border-color: var(--tg-accent);
63
+ }
64
+ .fb-tb-btn {
65
+ background: var(--tg-bg);
66
+ color: var(--tg-text);
67
+ border: 1px solid var(--tg-border);
68
+ border-radius: var(--tg-radius-sm);
69
+ padding: 5px 10px;
70
+ font-size: 12px;
71
+ font-family: var(--tg-mono);
72
+ cursor: pointer;
73
+ }
74
+ .fb-tb-btn:hover {
75
+ background: var(--tg-surface-hover);
76
+ border-color: var(--tg-border-active);
77
+ }
78
+
79
+ .fb-stage {
80
+ max-width: 1200px;
81
+ margin: 24px auto;
82
+ padding: 0 16px;
83
+ }
84
+
85
+ /* Funnel summary card */
86
+ .fb-funnel {
87
+ background: var(--tg-surface);
88
+ border: 1px solid var(--tg-border);
89
+ border-radius: var(--tg-radius);
90
+ padding: 18px 20px;
91
+ margin-bottom: 20px;
92
+ }
93
+ .fb-funnel-title {
94
+ font-size: 12px;
95
+ color: var(--tg-text-dim);
96
+ text-transform: uppercase;
97
+ letter-spacing: 0.06em;
98
+ margin: 0 0 12px 0;
99
+ font-weight: 600;
100
+ }
101
+ .fb-funnel-bars {
102
+ display: grid;
103
+ grid-template-columns: 1fr;
104
+ gap: 10px;
105
+ }
106
+ .fb-funnel-row {
107
+ display: grid;
108
+ grid-template-columns: 140px 1fr 80px;
109
+ align-items: center;
110
+ gap: 12px;
111
+ }
112
+ .fb-funnel-label {
113
+ font-size: 13px;
114
+ color: var(--tg-text);
115
+ }
116
+ .fb-funnel-bar {
117
+ position: relative;
118
+ height: 14px;
119
+ background: var(--tg-bg);
120
+ border: 1px solid var(--tg-border);
121
+ border-radius: 7px;
122
+ overflow: hidden;
123
+ }
124
+ .fb-funnel-bar-fill {
125
+ position: absolute;
126
+ inset: 0;
127
+ width: 0%;
128
+ transition: width 220ms ease-out;
129
+ }
130
+ .fb-funnel-row[data-tier="fires"] .fb-funnel-bar-fill { background: var(--tg-purple); }
131
+ .fb-funnel-row[data-tier="dismissed"] .fb-funnel-bar-fill { background: var(--tg-amber); }
132
+ .fb-funnel-row[data-tier="clicked"] .fb-funnel-bar-fill { background: var(--tg-green); }
133
+ .fb-funnel-count {
134
+ font-family: var(--tg-mono);
135
+ font-size: 12px;
136
+ color: var(--tg-text-bright);
137
+ text-align: right;
138
+ }
139
+ .fb-funnel-pct {
140
+ font-size: 10px;
141
+ color: var(--tg-text-dim);
142
+ margin-left: 4px;
143
+ }
144
+
145
+ /* Zero-state */
146
+ .fb-zero {
147
+ background: var(--tg-surface);
148
+ border: 1px dashed var(--tg-border-active);
149
+ border-radius: var(--tg-radius);
150
+ padding: 28px 24px;
151
+ text-align: center;
152
+ color: var(--tg-text-dim);
153
+ }
154
+ .fb-zero h3 {
155
+ margin: 0 0 8px 0;
156
+ color: var(--tg-text-bright);
157
+ font-size: 15px;
158
+ }
159
+ .fb-zero p {
160
+ margin: 6px 0;
161
+ max-width: 620px;
162
+ margin-left: auto;
163
+ margin-right: auto;
164
+ line-height: 1.5;
165
+ }
166
+ .fb-zero code {
167
+ font-family: var(--tg-mono);
168
+ background: var(--tg-bg);
169
+ padding: 1px 6px;
170
+ border-radius: 3px;
171
+ color: var(--tg-cyan);
172
+ font-size: 12px;
173
+ }
174
+
175
+ /* Table */
176
+ .fb-table-wrap {
177
+ background: var(--tg-surface);
178
+ border: 1px solid var(--tg-border);
179
+ border-radius: var(--tg-radius);
180
+ overflow: hidden;
181
+ }
182
+ .fb-table {
183
+ width: 100%;
184
+ border-collapse: collapse;
185
+ font-size: 12px;
186
+ }
187
+ .fb-table thead {
188
+ background: var(--tg-bg);
189
+ border-bottom: 1px solid var(--tg-border);
190
+ }
191
+ .fb-table th {
192
+ text-align: left;
193
+ padding: 10px 12px;
194
+ font-size: 11px;
195
+ font-weight: 600;
196
+ color: var(--tg-text-dim);
197
+ text-transform: uppercase;
198
+ letter-spacing: 0.05em;
199
+ }
200
+ .fb-table td {
201
+ padding: 10px 12px;
202
+ border-top: 1px solid var(--tg-border);
203
+ vertical-align: top;
204
+ color: var(--tg-text);
205
+ }
206
+ .fb-table tbody tr:hover {
207
+ background: var(--tg-surface-hover);
208
+ }
209
+ .fb-cell-time {
210
+ font-family: var(--tg-mono);
211
+ font-size: 11px;
212
+ color: var(--tg-text-dim);
213
+ white-space: nowrap;
214
+ }
215
+ .fb-cell-project {
216
+ font-family: var(--tg-mono);
217
+ font-size: 11px;
218
+ color: var(--tg-cyan);
219
+ white-space: nowrap;
220
+ }
221
+ .fb-cell-error {
222
+ font-family: var(--tg-mono);
223
+ font-size: 11px;
224
+ color: var(--tg-text);
225
+ max-width: 480px;
226
+ overflow: hidden;
227
+ text-overflow: ellipsis;
228
+ white-space: nowrap;
229
+ }
230
+ .fb-cell-hits {
231
+ font-family: var(--tg-mono);
232
+ color: var(--tg-text-bright);
233
+ text-align: right;
234
+ }
235
+ .fb-cell-score {
236
+ font-family: var(--tg-mono);
237
+ color: var(--tg-text-dim);
238
+ text-align: right;
239
+ font-size: 11px;
240
+ }
241
+ .fb-cell-status {
242
+ white-space: nowrap;
243
+ }
244
+ .fb-pill {
245
+ display: inline-block;
246
+ padding: 2px 7px;
247
+ font-size: 10px;
248
+ font-family: var(--tg-mono);
249
+ border-radius: 999px;
250
+ border: 1px solid var(--tg-border);
251
+ background: var(--tg-bg);
252
+ color: var(--tg-text-dim);
253
+ margin-right: 4px;
254
+ }
255
+ .fb-pill-clicked { color: var(--tg-green); border-color: var(--tg-green); }
256
+ .fb-pill-dismissed { color: var(--tg-amber); border-color: var(--tg-amber); }
257
+ .fb-pill-pending { color: var(--tg-purple); border-color: var(--tg-purple); }
258
+
259
+ .fb-loading {
260
+ text-align: center;
261
+ padding: 40px;
262
+ color: var(--tg-text-dim);
263
+ font-size: 13px;
264
+ }
265
+ .fb-error-banner {
266
+ background: rgba(247, 118, 142, 0.08);
267
+ border: 1px solid var(--tg-red);
268
+ color: var(--tg-red);
269
+ padding: 10px 14px;
270
+ border-radius: var(--tg-radius-sm);
271
+ margin-bottom: 16px;
272
+ font-size: 13px;
273
+ }
274
+ </style>
275
+ </head>
276
+ <body class="fb-page">
277
+
278
+ <header class="fb-topbar">
279
+ <a class="fb-tb-back" href="/" title="Back to dashboard" aria-label="Back to dashboard">
280
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
281
+ <path d="M10 12L6 8l4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
282
+ </svg>
283
+ TermDeck
284
+ </a>
285
+ <span class="fb-tb-divider">/</span>
286
+ <span class="fb-tb-title">Flashback History</span>
287
+ <span class="fb-tb-spacer"></span>
288
+ <div class="fb-tb-controls">
289
+ <label for="fbWindow">Window</label>
290
+ <select id="fbWindow">
291
+ <option value="1d">last 24 h</option>
292
+ <option value="7d" selected>last 7 days</option>
293
+ <option value="30d">last 30 days</option>
294
+ <option value="all">all time</option>
295
+ </select>
296
+ <button type="button" class="fb-tb-btn" id="fbRefresh" title="Reload data">refresh</button>
297
+ </div>
298
+ </header>
299
+
300
+ <main class="fb-stage">
301
+ <div id="fbErrorBanner" class="fb-error-banner" hidden></div>
302
+
303
+ <section class="fb-funnel" id="fbFunnel" aria-label="Click-through funnel">
304
+ <h3 class="fb-funnel-title">Click-through funnel</h3>
305
+ <div class="fb-funnel-bars">
306
+ <div class="fb-funnel-row" data-tier="fires">
307
+ <span class="fb-funnel-label">Fires</span>
308
+ <div class="fb-funnel-bar"><div class="fb-funnel-bar-fill" id="fbBarFires"></div></div>
309
+ <span class="fb-funnel-count"><span id="fbCountFires">—</span></span>
310
+ </div>
311
+ <div class="fb-funnel-row" data-tier="dismissed">
312
+ <span class="fb-funnel-label">Dismissed</span>
313
+ <div class="fb-funnel-bar"><div class="fb-funnel-bar-fill" id="fbBarDismissed"></div></div>
314
+ <span class="fb-funnel-count"><span id="fbCountDismissed">—</span><span class="fb-funnel-pct" id="fbPctDismissed"></span></span>
315
+ </div>
316
+ <div class="fb-funnel-row" data-tier="clicked">
317
+ <span class="fb-funnel-label">Clicked through</span>
318
+ <div class="fb-funnel-bar"><div class="fb-funnel-bar-fill" id="fbBarClicked"></div></div>
319
+ <span class="fb-funnel-count"><span id="fbCountClicked">—</span><span class="fb-funnel-pct" id="fbPctClicked"></span></span>
320
+ </div>
321
+ </div>
322
+ </section>
323
+
324
+ <div id="fbContent">
325
+ <div class="fb-loading">Loading flashback history…</div>
326
+ </div>
327
+ </main>
328
+
329
+ <script src="flashback-history.js"></script>
330
+ </body>
331
+ </html>