chill-agent 1.5.0 → 1.6.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/.claude-plugin/contrast-mcp-launcher.mjs +31 -0
- package/.claude-plugin/plugin.json +8 -3
- package/.cline-plugin/plugin.js +6 -0
- package/.codex-plugin/plugin.json +4 -3
- package/.cursor-plugin/marketplace.json +16 -0
- package/.cursor-plugin/plugin.json +2 -2
- package/.devin-plugin/plugin.json +2 -2
- package/.kimi-plugin/plugin.json +11 -29
- package/.omp-plugin/marketplace.json +15 -0
- package/.openclaw/skills/ponytail/SKILL.md +2 -7
- package/.openclaw/skills/ponytail-audit/SKILL.md +3 -6
- package/.openclaw/skills/ponytail-debt/SKILL.md +3 -4
- package/.openclaw/skills/ponytail-gain/SKILL.md +3 -4
- package/.openclaw/skills/ponytail-help/SKILL.md +3 -3
- package/.openclaw/skills/ponytail-review/SKILL.md +3 -6
- package/.opencode/plugins/ponytail.mjs +1 -1
- package/.qoder-plugin/plugin.json +12 -26
- package/AGENTS.md +32 -19
- package/__init__.py +1 -1
- package/assets/antislop-banner.png +0 -0
- package/assets/band/band.png +0 -0
- package/assets/band/board.png +0 -0
- package/assets/benchmark-3model.svg +21 -0
- package/assets/benchmark-agentic.svg +62 -0
- package/assets/codex-icon.png +0 -0
- package/assets/compare/code/after.webp +0 -0
- package/assets/compare/code/before.webp +0 -0
- package/assets/compare/text/after.webp +0 -0
- package/assets/compare/text/before.webp +0 -0
- package/assets/compare/ui/w-all.webp +0 -0
- package/assets/compare/ui/w-antislop.webp +0 -0
- package/assets/compare/ui/w-design.webp +0 -0
- package/assets/compare/ui/without.webp +0 -0
- package/assets/logo-dark.png +0 -0
- package/assets/logo-dark.svg +115 -0
- package/assets/logo-greenpt-dark.svg +27 -0
- package/assets/logo-greenpt.svg +27 -0
- package/assets/logo.png +0 -0
- package/assets/photoreunion-logo-dark.svg +4 -0
- package/assets/photoreunion-logo-light.svg +4 -0
- package/assets/profile.png +0 -0
- package/assets/retriever-logo-dark.svg +4 -0
- package/assets/retriever-logo-light.svg +4 -0
- package/assets/stories-banner.png +0 -0
- package/assets/v5/audit-es.png +0 -0
- package/assets/v5/audit-ja.png +0 -0
- package/assets/v5/audit-ko.png +0 -0
- package/assets/v5/audit-zh-CN.png +0 -0
- package/assets/v5/audit.png +0 -0
- package/assets/v5/beforeafter-es.png +0 -0
- package/assets/v5/beforeafter-ja.png +0 -0
- package/assets/v5/beforeafter-ko.png +0 -0
- package/assets/v5/beforeafter-zh-CN.png +0 -0
- package/assets/v5/beforeafter.png +0 -0
- package/assets/v5/chart-es.png +0 -0
- package/assets/v5/chart-ja.png +0 -0
- package/assets/v5/chart-ko.png +0 -0
- package/assets/v5/chart-zh-CN.png +0 -0
- package/assets/v5/chart.png +0 -0
- package/assets/v5/hero-es.jpg +0 -0
- package/assets/v5/hero-ja.jpg +0 -0
- package/assets/v5/hero-ko.jpg +0 -0
- package/assets/v5/hero-zh-CN.jpg +0 -0
- package/assets/v5/hero.jpg +0 -0
- package/assets/v5/ladder-es.png +0 -0
- package/assets/v5/ladder-ja.png +0 -0
- package/assets/v5/ladder-ko.png +0 -0
- package/assets/v5/ladder-zh-CN.png +0 -0
- package/assets/v5/ladder.png +0 -0
- package/assets/v5/review-es.png +0 -0
- package/assets/v5/review-ja.png +0 -0
- package/assets/v5/review-ko.png +0 -0
- package/assets/v5/review-zh-CN.png +0 -0
- package/assets/v5/review.png +0 -0
- package/assets/v5/tests-es.png +0 -0
- package/assets/v5/tests-ja.png +0 -0
- package/assets/v5/tests-ko.png +0 -0
- package/assets/v5/tests-zh-CN.png +0 -0
- package/assets/v5/tests.png +0 -0
- package/assets/waitlist-banner-es.png +0 -0
- package/assets/waitlist-banner-ja.png +0 -0
- package/assets/waitlist-banner-ko.png +0 -0
- package/assets/waitlist-banner-zh-CN.png +0 -0
- package/assets/waitlist-banner.png +0 -0
- package/benchmarks/.env.example +2 -0
- package/benchmarks/agentic/README.md +201 -0
- package/benchmarks/agentic/answer.py +288 -0
- package/benchmarks/agentic/compare.py +93 -0
- package/benchmarks/agentic/complete.py +178 -0
- package/benchmarks/agentic/judge.py +198 -0
- package/benchmarks/agentic/run.py +543 -0
- package/benchmarks/agentic/tasks.py +1544 -0
- package/benchmarks/arms/baseline.js +2 -0
- package/benchmarks/arms/caveman-SKILL.md +67 -0
- package/benchmarks/arms/caveman.js +8 -0
- package/benchmarks/arms/ponytail.js +8 -0
- package/benchmarks/behavior.js +54 -0
- package/benchmarks/behavior.yaml +40 -0
- package/benchmarks/correctness.js +291 -0
- package/benchmarks/loc.js +17 -19
- package/benchmarks/promptfooconfig.gemini.yaml +32 -0
- package/benchmarks/promptfooconfig.gpt-newest.yaml +33 -0
- package/benchmarks/promptfooconfig.yaml +41 -0
- package/benchmarks/results/2026-06-12-caveman-vs-ponytail.md +71 -0
- package/benchmarks/results/2026-06-12-v4-hardening-vs-caveman.md +125 -0
- package/benchmarks/results/2026-06-15-llama3.2-local.md +82 -0
- package/benchmarks/results/2026-06-16-correctness-gate-fix.md +113 -0
- package/benchmarks/results/2026-06-16-robustness-audit.md +135 -0
- package/benchmarks/results/2026-06-17-agentic-safety.md +164 -0
- package/benchmarks/results/2026-06-17-cost-verification.md +93 -0
- package/benchmarks/results/2026-06-18-agentic.md +219 -0
- package/benchmarks/results/2026-06-22-issue-245-217-comprehension.md +98 -0
- package/benchmarks/results/2026-10-07-agentic.md +186 -0
- package/cli/index.mjs +241 -0
- package/cli/lib/banner.mjs +21 -0
- package/cli/lib/install.mjs +498 -0
- package/cli/lib/settings.mjs +48 -0
- package/cli/package-lock.json +87 -0
- package/cli/package.json +42 -0
- package/cli/scripts/smoke-test.mjs +95 -0
- package/cli/scripts/smoke-worker.mjs +411 -0
- package/cli/scripts/sync-skills.mjs +81 -0
- package/examples/email-validation.md +156 -0
- package/examples/group-by.md +35 -0
- package/examples/infinite-scroll.md +58 -0
- package/examples/number-formatting.md +37 -0
- package/examples/rate-limit.md +272 -0
- package/examples/react-countdown.md +390 -0
- package/examples/url-params.md +41 -0
- package/gemini-extension.json +2 -2
- package/hooks/band/board.ts +560 -0
- package/hooks/band/logo.ts +3 -0
- package/hooks/band/metrics.ts +311 -0
- package/hooks/band/register.tsx +495 -0
- package/hooks/band/types.d.ts +34 -0
- package/hooks/claude-band.json +6 -0
- package/hooks/claude-codex-hooks.json +34 -4
- package/hooks/copilot-hooks.json +12 -2
- package/hooks/cursor-hooks.json +2 -2
- package/hooks/ponytail-band-import.js +274 -0
- package/hooks/ponytail-config.js +175 -0
- package/hooks/ponytail-map.js +111 -0
- package/hooks/ponytail-subagent.js +94 -0
- package/hooks/qoder-hooks.json +19 -4
- package/i18n/README.ko.md +230 -0
- package/package.json +12 -7
- package/pi-extension/test/extension.test.js +367 -0
- package/pi-extension/test/helpers.test.js +179 -0
- package/plugin.yaml +21 -12
- package/rules/antislop.mdc +18 -0
- package/scripts/build-openclaw-skills.js +60 -0
- package/scripts/check-repo.mjs +389 -0
- package/scripts/publish-openclaw-skills.js +75 -0
- package/scripts/skill-index.mjs +81 -0
- package/skills/antislop/SKILL.md +700 -0
- package/skills/antislop/VERSION +1 -0
- package/skills/antislop-code/SKILL.md +128 -0
- package/skills/antislop-copywriting/SKILL.md +407 -0
- package/skills/antislop-human/LICENSE-web-quality-skills +21 -0
- package/skills/antislop-human/SKILL.md +204 -0
- package/skills/antislop-human/contrast-check.py +168 -0
- package/skills/antislop-human/contrast-mcp.py +121 -0
- package/skills/antislop-human/references/A11Y-PATTERNS.md +249 -0
- package/skills/antislop-human/references/WCAG.md +127 -0
- package/skills/antislop-layoutmobile/SKILL.md +170 -0
- package/skills/antislop-ui/LICENSE-web-quality-skills +21 -0
- package/skills/antislop-ui/SKILL.md +344 -0
- package/skills/antislop-ui/references/CORE-WEB-VITALS.md +181 -0
- package/skills/antislop-ui/references/WEB-BASELINE.md +28 -0
- package/skills/plugin.json +5 -0
- package/src/package.json +3 -0
- package/tests/activation-map.test.js +63 -0
- package/tests/band-import.test.js +111 -0
- package/tests/band.test.tsx +520 -0
- package/tests/behavior.test.js +80 -0
- package/tests/gemini-extension.test.js +2 -2
- package/tests/grok-plugin.test.js +1 -1
- package/tests/hermes-plugin.test.js +1 -1
- package/tests/hooks-windows.test.js +205 -0
- package/tests/kimi-plugin.test.js +1 -1
- package/tests/map.test.js +30 -0
- package/tests/package-scripts.test.js +35 -0
- package/tests/package.test.js +23 -0
- package/tests/uninstall.test.js +202 -0
- /package/bin/{chill-agent.js → chill-agent.mjs} +0 -0
- /package/tests/{manifests.test.js → manifests.test.mjs} +0 -0
- /package/tests/{rules.test.js → rules.test.mjs} +0 -0
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
// The task board: one self-contained HTML page built from the band's CSV ledgers.
|
|
2
|
+
// No server and no network: inline CSS, inline SVG and a few lines of script.
|
|
3
|
+
|
|
4
|
+
import { LOGO_PATHS, LOGO_VIEWBOX } from './logo'
|
|
5
|
+
|
|
6
|
+
export const duration = (minutes: number) =>
|
|
7
|
+
minutes < 60 ? `${minutes}m` : `${Math.floor(minutes / 60)}h ${minutes % 60}m`
|
|
8
|
+
|
|
9
|
+
// Agent time reads better with seconds while it is short.
|
|
10
|
+
export const elapsed = (ms: number) => {
|
|
11
|
+
const s = Math.round(ms / 1000)
|
|
12
|
+
if (s < 60) return `${s}s`
|
|
13
|
+
if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`
|
|
14
|
+
return duration(Math.round(s / 60))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const fmtTokens = (n: number) =>
|
|
18
|
+
n < 1000 ? `${n}` : n < 1e6 ? `${+(n / 1e3).toFixed(1)}k` : `${+(n / 1e6).toFixed(1)}M`
|
|
19
|
+
|
|
20
|
+
export type Task = {
|
|
21
|
+
started: string
|
|
22
|
+
project: string
|
|
23
|
+
name: string
|
|
24
|
+
cost: number
|
|
25
|
+
turns: number
|
|
26
|
+
agentMinutes: number
|
|
27
|
+
tokens: number
|
|
28
|
+
reuse: number | null
|
|
29
|
+
tests: string
|
|
30
|
+
level: string
|
|
31
|
+
model: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// RFC 4180 rows: quoted fields may hold commas, doubled quotes and line breaks.
|
|
35
|
+
export function parseCsv(text: string): string[][] {
|
|
36
|
+
const rows: string[][] = []
|
|
37
|
+
let row: string[] = []
|
|
38
|
+
let field = ''
|
|
39
|
+
let quoted = false
|
|
40
|
+
for (let i = 0; i < text.length; i++) {
|
|
41
|
+
const c = text[i]
|
|
42
|
+
if (quoted) {
|
|
43
|
+
if (c === '"' && text[i + 1] === '"') {
|
|
44
|
+
field += '"'
|
|
45
|
+
i++
|
|
46
|
+
} else if (c === '"') quoted = false
|
|
47
|
+
else field += c
|
|
48
|
+
} else if (c === '"') quoted = true
|
|
49
|
+
else if (c === ',') {
|
|
50
|
+
row.push(field)
|
|
51
|
+
field = ''
|
|
52
|
+
} else if (c === '\n' || c === '\r') {
|
|
53
|
+
if (c === '\r' && text[i + 1] === '\n') i++
|
|
54
|
+
row.push(field)
|
|
55
|
+
if (row.some(f => f !== '')) rows.push(row)
|
|
56
|
+
row = []
|
|
57
|
+
field = ''
|
|
58
|
+
} else field += c
|
|
59
|
+
}
|
|
60
|
+
row.push(field)
|
|
61
|
+
if (row.some(f => f !== '')) rows.push(row)
|
|
62
|
+
return rows
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function tasksFrom(text: string): Task[] {
|
|
66
|
+
const [head, ...rows] = parseCsv(text)
|
|
67
|
+
if (!head?.includes('key')) return []
|
|
68
|
+
const at = (row: string[], col: string) => row[head.indexOf(col)] ?? ''
|
|
69
|
+
const num = (row: string[], col: string) => Number(at(row, col)) || 0
|
|
70
|
+
return rows.map(row => ({
|
|
71
|
+
started: at(row, 'started'),
|
|
72
|
+
project: at(row, 'project'),
|
|
73
|
+
name: at(row, 'name'),
|
|
74
|
+
cost: num(row, 'cost_usd'),
|
|
75
|
+
turns: num(row, 'turns'),
|
|
76
|
+
agentMinutes: num(row, 'agent_minutes'),
|
|
77
|
+
tokens: num(row, 'tokens_total'),
|
|
78
|
+
reuse: at(row, 'reuse_pct') === '' ? null : num(row, 'reuse_pct'),
|
|
79
|
+
tests: at(row, 'tests'),
|
|
80
|
+
level: at(row, 'level'),
|
|
81
|
+
model: at(row, 'model'),
|
|
82
|
+
}))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
86
|
+
const day = (ms: number) => {
|
|
87
|
+
const d = new Date(ms)
|
|
88
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Project colours, in sidebar order; the share card carries a gradient for each.
|
|
92
|
+
const PALETTE = ['#5fe08a', '#7c8cff', '#2fd4c0', '#ff8a3d', '#ff6fb5', '#ffd166', '#4cc3ff', '#b26bff']
|
|
93
|
+
|
|
94
|
+
const LIGHT = 'color-scheme:light;--bg:#f5f5f3;--panel:#fff;--card:#fff;--line:#ebeae6;--line2:#dedcd6;--text:#1d1c1a;--muted:#77756f;--faint:#a9a7a0;--track:#f2f1ed;--segOn:#fff;--tip:#1d1c1a;--tipText:#fff;--ok:#2f7d3b;--okbg:#e8f5e9;--bad:#b4401b;--badbg:#fcebe4;--warn:#946200;--warnbg:#fbf2d7;--nonebg:#f2f1ed;--empty:#eeede9;--tint:.12;--glow:.45;--shadow:0 1px 2px rgba(25,25,15,.05)'
|
|
95
|
+
const MONO_CSS = '"JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,monospace'
|
|
96
|
+
|
|
97
|
+
// Dark is the default; light (chosen, or the system's when "system" is chosen) keeps the same layout.
|
|
98
|
+
const CSS = `:root{color-scheme:dark;--bg:#060606;--panel:#0c0c0c;--card:linear-gradient(180deg,#0f0f0f,#090909);--line:#1c1c1b;--line2:#262624;--text:#f3f2ee;--muted:#8d8b84;--faint:#5e5c55;--track:#141413;--segOn:#1f1f1d;--tip:#050505;--tipText:#f3f2ee;--ok:#5fe08a;--okbg:#122016;--bad:#ff6b5b;--badbg:#2a1210;--warn:#f1c96b;--warnbg:#2a2412;--nonebg:#161615;--empty:#151514;--tint:.2;--glow:1;--shadow:inset 0 1px 0 rgba(255,255,255,.035),0 20px 50px rgba(0,0,0,.35)}
|
|
99
|
+
:root[data-theme=light]{${LIGHT}}
|
|
100
|
+
@media (prefers-color-scheme:light){:root[data-theme=system]{${LIGHT}}}
|
|
101
|
+
*{box-sizing:border-box}html,body{margin:0}body{background:var(--bg);color:var(--text);font:14px/1.5 Inter,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;-webkit-font-smoothing:antialiased}
|
|
102
|
+
.num{font-family:${MONO_CSS};font-variant-numeric:tabular-nums;letter-spacing:-.03em}
|
|
103
|
+
.sprite{position:absolute;width:0;height:0;overflow:hidden}
|
|
104
|
+
.app{display:grid;grid-template-columns:264px minmax(0,1fr);min-height:100vh}
|
|
105
|
+
.side{border-right:1px solid var(--line);padding:24px 16px;display:flex;flex-direction:column;gap:2px;position:sticky;top:0;height:100vh}
|
|
106
|
+
.brand{display:flex;align-items:center;gap:10px;font:600 15px/1 ${MONO_CSS};padding:0 10px 24px}
|
|
107
|
+
.logo{width:28px;height:32px;flex:none}.logo svg{width:100%;height:100%;display:block}
|
|
108
|
+
.side-label{font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--faint);padding:6px 10px}
|
|
109
|
+
.nav-item{display:flex;align-items:center;gap:10px;width:100%;border:0;background:transparent;color:var(--muted);font:inherit;padding:8px 10px;border-radius:10px;cursor:pointer;text-align:left}
|
|
110
|
+
.nav-item .name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
111
|
+
.nav-item:hover,.nav-item.on{background:var(--track);color:var(--text)}
|
|
112
|
+
.nav-item .n{font:12px ${MONO_CSS};color:var(--faint)}
|
|
113
|
+
.dot{width:8px;height:8px;border-radius:50%;flex:none;background:var(--c);box-shadow:0 0 10px var(--c)}
|
|
114
|
+
.side-foot{margin-top:auto;display:flex;align-items:center;gap:10px;font-size:12px;color:var(--faint);padding:12px 2px 0 10px;border-top:1px solid var(--line)}.foot-text{flex:1;min-width:0}.foot-sep{width:1px;align-self:stretch;margin:4px 0;background:var(--line)}
|
|
115
|
+
.theme{display:grid;place-items:center;width:34px;height:34px;flex:none;border:0;border-radius:9px;background:transparent;color:var(--muted);cursor:pointer}.theme:hover{background:var(--track);color:var(--text)}.theme svg{display:none}.theme[data-choice=system] .i-system,.theme[data-choice=light] .i-light,.theme[data-choice=dark] .i-dark{display:block}
|
|
116
|
+
main{padding:26px 32px 56px;min-width:0;max-width:1400px;width:100%;margin:0 auto}
|
|
117
|
+
.top{display:flex;justify-content:space-between;align-items:flex-end;gap:16px;margin-bottom:22px;flex-wrap:wrap}
|
|
118
|
+
.crumb{color:var(--faint);font-size:13px}.crumb b{color:var(--muted);font-weight:500}
|
|
119
|
+
h1{margin:2px 0 0;font-size:26px;font-weight:650;letter-spacing:-.02em}.top p{margin:2px 0 0;color:var(--muted)}
|
|
120
|
+
.actions{display:flex;gap:10px;align-items:center}
|
|
121
|
+
.seg{display:inline-flex;background:var(--track);border:1px solid var(--line);border-radius:12px;padding:3px;flex:none}
|
|
122
|
+
.seg button{border:0;background:transparent;color:var(--muted);font:inherit;font-size:13px;padding:6px 12px;border-radius:9px;cursor:pointer}
|
|
123
|
+
.seg button.on{background:var(--segOn);color:var(--text);box-shadow:inset 0 1px 0 rgba(255,255,255,.06),0 0 0 1px var(--line2)}
|
|
124
|
+
.btn{border:1px solid var(--line2);background:var(--track);color:var(--text);font:inherit;font-size:13px;padding:8px 14px;border-radius:11px;cursor:pointer}.btn:hover{border-color:var(--faint)}
|
|
125
|
+
.card{background:var(--card);border:1px solid var(--line);border-radius:22px;box-shadow:var(--shadow)}
|
|
126
|
+
.kpis{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;margin-bottom:14px}
|
|
127
|
+
.kpi{padding:18px 20px 0;position:relative;overflow:hidden}
|
|
128
|
+
.kpi::after{content:"";position:absolute;left:0;right:0;bottom:0;height:75%;background:radial-gradient(120% 100% at 50% 135%,var(--k),transparent 70%);opacity:var(--tint);pointer-events:none}
|
|
129
|
+
.kpi-label{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:13px}
|
|
130
|
+
.kpi-label::before{content:"";width:7px;height:7px;border-radius:50%;background:var(--k);box-shadow:0 0 10px var(--k)}
|
|
131
|
+
.kpi-value{font-size:30px;margin-top:4px;white-space:nowrap}
|
|
132
|
+
.delta{font:12px ${MONO_CSS};margin-left:8px;vertical-align:6px}.up{color:var(--ok)}.down{color:var(--bad)}
|
|
133
|
+
.kpi-note{color:var(--faint);font-size:12px}
|
|
134
|
+
.spark{display:block;width:calc(100% + 40px);height:58px;margin:10px -20px 0;position:relative;z-index:1}
|
|
135
|
+
.glow-line{opacity:calc(var(--glow) * .8)}
|
|
136
|
+
.chart-card{padding:22px 24px 18px;margin-bottom:14px}
|
|
137
|
+
.chart-head{display:flex;justify-content:space-between;align-items:flex-start;gap:12px;flex-wrap:wrap}
|
|
138
|
+
.label{color:var(--muted);font-size:13px}.big{font-size:40px;line-height:1.15;margin-top:2px}
|
|
139
|
+
.chart-wrap{position:relative;margin-top:6px}
|
|
140
|
+
#stream{display:block;width:100%;height:340px;overflow:visible}
|
|
141
|
+
.axis{fill:var(--faint);font:11px ${MONO_CSS}}.base{stroke:var(--line2)}.empty-chart{fill:var(--faint);font-size:13px}
|
|
142
|
+
.glow{opacity:calc(var(--glow) * .14)}.top-line{stroke:var(--text);stroke-opacity:.35;fill:none}
|
|
143
|
+
.hover-line{stroke:var(--text);stroke-opacity:.5}
|
|
144
|
+
.tip{position:absolute;pointer-events:none;background:var(--tip);color:var(--tipText);border:1px solid var(--line2);border-radius:12px;padding:7px 11px;font-size:12px;transform:translate(-50%,calc(-100% - 12px));white-space:nowrap;box-shadow:0 10px 30px rgba(0,0,0,.35)}
|
|
145
|
+
.tip-date{opacity:.65}.tip-value{font:600 13px ${MONO_CSS}}
|
|
146
|
+
.legend{display:flex;gap:8px;flex-wrap:wrap;margin-top:8px}
|
|
147
|
+
.chip{display:inline-flex;align-items:center;gap:8px;padding:6px 10px;border-radius:10px;background:var(--track);border:1px solid var(--line);color:var(--text);font:inherit;font-size:13px;cursor:pointer}
|
|
148
|
+
.chip .x{color:var(--faint)}.chip[aria-pressed=false]{opacity:.45}
|
|
149
|
+
.grid2{display:grid;grid-template-columns:minmax(0,1.4fr) minmax(0,1fr);gap:14px;align-items:start}
|
|
150
|
+
.list{padding:20px 0 6px}
|
|
151
|
+
.list-head{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:0 22px 14px}
|
|
152
|
+
h3{margin:0;font-size:15px;font-weight:600}h3 .n{color:var(--faint);font-weight:400;margin-left:4px}
|
|
153
|
+
.search{display:flex;align-items:center;gap:8px;background:var(--track);border:1px solid var(--line);border-radius:10px;padding:6px 10px;width:220px;color:var(--faint)}
|
|
154
|
+
.search input{border:0;outline:0;background:transparent;color:var(--text);font:inherit;font-size:13px;width:100%}
|
|
155
|
+
.table-wrap{overflow-x:auto}
|
|
156
|
+
table{width:100%;border-collapse:collapse;font-size:13px}
|
|
157
|
+
th{text-align:left;font-weight:400;color:var(--faint);font-size:12px;padding:0 10px 10px;white-space:nowrap}
|
|
158
|
+
th:first-child,td:first-child{padding-left:22px}th:last-child,td:last-child{padding-right:22px}
|
|
159
|
+
td{padding:11px 10px;border-top:1px solid var(--line);white-space:nowrap;vertical-align:middle}
|
|
160
|
+
th.r,td.r{text-align:right}
|
|
161
|
+
td.name{white-space:normal;min-width:140px;width:100%;max-width:0}.task{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sub{font-size:12px;color:var(--faint)}
|
|
162
|
+
.bar{height:3px;border-radius:2px;margin-top:6px;background:linear-gradient(90deg,var(--c),transparent)}
|
|
163
|
+
.pill{display:inline-flex;align-items:center;gap:6px;font-size:12px;padding:3px 9px;border-radius:999px;background:var(--nonebg);color:var(--muted)}
|
|
164
|
+
.pill i{width:6px;height:6px;border-radius:50%;background:currentColor}
|
|
165
|
+
.pill.ok{background:var(--okbg);color:var(--ok)}.pill.bad{background:var(--badbg);color:var(--bad)}.pill.warn{background:var(--warnbg);color:var(--warn)}
|
|
166
|
+
td.empty{text-align:center;color:var(--faint);padding:32px}
|
|
167
|
+
.heat{padding:20px 22px}.heat .sub{margin:2px 0 14px}
|
|
168
|
+
#heat{display:block;width:100%;height:auto}.cell-empty{fill:var(--empty)}.glow-cell{opacity:calc(var(--glow) * .45)}.day{fill:var(--faint);font:12px ${MONO_CSS}}
|
|
169
|
+
.heroes{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:20px}.hero-label{display:flex;align-items:center;gap:7px;color:var(--muted);font-size:12px}.hero-label i{font-style:normal;font-size:11px}.hero-value{font-size:30px;margin-top:4px;white-space:nowrap}.hero-sub{font-size:12px;color:var(--faint);margin-top:2px}.hero-sub b{color:var(--ok);font-weight:500;margin-right:6px}
|
|
170
|
+
#facts{margin-top:16px}.fact{display:flex;justify-content:space-between;gap:12px;padding:11px 0;border-top:1px solid var(--line);color:var(--muted)}.fact .num{color:var(--text)}
|
|
171
|
+
footer{color:var(--faint);font-size:12px;margin-top:22px}
|
|
172
|
+
dialog{border:1px solid var(--line);border-radius:18px;padding:0;background:var(--panel);color:var(--text);box-shadow:0 24px 70px rgba(0,0,0,.4);width:min(840px,92vw)}
|
|
173
|
+
dialog::backdrop{background:rgba(0,0,0,.55)}
|
|
174
|
+
.dlg-head{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:14px 18px;border-bottom:1px solid var(--line)}
|
|
175
|
+
.dlg-actions{display:flex;gap:8px}.dlg-body{padding:18px}.dlg-body svg{width:100%;height:auto;display:block;border-radius:12px}
|
|
176
|
+
@media (max-width:1360px){.kpis{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
|
177
|
+
@media (max-width:1100px){.grid2{grid-template-columns:minmax(0,1fr)}}
|
|
178
|
+
@media (max-width:860px){.app{grid-template-columns:minmax(0,1fr)}.side{position:static;height:auto;flex-direction:row;flex-wrap:wrap;align-items:center;border-right:0;border-bottom:1px solid var(--line)}
|
|
179
|
+
.side-label,.foot-text,.foot-sep{display:none}.side-foot{margin:0 0 0 auto;padding:0;border:0}.brand{padding:2px 10px}#projects{display:flex;gap:4px;flex-wrap:wrap}.nav-item{width:auto}
|
|
180
|
+
main{padding:16px}.search{width:160px}.big{font-size:32px}}
|
|
181
|
+
@media (max-width:520px){.kpis{grid-template-columns:minmax(0,1fr)}.top .actions{width:100%;justify-content:space-between}.list-head{flex-wrap:wrap}.search{width:100%}}`
|
|
182
|
+
|
|
183
|
+
// The page draws itself from the JSON block: project picker, period, charts and table need no reload.
|
|
184
|
+
// Names only ever reach the page as text (textContent), never as HTML.
|
|
185
|
+
const APP = `(function () {
|
|
186
|
+
var D = JSON.parse(document.getElementById('board-data').textContent)
|
|
187
|
+
var tasks = D.tasks, now = D.now, DAY = 86400000, NS = 'http://www.w3.org/2000/svg'
|
|
188
|
+
var state = { project: '', metric: 'time', q: '', period: 30, hidden: {} }
|
|
189
|
+
var MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
|
|
190
|
+
var PALETTE = ${JSON.stringify(PALETTE)}
|
|
191
|
+
// Each KPI its own colour pair, apart from the project colours: the line runs from one to the other.
|
|
192
|
+
var KPI = [['sessions', '#4cc3ff', '#7c8cff'], ['time', '#2fd4c0', '#5fe08a'], ['cost', '#ffd166', '#ff8a3d'], ['tokens', '#ff6fb5', '#b26bff']]
|
|
193
|
+
function byId(id) { return document.getElementById(id) }
|
|
194
|
+
function h(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e }
|
|
195
|
+
function svg(tag, attrs, parent) { var e = document.createElementNS(NS, tag); for (var k in attrs) e.setAttribute(k, attrs[k]); if (parent) parent.append(e); return e }
|
|
196
|
+
function two(n) { return (n < 10 ? '0' : '') + n }
|
|
197
|
+
function dayKey(ms) { var d = new Date(ms); return d.getFullYear() + '-' + two(d.getMonth() + 1) + '-' + two(d.getDate()) }
|
|
198
|
+
// claude-opus-5-5 reads as Opus 5.5; a trailing date in the id is dropped.
|
|
199
|
+
function modelName(m) { var p = m.replace(/^claude-/, '').split('-'); return p[0] ? p[0][0].toUpperCase() + p[0].slice(1) + (p.length > 1 ? '\\u00a0' + p.slice(1).filter(function (x) { return x.length < 3 }).join('.') : '') : '' }
|
|
200
|
+
function shortDate(key) { return MONTHS[+key.slice(5, 7) - 1] + ' ' + +key.slice(8, 10) }
|
|
201
|
+
function dur(min) { return min < 60 ? min + 'm' : Math.floor(min / 60) + 'h ' + (min % 60) + 'm' }
|
|
202
|
+
function time(ms) { var s = Math.round(ms / 1000); return s < 60 ? s + 's' : s < 3600 ? Math.floor(s / 60) + 'm ' + (s % 60) + 's' : dur(Math.round(s / 60)) }
|
|
203
|
+
function tok(n) { return n < 1000 ? String(Math.round(n)) : n < 1e6 ? +(n / 1e3).toFixed(1) + 'k' : +(n / 1e6).toFixed(1) + 'M' }
|
|
204
|
+
function money(n) { return '$' + n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }
|
|
205
|
+
function plural(n, word) { return n + ' ' + word + (n === 1 ? '' : 's') }
|
|
206
|
+
function add(a, b) { return a + b }
|
|
207
|
+
var METRICS = {
|
|
208
|
+
sessions: { label: 'Sessions', pick: function () { return 1 }, fmt: String },
|
|
209
|
+
time: { label: 'Agent time', pick: function (t) { return t.agentMinutes * 60000 }, fmt: time },
|
|
210
|
+
cost: { label: 'Cost', pick: function (t) { return t.cost }, fmt: money },
|
|
211
|
+
tokens: { label: 'Tokens', pick: function (t) { return t.tokens }, fmt: tok }
|
|
212
|
+
}
|
|
213
|
+
function total(list, pick) { return list.map(pick).reduce(add, 0) }
|
|
214
|
+
function latest(name) { return tasks.filter(function (t) { return t.project === name }).reduce(function (m, t) { return t.started > m ? t.started : m }, '') }
|
|
215
|
+
var names = Array.from(new Set(tasks.map(function (t) { return t.project }))).sort(function (a, b) { return latest(b).localeCompare(latest(a)) })
|
|
216
|
+
function colorOf(name) { return PALETTE[names.indexOf(name) % PALETTE.length] }
|
|
217
|
+
function scope() { return state.project ? tasks.filter(function (t) { return t.project === state.project }) : tasks }
|
|
218
|
+
function first(list) { return list.reduce(function (m, t) { return !m || t.started < m ? t.started : m }, '') }
|
|
219
|
+
// Days the chosen period covers; "All" reaches back to the first session on screen.
|
|
220
|
+
function span() {
|
|
221
|
+
if (state.period) return state.period
|
|
222
|
+
var f = first(scope())
|
|
223
|
+
return f ? Math.max(7, Math.round((now - new Date(f.slice(0, 10) + 'T00:00').getTime()) / DAY) + 1) : 30
|
|
224
|
+
}
|
|
225
|
+
function days(n) { var out = []; for (var i = n - 1; i >= 0; i--) out.push(dayKey(now - i * DAY)); return out }
|
|
226
|
+
function inDays(list, keys) { return list.filter(function (t) { return keys.indexOf(t.started.slice(0, 10)) >= 0 }) }
|
|
227
|
+
function perDay(list, keys, pick) { return keys.map(function (k) { return total(list.filter(function (t) { return t.started.slice(0, 10) === k }), pick) }) }
|
|
228
|
+
function avg(values, r) { return values.map(function (_, i) { var a = values.slice(Math.max(0, i - r), i + r + 1); return a.reduce(add, 0) / a.length }) }
|
|
229
|
+
// Monotone cubic through the points (as d3's curveMonotoneX): flat at peaks and valleys, never past a point.
|
|
230
|
+
function smooth(p) {
|
|
231
|
+
var n = p.length, m = [], d = 'M' + p[0][0].toFixed(1) + ',' + p[0][1].toFixed(1)
|
|
232
|
+
function slope(i) { return (p[i + 1][1] - p[i][1]) / (p[i + 1][0] - p[i][0]) }
|
|
233
|
+
if (n < 3) return n < 2 ? d : d + 'L' + p[1][0].toFixed(1) + ',' + p[1][1].toFixed(1)
|
|
234
|
+
for (var i = 1; i < n - 1; i++) {
|
|
235
|
+
var s0 = slope(i - 1), s1 = slope(i)
|
|
236
|
+
m[i] = (Math.sign(s0) + Math.sign(s1)) * Math.min(Math.abs(s0), Math.abs(s1), Math.abs(s0 + s1) / 4) || 0
|
|
237
|
+
}
|
|
238
|
+
m[0] = (3 * slope(0) - m[1]) / 2; m[n - 1] = (3 * slope(n - 2) - m[n - 2]) / 2
|
|
239
|
+
for (i = 0; i < n - 1; i++) {
|
|
240
|
+
var dx = (p[i + 1][0] - p[i][0]) / 3
|
|
241
|
+
d += 'C' + (p[i][0] + dx).toFixed(1) + ',' + (p[i][1] + m[i] * dx).toFixed(1) + ' ' + (p[i + 1][0] - dx).toFixed(1) + ',' + (p[i + 1][1] - m[i + 1] * dx).toFixed(1) + ' ' + p[i + 1][0].toFixed(1) + ',' + p[i + 1][1].toFixed(1)
|
|
242
|
+
}
|
|
243
|
+
return d
|
|
244
|
+
}
|
|
245
|
+
function gradient(parent, id, x2, y2, stops) {
|
|
246
|
+
var g = svg('linearGradient', { id: id, gradientUnits: 'userSpaceOnUse', x1: 0, y1: 0, x2: x2, y2: y2 }, parent)
|
|
247
|
+
stops.forEach(function (s) { svg('stop', { offset: s[0], 'stop-color': s[1], 'stop-opacity': s[2] }, g) })
|
|
248
|
+
}
|
|
249
|
+
function blur(parent, id, sd) { svg('feGaussianBlur', { stdDeviation: sd }, svg('filter', { id: id, x: '-20%', y: '-50%', width: '140%', height: '200%' }, parent)) }
|
|
250
|
+
|
|
251
|
+
function sidebar() {
|
|
252
|
+
var nav = byId('projects'); nav.textContent = ''
|
|
253
|
+
;[['', 'All projects', tasks.length]].concat(names.map(function (n) { return [n, n, tasks.filter(function (t) { return t.project === n }).length] })).forEach(function (p) {
|
|
254
|
+
var b = h('button', 'nav-item' + (state.project === p[0] ? ' on' : '')); b.type = 'button'; b.title = p[1]
|
|
255
|
+
if (p[0]) { var d = h('span', 'dot'); d.style.setProperty('--c', colorOf(p[0])); b.append(d) }
|
|
256
|
+
b.append(h('span', 'name', p[1]), h('span', 'n', String(p[2])))
|
|
257
|
+
b.addEventListener('click', function () { state.project = p[0]; state.hidden = {}; render() })
|
|
258
|
+
nav.append(b)
|
|
259
|
+
})
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function header() {
|
|
263
|
+
var list = scope(), name = state.project || 'All projects', f = first(list)
|
|
264
|
+
byId('crumb').textContent = name
|
|
265
|
+
byId('title').textContent = name
|
|
266
|
+
byId('subtitle').textContent = plural(list.length, 'session') + (f ? ' since ' + shortDate(f.slice(0, 10)) + ', ' + f.slice(0, 4) : '')
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function sparkline(values, i, k) {
|
|
270
|
+
var W = 300, H = 58, s = svg('svg', { viewBox: '0 0 300 58', preserveAspectRatio: 'none', 'class': 'spark', 'aria-hidden': 'true' })
|
|
271
|
+
var vals = values.length > 14 ? avg(values, 2) : values, max = Math.max.apply(null, vals.concat([0]))
|
|
272
|
+
if (max <= 0) return s
|
|
273
|
+
var defs = svg('defs', {}, s)
|
|
274
|
+
gradient(defs, 'kl' + i, W, 0, [[0, k[1], 1], [1, k[2], 1]])
|
|
275
|
+
gradient(defs, 'kf' + i, 0, H, [[0, k[2], .38], [1, k[1], 0]])
|
|
276
|
+
blur(defs, 'kg' + i, 5)
|
|
277
|
+
var line = smooth(vals.map(function (v, j) { return [j * W / Math.max(1, vals.length - 1), H - 6 - v / max * (H - 16)] }))
|
|
278
|
+
svg('path', { d: line + 'L' + W + ',' + H + 'L0,' + H + 'Z', fill: 'url(#kf' + i + ')' }, s)
|
|
279
|
+
svg('path', { d: line, fill: 'none', stroke: 'url(#kl' + i + ')', 'stroke-width': 2, filter: 'url(#kg' + i + ')', 'class': 'glow-line', 'vector-effect': 'non-scaling-stroke' }, s)
|
|
280
|
+
svg('path', { d: line, fill: 'none', stroke: 'url(#kl' + i + ')', 'stroke-width': 1.8, 'vector-effect': 'non-scaling-stroke' }, s)
|
|
281
|
+
return s
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function kpis() {
|
|
285
|
+
var list = scope(), n = span(), keys = days(n), wrap = byId('kpis'); wrap.textContent = ''
|
|
286
|
+
var cur = inDays(list, keys), prev = state.period ? inDays(list, days(2 * n).slice(0, n)) : []
|
|
287
|
+
KPI.forEach(function (k, i) {
|
|
288
|
+
var M = METRICS[k[0]], v = total(cur, M.pick), p = total(prev, M.pick)
|
|
289
|
+
var card = h('div', 'card kpi'), value = h('div', 'kpi-value num', M.fmt(v)); card.style.setProperty('--k', k[2])
|
|
290
|
+
if (p > 0) { var pct = Math.round((v - p) / p * 100); value.append(h('span', 'delta ' + (pct >= 0 ? 'up' : 'down'), (pct >= 0 ? '↑' : '↓') + Math.abs(pct) + '%')) }
|
|
291
|
+
var note = !state.period ? 'all time' : p > 0 ? 'vs previous ' + n + ' days' : 'last ' + n + ' days'
|
|
292
|
+
card.append(h('div', 'kpi-label', M.label), value, h('div', 'kpi-note', note), sparkline(perDay(list, keys, M.pick), i, k))
|
|
293
|
+
wrap.append(card)
|
|
294
|
+
})
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function chart() {
|
|
298
|
+
var list = scope(), n = span(), keys = days(n), M = METRICS[state.metric], box = byId('stream'), tip = byId('tip'); tip.hidden = true
|
|
299
|
+
var W = Math.max(320, Math.round(box.getBoundingClientRect().width) || 900), H = 340, top = 66, base = 282
|
|
300
|
+
box.setAttribute('viewBox', '0 0 ' + W + ' ' + H); box.textContent = ''
|
|
301
|
+
var defs = svg('defs', {}, box); blur(defs, 'sglow', 10)
|
|
302
|
+
var shown = names.filter(function (p) { return (!state.project || p === state.project) && list.some(function (t) { return t.project === p }) })
|
|
303
|
+
var projects = shown.filter(function (p) { return !state.hidden[p] })
|
|
304
|
+
var cur = inDays(list, keys).filter(function (t) { return !state.hidden[t.project] })
|
|
305
|
+
byId('chart-sub').textContent = M.label + (state.period ? ', last ' + n + ' days' : ', all time')
|
|
306
|
+
byId('chart-total').textContent = M.fmt(total(cur, M.pick))
|
|
307
|
+
legend(shown)
|
|
308
|
+
var x = function (i) { return n > 1 ? i * W / (n - 1) : W / 2 }
|
|
309
|
+
svg('line', { x1: 0, x2: W, y1: base + 22, y2: base + 22, 'class': 'base' }, box)
|
|
310
|
+
for (var w = 0, step = Math.max(1, Math.ceil(n / 6)); w < n; w += step) svg('text', { x: x(w), y: H - 6, 'class': 'axis', 'text-anchor': w ? 'middle' : 'start' }, box).textContent = shortDate(keys[w])
|
|
311
|
+
var raw = projects.map(function (p) { return perDay(cur.filter(function (t) { return t.project === p }), keys, M.pick) })
|
|
312
|
+
// Real day values: the curve passes through them, nothing is spread to neighbouring days.
|
|
313
|
+
var layers = raw
|
|
314
|
+
var totals = keys.map(function (_, i) { return layers.reduce(function (a, r) { return a + r[i] }, 0) })
|
|
315
|
+
var max = Math.max.apply(null, totals.concat([0]))
|
|
316
|
+
if (max <= 0) { svg('text', { x: W / 2, y: (top + base) / 2, 'text-anchor': 'middle', 'class': 'empty-chart' }, box).textContent = 'No sessions in this period'; return }
|
|
317
|
+
var y = function (v) { return base - v / max * (base - top) }, acc = keys.map(function () { return 0 }), shapes = []
|
|
318
|
+
layers.forEach(function (r, li) {
|
|
319
|
+
var lower = acc, upper = acc.map(function (a, i) { return a + r[i] }), c = colorOf(projects[li]); acc = upper
|
|
320
|
+
gradient(defs, 'sf' + li, 0, base, [[0, c, .95], [1, c, .55]])
|
|
321
|
+
var up = smooth(upper.map(function (v, i) { return [x(i), y(v)] })), low = smooth(lower.map(function (v, i) { return [x(i), y(v)] }).reverse())
|
|
322
|
+
shapes.push({ d: up + 'L' + low.slice(1) + 'Z', fill: 'url(#sf' + li + ')', top: up })
|
|
323
|
+
})
|
|
324
|
+
var last = shapes[shapes.length - 1]
|
|
325
|
+
svg('path', { d: last.top + 'L' + W + ',' + base + 'L0,' + base + 'Z', fill: '#5fe08a', filter: 'url(#sglow)', 'class': 'glow' }, box)
|
|
326
|
+
shapes.forEach(function (s) { svg('path', { d: s.d, fill: s.fill }, box) })
|
|
327
|
+
svg('path', { d: last.top, 'class': 'top-line', 'stroke-width': 1.2 }, box)
|
|
328
|
+
// One dot per session on the axis, at its hour of the day.
|
|
329
|
+
cur.forEach(function (t) {
|
|
330
|
+
var k = keys.indexOf(t.started.slice(0, 10)), hour = (+t.started.slice(11, 13) || 12) / 24
|
|
331
|
+
svg('circle', { cx: Math.min(W - 4, Math.max(4, x(k) + (hour - .5) * W / n)), cy: base + 22, r: 3.2, fill: colorOf(t.project) }, box)
|
|
332
|
+
})
|
|
333
|
+
var hover = svg('line', { y1: top - 12, y2: base, 'class': 'hover-line', visibility: 'hidden' }, box)
|
|
334
|
+
box.onmousemove = function (e) {
|
|
335
|
+
var r = box.getBoundingClientRect(), i = Math.max(0, Math.min(n - 1, Math.round((e.clientX - r.left) / r.width * (n - 1))))
|
|
336
|
+
hover.setAttribute('x1', x(i)); hover.setAttribute('x2', x(i)); hover.setAttribute('visibility', 'visible')
|
|
337
|
+
tip.textContent = ''; tip.append(h('div', 'tip-date', shortDate(keys[i])), h('div', 'tip-value', M.fmt(raw.reduce(function (a, rr) { return a + rr[i] }, 0))))
|
|
338
|
+
tip.style.left = x(i) / W * r.width + 'px'; tip.style.top = y(totals[i]) / H * r.height + 'px'; tip.hidden = false
|
|
339
|
+
}
|
|
340
|
+
box.onmouseleave = function () { hover.setAttribute('visibility', 'hidden'); tip.hidden = true }
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function legend(shown) {
|
|
344
|
+
var box = byId('legend'); box.textContent = ''
|
|
345
|
+
if (shown.length < 2) return
|
|
346
|
+
shown.forEach(function (p) {
|
|
347
|
+
var b = h('button', 'chip'), d = h('span', 'dot'); b.type = 'button'; b.setAttribute('aria-pressed', String(!state.hidden[p])); d.style.setProperty('--c', colorOf(p))
|
|
348
|
+
b.append(d, document.createTextNode(p), h('span', 'x', state.hidden[p] ? '+' : '×'))
|
|
349
|
+
b.addEventListener('click', function () { state.hidden[p] = !state.hidden[p]; chart(); card() })
|
|
350
|
+
box.append(b)
|
|
351
|
+
})
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function pill(text) {
|
|
355
|
+
var kind = /passed/.test(text) ? 'ok' : /failed/.test(text) ? 'bad' : /stale/.test(text) ? 'warn' : ''
|
|
356
|
+
var p = h('span', 'pill ' + kind); p.append(h('i'), document.createTextNode(text || 'not run')); return p
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function table() {
|
|
360
|
+
var body = byId('rows'), q = state.q.trim().toLowerCase(); body.textContent = ''
|
|
361
|
+
var list = inDays(scope(), days(span())).filter(function (t) { return !q || (t.name + ' ' + t.project + ' ' + modelName(t.model)).toLowerCase().indexOf(q) >= 0 })
|
|
362
|
+
.sort(function (a, b) { return b.started.localeCompare(a.started) })
|
|
363
|
+
byId('count').textContent = String(list.length)
|
|
364
|
+
if (!list.length) {
|
|
365
|
+
var tr = h('tr'), td = h('td', 'empty', q ? 'No session matches.' : tasks.length ? 'No sessions in this period.' : 'No sessions yet. The band records a session after its first turn.')
|
|
366
|
+
td.colSpan = 6; tr.append(td); body.append(tr); return
|
|
367
|
+
}
|
|
368
|
+
var max = Math.max.apply(null, list.map(function (t) { return t.agentMinutes })) || 1
|
|
369
|
+
list.forEach(function (t) {
|
|
370
|
+
var tr = h('tr'), name = h('td', 'name'), task = h('div', 'task', t.name || 'Untitled session'), bar = h('div', 'bar'), tests = h('td')
|
|
371
|
+
task.title = t.name; bar.style.setProperty('--c', colorOf(t.project)); bar.style.width = Math.max(4, t.agentMinutes / max * 100) + '%'
|
|
372
|
+
name.append(task, h('div', 'sub', (state.project ? '' : t.project + ' · ') + shortDate(t.started.slice(0, 10)) + ', ' + t.started.slice(11) + (t.model ? ' · ' + modelName(t.model) : '')), bar)
|
|
373
|
+
// An imported session has no test record: a dash, not "not run".
|
|
374
|
+
tests.append(t.tests || t.level !== 'import' ? pill(t.tests) : h('span', 'sub', '–'))
|
|
375
|
+
tr.append(name, h('td', 'r num', time(t.agentMinutes * 60000)), h('td', 'r num', money(t.cost)), h('td', 'r num', tok(t.tokens)), h('td', 'r num', String(t.turns)), tests)
|
|
376
|
+
body.append(tr)
|
|
377
|
+
})
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Thermal ramp: a quiet indigo for a little agent time, through red to yellow for the most.
|
|
381
|
+
var HEAT = [[0, [58, 48, 120]], [.4, [150, 45, 112]], [.65, [235, 72, 58]], [.85, [255, 140, 60]], [1, [255, 214, 102]]]
|
|
382
|
+
function heatColor(a) {
|
|
383
|
+
for (var i = 1; i < HEAT.length; i++) if (a <= HEAT[i][0]) {
|
|
384
|
+
var lo = HEAT[i - 1], hi = HEAT[i], t = (a - lo[0]) / (hi[0] - lo[0])
|
|
385
|
+
return 'rgb(' + lo[1].map(function (c, j) { return Math.round(c + (hi[1][j] - c) * t) }).join(',') + ')'
|
|
386
|
+
}
|
|
387
|
+
return 'rgb(255,214,102)'
|
|
388
|
+
}
|
|
389
|
+
function hex(cx, cy, r) {
|
|
390
|
+
var d = ''
|
|
391
|
+
for (var k = 0; k < 6; k++) { var a = Math.PI / 3 * k - Math.PI / 2; d += (k ? 'L' : 'M') + (cx + r * Math.cos(a)).toFixed(1) + ',' + (cy + r * Math.sin(a)).toFixed(1) }
|
|
392
|
+
return d + 'Z'
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// The last 12 weeks as a honeycomb: a column per week, a row per weekday, the colour is the day's agent time.
|
|
396
|
+
function heat() {
|
|
397
|
+
var s = byId('heat'), list = scope(), keys = days(84), vals = perDay(list, keys, METRICS.time.pick), max = Math.max.apply(null, vals.concat([0]))
|
|
398
|
+
var r = 17, w = Math.sqrt(3) * r, left = 44, top = r + 2, firstDow = new Date(now - 83 * DAY).getDay(), cols = Math.ceil((84 + firstDow) / 7); s.textContent = ''
|
|
399
|
+
blur(svg('defs', {}, s), 'hglow', 6)
|
|
400
|
+
var cells = keys.map(function (k, i) {
|
|
401
|
+
var pos = i + firstDow, row = pos % 7
|
|
402
|
+
return { k: k, v: vals[i], a: max ? vals[i] / max : 0, cx: left + Math.floor(pos / 7) * w + (row % 2 ? w / 2 : 0), cy: top + row * r * 1.5 }
|
|
403
|
+
})
|
|
404
|
+
cells.forEach(function (c) { if (c.a > .6) svg('path', { d: hex(c.cx, c.cy, r), fill: heatColor(c.a), filter: 'url(#hglow)', 'class': 'glow-cell' }, s) })
|
|
405
|
+
cells.forEach(function (c) {
|
|
406
|
+
var p = svg('path', c.v ? { d: hex(c.cx, c.cy, r - 2), fill: heatColor(c.a) } : { d: hex(c.cx, c.cy, r - 2), 'class': 'cell-empty' }, s)
|
|
407
|
+
svg('title', {}, p).textContent = shortDate(c.k) + ': ' + (c.v ? time(c.v) : 'no session')
|
|
408
|
+
})
|
|
409
|
+
;['Mon', 'Wed', 'Fri'].forEach(function (d, i) { svg('text', { x: 0, y: top + (1 + 2 * i) * r * 1.5 + 4, 'class': 'day' }, s).textContent = d })
|
|
410
|
+
// Legend: the ramp from less to more, under the right edge.
|
|
411
|
+
var right = left + cols * w + w / 2, ly = top + 6 * r * 1.5 + r + 20
|
|
412
|
+
var end = right - 40
|
|
413
|
+
svg('text', { x: end - 4 * 15 - 14, y: ly + 4, 'text-anchor': 'end', 'class': 'day' }, s).textContent = 'Less'
|
|
414
|
+
;[.1, .3, .55, .8, 1].forEach(function (a, i) { svg('path', { d: hex(end - (4 - i) * 15, ly, 6.5), fill: heatColor(a) }, s) })
|
|
415
|
+
svg('text', { x: right, y: ly + 4, 'text-anchor': 'end', 'class': 'day' }, s).textContent = 'More'
|
|
416
|
+
s.setAttribute('viewBox', '0 0 ' + Math.ceil(right + 2) + ' ' + Math.ceil(ly + 10))
|
|
417
|
+
// Two heroes from the same twelve weeks: on how many days agents worked, and the run of days up to today.
|
|
418
|
+
var active = vals.filter(function (v) { return v > 0 }).length, run = 0, longest = 0, streak = 0
|
|
419
|
+
vals.forEach(function (v) { run = v > 0 ? run + 1 : 0; longest = Math.max(longest, run) })
|
|
420
|
+
for (var j = vals[83] > 0 ? 83 : 82; j >= 0 && vals[j] > 0; j--) streak++
|
|
421
|
+
byId('h-active').textContent = String(active)
|
|
422
|
+
var share = byId('h-active-sub'); share.textContent = ''; share.append(h('b', '', Math.round(active / 84 * 100) + '%'), document.createTextNode('of the last 84 days'))
|
|
423
|
+
byId('h-streak').textContent = plural(streak, 'day')
|
|
424
|
+
byId('h-streak-sub').textContent = 'longest ' + plural(longest, 'day')
|
|
425
|
+
var cur = inDays(list, days(span())), byDay = {}, best = null
|
|
426
|
+
cur.forEach(function (t) { var k = t.started.slice(0, 10); byDay[k] = (byDay[k] || 0) + t.agentMinutes; if (!best || t.agentMinutes > best.agentMinutes) best = t })
|
|
427
|
+
var busiest = Object.keys(byDay).sort(function (a, b) { return byDay[b] - byDay[a] })[0], facts = byId('facts'); facts.textContent = ''
|
|
428
|
+
;[['Busiest day', busiest ? shortDate(busiest) + ' · ' + time(byDay[busiest] * 60000) : '–'],
|
|
429
|
+
['Longest session', best ? time(best.agentMinutes * 60000) : '–'],
|
|
430
|
+
['Average per session', cur.length ? time(total(cur, METRICS.time.pick) / cur.length) : '–'],
|
|
431
|
+
['Average cost per session', cur.length ? money(total(cur, METRICS.cost.pick) / cur.length) : '–']].forEach(function (f) {
|
|
432
|
+
var r = h('div', 'fact'); r.append(h('span', '', f[0]), h('span', 'num', f[1])); facts.append(r)
|
|
433
|
+
})
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// The share card: the numbers and the stream of what is on screen.
|
|
437
|
+
function card() {
|
|
438
|
+
var list = scope(), n = span(), keys = days(n), cur = inDays(list, keys).filter(function (t) { return !state.hidden[t.project] })
|
|
439
|
+
var projects = names.filter(function (p) { return cur.some(function (t) { return t.project === p }) })
|
|
440
|
+
byId('c-scope').textContent = (state.project || plural(projects.length, 'project')) + ' · ' + (state.period ? 'last ' + n + ' days' : 'all time')
|
|
441
|
+
byId('c-time').textContent = time(total(cur, METRICS.time.pick))
|
|
442
|
+
byId('c-cost').textContent = money(total(cur, METRICS.cost.pick))
|
|
443
|
+
byId('c-tokens').textContent = tok(total(cur, METRICS.tokens.pick))
|
|
444
|
+
byId('c-sessions').textContent = String(cur.length)
|
|
445
|
+
var g = byId('c-stream'), W = 1200, base = 600, topY = 380; g.textContent = ''
|
|
446
|
+
var layers = projects.map(function (p) { return perDay(cur.filter(function (t) { return t.project === p }), keys, METRICS.time.pick) })
|
|
447
|
+
var totals = keys.map(function (_, i) { return layers.reduce(function (a, r) { return a + r[i] }, 0) }), max = Math.max.apply(null, totals.concat([0]))
|
|
448
|
+
if (max <= 0) return
|
|
449
|
+
var x = function (i) { return n > 1 ? i * W / (n - 1) : W / 2 }, y = function (v) { return base - v / max * (base - topY) }, acc = keys.map(function () { return 0 }), shapes = []
|
|
450
|
+
layers.forEach(function (r, li) {
|
|
451
|
+
var lower = acc, upper = acc.map(function (a, i) { return a + r[i] }); acc = upper
|
|
452
|
+
var up = smooth(upper.map(function (v, i) { return [x(i), y(v)] })), low = smooth(lower.map(function (v, i) { return [x(i), y(v)] }).reverse())
|
|
453
|
+
shapes.push({ d: up + 'L' + low.slice(1) + 'Z', fill: 'url(#cf' + PALETTE.indexOf(colorOf(projects[li])) + ')', top: up })
|
|
454
|
+
})
|
|
455
|
+
svg('path', { d: shapes[shapes.length - 1].top + 'L' + W + ',' + base + 'L0,' + base + 'Z', fill: '#5fe08a', opacity: .18, filter: 'url(#cglow)' }, g)
|
|
456
|
+
shapes.forEach(function (s) { svg('path', { d: s.d, fill: s.fill }, g) })
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function render() { sidebar(); header(); kpis(); chart(); table(); heat(); card() }
|
|
460
|
+
|
|
461
|
+
function download(blob, name) { var a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = name; a.click() }
|
|
462
|
+
// The saved card carries its own copy of the logo, so it stands alone.
|
|
463
|
+
function cardText() { var c = byId('card').cloneNode(true); c.querySelector('defs').append(byId('pt-logo').cloneNode(true)); return new XMLSerializer().serializeToString(c) }
|
|
464
|
+
function saveSvg() { download(new Blob([cardText()], { type: 'image/svg+xml' }), 'ponytail-card.svg') }
|
|
465
|
+
function savePng() {
|
|
466
|
+
var img = new Image()
|
|
467
|
+
img.onload = function () {
|
|
468
|
+
try { var c = document.createElement('canvas'); c.width = 1200; c.height = 630; c.getContext('2d').drawImage(img, 0, 0); c.toBlob(function (b) { b ? download(b, 'ponytail-card.png') : saveSvg() }) } catch (e) { saveSvg() }
|
|
469
|
+
}
|
|
470
|
+
img.onerror = saveSvg
|
|
471
|
+
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(cardText())
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
byId('q').addEventListener('input', function (e) { state.q = e.target.value; table() })
|
|
475
|
+
document.querySelectorAll('[data-metric]').forEach(function (b) {
|
|
476
|
+
b.addEventListener('click', function () { state.metric = b.getAttribute('data-metric'); document.querySelectorAll('[data-metric]').forEach(function (x) { x.classList.toggle('on', x === b) }); chart() })
|
|
477
|
+
})
|
|
478
|
+
document.querySelectorAll('[data-period]').forEach(function (b) {
|
|
479
|
+
b.addEventListener('click', function () { state.period = +b.getAttribute('data-period'); document.querySelectorAll('[data-period]').forEach(function (x) { x.classList.toggle('on', x === b) }); render() })
|
|
480
|
+
})
|
|
481
|
+
var resizing; window.addEventListener('resize', function () { clearTimeout(resizing); resizing = setTimeout(chart, 120) })
|
|
482
|
+
byId('share').addEventListener('click', function () { byId('share-dialog').showModal() })
|
|
483
|
+
byId('share-close').addEventListener('click', function () { byId('share-dialog').close() })
|
|
484
|
+
byId('save-png').addEventListener('click', savePng)
|
|
485
|
+
byId('save-svg').addEventListener('click', saveSvg)
|
|
486
|
+
var THEMES = ['dark', 'light', 'system']
|
|
487
|
+
function applyTheme(choice) {
|
|
488
|
+
document.documentElement.setAttribute('data-theme', choice)
|
|
489
|
+
var b = byId('theme'), next = THEMES[(THEMES.indexOf(choice) + 1) % THEMES.length]
|
|
490
|
+
b.setAttribute('data-choice', choice)
|
|
491
|
+
b.setAttribute('aria-label', 'Theme: ' + choice + ', switch to ' + next)
|
|
492
|
+
b.title = 'Theme: ' + choice
|
|
493
|
+
}
|
|
494
|
+
var saved = 'dark'
|
|
495
|
+
try { saved = localStorage.getItem('ponytail-board-theme') || 'dark' } catch (e) {}
|
|
496
|
+
applyTheme(THEMES.indexOf(saved) >= 0 ? saved : 'dark')
|
|
497
|
+
byId('theme').addEventListener('click', function () {
|
|
498
|
+
var next = THEMES[(THEMES.indexOf(byId('theme').getAttribute('data-choice')) + 1) % THEMES.length]
|
|
499
|
+
applyTheme(next)
|
|
500
|
+
try { localStorage.setItem('ponytail-board-theme', next) } catch (e) {}
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
render()
|
|
504
|
+
})()`
|
|
505
|
+
|
|
506
|
+
// The share card stays dark in both themes: an image for X or LinkedIn, not part of the page's look.
|
|
507
|
+
// The page fills its numbers and stream; the logo symbol joins it when the card is saved.
|
|
508
|
+
const SANS = "Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif"
|
|
509
|
+
const MONO = "'JetBrains Mono', ui-monospace, Menlo, monospace"
|
|
510
|
+
const cardStat = (y: number, label: string, id: string) =>
|
|
511
|
+
`<text x="760" y="${y}" font-size="20" fill="#8d8b84">${label}</text><text id="${id}" x="1128" y="${y}" text-anchor="end" font-family="${MONO}" font-size="30" fill="#f3f2ee"></text>`
|
|
512
|
+
const CARD = `<svg id="card" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630" width="1200" height="630" font-family="${SANS}">
|
|
513
|
+
<defs>${PALETTE.map((c, i) => `<linearGradient id="cf${i}" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${c}" stop-opacity=".95"/><stop offset="1" stop-color="${c}" stop-opacity=".55"/></linearGradient>`).join('')}<filter id="cglow" x="-20%" y="-50%" width="140%" height="200%"><feGaussianBlur stdDeviation="12"/></filter><radialGradient id="chalo" cx=".62" cy=".8" r=".6"><stop offset="0" stop-color="#5fe08a" stop-opacity=".16"/><stop offset="1" stop-color="#5fe08a" stop-opacity="0"/></radialGradient></defs>
|
|
514
|
+
<rect width="1200" height="630" fill="#070707"/><rect width="1200" height="630" fill="url(#chalo)"/>
|
|
515
|
+
<g id="c-stream"></g><rect y="600" width="1200" height="30" fill="#070707"/>
|
|
516
|
+
<use href="#pt-logo" x="72" y="62" width="48" height="55"/>
|
|
517
|
+
<text x="134" y="98" font-family="${MONO}" font-size="28" font-weight="600" fill="#f3f2ee">ponytail</text>
|
|
518
|
+
<text id="c-scope" x="1128" y="98" text-anchor="end" font-size="20" fill="#8d8b84"></text>
|
|
519
|
+
<text x="72" y="196" font-size="22" fill="#8d8b84">Agent time</text><text id="c-time" x="66" y="292" font-family="${MONO}" font-size="104" font-weight="600" letter-spacing="-5" fill="#f3f2ee"></text>
|
|
520
|
+
${cardStat(172, 'Cost', 'c-cost')}${cardStat(228, 'Tokens', 'c-tokens')}${cardStat(284, 'Sessions', 'c-sessions')}
|
|
521
|
+
<text x="72" y="621" font-family="${MONO}" font-size="15" fill="#5e5c55">measured in Claude Code</text><text x="1128" y="621" text-anchor="end" font-family="${MONO}" font-size="15" fill="#5fe08a">ponytail.dev</text></svg>`
|
|
522
|
+
|
|
523
|
+
// JSON inside a script block: "<" is escaped so no name can close the block.
|
|
524
|
+
const json = (value: unknown) => JSON.stringify(value).replace(/</g, '\\u003c').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029')
|
|
525
|
+
|
|
526
|
+
// Dark, light, system: CSS shows the icon of the current choice.
|
|
527
|
+
const THEME_BUTTON = '<button type="button" class="theme" id="theme" data-choice="dark" aria-label="Theme: dark" title="Theme: dark"><svg class="i-system" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8M12 16v4"/></svg><svg class="i-light" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg><svg class="i-dark" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg></button>'
|
|
528
|
+
|
|
529
|
+
// The tab icon: the same face, centred on a square canvas.
|
|
530
|
+
const FAVICON = `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="-78 0 1241 1241"><g fill="#1d1c1a">${LOGO_PATHS}</g></svg>`)}`
|
|
531
|
+
|
|
532
|
+
const SEARCH_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>'
|
|
533
|
+
|
|
534
|
+
export function boardHtml(tasks: Task[], now: number): string {
|
|
535
|
+
const seg = (attr: string, items: [string, string][], on: string) =>
|
|
536
|
+
items.map(([value, label]) => `<button type="button" data-${attr}="${value}"${value === on ? ' class="on"' : ''}>${label}</button>`).join('')
|
|
537
|
+
return `<!doctype html><html lang="en" data-theme="dark"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>ponytail board</title><link rel="icon" href="${FAVICON}"><style>${CSS}</style></head>
|
|
538
|
+
<body><svg class="sprite" aria-hidden="true" focusable="false"><symbol id="pt-logo" viewBox="${LOGO_VIEWBOX}"><g fill="#1d1c1a">${LOGO_PATHS}</g></symbol></svg>
|
|
539
|
+
<div class="app">
|
|
540
|
+
<aside class="side"><div class="brand"><span class="logo"><svg viewBox="${LOGO_VIEWBOX}" aria-hidden="true"><use href="#pt-logo"/></svg></span>ponytail</div><div class="side-label">Projects</div><nav id="projects"></nav><div class="side-foot"><div class="foot-text">Local board<br>${esc(day(now))}</div><span class="foot-sep"></span>${THEME_BUTTON}</div></aside>
|
|
541
|
+
<main>
|
|
542
|
+
<div class="top"><div><div class="crumb">Board / <b id="crumb"></b></div><h1 id="title"></h1><p id="subtitle"></p></div>
|
|
543
|
+
<div class="actions"><div class="seg" role="group" aria-label="Period">${seg('period', [['7', '7d'], ['30', '30d'], ['90', '90d'], ['0', 'All']], '30')}</div><button type="button" class="btn" id="share">Share card</button></div></div>
|
|
544
|
+
<section class="kpis" id="kpis"></section>
|
|
545
|
+
<section class="card chart-card"><div class="chart-head"><div><div class="label" id="chart-sub"></div><div class="big num" id="chart-total"></div></div>
|
|
546
|
+
<div class="seg" role="group" aria-label="Metric">${seg('metric', [['time', 'Agent time'], ['cost', 'Cost'], ['tokens', 'Tokens']], 'time')}</div></div>
|
|
547
|
+
<div class="chart-wrap"><svg id="stream" role="img" aria-label="Per project and day"></svg><div class="tip" id="tip" hidden></div></div><div class="legend" id="legend"></div></section>
|
|
548
|
+
<div class="grid2">
|
|
549
|
+
<section class="card list"><div class="list-head"><h3>Sessions<span class="n" id="count"></span></h3><label class="search">${SEARCH_ICON}<input id="q" type="search" placeholder="Search sessions" aria-label="Search sessions"></label></div>
|
|
550
|
+
<div class="table-wrap"><table><thead><tr><th>Session</th><th class="r">Agent time</th><th class="r">Cost</th><th class="r">Tokens</th><th class="r">Turns</th><th>Tests</th></tr></thead><tbody id="rows"></tbody></table></div></section>
|
|
551
|
+
<section class="card heat"><h3>Activity</h3><div class="sub">Agent time per day, last 12 weeks</div><svg id="heat" role="img" aria-label="Agent time per day, last 12 weeks"></svg>
|
|
552
|
+
<div class="heroes"><div><div class="hero-label"><i style="color:#5fe08a">✦</i>Active days</div><div class="hero-value num" id="h-active"></div><div class="hero-sub" id="h-active-sub"></div></div>
|
|
553
|
+
<div><div class="hero-label"><i style="color:#ffd166">◆</i>Streak</div><div class="hero-value num" id="h-streak"></div><div class="hero-sub" id="h-streak-sub"></div></div></div><div id="facts"></div></section>
|
|
554
|
+
</div>
|
|
555
|
+
<footer>Measured by ponytail's band in Claude Code. Cost is the API-priced cost Claude Code reports (/cost), not a bill. Nothing here is estimated.</footer>
|
|
556
|
+
</main></div>
|
|
557
|
+
<dialog id="share-dialog"><div class="dlg-head"><b>Share card</b><div class="dlg-actions"><button type="button" class="btn" id="save-png">Save PNG</button><button type="button" class="btn" id="save-svg">Save SVG</button><button type="button" class="btn" id="share-close">Close</button></div></div><div class="dlg-body">${CARD}</div></dialog>
|
|
558
|
+
<script type="application/json" id="board-data">${json({ now, tasks })}</script>
|
|
559
|
+
<script>${APP}</script></body></html>`
|
|
560
|
+
}
|