pi-mega-compact 0.7.9 โ†’ 0.8.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 (79) hide show
  1. package/dist/extensions/dashboard-server/html.js +333 -66
  2. package/dist/extensions/dashboard-server/html.test.js +41 -0
  3. package/dist/extensions/dashboard-server/server.js +161 -1
  4. package/dist/extensions/dashboard-server/server.test.js +120 -0
  5. package/dist/extensions/dashboard-server-s32.test.js +181 -0
  6. package/dist/extensions/mega-compact.js +3 -0
  7. package/dist/extensions/mega-events/agent-handlers.js +49 -0
  8. package/dist/extensions/mega-events/compact-handlers.js +29 -0
  9. package/dist/extensions/mega-game-cmds.js +106 -0
  10. package/dist/extensions/mega-game-cmds.test.js +113 -0
  11. package/dist/extensions/mega-runtime/state.js +167 -1
  12. package/dist/extensions/mega-runtime/state.test.js +171 -0
  13. package/dist/extensions/mega-runtime/widget.js +93 -20
  14. package/dist/extensions/mega-runtime/widget.test.js +160 -0
  15. package/dist/src/config/themes.js +84 -0
  16. package/dist/src/config/themes.test.js +94 -0
  17. package/dist/src/game/scoring.js +105 -0
  18. package/dist/src/game/scoring.test.js +98 -0
  19. package/dist/src/store/sqlite/game-achievements.js +111 -0
  20. package/dist/src/store/sqlite/game-achievements.test.js +67 -0
  21. package/dist/src/store/sqlite/game-scores.js +105 -0
  22. package/dist/src/store/sqlite/game-scores.test.js +106 -0
  23. package/dist/src/store/sqlite/game-state.js +54 -0
  24. package/dist/src/store/sqlite/game-state.test.js +76 -0
  25. package/dist/src/store/sqlite/schema.js +44 -0
  26. package/dist/src/store/sqlite.js +3 -0
  27. package/extensions/dashboard-server/html.test.ts +50 -0
  28. package/extensions/dashboard-server/html.ts +334 -66
  29. package/extensions/dashboard-server/server.test.ts +131 -0
  30. package/extensions/dashboard-server/server.ts +148 -1
  31. package/extensions/dashboard-server-s32.test.ts +195 -0
  32. package/extensions/mega-compact.ts +3 -0
  33. package/extensions/mega-events/agent-handlers.ts +48 -0
  34. package/extensions/mega-events/compact-handlers.ts +28 -0
  35. package/extensions/mega-game-cmds.test.ts +137 -0
  36. package/extensions/mega-game-cmds.ts +122 -0
  37. package/extensions/mega-runtime/state.test.ts +171 -0
  38. package/extensions/mega-runtime/state.ts +170 -0
  39. package/extensions/mega-runtime/widget.test.ts +185 -0
  40. package/extensions/mega-runtime/widget.ts +119 -18
  41. package/package.json +1 -1
  42. package/src/config/themes.test.ts +116 -0
  43. package/src/config/themes.ts +124 -0
  44. package/src/game/scoring.test.ts +103 -0
  45. package/src/game/scoring.ts +158 -0
  46. package/src/store/sqlite/game-achievements.test.ts +80 -0
  47. package/src/store/sqlite/game-achievements.ts +147 -0
  48. package/src/store/sqlite/game-scores.test.ts +132 -0
  49. package/src/store/sqlite/game-scores.ts +168 -0
  50. package/src/store/sqlite/game-state.test.ts +89 -0
  51. package/src/store/sqlite/game-state.ts +87 -0
  52. package/src/store/sqlite/schema.ts +47 -0
  53. package/src/store/sqlite.ts +3 -0
  54. package/dist/extensions/dashboard-server/helpers.js +0 -37
  55. package/dist/extensions/dashboard-server/html/all-repos-tab.js +0 -26
  56. package/dist/extensions/dashboard-server/html/body-open.js +0 -23
  57. package/dist/extensions/dashboard-server/html/current-repo-tab.js +0 -130
  58. package/dist/extensions/dashboard-server/html/head-open.js +0 -16
  59. package/dist/extensions/dashboard-server/html/high-score-tab.js +0 -25
  60. package/dist/extensions/dashboard-server/html/repo-detail-modal.js +0 -26
  61. package/dist/extensions/dashboard-server/html/script.js +0 -259
  62. package/dist/extensions/dashboard-server/html/styles.js +0 -103
  63. package/dist/extensions/dashboard-server/html/summary-tab.js +0 -19
  64. package/dist/extensions/dashboard-server/html-template.js +0 -41
  65. package/dist/src/store/sqlite/connection.js +0 -35
  66. package/dist/src/store/sqlite/index-store.js +0 -167
  67. package/dist/src/store/sqlite/memory.js +0 -54
  68. package/dist/src/store/sqlite/minhash-lsh.js +0 -47
  69. package/dist/src/store/sqlite/sessions.js +0 -39
  70. package/dist/src/store/sqlite/transaction.js +0 -19
  71. package/dist/src/vectorStore/add.js +0 -260
  72. package/dist/src/vectorStore/dedup.js +0 -52
  73. package/dist/src/vectorStore/index.js +0 -10
  74. package/dist/src/vectorStore/queries.js +0 -83
  75. package/dist/src/vectorStore/search.js +0 -95
  76. package/dist/src/vectorStore/session.js +0 -19
  77. package/dist/src/vectorStore/store.js +0 -105
  78. package/dist/src/vectorStore/types.js +0 -6
  79. package/dist/src/vectorStore/utils.js +0 -23
@@ -1,3 +1,4 @@
1
+ import { ACHIEVEMENT_DEFS } from "../../game/scoring.js";
1
2
  const SCHEMA_VERSION = 2;
