sigrank 0.0.213 → 0.0.215

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/manifest.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://raw.githubusercontent.com/anthropics/mcpb/main/schemas/mcpb-manifest-v0.4.schema.json",
3
3
  "manifest_version": "0.4",
4
4
  "name": "sigrank",
5
- "version": "0.0.212",
5
+ "version": "0.0.214",
6
6
  "description": "Ranks AI coding operators by token cascade efficiency (Yield = cache_read \u00d7 output / input\u00b2). 23 tools for yield calculation, local token usage pulls, signed submission, and tokscale-powered analytics (market share, developer profiles, model trends, cost analysis, device profiles, MCP usage, competitive intelligence). Privacy-first: runs locally, only four token counts leave the machine.",
7
7
  "author": {
8
8
  "name": "SunrisesIllNeverSee",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sigrank",
3
- "version": "0.0.213",
3
+ "version": "0.0.215",
4
4
  "author": "SunrisesIllNeverSee (https://github.com/SunrisesIllNeverSee)",
5
5
  "description": "SigRank MCP server — AI operator evaluation and token cascade efficiency tools. The yield cascade + live leaderboard as MCP tools any agent can call.",
6
6
  "mcpName": "io.github.SunrisesIllNeverSee/sigrank-mcp",
@@ -85,6 +85,10 @@
85
85
  "ccusage": "^20.0.19",
86
86
  "tokscale": "^4.7.0"
87
87
  },
88
+ "devDependencies": {
89
+ "@resvg/resvg-js": "^2.6.2",
90
+ "gifenc": "^1.0.3"
91
+ },
88
92
  "overrides": {
89
93
  "@hono/node-server": "^2.0.5"
90
94
  }
