@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 +3 -2
- package/packages/cli/src/init-rumen.js +95 -54
- package/packages/client/public/app.js +17 -4
- package/packages/client/public/flashback-history.html +331 -0
- package/packages/client/public/flashback-history.js +258 -0
- package/packages/client/public/graph-controls.js +217 -0
- package/packages/client/public/graph.html +36 -0
- package/packages/client/public/graph.js +131 -15
- package/packages/client/public/index.html +1 -0
- package/packages/client/public/style.css +55 -0
- package/packages/server/src/database.js +49 -1
- package/packages/server/src/flashback-diag.js +187 -13
- package/packages/server/src/index.js +58 -2
- package/packages/server/src/setup/migrations.js +44 -4
- package/packages/server/src/setup/rumen/functions/graph-inference/index.ts +381 -0
- package/packages/server/src/setup/rumen/functions/graph-inference/tsconfig.json +14 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jhizzard/termdeck",
|
|
3
|
-
"version": "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
|
|
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
|
|
24
|
-
// 8. Apply pg_cron schedule
|
|
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
|
|
305
|
-
|
|
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
|
-
//
|
|
309
|
-
// `supabase
|
|
310
|
-
//
|
|
311
|
-
|
|
312
|
-
|
|
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
|
|
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
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
|
|
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(`
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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:
|
|
586
|
-
2. Store
|
|
587
|
-
|
|
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.
|
|
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 (!
|
|
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
|
-
|
|
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>
|