2
3
  /**
3
4
  * Add `column` (with `decl`, e.g. "INTEGER") to `table` if it does not already
@@ -225,6 +226,41 @@ export function initSchema(db) {
225
226
  first_seen_seq INTEGER NOT NULL,
226
227
  created_at INTEGER NOT NULL
227
228
  );
229
+
230
+ -- S30 game mode: global toggle state (single row, id=1). Holds the
231
+ -- game-mode on/off switch, the active visual-effect theme id, and the TUI
232
+ -- widget display mode. Global across all repos; written by /mega-game and
233
+ -- the dashboard settings strip (S32). Local SQLite (PREVENT-PI-004).
234
+ CREATE TABLE IF NOT EXISTS game_state (
235
+ id INTEGER PRIMARY KEY CHECK(id = 1),
236
+ game_mode_on INTEGER NOT NULL DEFAULT 0,
237
+ theme TEXT NOT NULL DEFAULT 'transparent',
238
+ tui_display_mode TEXT NOT NULL DEFAULT 'full'
239
+ CHECK(tui_display_mode IN ('full','minimal'))
240
+ );
241
+ -- S33 game mode: per-repo leaderboard metrics. One row per recorded event
242
+ -- (turn_end / session_compact); leaderboard() derives rankings. 'repos' is
243
+ -- derived (COUNT DISTINCT, never recorded). Local SQLite (PREVENT-PI-004).
244
+ CREATE TABLE IF NOT EXISTS game_scores (
245
+ repo_root TEXT NOT NULL,
246
+ metric TEXT NOT NULL CHECK(metric IN ('cache','dedupe','turns','repos','mega_cache')),
247
+ ts INTEGER NOT NULL,
248
+ value REAL NOT NULL,
249
+ meta TEXT,
250
+ PRIMARY KEY(repo_root, metric, ts)
251
+ ) WITHOUT ROWID;
252
+ CREATE INDEX IF NOT EXISTS idx_game_scores_metric_ts ON game_scores(metric, ts);
253
+
254
+ -- S35 game mode: achievements (9 rows, seeded idempotently on first open).
255
+ -- hidden=1 AND unlocked_at IS NULL => render NOTHING (no teaser). Local SQLite.
256
+ CREATE TABLE IF NOT EXISTS game_achievements (
257
+ id TEXT PRIMARY KEY,
258
+ title TEXT NOT NULL,
259
+ description TEXT NOT NULL,
260
+ hidden INTEGER NOT NULL DEFAULT 0 CHECK(hidden IN (0,1)),
261
+ icon TEXT,
262
+ unlocked_at INTEGER NULL
263
+ ) WITHOUT ROWID;
228
264
  `);
229
265
  // Idempotent column migrations. `CREATE TABLE IF NOT EXISTS` is a no-op on a
230
266
  // pre-existing table, so new columns added to context_chunks after a store was
@@ -243,6 +279,14 @@ export function initSchema(db) {
243
279
  // S25: RAPTOR freshness-guard timestamp. Additive; old DBs have NULL โ†’ 0 โ†’
244
280
  // treated as stale โ†’ flat fallback (safe).
245
281
  ensureColumn(db, "raptor_nodes", "built_at", "INTEGER");
282
+ // S35: idempotent seed of the 9 achievement rows. ON CONFLICT(id) DO
283
+ // NOTHING so a re-open never clobbers an already-unlocked row's
284
+ // unlocked_at. No user input reaches this SQL (PREVENT-002 safe).
285
+ const seedAch = db.prepare(`INSERT INTO game_achievements (id, title, description, hidden, icon)
286
+ VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO NOTHING`);
287
+ for (const d of ACHIEVEMENT_DEFS) {
288
+ seedAch.run(d.id, d.title, d.description, d.hidden ? 1 : 0, d.icon);
289
+ }
246
290
  const v = db.prepare("SELECT value FROM meta WHERE key='schema_version'").get();
247
291
  if (!v) {
248
292
  db.prepare("INSERT INTO meta(key, value) VALUES(?, ?)").run("schema_version", String(SCHEMA_VERSION));
@@ -20,3 +20,6 @@ export * from "./sqlite/raptor.js";
20
20
  export * from "./sqlite/raw-transcript.js";
21
21
  export * from "./sqlite/dedup-mirror.js";
22
22
  export * from "./sqlite/maintenance.js";
23
+ export * from "./sqlite/game-state.js";
24
+ export * from "./sqlite/game-scores.js";
25
+ export * from "./sqlite/game-achievements.js";
@@ -0,0 +1,50 @@
1
+ /**
2
+ * html.test.ts โ€” S34 Game Mode tab presence in the dashboard HTML template.
3
+ */
4
+ import { test, describe } from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import { dashboardHtml } from "./html.js";
7
+
8
+ describe("S34 dashboard HTML Game Mode", () => {
9
+ const html = dashboardHtml("custom");
10
+ test("Game Mode tab button + panel-game exist", () => {
11
+ assert.ok(
12
+ html.includes('<button class="tab" data-tab="game">Game Mode</button>'),
13
+ "game tab button present",
14
+ );
15
+ assert.ok(html.includes('<div class="tab-panel" id="panel-game">'), "panel-game present");
16
+ });
17
+ test("level-up-pulse + mega-flash keyframes present", () => {
18
+ assert.ok(html.includes("@keyframes level-up-pulse"), "level-up-pulse keyframe present");
19
+ assert.ok(html.includes("@keyframes mega-flash"), "mega-flash keyframe present");
20
+ });
21
+ test("empty-state string present", () => {
22
+ assert.ok(
23
+ html.includes("No scores yet โ€” run a session with game mode on."),
24
+ "empty-state string present",
25
+ );
26
+ });
27
+ });
28
+
29
+
30
+ describe("S35 dashboard HTML achievements", () => {
31
+ const html = dashboardHtml("custom");
32
+ test("Achievements heading + ach-tiles/ach-toast containers present", () => {
33
+ assert.ok(html.includes(">Achievements</h3>"), "Achievements heading present");
34
+ assert.ok(html.includes('id="ach-tiles"'), "ach-tiles container present");
35
+ assert.ok(html.includes('id="ach-toast"'), "ach-toast element present");
36
+ });
37
+ test("ach-unlock-pulse keyframe + ach-tile classes present", () => {
38
+ assert.ok(html.includes("@keyframes ach-unlock-pulse"), "ach-unlock-pulse keyframe present");
39
+ assert.ok(html.includes(".ach-tile"), "ach-tile class present");
40
+ assert.ok(html.includes(".ach-tile.unlocked"), "unlocked class present");
41
+ assert.ok(html.includes(".ach-tile.locked"), "locked class present");
42
+ });
43
+ test("renderAchievements fn + GET /api/achievements fetch present", () => {
44
+ assert.ok(html.includes("function renderAchievements"), "renderAchievements fn present");
45
+ assert.ok(html.includes("fetch('/api/achievements')"), "achievements fetch present");
46
+ });
47
+ test("visible-but-locked teaser string present", () => {
48
+ assert.ok(html.includes("??? "), "??? teaser present");
49
+ });
50
+ });
@@ -3,100 +3,172 @@
3
3
  */
4
4
 
5
5
  import { dashboardServerVersion } from "./state.js";