@@ -0,0 +1,126 @@
1
+ // tui-themes.mjs — centralized ANSI color palette with multiple themes
2
+ //
3
+ // Tokscale-inspired: all colors live in one module, render code references
4
+ // them through `currentTheme`. Supports dark (default), light, high-contrast,
5
+ // and monochrome themes for accessibility.
6
+ //
7
+ // Usage in tui.mjs:
8
+ // import { currentTheme as c, setTheme } from "./tui-themes.mjs";
9
+ // // c.gold, c.cyan, c.reset, etc. — same interface as the old `c` object
10
+ //
11
+ // CLI:
12
+ // node tui.mjs --theme dark # default
13
+ // node tui.mjs --theme light # light terminal
14
+ // node tui.mjs --theme high-contrast # visually impaired
15
+ // node tui.mjs --theme monochrome # colorblind
16
+
17
+ const ESC = "\x1b[";
18
+
19
+ export const THEMES = {
20
+ // ── Dark (default) — current palette, designed for dark terminals ──
21
+ // green uses 256-color 77 (#5fd75f) for WCAG AA compliance on dark bg (9.0:1).
22
+ // Standard ANSI 32 (#5a8a5a) only achieves 4.1:1 — below the 4.5:1 threshold.
23
+ dark: {
24
+ name: "dark",
25
+ reset: `${ESC}0m`,
26
+ bold: `${ESC}1m`,
27
+ dim: `${ESC}2m`,
28
+ gold: `${ESC}33m`,
29
+ boldGold: `${ESC}1;33m`,
30
+ cyan: `${ESC}36m`,
31
+ boldCyan: `${ESC}1;36m`,
32
+ green: `${ESC}38;5;77m`, // brighter green #5fd75f (9.0:1 on dark — was ANSI 32 at 4.1:1)
33
+ red: `${ESC}38;5;203m`, // brighter red #ff5f5f (5.6:1 on dark — was ANSI 31 at 4.46:1, just below AA)
34
+ white: `${ESC}97m`,
35
+ boldWhite: `${ESC}1;97m`,
36
+ magenta: `${ESC}35m`,
37
+ blue: `${ESC}34m`,
38
+ bgDim: `${ESC}48;5;236m`,
39
+ bgCyan: `${ESC}48;5;23m`,
40
+ // Medal backgrounds (256-color) — gold/silver/bronze podium tints
41
+ medalBg: { 1: 220, 2: 250, 3: 130 },
42
+ medalFg: `${ESC}38;5;232m`, // dark text on medal bg
43
+ },
44
+
45
+ // ── Light — dark text on light/white terminals ──
46
+ // Uses 256-color codes (38;5;N) with darker shades that pass WCAG AA (4.5:1)
47
+ // on white backgrounds. Standard ANSI 33/36/32/31/35/34 render as pastel
48
+ // shades that fail contrast on white — these 256-color variants fix that.
49
+ light: {
50
+ name: "light",
51
+ reset: `${ESC}0m`,
52
+ bold: `${ESC}1m`,
53
+ dim: `${ESC}38;5;242m`, // medium gray (4.5:1 on white — passes AA for large text)
54
+ gold: `${ESC}38;5;130m`, // dark orange-gold #af5f00 (4.7:1 — yellow can't pass AA on white, this is the closest)
55
+ boldGold: `${ESC}1;38;5;130m`, // bold dark orange-gold (4.7:1)
56
+ cyan: `${ESC}38;5;24m`, // dark cyan #005f87 (7.0:1)
57
+ boldCyan: `${ESC}1;38;5;24m`, // bold dark cyan (7.0:1)
58
+ green: `${ESC}38;5;28m`, // dark green #008700 (4.7:1)
59
+ red: `${ESC}38;5;124m`, // dark red #af0000 (7.4:1)
60
+ white: `${ESC}30m`, // BLACK text on light bg (21:1)
61
+ boldWhite: `${ESC}1;30m`, // bold black
62
+ magenta: `${ESC}38;5;90m`, // dark magenta #870087 (8.8:1)
63
+ blue: `${ESC}38;5;19m`, // dark blue #0000af (13.0:1)
64
+ bgDim: `${ESC}48;5;250m`, // light grey bg for active tab
65
+ bgCyan: `${ESC}48;5;194m`, // light teal bg for active tab
66
+ medalBg: { 1: 220, 2: 250, 3: 130 },
67
+ medalFg: `${ESC}38;5;232m`,
68
+ },
69
+
70
+ // ── High contrast — bold everything, no dim, maximum readability ──
71
+ highContrast: {
72
+ name: "high-contrast",
73
+ reset: `${ESC}0m`,
74
+ bold: `${ESC}1m`,
75
+ dim: `${ESC}0;37m`, // NO dim — full brightness white instead
76
+ gold: `${ESC}1;33m`, // bold gold
77
+ boldGold: `${ESC}1;33m`,
78
+ cyan: `${ESC}1;36m`, // bold cyan
79
+ boldCyan: `${ESC}1;36m`,
80
+ green: `${ESC}1;32m`, // bold green
81
+ red: `${ESC}1;31m`, // bold red
82
+ white: `${ESC}1;97m`, // bold bright white
83
+ boldWhite: `${ESC}1;97m`,
84
+ magenta: `${ESC}1;35m`, // bold magenta
85
+ blue: `${ESC}1;34m`, // bold blue
86
+ bgDim: `${ESC}48;5;238m`, // slightly lighter dark bg
87
+ bgCyan: `${ESC}48;5;24m`, // slightly lighter teal bg
88
+ medalBg: { 1: 220, 2: 250, 3: 130 },
89
+ medalFg: `${ESC}1;38;5;232m`,
90
+ },
91
+
92
+ // ── Monochrome — bold/normal only, no color (colorblind-safe) ──
93
+ monochrome: {
94
+ name: "monochrome",
95
+ reset: `${ESC}0m`,
96
+ bold: `${ESC}1m`,
97
+ dim: `${ESC}2m`, // dim is fine in monochrome (it's weight, not color)
98
+ gold: `${ESC}1m`, // bold only (no color)
99
+ boldGold: `${ESC}1m`,
100
+ cyan: `${ESC}0m`, // normal weight
101
+ boldCyan: `${ESC}1m`, // bold
102
+ green: `${ESC}0m`, // normal
103
+ red: `${ESC}1m`, // bold (errors stand out by weight)
104
+ white: `${ESC}0m`, // normal
105
+ boldWhite: `${ESC}1m`, // bold
106
+ magenta: `${ESC}0m`, // normal
107
+ blue: `${ESC}0m`, // normal
108
+ bgDim: `${ESC}7m`, // reverse video for active tab (no color)
109
+ bgCyan: `${ESC}7m`, // reverse video
110
+ medalBg: {}, // no medal colors in monochrome
111
+ medalFg: `${ESC}1m`, // bold for medal winners
112
+ },
113
+ };
114
+
115
+ export let currentTheme = THEMES.dark;
116
+
117
+ export function setTheme(name) {
118
+ if (THEMES[name]) {
119
+ currentTheme = THEMES[name];
120
+ }
121
+ return currentTheme;
122
+ }
123
+
124
+ export function getThemeNames() {
125
+ return Object.keys(THEMES);
126
+ }
@@ -22,6 +22,7 @@ import { execFile } from "child_process";
22
22
  import { existsSync, readFileSync } from "fs";
23
23
  import os from "os";
24
24
  import path from "path";
25
+ import { pathToFileURL } from "node:url";
25
26
  import { pkgVersion } from "../lib/pkg-version.mjs";
26
27
  import { LEADERBOARD_METRIC } from "../lib/constants.mjs";
27
28
 
@@ -49,24 +50,20 @@ export function dashboardEnabledPlatforms({
49
50
  }
50
51
 
51
52
  // ── ANSI ───────────────────────────────────────────────────────────────────
53
+ // Theme system: colors are centralized in tui-themes.mjs (tokscale-inspired).
54
+ // The `c` object is a live proxy to the current theme — changing the theme
55
+ // via setTheme() updates all colors. Supports dark/light/high-contrast/monochrome.
56
+ import * as _themeMod from "./tui-themes.mjs";
52
57
  const ESC = "\x1b[";
53
- const c = {
54
- reset: `${ESC}0m`,
55
- bold: `${ESC}1m`,
56
- dim: `${ESC}2m`,
57
- gold: `${ESC}33m`,
58
- boldGold: `${ESC}1;33m`,
59
- cyan: `${ESC}36m`,
60
- boldCyan: `${ESC}1;36m`,
61
- green: `${ESC}32m`,
62
- red: `${ESC}31m`,
63
- white: `${ESC}97m`,
64
- boldWhite: `${ESC}1;97m`,
65
- magenta: `${ESC}35m`,
66
- blue: `${ESC}34m`,
67
- bgDim: `${ESC}48;5;236m`, // dark grey bg for active tab
68
- bgCyan: `${ESC}48;5;23m`, // dark teal bg for active tab
69
- };
58
+ // `c` is a live proxy — reads currentTheme at access time so theme switches
59
+ // take effect immediately. The helper functions below (paint/bold/dim/etc.)
60
+ // read from `c` at call time, so they always use the active theme's colors.
61
+ const c = new Proxy({}, {
62
+ get(_target, prop) {
63
+ return _themeMod.currentTheme[prop];
64
+ },
65
+ });
66
+ const setTheme = _themeMod.setTheme;
70
67
  const paint = (col, s) => `${col}${s}${c.reset}`;
71
68
  const bold = (s) => paint(c.bold, s);
72
69
  const dim = (s) => paint(c.dim, s);
@@ -177,7 +174,7 @@ function ansiTrunc(s, w) {
177
174
  vis++;
178
175
  i++;
179
176
  }
180
- return out + "\x1b[0m"; // reset so a cut mid-color doesn't bleed into next line
177
+ return out + c.reset; // reset so a cut mid-color doesn't bleed into next line
181
178
  }