6
+ import { THEMES, DEFAULT_THEME, themeDataBlock } from "../../src/config/themes.js";
7
+
8
+ // Server-side injection of every theme's :root[data-theme="<id>"] CSS-var
9
+ // override block so the client can switch themes instantly by setting
10
+ // document.documentElement.dataset.theme. PREVENT-PI-004: pure local, no network.
11
+ const THEME_STYLE_BLOCKS = THEMES.map(themeDataBlock).join("\n");
12
+ const THEME_OPTIONS = THEMES.map((t) => `<option value="${t.id}">${t.label}</option>`).join("");
6
13
 
7
14
  export function dashboardHtml(tierName: string): string {
8
15
  return `<!DOCTYPE html>
9
- <html lang="en">
16
+ <html lang="en" data-theme="${DEFAULT_THEME}">
10
17
  <head>
11
18
  <meta charset="utf-8">
12
19
  <meta name="viewport" content="width=device-width, initial-scale=1">
13
20
  <title>mega-compact dashboard</title>
14
21
  <style>
22
+ /* S32: CSS-variable skin. Base :root holds the CURRENT hardcoded hexes so the
23
+ default (data-theme="transparent") is visually identical to the pre-S32
24
+ dashboard. Each :root[data-theme="<id>"] block (injected below from
25
+ src/config/themes.ts) overrides the 4 theme vars (--bg/--fg/--accent/--mega).
26
+ Non-theme palette tokens (card bg, borders, muted text, meter colors) stay
27
+ fixed so visual parity is preserved under the transparent default. */
28
+ :root {
29
+ --bg: #0d1117; /* page background (transparent theme -> transparent) */
30
+ --fg: #c9d1d9; /* default foreground */
31
+ --accent: #3fb950; /* accent (bars, ok values, on-bullets) */
32
+ --mega: #f0883e; /* MEGA CACHE highlight */
33
+ --fg-strong: #f0f6fc; /* headings, strong values */
34
+ --muted: #8b949e; /* labels, sub-text */
35
+ --dim: #484f58; /* timestamps, empty states */
36
+ --card-bg: #161b22; /* card / events / table background */
37
+ --border: #30363d; /* card / table borders */
38
+ --border-soft: #21262d; /* meter track, ev borders, table row borders */
39
+ --blue: #1f6feb; /* tier pill, active tab */
40
+ --green-bar: #238636; /* green meter fill, safe border */
41
+ --yellow-bar: #d29922; /* yellow meter fill, na-bullet */
42
+ --red-bar: #f85149; /* red meter fill, offline banner */
43
+ --purple: #a371f7; /* recall events, repo model, cost h2 */
44
+ --purple-pill: #6e40c9; /* model pill bg */
45
+ --hover-row: #1c2128; /* table row hover */
46
+ --link: #58a6ff; /* repo-link hover */
47
+ --th-bg: #0d1117; /* table header background */
48
+ }
49
+ /* html backdrop keeps the page dark even when --bg is transparent (transparent
50
+ theme) so there's no white flash โ€” visually identical to the pre-S32 fill. */
51
+ html { background: #0d1117; }
52
+ ${THEME_STYLE_BLOCKS}
15
53
  * { margin: 0; padding: 0; box-sizing: border-box; }
16
- body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #0d1117; color: #c9d1d9; padding: 24px; line-height: 1.5; }
17
- h1 { font-size: 20px; font-weight: 600; margin-bottom: 20px; display: flex; align-items: center; gap: 10px; color: #f0f6fc; }
18
- h1 .tier { background: #1f6feb; color: #fff; font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 10px; text-transform: uppercase; letter-spacing: .5px; }
19
- h1 .version-pill { background: #30363d; color: #8b949e; font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 10px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
54
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: var(--bg); color: var(--fg); padding: 24px; line-height: 1.5; }
55
+ h1 { font-size: 20px; font-weight: 600; margin-bottom: 20px; display: flex; align-items: center; gap: 10px; color: var(--fg-strong); }
56
+ h1 .tier { background: var(--blue); color: #fff; font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 10px; text-transform: uppercase; letter-spacing: .5px; }
57
+ h1 .version-pill { background: var(--border); color: var(--muted); font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 10px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
20
58
  .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 20px; }
21
- .card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; }
22
- .card.safe { border-color: #238636; }
23
- .card.safe h2 { color: #3fb950; }
24
- .safe-note { font-size: 12px; color: #8b949e; margin: 12px 0 0; line-height: 1.5; }
25
- .value.ok { color: #3fb950; }
59
+ .card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 16px; }
60
+ .card.safe { border-color: var(--green-bar); }
61
+ .card.safe h2 { color: var(--accent); }
62
+ .safe-note { font-size: 12px; color: var(--muted); margin: 12px 0 0; line-height: 1.5; }
63
+ .value.ok { color: var(--accent); }
26
64
  .label {
27
65
  cursor: help;
28
- border-bottom: 1px dotted #484f58;
66
+ border-bottom: 1px dotted var(--dim);
29
67
  }
30
68
  .card.legend { grid-column: 1 / -1; }
31
- .legend-list { margin: 0; padding-left: 18px; color: #c9d1d9; }
69
+ .legend-list { margin: 0; padding-left: 18px; color: var(--fg); }
32
70
  .legend-list li { margin-bottom: 8px; line-height: 1.5; }
33
- .legend-list b { color: #f0f6fc; }
34
- .legend-note { font-size: 12px; color: #8b949e; margin: 12px 0 0; font-style: italic; }
35
- .card h2 { font-size: 12px; text-transform: uppercase; letter-spacing: .5px; color: #8b949e; margin-bottom: 12px; font-weight: 600; }
36
- .meter-track { background: #21262d; border-radius: 4px; height: 20px; overflow: hidden; margin: 8px 0; }
71
+ .legend-list b { color: var(--fg-strong); }
72
+ .legend-note { font-size: 12px; color: var(--muted); margin: 12px 0 0; font-style: italic; }
73
+ .card h2 { font-size: 12px; text-transform: uppercase; letter-spacing: .5px; color: var(--muted); margin-bottom: 12px; font-weight: 600; }
74
+ .meter-track { background: var(--border-soft); border-radius: 4px; height: 20px; overflow: hidden; margin: 8px 0; }
37
75
  .meter-fill { height: 100%; border-radius: 4px; transition: width .6s ease; min-width: 2px; }
38
- .meter-green { background: #238636; }
39
- .meter-yellow { background: #d29922; }
40
- .meter-red { background: #f85149; }
41
- .meter-label { font-size: 24px; font-weight: 700; color: #f0f6fc; }
42
- .meter-sub { font-size: 12px; color: #8b949e; }
76
+ .meter-green { background: var(--green-bar); }
77
+ .meter-yellow { background: var(--yellow-bar); }
78
+ .meter-red { background: var(--red-bar); }
79
+ .meter-label { font-size: 24px; font-weight: 700; color: var(--fg-strong); }
80
+ .meter-sub { font-size: 12px; color: var(--muted); }
43
81
  .status-row { display: flex; align-items: center; gap: 8px; margin: 6px 0; font-size: 14px; }
44
82
  .status-row .bullet { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
45
- .bullet-on { background: #3fb950; box-shadow: 0 0 6px #3fb95088; }
46
- .bullet-off { background: #484f58; }
47
- .bullet-na { background: #d29922; }
48
- .state-text { font-size: 13px; color: #8b949e; margin-top: 8px; font-family: monospace; }
83
+ .bullet-on { background: var(--accent); box-shadow: 0 0 6px #3fb95088; }
84
+ .bullet-off { background: var(--dim); }
85
+ .bullet-na { background: var(--yellow-bar); }
86
+ .state-text { font-size: 13px; color: var(--muted); margin-top: 8px; font-family: monospace; }
49
87
  .stat-grid { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; font-size: 14px; }
50
- .stat-grid .label { color: #8b949e; }
51
- .stat-grid .value { color: #f0f6fc; font-weight: 600; font-family: monospace; }
88
+ .stat-grid .label { color: var(--muted); }
89
+ .stat-grid .value { color: var(--fg-strong); font-weight: 600; font-family: monospace; }
52
90
  .conf-grid { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; font-size: 14px; }
53
- .conf-grid .label { color: #8b949e; }
54
- .conf-grid .value { color: #f0f6fc; font-family: monospace; }
55
- .events { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; }
56
- .events h2 { font-size: 12px; text-transform: uppercase; letter-spacing: .5px; color: #8b949e; margin-bottom: 12px; font-weight: 600; }
91
+ .conf-grid .label { color: var(--muted); }
92
+ .conf-grid .value { color: var(--fg-strong); font-family: monospace; }
93
+ .events { background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 16px; }
94
+ .events h2 { font-size: 12px; text-transform: uppercase; letter-spacing: .5px; color: var(--muted); margin-bottom: 12px; font-weight: 600; }
57
95
  .events-wrap { max-height: 240px; overflow-y: auto; font-family: monospace; font-size: 12px; }
58
- .ev { padding: 3px 0; border-bottom: 1px solid #21262d; display: flex; gap: 8px; align-items: baseline; }
96
+ .ev { padding: 3px 0; border-bottom: 1px solid var(--border-soft); display: flex; gap: 8px; align-items: baseline; }
59
97
  .ev:last-child { border-bottom: none; }
60
98
  .ev-type { font-weight: 700; min-width: 70px; text-align: right; }
61
- .ev-type-compact { color: #3fb950; }
62
- .ev-type-recall { color: #a371f7; }
63
- .ev-time { color: #484f58; font-size: 10px; min-width: 80px; }
64
- .ev-detail { color: #8b949e; flex: 1; }
65
- .updated { font-size: 11px; color: #484f58; margin-top: 16px; text-align: right; }
66
- .empty { color: #484f58; font-style: italic; font-size: 13px; padding: 8px 0; }
67
- .offline-banner { background: #f8514922; border: 1px solid #f85149; border-radius: 6px; padding: 10px 16px; margin-bottom: 16px; font-size: 13px; color: #f85149; display: none; }
99
+ .ev-type-compact { color: var(--accent); }
100
+ .ev-type-recall { color: var(--purple); }
101
+ .ev-time { color: var(--dim); font-size: 10px; min-width: 80px; }
102
+ .ev-detail { color: var(--muted); flex: 1; }
103
+ .updated { font-size: 11px; color: var(--dim); margin-top: 16px; text-align: right; }
104
+ .empty { color: var(--dim); font-style: italic; font-size: 13px; padding: 8px 0; }
105
+ .offline-banner { background: #f8514922; border: 1px solid var(--red-bar); border-radius: 6px; padding: 10px 16px; margin-bottom: 16px; font-size: 13px; color: var(--red-bar); display: none; }
68
106
  .tabs { display: flex; gap: 8px; margin-bottom: 20px; }
69
- .tab { background: #161b22; color: #8b949e; border: 1px solid #30363d; border-radius: 6px; padding: 8px 16px; font-size: 13px; font-weight: 600; cursor: pointer; transition: all .15s ease; }
70
- .tab:hover { color: #c9d1d9; border-color: #484f58; }
71
- .tab.active { background: #1f6feb; color: #fff; border-color: #1f6feb; }
107
+ .tab { background: var(--card-bg); color: var(--muted); border: 1px solid var(--border); border-radius: 6px; padding: 8px 16px; font-size: 13px; font-weight: 600; cursor: pointer; transition: all .15s ease; }
108
+ .tab:hover { color: var(--fg); border-color: var(--dim); }
109
+ .tab.active { background: var(--blue); color: #fff; border-color: var(--blue); }
72
110
  .tab-panel { display: none; }
73
111
  .tab-panel.active { display: block; }
112
+ /* S32: header settings strip (game-mode toggle + theme + TUI display-mode) */
113
+ .settings-strip { display: flex; align-items: center; gap: 16px; margin-bottom: 20px; padding: 10px 14px; background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; font-size: 13px; color: var(--muted); flex-wrap: wrap; }
114
+ .settings-strip label { display: flex; align-items: center; gap: 6px; font-weight: 600; }
115
+ .settings-strip input[type="checkbox"] { width: 16px; height: 16px; cursor: pointer; accent-color: var(--accent); }
116
+ .settings-strip select { background: var(--th-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; padding: 4px 8px; font-size: 13px; cursor: pointer; }
117
+ .settings-strip select:hover { border-color: var(--dim); }
74
118
  .summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; margin-bottom: 20px; }
75
- .summary-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; }
76
- .summary-card .num { font-size: 24px; font-weight: 700; color: #f0f6fc; }
77
- .summary-card .lbl { font-size: 12px; color: #8b949e; text-transform: uppercase; letter-spacing: .5px; margin-top: 4px; }
78
- table.repos { width: 100%; border-collapse: collapse; background: #161b22; border: 1px solid #30363d; border-radius: 8px; overflow: hidden; }
79
- table.repos th, table.repos td { text-align: left; padding: 10px 14px; font-size: 13px; border-bottom: 1px solid #21262d; }
80
- table.repos th { color: #8b949e; text-transform: uppercase; letter-spacing: .5px; font-size: 11px; background: #0d1117; }
81
- table.repos td.num { font-family: monospace; color: #f0f6fc; text-align: right; }
119
+ .summary-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 16px; }
120
+ .summary-card .num { font-size: 24px; font-weight: 700; color: var(--fg-strong); }
121
+ .summary-card .lbl { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; margin-top: 4px; }
122
+ table.repos { width: 100%; border-collapse: collapse; background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
123
+ table.repos th, table.repos td { text-align: left; padding: 10px 14px; font-size: 13px; border-bottom: 1px solid var(--border-soft); }
124
+ table.repos th { color: var(--muted); text-transform: uppercase; letter-spacing: .5px; font-size: 11px; background: var(--th-bg); }
125
+ table.repos td.num { font-family: monospace; color: var(--fg-strong); text-align: right; }
82
126
  table.repos tr:last-child td { border-bottom: none; }
83
- table.repos tr:hover td { background: #1c2128; }
84
- .repo-model { color: #a371f7; }
85
- .repo-none { color: #484f58; font-style: italic; }
86
- .updated { font-size: 11px; color: #484f58; margin-top: 16px; text-align: right; }
87
- .model-pill { background: #6e40c9; color: #fff; font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 10px; text-transform: uppercase; letter-spacing: .5px; }
88
- .card.cost h2 { color: #a371f7; }
89
- .cost-usd { font-size: 22px; font-weight: 700; color: #3fb950; }
90
- .cost-sub { font-size: 12px; color: #8b949e; margin-top: 4px; }
127
+ table.repos tr:hover td { background: var(--hover-row); }
128
+ .repo-model { color: var(--purple); }
129
+ .repo-none { color: var(--dim); font-style: italic; }
130
+ .updated { font-size: 11px; color: var(--dim); margin-top: 16px; text-align: right; }
131
+ .model-pill { background: var(--purple-pill); color: #fff; font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 10px; text-transform: uppercase; letter-spacing: .5px; }
132
+ .card.cost h2 { color: var(--purple); }
133
+ .cost-usd { font-size: 22px; font-weight: 700; color: var(--accent); }
134
+ .cost-sub { font-size: 12px; color: var(--muted); margin-top: 4px; }
91
135
  .repo-link { cursor: pointer; }
92
- .repo-link:hover td { color: #58a6ff; }
136
+ .repo-link:hover td { color: var(--link); }
93
137
  .repo-detail { position: fixed; inset: 0; background: rgba(0,0,0,.6); display: none; align-items: center; justify-content: center; z-index: 50; }
94
138
  .repo-detail.open { display: flex; }
95
- .repo-detail-box { background: #161b22; border: 1px solid #30363d; border-radius: 10px; padding: 24px; width: 560px; max-width: 92vw; max-height: 86vh; overflow-y: auto; }
96
- .repo-detail-box h2 { font-size: 14px; color: #f0f6fc; margin-bottom: 14px; display: flex; justify-content: space-between; align-items: center; }
97
- .repo-close { cursor: pointer; color: #8b949e; font-size: 20px; line-height: 1; border: none; background: none; padding: 0 4px; }
98
- .repo-close:hover { color: #f0f6fc; }
99
- .repo-path { font-size: 11px; color: #484f58; word-break: break-all; margin: -8px 0 12px; }
139
+ .repo-detail-box { background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px; padding: 24px; width: 560px; max-width: 92vw; max-height: 86vh; overflow-y: auto; }
140
+ .repo-detail-box h2 { font-size: 14px; color: var(--fg-strong); margin-bottom: 14px; display: flex; justify-content: space-between; align-items: center; }
141
+ .repo-close { cursor: pointer; color: var(--muted); font-size: 20px; line-height: 1; border: none; background: none; padding: 0 4px; }
142
+ .repo-close:hover { color: var(--fg-strong); }
143
+ .repo-path { font-size: 11px; color: var(--dim); word-break: break-all; margin: -8px 0 12px; }
144
+ /* S34: Game Mode tab โ€” leaderboards, MEGA CACHE banner, Opie unlock tile */
145
+ #panel-game .game-leaderboards { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 16px; }
146
+ #panel-game .lb-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 14px; }
147
+ #panel-game .lb-card h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .5px; color: var(--muted); margin-bottom: 10px; font-weight: 600; }
148
+ #panel-game table { width: 100%; border-collapse: collapse; font-size: 13px; }
149
+ #panel-game td { padding: 4px 8px; border-bottom: 1px solid var(--border-soft); }
150
+ #panel-game td.num { text-align: right; font-family: monospace; color: var(--fg-strong); font-weight: 600; }
151
+ #panel-game .lb-meta { color: var(--muted); font-size: 11px; margin-left: 6px; }
152
+ #panel-game .repos-badge { display: inline-block; background: var(--blue); color: #fff; font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 10px; margin-left: 8px; }
153
+ #mega-cache-banner { display: none; background: var(--mega); color: #1a1006; font-weight: 700; padding: 10px 14px; border-radius: 8px; margin: 12px 0; }
154
+ .achievement-tile { display: none; background: linear-gradient(135deg, #f0883e, #ffd700); color: #1a1006; font-weight: 700; padding: 12px 16px; border-radius: 8px; margin: 12px 0; box-shadow: 0 0 16px #f0883e88; }
155
+ .achievement-tile .ach-detail { display: block; font-weight: 500; font-size: 12px; margin-top: 4px; }
156
+ #mega-cache-toast { display: none; position: fixed; top: 16px; left: 50%; transform: translateX(-50%); background: var(--mega); color: #1a1006; font-weight: 700; padding: 10px 18px; border-radius: 8px; z-index: 1000; box-shadow: 0 4px 20px #0008; }
157
+ #mega-cache-toast.show { display: block; animation: mega-flash 0.6s ease-in-out 2; }
158
+ .level-up { animation: level-up-pulse 1.2s ease-in-out; }
159
+ #game-empty { color: var(--dim); font-style: italic; padding: 12px 0; }
160
+ @keyframes level-up-pulse { 0%{transform:scale(1)} 50%{transform:scale(1.08); filter:brightness(1.3)} 100%{transform:scale(1)} }
161
+ @keyframes mega-flash { 0%{background:transparent} 25%{background:var(--mega-bg, gold)} 100%{background:transparent} }
162
+ /* S35: achievements tile row + unlock toast */
163
+ .ach-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 8px; margin: 8px 0 16px; }
164
+ .ach-tile { padding: 10px 12px; border-radius: 8px; border: 1px solid var(--border); font-size: 12px; background: var(--card-bg); }
165
+ .ach-tile.unlocked { background: linear-gradient(135deg, #f0883e, #ffd700); color: #1a1006; font-weight: 700; box-shadow: 0 0 12px #f0883e66; }
166
+ .ach-tile.locked { opacity: .55; }
167
+ .ach-tile.just-unlocked { animation: ach-unlock-pulse 0.6s ease-out; }
168
+ .ach-tile .ach-detail { display: block; font-weight: 500; font-size: 11px; margin-top: 3px; }
169
+ #ach-toast { display: none; position: fixed; top: 16px; left: 50%; transform: translateX(-50%); background: var(--blue); color: #fff; font-weight: 700; padding: 10px 18px; border-radius: 8px; z-index: 1000; box-shadow: 0 4px 20px #0008; }
170
+ #ach-toast.show { display: block; }
171
+ @keyframes ach-unlock-pulse { 0%{transform:scale(.9);opacity:.4} 60%{transform:scale(1.05)} 100%{transform:scale(1);opacity:1} }
100
172
  </style>
101
173
  </head>
102
174
  <body>
@@ -105,11 +177,18 @@ export function dashboardHtml(tierName: string): string {
105
177
 
106
178
  <h1><span>mega-compact</span><span class="tier" id="hdr-tier">${tierName}</span><span class="version-pill">v${dashboardServerVersion}</span><span class="model-pill" id="hdr-model">โ€”</span></h1>
107
179
 
180
+ <div class="settings-strip">
181
+ <label title="Turn game mode on/off (themes the widget + dashboard)"><input type="checkbox" id="set-game-mode"> Game mode</label>
182
+ <label title="Visual theme (applies instantly)">Theme <select id="set-theme">${THEME_OPTIONS}</select></label>
183
+ <label title="TUI widget display density">TUI <select id="set-tui-mode"><option value="full">Full</option><option value="minimal">Minimal</option></select></label>
184
+ </div>
185
+
108
186
  <nav class="tabs">
109
187
  <button class="tab active" data-tab="current">Current repo</button>
110
188
  <button class="tab" data-tab="all">All repos</button>
111
189
  <button class="tab" data-tab="active">Active Repos</button>
112
190
  <button class="tab" data-tab="summary">Summary</button>
191
+ <button class="tab" data-tab="game">Game Mode</button>
113
192
  </nav>
114
193
 
115
194
  <!-- Current repo (existing single-repo view) -->
@@ -237,7 +316,7 @@ export function dashboardHtml(tierName: string): string {
237
316
  <div class="events-wrap" id="events"><div class="empty">connectingโ€ฆ</div></div>
238
317
  </div>
239
318
 
240
- <h2 style="margin-top:24px;font-size:13px;color:#8b949e;text-transform:uppercase;letter-spacing:.5px">All Repositories</h2>
319
+ <h2 style="margin-top:24px;font-size:13px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px">All Repositories</h2>
241
320
  <table class="repos">
242
321
  <thead>
243
322
  <tr>
@@ -319,7 +398,7 @@ export function dashboardHtml(tierName: string): string {
319
398
  <div class="summary-card"><div class="num" id="sm-bytes">0 B</div><div class="lbl">Compressed-Original</div></div>
320
399
  </div>
321
400
 
322
- <h2 style="margin-top:24px;font-size:13px;color:#8b949e;text-transform:uppercase;letter-spacing:.5px">Savings by Model</h2>
401
+ <h2 style="margin-top:24px;font-size:13px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px">Savings by Model</h2>
323
402
  <p class="legend-note" style="margin-bottom:10px">How much context &amp; cost mega-compact has reclaimed, grouped by the model you were running. Compression ratio reflects workload/content, not model quality.</p>
324
403
  <table class="repos">
325
404
  <thead>
@@ -346,6 +425,24 @@ export function dashboardHtml(tierName: string): string {
346
425
  <div class="updated" id="sm-updated"></div>
347
426
  </div>
348
427
 
428
+ <!-- Game Mode (S34) โ€” high-score leaderboards, MEGA CACHE banner, Opie unlock -->
429
+ <div class="tab-panel" id="panel-game">
430
+ <h2 style="font-size:13px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px">High Scores</h2>
431
+ <div id="mega-cache-banner"></div>
432
+ <div id="mega-cache-toast"></div>
433
+ <div class="achievement-tile" id="opie-tile"></div>
434
+ <h3 style="font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin-top:16px">Achievements</h3>
435
+ <div id="ach-toast"></div>
436
+ <div id="ach-tiles" class="ach-tiles">loadingโ€ฆ</div>
437
+ <div class="game-leaderboards">
438
+ <div class="lb-card"><h3>Cache % <span class="repos-badge" id="repos-badge"></span></h3><table><tbody id="lb-cache"><tr><td colspan="2" class="repo-none">loadingโ€ฆ</td></tr></tbody></table></div>
439
+ <div class="lb-card"><h3>Dedupe (collapsed)</h3><table><tbody id="lb-dedupe"><tr><td colspan="2" class="repo-none">loadingโ€ฆ</td></tr></tbody></table></div>
440
+ <div class="lb-card"><h3>Turns <span id="turns-level"></span></h3><table><tbody id="lb-turns"><tr><td colspan="2" class="repo-none">loadingโ€ฆ</td></tr></tbody></table></div>
441
+ <div class="lb-card"><h3>MEGA CACHE trophies</h3><table><tbody id="lb-mega_cache"><tr><td colspan="2" class="repo-none">loadingโ€ฆ</td></tr></tbody></table></div>
442
+ </div>
443
+ <div id="game-empty">No scores yet โ€” run a session with game mode on.</div>
444
+ </div>
445
+
349
446
  <script>
350
447
  (function() {
351
448
  var evBox = document.getElementById('events');
@@ -518,7 +615,11 @@ export function dashboardHtml(tierName: string): string {
518
615
  fetch('/api/snapshot').then(function(r) { return r.json(); }).then(renderSnapshot).catch(function() {}); // guardrails-allow PREVENT-PI-004: browser-side fetch in dashboard HTML template (not Node runtime)
519
616
  }
520
617
  pollSnapshot();
618
+ renderGameScores();
619
+ renderAchievements();
521
620
  setInterval(pollSnapshot, 2000);
621
+ setInterval(renderGameScores, 2000);
622
+ setInterval(renderAchievements, 2000);
522
623
 
523
624
  // SSE for events
524
625
  function connectSSE() {
@@ -733,9 +834,175 @@ export function dashboardHtml(tierName: string): string {
733
834
  pollServers();
734
835
  setInterval(pollServers, 5000);
735
836
 
837
+ // --- Game-mode settings strip (S32) -------------------------------------
838
+ // Polls GET /api/game-state and applies the row to the settings controls +
839
+ // the document theme. On any control change, PUTs a partial patch back. The
840
+ // dashboard server is a detached child with no MegaRuntime ref, so it reads /
841
+ // writes the game_state SQLite row directly; the in-process MegaRuntime picks
842
+ // up the change via its fs.watch cache-eviction watcher (S32).
843
+ var gmCheckbox = document.getElementById('set-game-mode');
844
+ var gmTheme = document.getElementById('set-theme');
845
+ var gmTui = document.getElementById('set-tui-mode');
846
+
847
+ function applyGameState(gs) {
848
+ if (!gs) return;
849
+ if (gs.theme) document.documentElement.dataset.theme = gs.theme;
850
+ if (gmCheckbox) gmCheckbox.checked = !!gs.game_mode_on;
851
+ if (gmTheme) gmTheme.value = gs.theme || 'transparent';
852
+ if (gmTui) gmTui.value = gs.tui_display_mode || 'full';
853
+ }
854
+ function pollGameState() {
855
+ fetch('/api/game-state').then(function(r) { return r.json(); }).then(applyGameState).catch(function() {}); // guardrails-allow PREVENT-PI-004: browser-side fetch in dashboard HTML template (not Node runtime)
856
+ }
857
+ function putGameState(patch) {
858
+ fetch('/api/game-state', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch) }).then(function(r) { return r.json(); }).then(applyGameState).catch(function() {}); // guardrails-allow PREVENT-PI-004: browser-side fetch in dashboard HTML template (not Node runtime)
859
+ }
860
+ if (gmCheckbox) gmCheckbox.addEventListener('change', function() { putGameState({ game_mode_on: gmCheckbox.checked }); });
861
+ if (gmTheme) gmTheme.addEventListener('change', function() { putGameState({ theme: gmTheme.value }); });
862
+ if (gmTui) gmTui.addEventListener('change', function() { putGameState({ tui_display_mode: gmTui.value }); });
863
+ pollGameState();
864
+ setInterval(pollGameState, 5000);
865
+
866
+ // --- Game Mode leaderboards (S34) ----------------------------------------
867
+ // Polls GET /api/game-scores per metric, renders the per-repo leaderboard
868
+ // tables, the MEGA CACHE banner, the hidden Opie's Wild Ride unlock tile, and
869
+ // the transient oopsie toast (fires when a NEW mega_cache trophy row appears
870
+ // since the last poll). Browser-side fetch only (PREVENT-PI-004).
871
+ var GAME_METRICS = ['cache', 'dedupe', 'turns', 'mega_cache'];
872
+ var GAME_EMPTY = true;
873
+ var lastMegaTs = 0;
874
+ var lastMaxLevel = 0;
875
+ function fmtPct(v) { return (Math.round(v * 10) / 10) + '%'; }
876
+ function fmtDate(ts) { return ts ? new Date(ts).toLocaleString() : 'โ€”'; }
877
+ function trophyMeta(m) { try { return (m && typeof m === 'object') ? m : {}; } catch (e) { return {}; } }
878
+ function renderGameScores() {
879
+ var results = {};
880
+ var pending = GAME_METRICS.length + 1; // metrics + repos badge
881
+ function done() {
882
+ if (--pending > 0) return;
883
+ var cache = results['cache'] || [];
884
+ if (cache.length) GAME_EMPTY = false;
885
+ document.getElementById('lb-cache').innerHTML = cache.map(function(r) {
886
+ return '<tr><td title="' + sanitize(r.repo_root) + '">' + sanitize(r.repo_root.split('/').pop()) + '</td><td class="num">' + sanitize(String(r.value)) + '</td></tr>';
887
+ }).join('') || '<tr><td colspan="2" class="repo-none">no data</td></tr>';
888
+ var dedupe = results['dedupe'] || [];
889
+ if (dedupe.length) GAME_EMPTY = false;
890
+ document.getElementById('lb-dedupe').innerHTML = dedupe.map(function(r) {
891
+ return '<tr><td title="' + sanitize(r.repo_root) + '">' + sanitize(r.repo_root.split('/').pop()) + '</td><td class="num">' + sanitize(String(r.value)) + '</td></tr>';
892
+ }).join('') || '<tr><td colspan="2" class="repo-none">no data</td></tr>';
893
+ var turns = results['turns'] || [];
894
+ var maxTurns = turns.reduce(function(mx, r) { return Math.max(mx, r.value); }, 0);
895
+ var lvl = Math.floor(Math.log2(maxTurns + 1)) + 1;
896
+ var lvlEl = document.getElementById('turns-level');
897
+ if (lvlEl) lvlEl.textContent = 'LVL ' + lvl;
898
+ if (turns.length) GAME_EMPTY = false;
899
+ // level-up pulse: when max level increases vs last poll, pulse the cache bar
900
+ if (lvl > lastMaxLevel && lastMaxLevel > 0) {
901
+ var bar = document.getElementById('ctx-bar');
902
+ if (bar) { bar.classList.add('level-up'); setTimeout(function() { bar.classList.remove('level-up'); }, 1200); }
903
+ }
904
+ if (lvl > lastMaxLevel) lastMaxLevel = lvl;
905
+ document.getElementById('lb-turns').innerHTML = turns.map(function(r) {
906
+ return '<tr><td title="' + sanitize(r.repo_root) + '">' + sanitize(r.repo_root.split('/').pop()) + '</td><td class="num">' + sanitize(String(r.value)) + '</td></tr>';
907
+ }).join('') || '<tr><td colspan="2" class="repo-none">no data</td></tr>';
908
+ // mega_cache trophies + banner + Opie tile + transient toast
909
+ var mega = results['mega_cache'] || [];
910
+ var megaBody = document.getElementById('lb-mega_cache');
911
+ if (megaBody) {
912
+ megaBody.innerHTML = mega.map(function(r) {
913
+ var m = trophyMeta(r.meta);
914
+ var fs = m.firstSeenTs || m.firstSeen || r.ts;
915
+ var extra = fs ? ' <span class="lb-meta">' + sanitize(fmtDate(fs)) + '</span>' : '';
916
+ return '<tr><td title="' + sanitize(r.repo_root) + '">' + sanitize(r.repo_root.split('/').pop()) + '</td><td class="num">' + sanitize(fmtPct(r.value)) + extra + '</td></tr>';
917
+ }).join('') || '<tr><td colspan="2" class="repo-none">no trophies yet</td></tr>';
918
+ }
919
+ var banner = document.getElementById('mega-cache-banner');
920
+ var tile = document.getElementById('opie-tile');
921
+ var best = null, firstSeen = null;
922
+ mega.forEach(function(r) {
923
+ if (best == null || r.value > best) best = r.value;
924
+ var m = trophyMeta(r.meta);
925
+ var fs = m.firstSeenTs || m.firstSeen || r.ts;
926
+ if (firstSeen == null || fs < firstSeen) firstSeen = fs;
927
+ });
928
+ if (banner) {
929
+ if (best != null && best > 100) {
930
+ banner.style.display = 'block';
931
+ banner.textContent = '๐Ÿฅง MEGA CACHE! peak ' + fmtPct(best) + ' โ€” first reached ' + fmtDate(firstSeen);
932
+ } else { banner.style.display = 'none'; }
933
+ }
934
+ if (tile) {
935
+ if (best != null && best > 100) {
936
+ tile.style.display = 'block';
937
+ tile.className = 'achievement-tile unlocked';
938
+ tile.innerHTML = '๐Ÿ† Opie\'s Wild Ride<span class="ach-detail">best ' + sanitize(fmtPct(best)) + ' ยท first ' + sanitize(fmtDate(firstSeen)) + '</span>';
939
+ } else { tile.style.display = 'none'; tile.className = 'achievement-tile'; }
940
+ }
941
+ // transient oopsie toast: a NEW mega_cache trophy row since the last poll
942
+ var maxTs = mega.reduce(function(mx, r) { return Math.max(mx, r.ts); }, 0);
943
+ var newRow = mega.find(function(r) { return r.ts > lastMegaTs && r.value > 100; });
944
+ if (lastMegaTs && newRow) {
945
+ var toast = document.getElementById('mega-cache-toast');
946
+ if (toast) {
947
+ toast.textContent = 'oopsie! cache went to ' + Math.round(newRow.value) + '% โ€” MEGA CACHE ๐Ÿฅง';
948
+ toast.classList.add('show');
949
+ setTimeout(function() { toast.classList.remove('show'); }, 4000);
950
+ }
951
+ }
952
+ if (maxTs > lastMegaTs) lastMegaTs = maxTs;
953
+ // empty state
954
+ var emptyEl = document.getElementById('game-empty');
955
+ if (emptyEl) emptyEl.style.display = GAME_EMPTY ? 'block' : 'none';
956
+ }
957
+ GAME_METRICS.forEach(function(m) {
958
+ fetch('/api/game-scores?metric=' + encodeURIComponent(m) + '&limit=25').then(function(r) { return r.ok ? r.json() : []; }).then(function(rows) { results[m] = rows || []; }).catch(function() { results[m] = []; }).then(done);
959
+ });
960
+ fetch('/api/game-scores?metric=repos&limit=1').then(function(r) { return r.ok ? r.json() : []; }).then(function(rows) {
961
+ var badge = document.getElementById('repos-badge');
962
+ if (badge) badge.textContent = ((rows && rows.length) ? rows[0].value : 0) + ' repos';
963
+ if (rows && rows.length && rows[0].value > 0) GAME_EMPTY = false;
964
+ }).catch(function() {}).then(done);
965
+ }
966
+
967
+ // --- Achievements tile row (S35) ------------------------------------------
968
+ // Polls GET /api/achievements; renders the tile row (hidden+locked render
969
+ // NOTHING; unlocked show icon+title+date; visible-but-locked show ??? teaser)
970
+ // and fires a transient toast when a newly-unlocked achievement appears.
971
+ // Browser-side fetch only (PREVENT-PI-004).
972
+ var lastAchMaxTs = 0;
973
+ function renderAchievements() {
974
+ fetch('/api/achievements').then(function(r) { return r.ok ? r.json() : []; }).then(function(rows) { // guardrails-allow PREVENT-PI-004: browser-side fetch in dashboard HTML template (not Node runtime)
975
+ var box = document.getElementById('ach-tiles');
976
+ if (!box) return;
977
+ var html = '';
978
+ var maxTs = 0;
979
+ (rows || []).forEach(function(a) {
980
+ if (a.hidden === 1 && a.unlocked_at == null) return; // hidden invariant: render nothing
981
+ if (a.unlocked_at != null) {
982
+ var isNew = a.unlocked_at > lastAchMaxTs && lastAchMaxTs > 0;
983
+ maxTs = Math.max(maxTs, a.unlocked_at);
984
+ html += '<div class="ach-tile unlocked' + (isNew ? ' just-unlocked' : '') + '">' + sanitize(a.icon || '') + ' ' + sanitize(a.title) + '<span class="ach-detail">unlocked ' + sanitize(fmtDate(a.unlocked_at)) + '</span></div>';
985
+ } else {
986
+ html += '<div class="ach-tile locked">??? ' + sanitize(a.title) + '</div>';
987
+ }
988
+ });
989
+ box.innerHTML = html || '<span class="repo-none">no achievements yet</span>';
990
+ var newly = (rows || []).filter(function(a) { return a.unlocked_at != null && a.unlocked_at > lastAchMaxTs; });
991
+ if (lastAchMaxTs && newly.length) {
992
+ var toast = document.getElementById('ach-toast');
993
+ if (toast) {
994
+ toast.textContent = newly.map(function(a) { return (a.icon || '') + ' ' + a.title; }).join(', ') + ' unlocked!';
995
+ toast.classList.add('show');
996
+ setTimeout(function() { toast.classList.remove('show'); }, 4000);
997
+ }
998
+ }
999
+ if (maxTs > lastAchMaxTs) lastAchMaxTs = maxTs;
1000
+ }).catch(function() {});
1001
+ }
1002
+
736
1003
  // --- Tab switching ------------------------------------------------------
737
1004
  var tabs = document.querySelectorAll('.tab');
738
- var panels = { current: 'panel-current', all: 'panel-all', active: 'panel-active', summary: 'panel-summary' };
1005
+ var panels = { current: 'panel-current', all: 'panel-all', active: 'panel-active', summary: 'panel-summary', game: 'panel-game' };
739
1006
  for (var i = 0; i < tabs.length; i++) {
740
1007
  tabs[i].addEventListener('click', function() {
741
1008
  var name = this.getAttribute('data-tab');
@@ -749,6 +1016,7 @@ export function dashboardHtml(tierName: string): string {
749
1016
  }
750
1017
  if (name === 'all' || name === 'summary') pollIndex();
751
1018
  if (name === 'active') pollServers();
1019
+ if (name === 'game') { renderGameScores(); renderAchievements(); }
752
1020
  });
753
1021
  }
754
1022
  })();