182
179
  function padEnd(s, w) {
183
180
  const v = stripAnsi(s).length;
@@ -190,6 +187,27 @@ function padStart(s, w) {
190
187
  function trunc(s, w) {
191
188
  return stripAnsi(s).length <= w ? s : s.slice(0, w - 1) + "…";
192
189
  }
190
+ // Wrap a plain-text string into dim'd lines that fit within `maxW` columns.
191
+ // Used for no-data/empty-state messages that can be long on narrow terminals.
192
+ // Returns an array of strings (each already dim'd + indented) for emit().
193
+ function wrapDim(text, indent = " ", maxW) {
194
+ const w = maxW || W();
195
+ const avail = w - 2 - indent.length; // 2 = leading " " from emit caller
196
+ if (avail < 20) return [`${indent}${dim(text)}`]; // too narrow to wrap — let it be
197
+ const words = text.split(" ");
198
+ const lines = [];
199
+ let line = "";
200
+ for (const word of words) {
201
+ if ((line + " " + word).trim().length > avail) {
202
+ if (line) lines.push(`${indent}${dim(line.trim())}`);
203
+ line = word;
204
+ } else {
205
+ line = (line + " " + word).trim();
206
+ }
207
+ }
208
+ if (line) lines.push(`${indent}${dim(line.trim())}`);
209
+ return lines;
210
+ }
193
211
  function hr(ch = "─") {
194
212
  return dim(ch.repeat(Math.max(0, W() - 4)));
195
213
  }
@@ -644,8 +662,14 @@ function renderDashboard(data, status = "", scrollOffset = 0) {
644
662
  // Responsive: the full 12-col table is ~124 wide. On terminals narrower than
645
663
  // that, drop the two derived columns (Vel, 10x) and tighten the inter-column
646
664
  // gap from 2 spaces to 1, so the core pillars + Υ/SNR/Lev/Class still fit.
665
+ // On very narrow terminals (<100), also hide Class and shorten cache headers
666
+ // to CW/CR so the core pillars + Υ/SNR/Lev still fit at 80 cols.
647
667
  const narrow = w < 124;
668
+ const veryNarrow = w < 100;
669
+ const ultraNarrow = w < 90;
648
670
  const gap = narrow ? " " : " ";
671
+ const cwLabel = veryNarrow ? "CW" : "CacheW";
672
+ const crLabel = veryNarrow ? "CR" : "CacheR";
649
673
  const renderRow = (label, colorFn, winKey, p, est = false) => {
650
674
  const cas = cascadeFrom(p);
651
675
  if (!cas) return;
@@ -665,15 +689,15 @@ function renderDashboard(data, status = "", scrollOffset = 0) {
665
689
  ),
666
690
  padStart((p.cacheRead ?? 0) > 0 ? fmtTok(p.cacheRead) : dim("—"), 9),
667
691
  padStart(cas.yield > 10000 ? gold(fmtY(cas.yield)) : fmtY(cas.yield), 9),
668
- padStart(fmtSNR(cas.snr), 7),
669
- padStart(fmtLev(cas.leverage) + "×", 7),
692
+ ...(ultraNarrow ? [] : [padStart(fmtSNR(cas.snr), 7)]),
693
+ ...(ultraNarrow ? [] : [padStart(fmtLev(cas.leverage) + "×", 7)]),
670
694
  ...(narrow
671
695
  ? []
672
696
  : [
673
697
  padStart(cas.velocity?.toFixed(2) ?? "—", 6),
674
698
  padStart(cas.dev10x?.toFixed(2) ?? "—", 6),
675
699
  ]),
676
- padEnd(clsFn(cas.class), 13),
700
+ ...(veryNarrow ? [] : [padEnd(clsFn(cas.class), 13)]),
677
701
  ];
678
702
  emit(` ${cols.join(gap)}`);
679
703
  };
@@ -689,16 +713,16 @@ function renderDashboard(data, status = "", scrollOffset = 0) {
689
713
  padEnd(hdr("Win"), 5),
690
714
  padStart(hdr("Input"), 8),
691
715
  padStart(hdr("Output"), 8),
692
- padStart(hdr("CacheW"), 8),
693
- padStart(hdr("CacheR"), 9),
716
+ padStart(hdr(cwLabel), 8),
717
+ padStart(hdr(crLabel), 9),
694
718
  padStart(hdr("Υ Yield"), 9),
695
- padStart(hdr("SNR"), 7),
696
- padStart(hdr("Lev"), 7),
719
+ ...(ultraNarrow ? [] : [padStart(hdr("SNR"), 7)]),
720
+ ...(ultraNarrow ? [] : [padStart(hdr("Lev"), 7)]),
697
721
  ...(narrow ? [] : [padStart(hdr("Vel"), 6), padStart(hdr("10x"), 6)]),
698
- padEnd(hdr("Class"), 13),
722
+ ...(veryNarrow ? [] : [padEnd(hdr("Class"), 13)]),
699
723
  ];
700
724
  emit(` ${CH.join(gap)}`);
701
- emit(` ${dim("·".repeat(Math.max(0, Math.min(w - 4, narrow ? 96 : 114))))}`);
725
+ emit(` ${dim("·".repeat(Math.max(0, Math.min(w - 4, narrow ? (ultraNarrow ? 70 : veryNarrow ? 80 : 96) : 114))))}`);
702
726
 
703
727
  if (active.length === 0) {
704
728
  // Count is derived from the registry — a hardcoded number drifts every time an
@@ -1215,8 +1239,12 @@ function renderCompare(data) {
1215
1239
  ].filter((s) => Object.keys(s.pillars).length > 0);
1216
1240
 
1217
1241
  emit();
1242
+ // Shorten the source list on narrow terminals to avoid header overflow.
1243
+ const sourceList = w < 90
1244
+ ? `${SOURCES.length} sources`
1245
+ : "tokenpull vs ccusage vs token-dash vs tokscale";
1218
1246
  emit(
1219
- ` ${bold("Source Comparison")} ${dim(`platform: ${platform}`)} ${dim("· tokenpull vs ccusage vs token-dash vs tokscale")}`,
1247
+ ` ${bold("Source Comparison")} ${dim(`platform: ${platform}`)} ${dim(`· ${sourceList}`)}`,
1220
1248
  );
1221
1249
 
1222
1250
  if (!tpData) {
@@ -1239,16 +1267,17 @@ function renderCompare(data) {
1239
1267
  if (!td) missing.push("token-dashboard (~/.claude/token-dashboard.db)");
1240
1268
  if (!ts) missing.push("tokscale (npm i -g tokscale)");
1241
1269
  if (missing.length)
1242
- emit(` ${dim(` no data from: ${missing.join(" · ")}`)}`);
1270
+ for (const ln of wrapDim(`no data from: ${missing.join(" · ")}`)) emit(ln);
1243
1271
  emit();
1244
1272
 
1245
1273
  // FIX (2026-08-18): if ALL sources are empty (tokenpull has no windows AND all
1246
1274
  // verifiers are null), show a clear empty-state message instead of bare headers
1247
1275
  // with zero data rows.
1248
1276
  if (SOURCES.length === 0) {
1249
- emit(
1250
- ` ${dim(" no source data available for this platform — install a verifier or check ~/.claude/projects/")}`,
1251
- );
1277
+ for (const ln of wrapDim(
1278
+ "no source data available for this platform — install a verifier or check ~/.claude/projects/",
1279
+ ))
1280
+ emit(ln);
1252
1281
  return;
1253
1282
  }
1254
1283
 
@@ -1305,6 +1334,28 @@ function renderCompare(data) {
1305
1334
  emit();
1306
1335
  }
1307
1336
 
1337
+ // Fill the dead-space between the source table and cascade metrics with a
1338
+ // log-scale Υ Yield comparison bar — owner wants more mini-graphs, and this
1339
+ // gap was flagged as dead-space by the audit.
1340
+ if (used < budget - 6 && SOURCES.length > 0) {
1341
+ const yields = SOURCES.map((s) => {
1342
+ const p = s.pillars["all"];
1343
+ const cas = p ? cascadeFrom(p) : null;
1344
+ return { name: s.name, color: s.color, yield: cas?.yield ?? 0 };
1345
+ }).filter((y) => y.yield > 0);
1346
+ if (yields.length > 0) {
1347
+ const maxYield = Math.max(...yields.map((y) => y.yield));
1348
+ const maxLog = Math.log10(maxYield) || 1;
1349
+ const barW = Math.min(Math.max(w - 40, 15), 30);
1350
+ emit(` ${dim("Υ Yield comparison (log scale)")}`);
1351
+ for (const y of yields) {
1352
+ const { bar } = logBar(y.yield, maxLog, barW, c.cyan);
1353
+ emit(` ${padEnd(y.color(y.name), 12)} ${bar} ${fmtY(y.yield)}`);
1354
+ }
1355
+ emit();
1356
+ }
1357
+ }
1358
+
1308
1359
  if (used < budget - 4) {
1309
1360
  emit(` ${hr()}`);
1310
1361
  emit(
@@ -1387,8 +1438,12 @@ function renderSubmissions(
1387
1438
  ? `${dim(" · you highlighted")}`
1388
1439
  : "";
1389
1440
  emit();
1441
+ // Shorten the header on narrow terminals to avoid overflow.
1442
+ const boardHeader = w < 90
1443
+ ? `window: ${window} · ranked by Υ Yield`
1444
+ : `window: ${window} · all submissions ranked by Υ Yield · signalaf.com`;
1390
1445
  emit(
1391
- ` ${bold("Submissions")} ${dim(`window: ${window} · all submissions ranked by Υ Yield · signalaf.com`)}${modeLabel}`,
1446
+ ` ${bold("Submissions")} ${dim(boardHeader)}${modeLabel}`,
1392
1447
  );
1393
1448
  emit();
1394
1449
 
@@ -1419,15 +1474,26 @@ function renderSubmissions(
1419
1474
  ? v.toFixed(2)
1420
1475
  : String(v);
1421
1476
 
1477
+ // Responsive column hiding — the full table is 106 cols wide. Hide columns
1478
+ // at narrower terminals so rows never overflow/wrap/garble.
1479
+ // w >= 120: all columns (106 cols fits)
1480
+ // w < 120: hide Tokens (saves 11) → 95 cols
1481
+ // w < 100: hide Tokens + Class (saves 14) → 81 cols
1482
+ // w < 90: hide Tokens + Class + Op Ratio, shorten Codename 20→16 → 61 cols
1483
+ const showTokens = w >= 120;
1484
+ const showClass = w >= 100;
1485
+ const showOpRatio = w >= 90;
1486
+ const nameWidth = w < 90 ? 16 : 20;
1487
+
1422
1488
  const SH = [
1423
1489
  padStart(hdr("#"), 4),
1424
- padEnd(hdr("Codename"), 20),
1490
+ padEnd(hdr("Codename"), nameWidth),
1425
1491
  padEnd(hdr("Platform"), 10),
1426
1492
  padEnd(hdr("Win"), 6),
1427
1493
  padStart(hdr("Υ Yield"), 9),
1428
- padEnd(hdr("Op Ratio"), 18),
1429
- padEnd(hdr("Class"), 12),
1430
- padStart(hdr("Tokens"), 9),
1494
+ ...(showOpRatio ? [padEnd(hdr("Op Ratio"), 18)] : []),
1495
+ ...(showClass ? [padEnd(hdr("Class"), 12)] : []),
1496
+ ...(showTokens ? [padStart(hdr("Tokens"), 9)] : []),
1431
1497
  ];
1432
1498
  emit(` ${SH.join(" ")}`);
1433
1499
  emit(` ${dim("·".repeat(Math.min(w - 4, 100)))}`);
@@ -1445,10 +1511,10 @@ function renderSubmissions(
1445
1511
  : idx < 3
1446
1512
  ? cyan(`#${idx + 1}`)
1447
1513
  : `#${idx + 1}`;
1448
- const nmRaw = trunc(gName(e), 20);
1514
+ const nmRaw = trunc(gName(e), nameWidth);
1449
1515
  const nm = isYou
1450
- ? `${c.bgCyan}${c.boldCyan}${padEnd(` ${trunc(gName(e), 18)} `, 20)}${c.reset}`
1451
- : padEnd(nmRaw, 20);
1516
+ ? `${c.bgCyan}${c.boldCyan}${padEnd(` ${trunc(gName(e), nameWidth - 2)} `, nameWidth)}${c.reset}`
1517
+ : padEnd(nmRaw, nameWidth);
1452
1518
  const yv = gYield(e);
1453
1519
  const yld = padStart(
1454
1520
  yv != null ? (yv > 10000 ? gold(fmtY(yv)) : fmtY(yv)) : "—",
@@ -1457,9 +1523,17 @@ function renderSubmissions(
1457
1523
  const cls = padEnd(colorCls(gClass(e)), 12);
1458
1524
  const tk = gTokens(e);
1459
1525
  const youMark = isYou ? ` ${c.bgCyan}${c.boldCyan}YOU${c.reset}` : "";
1460
- emit(
1461
- ` ${padStart(rk, 4)} ${nm} ${padEnd(cyan(trunc(gPlat(e), 10)), 10)} ${padEnd(dim(trunc(gWin(e), 6)), 6)} ${yld} ${padEnd(dim(fmtOp(gOpRatio(e))), 18)} ${cls} ${padStart(tk != null ? fmtTok(tk) : "—", 9)}${youMark}`,
1462
- );
1526
+ const cells = [
1527
+ padStart(rk, 4),
1528
+ nm,
1529
+ padEnd(cyan(trunc(gPlat(e), 10)), 10),
1530
+ padEnd(dim(trunc(gWin(e), 6)), 6),
1531
+ yld,
1532
+ ...(showOpRatio ? [padEnd(dim(fmtOp(gOpRatio(e))), 18)] : []),
1533
+ ...(showClass ? [cls] : []),
1534
+ ...(showTokens ? [padStart(tk != null ? fmtTok(tk) : "—", 9)] : []),
1535
+ ];
1536
+ emit(` ${cells.join(" ")}${youMark}`);
1463
1537
  shown++;
1464
1538
  }
1465
1539
  // A3-style: if the height budget dropped rows, say so instead of silently truncating.
@@ -1547,10 +1621,16 @@ function renderBoard(
1547
1621
  d10: top3((e) => e.dev10x),
1548
1622
  };
1549
1623
  // Wrap a padded cell in a medal bg-tint (dark fg on the medal color) when its value places top-3.
1624
+ // Theme-aware: monochrome uses bold instead of color backgrounds.
1550
1625
  const medal = (map, val, padded) => {
1551
1626
  const place = val == null ? null : map.get(val);
1552
1627
  if (!place) return padded;
1553
- return `${ESC}48;5;${MEDAL_BG[place]}m${ESC}38;5;232m${padded}${c.reset}`;
1628
+ const bgCode = c.medalBg?.[place];
1629
+ if (bgCode != null) {
1630
+ return `${ESC}48;5;${bgCode}m${c.medalFg}${padded}${c.reset}`;
1631
+ }
1632
+ // Monochrome / no medal bg — use bold
1633
+ return `${c.bold}${padded}${c.reset}`;
1554
1634
  };
1555
1635
 
1556
1636
  const BH = [
@@ -1695,11 +1775,22 @@ function renderConnect(id, codeBuf = "", msg = "") {
1695
1775
  );
1696
1776
  writeln();
1697
1777
  writeln(
1698
- ` ${dim('Need a new key? Click "New key" at signalaf.com → Settings, then paste it here.')}`,
1699
- );
1700
- writeln(
1701
- ` ${dim("Signed in on the wrong device, or want a fresh start?")} ${bold("[X]")} ${dim("signs out — next paste provisions a fresh device.")}`,
1778
+ ` ${dim(W() < 90 ? 'Need a new key? signalaf.com → Settings → "New key", then paste here.' : 'Need a new key? Click "New key" at signalaf.com → Settings, then paste it here.')}`,
1702
1779
  );
1780
+ // Wrap the sign-out help across 2 lines on narrow terminals (the full
1781
+ // sentence is 108 cols — overflows 100-col terminals by 8).
1782
+ if (W() >= 110) {
1783
+ writeln(
1784
+ ` ${dim("Signed in on the wrong device, or want a fresh start?")} ${bold("[X]")} ${dim("signs out — next paste provisions a fresh device.")}`,
1785
+ );
1786
+ } else {
1787
+ writeln(
1788
+ ` ${dim("Signed in on the wrong device, or want a fresh start?")}`,
1789
+ );
1790
+ writeln(
1791
+ ` ${bold("[X]")} ${dim("signs out — next paste provisions a fresh device.")}`,
1792
+ );
1793
+ }
1703
1794
  } else {
1704
1795
  writeln(` ${bold("Log in to submit to board")}`);
1705
1796
  writeln(` ${hr()}`);
@@ -1723,7 +1814,13 @@ function renderConnect(id, codeBuf = "", msg = "") {
1723
1814
  // watch grid), instead of a single platform/window. `platform`/`win` are OPTIONAL focus filters —
1724
1815
  // null/'all'/'all-windows' means "everything". Each row is a (platform, window) cascade cell:
1725
1816
  // Υ Yield + SNR/Lev/Vel/class. Iterates active platforms (input+output > 0 in any window).
1726
- async function renderWatch(platform = "all", win = "all-windows") {
1817
+ // Load Watch data once — scan all candidate platforms for active sessions.
1818
+ // Separated from rendering (renderWatchData) so the headless audit can load
1819
+ // once and render across many sizes/themes without re-scanning the filesystem
1820
+ // each frame (each full scan is ~8s across 16 platforms; 16 frames × 8s would
1821
+ // be ~130s of pure re-scan otherwise). Mirrors how Dashboard/Compare/Board
1822
+ // already separate load from render.
1823
+ async function loadWatchData(platform = "all", win = "all-windows") {
1727
1824
  // ../tokenpull.mjs — this file lives in presentation/, so "./tokenpull.mjs" resolved
1728
1825
  // to presentation/tokenpull.mjs (nonexistent) and threw ERR_MODULE_NOT_FOUND for
1729
1826
  // every row. Matches the static `../tokenpull.mjs` import at the top of this file.
@@ -1748,17 +1845,6 @@ async function renderWatch(platform = "all", win = "all-windows") {
1748
1845
  ];
1749
1846
  const platFilter = platform && platform !== "all" ? platform : null;
1750
1847
  const winFilter = win && win !== "all-windows" ? win : null;
1751
- const WINS = winFilter ? [winFilter] : ["7d", "30d", "90d", "all"];
1752
- const w = W();
1753
- const budget = H() - 4;
1754
- let used = 0;
1755
- const emit = (s = "") => {
1756
- if (used < budget) {
1757
- writeln(s);
1758
- used++;
1759
- }
1760
- };
1761
-
1762
1848
  // Detect active platforms (any window with input+output > 0), respecting an optional [P] focus.
1763
1849
  const candidates = platFilter ? [platFilter] : ALL_PLATFORMS;
1764
1850
  const settled = await Promise.allSettled(
@@ -1778,6 +1864,23 @@ async function renderWatch(platform = "all", win = "all-windows") {
1778
1864
  (a, b) =>
1779
1865
  ALL_PLATFORMS.indexOf(a.platform) - ALL_PLATFORMS.indexOf(b.platform),
1780
1866
  );
1867
+ return { active, platFilter, winFilter };
1868
+ }
1869
+
1870
+ // Render the Watch tab from pre-loaded data. Sync — no scanning, safe to call
1871
+ // once per terminal size/theme in the audit.
1872
+ function renderWatchData(data) {
1873
+ const { active, platFilter, winFilter } = data;
1874
+ const WINS = winFilter ? [winFilter] : ["7d", "30d", "90d", "all"];
1875
+ const w = W();
1876
+ const budget = H() - 4;
1877
+ let used = 0;
1878
+ const emit = (s = "") => {
1879
+ if (used < budget) {
1880
+ writeln(s);
1881
+ used++;
1882
+ }
1883
+ };
1781
1884
 
1782
1885
  const scopeLabel =
1783
1886
  (platFilter || "all active platforms") +
@@ -1789,9 +1892,10 @@ async function renderWatch(platform = "all", win = "all-windows") {
1789
1892
 
1790
1893
  if (active.length === 0) {
1791
1894
  emit();
1792
- emit(
1793
- ` ${dim(" no active platforms detected — run some sessions, this picks them up automatically")}`,
1794
- );
1895
+ for (const ln of wrapDim(
1896
+ "no active platforms detected — run some sessions, this picks them up automatically",
1897
+ ))
1898
+ emit(ln);
1795
1899
  return;
1796
1900
  }
1797
1901
 
@@ -1821,6 +1925,13 @@ async function renderWatch(platform = "all", win = "all-windows") {
1821
1925
  }
1822
1926
  }
1823
1927
 
1928
+ // Convenience: scan + render in one call (used by --render and --record, which
1929
+ // only render once). The audit uses loadWatchData + renderWatchData separately
1930
+ // to avoid re-scanning across its 16 frames.
1931
+ async function renderWatch(platform = "all", win = "all-windows") {
1932
+ renderWatchData(await loadWatchData(platform, win));
1933
+ }
1934
+
1824
1935
  // ── SUBMIT PREVIEW (FIX I1 / FIX E) — see→confirm→send ───────────────────────
1825
1936
  // Renders a platform × window grid of what submit_verified WOULD send, using the
1826
1937
  // already-loaded dashData. [Enter] confirms → fires submit_verified per platform;
@@ -2043,6 +2154,12 @@ export async function runTui({
2043
2154
  platform: initPlatform = "claude",
2044
2155
  window: win = "7d",
2045
2156
  } = {}) {
2157
+ // Theme selection — --theme <name> sets the color palette (dark/light/high-contrast/monochrome)
2158
+ const themeIdx = process.argv.indexOf("--theme");
2159
+ if (themeIdx !== -1 && process.argv[themeIdx + 1]) {
2160
+ setTheme(process.argv[themeIdx + 1]);
2161
+ }
2162
+
2046
2163
  // FIX J: platform is mutable — [P] cycles it on Compare + Watch tabs. Was a const
2047
2164
  // param (permanently locked to 'claude'); now a let so the user can switch + [S]
2048
2165
  // submits the selected platform (needed for Phase 4 multi-platform submit).
@@ -2055,6 +2172,51 @@ export async function runTui({
2055
2172
  return;
2056
2173
  }
2057
2174
 
2175
+ // Audit mode — headless review of every tab × every terminal size. Checks
2176
+ // overflow, truncation, dead space, budget, color health, sparkline integrity,
2177
+ // responsive design, visual weight. Supports golden frame snapshots
2178
+ // (--golden-save / --golden-check), SVG export (--svg), and CI mode (--ci
2179
+ // exits non-zero on HIGH severity issues).
2180
+ const ai = process.argv.indexOf("--audit");
2181
+ if (ai !== -1) {
2182
+ let runAudit, formatAuditReport;
2183
+ try {
2184
+ ({ runAudit, formatAuditReport } = await import("../dev/tui-audit.mjs"));
2185
+ } catch {
2186
+ console.error("--audit is a development tool, not available in the npm package.");
2187
+ console.error("Clone the repo to use it: https://github.com/SunrisesIllNeverSee/sigrank-mcp");
2188
+ process.exit(1);
2189
+ }
2190
+ const auditData = await runAudit({
2191
+ goldenSave: process.argv.includes("--golden-save"),
2192
+ goldenCheck: process.argv.includes("--golden-check"),
2193
+ svg: process.argv.includes("--svg"),
2194
+ png: process.argv.includes("--png"),
2195
+ ci: process.argv.includes("--ci"),
2196
+ });
2197
+ process.stdout.write(formatAuditReport(auditData) + "\n");
2198
+ // Always exit in audit mode — a data load that timed out may still have a
2199
+ // pending background promise (network fetch / local scan) that would keep
2200
+ // the event loop alive after runTui returns. The audit is headless and must
2201
+ // terminate; unconditional exit guarantees that regardless of ciExitCode.
2202
+ process.exit(auditData.ciExitCode);
2203
+ }
2204
+
2205
+ // Record mode — capture a TUI session as an animated GIF (headless, no TTY needed)
2206
+ const recIdx = process.argv.indexOf("--record");
2207
+ if (recIdx !== -1) {
2208
+ let recordCli;
2209
+ try {
2210
+ ({ recordCli } = await import("../dev/tui-record.mjs"));
2211
+ } catch {
2212
+ console.error("--record is a development tool, not available in the npm package.");
2213
+ console.error("Clone the repo to use it: https://github.com/SunrisesIllNeverSee/sigrank-mcp");
2214
+ process.exit(1);
2215
+ }
2216
+ await recordCli(process.argv);
2217
+ return;
2218
+ }
2219
+
2058
2220
  write(ENTER_ALT); // switch to alternate screen — original terminal state preserved on exit
2059
2221
  write(CLEAR_SB); // FIXED-WINDOW (2026-06-27): clear scrollback so nothing is above the TUI
2060
2222
  write(HIDE);
@@ -2905,8 +3067,41 @@ export async function runTui({
2905
3067
  writeln();
2906
3068
  }
2907
3069
 
3070
+ // ── Exports for the headless audit system (tui-audit.mjs) ──────────────────
3071
+ // These expose the render functions + buffer state so the audit module can
3072
+ // render tabs in-process (fast) instead of spawning child processes.
3073
+ export {
3074
+ renderDashboard,
3075
+ renderTrends,
3076
+ renderCompare,
3077
+ renderBoard,
3078
+ renderConnect,
3079
+ renderWatch,
3080
+ renderWatchData,
3081
+ loadWatchData,
3082
+ loadDashboardData,
3083
+ loadCompareData,
3084
+ loadBoardData,
3085
+ startBuffer,
3086
+ stripAnsi,
3087
+ };
3088
+ // _screenBuf is a `let` — exporting it snapshots the value, not the binding.
3089
+ // Expose getter/setter so the audit module can read the live buffer after
3090
+ // startBuffer() + render() populate it, and reset it after capturing.
3091
+ export function _getScreenBuf() {
3092
+ return _screenBuf;
3093
+ }
3094
+ export function _resetBuf() {
3095
+ _screenBuf = null;
3096
+ _footerBuf = null;
3097
+ }
3098
+
2908
3099
  // Direct-run entry (e.g. `node tui.mjs --render 0`). Normal launch is via cli.mjs.
2909
- if (import.meta.url === `file://${process.argv[1]}`) {
3100
+ // pathToFileURL resolves relative argv[1] (e.g. `presentation/tui.mjs`) to the
3101
+ // same absolute file:// URL that import.meta.url always carries — without it,
3102
+ // `node presentation/tui.mjs --audit` never matched the guard and runTui never
3103
+ // ran (silent no-op). This is the standard Node ESM main-module detection.
3104
+ if (import.meta.url === pathToFileURL(process.argv[1]).href) {
2910
3105
  runTui().catch((e) => {
2911
3106
  console.error(e);
2912
3107
  process.exit(1);