From 9e614e44461f93bcb386e08fd697921a9fced574 Mon Sep 17 00:00:00 2001 From: Pratik Bhavsar Date: Fri, 6 Nov 2026 08:31:35 +0530 Subject: [PATCH 1/9] test(contracts): add a source-file size budget that can only shrink Large single files make coding-agent sessions expensive: agents cannot read them whole, so they search them repeatedly and every result stays in context. New source files stay under 80 KB; existing large files carry a ceiling about 5% above their current size that may only be lowered or removed. Adds the October session-cost survey and refactor plan. Co-Authored-By: Claude Code --- .../plans/2026-11-06-session-cost-refactor.md | 186 ++++++++++++++++++ tests/contracts/test_source_size_budget.py | 93 +++++++++ 2 files changed, 279 insertions(+) create mode 100644 specs/plans/2026-11-06-session-cost-refactor.md create mode 100644 tests/contracts/test_source_size_budget.py diff --git a/specs/plans/2026-11-06-session-cost-refactor.md b/specs/plans/2026-11-06-session-cost-refactor.md new file mode 100644 index 00000000..84f800be --- /dev/null +++ b/specs/plans/2026-11-06-session-cost-refactor.md @@ -0,0 +1,186 @@ +# Session-cost refactor: analysis and plan + +Historical point-in-time analysis (2026-11-06). Plan only; nothing implemented. + +## Method + +- Token Meter MCP `stats` grouped by `session_id`, 2026-10-01 → 2026-10-31, + sorted by `cost_usd`, top 100. Dollar figures below come from Token Meter. +- To attribute those sessions to projects and tools, a local script read the + matching Claude Code transcripts for **counts and sizes only**. No content + was printed. Transcript-derived figures double-count history copied into + forked or resumed sessions, so treat them as **shares**, not dollars. +- "Carry" = tool-result characters × the turns that result stayed in context. + It approximates how much each result was paid for again as cache reads. + +## Findings + +### Where the money went + +| Fact | Value | +|---|---| +| October spend, all sessions | $1,194 | +| Top 100 sessions | $1,010 (85%) | +| …of which token-meter repo sessions | 68 sessions, **$751** | +| Opus 5.5 share of the month | $1,050 | +| Top-100 tokens: cache read / cache write / output / uncached input | 1.25 B / 122 M / 6.4 M / 0.8 M | +| Estimated cost split (Opus rates) | cache writes ≈ 45–50%, cache reads ≈ 35–40%, output ≈ 10% | + +Output is a small share. Almost all the cost is context: re-reading a large +context every turn, and rebuilding it when the cache expires. + +### Context size is the multiplier + +| Context per model turn | p50 | p90 | max | +|---|---|---|---| +| Main sessions | ~300 k | ~705 k | ~998 k | +| Subagents | ~54 k | ~226 k | ~688 k | + +- Starting context before any work: main ≈ 60 k tokens, subagent ≈ 15 k. + `specs/AGENTS.md` (21.7 KB, ≈ 5.4 k tokens) is part of that in every main + session and every subagent. +- About 620 cache rebuilds above 50 k tokens happened in main sessions. 469 of + them came after waiting on a user reply (median gap 19 min). Each rebuilt + about 400 k tokens. The codebase can't shorten the user's reply time, but a + smaller working context makes every rebuild cheaper. + +### What fills the context: navigating huge files + +Share of carried tool-result volume across the 68 token-meter sessions (560 +transcripts, 492 of them subagents): + +| Source | Calls | Carry share | +|---|---|---| +| `grep`/`rg` via Bash | 8,459 | **31%** | +| `sed -n` range reads | 1,569 | **15%** | +| Reading `/tmp` scratch/persisted outputs | 995 | 8.5% | +| Ad-hoc `python3` probes | 2,091 | 8.4% | +| `git diff` | 1,877 | 3.6% | +| Targeted `unittest` runs | 2,055 | 3.0% | +| `Read` of `work_insights.py` | 133 | 3.0% | + +The grep/sed traffic is aimed at a few files: + +| File | Size | grep/sed calls | Output | +|---|---|---|---| +| `page.html` | 1.26 MB (≈ 315 k tokens, 13.2 k lines, 755 JS functions, 791 KB script, 333 KB CSS) | 4,350 | 9.5 MB | +| `token_meter/app.py` | 631 KB (15 k lines, 643 top-level defs) | 2,472 | 5.5 MB | +| `services/work_insights.py` | 106 KB | 1,063 | 2.2 MB | +| `tests/test_meter.py` | 1.43 MB (26 k lines, 1,034 tests, 97 classes) | 674 | 1.5 MB | + +`page.html` is larger than the context window, so agents can't read it whole. +They search it over and over, and every search result then stays in context. +`app.py` and `test_meter.py` work the same way. The monolithic files are the +main codebase-side cost driver. + +### Workflow signals (not code, but they multiply cost) + +- Subagent turns were about 98% Opus (20,148 Opus vs 404 Sonnet), even though + the tier policy sends the tester and extra reviewers to `sonnet`. This matches + the known alias fallback on this machine. The `*-local` agents fix it only + if they are actually spawned. +- 1,101 full-suite invocations across these sessions, against a policy of at + most once per head. + +## Plan (ranked by expected impact) + +Success metric: median main-session context per turn drops from ~300 k toward +≤ 150 k, and grep/sed calls per session halve. Measure both with Token Meter +on the next ~20 comparable sessions after each phase. Cost scales roughly +with context × turns, so halving resting context should cut token-meter +session cost by an estimated 30–45%. That number is a hypothesis to confirm +in Phase 1, not a promise. + +### Phase 0: guardrails first (small) + +1. Add a source-size ratchet to `tests/contracts/test_architecture_guards.py`: + a per-file byte baseline that can only shrink, plus a ceiling for new files + (e.g. 80 KB). This stops the monolith from regrowing during the split. +2. Save the survey script as a local tool so each phase reports the same + numbers. Keep it under ignored local state, or make it content-free and + commit it under `scripts/`. + +### Phase 1: split `page.html` (largest win) + +- Source layout: `dashboard/index.html` (shell), + `dashboard/styles/{tokens,components,}.css`, and + `dashboard/js/{core,router,charts,}.js`, one per top-level view + (Sessions, Spend, Models, Subagents, Efficiency, Work, Git, Learn, Tools, + Settings). Target ≤ 60 KB per file, so an agent can read the one view it is + working on in a single call. +- The server assembles the page in memory at startup with stdlib string + concatenation, in a fixed order. No build tool, no hosted assets, and the + page stays local-only and dependency-free. `/health` keeps validating the + assembled page. +- Do the move mechanically (a one-off script that cuts on existing section + markers) and verify byte-identical assembled output against today's + `page.html` before any cleanup. Don't have an agent hand-edit 1.26 MB. +- Update the consumers together: `runtime-manifest.txt` and parity checks, + `scripts/install`, `scripts/install-windows.ps1`, the Linux installer, the + design-system contract, `scripts/style-snapshot.js`, the embedded-JS parse + command in `specs/AGENTS.md`, and the 69 `page.html` string assertions in + `tests/test_meter.py`. Route these through one `assembled_page()` test + helper. +- Risk: high-surface (installer, runtime parity, design contract). This needs + the high-risk route: one tester and two reviewers. + +### Phase 2: split `tests/test_meter.py` + +- Move its 97 classes into `tests//test_*.py`, mirroring the source + modules (dashboard views, server/HTTP, installer, menubar source, parsers), + with shared fixtures in `tests/support/`. +- Benefit: agents find tests by file name instead of grepping 1.4 MB. Targeted + runs (`unittest tests.dashboard.test_spend`) replace full-file runs. Keep + `scripts/run-tests` and discovery unchanged. +- Coverage-preserving: assert the same test count before and after the move. + +### Phase 3: split `token_meter/app.py` + +- Pull out cohesive modules along existing seams: HTTP routing/handlers, + settings and atomic writes, budgets and caps, the menubar payload, discovery + and rebuild orchestration, and update/lifecycle. `app.py` keeps composition + plus compatibility re-exports (the `meter.py` facade contract is + unchanged). +- Do the same for `services/work_insights.py` (queue/pacing vs Ollama client + vs label ledger) and `domain/work.py`, which together draw about 3.7 MB of + grep/sed/Read traffic. +- Update `specs/ARCHITECTURE.md`'s component map in the same change. + +### Phase 4: cheaper agent defaults in the repo + +- Trim `specs/AGENTS.md` toward ~8 KB. Keep the invariants (privacy, pricing, + dashboard order, design rules) and move the long procedural flows + (reporter-end-to-end, Slack/GitHub contracts, PR-management replies) into + the existing on-demand skills under `.agents/skills/`. Estimated effect: + about 3 k fewer tokens on every turn of every main session and subagent, + roughly 5–7% of cache reads. +- Add bounded helpers that replace the ad-hoc probes, which account for about + 17% of carry (`python3` scratch and `/tmp` reads): + - `scripts/show ` prints one function or CSS block; + - `scripts/check-page` runs JS parse + design contract with a terse summary; + - an endpoint probe that summarizes `/health`/`/menubar` instead of dumping + JSON. +- Make terse output the default for `scripts/run-tests`: failures plus a + one-line summary, with `-v` opt-in. Many of these output-size controls may + already exist. +- Workflow: confirm subagents spawn the `*-local` tiers, and enforce one full + suite per head. These need no code change, but on today's evidence the + savings are comparable to a code phase. + +### Order and checkpoints + +Phase 0 → 1 → measure → 2 → 3 → 4. Phase 4's AGENTS.md trim and helpers are +independent and could run in parallel with Phase 1. Stop and reassess after +Phase 1: if context per turn and grep counts don't move meaningfully, the +cost is behavioral (session length, idle rebuilds) rather than structural, +and Phases 2–3 should shrink. + +## Not verified + +- Exact Opus 5.5 unit prices weren't checked against the repo's pricing + table. The cost split is an estimate from token mix. +- Transcript shares include forked-session history duplication. +- The 22 top-100 sessions outside this repo were not analyzed for tools. +- Some sessions show a covered execution count of exactly 180. That could be + a bound in Token Meter's per-session accounting, worth a separate check + before relying on per-session totals. diff --git a/tests/contracts/test_source_size_budget.py b/tests/contracts/test_source_size_budget.py new file mode 100644 index 00000000..dfbc99a4 --- /dev/null +++ b/tests/contracts/test_source_size_budget.py @@ -0,0 +1,93 @@ +"""Source-file size budget. + +Large single files are expensive for coding agents: they cannot be read whole, +so agents search them repeatedly and every result stays in context. New source +files stay under ``NEW_FILE_LIMIT``. Files that are already larger carry a +ceiling in ``LEGACY_CEILINGS``. Lower a ceiling, or delete the entry once a file is +split below the limit. Never raise a ceiling or add a new entry. +""" + +import subprocess +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +NEW_FILE_LIMIT = 80_000 +SOURCE_SUFFIXES = {".py", ".html", ".js", ".css", ".swift", ".ps1", ".sh"} +EXEMPT_PREFIXES = ("assets/vendor/", "tests/fixtures/") + +# Ceilings are about 5% above each file's size when the budget was introduced, +# so normal maintenance still fits. This dict may only shrink. +LEGACY_CEILINGS = { + "tests/test_meter.py": 1_500_000, + "page.html": 1_325_000, + "token_meter/app.py": 663_000, + "tests/test_work_insights.py": 236_000, + "menubar/TokenMeterMenuBar.swift": 148_000, + "tests/test_git_delivery.py": 141_000, + "docs/talk/index.html": 123_000, + "token_meter/services/work_insights.py": 111_000, + "token_meter/runtimes/claude.py": 110_000, + "token_meter/domain/aggregates.py": 104_000, + "token_meter/runtimes/codex.py": 97_000, + "token_meter/runtimes/opencode.py": 96_000, + "token_meter/domain/work.py": 85_000, +} + + +def tracked_sources(): + try: + listed = subprocess.run( + ["git", "ls-files", "-z"], + cwd=ROOT, + check=True, + capture_output=True, + ).stdout.decode("utf-8").split("\0") + except (OSError, subprocess.CalledProcessError): + return None + paths = [] + for name in listed: + if not name or name.startswith(EXEMPT_PREFIXES): + continue + path = ROOT / name + is_script = name.startswith("scripts/") and "." not in Path(name).name + if (path.suffix in SOURCE_SUFFIXES or is_script) and path.is_file(): + paths.append(name) + return paths + + +class SourceSizeBudgetTests(unittest.TestCase): + def setUp(self): + self.sources = tracked_sources() + if self.sources is None: + self.skipTest("git ls-files is unavailable") + + def test_source_files_stay_within_their_size_budget(self): + over = [] + for name in self.sources: + size = (ROOT / name).stat().st_size + limit = LEGACY_CEILINGS.get(name, NEW_FILE_LIMIT) + if size > limit: + over.append(f"{name}: {size} bytes > {limit}") + self.assertEqual( + over, + [], + "Split the file instead of raising its budget; see specs/ARCHITECTURE.md.", + ) + + def test_legacy_ceilings_name_only_files_that_still_need_them(self): + tracked = set(self.sources) + stale = [] + for name, ceiling in LEGACY_CEILINGS.items(): + if name not in tracked: + stale.append(f"{name}: no longer tracked; delete its ceiling") + elif (ROOT / name).stat().st_size <= NEW_FILE_LIMIT: + stale.append(f"{name}: now within the default limit; delete its ceiling") + elif ceiling <= NEW_FILE_LIMIT: + stale.append(f"{name}: ceiling is not above the default limit") + self.assertEqual(stale, []) + + +if __name__ == "__main__": + unittest.main() From bf72e62d0839a208c0740df9bfcb1667ffd0dd4b Mon Sep 17 00:00:00 2001 From: Pratik Bhavsar Date: Fri, 6 Nov 2026 08:38:23 +0530 Subject: [PATCH 2/9] refactor(dashboard): split page.html into per-view source parts page.html was 1.26 MB (about 315k tokens), larger than an agent's context window, so every dashboard change began with dozens of grep and sed probes whose results stayed in context. The October survey attributes about 46% of carried tool output in this repo's sessions to that navigation. The dashboard source now lives in dashboard/ as 42 parts (all under 80 KB): styles/, markup/, and js/ files grouped by view. dashboard/page.manifest lists their serving order and the server concatenates them byte for byte, so the served page is identical to the old page.html. No build step or dependency. - token_meter/dashboard_page.py: validated manifest, cached assembly - server: serves the assembled page; assets resolve beside dashboard/ - runtime manifest, start script, Windows runtime check, CI updated - tests read one assembled copy through tests/dashboard_page.py - scripts/check-page replaces the inline node parse command - scripts/split-dashboard re-splits a branch's own page.html by anchors, so open branches can port their dashboard edits Co-Authored-By: Claude Code --- .../skills/token-meter-development/SKILL.md | 2 +- .github/workflows/tests.yml | 2 +- AGENTS.md | 4 +- dashboard/head.html | 7 + dashboard/js/agent-access.js | 276 + dashboard/js/all-sessions.js | 192 + dashboard/js/budgets-discounts.js | 370 + dashboard/js/capabilities.js | 498 + dashboard/js/charts.js | 393 + dashboard/js/core.js | 396 + dashboard/js/current-sessions.js | 635 + dashboard/js/deck.js | 379 + dashboard/js/efficiency.js | 385 + dashboard/js/git.js | 419 + dashboard/js/goals.js | 535 + dashboard/js/live.js | 171 + dashboard/js/models.js | 361 + dashboard/js/motion-readouts.js | 103 + dashboard/js/notifications-settings.js | 450 + dashboard/js/router.js | 187 + dashboard/js/session-budgets-filters.js | 459 + dashboard/js/session-detail.js | 442 + dashboard/js/session-logs-compare.js | 364 + dashboard/js/spend.js | 535 + dashboard/js/subagents-explorer.js | 295 + dashboard/js/subagents.js | 365 + dashboard/js/theme-navigation.js | 288 + dashboard/js/work-insights.js | 407 + dashboard/js/work-status.js | 374 + dashboard/markup/efficiency-work.html | 109 + dashboard/markup/git-learn-tools.html | 108 + dashboard/markup/settings.html | 169 + dashboard/markup/shell.html | 369 + dashboard/markup/spend.html | 144 + dashboard/markup/subagents-models.html | 52 + dashboard/page.manifest | 45 + dashboard/styles/base.css | 71 + dashboard/styles/components.css | 495 + dashboard/styles/git.css | 98 + dashboard/styles/models-learn.css | 209 + dashboard/styles/motion-sessions.css | 324 + dashboard/styles/sessions-subagents.css | 342 + dashboard/styles/shell.css | 318 + dashboard/styles/themes.css | 789 + dashboard/styles/tools.css | 349 + dashboard/tail.html | 1 + page.html | 13233 ---------------- runtime-manifest.txt | 3 +- scripts/check-page | 11 + scripts/install-windows.ps1 | 3 +- scripts/split-dashboard | 122 + scripts/start-token-meter | 10 +- scripts/style-snapshot.js | 2 +- specs/AGENTS.md | 4 +- specs/ARCHITECTURE.md | 8 +- specs/CONTRIBUTING.md | 4 +- specs/DESIGN.md | 2 +- tests/contracts/test_design_system.py | 5 +- tests/contracts/test_kiro_extension_budget.py | 3 +- tests/contracts/test_runtime_catalog.py | 3 +- tests/contracts/test_source_size_budget.py | 1 - tests/dashboard_page.py | 37 + .../integration/test_model_pricing_compat.py | 3 +- tests/test_agent_skills.py | 3 +- tests/test_cache_countdown.py | 3 +- tests/test_claude_cost_correctness.py | 3 +- tests/test_codex_model_identity.py | 3 +- tests/test_dashboard_page.py | 108 + tests/test_git_delivery.py | 7 +- tests/test_goals.py | 3 +- tests/test_meter.py | 111 +- tests/test_work_insights.py | 39 +- token_meter/app.py | 41 +- token_meter/dashboard_page.py | 112 + 74 files changed, 13820 insertions(+), 13353 deletions(-) create mode 100644 dashboard/head.html create mode 100644 dashboard/js/agent-access.js create mode 100644 dashboard/js/all-sessions.js create mode 100644 dashboard/js/budgets-discounts.js create mode 100644 dashboard/js/capabilities.js create mode 100644 dashboard/js/charts.js create mode 100644 dashboard/js/core.js create mode 100644 dashboard/js/current-sessions.js create mode 100644 dashboard/js/deck.js create mode 100644 dashboard/js/efficiency.js create mode 100644 dashboard/js/git.js create mode 100644 dashboard/js/goals.js create mode 100644 dashboard/js/live.js create mode 100644 dashboard/js/models.js create mode 100644 dashboard/js/motion-readouts.js create mode 100644 dashboard/js/notifications-settings.js create mode 100644 dashboard/js/router.js create mode 100644 dashboard/js/session-budgets-filters.js create mode 100644 dashboard/js/session-detail.js create mode 100644 dashboard/js/session-logs-compare.js create mode 100644 dashboard/js/spend.js create mode 100644 dashboard/js/subagents-explorer.js create mode 100644 dashboard/js/subagents.js create mode 100644 dashboard/js/theme-navigation.js create mode 100644 dashboard/js/work-insights.js create mode 100644 dashboard/js/work-status.js create mode 100644 dashboard/markup/efficiency-work.html create mode 100644 dashboard/markup/git-learn-tools.html create mode 100644 dashboard/markup/settings.html create mode 100644 dashboard/markup/shell.html create mode 100644 dashboard/markup/spend.html create mode 100644 dashboard/markup/subagents-models.html create mode 100644 dashboard/page.manifest create mode 100644 dashboard/styles/base.css create mode 100644 dashboard/styles/components.css create mode 100644 dashboard/styles/git.css create mode 100644 dashboard/styles/models-learn.css create mode 100644 dashboard/styles/motion-sessions.css create mode 100644 dashboard/styles/sessions-subagents.css create mode 100644 dashboard/styles/shell.css create mode 100644 dashboard/styles/themes.css create mode 100644 dashboard/styles/tools.css create mode 100644 dashboard/tail.html delete mode 100644 page.html create mode 100755 scripts/check-page create mode 100755 scripts/split-dashboard create mode 100644 tests/dashboard_page.py create mode 100644 tests/test_dashboard_page.py create mode 100644 token_meter/dashboard_page.py diff --git a/.agents/skills/token-meter-development/SKILL.md b/.agents/skills/token-meter-development/SKILL.md index 1287cd23..26d12d50 100644 --- a/.agents/skills/token-meter-development/SKILL.md +++ b/.agents/skills/token-meter-development/SKILL.md @@ -59,7 +59,7 @@ that no longer qualifies. ### Dashboard styling -For any change to `page.html` styles, markup, or chart rendering: +For any change to dashboard styles, markup, or chart rendering (`dashboard/`): 1. Read `specs/DESIGN.md` → Tokens and Contract, Provider identity, and Components before editing. diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d0890c2d..fe955387 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -98,7 +98,7 @@ jobs: - name: Parse the dashboard JavaScript run: | - node -e "const fs=require('fs');const h=fs.readFileSync('page.html','utf8');const m=h.match(/ + +Token Meter + + + + +
+
+
Token Meter
+ +
+
+ + + +
+
+ + +
+
+
+

Sessions

+

Last 30 minutes

+
+
+
Waiting for evidence
+
+ +
+ + + + +
+ +
+
+
+
Spend today--
+
Sessions today--
+
Vs yesterday--
+
+ +
+
+
+
+ + +
+ Waiting +
+
+
+
+ + + +
+ +
+ source + -- + -- + + + + + +
+
+
Started with
+
No user message captured.
+
+
+
+
+
+
+
+ +
+
+
Output speed
--
+
Wait time
--
+
Context in use
--
+
Executions
0
+
Tool calls
0
+
Tool-result tokens
--
+
Cache rate
--
+
Cache saved
--
+
Burn / minute
--
+
Cost / task
--
+
+ +
Local-only
+
+
+ diff --git a/dashboard/markup/spend.html b/dashboard/markup/spend.html new file mode 100644 index 00000000..cf6c3b41 --- /dev/null +++ b/dashboard/markup/spend.html @@ -0,0 +1,144 @@ +
+
+
+

Spend

+
+
+ + +
+
+
+
+
+
+

Monthly budget

This month
+
Not configured
+
+ +
+
+
+ Spent this month +
$0of Not set
+
+
+
Waiting for spend data.
+
+
+
Remaining
--
+
Projected month
--
+
+
+
+
+
+ + +
+
+
+
Selected-period spend
--
+
Daily average
--
+
Top platform
--
+
Highest day
--
+
+

Spend goals

+
+

Spend trend

Line = average

+
+ +
+
+

Highest-cost logs

+

Platform split

+
+
+

Session economics

Concentration, typical sessions, and outliers.

+
+

Spend concentration

Top 10% of cost-covered sessions.

--
Loading range evidence…
Top decileRemaining sessions
+

Session shape

Loading session distributions…
+
+

Cost and input by active time

SessionTop-decile spend
+
+
+ diff --git a/dashboard/markup/subagents-models.html b/dashboard/markup/subagents-models.html new file mode 100644 index 00000000..97932e84 --- /dev/null +++ b/dashboard/markup/subagents-models.html @@ -0,0 +1,52 @@ +
+
+
+

Subagents

Track the cost of your custom agents.

+
+
+
+
+
+ +
+
+
+

Models

+
+
+ +
Models
All models
+ +
+
+
+
+

Where your spend goes

--
+
+
+

+
+
+

Model mix over time

+
+
+
+

Speed over time

Models
Top 5 by spend
+
+
+
+
#
+
+ Speed & timing diagnostics +
+
Observed output pace
--
waiting for timing evidence
+
Matched pace
--
Select 2 models
+
Typical wait
--
Timing unavailable
+
+
+

Model trends

+
+
+
+
+ diff --git a/dashboard/page.manifest b/dashboard/page.manifest new file mode 100644 index 00000000..1c93a979 --- /dev/null +++ b/dashboard/page.manifest @@ -0,0 +1,45 @@ +# Dashboard page parts, in serving order. The server concatenates these files +# byte for byte into the single page it serves; see token_meter/dashboard_page.py. +# Paths are relative to this directory. +head.html +styles/base.css +styles/themes.css +styles/components.css +styles/shell.css +styles/motion-sessions.css +styles/tools.css +styles/git.css +styles/sessions-subagents.css +styles/models-learn.css +markup/shell.html +markup/subagents-models.html +markup/spend.html +markup/efficiency-work.html +markup/git-learn-tools.html +markup/settings.html +js/core.js +js/deck.js +js/theme-navigation.js +js/goals.js +js/router.js +js/notifications-settings.js +js/budgets-discounts.js +js/agent-access.js +js/session-budgets-filters.js +js/charts.js +js/session-detail.js +js/capabilities.js +js/spend.js +js/motion-readouts.js +js/git.js +js/work-status.js +js/work-insights.js +js/efficiency.js +js/models.js +js/session-logs-compare.js +js/subagents.js +js/subagents-explorer.js +js/all-sessions.js +js/current-sessions.js +js/live.js +tail.html diff --git a/dashboard/styles/base.css b/dashboard/styles/base.css new file mode 100644 index 00000000..364fddc3 --- /dev/null +++ b/dashboard/styles/base.css @@ -0,0 +1,71 @@ +:root{ + color-scheme:dark; + /* == Fixed palette: identical in every theme; hue hex plus rgb channels for rgb(var(--x-rgb)/alpha) tints == */ + --white:#fff; --black:#000; --tint-rgb:255 255 255; --shade-rgb:0 0 0; + --pink:#f08cae; --pink-rgb:240 140 174; + --orange:#ffb457; --orange-rgb:255 180 87; + --green:#66d990; --green-rgb:102 217 144; + --red:#ff6f6f; --red-rgb:255 111 111; + /* == Lines: tinted by the theme line hue == */ + --line:rgb(var(--line-rgb)/.09); --line2:rgb(var(--line-rgb)/.17); --line3:rgb(var(--line-rgb)/.24); + /* == Semantic states: never themed == */ + --accent:var(--signal); --good:var(--green); --warn:var(--orange); --bad:var(--red); + --bad-text:#ffaaaa; --bad-text-strong:#ffd0d0; --bad-deep-rgb:91 30 35; --bad-ink-rgb:42 23 24; + /* == Data series that keep their meaning across themes == */ + --coord:var(--signal-mid); --c-cache-read:#66d990; --c-cache-write:#ffb457; --c-context:var(--fg); --c-deleted:#5c7d92; + --chart-cost-fill:rgb(var(--c-cost-rgb)/.24); --chart-grid:rgb(var(--signal-rgb)/.16); + --chart-tip:var(--bg-deep); --chart-tip-head:rgb(var(--signal-rgb)/.16); --chart-tip-line:rgb(var(--tint-rgb)/.42); + /* == Ordered (Bayer 4x4) dither masks: 50% coverage in 2px cells; color comes from the masked fill == */ + --dither-50:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4'%3E%3Crect width='2' height='2'/%3E%3Crect x='2' y='2' width='2' height='2'/%3E%3C/svg%3E"); + /* == Provider identity: one color per runtime for cards, Spend, badges, budgets, and model families == */ + --provider-claude:#e3825c; --provider-claude-rgb:227 130 92; + --provider-codex:#6f9cff; --provider-codex-rgb:111 156 255; + --provider-cursor:#3cc6c0; --provider-cursor-rgb:60 198 192; + --provider-opencode:#ff8fb8; --provider-opencode-rgb:255 143 184; + --provider-kiro:#e3cc5c; --provider-kiro-rgb:227 204 92; --provider-other:#8b96a3; + --badge-neutral:#d5dce5; --badge-neutral-rgb:213 220 229; + /* == Theme swatches for the Appearance picker == */ + --swatch-classic:linear-gradient(132deg,#00bceb,#7fdbf2 38%,#6d7bff 68%,#c7a7ff); --swatch-classic-bg:#07090c; + --swatch-nebula:linear-gradient(132deg,#9d8bff,#ff7ad9 38%,#7ef3ff 68%,#64ffda); --swatch-nebula-bg:#07061a; + --swatch-monolith:linear-gradient(132deg,#e4e7ee,#a7aebd 48%,#fff 82%,#ff6a3d); --swatch-monolith-bg:#09090a; + --swatch-voltage:linear-gradient(132deg,#f5e63d,#ffd23f 34%,#ff3df2 66%,#2ee6ff); --swatch-voltage-bg:#0a0907; + --swatch-phosphor:linear-gradient(132deg,#3dfc8f,#19d97a 40%,#c6ff4d 72%,#4dd2ff); --swatch-phosphor-bg:#040a06; + --swatch-omarchy:linear-gradient(132deg,#7aa2f7,#bb9af7 40%,#7dcfff 72%,#ff9e64); --swatch-omarchy-bg:#14151d; + --swatch-jadehorse:linear-gradient(132deg,#8bcbb1,#a7dfc5 40%,#96b7d0 72%,#c5cc91); --swatch-jadehorse-bg:#0c1a16; + --swatch-ferrous:linear-gradient(132deg,#ff5a36,#ffa27a 38%,#ffb36b 66%,#5ee6d8); --swatch-ferrous-bg:#0f0705; + --swatch-afterglow:linear-gradient(132deg,#ff5fa2,#ff8a5c 40%,#ffc775 70%,#8f7bff); --swatch-afterglow-bg:#0b0510; + /* == Type: families, size scale (11px floor), weights == */ + --font-sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Inter,system-ui,sans-serif; --font-mono:ui-monospace,"SF Mono",SFMono-Regular,Menlo,monospace; + --font-display:"Tektur Local","Avenir Next Condensed",-apple-system,BlinkMacSystemFont,sans-serif; + --fs-11:11px; --fs-12:12px; --fs-13:13px; --fs-14:14px; --fs-16:16px; --fs-18:18px; --fs-20:20px; --fs-22:22px; --fs-24:24px; --fs-28:28px; --fs-36:36px; --fs-44:44px; --fs-56:56px; + --fs-hero-fluid:clamp(36px,4.2vw,54px); --fs-readout-xl:clamp(32px,3.4vw,44px); --fs-readout-lg:clamp(28px,3.2vw,40px); + --fs-readout-md:clamp(18px,1.6vw,23px); --fs-readout-sm:clamp(16px,1.2vw,19px); + --fw-medium:500; --fw-semibold:600; --fw-bold:700; --fw-heavy:800; + /* == Shape: the corner scale itself is themed; pill and round stay fixed == */ + --radius-pill:999px; --radius-round:50%; + --focus-ring:0 0 0 3px rgb(var(--signal-rgb)/.14); + /* == Depth == */ + --tip-bg:linear-gradient(180deg,rgb(var(--bg-deep-rgb)/.98),rgb(var(--bg2-rgb)/.98)); --shadow-tip:0 16px 36px rgb(var(--shade-rgb)/.5); + --shadow:0 1px 0 rgb(var(--tint-rgb)/.055),0 18px 48px rgb(var(--shade-rgb)/.36); + --shadow-soft:0 1px 0 rgb(var(--tint-rgb)/.045),0 10px 28px rgb(var(--shade-rgb)/.22); + --inset:inset 0 1px 0 rgb(var(--tint-rgb)/.045); + /* == Spectrum system: shared atmosphere, cards, controls, active state; built only from theme tokens == */ + --spectrum-border:rgb(var(--signal-rgb)/.2);--spectrum-border-strong:rgb(var(--signal-hi-rgb)/.38); + --spectrum-edge-light:rgb(var(--signal-pale-rgb)/.2);--spectrum-edge-hover:rgb(var(--signal-hi-rgb)/.52); + --spectrum-page:radial-gradient(ellipse 72% 48% at 13% -9%,rgb(var(--signal-rgb)/calc(.26*var(--glow))),transparent 62%),radial-gradient(ellipse 56% 42% at 96% 2%,rgb(var(--aura-rgb)/calc(.17*var(--glow))),transparent 60%),radial-gradient(ellipse 44% 34% at 58% 38%,rgb(var(--flare-rgb)/calc(.07*var(--glow))),transparent 70%),radial-gradient(ellipse 56% 38% at 68% 105%,rgb(var(--signal-mid-rgb)/calc(.13*var(--glow))),transparent 68%),linear-gradient(155deg,var(--bg-deep) 0%,var(--bg-lift) 42%,var(--bg2) 68%,var(--bg) 100%); + --spectrum-card:radial-gradient(ellipse 82% 90% at 102% -8%,rgb(var(--signal-hi-rgb)/calc(.1*var(--glow))),transparent 58%),radial-gradient(ellipse 74% 74% at -8% 108%,rgb(var(--aura-rgb)/calc(.08*var(--glow))),transparent 62%),linear-gradient(145deg,rgb(var(--panel2-rgb)/.97),rgb(var(--panel-rgb)/.97) 55%,rgb(var(--bg2-rgb)/.98)); + --spectrum-control:radial-gradient(circle at 88% 0,rgb(var(--signal-hi-rgb)/calc(.08*var(--glow))),transparent 46%),linear-gradient(145deg,rgb(var(--panel2-rgb)/.96),rgb(var(--bg2-rgb)/.98)); + --spectrum-active:linear-gradient(132deg,var(--signal) 0%,var(--signal-mid) 48%,var(--signal-hi) 100%); + --spectrum-active-hover:linear-gradient(132deg,color-mix(in srgb,var(--signal) 86%,var(--white)) 0%,color-mix(in srgb,var(--signal-mid) 86%,var(--white)) 46%,color-mix(in srgb,var(--signal-hi) 86%,var(--white)) 100%); + --spectrum-foil:linear-gradient(90deg,transparent,var(--signal) 18%,var(--signal-hi) 40%,var(--flare) 60%,var(--aura) 80%,transparent); + --spectrum-glow:0 0 0 1px rgb(var(--signal-hi-rgb)/.34),0 8px 26px rgb(var(--signal-rgb)/calc(.34*var(--glow))),inset 0 1px 0 rgb(var(--tint-rgb)/.38); + --spectrum-ease:cubic-bezier(.16,1,.3,1); + /* == Motion: strong curves; UI stays under 300ms, data explanation may run longer == */ + --ease-out:cubic-bezier(.23,1,.32,1); --ease-in-out:cubic-bezier(.77,0,.175,1); + --dur-press:160ms; --dur-enter:320ms; --dur-data:480ms; + --grain:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 .6 0'/%3E%3C/filter%3E%3Crect width='180' height='180' filter='url(%23g)'/%3E%3C/svg%3E"); + --spectrum-shadow:0 1px 0 var(--spectrum-edge-light),0 16px 38px rgb(var(--shade-rgb)/.32),0 5px 16px rgb(var(--signal-rgb)/calc(.06*var(--glow))); + --spectrum-depth-focus:0 1px 0 rgb(var(--signal-pale-rgb)/.22),0 20px 46px rgb(var(--shade-rgb)/.38),0 8px 28px rgb(var(--signal-rgb)/calc(.13*var(--glow))); + /* == Work insights: work-type, tier, effort, and outcome hues == */ + --w1:#079bc2; --w2:#c17a01; --w3:#c36b95; --w4:#af851e; --w5:#9979cd; --w6:#d66555; --w7:#5f8adf; --w8:#05a386; --w-unclear:#56606d; --w-routine-2:#2a8fb4; --w-routine-3:#1d5f7a; --w-routine-4:#334050; --w-pending:#2c3541; --w-tier-light:#05a386; --w-tier-standard:#5f8adf; --w-tier-premium:#c17a01; --w-e1:#644493; --w-e2:#7858a9; --w-e3:#8d6dc0; --w-e4:#a282d7; --w-e5:#b798ee; --w-e6:#ccb0ff; --w-accepted:var(--good); --w-recovered:var(--warn); --w-ended:var(--bad); --w-single:var(--w-unclear); +} diff --git a/dashboard/styles/components.css b/dashboard/styles/components.css new file mode 100644 index 00000000..92fd0dcb --- /dev/null +++ b/dashboard/styles/components.css @@ -0,0 +1,495 @@ +/* == Components: shared building blocks. :where() gives them zero specificity, so any screen rule overrides them. They own identity (color, type, tracking), not layout. == */ +:where(.emptyState){display:grid;place-items:center;align-content:center;gap:4px;padding:18px;color:var(--dim);font-size:var(--fs-12);line-height:1.45;text-align:center} +:where(.emptyState)[hidden]{display:none} +:where(.emptyState>strong,.emptyState>h3){display:block;margin:0;color:var(--fg);font-size:var(--fs-13)} +:where(.emptyState>p){max-width:56ch;margin:0 auto} +:where(.metric){min-width:0} +:where(.metric>span:first-child,.metric>.label){display:block;color:var(--faint);font-size:var(--fs-11);font-weight:var(--fw-heavy);letter-spacing:.04em;text-transform:uppercase} +:where(.metric>strong,.metric>b,.metric>.v){display:block;margin-top:4px;color:var(--fg);font-family:var(--font-mono);font-size:var(--fs-16);font-weight:var(--fw-heavy);font-variant-numeric:tabular-nums} +:where(.metric>small,.metric>.subline,.metric>.sm){display:block;margin-top:2px;color:var(--dim);font-size:var(--fs-11)} +:where(.chartTip){position:absolute;pointer-events:none;border:1px solid rgb(var(--signal-rgb)/.42);border-radius:var(--radius-md);background:var(--tip-bg);box-shadow:var(--shadow-tip);color:var(--dim)} +:where(.valueWithDelta){display:flex;flex-wrap:wrap;align-items:center;gap:6px 16px;min-width:0} +@media(max-width:1180px){:where(.valueWithDelta){gap:6px 10px}} +:where(.trendDelta){display:grid;justify-items:start;gap:2px;color:var(--dim);line-height:1.15} +:where(.trendDelta)[hidden]{display:none} +:where(.trendDelta>strong){font-family:var(--font-mono);font-size:var(--fs-20);font-weight:var(--fw-heavy);font-variant-numeric:tabular-nums;white-space:nowrap} +:where(.trendDelta>span){max-width:16ch;color:var(--dim);font-size:var(--fs-12);font-weight:var(--fw-bold)} +:where(.trendDelta[data-tone=improving]>strong){color:var(--good)} +:where(.trendDelta[data-tone=degrading]>strong){color:var(--bad)} +:where(.trendDelta[data-direction=""]>strong){color:var(--faint);font-family:var(--font-sans);font-size:var(--fs-12);font-weight:var(--fw-bold)} +:where(.readoutScale){--scale-tone:var(--signal);position:relative;display:block;height:8px;margin-top:8px;border-radius:var(--radius-2xs);background:rgb(var(--tint-rgb)/.06)} +:where(.readoutScale)[hidden]{display:none} +:where(.readoutScale[data-tone=high]){--scale-tone:var(--warn)} +:where(.readoutScale>.readoutScaleFill){position:absolute;inset:0 auto 0 0;width:var(--at,0%);border-radius:inherit;background:var(--scale-tone);-webkit-mask:var(--dither-50) 0 0/4px 4px;mask:var(--dither-50) 0 0/4px 4px;transition:width var(--dur-data) var(--ease-out)} +:where(.readoutScale>.readoutScaleTypical){position:absolute;top:-3px;bottom:-3px;left:var(--typ,50%);width:2px;border-radius:var(--radius-2xs);background:var(--soft);transform:translateX(-50%)} +:where(.readoutScale>.readoutScaleMark){position:absolute;top:-2px;bottom:-2px;left:var(--at,0%);width:4px;border-radius:var(--radius-2xs);background:var(--scale-tone);box-shadow:0 0 10px var(--scale-tone);transform:translateX(-50%);transition:left var(--dur-data) var(--ease-out)} +:where(.readoutCaption){display:block;margin-top:6px;color:var(--soft);font-size:var(--fs-12);font-weight:var(--fw-medium);line-height:1.35} +:where(.readoutCaption)[hidden]{display:none} +:where(.chartGapBridge){fill:none;stroke:var(--faint);stroke-width:1.5;stroke-dasharray:2 4;stroke-linecap:round} +:where(.readoutCaption)::before{content:"› ";color:var(--signal);font-family:var(--font-mono);font-weight:var(--fw-heavy)} +:where(.conceptFrame){display:grid;align-content:start;gap:16px;min-width:0;margin:0;padding:20px;border:1px solid var(--spectrum-border);border-radius:var(--radius-lg);background:var(--spectrum-card);box-shadow:var(--spectrum-shadow);color:var(--fg)} +:where(.conceptFrame)[hidden]{display:none} +:where(.conceptFrame>.conceptFrameHead){display:flex;align-items:center;justify-content:space-between;gap:8px;min-height:22px;color:var(--faint);font-size:var(--fs-11);font-weight:var(--fw-heavy);letter-spacing:.04em;text-transform:uppercase} +:where(.conceptFrame>.conceptFrameHead>.chip){letter-spacing:0;text-transform:none} +:where(.conceptFrame>.conceptFrameStage){position:relative;display:grid;gap:12px;min-width:0} +:where(.conceptFrame>.conceptFrameStage [data-callout]){position:relative} +:where(.conceptFrame>figcaption){margin:0;padding-top:14px;border-top:1px solid var(--line)} +:where(.frameCallout){display:inline-grid;flex:none;place-items:center;width:24px;height:24px;border:2px solid var(--bg-deep);border-radius:var(--radius-round);background:var(--spectrum-active);box-shadow:var(--spectrum-glow);color:var(--on-accent);font:var(--fw-heavy) var(--fs-12)/1 var(--font-mono);font-style:normal;letter-spacing:0;text-transform:none;vertical-align:middle} +:where(.frameCallout)[hidden]{display:none} +:where(.frameCallout[data-at]){position:absolute;z-index:3} +:where(.frameCallout[data-at=tr]){top:-12px;right:-12px} +:where(.frameCallout[data-at=tl]){top:-12px;left:-12px} +:where(.frameCallout[data-at=t]){top:-14px;left:50%;transform:translateX(-50%)} +:where(.frameCallout[data-at=r]){top:50%;right:-32px;transform:translateY(-50%)} +:where(.frameCallout[data-at=l]){top:50%;left:-32px;transform:translateY(-50%)} +:where(.frameCallout[data-at=bar]){bottom:calc(var(--bar-h) + 6px);left:50%;transform:translateX(-50%)} +:where(.frameLegend){display:grid;gap:10px;margin:0;padding:0;list-style:none} +:where(.frameLegend)[hidden]{display:none} +:where(.frameLegend>li){display:grid;grid-template-columns:24px minmax(0,1fr);align-items:center;gap:12px;color:var(--fg);font-size:var(--fs-14);font-weight:var(--fw-medium);line-height:1.4} +:where(.levelBar){--level-tone:var(--spectrum-active);position:relative;display:block;height:8px;border-radius:var(--radius-pill);background:rgb(var(--tint-rgb)/.07)} +:where(.levelBar)[hidden]{display:none} +:where(.levelBar[data-tone=warn]){--level-tone:var(--warn)} +:where(.levelBar>i){display:block;width:var(--fill,0%);min-width:2px;height:100%;border-radius:inherit;background:var(--level-tone);transition:width var(--dur-data) var(--ease-out)} +:where(.levelBar>b){position:absolute;top:-4px;bottom:-4px;left:var(--mark,0%);width:2px;border-radius:var(--radius-2xs);background:var(--fg);transform:translateX(-50%)} +@media(prefers-reduced-motion:reduce){:where(.readoutScale>i){transition:none}} +.badge.codex{color:var(--provider-codex);border-color:rgb(var(--provider-codex-rgb)/.42);background:rgb(var(--provider-codex-rgb)/.08)} +.badge.claude{color:var(--provider-claude);border-color:rgb(var(--provider-claude-rgb)/.42);background:rgb(var(--provider-claude-rgb)/.08)} +.badge.cursor{color:var(--provider-cursor);border-color:rgb(var(--provider-cursor-rgb)/.44);background:rgb(var(--provider-cursor-rgb)/.08)} +.badge.opencode{color:var(--provider-opencode);border-color:rgb(var(--provider-opencode-rgb)/.42);background:rgb(var(--provider-opencode-rgb)/.08)} +.badge.kiro{color:var(--provider-kiro);border-color:rgb(var(--provider-kiro-rgb)/.42);background:rgb(var(--provider-kiro-rgb)/.08)} +.srow .subagentTag{display:inline-block;margin-right:6px;padding:1px 6px;font-size:var(--fs-11);font-weight:var(--fw-bold);letter-spacing:.03em;vertical-align:1px;color:var(--aura);border-color:rgb(var(--aura-rgb)/.34);background:rgb(var(--aura-rgb)/.08)} +/* Child-agent subsection inside a parent session card. The parent row stays a + single button; children are their own buttons nested in a non-interactive + container so a child click never double-fires the parent navigation. */ +.subagents{margin-top:10px;padding:10px 12px 8px;border:1px solid var(--line);border-radius:var(--radius-sm);background:rgb(var(--tint-rgb)/.022);display:grid;gap:6px;min-width:0} +.subagentsHead{display:flex;align-items:center;justify-content:space-between;gap:10px;min-width:0} +.subagentsTitle{display:inline-flex;align-items:center;gap:6px;font-size:var(--fs-11);font-weight:var(--fw-bold);text-transform:uppercase;letter-spacing:.06em;color:var(--faint);min-width:0} +.subagentsTitle b{color:var(--dim);font-weight:var(--fw-bold);letter-spacing:0} +.subagentsStats{font-size:var(--fs-12);color:var(--faint);white-space:nowrap;font-variant-numeric:tabular-nums} +.subagentsStats b{color:var(--fg);font-weight:var(--fw-heavy)} +.subagentList{display:grid;gap:4px;min-width:0} +.subagentRow{display:grid;grid-template-columns:minmax(0,1fr) auto auto;gap:10px;align-items:center;width:100%;text-align:left;font:inherit;color:inherit;background:rgb(var(--tint-rgb)/.028);border:1px solid transparent;border-radius:var(--radius-sm);padding:6px 8px;cursor:pointer;min-width:0;transition:border-color .16s,background .16s} +.subagentRow:hover,.subagentRow:focus-visible{border-color:rgb(var(--signal-rgb)/.34);background:rgb(var(--signal-rgb)/.07);outline:none} +.subagentMain{min-width:0;overflow:hidden} +.subagentMain .t{font-size:var(--fs-13);font-weight:var(--fw-bold);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.subagentMain .meta{font-size:var(--fs-11);color:var(--faint);margin-top:2px;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0} +.subagentRole{display:inline-flex;align-items:center;border:1px solid rgb(var(--pink-rgb)/.34);background:rgb(var(--pink-rgb)/.11);color:var(--pink);border-radius:var(--radius-xs);padding:1px 6px;font-size:var(--fs-11);font-weight:var(--fw-bold);margin-right:6px;white-space:nowrap;vertical-align:1px} +.subagentTokens{font-size:var(--fs-12);color:var(--dim);white-space:nowrap;font-variant-numeric:tabular-nums} +.subagentCost{font-size:var(--fs-13);font-weight:var(--fw-heavy);text-align:right;white-space:nowrap;font-variant-numeric:tabular-nums} +.subagentZero{font-weight:var(--fw-semibold);color:var(--faint)} +.subagentMore{font-size:var(--fs-11);color:var(--faint);padding:1px 2px} +@media(max-width:1100px){.subagentRow{grid-template-columns:minmax(0,1fr) auto}.subagentTokens{display:none}} +.signalEmpty{min-height:112px;padding:18px} +.settingsLexicon{box-shadow:var(--shadow-soft)} +.settingsLexiconHead{display:flex;align-items:flex-start;justify-content:space-between;gap:12px} +.settingsLexicon h2{font-size:var(--fs-16);line-height:1.25;margin:4px 0} +.settingsLexicon p{color:var(--dim);font-size:var(--fs-12);line-height:1.45;margin:0;max-width:420px} +.settingsField{display:grid;gap:6px;margin-top:12px} +.settingsField span{font-size:var(--fs-11);text-transform:uppercase;color:var(--faint);font-weight:var(--fw-heavy)} +.settingsField textarea{width:100%;min-height:150px;resize:vertical;border:1px solid var(--line2);background:rgb(var(--tint-rgb)/.045);color:var(--fg);border-radius:var(--radius-md);padding:8px 10px;font:var(--fs-12)/1.45 var(--font-mono);box-shadow:var(--inset)} +.settingsField textarea:focus{outline:none;border-color:var(--accent);box-shadow:var(--focus-ring)} +.settingsStatus{width:100%;font-size:var(--fs-11);color:var(--faint)} +.settingsStatus.good{color:var(--good)} +.settingsStatus.warn{color:var(--warn)} +.previewHead{display:flex;align-items:flex-end;justify-content:space-between;gap:18px;margin:3px 0 14px} +.previewHeadCopy{max-width:760px} +.previewHead h1{font-size:var(--fs-28);line-height:1.08;margin:6px 0 0} +.previewHead p{color:var(--dim);font-size:var(--fs-13);margin:6px 0 0} +.previewHead p:empty{display:none} +.previewRunMeta{display:flex;align-items:center;gap:8px;margin-bottom:10px;min-width:0} +.sessionBackRow{display:flex;margin-bottom:10px} +.sessionBack{display:inline-flex;align-items:center;gap:6px} +.previewRunMeta .repo{max-width:460px} +.previewRunGrid{display:grid;grid-template-columns:minmax(0,1.75fr) minmax(300px,.65fr);gap:12px;align-items:stretch} +.previewChartSlot,.previewDecision{min-width:0} +.previewChartSlot{display:grid} +.previewChartSlot #session-chart-module{display:grid;grid-template-rows:auto minmax(0,1fr);gap:12px} +.previewChartSlot .section-h,.previewDecision>.section-h{margin:0} +.previewChartSlot #session-chart-module>.card{min-height:350px} +.previewChartSlot .chart{height:286px} +.previewDecision{display:grid;grid-template-columns:minmax(0,1fr);grid-template-rows:auto minmax(0,1fr) auto;gap:12px} +.previewStatus{display:flex;flex-direction:column;overflow:hidden;padding:16px;background:linear-gradient(155deg,rgb(var(--panel2-rgb)/.98),rgb(var(--bg2-rgb)/.98))} +.previewStatus:before{content:"";position:absolute;inset:0 0 auto;height:3px;background:linear-gradient(90deg,var(--good),var(--accent),transparent)} +.previewStatus.warn:before{background:linear-gradient(90deg,var(--bad),var(--warn),transparent)} +.previewStatusTop{display:flex;align-items:center;justify-content:space-between;gap:12px} +.previewState{display:inline-flex;align-items:center;gap:6px;color:var(--good);font-size:var(--fs-11);font-weight:var(--fw-heavy);text-transform:uppercase;letter-spacing:.05em} +.previewState:before{content:"";width:7px;height:7px;border-radius:var(--radius-round);background:currentColor;box-shadow:0 0 10px currentColor} +.previewStatus.warn .previewState{color:var(--bad)} +.previewIdle{color:var(--faint);font-size:var(--fs-11)} +.previewCost{font-size:var(--fs-readout-xl);font-weight:var(--fw-heavy);line-height:1.02;margin-top:12px;white-space:nowrap} +.previewCostNote{color:var(--dim);font-size:var(--fs-12);margin-top:6px} +.previewPressure{margin-top:16px;padding-top:12px;border-top:1px solid var(--line)} +.previewStatus .previewPressure{margin-block:auto} +.previewPressureHead{display:flex;align-items:baseline;justify-content:space-between;gap:10px;color:var(--dim);font-size:var(--fs-12)} +.previewPressureHead b{color:var(--fg)} +.previewPressureHead>span{color:var(--faint);font-size:var(--fs-11);text-transform:uppercase;letter-spacing:.04em} +.previewPressureBar{height:7px;margin-top:6px;border-radius:var(--radius-pill);background:rgb(var(--tint-rgb)/.07);overflow:hidden} +.previewPressureBar i{display:block;width:0;height:100%;border-radius:inherit;background:linear-gradient(90deg,var(--accent),var(--signal-hi));transition:width .2s} +.previewRunBudgetSlot .sessionBudget{margin:0;box-shadow:var(--shadow-soft)} +.previewRunBudgetSlot .sessionBudgetHead{align-items:flex-start;flex-direction:column;gap:8px} +.previewRunBudgetSlot .sessionBudgetExact{width:100%;justify-content:space-between} +.previewRunBudgetSlot .sessionBudgetTrack{grid-template-columns:52px minmax(0,1fr) 60px} +.previewKpis{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:8px;margin-top:12px} +.previewKpi{padding:12px 12px;min-height:92px} +.previewKpi .v{font-size:var(--fs-20);margin-top:4px;overflow-wrap:anywhere} +.previewKpi .sm{font-size:var(--fs-11);margin-top:3px} +.previewStartText{font-size:var(--fs-14);font-weight:var(--fw-bold);line-height:1.45;margin-top:6px;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden} +.sessionChartModule{min-width:0} +.previewChartColumn{display:grid;grid-template-rows:minmax(0,1fr) auto;gap:12px;min-width:0} +.previewChartColumn .previewTokenSlot{margin-top:0} +.previewTimes{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:12px;padding-top:12px;border-top:1px solid var(--line)} +.previewTimes div{min-width:0} +.previewTimes span{display:block;color:var(--faint);font-size:var(--fs-11);text-transform:uppercase;letter-spacing:.04em} +.previewTimes b{display:block;margin-top:4px;color:var(--dim);font-size:var(--fs-11);line-height:1.5;overflow:hidden;text-overflow:ellipsis;white-space:pre} +.previewKpis{grid-template-columns:repeat(4,minmax(0,1fr));gap:8px} +.previewKpi{min-height:96px} +.previewKpi .v{font-size:var(--fs-readout-md)} +.settingsPageLayout{display:grid;gap:10px} +.themePicker{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin:14px 0 10px} +.themeOption{display:grid;gap:8px;min-width:0;padding:8px;border:1px solid var(--line2);border-radius:var(--radius-md);background:rgb(var(--tint-rgb)/.02);color:var(--fg);text-align:left;font:inherit;cursor:pointer;transition:border-color .16s,box-shadow .16s,transform .16s} +.themeOption:hover{border-color:var(--spectrum-edge-hover);transform:translateY(-1px)} +.themeOption[aria-pressed=true]{border-color:var(--signal-hi);box-shadow:var(--spectrum-glow)} +.themePreview{position:relative;display:block;height:48px;overflow:hidden;border-radius:var(--radius-sm);background:var(--theme-swatch-bg);box-shadow:inset 0 0 0 1px rgb(var(--tint-rgb)/.08)} +.themePreview i{position:absolute;display:block;left:10px} +.themePreview i:nth-child(1){top:9px;width:42%;height:8px;border-radius:var(--radius-pill);background:var(--theme-swatch)} +.themePreview i:nth-child(2){top:24px;width:58%;height:4px;border-radius:var(--radius-pill);background:rgb(var(--tint-rgb)/.16)} +.themePreview i:nth-child(3){right:10px;bottom:8px;height:18px;background:var(--theme-swatch);opacity:.55;-webkit-mask-image:repeating-linear-gradient(90deg,var(--black) 0 7px,transparent 7px 11px),linear-gradient(0deg,var(--black),transparent);mask-image:repeating-linear-gradient(90deg,var(--black) 0 7px,transparent 7px 11px),linear-gradient(0deg,var(--black),transparent);-webkit-mask-composite:source-in;mask-composite:intersect} +.themeCopy{display:grid;gap:2px;min-width:0} +.themeCopy b{font-family:var(--font-display);font-size:var(--fs-14);font-weight:var(--fw-semibold)} +.themeCopy small{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;color:var(--dim);font-size:var(--fs-12);line-height:1.35} +.appearanceSettings kbd{padding:1px 4px;border:1px solid var(--line2);border-radius:var(--radius-xs);color:var(--fg);font:var(--fw-bold) var(--fs-11)/1.35 var(--font-mono)} +.settingsMap{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));align-items:stretch;padding:8px 10px;overflow:hidden} +.settingsMap:before{content:"";position:absolute;inset:0 0 auto;height:3px;background:linear-gradient(90deg,var(--accent),var(--signal-hi),transparent)} +.settingsMap button{width:100%;display:grid;grid-template-columns:25px minmax(0,1fr);gap:8px;align-items:center;text-align:left;border:0;border-left:1px solid var(--line);background:transparent;color:var(--dim);padding:6px 10px;font:inherit;cursor:pointer;border-radius:0} +.settingsMap button:hover,.settingsMap button:focus-visible{color:var(--fg);background:rgb(var(--tint-rgb)/.03)} +.settingsMap button>span:first-child{display:grid;place-items:center;width:23px;height:23px;border:1px solid var(--line2);border-radius:var(--radius-sm);color:var(--signal-hi);font:var(--fw-heavy) var(--fs-11)/1 var(--font-mono)} +.settingsMap button b,.settingsMap button small{display:block} +.settingsMap button b{font-size:var(--fs-12)} +.settingsMap button small{color:var(--faint);font-size:var(--fs-11);font-weight:var(--fw-medium);margin-top:1px} +.settingsPageContent{display:grid;gap:10px;min-width:0} +.settingsPageContent>.card{scroll-margin-top:100px;width:100%;padding:14px 16px} +.settingsPageContent .settingsLexicon p{max-width:760px} +.settingsPageContent .agentClients{margin-top:12px} +.sessionChartModule{min-width:0} +.previewSurface{display:none} +.previewSurface.on{display:block} +.previewStartStrip{display:grid;grid-template-columns:max-content minmax(0,1fr);align-items:center;gap:12px;margin-bottom:12px;padding:10px 14px;min-width:0;border-left:3px solid var(--accent)} +.previewStartLabel{color:var(--signal-hi);font-size:var(--fs-11);font-weight:var(--fw-heavy);text-transform:uppercase;letter-spacing:.05em;white-space:nowrap} +.previewStartStrip .previewStartText{display:block;min-width:0;margin:0;color:var(--fg);font-size:var(--fs-13);font-weight:var(--fw-bold);line-height:1.35;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.previewStartStrip .previewStartText:after{bottom:auto;top:calc(100% + 8px);z-index:60} +.previewTokenSlot{margin-top:12px;min-width:0} +.previewTokenSlot>.usageSplit{width:100%;margin:0} +.previewTokenSlot .bar{height:26px} +.previewKpi.fieldtip{display:block;overflow:visible} +.previewKpi.fieldtip:after{bottom:auto;top:calc(100% + 8px);z-index:45} +.previewKpi:hover,.previewKpi:focus{z-index:50;border-color:rgb(var(--signal-rgb)/.34)} +.previewSpeed .v{color:var(--accent);text-shadow:0 0 18px rgb(var(--signal-rgb)/.18)} +.sessionChartModule{min-width:0} +.currentSessionFilterGroups{display:flex;align-items:center;gap:10px;min-width:0;overflow-x:auto;scrollbar-width:none} +.currentSessionFilterGroups::-webkit-scrollbar{display:none} +.currentSessionFilterGroup{display:flex;align-items:center;gap:4px;flex:0 0 auto} +.currentSessionFilterGroup[hidden]{display:none} +.currentSessionFilterLabel{color:var(--faint);font-size:var(--fs-11);font-weight:var(--fw-heavy);letter-spacing:.06em;text-transform:uppercase} +.currentSessionsCount{white-space:nowrap} +.currentOverviewHidden{display:none!important} +.currentSessionsHead{display:flex;align-items:center;gap:12px;margin:0 0 12px} +.currentSessionsCount{color:var(--faint);font:var(--fw-bold) var(--fs-11)/1.2 var(--font-mono)} +.currentSessionGrid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px} +.currentSessionCard{--harness:var(--provider-other);appearance:none;display:flex;flex-direction:column;width:100%;min-width:0;min-height:218px;overflow:hidden;padding:20px 20px 16px;border:1px solid color-mix(in srgb,var(--harness) 48%,var(--line));border-radius:var(--radius-lg);background:linear-gradient(160deg,rgb(var(--panel2-rgb)/.98),rgb(var(--bg2-rgb)/.98));box-shadow:var(--shadow-soft);color:var(--fg);font:inherit;text-align:left;cursor:grab;transition:transform .16s,border-color .16s,box-shadow .16s,opacity .16s} +.currentSessionCard:before{content:"";position:absolute;inset:0 auto 0 0;width:3px;background:var(--harness);box-shadow:0 0 18px color-mix(in srgb,var(--harness) 55%,transparent)} +.currentSessionCard:hover{transform:translateY(-2px);border-color:color-mix(in srgb,var(--harness) 78%,var(--line));box-shadow:0 14px 30px rgb(var(--shade-rgb)/.34)} +.currentSessionCard:focus-visible{outline:0;border-color:var(--harness);box-shadow:0 0 0 3px color-mix(in srgb,var(--harness) 20%,transparent),var(--shadow)} +.currentSessionCard.dragging{opacity:.42;cursor:grabbing;transform:scale(.99)} +.currentSessionCard.dropBefore{box-shadow:inset 4px 0 0 var(--harness),var(--shadow)} +.currentSessionCard.dropAfter{box-shadow:inset -4px 0 0 var(--harness),var(--shadow)} +.currentSessionTop{display:flex;align-items:center;justify-content:space-between;gap:12px} +.currentSessionRuntime{display:inline-flex;align-items:center;gap:8px;min-width:0;color:var(--harness);font-size:var(--fs-12);font-weight:var(--fw-heavy);letter-spacing:.012em} +.currentSessionRuntime:before{content:"";width:7px;height:7px;flex:0 0 auto;border-radius:var(--radius-2xs);background:currentColor;box-shadow:0 0 10px currentColor} +.currentSessionRuntime span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.currentSessionStatus{display:inline-flex;align-items:center;gap:6px;flex:0 0 auto;color:var(--faint);font-size:var(--fs-11);font-weight:var(--fw-bold)} +.currentSessionStatus:before{content:"";width:6px;height:6px;border-radius:var(--radius-round);background:currentColor} +.currentSessionStatus.working{color:var(--good)} +.currentSessionStatus.working:before{box-shadow:0 0 9px currentColor} +.currentSessionStatus.waiting{color:var(--warn)} +.currentSessionStatus.recent{color:var(--faint)} +.cacheRing{--ring-tone:var(--good);position:relative;display:inline-grid;place-items:center;flex:0 0 auto;width:40px;height:40px;margin:-10px 0;color:var(--fg)} +.cacheRing svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible} +.cacheRing line{stroke:var(--ring-tone);stroke-width:1.5;stroke-linecap:round;transition:stroke .5s,opacity .5s} +.cacheRing line.off{stroke:var(--line2)} +.cacheRing line.maybe{stroke:var(--warn);opacity:.38} +.cacheRing b{position:relative;font-family:var(--font-mono);font-size:var(--fs-11);font-weight:var(--fw-semibold);letter-spacing:0;line-height:1} +.cacheRing.warn{--ring-tone:var(--warn)} +.cacheRing.bad{--ring-tone:var(--bad);animation:cacheRingPulse 1s ease-in-out infinite} +.cacheRing.maybe b,.cacheRing.cold b{color:var(--faint)} +@keyframes cacheRingPulse{50%{filter:drop-shadow(0 0 4px var(--bad))}} +@media(prefers-reduced-motion:reduce){.cacheRing.bad{animation:none}.cacheRing line{transition:none}} +.currentSessionIdentity{margin-top:18px;min-width:0} +.currentSessionIdentity h3{margin:0;font-size:var(--fs-20);line-height:1.2;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.currentSessionIdentity p{margin:4px 0 0;color:var(--faint);font:var(--fs-11)/1.25 var(--font-mono);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.currentSessionMetrics{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;margin-top:20px;padding-top:16px;border-top:1px solid var(--line)} +.currentSessionMetric{min-width:0} +.currentSessionMetric span{font-size:var(--fs-11)} +.currentSessionMetric b{margin-top:6px;font-size:var(--fs-18);line-height:1.15;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.currentSessionMetric b.mono{font-size:var(--fs-18);letter-spacing:-.015em} +.currentSessionMove{display:inline-flex;align-items:center;gap:4px} +.currentSessionCaps{display:block;margin-top:6px;overflow:hidden;color:var(--faint);font-size:var(--fs-11);text-overflow:ellipsis;white-space:nowrap} +.currentSessionProject{flex:0 1 auto;color:var(--faint);font-family:var(--font-mono);font-size:var(--fs-11);font-weight:var(--fw-medium);letter-spacing:0} +.currentSessionProject:before{content:"·";margin-right:8px} +.currentSessionTopMeta{display:inline-flex;flex:0 0 auto;align-items:center;gap:12px;color:var(--faint);font-size:var(--fs-11)} +.currentSessionMove b{color:var(--harness);font-size:var(--fs-13);letter-spacing:-2px} +.currentSessionOrderStatus{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap} +.currentSessionsEmpty{padding:32px 20px;text-align:center} +.currentSessionsEmpty h3{font-size:var(--fs-18);margin:0} +.currentSessionsEmpty p{margin:4px auto 0;color:var(--dim);font-size:var(--fs-12)} +.budgetDashboard{display:block} +.budgetLead{overflow:hidden;background:linear-gradient(145deg,rgb(var(--panel2-rgb)/.98),rgb(var(--bg2-rgb)/.98))} +.budgetLead:before{content:"";position:absolute;inset:0 0 auto;height:3px;background:linear-gradient(90deg,var(--accent),var(--signal-hi),transparent 78%)} +.budgetLead h2,.budgetConfig h2{font-size:var(--fs-18);margin:4px 0 0} +.budgetLeadHeadActions{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap} +.budgetLeadBody{display:grid;grid-template-columns:minmax(0,1.08fr) minmax(440px,.92fr);gap:18px;align-items:stretch;margin-top:14px} +.budgetSpend{margin:0;display:flex;flex-direction:column;justify-content:center} +.budgetSpendValue{font-size:var(--fs-hero-fluid);font-weight:var(--fw-heavy);line-height:1;letter-spacing:-1.5px;margin-top:4px} +.budgetState{display:inline-flex;align-items:center;gap:6px} +.budgetState:before{content:"";width:7px;height:7px;border-radius:var(--radius-round);background:currentColor;box-shadow:0 0 10px currentColor} +.budgetState.on_track{color:var(--good);border-color:rgb(var(--green-rgb)/.32);background:rgb(var(--green-rgb)/.07)} +.budgetState.warning{color:var(--warn);border-color:rgb(var(--orange-rgb)/.34);background:rgb(var(--orange-rgb)/.07)} +.budgetState.over{color:var(--bad);border-color:rgb(var(--red-rgb)/.36);background:rgb(var(--red-rgb)/.08)} +.budgetProgress{position:relative;height:10px;border:1px solid var(--line2);border-radius:var(--radius-pill);background:rgb(var(--tint-rgb)/.035);overflow:hidden;margin-top:12px} +.budgetProgress>i{display:block;height:100%;width:0;background:linear-gradient(90deg,var(--accent),var(--signal));border-radius:var(--radius-pill);transition:width .2s} +.budgetProgress.warn>i{background:linear-gradient(90deg,var(--warn),var(--bad))} +.budgetProgressMarkers{position:absolute;inset:0;pointer-events:none} +.budgetProgressMarkers span{position:absolute;top:0;bottom:0;width:1px;background:rgb(var(--tint-rgb)/.58);box-shadow:0 0 0 1px rgb(var(--shade-rgb)/.18);transform:translateX(-1px)} +.budgetRuntimeCard{min-width:0;overflow:hidden;padding:14px 16px} +.spendBudgetSection{display:grid;gap:10px;margin:0 0 12px} +.spendBudgetEditor .budgetConfig{margin-top:0} +.budgetConfig[hidden]{display:none} +.spendBudgetSection .budgetForm.budgetInlineForm{grid-template-columns:minmax(220px,.7fr) minmax(0,1.3fr);column-gap:24px;row-gap:12px;align-items:start} +.spendBudgetSection .budgetConfigHead,.spendBudgetSection .budgetInlineForm .budgetActions{grid-column:1/-1} +.spendBudgetSection .budgetRuntimeFields{grid-column:2;grid-row:2} +.spendBudgetSection .budgetSessionDefault{grid-template-columns:minmax(0,220px)} +@media(max-width:900px){.spendBudgetSection .budgetForm.budgetInlineForm{grid-template-columns:1fr}.spendBudgetSection .budgetRuntimeFields{grid-column:auto;grid-row:auto}} +.spendBudgetSection .budgetLead{padding:16px 18px 14px;background:linear-gradient(150deg,rgb(var(--panel2-rgb)/.96),rgb(var(--bg-rgb)/.96))} +.spendBudgetSection .budgetLead:before{height:2px;opacity:.75} +.budgetLeadTop{display:flex;align-items:center;justify-content:space-between;gap:14px} +.budgetLeadTitle{display:flex;align-items:baseline;gap:10px;min-width:0;flex-wrap:wrap} +.spendBudgetSection .budgetLead h2{font-size:var(--fs-16);margin:0} +.budgetMonth{color:var(--fg);font-size:var(--fs-12);font-weight:var(--fw-bold);opacity:.82} +.budgetMeta{color:var(--faint);font-size:var(--fs-12)} +.budgetMeta:not(:empty):before{content:"·";margin-right:10px;color:var(--line3)} +.spendBudgetSection .budgetLeadBody{grid-template-columns:minmax(0,1fr) minmax(250px,.42fr);gap:24px;align-items:center;margin-top:12px} +.budgetFigure{display:grid;gap:6px} +.budgetFigureLine{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap;min-width:0} +.spendBudgetSection .budgetSpendValue{font-size:var(--fs-readout-lg);font-weight:var(--fw-heavy);letter-spacing:-1px;line-height:1;margin:0;font-variant-numeric:tabular-nums} +.budgetQualifier{align-self:center;padding:3px 8px;border:1px solid rgb(var(--orange-rgb)/.34);border-radius:var(--radius-pill);color:var(--warn);font-size:var(--fs-11);font-weight:var(--fw-heavy);text-transform:uppercase;letter-spacing:.04em} +.budgetQualifier[hidden]{display:none} +.budgetOf{color:var(--dim);font-size:var(--fs-14)} +.budgetOf b{color:var(--fg);font-size:var(--fs-18);font-weight:var(--fw-bold)} +.budgetTotalNote{color:var(--faint);font-size:var(--fs-11)} +.spendBudgetSection .budgetProgress{height:7px;margin-top:12px;border-color:rgb(var(--tint-rgb)/.08)} +.budgetMeterFoot{display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-top:8px;color:var(--dim);font-size:var(--fs-12)} +.spendBudgetSection .budgetCoverage{margin:0;font-size:var(--fs-11)} +.budgetStats{display:grid;gap:0;margin:0;padding-left:24px;border-left:1px solid var(--line)} +.budgetStats>div{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:baseline;gap:2px 12px;padding:10px 0} +.budgetStats>div+div{border-top:1px solid var(--line)} +.budgetStats dt{color:var(--faint);font-size:var(--fs-11);font-weight:var(--fw-heavy);text-transform:uppercase;letter-spacing:.04em} +.budgetStats dd{margin:0} +.budgetStats dd.mono{font-size:var(--fs-20);font-weight:var(--fw-heavy);text-align:right;font-variant-numeric:tabular-nums} +.budgetStats dd.mono.warn{color:var(--warn)} +.budgetStatNote{grid-column:1/-1;color:var(--faint);font-size:var(--fs-11);line-height:1.35} +.budgetRuntimeList{display:grid;margin-top:6px} +.budgetRuntime{--provider-color:var(--accent);display:grid;grid-template-columns:minmax(0,1fr) auto;gap:4px 12px;padding:8px 0;border-top:1px solid var(--line)} +.budgetRuntime:first-child{border-top:0;padding-top:2px} +.budgetRuntimeName{display:flex;align-items:center;gap:8px;font-weight:var(--fw-heavy)} +.budgetRuntimeName:before{content:"";width:7px;height:7px;border-radius:var(--radius-round);background:var(--provider-color);box-shadow:0 0 9px var(--provider-color)} +.budgetRuntimeValue{display:flex;align-items:baseline;justify-content:flex-end;gap:6px} +.budgetRuntimeValue b{font-weight:var(--fw-heavy)} +.budgetRuntimeValue small{color:var(--faint);font-size:var(--fs-11);font-weight:var(--fw-semibold)} +.budgetRuntimeTrack{grid-column:1/-1;height:5px;border-radius:var(--radius-pill);background:rgb(var(--tint-rgb)/.05);overflow:hidden} +.budgetRuntimeTrack i{display:block;height:100%;border-radius:inherit;background:var(--provider-color)} +.budgetConfig{min-width:0;margin-top:12px;transition:border-color .18s ease,box-shadow .18s ease} +.budgetConfig.attention{border-color:rgb(var(--signal-rgb)/.62);box-shadow:var(--focus-ring),var(--shadow-soft)} +.budgetConfigHead{display:flex;align-items:flex-start;justify-content:space-between;gap:18px} +.budgetConfigHead .subline{margin-top:3px} +.budgetForm{display:grid;grid-template-columns:minmax(300px,.75fr) minmax(440px,1.15fr) auto;gap:14px;margin-top:12px;align-items:end} +.budgetFormGroup{min-width:0;margin:0;padding:0;border:0} +.budgetFormGroup legend{width:100%;padding:0 0 6px;border-bottom:1px solid var(--line);color:var(--dim);font-size:var(--fs-11);font-weight:var(--fw-heavy);text-transform:uppercase;letter-spacing:.04em} +.budgetForm label{display:grid;gap:4px} +.budgetForm label span{font-size:var(--fs-11);text-transform:uppercase;color:var(--faint);font-weight:var(--fw-heavy)} +.budgetForm input[type=number],.budgetForm input[type=text]{width:100%;border:1px solid var(--line2);background:rgb(var(--tint-rgb)/.045);color:var(--fg);border-radius:var(--radius-sm);padding:6px 8px;font:var(--fs-12)/1.2 var(--font-mono)} +.budgetForm input:focus{outline:none;border-color:var(--accent);box-shadow:var(--focus-ring)} +.budgetActions{display:grid;align-content:end;justify-items:start;gap:6px;min-width:128px;padding:0;border:0} +.budgetActions .tbtn{white-space:nowrap} +.budgetActions .settingsStatus{font-size:var(--fs-11);line-height:1.25} +.budgetCoverage{margin-top:6px;color:var(--dim);font-size:var(--fs-11)} +.budgetCoverage.warn{color:var(--warn)} +.budgetForm.budgetInlineForm{grid-template-columns:1fr;gap:10px;margin-top:12px;align-items:stretch} +.budgetInlineForm .budgetActions{display:flex;align-items:center;justify-content:flex-start;gap:8px;min-width:0;padding-top:8px;border-top:1px solid var(--line)} +.budgetInlineForm .budgetRuntimeList{grid-template-columns:1fr;gap:0;margin-top:0} +.budgetRuntimeHead{display:grid;grid-template-columns:minmax(90px,.6fr) minmax(135px,1fr) minmax(100px,.8fr);gap:12px;padding:6px 0;color:var(--faint);font-size:var(--fs-11);font-weight:var(--fw-heavy);text-transform:uppercase} +.budgetRuntime{grid-template-columns:minmax(90px,.6fr) minmax(135px,1fr) minmax(100px,.8fr);grid-template-areas:"name value input";gap:4px 12px;align-items:center;padding:8px 0;border:0;border-top:1px solid var(--line);border-radius:0;background:transparent} +.budgetRuntime:first-child{border-top:1px solid var(--line);padding-top:8px} +.budgetRuntimeName{grid-area:name} +.budgetRuntimeValue{grid-area:value;align-self:start;display:flex;gap:6px;justify-content:flex-start;justify-items:start} +.budgetRuntimeTrack{grid-area:value;grid-column:auto;align-self:end;height:3px;transform:translateY(2px)} +.budgetRuntimeInput{grid-area:input;align-self:center;display:grid;gap:4px} +.budgetRuntimeInput span{display:none;font-size:var(--fs-11);text-transform:uppercase;color:var(--faint);font-weight:var(--fw-heavy)} +.budgetConfigHead{display:grid;grid-template-columns:minmax(108px,1fr) auto;align-items:center;gap:14px} +.budgetConfigHead .subline{margin-top:3px} +.budgetAlertInline{display:flex;align-items:center;justify-content:flex-end;gap:8px;min-height:32px} +.budgetAlertTitle{color:var(--dim);font-size:var(--fs-11);font-weight:var(--fw-heavy);white-space:nowrap} +.budgetThresholds{display:flex;align-items:center;gap:4px} +.budgetThresholds label{position:relative;display:block} +.budgetForm .budgetThresholds input[type=number]{width:46px;padding:6px 14px 6px 4px;text-align:right;appearance:textfield} +.budgetThresholds input::-webkit-inner-spin-button,.budgetThresholds input::-webkit-outer-spin-button{margin:0;appearance:none} +.budgetForm .budgetThresholds label span{position:absolute;right:5px;top:50%;transform:translateY(-50%);color:var(--faint);font:var(--fw-bold) var(--fs-11)/1 var(--font-mono);pointer-events:none} +.budgetNotifySwitch{position:relative;display:flex!important;align-items:center;cursor:pointer} +.budgetNotifySwitch input{position:absolute;width:1px;height:1px;opacity:0;pointer-events:none} +.budgetNotifySwitch input:checked+.sw{background:var(--good)} +.budgetNotifySwitch input:checked+.sw:after{left:16px} +.budgetNotifySwitch input:focus-visible+.sw{outline:2px solid var(--accent);outline-offset:2px} +.budgetForm.budgetInlineForm{margin-top:0} +.budgetSessionDefault{display:grid;grid-template-columns:minmax(160px,220px);gap:12px;align-items:end;margin-top:8px} +.settingsPricing{grid-column:1/-1} +.settingsPricing .agentAccessBadge[hidden]{display:none} +.modelPricingProvenance{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:8px;color:var(--faint);font-size:var(--fs-11)} +.modelPricingProvenance a{color:var(--dim);text-decoration:none} +.modelPricingProvenance a:hover{color:var(--accent);text-decoration:underline} +.modelPriceScope{display:flex;align-items:end;gap:12px;flex-wrap:wrap;margin:12px 0 0;padding:10px 10px;border:1px solid var(--line);border-radius:var(--radius-md);background:rgb(var(--tint-rgb)/.025)} +.modelPriceScope legend{position:static;width:100%;height:auto;padding:0;margin:0;overflow:visible;clip:auto;white-space:normal;border:0;color:var(--fg);font-size:var(--fs-11);font-weight:var(--fw-heavy)} +.modelPriceScopeOptions{display:flex;border:1px solid var(--line2);border-radius:var(--radius-md);padding:2px;background:rgb(var(--shade-rgb)/.14)} +.modelPriceScopeOptions label{position:relative;display:flex;cursor:pointer} +.modelPriceScopeOptions input{position:absolute;opacity:0;pointer-events:none} +.modelPriceScopeOptions span{padding:6px 10px;border-radius:var(--radius-sm);color:var(--dim);font-size:var(--fs-12);font-weight:var(--fw-heavy)} +.modelPriceScopeOptions input:checked+span{background:rgb(var(--signal-rgb)/.16);color:var(--fg);box-shadow:var(--inset)} +.modelPriceScopeOptions input:focus-visible+span{outline:2px solid var(--accent);outline-offset:1px} +.modelPriceEffective{display:grid;gap:4px;margin-left:auto} +.modelPriceEffective span{color:var(--faint);font-size:var(--fs-11);text-transform:uppercase} +.modelPriceEffective input{width:190px;border:1px solid var(--line2);background:rgb(var(--tint-rgb)/.045);color:var(--fg);border-radius:var(--radius-sm);padding:6px 6px;font:var(--fs-11)/1.2 var(--font-mono);color-scheme:dark} +.modelPriceEffective input:focus{outline:none;border-color:var(--accent);box-shadow:var(--focus-ring)} +.modelPriceEffective input:disabled{opacity:.45} +.modelPriceScope>span{flex:1 1 100%;color:var(--dim);font-size:var(--fs-12)} +#model-price-selection-count{color:var(--fg);font-weight:var(--fw-bold)} +.modelPriceScope.history{border-color:rgb(var(--orange-rgb)/.48);background:rgb(var(--orange-rgb)/.07)} +.modelPriceScope.history>span{color:var(--warn)} +.modelPriceTabs{display:flex;gap:3px;width:max-content;max-width:100%;overflow-x:auto;margin-top:12px;padding:3px;border:1px solid var(--line2);border-radius:var(--radius-md);background:rgb(var(--shade-rgb)/.14);scrollbar-width:none} +.modelPriceTabs:empty{display:none} +.modelPriceTabs button{display:flex;align-items:center;gap:8px;border:0;border-radius:var(--radius-sm);background:transparent;color:var(--dim);padding:6px 12px;font:inherit;font-size:var(--fs-12);font-weight:var(--fw-heavy);white-space:nowrap;cursor:pointer} +.modelPriceTabs button:hover{color:var(--fg);background:rgb(var(--signal-rgb)/.07)} +.modelPriceTabs button[aria-selected=true]{background:rgb(var(--signal-rgb)/.16);color:var(--fg);box-shadow:var(--inset)} +.modelPriceTabs button:focus-visible{outline:2px solid var(--accent);outline-offset:1px} +.modelPriceTabs .count{color:var(--faint);font:var(--fs-11)/1 var(--font-mono)} +.modelPriceTabs .edited{width:6px;height:6px;border-radius:var(--radius-round);background:var(--warn)} +.modelPriceTable tr[hidden]{display:none!important} +.modelPriceTableWrap{overflow:auto;max-height:min(460px,58vh);overscroll-behavior:contain;margin-top:8px;border:1px solid var(--line);border-radius:var(--radius-md)} +.modelPriceTable{min-width:1030px} +.modelPriceTable th{white-space:nowrap} +.modelPriceTable td{vertical-align:middle} +.modelPriceTable tr.selected{outline:1px solid rgb(var(--signal-rgb)/.58);outline-offset:-1px;background:rgb(var(--signal-rgb)/.055)} +.modelPriceSelectCell{width:54px;text-align:center} +.modelPriceSelectCell input{width:16px;height:16px;accent-color:var(--accent);cursor:pointer} +.modelPriceModel{display:grid;gap:3px;min-width:205px} +.modelPriceModel code{font-size:var(--fs-12);color:var(--fg)} +.modelPriceSource{font-size:var(--fs-11);color:var(--faint);text-transform:capitalize} +.modelPriceLifecycle{justify-self:start;border:0;background:transparent;color:var(--accent);padding:1px 0;text-align:left;font:inherit;font-size:var(--fs-11);font-weight:var(--fw-bold);line-height:1.3;cursor:pointer} +.modelPriceLifecycle:hover{text-decoration:underline} +.modelPriceLifecycle:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:var(--radius-2xs)} +.modelPriceInput{width:90px;border:1px solid var(--line2);background:rgb(var(--tint-rgb)/.045);color:var(--fg);border-radius:var(--radius-sm);padding:6px 6px;font:var(--fs-11)/1.2 var(--font-mono)} +.modelPriceInput:focus,.modelPriceAdd input:focus,.modelPriceAdd select:focus{outline:none;border-color:var(--accent);box-shadow:var(--focus-ring)} +.modelPriceAdd{display:grid;grid-template-columns:150px minmax(180px,1.4fr) repeat(4,minmax(105px,.65fr)) auto;gap:8px;align-items:end;margin-top:14px;padding-top:12px;border-top:1px solid var(--line)} +.modelPriceAdd label{display:grid;gap:4px;min-width:0} +.modelPriceAdd label span{font-size:var(--fs-11);text-transform:uppercase;color:var(--faint);font-weight:var(--fw-heavy)} +.modelPriceAdd input,.modelPriceAdd select{width:100%;min-width:0;border:1px solid var(--line2);background:rgb(var(--tint-rgb)/.045);color:var(--fg);border-radius:var(--radius-sm);padding:6px 8px;font:var(--fs-11)/1.2 var(--font-mono)} +.modelPriceAdd select{font-family:inherit} +.discountCue{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-top:12px;padding:10px 12px;border:1px solid rgb(var(--signal-rgb)/.35);border-radius:var(--radius-md);background:rgb(var(--signal-rgb)/.07);color:var(--dim);font-size:var(--fs-12)} +.discountCue.active{border-color:var(--line);background:rgb(var(--tint-rgb)/.03)} +.discountCue b{color:var(--fg)} +.discountCue .discountCueText{display:grid;gap:2px;min-width:0} +.discountCue .discountCueActions{display:flex;gap:8px;align-items:center} +.discountCueList{display:flex;gap:6px;flex-wrap:wrap;align-items:center} +.discountLink{border:0;background:none;padding:0;color:var(--faint);font:inherit;font-size:var(--fs-11);cursor:pointer;text-decoration:underline} +.discountCard,.spendBudgetEditor{min-width:0} +.discountCard .modelPriceTable{min-width:760px} +.discountCard h2{font-size:var(--fs-16);margin:0} +.discountAdd{grid-template-columns:repeat(auto-fill,minmax(150px,1fr))} +.discountAdd .tbtn{justify-self:start;align-self:end;padding:6px 12px} +.discountAdd .hint{display:block;font-size:var(--fs-11);color:var(--faint);text-transform:none;font-weight:var(--fw-medium)} +.discountMore{margin-top:12px;border-top:1px solid var(--line);padding-top:10px} +.discountMore summary{cursor:pointer;color:var(--dim);font-size:var(--fs-12);font-weight:var(--fw-heavy)} +.discountMore p{margin:8px 0;color:var(--faint);font-size:var(--fs-12)} +.discountMore code{color:var(--dim)} +.discountApps{display:flex;gap:12px;flex-wrap:wrap} +.discountApps label{display:grid;gap:4px} +.discountApps label span{font-size:var(--fs-11);text-transform:uppercase;color:var(--faint);font-weight:var(--fw-heavy)} +.discountApps select{border:1px solid var(--line2);background:rgb(var(--tint-rgb)/.045);color:var(--fg);border-radius:var(--radius-sm);padding:6px 8px;font:inherit;font-size:var(--fs-12)} +.modelPriceAdd .tbtn{height:31px;white-space:nowrap} +.modelPricingFooter{display:flex;align-items:center;gap:10px;justify-content:flex-end;margin-top:12px;padding-top:12px;border-top:1px solid var(--line)} +.modelPricingFooter .settingsStatus{margin-right:auto} +.modelPricingFooter .tbtn:disabled{opacity:.42;cursor:not-allowed} +.modelPricingEmpty{padding:18px;text-align:center;color:var(--faint)} +.softwareUpdates{display:grid;gap:8px;padding:12px 16px!important} +.softwareUpdateHead{display:flex;align-items:flex-start;justify-content:space-between;gap:14px} +.softwareUpdateHead h2{margin-top:2px} +.softwareUpdateHead p{margin-top:3px;color:var(--dim);font-size:var(--fs-12);line-height:1.4} +.softwareUpdateToggle{display:flex;align-items:flex-start;gap:8px;padding:8px 10px;border:1px solid var(--line);border-radius:var(--radius-md);background:rgb(var(--tint-rgb)/.025);cursor:pointer} +.softwareUpdateToggle input{margin-top:2px;accent-color:var(--accent)} +.softwareUpdateToggle span{display:grid;gap:1px} +.softwareUpdateToggle b{font-size:var(--fs-13)} +.softwareUpdateToggle small{color:var(--faint);font-size:var(--fs-11);line-height:1.35} +.softwareUpdateActions{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:10px} +.softwareUpdateActions .tbtn:disabled{opacity:.45;cursor:not-allowed} +.softwareUpdateActions .settingsStatus{width:auto;min-width:0} +.softwareUpdateMeta{justify-self:end;color:var(--faint);font:var(--fs-11)/1.4 var(--font-mono);white-space:nowrap} +.settingsMap:before,.agentAccess:before,.budgetLead:before{display:none} +.settingsPageContent .agentAccessHead h2{font-size:var(--fs-16);margin:0} +.settingsPageContent :is(.learnSectionHead,.softwareUpdateHead) h2.fieldtip{display:flex;width:fit-content;margin:0} +.workSettings>.softwareUpdateHead{margin-bottom:14px} +.workSettings>.softwareUpdateHead .subline{margin-top:4px} +.softwareUpdateToggles{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px} +.softwareUpdateToggle:has(input:disabled){opacity:.55;cursor:not-allowed} +@media(max-width:900px){.modelPriceAdd{grid-template-columns:repeat(2,minmax(0,1fr))}.modelPriceAdd .tbtn{justify-self:start}.softwareUpdateToggles{grid-template-columns:1fr}} +@media(max-width:700px){.modelPriceTableWrap{overflow:visible;border:0}.modelPriceTable{display:block;min-width:0}.modelPriceTable thead{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.modelPriceTable tbody{display:grid;gap:10px}.modelPriceTable tr{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;padding:10px;border:1px solid var(--line);border-radius:var(--radius-md);background:rgb(var(--tint-rgb)/.02)}.modelPriceTable td{display:grid;gap:4px;padding:0;border:0;text-align:left}.modelPriceTable td:before{content:attr(data-label);color:var(--faint);font-size:var(--fs-11);font-weight:var(--fw-heavy);text-transform:uppercase}.modelPriceTable td:nth-child(1){grid-column:1;grid-row:1}.modelPriceTable td:nth-child(2){grid-column:1/-1;grid-row:2}.modelPriceTable td:nth-child(3){grid-column:2;grid-row:1}.modelPriceTable td:nth-child(n+4){grid-column:auto}.modelPriceTable .badge{justify-self:start}.modelPriceSelectCell{width:auto;text-align:left}.modelPriceModel{min-width:0}.modelPriceInput{width:100%}.modelPricingEmpty{display:block!important;grid-column:1/-1!important}.modelPriceScopeOptions{width:100%}.modelPriceScopeOptions label{flex:1}.modelPriceScopeOptions span{width:100%;text-align:center}.modelPriceEffective{width:100%;margin-left:0}.modelPriceEffective input{width:100%}.modelPricingFooter{align-items:flex-start;flex-direction:column}.modelPricingFooter .settingsStatus{margin-right:0}} +@media(max-width:520px){.modelPriceAdd{grid-template-columns:1fr}.modelPriceScope .modelPriceEffective{width:100%;margin-left:0;justify-content:space-between}.modelPriceEffective input{width:min(210px,62vw)}} +@media(max-width:520px){.updateNotice{right:14px;bottom:14px;max-width:calc(100vw - 28px)}.updateReady .toastbox{bottom:66px}.softwareUpdateHead{display:block}.softwareUpdateHead .agentAccessBadge{display:inline-block;margin-top:6px}.softwareUpdateActions{grid-template-columns:1fr;align-items:stretch;gap:6px}.softwareUpdateActions .tbtn{width:100%}.softwareUpdateMeta{justify-self:start;white-space:normal}} +.spark .b.estimated{background:repeating-linear-gradient(135deg,var(--accent) 0 5px,rgb(var(--signal-rgb)/.28) 5px 10px)} +.capFilters{grid-template-columns:minmax(220px,1fr) auto auto minmax(130px,auto)} +.capFilters select{border:1px solid var(--line2);background:var(--panel);color:var(--fg);border-radius:var(--radius-sm);padding:6px 8px;font:inherit;font-weight:var(--fw-bold);color-scheme:dark} +@media(max-width:900px){.section-h{flex-wrap:wrap}} +@media(max-width:1120px){.hero{grid-template-columns:1fr}.costcard{min-height:0}.overview{grid-template-columns:repeat(2,minmax(0,1fr))}} +@media(max-width:900px){.budgetLeadBody{grid-template-columns:1fr}} +@media(max-width:980px){.budgetForm{grid-template-columns:minmax(280px,.8fr) minmax(0,1.2fr)}.budgetActions{grid-column:1/-1;display:flex;align-items:center;justify-items:normal;padding-top:8px;border-top:1px solid var(--line)}} +@media(max-width:1120px){.previewRunGrid{grid-template-columns:minmax(0,1.5fr) minmax(280px,.7fr)}.previewKpis{grid-template-columns:repeat(3,minmax(0,1fr))}} +.currentSessionGrid{gap:10px} +.currentSessionCard{min-height:190px;padding:16px 18px 14px} +.currentSessionIdentity{margin-top:14px} +.currentSessionIdentity h3{font-size:var(--fs-18)} +.currentSessionMetrics{gap:12px;margin-top:14px;padding-top:12px} +.currentSessionMetric b{margin-top:4px;font-size:var(--fs-18)} +.currentSessionMetric b.mono{font-size:var(--fs-18)} +.currentSessionContextValue{display:flex;align-items:flex-end;gap:8px;min-width:0} +.currentSessionContextValue b{flex:0 0 auto} +.currentSessionContextUnavailable{display:block;min-width:0;margin:0 0 2px;color:var(--faint);font:var(--fw-bold) var(--fs-11)/1.1 var(--font-mono);letter-spacing:.02em;white-space:nowrap} +.currentSessionContextSpark{--context-tone:var(--harness);display:block;flex:1 1 44px;width:auto;min-width:30px;max-width:88px;height:20px;margin:0 0 1px;overflow:visible} +.currentSessionContextSpark.watch{--context-tone:var(--warn)} +.currentSessionContextSpark.high{--context-tone:var(--bad)} +.currentSessionMetric b,.currentSessionMetric b.mono{font-size:var(--fs-20)} +.currentSessionContextSpark{height:22px} +.currentSessionContextBaseline{fill:none;stroke:rgb(var(--tint-rgb)/.1);stroke-width:1;vector-effect:non-scaling-stroke} +.currentSessionContextBar{fill:var(--harness);opacity:.42} +.currentSessionContextBar.watch{fill:var(--warn);opacity:.78} +.currentSessionContextBar.high{fill:var(--bad);opacity:.86} +.currentSessionContextBar.latest{opacity:1;filter:drop-shadow(0 0 2px color-mix(in srgb,var(--harness) 60%,transparent))} +.currentSessionContextBar.watch.latest{filter:drop-shadow(0 0 2px rgb(var(--orange-rgb)/.48))} +.currentSessionContextBar.high.latest{filter:drop-shadow(0 0 2px rgb(var(--red-rgb)/.52))} +@media(max-width:1120px){.currentSessionGrid{grid-template-columns:repeat(2,minmax(0,1fr))}} +@media(max-width:1120px) and (min-width:901px){.globalControls{grid-template-columns:minmax(220px,1fr) minmax(190px,.8fr) minmax(150px,.55fr)}.filterSort{grid-column:1/3}.filterCount{align-self:end}} +@media(max-width:900px){.logsSummary{grid-template-columns:1fr}.logKpis,.modelHero{grid-template-columns:repeat(2,minmax(0,1fr))}.modelHead{align-items:flex-start;flex-direction:column}.modelControls{width:100%;grid-template-columns:repeat(2,minmax(0,1fr))}.efficiencySupport{grid-template-columns:repeat(2,minmax(0,1fr))}.efficiencyHead{align-items:flex-start;flex-direction:column}.efficiencyRange{width:100%;min-width:0}} +@media(max-width:900px){.budgetForm{grid-template-columns:1fr}} +@media(max-width:900px){.wrap{padding:14px}.top{display:grid;grid-template-columns:minmax(0,1fr) auto auto;gap:8px 8px;padding-top:8px}.top .brand{grid-column:1/-1;min-width:0}.top .tabs{grid-column:1/-1;justify-self:stretch;justify-content:flex-start;width:100%;max-width:100%;overflow-x:auto}.top .spacer{display:none}.top .notify{grid-column:3;justify-self:end}.globalControls,.capHero,.capFilters,.agentClients,.budgetDashboard{grid-template-columns:1fr}.globalControls .seg,.capFilters .seg{justify-content:flex-start}.globalMeta{white-space:normal}.srow{grid-template-columns:26px 132px minmax(0,1fr) auto 28px}.srow .models{display:none}.meter{font-size:var(--fs-44);overflow-wrap:anywhere}.costcard .meter{font-size:var(--fs-56)}.sessionhead{flex-wrap:wrap}.sessid{max-width:100%}.pill{white-space:normal}.statgrid{grid-template-columns:repeat(2,minmax(0,1fr))}.dailyHead{align-items:flex-start;flex-direction:column}.agentDiscovery{align-items:flex-start;flex-wrap:wrap}.agentAccessHead{display:block}.agentAccessBadge{display:inline-block;margin-top:10px}} +@media(max-width:900px){.previewHead{align-items:flex-start;flex-direction:column}.previewRunGrid{grid-template-columns:1fr}.settingsMap{grid-template-columns:repeat(2,minmax(0,1fr))}.settingsMap button{border:1px solid var(--line);border-radius:var(--radius-sm);padding:8px}.previewChartSlot .chart{height:250px}.previewKpis{grid-template-columns:repeat(3,minmax(0,1fr))}} +@media(max-width:520px){body{overflow-x:hidden}.tab,.seg button{padding:6px 10px}.top{grid-template-columns:minmax(0,1fr) auto}.top .notify{grid-column:1/-1;justify-self:start}.commandPalette{width:calc(100vw - 20px)}.commandSearch{padding:12px}.commandFooter{justify-content:flex-start;flex-wrap:wrap}.commandDescription{white-space:normal}.sessionStart{grid-template-columns:1fr;gap:3px}.overview,.modelHero,.efficiencyHero{grid-template-columns:1fr;gap:8px}.efficiencySupport{grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.efficiencyHeadline{min-height:226px;padding:16px}.efficiencyHeadline .v{font-size:var(--fs-36)}.efficiencyModelHead,.efficiencyEvidence{align-items:flex-start;flex-direction:column}.ov{padding:12px 12px}.stat .v,.ov .v{font-size:var(--fs-22)}.meter{font-size:var(--fs-44)}.costcard .meter{font-size:var(--fs-44)}.costcard .subline{white-space:normal}.costmeta,.statgrid,.modelControls{grid-template-columns:1fr}.repo{max-width:100%}.dailyHead{width:100%}.modelChart{height:230px}.modelTrendHead{display:block}.outputPair{display:block}.outputRate{justify-items:start;margin-top:4px}.settingsLexiconHead{display:block}.settingsStatus{width:100%;margin-left:0}} +@media(max-width:520px){.previewHead h1{font-size:var(--fs-24)}.previewRunMeta{flex-wrap:wrap}.previewRunMeta .repo{max-width:100%;width:100%}.previewChartSlot #session-chart-module>.card{min-height:0}.previewChartSlot .chart{height:226px}.previewChartSlot .chartbar{align-items:flex-start}.previewChartSlot .chartbar .label{width:100%;margin:0}.previewKpis{grid-template-columns:repeat(2,minmax(0,1fr))}.previewKpi{min-height:82px;padding:10px}.previewKpi .v{font-size:var(--fs-18)}.previewCost{font-size:var(--fs-36)}.budgetForm,.settingsMap{grid-template-columns:1fr}.budgetLeadHeadActions{justify-content:flex-start;margin-top:10px}.learnSectionHead .budgetLeadHeadActions .tbtn{margin-top:0}.budgetConfigHead{display:block}.budgetActions .settingsStatus{width:auto}.budgetRuntimeValue{display:grid;gap:1px;justify-items:end}.settingsMap button{width:100%;margin:0}.settingsPageContent>.card{padding:12px}.settingsPageContent .modelPriceTableWrap{margin-left:-4px;margin-right:-4px}} +@media(max-width:520px){.budgetRuntimeHead{display:none}.budgetRuntime{grid-template-columns:minmax(0,1fr) minmax(100px,108px);grid-template-areas:"name input" "value input";padding:8px 0}.budgetRuntime:first-child{padding-top:8px}.budgetRuntimeValue{justify-items:start}.budgetRuntimeInput span{display:block}} +@media(max-width:520px){.budgetSpendValue{font-size:var(--fs-readout-xl)}} +@media(max-width:520px){.previewStartStrip{grid-template-columns:1fr;gap:4px}.previewTokenSlot .legend{gap:8px}} +@media(max-width:700px){.currentSessionGrid{grid-template-columns:1fr;gap:8px}.currentSessionCard{min-height:184px;padding:14px 14px 12px}.currentSessionIdentity{margin-top:12px}.currentSessionIdentity h3{font-size:var(--fs-18)}.currentSessionMetrics{grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;margin-top:12px;padding-top:10px}.currentSessionMetric b,.currentSessionMetric b.mono{font-size:var(--fs-16);letter-spacing:-.025em}.currentSessionContextValue{gap:4px}.currentSessionContextSpark{min-width:24px;max-width:58px;height:17px}.currentSessionCard.dropBefore{box-shadow:inset 0 4px 0 var(--harness),var(--shadow)}.currentSessionCard.dropAfter{box-shadow:inset 0 -4px 0 var(--harness),var(--shadow)}} +@media(max-width:700px){.currentSessionMetric b,.currentSessionMetric b.mono{font-size:var(--fs-18)}.currentSessionContextSpark{height:19px}} diff --git a/dashboard/styles/git.css b/dashboard/styles/git.css new file mode 100644 index 00000000..51ebf51c --- /dev/null +++ b/dashboard/styles/git.css @@ -0,0 +1,98 @@ +/* git-delivery-economics-v1 */ +.deliveryInsightSection{margin-top:24px} +.deliveryInsightHead{display:flex;align-items:flex-end;justify-content:space-between;gap:18px;margin-bottom:10px;padding-top:20px;border-top:1px solid var(--line)} +.deliveryInsightHead h2{margin:0;font-size:var(--fs-18)} +.deliveryInsightHead p{max-width:760px;margin:4px 0 0;color:var(--dim);font-size:var(--fs-12);line-height:1.5} +.deliveryInsightGrid{display:grid;grid-template-columns:minmax(300px,.78fr) minmax(0,1.22fr);align-items:start;gap:12px} +.deliverySignalCard,.deliveryShapeCard{padding:16px 18px} +.deliveryInsightCardHead{display:flex;align-items:flex-start;justify-content:space-between;gap:14px} +.deliveryInsightCardHead h3{margin:0;font-size:var(--fs-14)} +.deliveryInsightCardHead p{margin:3px 0 0;color:var(--faint);font-size:var(--fs-11);line-height:1.45} +.deliverySignalCard{display:flex;height:360px;min-height:0;flex-direction:column} +.deliverySignalList{display:grid;flex:1;min-height:0;margin-top:12px;padding-right:6px;overflow-y:auto;overscroll-behavior:contain;scrollbar-gutter:stable;scrollbar-width:thin;border-top:1px solid var(--line)} +.deliverySignal{display:grid;grid-template-columns:8px minmax(0,1fr);gap:8px;align-items:start;border-bottom:1px solid var(--line);padding:10px 0} +.deliverySignal:last-child{border-bottom:0} +.deliverySignal i{width:7px;height:7px;border-radius:var(--radius-round);margin-top:4px;background:var(--accent)} +.deliverySignal.warn i{background:var(--warn)} +.deliverySignal.good i{background:var(--good)} +.deliverySignalBody b{display:block;color:var(--fg);font-size:var(--fs-12);font-weight:var(--fw-bold)} +.deliverySignalBody span{display:block;margin-top:2px;color:var(--dim);font-size:var(--fs-11);line-height:1.45} +.deliverySignalEmpty{min-height:150px;padding:16px} +.deliveryShape{margin-top:10px} +.deliveryShapeHeader,.deliveryShapeRow{display:grid;grid-template-columns:130px minmax(150px,1fr) minmax(230px,.86fr);align-items:center;gap:12px} +.deliveryShapeHeader{padding:0 0 6px;color:var(--faint);font-size:var(--fs-11);font-weight:var(--fw-heavy);text-transform:uppercase} +.deliveryShapeHeader span:last-child{display:grid;grid-template-columns:minmax(0,.78fr) minmax(0,1.44fr) minmax(0,.78fr);gap:8px;text-align:right} +.deliveryShapeRow{min-height:62px;border-top:1px solid var(--line)} +.deliveryShapeIdentity b{display:block;font-size:var(--fs-12)} +.deliveryShapeIdentity small{display:block;margin-top:2px;color:var(--faint);font-size:var(--fs-11)} +.deliveryShapeRail{position:relative;height:22px} +.deliveryShapeRail:before{content:"";position:absolute;top:10px;right:0;left:0;height:2px;border-radius:var(--radius-pill);background:rgb(var(--tint-rgb)/.09)} +.deliveryShapeIqr{position:absolute;top:7px;left:var(--p25);width:calc(var(--p75) - var(--p25));height:8px;border-radius:var(--radius-pill);background:rgb(var(--signal-rgb)/.3)} +.deliveryShapeMedian,.deliveryShapeHigh{position:absolute;top:4px;left:var(--median);width:2px;height:14px;border-radius:var(--radius-2xs);background:var(--accent);transform:translateX(-50%)} +.deliveryShapeHigh{top:7px;left:var(--p90);width:7px;height:7px;border:1px solid var(--panel);background:var(--signal-hi);transform:translateX(-50%) rotate(45deg)} +.deliveryShapeHigh.beyond{border-color:var(--signal-hi);background:transparent;transform:translateX(-90%) rotate(45deg)} +.deliveryShapeValues{display:grid;grid-template-columns:minmax(0,.78fr) minmax(0,1.44fr) minmax(0,.78fr);gap:8px;text-align:right} +.deliveryShapeValues span{min-width:0;overflow:hidden;color:var(--dim);font:var(--fw-semibold) var(--fs-11)/1.3 var(--font-mono);text-overflow:ellipsis;white-space:nowrap} +.deliveryShapeEmpty{min-height:212px} +.deliveryChartViewport{min-width:0} +.deliveryMobileScrollHint{display:none} +.deliveryModels{margin-top:12px;overflow:hidden;box-shadow:var(--shadow-soft)} +.deliveryModelNote{color:var(--faint);font-size:var(--fs-11);text-align:right} +.deliveryModels .modelName{max-width:280px} +.deliverySpendShare,.deliveryComposition{display:block;height:4px;margin-top:4px;overflow:hidden;border-radius:var(--radius-pill);background:rgb(var(--tint-rgb)/.06)} +.deliverySpendShare i{display:block;width:calc(var(--share,0)*1%);height:100%;border-radius:inherit;background:linear-gradient(90deg,var(--signal-mid),var(--signal-hi))} +.deliveryComposition{display:flex} +.deliveryComposition i{height:100%;background:var(--signal)} +.deliveryComposition i.deleted{background:var(--c-deleted)} +@media(max-width:1180px){.deliveryShapeHeader,.deliveryShapeRow{grid-template-columns:112px minmax(96px,1fr) minmax(240px,.88fr)}} +@media(max-width:900px){.deliveryInsightGrid{grid-template-columns:1fr}.deliveryInsightHead{align-items:flex-start;flex-direction:column}.deliveryShapeHeader,.deliveryShapeRow{grid-template-columns:minmax(0,1fr) minmax(150px,1fr)}.deliveryShapeHeader span:last-child,.deliveryShapeValues{grid-column:1/-1;grid-row:2}.deliveryShapeRow{padding-bottom:8px}} +@media(max-width:520px){.deliverySignalCard,.deliveryShapeCard{padding:14px 12px}.deliveryShapeHeader{display:none}.deliveryShapeRow{grid-template-columns:1fr}.deliveryShapeValues{grid-row:auto}.deliveryShapeValues span{display:grid;gap:2px;text-align:left}.deliveryShapeValues span:before{content:attr(data-label);color:var(--faint);font:var(--fw-bold) var(--fs-11)/1.2 var(--font-sans);text-transform:uppercase}.deliveryMobileScrollHint{display:block;margin:6px 0 2px;color:var(--faint);font-size:var(--fs-11);text-align:right}.deliveryChartViewport{overflow-x:auto;overscroll-behavior-x:contain;scrollbar-width:thin}.deliveryChartWrap{min-width:720px}.deliveryChart .axis{font-size:var(--fs-11)}} +/* git-evidence-explorer-v2 */ +.deliveryCoverageState{font-family:inherit;cursor:pointer} +.deliveryCoverageState:after{content:"›";color:var(--faint);font-size:var(--fs-13);line-height:8px;transform:rotate(90deg);transition:transform .15s ease} +.deliveryCoverageState[aria-expanded=true]:after{transform:rotate(-90deg)} +.deliveryCoverageState:hover,.deliveryCoverageState:focus-visible{border-color:rgb(var(--signal-hi-rgb)/.5);color:var(--fg);outline:none} +.deliveryEvidencePanel{margin-top:8px;padding:16px 18px;background:linear-gradient(135deg,rgb(var(--signal-rgb)/.045),rgb(var(--orange-rgb)/.025));box-shadow:var(--shadow-soft)} +.deliveryEvidencePanel[hidden]{display:none} +.deliveryEvidenceHead{display:flex;align-items:flex-start;justify-content:space-between;gap:18px} +.deliveryEvidenceHead h2{margin:0;font-size:var(--fs-14)} +.deliveryEvidenceHead p{max-width:720px;margin:4px 0 0;color:var(--dim);font-size:var(--fs-11);line-height:1.45} +.deliveryEvidenceClose{flex:none;width:28px;height:28px;border:1px solid var(--line);border-radius:var(--radius-sm);background:transparent;color:var(--dim);font-size:var(--fs-18);cursor:pointer} +.deliveryEvidenceClose:hover,.deliveryEvidenceClose:focus-visible{border-color:var(--line3);color:var(--fg);outline:none} +.deliveryEvidenceSummary{display:flex;align-items:baseline;gap:8px;margin-top:14px} +.deliveryEvidenceSummary strong{color:var(--fg);font:var(--fw-heavy) var(--fs-22)/1 var(--font-mono)} +.deliveryEvidenceSummary span{color:var(--dim);font-size:var(--fs-11)} +.deliveryEvidenceChoices{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;margin-top:12px} +.deliveryEvidenceChoice{display:grid;grid-template-columns:1fr auto;align-items:center;gap:10px;padding:10px 10px;border:1px solid var(--line);border-radius:var(--radius-sm);background:rgb(var(--tint-rgb)/.018);color:var(--dim);text-align:left;cursor:pointer} +.deliveryEvidenceChoice:hover,.deliveryEvidenceChoice:focus-visible{border-color:rgb(var(--signal-hi-rgb)/.4);background:rgb(var(--signal-rgb)/.04);outline:none} +.deliveryEvidenceChoice span{font-size:var(--fs-11);font-weight:var(--fw-bold)} +.deliveryEvidenceChoice strong{color:var(--fg);font:var(--fw-bold) var(--fs-14)/1 var(--font-mono)} +.deliveryEvidenceMeta{display:flex;justify-content:space-between;gap:18px;margin-top:10px;padding-top:10px;border-top:1px solid var(--line);color:var(--faint);font-size:var(--fs-11);line-height:1.45} +.deliveryDayInspector{display:grid;grid-template-columns:minmax(150px,.72fr) minmax(0,1.6fr) auto;align-items:center;gap:18px;margin-top:8px;padding:10px 12px;border:1px solid rgb(var(--signal-rgb)/.24);border-radius:var(--radius-md);background:rgb(var(--signal-rgb)/.035)} +.deliveryDayInspector[hidden]{display:none} +.deliveryDayIdentity span{display:block;color:var(--faint);font-size:var(--fs-11);font-weight:var(--fw-heavy);text-transform:uppercase} +.deliveryDayIdentity strong{display:block;margin-top:3px;color:var(--fg);font-size:var(--fs-12)} +.deliveryDayIdentity small{display:block;margin-top:2px;color:var(--warn);font-size:var(--fs-11)} +.deliveryDayMetrics{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:8px} +.deliveryDayMetric span{font-size:var(--fs-11)} +.deliveryDayMetric b{margin-top:3px;font-size:var(--fs-11);line-height:1.2} +.deliveryDayActions{display:flex;align-items:center;gap:4px} +.deliveryDayActions button{height:28px;padding:0 8px;border:1px solid var(--line);border-radius:var(--radius-sm);background:transparent;color:var(--dim);font-size:var(--fs-11);cursor:pointer} +.deliveryDayActions button:hover:not(:disabled),.deliveryDayActions button:focus-visible{border-color:var(--line3);color:var(--fg);outline:none} +.deliveryDayActions button:disabled{opacity:.36;cursor:default} +.deliveryDayActions .deliveryDayClose{width:28px;padding:0;font-size:var(--fs-16)} +.deliveryProjectControls{display:flex;align-items:center;gap:8px} +.deliveryEvidenceFilter{display:flex;align-items:center;gap:6px;color:var(--faint);font-size:var(--fs-11);font-weight:var(--fw-heavy);text-transform:uppercase} +.deliveryEvidenceFilter select{height:32px;min-width:150px} +.deliveryTableWrap .tbl{min-width:660px} +.deliveryProjectLink{padding:0;border:0;background:none;color:var(--fg);font:inherit;font-weight:var(--fw-bold);text-align:left;cursor:pointer} +.deliveryProjectLink:hover,.deliveryProjectLink:focus-visible{color:var(--accent);outline:none;text-decoration:underline;text-underline-offset:3px} +.deliverySignal{width:100%;background:none;color:inherit;font:inherit;text-align:left} +.deliverySignal[data-delivery-signal-action]{grid-template-columns:8px minmax(0,1fr) auto;border:0;border-bottom:1px solid var(--line);cursor:pointer} +.deliverySignal[data-delivery-signal-action]:after{content:"›";align-self:center;color:var(--faint);font-size:var(--fs-16)} +.deliverySignal[data-delivery-signal-action]:hover .deliverySignalBody b,.deliverySignal[data-delivery-signal-action]:focus-visible .deliverySignalBody b{color:var(--accent)} +.deliverySignal[data-delivery-signal-action]:focus-visible{outline:1px solid rgb(var(--signal-hi-rgb)/.4);outline-offset:3px;border-radius:var(--radius-xs)} +.deliveryEvidenceFilter select,.deliveryMobileSort select{padding-top:4px;padding-bottom:4px} +@media(max-width:900px){.deliveryEvidenceChoices{grid-template-columns:repeat(2,minmax(0,1fr))}.deliveryDayInspector{grid-template-columns:minmax(140px,.7fr) minmax(0,1.3fr)}.deliveryDayActions{grid-column:1/-1;justify-content:flex-end}} +@media(max-width:700px){.deliveryProjectHead{align-items:flex-start;flex-direction:column}.deliveryProjectControls{width:100%;align-items:flex-end}.deliveryEvidenceFilter{display:grid;grid-template-columns:auto minmax(132px,180px)}} +@media(max-width:520px){.deliveryEvidencePanel{padding:14px 12px}.deliveryEvidenceChoices{grid-template-columns:1fr}.deliveryChartTip{display:none!important}.deliveryEvidenceMeta{flex-direction:column;gap:4px}.deliveryDayInspector{grid-template-columns:1fr;gap:10px}.deliveryDayMetrics{grid-template-columns:repeat(2,minmax(0,1fr))}.deliveryDayActions{grid-column:auto;justify-content:flex-start}.deliveryProjectControls{align-items:stretch;flex-direction:column}.deliveryEvidenceFilter,.deliveryMobileSort{grid-template-columns:42px minmax(0,1fr)}} diff --git a/dashboard/styles/models-learn.css b/dashboard/styles/models-learn.css new file mode 100644 index 00000000..4349e860 --- /dev/null +++ b/dashboard/styles/models-learn.css @@ -0,0 +1,209 @@ +/* models-leaderboard-v1 */ +#view-models .modelSpend{padding:16px 16px 14px} +:is(#view-models,.conceptFrame) .modelSpendHead{display:flex;align-items:baseline;justify-content:space-between;gap:14px;margin-bottom:10px} +:is(#view-models,.conceptFrame) .modelSpendHead h2{font-size:var(--fs-16);margin:0} +:is(#view-models,.conceptFrame) .modelSpendTotal{font:var(--fw-heavy) var(--fs-16)/1.2 var(--font-mono);color:var(--fg);white-space:nowrap} +:is(#view-models,.conceptFrame) .modelSpendBar{display:flex;gap:2px;height:38px;border-radius:var(--radius-md);overflow:hidden;background:rgb(var(--tint-rgb)/.04)} +:is(#view-models,.conceptFrame) .modelSpendSeg{display:flex;align-items:center;justify-content:space-between;gap:8px;min-width:4px;padding:0 10px;border:0;color:var(--on-accent-deep);font:inherit;font-size:var(--fs-12);cursor:default;overflow:hidden;white-space:nowrap} +#view-models button.modelSpendSeg{cursor:pointer} +#view-models button.modelSpendSeg:hover,#view-models button.modelSpendSeg:focus-visible{filter:brightness(1.12);outline:none} +:is(#view-models,.conceptFrame) .modelSpendSeg span{font-weight:var(--fw-bold);overflow:hidden;text-overflow:ellipsis} +:is(#view-models,.conceptFrame) .modelSpendSeg b{font-weight:var(--fw-heavy)} +#view-models .modelSpendEmpty{display:flex;align-items:center;padding:0 12px;color:var(--faint);font-size:var(--fs-12)} +:is(#view-models,.conceptFrame) .modelSpendLegend{display:flex;flex-wrap:wrap;gap:6px 8px;margin-top:10px} +:is(#view-models,.conceptFrame) .modelSpendChip{display:inline-flex;align-items:center;gap:6px;max-width:100%;padding:4px 8px;border:1px solid var(--line);border-radius:var(--radius-pill);background:rgb(var(--tint-rgb)/.03);color:var(--dim);font:inherit;font-size:var(--fs-12)} +#view-models button.modelSpendChip{cursor:pointer} +#view-models button.modelSpendChip:hover,#view-models button.modelSpendChip:focus-visible{border-color:var(--accent);color:var(--fg);outline:none} +:is(#view-models,.conceptFrame) .modelSpendChip i{width:9px;height:9px;border-radius:var(--radius-xs);flex:0 0 auto} +:is(#view-models,.conceptFrame) .modelSpendChipName{color:var(--fg);font-weight:var(--fw-bold);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:190px} +#view-models .modelSpendChip small{color:var(--faint);font-size:var(--fs-11)} +:is(#view-models,.conceptFrame) .modelSpendChip b{color:var(--fg);font-size:var(--fs-12)} +:is(#view-models,.conceptFrame) .modelSpendChip em{font-style:normal;color:var(--faint)} +#view-models .modelSpendNote{margin:8px 0 0;color:var(--faint);font-size:var(--fs-11)} +#view-models .modelSpendNote:empty{display:none} +#view-models .modelLinkBtn{border:0;background:none;padding:0;color:var(--accent);font:inherit;cursor:pointer;text-decoration:underline} +#view-models .modelMixCard .modelChart,#view-models .modelSpeedCard .modelChart{height:250px} +#view-models .modelSpeedCard .modelTrendControls{align-items:flex-end;flex-wrap:nowrap} +#view-models .modelSpeedCard .modelPicker{width:260px} +#view-models .modelSpeedLegend{display:flex;flex-wrap:wrap;gap:6px 14px;margin-top:8px;color:var(--dim);font-size:var(--fs-12)} +#view-models .modelSpeedChip{display:inline-flex;align-items:center;gap:6px;max-width:320px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +#view-models .modelSpeedChip i{width:14px;height:3px;border-radius:var(--radius-2xs);flex:0 0 auto} +#view-models .modelSpeedChip small{color:var(--faint)} +#view-models .modelTableWrap{max-height:none;overflow-x:auto} +#m-table.modelBoard{min-width:0} +#m-table.modelBoard th{vertical-align:bottom;white-space:normal;line-height:1.25} +#m-table .modelSortBtn{display:inline-flex;align-items:center;gap:4px;border:0;background:none;padding:0;color:inherit;font:inherit;text-transform:inherit;letter-spacing:inherit;cursor:pointer} +#m-table th.num .modelSortBtn{justify-content:flex-end} +#m-table .modelSortBtn:after{content:"";width:8px;color:var(--faint);font-size:var(--fs-11)} +#m-table .modelSortBtn.fieldtip{cursor:pointer} +#m-table .modelSortBtn:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:var(--radius-xs)} +#m-table .modelSortBtn.on{color:var(--fg)} +#m-table .modelSortBtn.on[data-direction=desc]:after{content:"\25BE"} +#m-table .modelSortBtn.on[data-direction=asc]:after{content:"\25B4"} +#m-table .modelRankCol{width:30px;color:var(--faint);text-align:right;padding-right:4px} +#m-table td.num{white-space:nowrap} +#m-table .modelNameCell{min-width:210px} +#m-table .modelName{max-width:230px} +#m-table .modelRuntime{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:250px} +#m-table .modelNameLine{gap:0} +#m-table .modelExpand,#m-table .modelExpandSpacer{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;margin-right:4px;flex:0 0 auto} +#m-table .modelExpand{border:1px solid var(--line2);border-radius:var(--radius-xs);background:rgb(var(--tint-rgb)/.04);color:var(--dim);font-size:var(--fs-11);cursor:pointer;padding:0} +#m-table .modelExpand:hover,#m-table .modelExpand:focus-visible{border-color:var(--accent);color:var(--fg);outline:none} +#m-table .modelRuntime{padding-left:20px} +#m-table .modelSpendCell{min-width:170px} +#m-table .modelSpendCell .modelSpendTrack{display:inline-block;width:72px;height:7px;margin-right:8px;vertical-align:middle;border-radius:var(--radius-pill);background:rgb(var(--tint-rgb)/.06);overflow:hidden} +#m-table .modelSpendCell .modelSpendTrack i{display:block;height:100%;border-radius:var(--radius-pill)} +#m-table .modelSpendCell b{display:inline-block;min-width:66px;font-size:var(--fs-13);color:var(--fg)} +#m-table .modelSpendCell small{display:inline-block;min-width:34px;margin-left:6px;color:var(--faint);font-size:var(--fs-11)} +#m-table .modelMuted{color:var(--faint)} +#m-table .modelVariantRow td{background:rgb(var(--tint-rgb)/.018);padding-top:6px;padding-bottom:6px;font-size:var(--fs-12)} +#m-table .modelVariantName{padding-left:44px;color:var(--dim);font-weight:var(--fw-bold)} +#m-table .modelVariantName small{margin-left:8px;color:var(--faint);font-weight:var(--fw-medium)} +#m-table .modelVariantRow .modelSpendCell b{font-size:var(--fs-12);color:var(--dim)} +#view-models .modelDiagnostics{margin-top:12px;padding:0} +#view-models .modelDiagnostics>summary{display:flex;align-items:baseline;gap:12px;padding:14px 16px;cursor:pointer;list-style:none} +#view-models .modelDiagnostics>summary::-webkit-details-marker{display:none} +#view-models .modelDiagnostics>summary:before{content:"\25B8";color:var(--faint);font-size:var(--fs-11);transition:transform .15s ease} +#view-models .modelDiagnostics[open]>summary:before{transform:rotate(90deg)} +#view-models .modelDiagnostics>summary span{font-size:var(--fs-14);font-weight:var(--fw-heavy);color:var(--fg)} +#view-models .modelDiagnostics>summary:focus-visible{outline:2px solid var(--accent);outline-offset:-3px;border-radius:var(--radius-md)} +#view-models .modelDiagHero{grid-template-columns:repeat(3,minmax(0,1fr));padding:0 16px} +#view-models .modelDiagTrend{padding:4px 16px 16px;margin-top:4px} +@media(max-width:1180px){#m-table .modelSpendCell{min-width:0}#m-table .modelSpendCell .modelSpendTrack{width:40px;margin-right:6px}#m-table .modelSpendCell b{min-width:0}#m-table .modelSpendCell small{display:none}#m-table .modelNameCell{min-width:180px}#m-table .modelName{max-width:170px}#m-table td,#m-table th{padding-left:4px;padding-right:4px}#m-table .modelHelp:before{display:none}#m-table th{font-size:var(--fs-11)}#m-table td.num{font-size:var(--fs-12)}#m-table .modelRuntime{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:200px}#view-models .modelSpeedCard .modelPicker{width:210px}} +#view-models .modelKpi .modelValueQual{display:block;margin-top:3px;color:var(--faint);font-size:var(--fs-11);font-weight:var(--fw-bold);line-height:1.2;letter-spacing:0} +.goalsSection{margin:18px 0} +.goalsPrevious{margin-top:14px} +.goalsPrevious summary{cursor:pointer;color:var(--dim);font-size:var(--fs-12);font-weight:var(--fw-bold);margin-bottom:10px} +.goalsHead{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;margin-bottom:12px} +.goalsHead h2{font-size:var(--fs-18);margin:0 0 4px} +.goalsHead p{margin:0;color:var(--dim);font-size:var(--fs-12)} +.goalsNotice{margin:0 0 12px;padding:10px 12px;border:1px solid var(--line2);border-radius:var(--radius-md);color:var(--dim);font-size:var(--fs-12)} +.goalsNotice.tone-warn{border-color:var(--warn)} +.goalsNotice[hidden],.goalsCustom[hidden],.goalsDates[hidden],.goalsFormError[hidden],.goalsRepeat[hidden],.goalsStep[hidden],.goalsList[hidden]{display:none} +.goalActions button:disabled{opacity:.5;cursor:not-allowed} +.goalsDialog{width:min(920px,calc(100vw - 28px));max-height:calc(100vh - 40px);overflow:auto} +.goalsForm{display:grid;gap:16px} +.goalsFormBody{display:grid;grid-template-columns:minmax(0,1fr) minmax(260px,300px);gap:24px;align-items:start;padding:0 20px} +.goalsFields{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px 12px;min-width:0} +.goalsSpan2{grid-column:span 2} +.goalsModelSearch{display:grid;gap:4px;padding:4px 4px 8px;border-bottom:1px solid var(--line);margin-bottom:6px} +.goalsModelSearch input{box-sizing:border-box;width:100%;min-height:32px;border:1px solid var(--line2);border-radius:var(--radius-md);background:var(--panel);color:var(--fg);font:var(--fs-13) var(--font-sans);padding:6px 10px} +.goalPreview{position:sticky;top:16px;display:grid;gap:12px;min-width:0;padding:16px;border:1px solid var(--line2);border-radius:var(--radius-md);background:linear-gradient(160deg,var(--panel2),var(--bg2))} +.goalPreviewKicker{color:var(--faint);font-size:var(--fs-11);font-weight:var(--fw-heavy);letter-spacing:.04em;text-transform:uppercase} +.goalPreview h4{margin:0 0 4px;font-size:var(--fs-16);overflow-wrap:anywhere} +.goalPreview .goalMeta{margin:0} +.goalPreviewLedger{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;padding-top:12px;border-top:1px solid var(--line)} +.goalPreviewReadout strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.goalPreview .readoutScale,.goalPreview .readoutCaption{margin-top:0} +.goalPreviewNote{display:block} +.goalsForm h3{margin:0;padding:20px 20px 0;font-size:var(--fs-18)} +.goalsStep{display:grid;gap:6px;min-width:0;margin:0;padding:0;border:0;color:var(--dim);font-size:var(--fs-12);font-weight:var(--fw-bold)} +.goalsStep legend{padding:0;margin-bottom:6px} +.goalsWide{grid-column:1/-1} +.goalsHelp{margin:0;color:var(--dim);font-size:var(--fs-12);font-weight:var(--fw-medium);line-height:1.45} +.goalsDates{display:grid;grid-template-columns:1fr 1fr;gap:8px} +.goalsStep input:not([type=checkbox]){box-sizing:border-box;width:100%;min-height:38px;border:1px solid var(--line2);border-radius:var(--radius-md);background:var(--panel);color:var(--fg);font:var(--fs-13) var(--font-sans);padding:8px 10px;color-scheme:dark} +.goalsRepeat{display:flex;align-items:center;align-self:end;gap:6px;min-height:38px;color:var(--dim);font-size:var(--fs-12);font-weight:var(--fw-semibold)} +.goalsOptions{display:flex;flex-wrap:wrap;gap:8px} +.goalOption{border:1px solid var(--line2);border-radius:var(--radius-md);background:var(--panel);color:var(--fg);font:var(--fw-bold) var(--fs-12) var(--font-sans);padding:8px 12px;cursor:pointer;text-align:left} +.goalOption small{display:block;color:var(--dim);font-weight:var(--fw-semibold)} +.goalOption[aria-pressed=true]{border-color:var(--accent);background:rgb(var(--signal-rgb)/.12)} +.goalOption:focus-visible{outline:none;box-shadow:var(--focus-ring)} +.goalsCustom{display:grid;grid-template-columns:minmax(0,160px) minmax(0,1fr);gap:8px;margin-top:8px} +.goalsFormError{flex:1;min-width:0;padding:6px 10px;border:1px solid var(--warn);border-radius:var(--radius-md);color:var(--warn);font-size:var(--fs-12)} +.goalsFormActions{position:sticky;bottom:0;display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:14px 20px;border-top:1px solid var(--line);background:var(--panel2)} +.goalsList{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:12px} +.goalsEmpty{grid-column:1/-1} +.goalCard{display:grid;gap:10px;align-content:start;padding:16px;min-width:0;transition:border-color .3s,box-shadow .3s} +.goalCard.is-saved{border-color:var(--accent);box-shadow:var(--focus-ring)} +.goalCardHead{display:flex;justify-content:space-between;gap:10px;align-items:flex-start} +.goalCard h4{margin:0;font-size:var(--fs-14);overflow-wrap:anywhere} +.goalCard .goalMeta{margin:0} +.goalStatus{flex:none;font-weight:var(--fw-heavy)} +.goalStatus.on_track,.goalStatus.met{color:var(--good);border-color:currentColor} +.goalStatus.at_risk{color:var(--warn);border-color:currentColor} +.goalStatus.missed{color:var(--bad);border-color:currentColor} +.goalStatus.no_result{color:var(--faint);border-style:dashed} +.goalStatus.ended{color:var(--faint)} +.goalMeta{margin:4px 0 10px;color:var(--dim);font-size:var(--fs-12)} +.goalNumbers{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap} +.goalNumbers strong{font-size:var(--fs-20)} +.goalNumbers span,.goalDetail{color:var(--dim);font-size:var(--fs-12)} +.goalNumbers .goalConfidence{margin-left:auto;color:var(--faint)} +.goalDetail{margin:0} +.goalFoot{display:flex;align-items:center;gap:10px;min-height:28px} +.goalFoot>:first-child{flex:1;min-width:0} +.goalCard .readoutScale,.goalFoot .readoutCaption{margin-top:0} +.goalFoot .goalActions{margin:0;align-items:center} +.goalDots{display:flex;gap:4px} +.goalDots i{width:8px;height:8px;border-radius:var(--radius-round);background:currentColor} +.goalMenu{position:relative} +.goalMenu>summary{list-style:none;padding:4px 8px;font-size:var(--fs-12);line-height:1;cursor:pointer} +.goalMenu>summary::-webkit-details-marker{display:none} +.goalMenuList{width:160px;display:grid;gap:2px} +.goalMenuList button{border:0;border-radius:var(--radius-sm);background:none;color:var(--fg);font:var(--fw-semibold) var(--fs-12) var(--font-sans);text-align:left;padding:8px 10px;cursor:pointer} +.goalMenuList button:hover,.goalMenuList button:focus-visible{outline:none;background:var(--line)} +.goalMenuList .goalMenuDanger{color:var(--bad)} +.goalSparkLine{fill:none;stroke:var(--accent);stroke-width:1.6;vector-effect:non-scaling-stroke} +.goalSparkTarget{stroke:var(--faint);stroke-width:1;stroke-dasharray:3 3;vector-effect:non-scaling-stroke} +.goalHistory{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px} +.goalActions{display:flex;gap:8px;margin-top:10px} +.goalActions button{font-size:var(--fs-11);padding:4px 8px} +#view-efficiency.goalDetailOpen>:not(#efficiency-goals){display:none} +.goalDetailView{display:grid;gap:14px} +.goalDetailView[hidden]{display:none} +.goalDetailHead{display:flex;align-items:flex-start;gap:14px} +.goalDetailTitle{flex:1;min-width:0} +.goalDetailHead h3{margin:0 0 4px;font-size:var(--fs-20);overflow-wrap:anywhere} +.goalDetailHead .goalMeta{margin:0} +.goalDetailSummary{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px} +.goalDetailMetric{padding:14px 16px} +.goalDetailCard{padding:16px 18px;min-width:0} +.goalDetailCard h4{margin:0 0 10px;font-size:var(--fs-14)} +.goalDetailCard h5{margin:12px 0 6px;color:var(--dim);font-size:var(--fs-12);font-weight:var(--fw-bold)} +.goalDetailGrid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px} +.goalTrend{display:block;width:100%;height:160px} +.goalTrendTip{white-space:normal;width:max-content;max-width:calc(100% - 16px)} +.goalTrendAxis{display:flex;justify-content:space-between;gap:12px;margin-top:6px;color:var(--faint);font-size:var(--fs-11)} +.goalDriverRow{display:grid;grid-template-columns:minmax(0,1.4fr) minmax(0,1fr) auto;gap:10px;align-items:center;padding:4px 0;font-size:var(--fs-12)} +.goalDriverRow span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.goalDriverRow b{font-family:var(--font-mono);font-weight:var(--fw-bold);color:var(--fg)} +.goalDriverBar{height:8px;border-radius:var(--radius-sm);background:rgb(var(--tint-rgb)/.07);overflow:hidden} +.goalDriverBar i{display:block;height:100%;background:var(--accent)} +.goalDriverNote{margin:8px 0 0;color:var(--dim);font-size:var(--fs-12);line-height:1.45} +.goalCoachTip{padding:12px 0;border-top:1px solid var(--line)} +.goalCoachTip:first-of-type{border-top:0;padding-top:0} +.goalCoachTip h5{margin:0 0 4px;color:var(--fg);font-size:var(--fs-13)} +.goalCoachTip p{margin:4px 0 0;color:var(--dim);font-size:var(--fs-12);line-height:1.45} +.goalCoachTip .goalCoachSaving{color:var(--good);font-weight:var(--fw-bold)} +.goalCoachSaving small{display:block;margin-top:2px;color:var(--dim);font-weight:var(--fw-medium)} +.goalTitleLink{color:inherit;text-decoration:none} +.goalTitleLink:hover{color:var(--accent)} +@media(max-width:1200px){.goalDetailSummary{grid-template-columns:repeat(2,minmax(0,1fr))}.goalDetailGrid{grid-template-columns:minmax(0,1fr)}} + +/* Concept frames show real dashboard components with sample values; these rules only fit them to the frame and keep them still. */ +:where(.conceptFrame) :is(.currentSessionCard,.capReviewQueue,.subagentRoleTrendList,.spendPlot,.efficiencyHeadline){overflow:visible} +:where(.conceptFrame) .currentSessionCard{min-height:0;cursor:default} +:where(.conceptFrame) .currentSessionCard:hover{transform:none;border-color:color-mix(in srgb,var(--harness) 48%,var(--line));box-shadow:var(--shadow-soft)} +:where(.conceptFrame) .currentSessionContextSpark{max-width:96px;height:22px} +:where(.conceptFrame) .spendChartInner{height:132px} +:where(.conceptFrame) .spendDayTrack{background:repeating-linear-gradient(to top,transparent 0 44px,rgb(var(--tint-rgb)/.035) 44px 45px)} +:where(.conceptFrame) .spendDay{cursor:default} +:where(.conceptFrame) .spendDay:hover{background:transparent} +:where(.conceptFrame) .spendChart{min-height:0;margin-top:0;grid-template-columns:minmax(0,1fr)} +:where(.conceptFrame) .budgetSpend{display:grid;justify-content:normal;gap:4px} +:where(.conceptFrame) .budgetSpendValue{font-size:var(--fs-readout-md);letter-spacing:-.02em} +:where(.conceptFrame) .budgetProgress{margin-top:4px} +:where(.conceptFrame) .budgetMeterFoot>span:last-child{display:inline-flex;align-items:center;gap:8px;color:var(--fg);font-weight:var(--fw-semibold)} +:where(.conceptFrame) .efficiencyHeadline{min-height:0;padding:16px 18px 12px} +:where(.conceptFrame) .efficiencyValueRow{display:flex;align-items:center;gap:20px} +:where(.conceptFrame) .efficiencyHeadline .efficiencyChart{height:88px;cursor:default} +:where(.conceptFrame) .capReviewHead,:where(.conceptFrame) .capReviewItem{grid-template-columns:minmax(0,1fr)} +:where(.conceptFrame) .capReviewCols{grid-template-columns:0 minmax(0,1fr) 92px 168px} +:where(.conceptFrame) .capReviewRow{min-height:40px;display:grid;align-items:center} +:where(.conceptFrame) .capReviewHead>span:last-child{text-align:left} +:where(.conceptFrame) .capReviewNum{display:inline-flex;align-items:center;gap:6px;overflow:visible} +:where(.conceptFrame) .subagentRoleTrendRow{grid-template-columns:minmax(0,1fr) auto minmax(0,1.1fr);min-height:0;padding:14px 16px} +:where(.conceptFrame) .compareCardTitle{overflow:hidden;font-size:var(--fs-13);text-overflow:ellipsis;white-space:nowrap} +:where(.conceptFrame) .compareBreakdownRow b{display:inline-flex;align-items:center;justify-content:flex-end;gap:8px} +:where(.conceptFrame) .compareSection{padding:14px 16px} diff --git a/dashboard/styles/motion-sessions.css b/dashboard/styles/motion-sessions.css new file mode 100644 index 00000000..21b1837b --- /dev/null +++ b/dashboard/styles/motion-sessions.css @@ -0,0 +1,324 @@ +/* spectrum-motion-v1: purposeful motion and material detail shared by every theme */ +html:after{content:"";position:fixed;inset:0;z-index:-1;pointer-events:none;background-image:var(--grain);background-size:180px 180px;opacity:var(--grain-opacity);mix-blend-mode:overlay} +body.spectrumApp .card:not(.currentSessionCard):not(.budgetConfig){transition:border-color .16s,box-shadow .16s} +body.spectrumApp :is(.tbtn,.seg button,.themeOption){transition:border-color .16s,background .16s,box-shadow .16s,color .16s,transform var(--dur-press) var(--ease-out)} +body.spectrumApp :is(.tbtn,.seg button,.themeOption):active:not(:disabled){transform:scale(.97)} +body.spectrumApp .top .tab:active{transform:scale(.98)} +@keyframes viewEnter{from{opacity:0;transform:translateY(8px)}} +.view.viewEnter>*{animation:viewEnter var(--dur-enter) var(--ease-out) both;animation-delay:calc(var(--enter-i,0)*45ms)} +html.themeReveal::view-transition-old(root),html.themeReveal::view-transition-new(root){animation:none;mix-blend-mode:normal} +html.themeReveal::view-transition-new(root){z-index:1} +@media(prefers-reduced-motion:reduce){body.spectrumApp :is(.tbtn,.seg button,.themeOption,.top .tab):active{transform:none}.view.viewEnter>*{animation-name:none}body.spectrumApp .card:not(.currentSessionCard):not(.budgetConfig){transition:none}} +/* session-spectrum-field-v2 */ +body.sessionRoute{--session-signal:var(--signal);--session-signal-mid:var(--signal-mid);--session-signal-hi:var(--signal-hi);--session-aura:var(--aura);--session-orange:var(--orange)} +/* session-signal-field-v1 */ +body.sessionRoute #view-session .sessionFlowStatus{display:inline-flex;width:auto;min-height:28px;align-items:center;gap:8px;margin:0;padding:6px 10px;border:1px solid rgb(var(--signal-hi-rgb)/.22);border-radius:var(--radius-pill);background:rgb(var(--bg-lift-rgb)/.72);box-shadow:inset 0 1px 0 rgb(var(--tint-rgb)/.035);color:var(--session-signal-hi);font:var(--fw-bold) var(--fs-11)/1 var(--font-mono);letter-spacing:.055em;text-transform:uppercase} +body.sessionRoute #view-session .sessionFlowStatus i{width:6px;height:6px;flex:0 0 auto;border-radius:var(--radius-round);background:currentColor;box-shadow:0 0 10px currentColor} +body.sessionRoute #view-session .spectrumPageHeadFrame[data-session-flow=working] .sessionFlowStatus{border-color:rgb(var(--green-rgb)/.3);color:var(--good)} +body.sessionRoute #view-session .spectrumPageHeadFrame[data-session-flow=working] .sessionFlowStatus i{animation:sessionFlowStatusPulse 2.8s ease-out infinite} +body.sessionRoute #view-session .spectrumPageHeadFrame[data-session-flow=waiting] .sessionFlowStatus{color:var(--warn)} +body.sessionRoute #view-session .spectrumPageHeadFrame[data-session-flow=recent] .sessionFlowStatus{color:var(--session-aura)} +body.sessionRoute #view-session .spectrumPageHeadFrame[data-session-flow=idle] .sessionFlowStatus{color:var(--dim)} +body.sessionRoute #view-session .spectrumPageHeadFrame[data-session-flow=unavailable] .sessionFlowStatus{border-style:dashed;color:var(--faint)} +body.sessionRoute #view-session .sessionFlowHead[data-session-flow=waiting] .pageSignalField{opacity:.86} +body.sessionRoute #view-session .sessionFlowHead[data-session-flow=recent] .pageSignalField{opacity:.8} +body.sessionRoute #view-session .sessionFlowHead[data-session-flow=idle] .pageSignalField{opacity:.72} +body.sessionRoute #view-session .sessionFlowHead[data-session-flow=unavailable] .pageSignalField{opacity:.62} +body.sessionRoute #view-session.sessionDetail .sessionFlowHead :is(.pageSignalField,.pageHeroBorder),body.sessionRoute #view-session.sessionDetail .spectrumPageActions{display:none} +@keyframes sessionFlowStatusPulse{0%,70%{box-shadow:0 0 0 0 rgb(var(--green-rgb)/.4)}100%{box-shadow:0 0 0 9px transparent}} +body.sessionRoute #view-session .sessionScopeTabs{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:4px;width:100%;margin:-4px 0 12px;padding:4px;border-color:rgb(var(--signal-rgb)/.24);background:rgb(var(--bg-rgb)/.72)} +body.sessionRoute #view-session .sessionScopeTabs .tab{display:flex;min-height:44px;align-items:center;justify-content:center;gap:8px;padding:10px 14px;text-align:center} +body.sessionRoute #view-session .sessionScopeTabs .tab .tabIcon{width:17px;height:17px;flex:0 0 auto} +body.sessionRoute #view-session .sessionScopeTabs .tab[aria-selected=true]{background:var(--spectrum-active);color:var(--on-accent);box-shadow:0 7px 20px rgb(var(--signal-rgb)/.16)} +body.sessionRoute #view-session .sessionScopeTabs .tab[aria-selected=false]:hover{background:rgb(var(--signal-rgb)/.07);color:var(--fg)} +body.sessionRoute #view-session .currentDaySummary{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:stretch;gap:18px;margin:0 0 10px;padding:10px 12px;border-top:1px solid rgb(var(--signal-rgb)/.16);border-bottom:1px solid rgb(var(--signal-rgb)/.13);background:linear-gradient(90deg,rgb(var(--signal-rgb)/.055),rgb(var(--signal-hi-rgb)/.025) 62%,rgb(var(--aura-rgb)/.035))} +body.sessionRoute #view-session .currentDayReadouts{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));min-width:0} +body.sessionRoute #view-session .currentDayMetric{min-width:0;padding:0 16px;border-left:1px solid rgb(var(--tint-rgb)/.08)} +body.sessionRoute #view-session .currentDayMetric:first-child{padding-left:0;border-left:0} +body.sessionRoute #view-session .currentDayMetricLabel{display:block;color:var(--faint);font-size:var(--fs-11);font-weight:var(--fw-heavy);letter-spacing:.045em;text-transform:uppercase} +body.sessionRoute #view-session .currentDayMetricValue{display:block;margin-top:3px;color:var(--fg);font-size:var(--fs-18);font-weight:var(--fw-bold);line-height:1.15;font-variant-numeric:tabular-nums;overflow-wrap:anywhere} +body.sessionRoute #view-session .currentDayMetricNote{display:block;margin-top:3px;color:var(--faint);font-size:var(--fs-11);line-height:1.3;overflow-wrap:anywhere} +body.sessionRoute #view-session .currentDayActions{align-self:center;display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap} +body.sessionRoute #view-session :is(.currentDayMonth,.currentDayLearn){display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:40px;white-space:nowrap} +body.sessionRoute #view-session .currentDayLearn{text-decoration:none} +body.sessionRoute #view-session .currentDayLearnIcon{width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round} +body.sessionRoute #view-session .currentSessionsHead{justify-content:space-between;margin:-4px 0 8px;padding:0 2px} +body.sessionRoute #view-session .currentSessionsCount{color:var(--faint);font-size:var(--fs-11);letter-spacing:.02em} +body.sessionRoute #view-session .currentSessionFilterGroups{max-width:calc(100% - 96px)} +body.sessionRoute #view-session .currentSessionRuntimeFilters,body.sessionRoute #view-session .currentSessionActivityFilters{display:flex;min-width:0} +body.sessionRoute #view-session .currentSessionRuntimeFilters[hidden],body.sessionRoute #view-session .currentSessionActivityFilters[hidden]{display:none} +body.sessionRoute #view-session .currentSessionRuntimeFilters button,body.sessionRoute #view-session .currentSessionActivityFilters button{min-height:30px;padding:6px 10px;white-space:nowrap} +body.sessionRoute #view-session .currentSessionGrid{gap:12px} +body.sessionRoute #view-session .currentSessionCard{isolation:isolate;min-height:0;padding:18px 20px 16px;border-color:rgb(var(--signal-rgb)/.16);border-radius:var(--radius-md);background:radial-gradient(circle at 92% 8%,rgb(var(--signal-hi-rgb)/.1),transparent 28%),radial-gradient(circle at 8% 100%,rgb(var(--aura-rgb)/.07),transparent 34%),linear-gradient(145deg,rgb(var(--panel2-rgb)/.98),rgb(var(--bg2-rgb)/.99));box-shadow:var(--shadow-soft);transition:transform .18s cubic-bezier(.16,1,.3,1),border-color .18s,box-shadow .18s,opacity .18s} +body.sessionRoute #view-session .currentSessionCard:before{content:none} +body.sessionRoute #view-session .currentSessionCard.activity-working{border-color:rgb(var(--signal-rgb)/.42);background:radial-gradient(circle at 88% 4%,rgb(var(--signal-rgb)/.18),transparent 34%),linear-gradient(135deg,rgb(var(--signal-deep-rgb)/.98),rgb(var(--bg-lift-rgb)/.99) 56%,rgb(var(--bg2-rgb)/.99))} +body.sessionRoute #view-session .currentSessionCard.is-arriving{animation:currentSessionArrive .82s var(--spectrum-ease) both} +@keyframes currentSessionArrive{0%{opacity:.15;transform:translateY(9px);box-shadow:0 0 0 1px rgb(var(--signal-rgb)/.06)}55%{opacity:1;box-shadow:0 0 0 1px rgb(var(--signal-rgb)/.58),0 0 34px rgb(var(--signal-rgb)/.18)}100%{opacity:1;transform:none;box-shadow:var(--shadow-soft)}} +body.sessionRoute #view-session .currentSessionCard:hover{transform:translateY(-3px);border-color:color-mix(in srgb,var(--harness) 72%,var(--session-signal));box-shadow:0 18px 38px rgb(var(--shade-rgb)/.3)} +body.sessionRoute #view-session .currentSessionCard:focus-visible{outline:2px solid var(--session-signal);outline-offset:3px;border-color:var(--session-signal);box-shadow:none} +body.sessionRoute #view-session .currentSessionIdentity{position:relative;z-index:1;margin-top:20px} +body.sessionRoute #view-session .currentSessionIdentity h3{font-family:var(--font-display);font-size:var(--fs-22);font-stretch:91%;font-weight:var(--fw-semibold);letter-spacing:-.025em} +body.sessionRoute #view-session .currentSessionIdentity p{margin-top:6px;color:var(--faint);font-size:var(--fs-11)} +body.sessionRoute #view-session .currentSessionMetrics{grid-template-columns:.9fr 1.2fr 1fr;gap:0;margin-top:14px;padding-top:14px;border-top:1px solid rgb(var(--signal-rgb)/.13)} +body.sessionRoute #view-session .currentSessionMetric{padding:0 14px;border-left:1px solid rgb(var(--tint-rgb)/.08)} +body.sessionRoute #view-session .currentSessionMetric:first-child{padding-left:0;border-left:0} +body.sessionRoute #view-session .currentSessionMetric:last-child{padding-right:0} +body.sessionRoute #view-session .currentSessionMetric span{color:var(--faint);font-size:var(--fs-11);letter-spacing:.04em} +body.sessionRoute #view-session .currentSessionMetric b,body.sessionRoute #view-session .currentSessionMetric b.mono{margin-top:6px;color:var(--fg);font-size:var(--fs-readout-sm);font-weight:var(--fw-bold)} +:is(body.sessionRoute #view-session,.conceptFrame) .currentSessionHero{display:grid;grid-template-columns:auto minmax(0,1fr);align-items:end;gap:8px 24px;margin-top:16px} +body.sessionRoute #view-session .currentSessionHeroCompare{min-width:0;padding-bottom:4px} +:is(body.sessionRoute #view-session,.conceptFrame) .currentSessionHeroLabel{display:block;color:var(--faint);font-size:var(--fs-11);font-weight:var(--fw-heavy);letter-spacing:.04em;text-transform:uppercase} +:is(body.sessionRoute #view-session,.conceptFrame) .currentSessionHeroValue{display:block;margin-top:4px;color:var(--fg);font-family:var(--font-sans);font-size:var(--fs-readout-lg);font-weight:var(--fw-bold);line-height:1;letter-spacing:-.035em;font-variant-numeric:tabular-nums;white-space:nowrap} +body.sessionRoute #view-session .currentSessionHero .readoutScale{max-width:280px;margin-top:0} +body.sessionRoute #view-session .currentSessionContextSpark{--context-tone:var(--session-signal);max-width:98px;height:24px} +body.sessionRoute #view-session .currentSessionContextBaseline{stroke:rgb(var(--signal-rgb)/.12)} +body.sessionRoute #view-session .currentSessionContextBar{fill:var(--session-signal);opacity:.52} +body.sessionRoute #view-session .currentSessionGrip{width:12px;height:16px;fill:var(--session-signal);opacity:.7} +body.sessionRoute #view-session .currentSessionsEmpty{border-color:rgb(var(--signal-rgb)/.18);border-radius:var(--radius-md);background:linear-gradient(145deg,rgb(var(--panel2-rgb)/.95),rgb(var(--bg2-rgb)/.98))} +body.sessionRoute #view-session .currentSessionsEmpty{position:relative;display:flex;grid-column:1/-1;min-height:390px;overflow:hidden;align-items:center;justify-content:center;padding:36px 24px 36px;text-align:center;background:radial-gradient(ellipse at 50% 48%,rgb(var(--signal-rgb)/.13),transparent 44%),radial-gradient(ellipse at 76% 42%,rgb(var(--aura-rgb)/.08),transparent 36%),linear-gradient(150deg,rgb(var(--panel2-rgb)/.96),rgb(var(--bg-rgb)/.98))} +body.sessionRoute #view-session .currentSessionsEmpty:after{content:"";position:absolute;right:7%;bottom:-120px;left:7%;height:210px;border-radius:var(--radius-round);background:rgb(var(--signal-rgb)/.07);filter:blur(38px)} +body.sessionRoute #view-session .currentSessionsEmptyInner{position:relative;z-index:1;width:min(720px,100%)} +body.sessionRoute #view-session .currentSessionsEmpty h3{margin:0;font-family:var(--font-display);font-size:var(--fs-readout-lg);font-stretch:91%;font-weight:var(--fw-semibold);letter-spacing:-.03em} +body.sessionRoute #view-session .currentSessionsEmptyCopy{max-width:65ch;margin:8px auto 0;color:var(--dim);font-size:var(--fs-13);line-height:1.55} +body.sessionRoute #view-session .currentSessionsArrival{width:min(390px,100%);margin:20px auto 28px;padding:14px 16px 14px;border:1px solid rgb(var(--aura-rgb)/.48);border-radius:var(--radius-md);background:linear-gradient(145deg,rgb(var(--panel2-rgb)/.98),rgb(var(--bg-lift-rgb)/.98));box-shadow:0 14px 30px rgb(var(--shade-rgb)/.24),0 0 30px rgb(var(--aura-rgb)/.08);text-align:left} +body.sessionRoute #view-session .currentSessionsArrivalHead{display:flex;align-items:center;gap:8px} +body.sessionRoute #view-session .currentSessionsArrivalHead:before{content:"";width:7px;height:7px;flex:0 0 auto;border-radius:var(--radius-round);background:var(--session-aura);box-shadow:0 0 12px rgb(var(--aura-rgb)/.72)} +body.sessionRoute #view-session .currentSessionsArrivalHead strong{font-size:var(--fs-13)} +body.sessionRoute #view-session .currentSessionsArrivalHead span{margin-left:auto;color:var(--session-aura);font-size:var(--fs-11);font-weight:var(--fw-bold)} +body.sessionRoute #view-session .currentSessionsArrivalMetrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;margin-top:12px;padding-top:12px;border-top:1px solid rgb(var(--signal-hi-rgb)/.14)} +body.sessionRoute #view-session .currentSessionsArrivalMetric span{display:block;color:var(--faint);font-size:var(--fs-11);font-weight:var(--fw-heavy);letter-spacing:.06em;text-transform:uppercase} +body.sessionRoute #view-session .currentSessionsArrivalMetric strong{display:block;margin-top:4px;font:var(--fw-bold) var(--fs-14)/1.1 var(--font-mono)} +body.sessionRoute #view-session .currentSessionsHistorySummary{display:flex;align-items:baseline;justify-content:center;gap:14px;margin-bottom:3px} +body.sessionRoute #view-session .currentSessionsHistorySummary>strong{font-family:var(--font-display);font-size:var(--fs-24);font-weight:var(--fw-semibold);letter-spacing:-.03em} +body.sessionRoute #view-session .currentSessionsHistorySummary>span{color:var(--dim);font-size:var(--fs-12)} +body.sessionRoute #view-session .currentSessionsHistorySummary>span b{color:var(--fg);font:var(--fw-bold) var(--fs-12)/1.2 var(--font-mono)} +body.sessionRoute #view-session .currentSessionsHorizon{position:relative;display:grid;grid-template-columns:repeat(7,minmax(0,1fr));height:92px;margin:0 auto 20px;padding:0 10px} +body.sessionRoute #view-session .currentSessionsHorizon:before{content:"";position:absolute;right:10px;bottom:25px;left:10px;height:1px;background:linear-gradient(90deg,rgb(var(--signal-rgb)/.14),rgb(var(--signal-hi-rgb)/.7) 72%,rgb(var(--aura-rgb)/.72));box-shadow:0 8px 28px rgb(var(--signal-rgb)/.18)} +body.sessionRoute #view-session .currentSessionsHistoryDay{position:relative;display:grid;grid-template-rows:1fr auto 24px;justify-items:center;min-width:0} +body.sessionRoute #view-session .currentSessionsHistoryValue{align-self:end;margin-bottom:6px;color:var(--dim);font:var(--fw-bold) var(--fs-11)/1.1 var(--font-mono)} +body.sessionRoute #view-session .currentSessionsHistoryBar{width:clamp(9px,1.5vw,15px);height:var(--activity);min-height:0;align-self:end;border-radius:var(--radius-xs) var(--radius-xs) 0 0;background:linear-gradient(to top,rgb(var(--signal-rgb)/.36),var(--session-signal-hi));box-shadow:0 0 20px rgb(var(--signal-rgb)/.17)} +body.sessionRoute #view-session .currentSessionsHistoryDay small{align-self:end;color:var(--faint);font-size:var(--fs-11);font-weight:var(--fw-bold);letter-spacing:.05em;text-transform:uppercase} +body.sessionRoute #view-session .currentSessionsEmptyActions{display:flex;align-items:center;justify-content:center;gap:20px} +body.sessionRoute #view-session .currentSessionsEmptyAction{appearance:none;display:inline-flex;align-items:center;gap:6px;padding:2px;border:0;background:transparent;color:var(--session-signal-hi);font:inherit;font-size:var(--fs-12);font-weight:var(--fw-bold);line-height:1.2;text-decoration:none;cursor:pointer} +body.sessionRoute #view-session .currentSessionsEmptyAction.secondary{color:var(--dim)} +body.sessionRoute #view-session .currentSessionsEmptyAction:hover{color:var(--fg)} +body.sessionRoute #view-session .currentSessionsEmptyAction:focus-visible{outline:2px solid var(--session-signal);outline-offset:4px;border-radius:var(--radius-xs)} +body.sessionRoute #view-session .currentSessionsEmptyAction svg{width:15px;height:15px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round} +@media(max-width:700px){body.sessionRoute #view-session .currentSessionsEmpty{min-height:0;padding:32px 14px 32px}body.sessionRoute #view-session .currentSessionsEmpty h3{font-size:var(--fs-28)}body.sessionRoute #view-session .currentSessionsArrival{margin:20px auto 24px}body.sessionRoute #view-session .currentSessionsHistorySummary{flex-wrap:wrap;gap:3px 10px}body.sessionRoute #view-session .currentSessionsHistorySummary>strong{font-size:var(--fs-24)}body.sessionRoute #view-session .currentSessionsHistorySummary>span{flex-basis:100%}body.sessionRoute #view-session .currentSessionsHorizon{height:86px;padding:0}body.sessionRoute #view-session .currentSessionsHorizon:before{right:0;left:0}body.sessionRoute #view-session .currentSessionsHistoryValue{visibility:hidden}body.sessionRoute #view-session .currentSessionsHistoryDay small{font-size:0}body.sessionRoute #view-session .currentSessionsHistoryDay small:after{content:attr(data-short);font-size:var(--fs-11)}body.sessionRoute #view-session .currentSessionsEmptyActions{flex-direction:column;gap:14px}} +body.sessionRoute #view-session #all-session-history{margin-top:2px} +body.sessionRoute #view-session .allSessionsHead{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;margin:0 0 10px;padding:2px 2px 0} +body.sessionRoute #view-session .allSessionsHead h2{margin:0;font-family:var(--font-display);font-size:var(--fs-22);font-weight:var(--fw-semibold);letter-spacing:-.025em} +body.sessionRoute #view-session .allSessionsHead p{margin:3px 0 0;color:var(--faint);font-size:var(--fs-12)} +body.sessionRoute #view-session .allSessionsHint{margin-left:auto;color:var(--faint);font-size:var(--fs-11);white-space:nowrap} +body.sessionRoute #view-session .logsToolbar{margin-top:0} +body.sessionRoute #view-session .logsToolbarStatus{margin-bottom:8px} +body.sessionRoute #view-session .logsSummary{display:block;margin:0 0 12px} +body.sessionRoute #view-session .logsSummary .logKpis{grid-template-columns:repeat(5,minmax(0,1fr));gap:0;border:1px solid rgb(var(--signal-rgb)/.16);border-radius:var(--radius-md);background:linear-gradient(90deg,rgb(var(--signal-rgb)/.055),rgb(var(--signal-hi-rgb)/.025) 62%,rgb(var(--aura-rgb)/.035));box-shadow:var(--shadow-soft)} +body.sessionRoute #view-session .logsSummary .logStat{min-height:76px;padding:12px 12px;border:0;border-left:1px solid rgb(var(--tint-rgb)/.08);border-radius:0;background:transparent;box-shadow:none} +body.sessionRoute #view-session .logsSummary .logStat:first-child{border-left:0} +body.sessionRoute #view-session .logsSummary .logStat.waitStat{grid-column:auto;min-height:76px} +body.sessionRoute #view-session .logsSummary .logStat .v{font-size:var(--fs-20)} +body.sessionRoute #view-session .logsSummary .logStat .subline{font-size:var(--fs-11)} +body.sessionRoute #view-session .historyModels{margin-top:8px;border:1px solid var(--line);border-radius:var(--radius-md);background:linear-gradient(180deg,rgb(var(--panel-rgb)/.8),rgb(var(--bg2-rgb)/.78));box-shadow:var(--shadow-soft)} +body.sessionRoute #view-session .historyModels>summary{display:flex;align-items:center;gap:8px;min-height:42px;padding:0 12px;cursor:pointer;color:var(--fg);font-size:var(--fs-12);font-weight:var(--fw-heavy);list-style:none} +body.sessionRoute #view-session .historyModels>summary::-webkit-details-marker{display:none} +body.sessionRoute #view-session .historyModels>summary:after{content:"▾";margin-left:auto;color:var(--faint);font-size:var(--fs-11);transition:transform .16s ease} +body.sessionRoute #view-session .historyModels[open]>summary{border-bottom:1px solid var(--line)} +body.sessionRoute #view-session .historyModels[open]>summary:after{transform:rotate(180deg)} +body.sessionRoute #view-session .historyModels>summary:focus-visible{outline:2px solid var(--session-signal);outline-offset:-3px} +body.sessionRoute #view-session .historyModels .logModelList{padding:2px 12px 10px} +body.sessionRoute #view-session .historyModels .logModelCount{color:var(--faint);font-size:var(--fs-11);font-weight:var(--fw-medium)} +body.sessionRoute #view-session.sessionDetail .previewRunMeta{gap:6px;margin-bottom:10px;padding:0 2px} +body.sessionRoute #view-session.sessionDetail .previewRunMeta .badge,body.sessionRoute #view-session.sessionDetail .previewRunMeta .repo,body.sessionRoute #view-session.sessionDetail .previewRunMeta .pill,body.sessionRoute #view-session.sessionDetail .sessionDelete{background:rgb(var(--panel-rgb)/.86)} +body.sessionRoute #view-session.sessionDetail .previewStartStrip{border-color:rgb(var(--signal-rgb)/.18);background:linear-gradient(100deg,rgb(var(--signal-rgb)/.11),rgb(var(--panel2-rgb)/.92) 46%,rgb(var(--aura-rgb)/.05));box-shadow:none} +body.sessionRoute #view-session.sessionDetail .previewStartLabel{color:var(--session-signal-hi);font-size:var(--fs-11)} +body.sessionRoute #view-session.sessionDetail .previewChartSlot #session-chart-module>.card{border-color:rgb(var(--signal-rgb)/.15);border-radius:var(--radius-md);background:radial-gradient(circle at 100% 0,rgb(var(--signal-rgb)/.07),transparent 28%),linear-gradient(160deg,rgb(var(--panel-rgb)/.98),rgb(var(--bg-rgb)/.98));box-shadow:none} +body.sessionRoute #view-session.sessionDetail .previewChartSlot .chart{border-color:rgb(var(--tint-rgb)/.07);border-radius:var(--radius-sm);background:linear-gradient(180deg,rgb(var(--bg-rgb)/.72),rgb(var(--bg-deep-rgb)/.5))} +body.sessionRoute #view-session.sessionDetail .previewStatus{padding:18px;border-color:rgb(var(--signal-rgb)/.24);border-radius:var(--radius-md);background:radial-gradient(circle at 96% 5%,rgb(var(--signal-hi-rgb)/.16),transparent 34%),radial-gradient(circle at 8% 100%,rgb(var(--aura-rgb)/.1),transparent 40%),linear-gradient(145deg,rgb(var(--panel2-rgb)/.99),rgb(var(--bg2-rgb)/.99));box-shadow:var(--shadow-soft)} +body.sessionRoute #view-session.sessionDetail .previewState{color:var(--good);font-size:var(--fs-11)} +body.sessionRoute #view-session.sessionDetail .previewCost{color:var(--session-signal-hi);font-size:var(--fs-readout-xl);font-weight:var(--fw-bold);letter-spacing:-.02em;white-space:nowrap} +body.sessionRoute #view-session.sessionDetail .previewCostNote{color:var(--dim)} +body.sessionRoute #view-session.sessionDetail .previewPressure{border-top-color:rgb(var(--signal-rgb)/.14)} +body.sessionRoute #view-session.sessionDetail .previewPressureHead{color:var(--dim)} +body.sessionRoute #view-session.sessionDetail .previewPressureBar{height:8px;border-color:rgb(var(--signal-rgb)/.2);border-radius:var(--radius-sm);background:rgb(var(--bg-rgb)/.72)} +body.sessionRoute #view-session.sessionDetail .previewPressureBar i{border-radius:inherit;background:linear-gradient(90deg,var(--session-signal),var(--session-signal-mid) 58%,var(--session-signal-hi))} +body.sessionRoute #view-session.sessionDetail .previewTimes{border-top-color:rgb(var(--signal-rgb)/.12)} +body.sessionRoute #view-session.sessionDetail .previewTimes span{color:var(--faint);font-size:var(--fs-11)} +body.sessionRoute #view-session.sessionDetail .previewTimes b{overflow:visible;white-space:pre-line;line-height:1.35} +body.sessionRoute #view-session.sessionDetail .previewRunBudgetSlot .sessionBudget{padding:18px;border-color:rgb(var(--signal-rgb)/.14);border-radius:var(--radius-md);background:linear-gradient(145deg,rgb(var(--panel-rgb)/.96),rgb(var(--bg-rgb)/.98));box-shadow:none} +body.sessionRoute #view-session.sessionDetail .previewTokenSlot .bar{border-color:rgb(var(--signal-rgb)/.16);background:rgb(var(--bg-rgb)/.7)} +body.sessionRoute #view-session.sessionDetail .previewKpi.previewSpeed .v{color:var(--session-signal-hi);text-shadow:none} +@media(max-width:900px){body.sessionRoute #view-session .logsSummary .logKpis{grid-template-columns:repeat(3,minmax(0,1fr))}body.sessionRoute #view-session .logsSummary .logStat:nth-child(4){border-left:0}body.sessionRoute #view-session .logsSummary .logStat:nth-child(n+4){margin-top:8px;padding-top:12px;border-top:1px solid rgb(var(--tint-rgb)/.08)}} +@media(max-width:700px){body.sessionRoute #view-session .currentSessionMetric{padding:0 8px}body.sessionRoute #view-session .currentSessionMetric b,body.sessionRoute #view-session .currentSessionMetric b.mono{font-size:var(--fs-18)}} +@media(max-width:520px){body.sessionRoute #view-session .sessionFlowStatus{padding:4px 8px;font-size:var(--fs-11)}body.sessionRoute #view-session .currentDaySummary{grid-template-columns:1fr;gap:10px;padding:10px 12px}body.sessionRoute #view-session .currentDayReadouts{grid-template-columns:repeat(2,minmax(0,1fr))}body.sessionRoute #view-session .currentDayMetric{padding:0 10px}body.sessionRoute #view-session .currentDayMetric:nth-child(3){grid-column:1/-1;margin-top:8px;padding:8px 10px 0;border-top:1px solid rgb(var(--signal-rgb)/.12);border-left:0}body.sessionRoute #view-session .currentDayActions{display:grid;grid-template-columns:1fr}body.sessionRoute #view-session :is(.currentDayMonth,.currentDayLearn){width:100%}body.sessionRoute #view-session .currentSessionsHead{display:grid;grid-template-columns:minmax(0,1fr) auto}body.sessionRoute #view-session .currentSessionRuntimeFilters{width:100%;max-width:none}body.sessionRoute #view-session .currentSessionCard{min-height:224px;padding:16px 14px 12px}body.sessionRoute #view-session .currentSessionIdentity h3{display:-webkit-box;min-height:44px;overflow:hidden;font-size:var(--fs-20);line-height:1.16;white-space:normal;-webkit-box-orient:vertical;-webkit-line-clamp:2}body.sessionRoute #view-session .currentSessionMetrics{margin-top:16px}body.sessionRoute #view-session .currentSessionMetric{padding-right:10px}body.sessionRoute #view-session .currentSessionMetric b,body.sessionRoute #view-session .currentSessionMetric b.mono{font-size:var(--fs-18)}body.sessionRoute #view-session .currentSessionsArrivalMetrics{grid-template-columns:repeat(2,minmax(0,1fr))}body.sessionRoute #view-session.sessionDetail .previewCost{font-size:var(--fs-36)}} +@media(max-width:520px){body.sessionRoute #view-session .allSessionsHead{display:block}body.sessionRoute #view-session .allSessionsHint{display:block;margin-top:6px}body.sessionRoute #view-session .logsSummary .logKpis{grid-template-columns:1fr}body.sessionRoute #view-session .logsSummary .logStat,body.sessionRoute #view-session .logsSummary .logStat:nth-child(4){margin-top:0;padding:10px 12px;border-top:1px solid rgb(var(--tint-rgb)/.08);border-left:0}body.sessionRoute #view-session .logsSummary .logStat:first-child{border-top:0}} +@media(max-width:520px){body.sessionRoute #view-session .currentSessionFilterGroups{display:grid;width:100%;max-width:none;overflow:visible}body.sessionRoute #view-session .currentSessionFilterGroup{min-width:0;max-width:100%;overflow-x:auto;overflow-y:hidden;scrollbar-width:thin}} +@media(prefers-reduced-motion:reduce){body.sessionRoute #view-session .currentSessionCard{transition:none}body.sessionRoute #view-session :is(.sessionFlowStatus i,.currentSessionCard.is-arriving){animation:none!important}} +.modelCellTip.fieldtip{display:table-cell} +.modelCellTip>span{display:none} +.modelKpi:hover,.modelKpi:focus-within{z-index:50} +.modelKpi:nth-child(n+4) .fieldtip:after{left:auto;right:0} +.modelHelp:before{content:"i";display:inline-grid;place-items:center;width:13px;height:13px;margin-right:4px;border:1px solid currentColor;border-radius:var(--radius-round);font:var(--fw-heavy) var(--fs-11)/1 var(--font-mono);opacity:.72} +.modelCoverage.fieldtip{display:block} +.modelCoverage.fieldtip:after{bottom:auto;top:calc(100% + 7px)} +.learnSectionHead{display:flex;align-items:flex-start;justify-content:space-between;gap:16px} +.learnSectionHead .subline{margin-top:4px;max-width:820px} +@media(max-width:520px){.learnSectionHead{display:block}.learnSectionHead .tbtn{margin-top:10px}} +.srOnly{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0} +.welcome{position:fixed;inset:0;width:100%;max-width:none;height:100%;max-height:none;margin:0;padding:0;border:0;background:var(--spectrum-page);color:var(--fg);overflow:hidden} +.welcome[open]{display:grid;grid-template-rows:auto minmax(0,1fr);animation:welcomeFade var(--dur-enter) var(--ease-out)} +.welcome::backdrop{background:var(--bg-deep)} +.welcome:before{content:"";position:absolute;inset:0;z-index:-1;pointer-events:none;background-image:var(--page-texture);background-size:var(--page-texture-size);mask-image:linear-gradient(180deg,rgb(var(--shade-rgb)/.9),rgb(var(--shade-rgb)/.28) 52%,transparent)} +.welcomeTop{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:16px 32px;border-bottom:1px solid var(--line)} +.welcomeBrand{display:flex;align-items:baseline;gap:12px;min-width:0} +.welcomeBrand b{color:var(--fg);font-family:var(--font-display);font-size:var(--fs-18);font-weight:var(--fw-semibold);letter-spacing:-.01em} +.welcomeTopActions{display:flex;align-items:center;gap:12px} +.welcomeClose{display:grid;place-items:center;width:40px;height:40px;padding:0;border:1px solid var(--line3);border-radius:var(--radius-round);background:rgb(var(--panel-rgb)/.72);color:var(--fg);font-size:var(--fs-16);line-height:1;cursor:pointer;transition:border-color var(--dur-press) var(--ease-out),background var(--dur-press) var(--ease-out),transform var(--dur-press) var(--ease-out)} +.welcomeClose:hover{border-color:var(--spectrum-edge-hover);background:rgb(var(--signal-rgb)/.16)} +.welcomeClose:active{transform:scale(.94)} +:is(.welcomeClose,.welcomeSkip,.deckSkip,.deckStep,.deckPrimary,.leverRow):focus-visible{outline:2px solid var(--signal-hi);outline-offset:2px} +:is(.welcomeSkip,.deckSkip){padding:8px 4px;border:0;border-radius:var(--radius-sm);background:none;color:var(--dim);font:inherit;font-size:var(--fs-13);font-weight:var(--fw-semibold);cursor:pointer} +:is(.welcomeSkip,.deckSkip):hover{color:var(--fg)} +.deckPrimary.tourReturn{position:fixed;right:24px;bottom:24px;z-index:40;display:inline-flex;align-items:center;gap:10px;padding:14px 24px;border-radius:var(--radius-lg);font-size:var(--fs-16);font-weight:var(--fw-heavy);box-shadow:var(--spectrum-glow);animation:tourReturnIn .42s cubic-bezier(.2,.8,.2,1) both,tourReturnPulse 1.6s ease-out .45s 3} +.tourReturn[hidden]{display:none} +.tourReturnArrow{display:inline-block;font-size:var(--fs-18);animation:tourReturnNudge 1.6s ease-in-out .45s 3} +.tourReturn:hover .tourReturnArrow{transform:translateX(-3px)} +@keyframes tourReturnIn{from{transform:translateY(16px) scale(.94)}to{transform:none}} +@keyframes tourReturnPulse{0%{box-shadow:var(--spectrum-glow),0 0 0 0 rgb(var(--signal-rgb)/.55)}70%,100%{box-shadow:var(--spectrum-glow),0 0 0 14px rgb(var(--signal-rgb)/0)}} +@keyframes tourReturnNudge{0%,100%{transform:none}50%{transform:translateX(-5px)}} +@media(prefers-reduced-motion:reduce){.tourReturn,.tourReturnArrow{animation:none}} +.deck{display:grid;grid-template-columns:212px minmax(0,1fr);min-width:0;min-height:0} +.deckInline{min-height:560px;border:1px solid var(--spectrum-border);border-radius:var(--radius-lg);background:rgb(var(--panel-rgb)/.42);overflow:clip} +.deckInline .deckRail{position:sticky;top:0;align-self:start;max-height:100vh;border-right:0} +.deckInline .deckMain{border-left:1px solid var(--line)} +.deckInline .deckStage{overflow:visible} +.deckInline .deckFoot{position:sticky;bottom:0;z-index:2;background:rgb(var(--panel-rgb)/.94);backdrop-filter:blur(12px)} +.deckRail{min-height:0;padding:24px 12px 24px 20px;overflow:auto;border-right:1px solid var(--line)} +.welcome .deckRail{padding-left:28px} +.deckChapters{display:grid;gap:18px;margin:0;padding:0;list-style:none} +.deckChapter{display:grid;gap:4px} +.deckChapter>h3{display:flex;align-items:baseline;gap:8px;margin:0 0 2px;padding-left:8px;color:var(--faint);font-size:var(--fs-11);font-weight:var(--fw-heavy);letter-spacing:.06em;text-transform:uppercase} +.deckChapter[data-state=current]>h3{color:var(--signal-hi)} +.deckChapter ol{display:grid;gap:2px;margin:0;padding:0;list-style:none} +.deckStep{display:grid;grid-template-columns:14px minmax(0,1fr);align-items:baseline;gap:8px;width:100%;padding:6px 8px;border:0;border-radius:var(--radius-sm);background:none;color:var(--dim);font:inherit;font-size:var(--fs-13);line-height:1.35;text-align:left;cursor:pointer;transition:background var(--dur-press) var(--ease-out),color var(--dur-press) var(--ease-out)} +.deckStep>i{color:var(--faint);font-family:var(--font-mono);font-size:var(--fs-11);font-style:normal} +.deckStep:hover{background:rgb(var(--tint-rgb)/.05);color:var(--fg)} +.deckStep[data-state=done]>i{color:var(--good)} +.deckStep[aria-current=step]{background:rgb(var(--signal-rgb)/.12);color:var(--fg);font-weight:var(--fw-semibold)} +.deckStep[aria-current=step]>i{color:var(--signal-hi)} +.deckMain{display:grid;grid-template-rows:minmax(0,1fr) auto;min-width:0;min-height:0} +.deckStage{display:grid;align-items:center;min-height:0;padding:28px 40px;overflow:auto} +.deckSlide{display:grid;grid-template-columns:minmax(0,5fr) minmax(0,6fr);align-items:center;gap:40px;width:100%;max-width:1080px;margin:0 auto} +.deckCopy{display:grid;justify-items:start;gap:14px;min-width:0} +.deckKicker{color:var(--signal-hi);font-size:var(--fs-12);font-weight:var(--fw-heavy);letter-spacing:.06em;text-transform:uppercase} +.deckCopy h2{margin:0;font-family:var(--font-display);font-size:var(--fs-36);font-weight:var(--fw-semibold);line-height:1.08;letter-spacing:-.02em;text-wrap:balance} +.deckCopy p{margin:0;max-width:46ch;color:var(--dim);font-size:var(--fs-16);line-height:1.55} +.deckInline .deckCopy h2{font-size:var(--fs-28)} +.deckInline .deckCopy p{font-size:var(--fs-14)} +.deckActions{display:flex;flex-wrap:wrap;align-items:center;gap:10px} +.deckActions .tbtn{display:inline-flex;align-items:center;gap:6px;padding:8px 14px;font-size:var(--fs-13)} +.deckVisual{padding:20px} +.deckFoot{display:grid;grid-template-columns:minmax(0,1fr) auto minmax(0,1fr);align-items:center;gap:16px;padding:14px 40px 18px;border-top:1px solid var(--line)} +.deckFoot[hidden]{display:none} +.deckSkip{justify-self:start} +.deckSkip:disabled{visibility:hidden} +.deckCount{color:var(--faint);font-size:var(--fs-12);font-weight:var(--fw-bold)} +.deckNav{display:flex;justify-content:flex-end;gap:8px} +.deckNav .tbtn{min-width:88px;padding:10px 16px;font-size:var(--fs-13)} +.deckNav .tbtn:disabled{opacity:.4;cursor:default} +.deckPrimary{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-width:88px;padding:10px 18px;border:0;border-radius:var(--radius-md);background:var(--spectrum-active);box-shadow:var(--spectrum-glow);color:var(--on-accent);font:inherit;font-size:var(--fs-13);font-weight:var(--fw-heavy);cursor:pointer;transition:transform var(--dur-press) var(--ease-out)} +.deckPrimary:hover{background:var(--spectrum-active-hover)} +.deckPrimary:active{transform:scale(.97)} +.deckArrow{width:14px;height:14px;flex:0 0 auto;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round} +@keyframes welcomeFade{from{opacity:0}to{opacity:1}} +@keyframes deckRise{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}} +.deckSlide.deckEnter>*{animation:deckRise var(--dur-enter) var(--ease-out) both} +.deckSlide.deckEnter>.deckVisual{animation-delay:60ms} +.deckVisual{min-width:0} +.deckVisual>.conceptFrame{padding:20px 24px} +.deckList{display:grid;gap:12px;min-width:0} +.priceLadder{display:grid;gap:14px} +.priceRow{display:grid;grid-template-columns:minmax(200px,auto) minmax(0,1fr) 52px 44px 24px;align-items:center;gap:12px;font-size:var(--fs-13)} +.priceRow>b{text-align:right} +.priceRow>small{color:var(--faint);font-size:var(--fs-12);text-align:right} +.priceType{display:inline-flex;align-items:center;gap:8px;color:var(--fg);white-space:nowrap} +.priceType>i{width:10px;height:10px;border-radius:var(--radius-2xs);background:var(--seg)} +.leverList{display:grid;gap:8px;margin:0;padding:0;list-style:none} +.leverRow{display:grid;grid-template-columns:20px minmax(0,1fr) auto 14px;align-items:center;gap:14px;width:100%;padding:14px 16px;border:1px solid var(--line);border-radius:var(--radius-md);background:rgb(var(--tint-rgb)/.025);color:var(--fg);font:inherit;text-align:left;cursor:pointer;transition:border-color var(--dur-press) var(--ease-out),background var(--dur-press) var(--ease-out)} +.leverRow:hover{border-color:var(--spectrum-edge-hover);background:rgb(var(--signal-rgb)/.08)} +.leverRow>.deckArrow{color:var(--signal-hi)} +.leverIcon{width:20px;height:20px;fill:none;stroke:var(--signal-hi);stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round} +.leverName{min-width:0} +.leverName>b{font-size:var(--fs-16);font-weight:var(--fw-semibold)} +.leverPage{color:var(--faint);font-size:var(--fs-12);white-space:nowrap} +.menuMock{display:grid;max-width:360px;border:1px solid var(--line2);border-radius:var(--radius-md);background:rgb(var(--bg-deep-rgb)/.6)} +.menuMockBar{display:flex;align-items:center;justify-content:flex-end;gap:10px;padding:8px 12px;border-bottom:1px solid var(--line);border-radius:var(--radius-md) var(--radius-md) 0 0;background:rgb(var(--tint-rgb)/.06);font-size:var(--fs-13)} +.menuMockBar>b{color:var(--fg)} +.menuMockBar>span{color:var(--dim)} +.menuMockMark{width:12px;height:12px;border:2px solid var(--signal-hi);border-radius:var(--radius-round)} +.menuMockRows{display:grid;gap:2px;margin:0;padding:8px 12px 10px} +.menuMockRows>div{display:flex;align-items:center;gap:12px;padding:6px 0;font-size:var(--fs-14)} +.menuMockRows dt{margin-right:auto;color:var(--dim)} +.menuMockRows dd{margin:0;color:var(--fg)} +.teamsRow{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:12px;padding:12px 14px;border:1px dashed var(--line3);border-radius:var(--radius-md)} +.teamsRow>span{display:flex;align-items:baseline;gap:10px} +.teamsRow b{font-size:var(--fs-16)} +.teamsRow>.tbtn{padding:8px 14px;font-size:var(--fs-13);text-decoration:none} +.chatMock{display:grid;gap:10px} +.chatMock>p{margin:0;font-size:var(--fs-14);line-height:1.5} +.chatAsk{justify-self:end;max-width:85%;padding:8px 12px;border-radius:var(--radius-lg) var(--radius-lg) var(--radius-2xs) var(--radius-lg);background:rgb(var(--signal-rgb)/.14);color:var(--fg)} +.chatTool{color:var(--faint);font-size:var(--fs-12)} +.chatAnswer{padding:10px 12px;border:1px solid var(--line2);border-radius:var(--radius-md);background:rgb(var(--tint-rgb)/.04);color:var(--fg)} +@media(max-width:1180px){.deck{grid-template-columns:200px minmax(0,1fr)}.deckStage{padding:20px 28px}.deckFoot{padding:12px 28px 16px}.deckSlide{grid-template-columns:1fr;gap:20px;max-width:760px}.deckCopy{grid-template-columns:minmax(0,1fr) auto;column-gap:24px;row-gap:8px}.deckCopy>*{grid-column:1}.deckCopy>.deckActions{grid-column:2;grid-row:1/span 3;align-self:end}.deckCopy h2,.deckInline .deckCopy h2{font-size:var(--fs-28)}.deckCopy p,.deckInline .deckCopy p{font-size:var(--fs-14)}} +@media(max-height:800px){.welcomeTop{padding:10px 32px}.deckStage{padding-top:16px;padding-bottom:16px}.deckSlide{gap:16px}.deckCopy{gap:8px}.deckVisual>.conceptFrame{gap:12px;padding:16px 20px}.deckVisual .frameLegend{gap:6px}.deckVisual .conceptFrame>figcaption{padding-top:10px}} +@media(max-width:900px){.deck{grid-template-columns:1fr}.deckRail{display:none}.deckFoot{grid-template-columns:auto 1fr}.deckCount{display:none}} +@media(prefers-reduced-motion:reduce){.welcome[open],.deckSlide.deckEnter>*{animation:none}.welcomeClose,.deckPrimary,.deckStep,.leverRow,:where(.levelBar>i){transition:none}.welcomeClose:active,.deckPrimary:active{transform:none}} +.globalControls.logsToolbar{grid-template-columns:minmax(260px,1.25fr) repeat(3,minmax(145px,.62fr));padding:14px;border:1px solid var(--line);border-radius:var(--radius-lg);background:linear-gradient(180deg,rgb(var(--panel-rgb)/.8),rgb(var(--bg2-rgb)/.78));box-shadow:var(--shadow-soft)} +.logsToolbarRow{grid-column:1/-1;display:flex;flex-wrap:wrap;align-items:flex-end;gap:12px 20px;padding-top:12px;border-top:1px solid var(--line)} +.logsToolbarRow .filterSort{order:1} +.logsToolbarRow .filterChildren{order:2} +.logsToolbar .filterClear{order:3;margin-left:auto;min-width:0} +.logsToolbar .clearFilters{height:32px;padding:0 12px;font-size:var(--fs-12)} +.logsToolbar .searchbox,.logsToolbar .filterSelect{height:40px} +.logsToolbar .searchbox{background:rgb(var(--bg-rgb)/.58)} +.logsToolbar .filterSelect{background:var(--panel)} +.logsToolbarStatus{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:start;gap:12px;min-height:18px;margin:-3px 0 12px;padding:0 2px} +.logsToolbarStatus .filterCount{grid-column:2;max-width:min(560px,50vw);white-space:normal;overflow-wrap:anywhere;line-height:1.3} +.logsSummary .logStat .v{font-size:var(--fs-readout-md)} +.activeFilters{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin:0;color:var(--faint);font-size:var(--fs-11)} +.activeFilters[hidden]{display:none} +.activeFilters>span{font-weight:var(--fw-bold);text-transform:uppercase} +.activeFilters button{display:inline-flex;align-items:center;gap:6px;border:1px solid rgb(var(--signal-rgb)/.26);border-radius:var(--radius-pill);background:rgb(var(--signal-rgb)/.08);color:var(--dim);padding:4px 8px;font:inherit;cursor:pointer} +.activeFilters button:hover,.activeFilters button:focus-visible{border-color:rgb(var(--signal-rgb)/.55);color:var(--fg);outline:none} +.activeFilters button b{color:var(--signal-hi);font-size:var(--fs-14);line-height:1} +.srow:focus-visible{outline:0;border-color:rgb(var(--signal-rgb)/.7);box-shadow:var(--focus-ring),var(--shadow-soft)} +.badge.app{display:inline-flex;align-items:center;gap:6px;white-space:nowrap} +.badge.app:before{content:"";width:6px;height:6px;border-radius:var(--radius-round);background:currentColor;box-shadow:0 0 10px currentColor} +.badge.chatgpt{color:var(--badge-neutral);border-color:rgb(var(--badge-neutral-rgb)/.3);background:rgb(var(--badge-neutral-rgb)/.08)} +@media(max-width:1120px) and (min-width:901px){.globalControls.logsToolbar{grid-template-columns:minmax(240px,1fr) repeat(2,minmax(150px,.7fr));}.logsToolbar .logSearchControl{grid-column:1/3}} +@media(max-width:900px){.globalControls.logsToolbar{grid-template-columns:1fr 1fr}.logsToolbar .logSearchControl{grid-column:1/-1}.logsToolbarStatus{grid-template-columns:1fr;gap:6px}.logsToolbarStatus .filterCount{grid-column:1;max-width:none;text-align:left}} +@media(max-width:520px){.globalControls.logsToolbar{grid-template-columns:1fr}.logsToolbar .logSearchControl{grid-column:1}.srow{padding:10px 10px;gap:8px}.badge.app{font-size:var(--fs-11)}} +@media(max-width:520px){.sessionBudgetHead,.sessionBudgetMeta{align-items:flex-start;flex-direction:column}.sessionBudgetHead{gap:8px}.sessionBudgetExact{width:100%;justify-content:space-between}.sessionBudgetExact input{width:96px}.sessionBudgetTrack{grid-template-columns:44px minmax(0,1fr) 54px;gap:6px}.sessionBudgetMeta{gap:3px}} +@media(max-width:520px){.budgetConfigHead{display:grid;grid-template-columns:1fr;gap:8px}.budgetAlertInline{justify-content:flex-start}.budgetSessionDefault{grid-template-columns:1fr}.budgetForm input[type=number],.budgetForm input[type=text]{font-size:var(--fs-16)}.budgetForm .budgetThresholds input[type=number]{width:48px;padding-right:16px;font-size:var(--fs-14)}} +.modelHead>div:first-child{min-width:0} +.modelControls{grid-template-columns:minmax(150px,210px) minmax(200px,280px) minmax(130px,160px)} +.modelControls.deliveryControls{grid-template-columns:minmax(150px,210px) minmax(130px,160px)} +.modelScopeStatus{margin:-6px 0 10px;padding:6px 10px;border-left:2px solid var(--accent);border-radius:0 var(--radius-sm) var(--radius-sm) 0;background:rgb(var(--signal-rgb)/.055);color:var(--dim);font-size:var(--fs-11)} +.modelScopeStatus.warn{border-left-color:var(--warn);background:rgb(var(--orange-rgb)/.055);color:var(--warn)} +.modelScopeStatus:empty{display:none} +.modelTrendControls #m-metric{white-space:nowrap} +.modelChartTip{max-width:min(620px,calc(100% - 16px))} +.modelTipMetrics{grid-template-columns:repeat(7,minmax(0,1fr));gap:6px} +#m-chart-tip{pointer-events:auto;overscroll-behavior:contain;scrollbar-gutter:stable;scrollbar-color:var(--line2) transparent;cursor:default} +#m-chart-tip .h{position:sticky;top:0;z-index:1} +.signalEmpty{min-height:82px;padding:12px} +@media(max-width:1120px) and (min-width:901px){.modelControls{grid-template-columns:minmax(130px,180px) minmax(170px,230px) minmax(120px,145px)}.modelControls.deliveryControls{grid-template-columns:minmax(130px,180px) minmax(120px,145px)}} +@media(max-width:900px){.modelControls{grid-template-columns:repeat(3,minmax(0,1fr))}} +@media(max-width:700px){#m-metric{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));width:100%}.modelTrendControls{display:grid;justify-content:stretch;margin-top:8px}.modelTipMetrics{grid-template-columns:repeat(4,minmax(0,1fr))}} +@media(max-width:520px){.modelControls{grid-template-columns:1fr}} +:is(.modelHead,.efficiencyHead).spectrumPageHead{overflow:hidden;z-index:2} +.spectrumPageHeadFrame:has(.modelControls){z-index:10} +.modelPickerMenu{max-height:min(360px,calc(100dvh - 136px))} +.modelPicker.opensUp .modelPickerMenu{top:auto;bottom:calc(100% + 6px)} +body.spectrumApp .currentSessionCard:hover,body.spectrumApp.sessionRoute #view-session .currentSessionCard:hover{transform:none} +body.spectrumApp.sessionRoute #view-session .srow:hover{transform:none} diff --git a/dashboard/styles/sessions-subagents.css b/dashboard/styles/sessions-subagents.css new file mode 100644 index 00000000..db31c138 --- /dev/null +++ b/dashboard/styles/sessions-subagents.css @@ -0,0 +1,342 @@ +/* sessions-agent-observability-v1 */ +.agentActivity{margin-top:14px;padding:16px 18px;overflow:hidden} +.agentActivity[hidden]{display:none} +.agentActivity:before{content:"";position:absolute;inset:0 0 auto;height:2px;background:linear-gradient(90deg,var(--accent),var(--signal-hi),transparent 72%)} +.agentActivityHead{display:flex;align-items:flex-start;justify-content:space-between;gap:16px} +.agentActivityHead h2{margin:1px 0 0;font-size:var(--fs-16)} +.agentActivityHead p{margin:3px 0 0;color:var(--dim);font-size:var(--fs-12);line-height:1.45} +.agentCoverage{max-width:380px;color:var(--faint);font-size:var(--fs-11);text-align:right} +.agentActivityMetrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;margin-top:12px} +.agentActivityMetric{padding:10px 10px;border:1px solid var(--line);border-radius:var(--radius-sm);background:rgb(var(--tint-rgb)/.025)} +.agentActivityMetric span{font-size:var(--fs-11)} +.agentActivityMetric strong{margin-top:3px;font-size:var(--fs-16);line-height:1.2} +.agentActivityMetric small{margin-top:2px;font-size:var(--fs-11)} +.agentActivityTree{margin-top:12px;overflow-x:auto;border:1px solid var(--line);border-radius:var(--radius-md);background:rgb(var(--bg-rgb)/.24);scrollbar-width:thin} +.agentTreeHeader,.agentRow{display:grid;grid-template-columns:minmax(220px,1.5fr) minmax(105px,.8fr) 82px repeat(4,minmax(70px,.5fr));align-items:center;gap:8px;min-width:820px} +.agentTreeHeader{padding:6px 10px;color:var(--faint);font-size:var(--fs-11);font-weight:var(--fw-heavy);text-transform:uppercase} +.agentTreeHeader span:not(:first-child){text-align:right} +.agentTreeHeader .fieldtip{justify-content:flex-end} +.agentRow{width:100%;min-height:58px;padding:8px 10px;border:0;border-top:1px solid var(--line);background:transparent;color:var(--dim);font:inherit;text-align:left;cursor:pointer} +.agentRow:hover,.agentRow:focus-visible{background:rgb(var(--signal-rgb)/.045);outline:none} +.agentRow.selected{background:rgb(var(--signal-rgb)/.07);box-shadow:inset 2px 0 var(--accent)} +.agentRow.attention{box-shadow:inset 2px 0 var(--warn)} +.agentRow.selected.attention{box-shadow:inset 2px 0 var(--warn),inset 5px 0 rgb(var(--signal-rgb)/.38)} +.agentIdentity{min-width:0;padding-left:calc(var(--agent-depth)*18px)} +.agentIdentity strong{display:flex;align-items:center;gap:6px;min-width:0;color:var(--fg);font-size:var(--fs-12)} +.agentIdentity strong span:first-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.agentIdentity small{display:block;margin-top:2px;color:var(--faint);font-size:var(--fs-11);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.agentMetric{text-align:right;font:var(--fw-semibold) var(--fs-11)/1.3 var(--font-mono)} +.agentState{display:inline-flex;align-items:center;gap:4px;justify-content:flex-end;color:var(--dim);font-size:var(--fs-11);text-transform:capitalize} +.agentState:before{content:"";width:6px;height:6px;border-radius:var(--radius-round);background:var(--faint)} +.agentState.working:before{background:var(--good);box-shadow:0 0 0 3px rgb(var(--green-rgb)/.1)} +.agentState.waiting:before{background:var(--warn)} +.agentAttentionBadge{flex:none;padding:2px 6px;font-weight:var(--fw-heavy);text-transform:uppercase} +.agentAttentionDetail{margin-top:8px;padding:10px 10px;border:1px solid var(--line);border-radius:var(--radius-sm);background:rgb(var(--tint-rgb)/.018);color:var(--dim);font-size:var(--fs-11);line-height:1.45} +.agentAttentionDetail b{color:var(--fg)} +.agentAttentionDetail ul{margin:4px 0 0;padding-left:16px} +.subagentUsageStat{padding:8px 10px;border:1px solid var(--line);border-radius:var(--radius-sm);background:rgb(var(--tint-rgb)/.022);min-width:0} +.subagentUsageStat span{font-size:var(--fs-11)} +.subagentUsageStat strong{margin-top:3px;font-size:var(--fs-16);line-height:1.2;overflow:hidden;text-overflow:ellipsis} +.subagentUsageStat small{margin-top:2px;font-size:var(--fs-11);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.subagentUsageRow{display:grid;grid-template-columns:minmax(170px,1.15fr) minmax(120px,.8fr) repeat(4,minmax(72px,.45fr));align-items:center;gap:10px;min-height:58px;border-bottom:1px solid var(--line);padding:8px 0} +.subagentUsageIdentity strong{display:block;color:var(--fg);font-size:var(--fs-12)} +.subagentUsageIdentity small{display:block;margin-top:2px;color:var(--faint);font-size:var(--fs-11)} +.subagentUsageBar{height:5px;overflow:hidden;border-radius:var(--radius-pill);background:rgb(var(--tint-rgb)/.055)} +.subagentUsageBar i{display:block;height:100%;border-radius:inherit;background:linear-gradient(90deg,var(--accent),var(--signal-hi))} +.subagentUsageValue{text-align:right} +.subagentUsageValue span{display:block;color:var(--faint);font-size:var(--fs-11);text-transform:uppercase} +.subagentUsageValue b{display:block;margin-top:2px;color:var(--dim);font:var(--fw-bold) var(--fs-11)/1.2 var(--font-mono)} +.subagentUsageEmpty{padding:24px} +.subagentExplorerMeta{color:var(--faint);font-size:var(--fs-11);white-space:nowrap} +.subagentExplorerStat{min-width:0;padding:12px 14px;border-left:1px solid var(--line)} +.subagentExplorerStat:first-child{border-left:0} +.subagentExplorerStat span{font-size:var(--fs-11)} +.subagentExplorerStat strong{margin-top:3px;font-size:var(--fs-20);line-height:1.2} +.subagentExplorerStat small{margin-top:3px;font-size:var(--fs-11);line-height:1.35} +.subagentInsight{padding:10px 12px;border:1px solid var(--line);border-radius:var(--radius-md);background:rgb(var(--tint-rgb)/.02)} +.subagentInsight strong{display:flex;align-items:center;justify-content:space-between;gap:10px;color:var(--fg);font-size:var(--fs-12)} +.subagentInsight strong b{color:var(--warn);font:var(--fw-heavy) var(--fs-14)/1 var(--font-mono)} +.subagentInsight p{margin:4px 0 0;color:var(--faint);font-size:var(--fs-11);line-height:1.4} +.subagentInventoryHeader,.subagentInventoryRow{display:grid;grid-template-columns:minmax(230px,1.35fr) minmax(170px,1fr) minmax(125px,.75fr) 95px 105px 105px 90px;gap:10px;align-items:center;min-width:960px} +.subagentInventoryHeader{padding:8px 14px;color:var(--faint);font-size:var(--fs-11);font-weight:var(--fw-heavy);text-transform:uppercase} +.subagentInventoryHeader span:not(:first-child){text-align:right} +.subagentInventoryRow{width:100%;min-height:64px;padding:8px 14px;border:0;border-top:1px solid var(--line);background:transparent;color:var(--dim);font:inherit;text-align:left;cursor:pointer} +.subagentInventoryRow:hover,.subagentInventoryRow:focus-visible{background:rgb(var(--signal-rgb)/.045);outline:none} +.subagentInventoryIdentity{min-width:0} +.subagentInventoryIdentity strong{display:flex;align-items:center;gap:6px;min-width:0;color:var(--fg);font-size:var(--fs-12)} +.subagentInventoryIdentity strong>span:first-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.subagentInventoryIdentity small{display:block;margin-top:2px;overflow:hidden;color:var(--faint);font-size:var(--fs-11);text-overflow:ellipsis;white-space:nowrap} +.subagentInventoryProject{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.subagentInventoryMetric{text-align:right;font:var(--fw-semibold) var(--fs-11)/1.3 var(--font-mono)} +.subagentInventoryEmpty{padding:32px 16px} +.subagentInventoryWarning{margin-top:10px;border:1px solid rgb(var(--orange-rgb)/.3);border-radius:var(--radius-md);background:rgb(var(--orange-rgb)/.065);padding:10px 12px;color:var(--warn);font-size:var(--fs-11)} +.subagentInventoryWarning[hidden]{display:none} +.subagentCoverageNote{margin-top:10px;border:1px solid var(--line);border-radius:var(--radius-md);background:rgb(var(--tint-rgb)/.02);padding:8px 12px;color:var(--dim);font-size:var(--fs-11);line-height:1.45} +.subagentCoverageNote b{color:var(--fg)} +.subagentCoverageNote[hidden]{display:none} +@media(max-width:1100px){.agentActivityMetrics{grid-template-columns:repeat(2,minmax(0,1fr))}.agentActivityHead{align-items:flex-start;flex-direction:column}.agentCoverage{text-align:left}.subagentUsageRow{grid-template-columns:minmax(150px,1fr) minmax(105px,.7fr) repeat(4,minmax(68px,.4fr))}} +@media(max-width:1100px){.subagentExplorerStat:nth-child(3){border-left:0;border-top:1px solid var(--line)}.subagentExplorerStat:nth-child(4){border-top:1px solid var(--line)}} +/* subagent-investigation-v2 */ +.subagentFilterPrimary{display:grid;grid-template-columns:minmax(240px,1.4fr) repeat(3,minmax(130px,.72fr)) auto;gap:8px;align-items:end} +.subagentFilterPrimary .filterControl,.subagentFilterAdvanced .filterControl{min-width:0} +.subagentFilterPrimary input,.subagentFilterPrimary select,.subagentFilterAdvanced select{width:100%} +.subagentFilterMore{height:38px;border:1px solid var(--line2);border-radius:var(--radius-sm);background:rgb(var(--tint-rgb)/.035);padding:0 12px;color:var(--dim);font:inherit;font-size:var(--fs-11);font-weight:var(--fw-bold);white-space:nowrap;cursor:pointer} +.subagentFilterMore:hover,.subagentFilterMore:focus-visible{border-color:rgb(var(--signal-hi-rgb)/.48);color:var(--fg);outline:none} +.subagentFilterMore[aria-expanded=true]{border-color:rgb(var(--signal-rgb)/.38);background:rgb(var(--signal-rgb)/.07);color:var(--fg)} +.subagentFilterMore span:not(:empty){display:inline-grid;min-width:18px;height:18px;margin-left:4px;place-items:center;border-radius:var(--radius-pill);background:rgb(var(--signal-rgb)/.16);color:var(--signal-hi);font:var(--fw-bold) var(--fs-11)/1 var(--font-mono)} +.subagentFilterAdvanced{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;margin-top:10px;padding-top:10px;border-top:1px solid var(--line)} +.subagentFilterAdvanced[hidden]{display:none} +.subagentFilterFooter{display:flex;align-items:center;gap:12px;min-height:34px;margin-top:10px;padding-top:10px;border-top:1px solid var(--line)} +.subagentActiveFilters{display:flex;flex:1;align-items:center;gap:6px;min-width:0;flex-wrap:wrap} +.subagentActiveFilters>span{color:var(--faint);font-size:var(--fs-11)} +.subagentFilterChip{display:inline-flex;align-items:center;gap:6px;max-width:230px;border:1px solid rgb(var(--signal-rgb)/.28);border-radius:var(--radius-pill);background:rgb(var(--signal-rgb)/.065);padding:4px 8px;color:var(--dim);font:inherit;font-size:var(--fs-11);cursor:pointer} +.subagentFilterChip:hover,.subagentFilterChip:focus-visible{border-color:rgb(var(--signal-hi-rgb)/.58);color:var(--fg);outline:none} +.subagentFilterChip b{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:var(--fw-bold)} +.subagentFilterChip i{color:var(--signal-hi);font-style:normal} +.subagentFilterFooter>.clearFilters{flex:none} +.subagentInsight{width:100%;color:inherit;font:inherit;text-align:left;cursor:pointer} +.subagentInsight:hover,.subagentInsight:focus-visible{border-color:rgb(var(--orange-rgb)/.36);background:rgb(var(--orange-rgb)/.04);outline:none} +.subagentInsight[aria-pressed=true]{border-color:rgb(var(--orange-rgb)/.52);background:rgb(var(--orange-rgb)/.08);box-shadow:inset 2px 0 var(--warn)} +.subagentInsight p{min-height:27px} +.subagentInventoryRow.selected{background:rgb(var(--signal-rgb)/.07);box-shadow:inset 2px 0 var(--accent)} +.subagentInventoryRow.attention.selected{box-shadow:inset 2px 0 var(--warn),inset 5px 0 rgb(var(--signal-rgb)/.38)} +.subagentEvidence{padding:14px 16px 16px;border-top:1px solid rgb(var(--signal-rgb)/.26);border-bottom:1px solid rgb(var(--signal-rgb)/.18);background:linear-gradient(135deg,rgb(var(--signal-rgb)/.07),rgb(var(--bg2-rgb)/.84) 54%,rgb(var(--aura-rgb)/.04));box-shadow:inset 3px 0 var(--accent)} +.subagentEvidenceHead{display:flex;align-items:flex-start;justify-content:space-between;gap:14px} +.subagentEvidenceHead h4{margin:0;color:var(--fg);font-size:var(--fs-14)} +.subagentEvidenceHead p{margin:3px 0 0;color:var(--faint);font-size:var(--fs-11)} +.subagentEvidenceClose{flex:none;width:28px;height:28px;border:1px solid var(--line2);border-radius:var(--radius-sm);background:transparent;color:var(--dim);font-size:var(--fs-16);cursor:pointer} +.subagentEvidenceClose:hover,.subagentEvidenceClose:focus-visible{border-color:var(--line3);color:var(--fg);outline:none} +.subagentEvidenceContext{display:flex;gap:6px;margin-top:10px;flex-wrap:wrap} +.subagentEvidenceContext span{border:1px solid var(--line);border-radius:var(--radius-pill);background:rgb(var(--tint-rgb)/.025);padding:4px 8px;color:var(--dim);font-size:var(--fs-11)} +.subagentEvidenceGrid{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:6px;margin-top:10px} +.subagentEvidenceMetric{min-width:0;border:1px solid var(--line);border-radius:var(--radius-sm);background:rgb(var(--tint-rgb)/.024);padding:8px 10px} +.subagentEvidenceMetric span{font-size:var(--fs-11)} +.subagentEvidenceMetric strong{margin-top:3px;overflow:hidden;font-size:var(--fs-12);line-height:1.2;text-overflow:ellipsis;white-space:nowrap} +.subagentEvidenceSignals{margin-top:10px;padding:10px 10px;border:1px solid var(--line);border-radius:var(--radius-sm);background:rgb(var(--bg-rgb)/.3);color:var(--dim);font-size:var(--fs-11);line-height:1.45} +.subagentEvidenceSignals strong{color:var(--fg)} +.subagentEvidenceSignals ul{margin:4px 0 0;padding-left:16px} +.subagentEvidenceActions{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:10px} +.subagentEvidenceActions p{margin:0;color:var(--faint);font-size:var(--fs-11)} +.subagentOpenParent{flex:none;border:1px solid rgb(var(--signal-rgb)/.44);border-radius:var(--radius-sm);background:rgb(var(--signal-rgb)/.09);padding:6px 10px;color:var(--signal-hi);font:inherit;font-size:var(--fs-11);font-weight:var(--fw-bold);cursor:pointer} +.subagentOpenParent:hover,.subagentOpenParent:focus-visible{border-color:rgb(var(--signal-hi-rgb)/.7);background:rgb(var(--signal-rgb)/.15);color:var(--fg);outline:none} +@media(max-width:1100px){.subagentFilterPrimary{grid-template-columns:minmax(220px,1.2fr) repeat(2,minmax(125px,.7fr));}.subagentFilterPrimary .filterControl:nth-child(4){grid-column:1/2}.subagentFilterMore{grid-column:3/4}.subagentEvidenceGrid{grid-template-columns:repeat(3,minmax(0,1fr))}} +@media(max-width:760px){.subagentFilterPrimary,.subagentFilterAdvanced{grid-template-columns:1fr 1fr}.subagentFilterPrimary .filterControl:first-child{grid-column:1/-1}.subagentFilterPrimary .filterControl:nth-child(4),.subagentFilterMore{grid-column:auto}.subagentEvidenceGrid{grid-template-columns:repeat(2,minmax(0,1fr))}.subagentEvidenceActions{align-items:flex-start;flex-direction:column}} +/* subagent-investigation-v3 */ +body.sessionRoute #view-session.sessionSubagents .currentDaySummary{display:none} +.subagentCommand{overflow:hidden;padding:0;box-shadow:var(--shadow-soft)} +.subagentViewTabs{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));border-bottom:1px solid var(--line);background:rgb(var(--bg-rgb)/.26)} +.subagentViewTabs{grid-template-columns:repeat(2,minmax(0,1fr))} +#view-subagents .subagentViewTabs{display:none} +#view-session .subagentViewTabs [data-subagent-view=roles]{display:none} +.subagentViewTabs button{display:flex;min-height:48px;align-items:center;justify-content:center;gap:8px;border:0;border-right:1px solid var(--line);background:transparent;color:var(--dim);font:inherit;font-size:var(--fs-12);font-weight:var(--fw-heavy);cursor:pointer} +.subagentViewTabs button:last-child{border-right:0} +.subagentViewTabs button:hover,.subagentViewTabs button:focus-visible{background:rgb(var(--signal-rgb)/.045);color:var(--fg);outline:none} +.subagentViewTabs button[aria-selected=true]{background:linear-gradient(180deg,rgb(var(--signal-rgb)/.12),rgb(var(--signal-rgb)/.035));box-shadow:inset 0 -2px var(--accent);color:var(--fg)} +.subagentViewTabs small{display:inline-grid;min-width:24px;height:20px;place-items:center;border:1px solid var(--line2);border-radius:var(--radius-pill);background:rgb(var(--tint-rgb)/.035);color:var(--faint);font:var(--fw-bold) var(--fs-11)/1 var(--font-mono)} +.subagentViewTabs button[aria-selected=true] small{border-color:rgb(var(--signal-rgb)/.38);background:rgb(var(--signal-rgb)/.1);color:var(--signal-hi)} +.subagentCommand .subagentFilterPrimary{padding:12px 12px 0} +.subagentCommand .subagentFilterAdvanced{margin:10px 12px 0} +.subagentCommand .subagentFilterFooter{margin:10px 12px 0;padding-bottom:10px} +.subagentCommand .subagentFilterFooter[hidden]{display:none} +.subagentCompactSummary{display:flex;align-items:center;gap:8px;margin-top:10px;overflow-x:auto;scrollbar-width:thin} +.subagentCompactMetric{display:flex;min-width:max-content;align-items:baseline;gap:6px;border:1px solid var(--line);border-radius:var(--radius-pill);background:rgb(var(--tint-rgb)/.022);padding:6px 10px;color:var(--faint);font:inherit;font-size:var(--fs-11)} +.subagentCompactMetric b{color:var(--fg);font:var(--fw-heavy) var(--fs-12)/1 var(--font-mono)} +.subagentCompactMetric.warn{border-color:rgb(var(--orange-rgb)/.3);background:rgb(var(--orange-rgb)/.055)} +.subagentCompactMetric.warn b{color:var(--warn)} +.subagentCompactMetric.dim b{color:var(--dim)} +.subagentCompactMetric.actionable{cursor:pointer} +.subagentCompactMetric.actionable:hover,.subagentCompactMetric.actionable:focus-visible{border-color:rgb(var(--signal-hi-rgb)/.52);background:rgb(var(--signal-rgb)/.075);color:var(--fg);outline:none} +.subagentCompactMetric.actionable[aria-pressed=true]{border-color:rgb(var(--signal-rgb)/.58);background:rgb(var(--signal-rgb)/.13);box-shadow:inset 0 0 0 1px rgb(var(--signal-rgb)/.12);color:var(--signal-hi)} +.subagentCompactMetric.actionable[aria-pressed=true] b{color:var(--signal-hi)} +.subagentWorkspace{display:grid;grid-template-columns:minmax(0,1fr) minmax(300px,340px);gap:12px;align-items:start;margin-top:12px} +.subagentResultsCard{overflow:hidden;box-shadow:var(--shadow-soft)} +.subagentResultsHead{display:flex;min-height:58px;align-items:center;justify-content:space-between;gap:14px;padding:12px 14px;border-bottom:1px solid var(--line)} +.subagentResultsHead h3{margin:0;font-size:var(--fs-13)} +.subagentResultsHead p{margin:2px 0 0;color:var(--faint);font-size:var(--fs-11)} +.subagentResultsHead>span{flex:none;color:var(--faint);font-size:var(--fs-11)} +.subagentResultsEmpty{min-height:220px;padding:28px} +.subagentResultsEmpty strong{display:block;margin-bottom:4px;color:var(--fg);font-size:var(--fs-13)} +.subagentWorkspace.roleView{grid-template-columns:1fr} +.subagentWorkspace.roleView .subagentInspector{display:none} +.subagentModelDistribution{margin-top:12px;padding:12px 14px} +.subagentModelHead{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:10px} +.subagentModelHead h3{margin:0;font-size:var(--fs-12)} +.subagentModelHead small{color:var(--faint);font-size:var(--fs-11)} +.subagentModelRows{display:grid;gap:6px} +.subagentModelRow{display:grid;grid-template-columns:minmax(150px,1fr) minmax(90px,2fr) 52px 90px;align-items:center;gap:12px;color:var(--dim);font-size:var(--fs-11)} +.subagentModelIdentity{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.subagentModelIdentity strong{color:var(--fg)} +.subagentModelTrack{height:7px;overflow:hidden;border-radius:var(--radius-pill);background:rgb(var(--tint-rgb)/.07)} +.subagentModelTrack i{display:block;height:100%;border-radius:inherit;background:linear-gradient(90deg,var(--accent),var(--signal-hi))} +.subagentModelNumber{text-align:right;font:var(--fw-bold) var(--fs-11)/1 var(--font-mono)} +.subagentModelCost{text-align:right;font:var(--fw-bold) var(--fs-11)/1 var(--font-mono)} +.subagentModelCost small{display:block;margin-top:4px;color:var(--faint);font:var(--fw-medium) var(--fs-11)/1.2 var(--font-sans)} +.subagentSessionGroup{border-bottom:1px solid var(--line);background:rgb(var(--bg-rgb)/.12)} +.subagentSessionGroup:last-child{border-bottom:0} +.subagentSessionGroup>summary{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:16px;min-height:70px;padding:10px 14px;list-style:none;cursor:pointer} +.subagentSessionGroup>summary::-webkit-details-marker{display:none} +.subagentSessionGroup>summary:hover,.subagentSessionGroup>summary:focus-visible{background:rgb(var(--signal-rgb)/.035);outline:none} +.subagentSessionGroup[open]>summary{background:linear-gradient(90deg,rgb(var(--signal-rgb)/.065),transparent 72%);box-shadow:inset 2px 0 var(--accent)} +.subagentSessionGroup.hasAttention>summary{box-shadow:inset 2px 0 var(--warn)} +.subagentSessionIdentity{min-width:0} +.subagentSessionIdentity strong{display:flex;align-items:center;gap:6px;color:var(--fg);font-size:var(--fs-12)} +.subagentSessionIdentity strong span:first-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.subagentSessionIdentity small{display:block;margin-top:3px;overflow:hidden;color:var(--faint);font-size:var(--fs-11);text-overflow:ellipsis;white-space:nowrap} +.subagentSessionBadge{padding:2px 6px;font-weight:var(--fw-heavy);text-transform:uppercase;white-space:nowrap} +.subagentSessionMetrics{display:grid;grid-template-columns:repeat(3,minmax(58px,auto));gap:8px;text-align:right} +.subagentSessionMetric span{display:block;color:var(--faint);font-size:var(--fs-11);text-transform:uppercase} +.subagentSessionMetric b{display:block;margin-top:2px;color:var(--dim);font:var(--fw-bold) var(--fs-11)/1.2 var(--font-mono)} +.subagentSessionGroup>summary:after{content:"›";position:absolute;right:9px;color:var(--faint);transform:rotate(90deg)} +.subagentSessionGroup[open]>summary:after{transform:rotate(-90deg)} +.subagentChildHeader,.subagentChildRow{display:grid;grid-template-columns:minmax(150px,1.2fr) minmax(92px,.75fr) minmax(72px,.5fr) minmax(68px,.48fr);align-items:center;gap:8px} +.subagentChildHeader{padding:6px 14px;border-top:1px solid var(--line);background:rgb(var(--shade-rgb)/.12);color:var(--faint);font-size:var(--fs-11);font-weight:var(--fw-heavy);text-transform:uppercase} +.subagentChildHeader span:not(:first-child){text-align:right} +.subagentChildRow{width:100%;min-height:58px;padding:8px 14px;border:0;border-top:1px solid var(--line);background:transparent;color:var(--dim);font:inherit;text-align:left;cursor:pointer} +.subagentChildRow:hover,.subagentChildRow:focus-visible{background:rgb(var(--signal-rgb)/.04);outline:none} +.subagentChildRow.selected{background:rgb(var(--signal-rgb)/.08);box-shadow:inset 3px 0 var(--accent)} +.subagentChildRow.attention{box-shadow:inset 2px 0 var(--warn)} +.subagentChildRow.attention.selected{box-shadow:inset 2px 0 var(--warn),inset 5px 0 rgb(var(--signal-rgb)/.36)} +.subagentChildIdentity{min-width:0} +.subagentChildIdentity strong{display:flex;align-items:center;gap:6px;min-width:0;color:var(--fg);font-size:var(--fs-11)} +.subagentChildIdentity strong>span:first-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.subagentChildIdentity small{display:block;margin-top:2px;overflow:hidden;color:var(--faint);font-size:var(--fs-11);text-overflow:ellipsis;white-space:nowrap} +.subagentChildMetric{text-align:right;font:var(--fw-semibold) var(--fs-11)/1.25 var(--font-mono)} +.subagentSiblingLink{width:100%;border:0;border-top:1px solid var(--line);background:rgb(var(--signal-rgb)/.025);padding:10px 14px;color:var(--signal-hi);font:inherit;font-size:var(--fs-11);font-weight:var(--fw-bold);text-align:left;cursor:pointer} +.subagentSiblingLink:hover,.subagentSiblingLink:focus-visible{background:rgb(var(--signal-rgb)/.07);color:var(--fg);outline:none} +.subagentInspector{position:sticky;top:76px;min-height:310px;overflow:hidden;padding:14px;box-shadow:var(--shadow-soft)} +.subagentInspector:before{content:"";position:absolute;inset:0 0 auto;height:2px;background:linear-gradient(90deg,var(--accent),var(--signal-hi),transparent)} +.subagentInspectorEmpty{min-height:278px} +.subagentInspectorEmpty svg{width:34px;height:34px;margin:auto;fill:none;stroke:var(--faint);stroke-width:1.5} +.subagentInspectorEmpty strong{display:block;margin-top:10px;color:var(--fg);font-size:var(--fs-12)} +.subagentInspectorEmpty p{max-width:235px;margin:4px auto 0;color:var(--faint);font-size:var(--fs-11);line-height:1.45} +.subagentInspectorHead{display:flex;align-items:flex-start;justify-content:space-between;gap:10px} +.subagentInspectorHead h3{margin:0;color:var(--fg);font-size:var(--fs-14);overflow-wrap:anywhere} +.subagentInspectorHead p{margin:3px 0 0;color:var(--faint);font-size:var(--fs-11)} +.subagentInspectorClose{flex:none;width:28px;height:28px;border:1px solid var(--line);border-radius:var(--radius-sm);background:transparent;color:var(--dim);font-size:var(--fs-16);cursor:pointer} +.subagentInspectorClose:hover,.subagentInspectorClose:focus-visible{border-color:var(--line3);color:var(--fg);outline:none} +.subagentInspectorParent{margin-top:12px;padding:8px 10px;border:1px solid rgb(var(--signal-rgb)/.2);border-radius:var(--radius-sm);background:rgb(var(--signal-rgb)/.045)} +.subagentInspectorParent span{display:block;color:var(--faint);font-size:var(--fs-11);font-weight:var(--fw-heavy);text-transform:uppercase} +.subagentInspectorParent strong{display:block;margin-top:3px;overflow:hidden;color:var(--fg);font-size:var(--fs-11);text-overflow:ellipsis;white-space:nowrap} +.subagentInspectorParent small{display:block;margin-top:2px;color:var(--dim);font-size:var(--fs-11)} +.subagentInspectorTags{display:flex;gap:4px;margin-top:8px;flex-wrap:wrap} +.subagentInspectorTags span{border:1px solid var(--line);border-radius:var(--radius-pill);background:rgb(var(--tint-rgb)/.025);padding:3px 6px;color:var(--dim);font-size:var(--fs-11)} +.subagentInspectorGrid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;margin-top:10px} +.subagentInspectorMetric{min-width:0;border:1px solid var(--line);border-radius:var(--radius-sm);background:rgb(var(--tint-rgb)/.02);padding:8px 8px} +.subagentInspectorMetric span{display:block;color:var(--faint);font-size:var(--fs-11);font-weight:var(--fw-heavy);text-transform:uppercase} +.subagentInspectorMetric b{display:block;margin-top:3px;overflow:hidden;color:var(--fg);font:var(--fw-bold) var(--fs-11)/1.2 var(--font-mono);text-overflow:ellipsis;white-space:nowrap} +.subagentInspectorSignals{margin-top:10px;padding:8px 10px;border:1px solid var(--line);border-radius:var(--radius-sm);background:rgb(var(--bg-rgb)/.28);color:var(--dim);font-size:var(--fs-11);line-height:1.45} +.subagentInspectorSignals>strong{color:var(--fg)} +.subagentInspectorSignals ul{margin:4px 0 0;padding-left:16px} +.subagentInspectorSignals p{margin:4px 0 0} +.subagentInspectorAction{width:100%;margin-top:10px} +.subagentRoleTable{overflow-x:auto} +.subagentRoleHeader,.subagentRoleRow{display:grid;grid-template-columns:minmax(170px,1.15fr) repeat(5,minmax(72px,.55fr));align-items:center;gap:10px;min-width:690px} +.subagentRoleHeader{padding:8px 14px;color:var(--faint);font-size:var(--fs-11);font-weight:var(--fw-heavy);text-transform:uppercase} +.subagentRoleHeader span:not(:first-child){text-align:right} +.subagentRoleRow{min-height:68px;padding:8px 14px;border-top:1px solid var(--line)} +.subagentRoleIdentity{min-width:0} +.subagentRoleIdentity strong{display:block;overflow:hidden;color:var(--fg);font-size:var(--fs-11);text-overflow:ellipsis;white-space:nowrap} +.subagentRoleIdentity small{display:block;margin-top:2px;color:var(--faint);font-size:var(--fs-11)} +.subagentRoleMetric{text-align:right} +.subagentRoleMetric span{display:block;color:var(--faint);font-size:var(--fs-11);text-transform:uppercase} +.subagentRoleMetric b{display:block;margin-top:2px;color:var(--dim);font:var(--fw-bold) var(--fs-11)/1.2 var(--font-mono)} +.subagentRoleMetric.warn b{color:var(--warn)} +.subagentSessionMetrics{grid-template-columns:repeat(4,minmax(58px,auto))} +.subagentRoleHeader,.subagentRoleRow{grid-template-columns:minmax(170px,1.15fr) repeat(6,minmax(72px,.55fr));min-width:780px} +.subagentRoleEconomics{padding:12px} +.subagentRoleKpis{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px} +.subagentRoleKpi{min-width:0;padding:10px 12px;border:1px solid var(--line);border-radius:var(--radius-md);background:linear-gradient(145deg,rgb(var(--tint-rgb)/.03),rgb(var(--bg-rgb)/.18))} +.subagentRoleKpi span{font-size:var(--fs-11)} +.subagentRoleKpi strong{margin-top:4px;overflow:hidden;font-size:var(--fs-18);line-height:1.15;text-overflow:ellipsis;white-space:nowrap} +.subagentRoleKpi small{margin-top:3px;overflow:hidden;font-size:var(--fs-11);text-overflow:ellipsis;white-space:nowrap} +.subagentRoleKpi.good strong{color:var(--good)} +.subagentRoleKpi.warn strong{color:var(--warn)} +.subagentRoleTrendHead{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:14px 2px 6px} +.subagentRoleTrendHead h4{margin:0;color:var(--fg);font-size:var(--fs-12)} +.subagentRoleTrendHead p{margin:2px 0 0;color:var(--faint);font-size:var(--fs-11)} +.subagentRoleChartModes{flex:none} +.subagentRoleChartModes button{padding:4px 8px;font-size:var(--fs-11)} +.subagentRoleTrendList{overflow:hidden;border:1px solid var(--line);border-radius:var(--radius-md);background:rgb(var(--bg-rgb)/.2)} +.subagentRoleTrendRow{display:grid;grid-template-columns:minmax(175px,1.05fr) minmax(88px,.5fr) minmax(180px,1fr) minmax(150px,.8fr) auto;align-items:center;gap:14px;min-height:82px;padding:10px 12px;border-top:1px solid var(--line)} +.subagentRoleTrendRow:first-child{border-top:0} +.subagentRoleIdentity{min-width:0} +.subagentRoleIdentity strong{display:block;overflow:hidden;color:var(--fg);font-size:var(--fs-11);text-overflow:ellipsis;white-space:nowrap} +.subagentRoleIdentity small{display:block;margin-top:3px;color:var(--faint);font-size:var(--fs-11);line-height:1.35} +.subagentRolePrimary span,.subagentRoleMeta span{display:block;color:var(--faint);font-size:var(--fs-11);font-weight:var(--fw-heavy);text-transform:uppercase} +.subagentRolePrimary b{display:block;margin-top:3px;color:var(--fg);font:var(--fw-bold) var(--fs-13)/1.2 var(--font-mono)} +.subagentRolePrimary small{display:block;margin-top:3px;color:var(--dim);font-size:var(--fs-11)} +.subagentRolePrimary.good small{color:var(--good)} +.subagentRolePrimary.warn small{color:var(--warn)} +.subagentRoleSpark{min-width:0;height:48px} +.subagentRoleSpark svg{display:block;width:100%;height:48px;overflow:visible} +.subagentRoleSparkEmpty{display:flex;height:48px;align-items:center;color:var(--faint);font-size:var(--fs-11)} +.subagentRoleMeta{display:flex;align-items:center;gap:6px;flex-wrap:wrap} +.subagentRoleMeta>span:first-child{margin-right:3px} +.subagentRoleMeta b{color:var(--dim);font:var(--fw-bold) var(--fs-11)/1.2 var(--font-mono)} +.subagentRoleSignal{border:1px solid var(--line);border-radius:var(--radius-pill);background:rgb(var(--tint-rgb)/.025);padding:3px 6px;color:var(--dim);font-size:var(--fs-11);font-weight:var(--fw-bold)} +.subagentRoleSignal.warn{border-color:rgb(var(--orange-rgb)/.34);background:rgb(var(--orange-rgb)/.065);color:var(--warn)} +.subagentRoleInspect{justify-self:end;border:1px solid rgb(var(--signal-rgb)/.34);border-radius:var(--radius-sm);background:rgb(var(--signal-rgb)/.065);padding:6px 8px;color:var(--signal-hi);font:inherit;font-size:var(--fs-11);font-weight:var(--fw-bold);cursor:pointer;white-space:nowrap} +.subagentRoleInspect:hover,.subagentRoleInspect:focus-visible{border-color:rgb(var(--signal-hi-rgb)/.68);background:rgb(var(--signal-rgb)/.12);color:var(--fg);outline:none} +.subagentRoleEconomicsFoot{margin:8px 2px 0;color:var(--faint);font-size:var(--fs-11)} +.subagentRoleAnalyticsEmpty{margin:12px;border:1px solid rgb(var(--orange-rgb)/.28);border-radius:var(--radius-md);background:rgb(var(--orange-rgb)/.05);padding:12px;color:var(--dim)} +.subagentRoleAnalyticsEmpty strong{display:block;color:var(--fg);font-size:var(--fs-11)} +.subagentRoleAnalyticsEmpty p{margin:4px 0 0;font-size:var(--fs-11)} +.subagentRoleAnalyticsEmpty+.subagentRoleTable{border-top:1px solid var(--line)} +.subagentSessionGroup>summary{position:relative;padding-right:24px} +@media(max-width:1180px){.subagentWorkspace{grid-template-columns:minmax(0,1fr) 300px}.subagentSessionMetrics{gap:8px}.subagentChildHeader,.subagentChildRow{grid-template-columns:minmax(130px,1fr) minmax(82px,.7fr) minmax(68px,.48fr) minmax(62px,.44fr)}} +@media(max-width:1100px){.subagentCommand .subagentFilterPrimary .filterControl:nth-child(4){grid-column:1/3}.subagentRoleTrendRow{grid-template-columns:minmax(160px,1fr) minmax(82px,.5fr) minmax(150px,.9fr) auto}.subagentRoleMeta{grid-column:1/4}.subagentRoleInspect{grid-column:4;grid-row:1/3}} +@media(max-width:900px){.subagentWorkspace{grid-template-columns:1fr}.subagentInspector{position:relative;top:auto}.subagentFilterPrimary{grid-template-columns:repeat(2,minmax(0,1fr))}.subagentFilterPrimary .filterControl:first-child{grid-column:1/-1}.subagentFilterPrimary .filterControl:nth-child(4),.subagentFilterMore{grid-column:auto}.subagentSessionGroup>summary{grid-template-columns:1fr}.subagentSessionMetrics{text-align:left}.subagentSessionMetric{display:inline-flex;align-items:baseline;gap:4px}.subagentSessionMetric span,.subagentSessionMetric b{display:inline}.subagentChildHeader,.subagentChildRow{grid-template-columns:minmax(140px,1fr) minmax(80px,.7fr) minmax(66px,.45fr) minmax(62px,.42fr)}} +.subagentCostHeat{position:relative;display:block;min-width:0;padding-bottom:8px;color:var(--cost-heat);font-weight:var(--fw-bold)} +.subagentCostHeat:after{content:"";position:absolute;right:0;bottom:1px;width:var(--cost-fill);height:3px;max-width:100%;border-radius:var(--radius-xs);background:linear-gradient(90deg,var(--session-signal),var(--cost-heat))} +.subagentCostHeat[style=""]{color:inherit} +.subagentRoleKpi span{font-size:var(--fs-11)} +.subagentRoleKpi strong{font-size:var(--fs-22)} +.subagentRoleKpi small{font-size:var(--fs-11);line-height:1.35} +.subagentRoleTrendHead h4{font-size:var(--fs-16)} +.subagentRoleTrendHead p{font-size:var(--fs-11)} +.subagentRoleChartModes button{font-size:var(--fs-11)} +.subagentRoleTrendRow{min-height:116px} +.subagentRoleIdentity strong{font-size:var(--fs-12)} +.subagentRoleIdentity small{font-size:var(--fs-11);line-height:1.4} +.subagentRolePrimary span,.subagentRoleMeta span{font-size:var(--fs-11)} +.subagentRolePrimary b{font-size:var(--fs-16)} +.subagentRolePrimary small{font-size:var(--fs-11)} +.subagentRoleMeta b{font-size:var(--fs-11)} +.subagentRoleSignal{font-size:var(--fs-11)} +.subagentRoleInspect{font-size:var(--fs-11)} +.subagentRoleEconomicsFoot{font-size:var(--fs-11);line-height:1.4} +.subagentRoleSpark{display:flex;height:83px;flex-direction:column;justify-content:center} +.subagentRoleSpark svg{height:62px;flex:none} +.subagentRoleSparkDates{display:flex;justify-content:space-between;gap:8px;margin-top:3px;color:var(--faint);font:var(--fw-bold) var(--fs-11)/1 var(--font-mono)} +.subagentRoleSparkDates span:last-child{text-align:right} +.subagentRoleHeader,.subagentRoleRow{grid-template-columns:minmax(170px,1.2fr) repeat(5,minmax(82px,.56fr));min-width:750px} +.subagentResultsHead h3{font-size:var(--fs-16)} +.subagentResultsHead p{font-size:var(--fs-11);line-height:1.4} +.subagentSessionIdentity strong{font-size:var(--fs-13)} +.subagentSessionIdentity small{font-size:var(--fs-11)} +.subagentSessionMetric span{font-size:var(--fs-11)} +.subagentSessionMetric b{font-size:var(--fs-12)} +.subagentChildHeader{font-size:var(--fs-11)} +.subagentChildIdentity strong{font-size:var(--fs-12)} +.subagentChildIdentity small{font-size:var(--fs-11)} +.subagentChildMetric{font-size:var(--fs-11)} +.subagentInspectorHead h3{font-size:var(--fs-16)} +.subagentInspectorMetric span{font-size:var(--fs-11)} +.subagentInspectorMetric b{font-size:var(--fs-12)} +.subagentInspectorEmpty p{font-size:var(--fs-11)} +#view-subagents .subagentExplorerMeta{font-size:var(--fs-12);font-weight:var(--fw-semibold);color:var(--dim)} +#view-subagents .subagentWorkspace.roleView .subagentResultsHead{display:none} +#view-subagents .subagentRoleEconomics{padding:18px} +#view-subagents .subagentRoleKpis{gap:0;overflow:hidden;border:1px solid var(--line2);border-radius:var(--radius-lg);background:linear-gradient(105deg,rgb(var(--signal-rgb)/.055),rgb(var(--tint-rgb)/.015) 52%,rgb(var(--signal-hi-rgb)/.03))} +#view-subagents .subagentRoleKpi{border:0;border-left:1px solid var(--line);border-radius:0;background:none;padding:14px 18px} +#view-subagents .subagentRoleKpi:first-child{border-left:0} +#view-subagents .subagentRoleKpi strong{font-size:var(--fs-24)} +#view-subagents .subagentRoleTrendHead{margin-top:20px;margin-bottom:10px} +#view-subagents .subagentRoleTrendRow{min-height:108px} +#view-subagents .subagentRoleIdentity strong{font-size:var(--fs-13)} +#view-subagents .subagentRoleEconomicsFoot{margin-top:10px} +#view-subagents .subagentCompactSummary{margin-top:8px} +#view-subagents .subagentWorkspace{margin-top:10px} +#view-subagents .subagentCommand .subagentFilterPrimary{padding-bottom:12px} +#view-subagents .subagentCommand .subagentFilterFooter{margin-top:0} +@media(max-width:1100px){#view-subagents .subagentRoleKpi{padding:12px}#view-subagents .subagentRoleKpi strong{font-size:var(--fs-20)}} diff --git a/dashboard/styles/shell.css b/dashboard/styles/shell.css new file mode 100644 index 00000000..7a3ffe8d --- /dev/null +++ b/dashboard/styles/shell.css @@ -0,0 +1,318 @@ +/* shared-spectrum-system-v1 */ +/* spectrum-professional-finish-v2 */ +@font-face{font-family:"Tektur Local";src:url("/assets/fonts/Tektur-Variable.ttf") format("truetype");font-style:normal;font-weight:400 900;font-stretch:75% 100%;font-display:swap} +body.spectrumApp{background:var(--spectrum-page)} +body.spectrumApp:after{content:"";position:fixed;inset:-18%;z-index:-2;pointer-events:none;background:radial-gradient(34% 28% at 28% 22%,rgb(var(--signal-rgb)/calc(.13*var(--glow))),transparent 70%),radial-gradient(30% 26% at 78% 68%,rgb(var(--aura-rgb)/calc(.1*var(--glow))),transparent 70%),radial-gradient(26% 22% at 52% 96%,rgb(var(--signal-hi-rgb)/calc(.07*var(--glow))),transparent 70%);animation:atmosphereDrift 46s ease-in-out infinite alternate} +@keyframes atmosphereDrift{0%{transform:translate3d(-3%,-2%,0) scale(1)}100%{transform:translate3d(3%,2%,0) scale(1.06)}} +@keyframes foilDrift{0%{background-position:0 0}100%{background-position:200% 0}} +@keyframes ionSheen{0%{transform:translateX(-120%)}100%{transform:translateX(220%)}} +/* vertical-navigation-rail-v1 */ +body.spectrumApp .wrap{--navigation-rail-width:184px;display:grid;grid-template-columns:var(--navigation-rail-width) minmax(0,1320px);column-gap:20px;align-items:start;width:100%;max-width:1532px} +body.spectrumApp .wrap>:not(.top){grid-column:2;min-width:0} +body.spectrumApp .top{grid-column:1;grid-row:1;position:sticky;top:18px;z-index:20;display:flex;height:calc(100vh - 36px);min-height:520px;align-items:stretch;flex-direction:column;gap:12px;margin:0;padding:12px;border:1px solid var(--spectrum-border);border-radius:var(--radius-lg);background:radial-gradient(ellipse 120% 38% at 18% -4%,rgb(var(--signal-rgb)/.22),transparent 62%),radial-gradient(ellipse 110% 42% at 118% 84%,rgb(var(--aura-rgb)/.11),transparent 62%),linear-gradient(165deg,rgb(var(--panel-rgb)/.86),rgb(var(--bg-lift-rgb)/.84) 52%,rgb(var(--bg-rgb)/.9));box-shadow:var(--spectrum-depth-focus);backdrop-filter:blur(22px) saturate(1.25)} +body.spectrumApp .top:before{content:"";position:absolute;inset:0 12px auto;height:1px;background:var(--spectrum-foil);background-size:200% 100%;opacity:.8;animation:foilDrift 14s linear infinite} +body.spectrumApp .top .brand{flex:0 0 auto;min-width:0;padding:3px 2px 0} +.brandCopy{display:grid;min-width:0;line-height:1.1} +.brandName{overflow:hidden;text-overflow:ellipsis} +body.spectrumApp .top .tabs{display:flex;min-width:0;max-width:100%;overflow:visible;flex:1;flex-direction:column;gap:4px;padding:10px 0 0;border:0;border-top:1px solid var(--line);border-radius:0;background:transparent;box-shadow:none;scrollbar-width:none} +body.spectrumApp .top .tabs::-webkit-scrollbar{display:none} +.navPrimary,.navSecondary{display:flex;flex-direction:column;gap:4px} +.navSecondary{margin-top:auto;padding-top:10px;border-top:1px solid var(--line)} +body.spectrumApp .top .tab{display:grid;width:100%;min-height:42px;grid-template-columns:18px minmax(0,1fr);align-items:center;gap:10px;padding:8px 10px;text-align:left} +.tabIcon{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round} +.tabLabel{min-width:0;overflow:hidden;text-overflow:ellipsis} +body.spectrumApp .top>.spacer{display:none} +@media(max-width:1180px){body.spectrumApp .wrap{--navigation-rail-width:68px;column-gap:16px;padding-right:18px;padding-left:18px}body.spectrumApp .top{padding:10px 8px}body.spectrumApp .top .brand{justify-content:center;padding-right:0;padding-left:0}.logo,.logo img{width:50px}body.spectrumApp .top .brandCopy,body.spectrumApp .top .tabLabel{display:none}body.spectrumApp .top .tab{display:grid;grid-template-columns:1fr;place-items:center;padding:8px}} +@media(max-width:760px){body.spectrumApp .wrap{display:block;max-width:1320px;padding:12px 14px 64px}body.spectrumApp .wrap>:not(.top){min-width:0}body.spectrumApp .top{position:sticky;top:0;display:grid;height:auto;min-height:0;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:8px;margin:0 0 14px;padding:8px 0 10px;border:0;border-bottom:1px solid var(--spectrum-border);border-radius:0;background:radial-gradient(ellipse 62% 90% at 5% 0,rgb(var(--signal-rgb)/.16),transparent 70%),linear-gradient(180deg,var(--bg),var(--bg-lift));box-shadow:0 10px 26px rgb(var(--shade-rgb)/.28)}body.spectrumApp .top:before{inset:auto 0 0}body.spectrumApp .top .brand{grid-column:1;grid-row:1;justify-content:flex-start;padding:0}.logo,.logo img{width:58px}body.spectrumApp .top .brandCopy{display:grid}body.spectrumApp .top .tabs{grid-column:1/-1;grid-row:2;min-width:0;width:100%;overflow-x:auto;flex:none;flex-direction:row;gap:3px;padding:8px 0 0;border-top:1px solid var(--line)}.navPrimary,.navSecondary{display:contents}body.spectrumApp .top .tab{display:flex;width:auto;min-height:38px;grid-template-columns:none;gap:6px;padding:8px 10px;white-space:nowrap}body.spectrumApp .top .tabLabel{display:inline}} +@media(max-width:760px){#view-models{overflow-x:clip}} +@media(max-width:520px){.brandName{font-size:var(--fs-14)}} +body.spectrumApp .seg{border-color:var(--spectrum-border);background:var(--spectrum-control);box-shadow:inset 0 1px 0 rgb(var(--tint-rgb)/.055),0 6px 18px rgb(var(--shade-rgb)/.1)} +body.spectrumApp .tab.on,body.spectrumApp .seg button.on{background:var(--spectrum-active);color:var(--on-accent);box-shadow:var(--spectrum-glow)} +body.spectrumApp .top .tab{position:relative;overflow:hidden} +body.spectrumApp .top .tab.on:after{content:"";position:absolute;inset:0 auto 0 0;width:40%;pointer-events:none;background:linear-gradient(100deg,transparent,rgb(var(--tint-rgb)/.42),transparent);transform:translateX(-120%);animation:ionSheen .9s var(--spectrum-ease) .08s 1 both} +body.spectrumApp .tab.on:hover,body.spectrumApp .seg button.on:hover{background:var(--spectrum-active-hover)} +body.spectrumApp .tab:hover:not(.on),body.spectrumApp .seg button:hover:not(.on){background:linear-gradient(135deg,rgb(var(--signal-rgb)/.105),rgb(var(--aura-rgb)/.055));color:var(--fg)} +body.spectrumApp .card{border-color:var(--spectrum-border);background:var(--spectrum-card);box-shadow:var(--spectrum-shadow)} +body.spectrumApp .card:hover{border-color:var(--spectrum-edge-hover);box-shadow:var(--spectrum-depth-focus),0 0 0 1px rgb(var(--signal-rgb)/.1),0 0 32px rgb(var(--signal-rgb)/calc(.1*var(--glow)))} +body.spectrumApp .tbtn{border-color:rgb(var(--signal-rgb)/.3);background:radial-gradient(circle at 88% 0,rgb(var(--signal-hi-rgb)/.12),transparent 52%),linear-gradient(145deg,rgb(var(--signal-rgb)/.18),rgb(var(--signal-mid-rgb)/.075));box-shadow:inset 0 1px 0 rgb(var(--tint-rgb)/.075),0 6px 16px rgb(var(--shade-rgb)/.12);transition:border-color .16s,background .16s,box-shadow .16s,color .16s} +body.spectrumApp .tbtn:hover:not(:disabled){border-color:var(--spectrum-edge-hover);background:radial-gradient(circle at 88% 0,rgb(var(--signal-hi-rgb)/.17),transparent 52%),linear-gradient(145deg,rgb(var(--signal-rgb)/.25),rgb(var(--signal-mid-rgb)/.11));box-shadow:inset 0 1px 0 rgb(var(--tint-rgb)/.1),0 8px 20px rgb(var(--signal-rgb)/.12)} +/* After the spectrum button rules: the turn-on button must win their signal fill (see DESIGN.md Buttons). */ +:is(body,body.spectrumApp) .tbtn.go{padding:10px 18px;border-color:rgb(var(--green-rgb)/.7);background:var(--good);box-shadow:none;color:var(--on-accent-deep);font-size:var(--fs-14);font-weight:var(--fw-heavy)} +:is(body,body.spectrumApp) .tbtn.go:hover:not(:disabled){border-color:var(--good);background:rgb(var(--green-rgb)/.86);box-shadow:none} +:is(body,body.spectrumApp) .tbtn.go:focus-visible{outline:2px solid var(--good);outline-offset:2px} +body.spectrumApp :is(input:not([type=checkbox]):not([type=range]),select,textarea,.modelPicker>summary){border-color:var(--spectrum-border);background:var(--spectrum-control);box-shadow:inset 0 1px 0 rgb(var(--tint-rgb)/.035),0 5px 14px rgb(var(--shade-rgb)/.1)} +body.spectrumApp :is(input:not([type=checkbox]):not([type=range]),select,textarea,.modelPicker>summary):hover{border-color:var(--spectrum-border-strong)} +body.spectrumApp :is(input:not([type=checkbox]):not([type=range]),select,textarea,.modelPicker>summary):focus{outline:none;border-color:var(--signal-hi);box-shadow:0 0 0 3px rgb(var(--signal-rgb)/.16),0 9px 22px rgb(var(--signal-rgb)/.12)} +body.spectrumApp :is(button,input,select,textarea,summary):focus-visible{outline:2px solid var(--signal-hi);outline-offset:2px} +body.spectrumApp .meter{background:none;color:var(--signal-hi);-webkit-background-clip:initial;background-clip:initial} +body.spectrumApp .tbl th,body.spectrumApp .modelTableWrap .tbl th,body.spectrumApp .capTableWrap .tbl th{background:linear-gradient(180deg,rgb(var(--panel-rgb)/.99),rgb(var(--bg-lift-rgb)/.99));box-shadow:inset 0 -1px 0 var(--spectrum-border)} +body.spectrumApp .tbl tr:hover td{background:linear-gradient(90deg,rgb(var(--signal-rgb)/.055),rgb(var(--aura-rgb)/.025))} +.spectrumPageHeadFrame{position:relative;width:100%;margin:0 0 16px} +@property --ring-angle{syntax:"";inherits:false;initial-value:0deg} +@keyframes ringSpin{to{--ring-angle:360deg}} +@keyframes titleShimmer{0%{background-position:0 0}100%{background-position:100% 0}} +body.spectrumApp .spectrumPageHeadFrame:before,body.spectrumApp .spectrumPageHeadFrame:after{content:"";position:absolute;inset:-1px;pointer-events:none;border-radius:var(--radius-md);background:conic-gradient(from var(--ring-angle),var(--ring));animation:ringSpin var(--ring-speed) linear infinite} +body.spectrumApp .spectrumPageHeadFrame:before{z-index:1;padding:1px;-webkit-mask:linear-gradient(var(--black) 0 0) content-box,linear-gradient(var(--black) 0 0);-webkit-mask-composite:xor;mask:linear-gradient(var(--black) 0 0) content-box exclude,linear-gradient(var(--black) 0 0)} +body.spectrumApp .spectrumPageHeadFrame:after{z-index:-1;opacity:calc(.32*var(--glow));filter:blur(16px)} +.spectrumPageHead{position:relative;isolation:isolate;display:flex;width:100%;max-width:none;height:138px;align-items:flex-start;justify-content:space-between;gap:24px;flex-direction:row;margin:0;padding:24px 28px;overflow:hidden;border:1px solid var(--spectrum-border-strong);border-radius:var(--radius-md);background:linear-gradient(116deg,rgb(var(--panel-rgb)/.995),rgb(var(--bg-lift-rgb)/.995) 52%,rgb(var(--bg-lift-rgb)/.99));box-shadow:var(--spectrum-depth-focus)} +.spectrumPageHead:before{content:none} +body.spectrumApp .spectrumPageHead:after{content:"";position:absolute;right:0;bottom:0;left:0;height:1px;background:var(--spectrum-foil);background-size:200% 100%;opacity:.9;box-shadow:0 0 14px rgb(var(--signal-hi-rgb)/calc(.5*var(--glow)));animation:foilDrift 9s linear infinite} +body.spectrumApp .spectrumPageHead h1{background:linear-gradient(100deg,var(--fg) 0%,var(--fg) 30%,var(--signal-hi) 50%,var(--aura) 62%,var(--fg) 80%,var(--fg) 100%);background-size:250% 100%;-webkit-background-clip:text;background-clip:text;color:transparent;filter:drop-shadow(var(--title-glow));animation:titleShimmer 11s ease-in-out infinite alternate} +.spectrumPageHeadCopy{position:relative;z-index:3;max-width:min(760px,72%);min-width:0} +.hasPageActions .spectrumPageHeadCopy{max-width:min(560px,48%)} +.spectrumPageHead h1{max-width:22ch;margin:0;color:var(--fg);font-family:var(--font-display);font-size:var(--fs-hero-fluid);font-stretch:88%;font-weight:var(--fw-semibold);line-height:.98;letter-spacing:-.035em;text-wrap:balance} +.spectrumPageSubtitle{max-width:52ch;margin:6px 0 0;color:var(--dim);font-size:var(--fs-12);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.spectrumPageActions{position:absolute;z-index:5;right:30px;bottom:24px;display:flex;max-width:62%;align-items:flex-end;justify-content:flex-end} +.pageSignalField{position:absolute;z-index:1;top:-46%;right:-8%;bottom:-48%;width:76%;overflow:hidden;pointer-events:none;opacity:.88;-webkit-mask-image:linear-gradient(90deg,transparent 0,var(--black) 23%,var(--black) 94%,transparent 100%);mask-image:linear-gradient(90deg,transparent 0,var(--black) 23%,var(--black) 94%,transparent 100%);transition:opacity .35s ease} +.pageDither,.pageSignalFallback{position:absolute;inset:0} +.pageDither{z-index:1;transform:scale(1.02)} +.pageDither canvas{z-index:0!important;opacity:.94;filter:saturate(.92) contrast(1.05)} +.pageSignalFallback{z-index:0;background:radial-gradient(circle,rgb(var(--fx-front-rgb)/.32) 0 1px,transparent 1.4px) 0 0/7px 7px,linear-gradient(118deg,transparent 16%,rgb(var(--fx-edge-a-rgb)/.14) 56%,rgb(var(--fx-back-rgb)/.16));opacity:.82;transition:opacity .35s ease} +.pageDither[data-page-effect=ready]+.pageSignalFallback{opacity:.08} +.pageHeroBorder{position:absolute;z-index:4;inset:0;overflow:hidden;pointer-events:none;border-radius:inherit;opacity:.88} +.pageHeroBorder canvas{z-index:0!important;opacity:.9} +.workTags{display:inline-flex;flex-wrap:wrap;gap:6px;min-width:0} +.workTag{padding:3px 8px;border:1px solid var(--line2);border-radius:var(--radius-pill);background:rgb(var(--signal-mid-rgb)/.1);color:var(--dim);font-size:var(--fs-11);white-space:nowrap} +.workTag.pushback{border-color:rgb(var(--orange-rgb)/.45);color:var(--warn)} +.workSrOnly{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap} +.workStatus{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:10px;padding:12px 14px} +.workStatusMain{display:flex;align-items:center;gap:12px;min-width:0} +.workStatusText{display:grid;gap:2px;min-width:0} +.workStatusText strong{font-size:var(--fs-13)} +.currentSessionHints{display:grid;gap:6px;margin-top:14px} +.currentSessionHint{display:-webkit-box;min-width:0;overflow:hidden;padding:6px 10px 6px 10px;border-left:2px solid var(--warn);border-radius:0 var(--radius-sm) var(--radius-sm) 0;background:rgb(var(--orange-rgb)/.07);color:var(--dim);font-size:var(--fs-12);line-height:1.45;-webkit-box-orient:vertical;-webkit-line-clamp:2} +.currentSessionHint b{margin-right:6px;color:var(--fg);font-size:var(--fs-12);font-weight:var(--fw-bold)} +.currentSessionHint small{font-size:inherit} +.currentSessionHint[data-kind=premium_routine],.currentSessionHint[data-kind=effort_routine],.currentSessionHint[data-kind=long_thread]{border-left-color:var(--signal);background:rgb(var(--signal-rgb)/.07)} +.workEnableCta{display:flex;align-items:center;gap:12px;margin:12px 0} +.workEnableCta[hidden]{display:none} +.workSetupSteps{display:flex;flex-wrap:wrap;gap:6px 16px;margin:8px 0 0;padding:0;list-style:none;color:var(--faint);font-size:var(--fs-12)} +.workSetupSteps[hidden]{display:none} +.workSetupSteps li::before{content:"○";margin-right:6px} +.workSetupSteps li[data-state=done]{color:var(--dim)} +.workSetupSteps li[data-state=done]::before{content:"✓";color:var(--good)} +.workSetupSteps li[data-state=current]{color:var(--fg);font-weight:var(--fw-bold)} +.workSetupSteps li[data-state=current]::before{content:"●";color:var(--accent)} +.workSetupSteps li[data-state=failed]{color:var(--warn);font-weight:var(--fw-bold)} +.workSetupSteps li[data-state=failed]::before{content:"!";color:var(--warn)} +.workSetupBar{width:min(100%,420px);height:6px;margin-top:8px;border-radius:var(--radius-pill);background:rgb(var(--tint-rgb)/.08);overflow:hidden} +.workSetupBar[hidden]{display:none} +.workSetupBar i{display:block;height:100%;width:0;border-radius:inherit;background:var(--accent);transition:width .4s} +.workEnableCta small{color:var(--dim);font-size:var(--fs-12)} +.workStatusText span{color:var(--dim);font-size:var(--fs-11)} +.workStatusActions{display:flex;align-items:center;gap:8px;flex:0 0 auto} +.workStatusActions .filterSelect{min-width:170px} +.workStatusPill{flex:0 0 auto;padding:4px 10px;border:1px solid var(--line2);border-radius:var(--radius-pill);color:var(--dim);font-size:var(--fs-11);font-weight:var(--fw-heavy);letter-spacing:.05em;text-transform:uppercase} +.workStatusPill::before{content:"";display:inline-block;width:7px;height:7px;margin-right:6px;border-radius:var(--radius-round);background:currentColor;vertical-align:1px} +.workStatusPill[data-state=running]{color:var(--good);border-color:rgb(var(--green-rgb)/.45)} +.workStatusPill[data-state=idle]{color:var(--signal-hi);border-color:rgb(var(--signal-rgb)/.4)} +.workStatusPill:is([data-state=paused],[data-state=throttled],[data-state=backoff]){color:var(--warn);border-color:rgb(var(--orange-rgb)/.45)} +.workStatusPill:is([data-state=setup_needed],[data-state=storage_error]){color:var(--bad);border-color:rgb(var(--red-rgb)/.45)} +.workCardHead{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:12px} +.workCardHead h2{margin:0;font-size:var(--fs-14)} +.workCardHead .filterControl{min-width:160px} +.workCardHead .filterControl>span{font-size:var(--fs-11);color:var(--faint);font-weight:var(--fw-heavy);text-transform:uppercase} +.workRework,.workSizing{position:relative;padding:16px 16px} +.workMixSplit{margin-top:16px} +.workSuggestion{display:grid;gap:2px;text-align:left} +.workSuggestion b{font-weight:var(--fw-semibold)} +.workSuggestion small{color:var(--faint);font-size:var(--fs-11);line-height:1.4;white-space:normal} +.workSizing{margin-top:10px} +.workSwatch{display:inline-block;flex:0 0 auto;width:10px;height:10px;border-radius:var(--radius-xs);background:var(--swatch)} +.workTableToggle{margin-top:12px;color:var(--dim);font-size:var(--fs-11)} +.workTableToggle summary{cursor:pointer} +.workTableWrap{overflow:auto;max-height:460px} +.workTableWrap .tbl th{position:sticky;top:0;z-index:1;background:var(--panel);white-space:nowrap} +.workTableWrap .tbl td{vertical-align:middle} +.workMuted{color:var(--faint)} +.workProgressNote{margin:0} +.workProgressNote strong[hidden]{display:none} +.workEmpty{padding:18px 4px;color:var(--faint);font-size:var(--fs-12);text-align:center} +.workSizingGrid{display:grid;grid-template-columns:110px repeat(3,minmax(0,1fr));gap:6px} +.workSizingHead{color:var(--faint);font-size:var(--fs-11);font-weight:var(--fw-heavy);letter-spacing:.05em;text-transform:uppercase;align-self:end;padding:0 4px 2px} +.workSizingCell{min-height:78px;padding:10px 10px;border:1px solid var(--line);border-radius:var(--radius-md);background:rgb(var(--tint-rgb)/.025)} +.workSizingCell strong{display:block;font-size:var(--fs-16)} +.workSizingCell span{display:block;color:var(--dim);font-size:var(--fs-11);margin-top:2px} +.workSizingCell[data-flag]:not([data-flag=""]){border-color:rgb(var(--orange-rgb)/.5);background:rgb(var(--orange-rgb)/.07)} +.workFlag{display:inline-flex;align-items:center;gap:4px;margin-top:6px;color:var(--warn);font-size:var(--fs-11);font-weight:var(--fw-bold)} +.workSizingRowHead{display:flex;align-items:center;padding:0 4px;color:var(--dim);font-size:var(--fs-11);font-weight:var(--fw-bold)} +@media(max-width:1180px){.workStatus{flex-wrap:wrap}} +.workSettingsGrid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px} +.workSettingsGrid input,.workAreaRow input{width:100%;min-width:0;padding:8px 10px;border:1px solid var(--line2);border-radius:var(--radius-md);background:rgb(var(--tint-rgb)/.035);color:var(--fg);font:inherit;font-size:var(--fs-12)} +.workSettingsGrid .settingsField>span{font-size:var(--fs-11);color:var(--faint);font-weight:var(--fw-heavy);text-transform:uppercase} +.workSetupHint{margin-top:10px;color:var(--dim);font-size:var(--fs-11)} +.workSetupHint code{padding:2px 6px;border-radius:var(--radius-sm);background:rgb(var(--tint-rgb)/.06);color:var(--fg)} +.workSettingsActions{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:12px;margin-top:12px} +.workActionGroup{display:flex;flex-wrap:wrap;align-items:center;gap:8px} +.workSettingsActions .settingsStatus{width:auto;min-width:0;text-align:right} +.workSettingsActions .tbtn,.workSettingsManage .tbtn,.workSettingsManage .filterSelect{height:32px;padding-top:0;padding-bottom:0} +.workSettingsActions .tbtn:disabled{opacity:.45;cursor:not-allowed} +.workSettingsManage{margin-top:16px;padding-top:12px;border-top:1px solid var(--line)} +.workAreasHead{display:flex;align-items:baseline;gap:10px;margin-top:16px} +.workAreasHead h3{margin:0;font-size:var(--fs-13)} +.workAreas{display:grid;gap:6px;margin-top:8px} +.workAreaRow{display:grid;grid-template-columns:14px minmax(140px,220px) minmax(0,1fr) auto;align-items:center;gap:8px} +.workAreaRow .tbtn{padding:6px 8px} +@media(max-width:1180px){.workSettingsGrid{grid-template-columns:repeat(2,minmax(0,1fr))}} +.workFit{position:relative;padding:16px 16px;margin-top:10px} +.workOutcomeBar{display:flex;gap:2px;height:14px;margin-bottom:12px;border-radius:var(--radius-xs);overflow:hidden;background:rgb(var(--tint-rgb)/.03)} +.workOutcomeBar i{display:block;height:100%;min-width:2px;background:var(--swatch)} +.workFit .workTableWrap,.workBreakdown .workTableWrap{max-height:none} +.workFitBest{display:inline-block;margin-left:6px;padding:1px 6px;border:1px solid rgb(var(--green-rgb)/.5);border-radius:var(--radius-pill);color:var(--good);font-size:var(--fs-11);font-weight:var(--fw-heavy);text-transform:uppercase;letter-spacing:.04em} +.workFitCell small{display:block;color:var(--faint);font-size:var(--fs-11)} +[data-drill]{cursor:pointer} +tr[data-drill]:hover td,td:has(>.workDrillBtn):hover,.workSizingCell:has(>.workDrillBtn):hover{background:rgb(var(--tint-rgb)/.035)} +.workDrillBtn{display:block;width:100%;height:100%;margin:0;padding:0;border:0;border-radius:var(--radius-xs);background:none;color:inherit;font:inherit;text-align:inherit;cursor:pointer} +.workDrillBtn:focus-visible{outline:2px solid var(--accent);outline-offset:2px} +.workSizingCell:has(>.workDrillBtn){padding:0} +.workSizingBtn{min-height:78px;padding:10px 10px;border-radius:var(--radius-md)} +.workSizingBtn:focus-visible{outline-offset:-2px} + +.workAxisText{fill:var(--faint);font:var(--fs-11) var(--font-sans)} +.workTrendWrap{flex:1;display:flex;align-items:center;margin:2px 0 4px} +.workTrend{display:block;width:100%;height:auto;overflow:visible} +.workGridLine{stroke:rgb(var(--tint-rgb)/.07)} +.workLine{fill:none;stroke:var(--signal);stroke-width:2;stroke-linejoin:round;stroke-linecap:round} +.workPoint{fill:var(--signal);stroke:var(--panel);stroke-width:2} +.workPoint.few{fill:var(--panel);stroke:var(--signal)} +.workTrendAvg{stroke:var(--aura);stroke-width:1;stroke-dasharray:4 4;opacity:.8} +.workTrendAvgText{fill:var(--aura);font:var(--fw-semibold) var(--fs-11) var(--font-sans);paint-order:stroke;stroke:var(--panel);stroke-width:4px;stroke-linejoin:round} +.workSplit{display:grid;grid-template-columns:minmax(0,1.2fr) minmax(0,1fr);gap:24px;align-items:start} +.workSplit>div{min-width:0} +.workSubhead{display:flex;align-items:baseline;justify-content:space-between;gap:10px;margin:0 0 8px} +.workSubhead h3{margin:0;color:var(--dim);font-size:var(--fs-11);font-weight:var(--fw-heavy);letter-spacing:.06em;text-transform:uppercase} +.workSubhead span{color:var(--faint);font-size:var(--fs-11)} +.workScore{width:100%;border-collapse:collapse;font-size:var(--fs-12)} +.workScore th{padding:0 8px 6px;color:var(--faint);font-size:var(--fs-11);font-weight:var(--fw-heavy);letter-spacing:.05em;text-transform:uppercase;text-align:right;white-space:nowrap} +.workScore th:first-child{text-align:left;padding-left:0} +.workScore td{padding:0;border-top:1px solid var(--line);text-align:right;white-space:nowrap} +.workScore td:first-child{text-align:left} +.workScore tr:hover td{background:rgb(var(--tint-rgb)/.03)} +.workScore .workDrillBtn{display:flex;align-items:center;gap:8px;padding:8px 8px 8px 0} +.workScore td>span{display:block;padding:8px} +.workLowest{margin-left:6px;padding:1px 6px;border:1px solid rgb(var(--green-rgb)/.45);border-radius:var(--radius-pill);color:var(--good);font:var(--fw-heavy) var(--fs-11) var(--font-sans);font-style:normal;letter-spacing:.04em;text-transform:uppercase;vertical-align:1px} +.workScoreDot{flex:0 0 auto;width:8px;height:8px;border-radius:var(--radius-round);background:var(--swatch)} +.workScoreName{display:grid;min-width:0;text-align:left} +.workScoreName b{overflow:hidden;text-overflow:ellipsis;font-weight:var(--fw-bold)} +.workScoreName small{color:var(--faint);font-size:var(--fs-11)} +.workScoreKinds{display:flex;flex-wrap:wrap;align-items:center;justify-content:flex-end;gap:4px;padding:4px 8px;white-space:normal} +.workScoreKinds .chip{white-space:nowrap} +.workScoreKinds small{color:var(--faint);font-size:var(--fs-11)} +.workMeter{display:inline-grid;grid-template-columns:64px 34px;align-items:center;gap:6px} +.workMeter i{display:block;height:6px;border-radius:var(--radius-xs);background:rgb(var(--tint-rgb)/.06);overflow:hidden} +.workMeter i b{display:block;height:100%;border-radius:var(--radius-xs);background:var(--meter,var(--signal))} +.workMeter[data-few=true]{opacity:.5} +.workMix{display:grid;gap:10px} +.workMixRow{display:grid;grid-template-columns:96px minmax(0,1fr) 78px;align-items:center;gap:10px} +.workMixLabel{color:var(--dim);font-size:var(--fs-12);font-weight:var(--fw-bold)} +.workMixTotal{text-align:right;font:var(--fw-semibold) var(--fs-12) var(--font-sans);color:var(--fg)} +.workStack{display:flex;gap:2px;height:22px;border-radius:var(--radius-sm);overflow:hidden;background:rgb(var(--tint-rgb)/.03)} +.workStack button{position:relative;min-width:3px;height:100%;padding:0;border:0;background:var(--seg);cursor:pointer} +.workStack button:hover,.workStack button:focus-visible{filter:brightness(1.25);outline:none} +.workStack button[data-flag=true]{background:repeating-linear-gradient(135deg,var(--seg) 0 6px,rgb(var(--bg-rgb)/.5) 6px 9px);box-shadow:inset 0 0 0 1.5px var(--warn)} +.workStack button[data-flag=true] span{inset:auto;left:50%;top:50%;padding:1px 6px;border-radius:var(--radius-xs);background:rgb(var(--bg-rgb)/.86);color:var(--fg);transform:translate(-50%,-50%)} +.workStack button span{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:var(--on-accent-deep);font:var(--fw-bold) var(--fs-11) var(--font-sans);pointer-events:none} +.workMixLegend{display:flex;flex-wrap:wrap;gap:4px 14px;margin-top:2px;color:var(--faint);font-size:var(--fs-11)} +.workMixLegend span{display:inline-flex;align-items:center;gap:6px} +.workMixLegend i{display:inline-block;width:10px;height:10px;border-radius:var(--radius-xs);background:var(--swatch)} +.workMixLegend i.flag{background:repeating-linear-gradient(135deg,var(--w-tier-premium) 0 4px,rgb(var(--bg-rgb)/.55) 4px 6px);box-shadow:inset 0 0 0 1px var(--warn)} +.workMixLegend small{color:var(--faint);font-size:var(--fs-11);margin-left:2px} +.workMixLegend i.flag.effort{background:repeating-linear-gradient(135deg,var(--w-tier-premium) 0 4px,rgb(var(--bg-rgb)/.55) 4px 6px)} +.workSuggestCards{display:grid;grid-template-columns:1.3fr 1fr 1fr;gap:14px;margin:16px 0 12px} +.workEffortModels{margin:4px 0 14px;padding-top:12px;border-top:1px solid var(--line)} +.workEffortModels .workSubhead{align-items:center;margin-bottom:10px} +.workEffortGrid{display:grid;gap:6px;overflow-x:auto} +.workEffortHead{display:grid;gap:2px} +.workEffortHead small{color:var(--faint);font-size:var(--fs-11);font-weight:var(--fw-medium);letter-spacing:0;text-transform:none} +.workEffortCell{display:flex;align-items:center;justify-content:center;min-width:0;min-height:40px;border:1px solid var(--line);border-radius:var(--radius-md);background:rgb(var(--red-rgb)/var(--heat,0));font-size:var(--fs-16);font-weight:var(--fw-heavy);font-variant-numeric:tabular-nums} +.workEffortCell[data-few],.workEffortCell[data-empty]{border-color:transparent;background:rgb(var(--tint-rgb)/.025);color:var(--faint);font-size:var(--fs-12);font-weight:var(--fw-medium)} +.workEffortVerdicts{display:grid;gap:6px;margin:10px 0 0;padding:0;list-style:none;font-size:var(--fs-12)} +.workEffortVerdicts li{padding:6px 10px;border-left:2px solid var(--line2);border-radius:0 var(--radius-sm) var(--radius-sm) 0;color:var(--dim)} +.workEffortVerdicts li b{color:var(--fg)} +.workEffortVerdicts li[data-kind=lower]{border-left-color:var(--good);background:rgb(var(--green-rgb)/.06)} +.workEffortVerdicts li[data-kind=raise]{border-left-color:var(--warn);background:rgb(var(--orange-rgb)/.06)} +.workEffortNote{margin:8px 0 0;color:var(--faint);font-size:var(--fs-11)} +.workEffortDetail{min-height:18px;margin:8px 0 0;color:var(--dim);font-size:var(--fs-12);font-variant-numeric:tabular-nums} +.workEffortCell[tabindex]:focus-visible{outline:2px solid var(--accent);outline-offset:2px} +.workSuggestCard{display:flex;flex-direction:column;min-width:0;min-height:236px;padding:18px 18px 14px;border:1px solid var(--line);border-radius:var(--radius-lg);background:rgb(var(--tint-rgb)/.022)} +.workSuggestEyebrow{display:flex;align-items:center;gap:8px;color:var(--faint);font-size:var(--fs-11);font-weight:var(--fw-heavy);letter-spacing:.06em;text-transform:uppercase} +.workSuggestEyebrow i{width:8px;height:8px;border-radius:var(--radius-2xs);display:inline-block} +.workSuggestHero{margin-top:12px;font:var(--fw-heavy) var(--fs-44)/1 var(--font-sans);letter-spacing:-.02em} +.workSuggestHero small{display:block;margin-top:6px;color:var(--dim);font-size:var(--fs-13);font-weight:var(--fw-semibold);letter-spacing:0} +.workSuggestRows{display:flex;flex-direction:column;gap:8px;margin-top:14px} +.workSuggestRows .workDrillBtn{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:10px;padding:8px 10px;border-radius:var(--radius-md);background:rgb(var(--tint-rgb)/.03)} +.workSuggestRows .workDrillBtn:hover,.workSuggestRows .workDrillBtn:focus-visible{background:rgb(var(--signal-rgb)/.08)} +.workSwap{min-width:0;font-size:var(--fs-13);font-weight:var(--fw-semibold);font-variant-numeric:tabular-nums;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.workSwap b{margin:0 6px;color:var(--faint);font-weight:var(--fw-medium)} +.workSwap em{display:block;margin-top:2px;color:var(--faint);font:var(--fs-11) var(--font-sans);font-style:normal;overflow:hidden;text-overflow:ellipsis} +.workSwapSave{color:var(--good);font-size:var(--fs-13);font-weight:var(--fw-bold);font-variant-numeric:tabular-nums} +.workSuggestFoot{display:flex;justify-content:space-between;gap:10px;margin-top:auto;padding-top:12px;color:var(--faint);font-size:var(--fs-12)} +.workSuggestFoot button{flex:none;padding:0;border:0;background:none;color:var(--accent);font:inherit;white-space:nowrap;cursor:pointer} +.workSuggestBar{display:flex;gap:2px;height:10px;margin-top:16px;border-radius:var(--radius-sm);overflow:hidden} +.workSuggestLegend{display:flex;flex-wrap:wrap;gap:6px 12px;margin-top:8px;color:var(--dim);font-size:var(--fs-11)} +.workSuggestLegend i{display:inline-block;width:8px;height:8px;margin-right:6px;border-radius:var(--radius-2xs)} +.workSuggestSpark{display:block;width:100%;height:54px;margin-top:14px} +.workSuggestChips{display:flex;gap:8px;margin-top:12px} +.workSuggestChips div{flex:1;min-width:0;padding:8px 10px;border-radius:var(--radius-md);background:rgb(var(--tint-rgb)/.03)} +.workSuggestChips span{display:block;color:var(--faint);font-size:var(--fs-11);font-weight:var(--fw-heavy);letter-spacing:.06em;text-transform:uppercase} +.workSuggestChips b{display:block;margin-top:3px;font-size:var(--fs-13);font-weight:var(--fw-semibold);font-variant-numeric:tabular-nums;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.workSuggestChips b s{color:var(--dim);font-weight:var(--fw-medium);text-decoration:none} +.workSuggestEmpty{margin-top:14px;color:var(--faint);font-size:var(--fs-13)} +.workIssues{width:100%;border-collapse:collapse;font-size:var(--fs-12)} +.workIssues th{padding:0 8px 6px;color:var(--faint);font-size:var(--fs-11);font-weight:var(--fw-heavy);letter-spacing:.05em;text-transform:uppercase;text-align:right} +.workIssues th:first-child{text-align:left;padding-left:0} +.workIssues td{border-top:1px solid var(--line);padding:0;text-align:right;white-space:nowrap} +.workIssues td:first-child{text-align:left} +.workIssues td>span{display:block;padding:8px} +.workIssues .workDrillBtn{display:flex;align-items:center;gap:8px;padding:8px 8px 8px 0} +.workIssues tr:hover td{background:rgb(var(--tint-rgb)/.03)} +.workIssueKind{color:var(--warn);font-size:var(--fs-11)} +@media(max-width:1180px){.workSplit{grid-template-columns:1fr}.workSuggestCards{grid-template-columns:1.2fr 1fr 1fr}} +#view-work .mono,#view-work .num{font-family:inherit;font-variant-numeric:tabular-nums} +#view-work em.mono{font-style:normal} +.workTagsCard,.workRhythm,.workBreakdown{padding:16px 16px;margin-top:10px} +.workTrendPair{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin-top:10px;align-items:stretch} +.workTrendPair>.card{display:flex;flex-direction:column;margin:0} +.workBreakdownTable small{color:var(--faint);font-size:var(--fs-11)} +.workCellNote,.workOutcomeCell small{display:block;margin-top:2px} +.workTableWrap .workBreakdownTable th{white-space:normal;vertical-align:bottom} +.workBreakdownTable .efficiencySort{text-align:right} +.workBreakdownTable th:first-child .efficiencySort{text-align:left} +.workBreakdownTable td.num{white-space:nowrap} +.workCellBar{display:block;height:4px;margin-top:4px;border-radius:var(--radius-xs);background:rgb(var(--tint-rgb)/.05);overflow:hidden} +.workCellBar i{display:block;height:100%;border-radius:var(--radius-xs);background:linear-gradient(90deg,var(--signal-mid),var(--signal))} +.workBreakdownTable .workMuted .workCellBar i{background:var(--w-unclear)} +.workBreakdownTable .workOutcomeBar{height:8px;min-width:80px;margin:0} +.workBreakdownTotal td{background:rgb(var(--tint-rgb)/.02)} + +.workTagGrid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px} +.workTagTile{display:grid;align-content:start;gap:4px;min-width:0;padding:12px 14px;border:1px solid var(--line);border-radius:var(--radius-md);background:rgb(var(--tint-rgb)/.02);color:var(--fg);font:inherit;text-align:left;cursor:pointer} +.workTagTile:hover,.workTagTile:focus-visible{border-color:var(--tone);background:rgb(var(--tint-rgb)/.045);outline:none} +.workTagName{display:flex;align-items:center;gap:8px;font-size:var(--fs-13);font-weight:var(--fw-bold)} +.workTagName i{flex:0 0 auto;width:8px;height:8px;border-radius:var(--radius-round);background:var(--tone)} +.workTagTile strong{font-size:var(--fs-18);font-weight:var(--fw-bold)} +.workTagStats{color:var(--dim);font-size:var(--fs-12)} +.workTagTile small{color:var(--faint);font-size:var(--fs-11);line-height:1.4} +.workRhythm .workSplit{grid-template-columns:minmax(0,1.6fr) minmax(0,1fr);gap:32px} +.workHeat{display:grid;grid-template-columns:30px repeat(24,minmax(0,1fr));gap:3px;align-items:center} +.workHeat>span{color:var(--dim);font-size:var(--fs-11)} +.workHeat>span.h{grid-column:span 6;color:var(--faint);font-size:var(--fs-11)} +.workHeat>i{display:block;height:16px;border-radius:var(--radius-xs);background:rgb(var(--tint-rgb)/.035)} +.workHeat>i[data-v]{background:color-mix(in oklab,var(--signal) calc(var(--v) * 100%),rgb(var(--tint-rgb)/.035))} +.workBandList{display:grid;gap:2px} +.workBand{display:grid;grid-template-columns:118px minmax(0,1fr) 52px;grid-template-areas:"label bar value" ". note note";align-items:center;column-gap:10px;row-gap:3px;padding:8px 6px} +.workBand>span:first-child{grid-area:label;font-size:var(--fs-13)} +.workBand>span small{display:block;color:var(--faint);font-size:var(--fs-11)} +.workBand .workBarTrack{grid-area:bar;height:10px;border-radius:var(--radius-xs);background:rgb(var(--tint-rgb)/.04);overflow:hidden} +.workBand .workBarTrack i{display:block;height:100%;border-radius:var(--radius-xs);background:var(--signal)} +.workBand b{grid-area:value;text-align:right;font-size:var(--fs-12)} +.workBand>small{grid-area:note;color:var(--faint);font-size:var(--fs-11)} +.workBand[data-few=true] .workBarTrack i{opacity:.45} +@media(max-width:1180px){.workRhythm .workSplit{grid-template-columns:1fr}} +.workFootnote{margin-top:10px;color:var(--faint);font-size:var(--fs-11)} +body.spectrumApp .settingsMap{grid-template-columns:repeat(6,minmax(0,1fr));background:var(--spectrum-card)} +body.spectrumApp .settingsMap button{grid-template-columns:1fr;border-left:1px solid var(--line);border-radius:0} +body.spectrumApp :is(.sessionStart,.toast,.capReview,.agentDiscovery,.previewStartStrip,.modelScopeStatus){border-left-width:1px} +@media(max-width:900px){.spectrumPageHead{height:126px;align-items:flex-start;padding:20px 24px}.spectrumPageHeadCopy,.hasPageActions .spectrumPageHeadCopy{max-width:72%}.spectrumPageActions{position:static;display:flex;width:100%;max-width:none;min-height:46px;align-items:center;justify-content:flex-start;margin-top:8px;padding:6px 8px;border:1px solid var(--spectrum-border);border-radius:var(--radius-md);background:var(--spectrum-control);box-shadow:var(--shadow-soft)}.spectrumPageActions>*{width:100%}.pageSignalField{right:-18%;width:88%}body.spectrumApp .settingsMap{grid-template-columns:repeat(2,minmax(0,1fr))}} +@media(max-width:520px){.spectrumPageHead{height:116px;padding:18px 18px}.spectrumPageHeadCopy,.hasPageActions .spectrumPageHeadCopy{max-width:62%}.spectrumPageHead h1{font-size:var(--fs-28);line-height:1.02}.spectrumPageSubtitle{max-width:34ch;font-size:var(--fs-11)}.spectrumPageActions{padding:6px}.pageSignalField{top:-36%;right:-43%;bottom:-42%;width:112%;opacity:.78}body.spectrumApp .settingsMap{grid-template-columns:1fr}} +@media(prefers-reduced-motion:reduce){.pageSignalField{transition:none}body.spectrumApp .tbtn{transition:none}body.spectrumApp:after,body.spectrumApp .top:before,body.spectrumApp .spectrumPageHead:after,body.spectrumApp .top .tab.on:after,.themeOption,body.spectrumApp .spectrumPageHeadFrame:before,body.spectrumApp .spectrumPageHeadFrame:after,body.spectrumApp .spectrumPageHead h1{animation:none;transition:none}} diff --git a/dashboard/styles/themes.css b/dashboard/styles/themes.css new file mode 100644 index 00000000..2eb25b61 --- /dev/null +++ b/dashboard/styles/themes.css @@ -0,0 +1,789 @@ +/* == Themes: each block declares the same tokens; html[data-theme] selects one == */ +:root,[data-theme=classic]{ + --signal:#00bceb; --signal-rgb:0 188 235; --signal-mid:#1ba0e1; --signal-mid-rgb:27 160 225; + --signal-hi:#7fdbf2; --signal-hi-rgb:127 219 242; --signal-pale:#dff8ff; --signal-pale-rgb:223 248 255; --signal-deep-rgb:14 54 67; + --aura:#c7a7ff; --aura-rgb:199 167 255; --aura-pale:#d4c0ff; --aura-deep:#9d72f1; --aura-glow-rgb:183 140 255; + --flare:#6d7bff; --flare-rgb:109 123 255; + --bg-deep:#05070a; --bg-deep-rgb:5 7 10; --bg:#07090c; --bg-rgb:7 9 12; --bg-lift:#071824; --bg-lift-rgb:7 24 36; + --bg2:#0d1117; --bg2-rgb:13 17 23; --panel:#111820; --panel-rgb:17 24 32; --panel2:#17212b; --panel2-rgb:23 33 43; --panel3:#202b36; + --line-rgb:177 228 241; + --fg:#f6f8fb; --soft:#cbd9e4; --soft-rgb:203 217 228; --dim:#a8b3c1; --faint:#8693a6; + --on-accent:#062430; --on-accent-deep:#061018; + --reason:#4ec6ee; --output:#7fdbf2; --retrieval:#19b3e6; --toolcall:#0aa4d4; + --c-input:#00bceb; --c-output:#64d8ff; --c-cost:#c7a7ff; --c-cost-rgb:199 167 255; + --chart-axis:#636d7b; --chart-label:#a8b3c1; + --glow:.8; + --page-texture:linear-gradient(rgb(var(--tint-rgb)/.03) 1px,transparent 1px),linear-gradient(90deg,rgb(var(--tint-rgb)/.022) 1px,transparent 1px); + --page-texture-size:44px 44px; + --radius-2xs:2px; --radius-xs:4px; --radius-sm:6px; --radius-md:8px; --radius-lg:12px; + --ring:var(--signal),rgb(var(--signal-rgb)/.4) 25%,var(--signal-hi) 50%,rgb(var(--signal-rgb)/.4) 75%,var(--signal); + --ring-speed:60s; + --title-glow:0 0 18px rgb(var(--signal-rgb)/.28); + --grain-opacity:.05; + --fx-front-rgb:53 202 238; --fx-back-rgb:103 74 180; --fx-edge-a-rgb:0 188 235; --fx-edge-b-rgb:77 139 214; +} +[data-theme=nebula]{ + --signal:#9d8bff; --signal-rgb:157 139 255; --signal-mid:#c77dff; --signal-mid-rgb:199 125 255; + --signal-hi:#7ef3ff; --signal-hi-rgb:126 243 255; --signal-pale:#e6e0ff; --signal-pale-rgb:230 224 255; --signal-deep-rgb:38 26 92; + --aura:#ff7ad9; --aura-rgb:255 122 217; --aura-pale:#ffc2ee; --aura-deep:#d24fb0; --aura-glow-rgb:214 92 255; + --flare:#64ffda; --flare-rgb:100 255 218; + --bg-deep:#040310; --bg-deep-rgb:4 3 16; --bg:#07061a; --bg-rgb:7 6 26; --bg-lift:#110c2e; --bg-lift-rgb:17 12 46; + --bg2:#0c0a22; --bg2-rgb:12 10 34; --panel:#13102c; --panel-rgb:19 16 44; --panel2:#1b1739; --panel2-rgb:27 23 57; --panel3:#252046; + --line-rgb:190 180 255; + --fg:#f4f2ff; --soft:#d3cdf2; --soft-rgb:211 205 242; --dim:#afa9d4; --faint:#9089bd; + --on-accent:#0d0726; --on-accent-deep:#07031a; + --reason:#8fd8ff; --output:#c9bcff; --retrieval:#6fb6ff; --toolcall:#8a7dff; + --c-input:#7ef3ff; --c-output:#b6a6ff; --c-cost:#ff8ad8; --c-cost-rgb:255 138 216; + --chart-axis:#6c6890; --chart-label:#afa9d4; + --glow:1; + --page-texture:radial-gradient(circle at 20% 30%,rgb(var(--tint-rgb)/.07) 0 1px,transparent 1.6px),radial-gradient(circle at 70% 80%,rgb(var(--signal-hi-rgb)/.06) 0 1px,transparent 1.6px); + --page-texture-size:97px 89px,61px 53px; + --radius-2xs:2px; --radius-xs:4px; --radius-sm:6px; --radius-md:10px; --radius-lg:14px; + --ring:var(--signal),rgb(var(--signal-rgb)/.4) 25%,var(--signal-hi) 50%,rgb(var(--signal-rgb)/.4) 75%,var(--signal); + --ring-speed:48s; + --title-glow:0 0 22px rgb(var(--signal-mid-rgb)/.42); + --grain-opacity:.06; + --fx-front-rgb:255 122 217; --fx-back-rgb:157 139 255; --fx-edge-a-rgb:199 125 255; --fx-edge-b-rgb:255 122 217; +} +[data-theme=monolith]{ + --signal:#e4e7ee; --signal-rgb:228 231 238; --signal-mid:#a7aebd; --signal-mid-rgb:167 174 189; + --signal-hi:#ffffff; --signal-hi-rgb:255 255 255; --signal-pale:#f4f5f8; --signal-pale-rgb:244 245 248; --signal-deep-rgb:44 46 54; + --aura:#a9b8ff; --aura-rgb:169 184 255; --aura-pale:#d3dbff; --aura-deep:#7d8de0; --aura-glow-rgb:150 165 240; + --flare:#ff6a3d; --flare-rgb:255 106 61; + --bg-deep:#050506; --bg-deep-rgb:5 5 6; --bg:#09090a; --bg-rgb:9 9 10; --bg-lift:#121214; --bg-lift-rgb:18 18 20; + --bg2:#0f0f11; --bg2-rgb:15 15 17; --panel:#151517; --panel-rgb:21 21 23; --panel2:#1c1c1f; --panel2-rgb:28 28 31; --panel3:#26262a; + --line-rgb:232 234 240; + --fg:#f5f5f7; --soft:#d5d6db; --soft-rgb:213 214 219; --dim:#abadb5; --faint:#909299; + --on-accent:#0a0a0c; --on-accent-deep:#050506; + --reason:#c3cad8; --output:#a9b8ff; --retrieval:#dfe3ec; --toolcall:#8e95a6; + --c-input:#e4e7ee; --c-output:#a9b8ff; --c-cost:#c7a7ff; --c-cost-rgb:199 167 255; + --chart-axis:#5f6068; --chart-label:#abadb5; + --glow:.3; + --page-texture:radial-gradient(circle,rgb(var(--tint-rgb)/.055) 0 .8px,transparent 1.2px); + --page-texture-size:6px 6px; + --radius-2xs:1px; --radius-xs:2px; --radius-sm:3px; --radius-md:4px; --radius-lg:6px; + --ring:var(--signal),rgb(var(--signal-rgb)/.4) 25%,var(--signal-hi) 50%,rgb(var(--signal-rgb)/.4) 75%,var(--signal); + --ring-speed:24s; + --title-glow:0 0 0 transparent; + --grain-opacity:.07; + --fx-front-rgb:244 245 248; --fx-back-rgb:169 184 255; --fx-edge-a-rgb:228 231 238; --fx-edge-b-rgb:167 174 189; +} +[data-theme=voltage]{ + --signal:#f5e63d; --signal-rgb:245 230 61; --signal-mid:#ffd23f; --signal-mid-rgb:255 210 63; + --signal-hi:#fff27d; --signal-hi-rgb:255 242 125; --signal-pale:#fffbd6; --signal-pale-rgb:255 251 214; --signal-deep-rgb:64 58 8; + --aura:#2ee6ff; --aura-rgb:46 230 255; --aura-pale:#a8f5ff; --aura-deep:#12b5cc; --aura-glow-rgb:46 200 255; + --flare:#ff3df2; --flare-rgb:255 61 242; + --bg-deep:#050504; --bg-deep-rgb:5 5 4; --bg:#0a0907; --bg-rgb:10 9 7; --bg-lift:#15130b; --bg-lift-rgb:21 19 11; + --bg2:#100f0b; --bg2-rgb:16 15 11; --panel:#16150f; --panel-rgb:22 21 15; --panel2:#1e1c14; --panel2-rgb:30 28 20; --panel3:#28261b; + --line-rgb:255 244 150; + --fg:#f8f6ea; --soft:#ddd9c4; --soft-rgb:221 217 196; --dim:#b6b19a; --faint:#99947d; + --on-accent:#141200; --on-accent-deep:#0a0900; + --reason:#fff27d; --output:#2ee6ff; --retrieval:#ffe68a; --toolcall:#c8bb2a; + --c-input:#f5e63d; --c-output:#2ee6ff; --c-cost:#ff6fd8; --c-cost-rgb:255 111 216; + --chart-axis:#6c6956; --chart-label:#b6b19a; + --glow:.85; + --page-texture:repeating-linear-gradient(0deg,rgb(var(--tint-rgb)/.026) 0 1px,transparent 1px 3px); + --page-texture-size:auto; + --radius-2xs:0; --radius-xs:1px; --radius-sm:2px; --radius-md:2px; --radius-lg:3px; + --ring:var(--signal),rgb(var(--signal-rgb)/.4) 25%,var(--signal-hi) 50%,rgb(var(--signal-rgb)/.4) 75%,var(--signal); + --ring-speed:18s; + --title-glow:0 0 20px rgb(var(--signal-rgb)/.5); + --grain-opacity:.05; + --fx-front-rgb:245 230 61; --fx-back-rgb:46 230 255; --fx-edge-a-rgb:245 230 61; --fx-edge-b-rgb:46 230 255; +} +[data-theme=phosphor]{ + --signal:#3dfc8f; --signal-rgb:61 252 143; --signal-mid:#19d97a; --signal-mid-rgb:25 217 122; + --signal-hi:#b8ffd2; --signal-hi-rgb:184 255 210; --signal-pale:#e3fff0; --signal-pale-rgb:227 255 240; --signal-deep-rgb:6 60 32; + --aura:#c6ff4d; --aura-rgb:198 255 77; --aura-pale:#e6ffb0; --aura-deep:#8fd11f; --aura-glow-rgb:120 255 160; + --flare:#4dd2ff; --flare-rgb:77 210 255; + --bg-deep:#020604; --bg-deep-rgb:2 6 4; --bg:#040a06; --bg-rgb:4 10 6; --bg-lift:#08160e; --bg-lift-rgb:8 22 14; + --bg2:#07100a; --bg2-rgb:7 16 10; --panel:#0b170f; --panel-rgb:11 23 15; --panel2:#102016; --panel2-rgb:16 32 22; --panel3:#172b1e; + --line-rgb:140 255 190; + --fg:#effff4; --soft:#c9e8d4; --soft-rgb:201 232 212; --dim:#a2c4ae; --faint:#86aa93; + --on-accent:#021a0c; --on-accent-deep:#010d06; + --reason:#b8ffd2; --output:#c6ff4d; --retrieval:#7dffb6; --toolcall:#19d97a; + --c-input:#3dfc8f; --c-output:#c6ff4d; --c-cost:#c7a7ff; --c-cost-rgb:199 167 255; + --chart-axis:#4f6b59; --chart-label:#a2c4ae; + --glow:.9; + --page-texture:repeating-linear-gradient(90deg,rgb(var(--signal-rgb)/.026) 0 1px,transparent 1px 14px); + --page-texture-size:auto; + --radius-2xs:1px; --radius-xs:2px; --radius-sm:4px; --radius-md:4px; --radius-lg:6px; + --ring:var(--signal),rgb(var(--signal-rgb)/.4) 25%,var(--signal-hi) 50%,rgb(var(--signal-rgb)/.4) 75%,var(--signal); + --ring-speed:30s; + --title-glow:0 0 22px rgb(var(--signal-rgb)/.45); + --grain-opacity:.06; + --fx-front-rgb:61 252 143; --fx-back-rgb:25 120 70; --fx-edge-a-rgb:61 252 143; --fx-edge-b-rgb:198 255 77; +} +[data-theme=omarchy]{ + --signal:#7aa2f7; --signal-rgb:122 162 247; --signal-mid:#7dcfff; --signal-mid-rgb:125 207 255; + --signal-hi:#bb9af7; --signal-hi-rgb:187 154 247; --signal-pale:#d6dbff; --signal-pale-rgb:214 219 255; --signal-deep-rgb:30 44 92; + --aura:#ff9e64; --aura-rgb:255 158 100; --aura-pale:#ffc9a3; --aura-deep:#d96f3f; --aura-glow-rgb:255 158 100; + --flare:#f7768e; --flare-rgb:247 118 142; + --bg-deep:#0d0e14; --bg-deep-rgb:13 14 20; --bg:#14151d; --bg-rgb:20 21 29; --bg-lift:#1a1b26; --bg-lift-rgb:26 27 38; + --bg2:#1a1b26; --bg2-rgb:26 27 38; --panel:#1b1e2b; --panel-rgb:27 30 43; --panel2:#202334; --panel2-rgb:32 35 52; --panel3:#24283b; + --line-rgb:187 154 247; + --fg:#c0caf5; --soft:#b4bee6; --soft-rgb:180 190 230; --dim:#a9b1d6; --faint:#9099c0; + --on-accent:#10121c; --on-accent-deep:#07080f; + --reason:#7aa2f7; --output:#7dcfff; --retrieval:#bb9af7; --toolcall:#2ac3de; + --c-input:#7aa2f7; --c-output:#7dcfff; --c-cost:#bb9af7; --c-cost-rgb:187 154 247; + --chart-axis:#565f89; --chart-label:#a9b1d6; + --glow:.9; + --page-texture:linear-gradient(rgb(var(--tint-rgb)/.022) 1px,transparent 1px),linear-gradient(90deg,rgb(var(--tint-rgb)/.018) 1px,transparent 1px); + --page-texture-size:34px 34px; + --radius-2xs:2px; --radius-xs:4px; --radius-sm:8px; --radius-md:10px; --radius-lg:14px; + --ring:var(--signal),rgb(var(--signal-rgb)/.4) 25%,var(--signal-hi) 50%,rgb(var(--signal-rgb)/.4) 75%,var(--signal); + --ring-speed:40s; + --title-glow:0 0 20px rgb(var(--signal-rgb)/.4); + --grain-opacity:.05; + --fx-front-rgb:122 162 247; --fx-back-rgb:187 154 247; --fx-edge-a-rgb:122 162 247; --fx-edge-b-rgb:125 207 255; +} +[data-theme=jadehorse]{ + --signal:#8bcbb1; --signal-rgb:139 203 177; --signal-mid:#86c7c5; --signal-mid-rgb:134 199 197; + --signal-hi:#a7dfc5; --signal-hi-rgb:167 223 197; --signal-pale:#dff3ea; --signal-pale-rgb:223 243 234; --signal-deep-rgb:23 52 43; + --aura:#96b7d0; --aura-rgb:150 183 208; --aura-pale:#c9dcea; --aura-deep:#6b93b3; --aura-glow-rgb:150 183 208; + --flare:#c5cc91; --flare-rgb:197 204 145; + --bg-deep:#08120f; --bg-deep-rgb:8 18 15; --bg:#0c1a16; --bg-rgb:12 26 22; --bg-lift:#152e27; --bg-lift-rgb:21 46 39; + --bg2:#10211c; --bg2-rgb:16 33 28; --panel:#132720; --panel-rgb:19 39 32; --panel2:#162b24; --panel2-rgb:22 43 36; --panel3:#182f28; + --line-rgb:186 214 199; + --fg:#dfede5; --soft:#c3d8cc; --soft-rgb:195 216 204; --dim:#a9c3b5; --faint:#93b0a2; + --on-accent:#081712; --on-accent-deep:#030b08; + --reason:#86c7c5; --output:#a7dfc5; --retrieval:#b7a1c8; --toolcall:#96b7d0; + --c-input:#8bcbb1; --c-output:#86c7c5; --c-cost:#b7a1c8; --c-cost-rgb:183 161 200; + --chart-axis:#5b7a6c; --chart-label:#a9c3b5; + --glow:.85; + --page-texture:linear-gradient(rgb(var(--tint-rgb)/.024) 1px,transparent 1px),linear-gradient(90deg,rgb(var(--tint-rgb)/.018) 1px,transparent 1px); + --page-texture-size:36px 36px; + --radius-2xs:2px; --radius-xs:4px; --radius-sm:8px; --radius-md:10px; --radius-lg:14px; + --ring:var(--signal),rgb(var(--signal-rgb)/.4) 25%,var(--signal-hi) 50%,var(--aura) 75%,var(--signal); + --ring-speed:44s; + --title-glow:0 0 20px rgb(var(--signal-rgb)/.4); + --grain-opacity:.05; + --fx-front-rgb:139 203 177; --fx-back-rgb:43 92 76; --fx-edge-a-rgb:139 203 177; --fx-edge-b-rgb:197 204 145; +} +[data-theme=ferrous]{ + --signal:#ff5a36; --signal-rgb:255 90 54; --signal-mid:#e8481f; --signal-mid-rgb:232 72 31; + --signal-hi:#ffa27a; --signal-hi-rgb:255 162 122; --signal-pale:#ffe0d2; --signal-pale-rgb:255 224 210; --signal-deep-rgb:74 22 10; + --aura:#ffb36b; --aura-rgb:255 179 107; --aura-pale:#ffd9ad; --aura-deep:#d9822b; --aura-glow-rgb:255 150 70; + --flare:#5ee6d8; --flare-rgb:94 230 216; + --bg-deep:#080403; --bg-deep-rgb:8 4 3; --bg:#0f0705; --bg-rgb:15 7 5; --bg-lift:#2a110a; --bg-lift-rgb:42 17 10; + --bg2:#150b08; --bg2-rgb:21 11 8; --panel:#1c100c; --panel-rgb:28 16 12; --panel2:#271713; --panel2-rgb:39 23 19; --panel3:#33201a; + --line-rgb:255 150 110; + --fg:#fff3ee; --soft:#efd3c6; --soft-rgb:239 211 198; --dim:#c9aa9d; --faint:#ae8f83; + --on-accent:#1c0602; --on-accent-deep:#0d0301; + --reason:#ffa27a; --output:#ffb36b; --retrieval:#5ee6d8; --toolcall:#e8481f; + --c-input:#ff5a36; --c-output:#ffb36b; --c-cost:#5ee6d8; --c-cost-rgb:94 230 216; + --chart-axis:#7a5e54; --chart-label:#c9aa9d; + --glow:1; + --page-texture:repeating-linear-gradient(135deg,rgb(var(--signal-rgb)/.035) 0 1px,transparent 1px 10px); + --page-texture-size:auto; + --radius-2xs:1px; --radius-xs:2px; --radius-sm:3px; --radius-md:4px; --radius-lg:6px; + --ring:var(--signal),rgb(var(--signal-rgb)/.35) 20%,var(--aura) 40%,rgb(var(--signal-rgb)/.35) 60%,var(--flare) 80%,var(--signal); + --ring-speed:28s; + --title-glow:0 0 22px rgb(var(--signal-rgb)/.5); + --grain-opacity:.07; + --fx-front-rgb:255 90 54; --fx-back-rgb:150 40 20; --fx-edge-a-rgb:255 90 54; --fx-edge-b-rgb:255 179 107; +} +[data-theme=afterglow]{ + --signal:#ff5fa2; --signal-rgb:255 95 162; --signal-mid:#ff8a5c; --signal-mid-rgb:255 138 92; + --signal-hi:#ffc775; --signal-hi-rgb:255 199 117; --signal-pale:#ffe3f0; --signal-pale-rgb:255 227 240; --signal-deep-rgb:72 14 46; + --aura:#8f7bff; --aura-rgb:143 123 255; --aura-pale:#c9bfff; --aura-deep:#6a55e0; --aura-glow-rgb:143 110 255; + --flare:#3fe3ff; --flare-rgb:63 227 255; + --bg-deep:#06020a; --bg-deep-rgb:6 2 10; --bg:#0b0510; --bg-rgb:11 5 16; --bg-lift:#240a26; --bg-lift-rgb:36 10 38; + --bg2:#12081a; --bg2-rgb:18 8 26; --panel:#190d22; --panel-rgb:25 13 34; --panel2:#22122e; --panel2-rgb:34 18 46; --panel3:#2e1a3b; + --line-rgb:255 160 210; + --fg:#fff1f8; --soft:#efd0e2; --soft-rgb:239 208 226; --dim:#cbabc2; --faint:#b08fa8; + --on-accent:#240418; --on-accent-deep:#12020c; + --reason:#ffc775; --output:#ff8a5c; --retrieval:#c9bfff; --toolcall:#ff5fa2; + --c-input:#ff5fa2; --c-output:#ffc775; --c-cost:#8f7bff; --c-cost-rgb:143 123 255; + --chart-axis:#7a5f78; --chart-label:#cbabc2; + --glow:1; + --page-texture:linear-gradient(rgb(var(--signal-rgb)/.045) 1px,transparent 1px); + --page-texture-size:100% 28px; + --radius-2xs:3px; --radius-xs:6px; --radius-sm:8px; --radius-md:12px; --radius-lg:18px; + --ring:var(--signal),var(--signal-mid) 25%,var(--signal-hi) 45%,var(--aura) 70%,var(--flare) 85%,var(--signal); + --ring-speed:32s; + --title-glow:0 0 22px rgb(var(--signal-rgb)/.45); + --grain-opacity:.05; + --fx-front-rgb:255 95 162; --fx-back-rgb:143 123 255; --fx-edge-a-rgb:255 95 162; --fx-edge-b-rgb:255 199 117; +} +*{box-sizing:border-box} +html,body{margin:0} +body{min-height:100vh;background: + linear-gradient(180deg,var(--bg-deep) 0%,var(--bg2) 42%,var(--bg) 100%); + color:var(--fg);font:var(--fs-14)/1.5 var(--font-sans);-webkit-font-smoothing:antialiased} +body:before{content:"";position:fixed;inset:0;z-index:-1;pointer-events:none;background-image:var(--page-texture);background-size:var(--page-texture-size);mask-image:linear-gradient(180deg,rgb(var(--shade-rgb)/.9),rgb(var(--shade-rgb)/.28) 52%,transparent)} +html{scrollbar-color:rgb(var(--signal-rgb)/.32) transparent} +::selection{background:rgb(var(--signal-rgb)/.34);color:var(--fg)} +::view-transition-old(root),::view-transition-new(root){animation-duration:.42s;animation-timing-function:var(--spectrum-ease)} +.mono{font-family:var(--font-mono);font-variant-numeric:tabular-nums} +.wrap{max-width:1320px;margin:0 auto;padding:18px 24px 64px} +.top{position:sticky;top:0;z-index:20;display:flex;align-items:center;gap:14px;margin:0 0 18px;padding:10px 0 12px;background:linear-gradient(180deg,rgb(var(--bg-rgb)/.98),rgb(var(--bg-rgb)/.82));backdrop-filter:blur(18px);border-bottom:1px solid var(--line)} +.brand{display:flex;align-items:center;gap:8px;font-weight:var(--fw-heavy);font-size:var(--fs-14);white-space:nowrap} +.logo{width:50px;height:30px;display:flex;flex:0 0 auto;align-items:center;justify-content:flex-start} +.logo img{display:block;width:50px;height:auto;max-height:30px;object-fit:contain} +.tabs,.seg{display:flex;gap:3px;background:rgb(var(--tint-rgb)/.045);border:1px solid var(--line);border-radius:var(--radius-md);padding:3px;box-shadow:var(--inset)} +.tab,.seg button{border:0;background:transparent;color:var(--dim);border-radius:var(--radius-sm);padding:6px 12px;font:inherit;font-size:var(--fs-13);font-weight:var(--fw-bold);text-decoration:none;cursor:pointer;transition:background .16s,border-color .16s,color .16s,transform .16s} +.tab:hover,.seg button:hover{background:rgb(var(--tint-rgb)/.045);color:var(--fg)} +.tab.on,.seg button.on{background:linear-gradient(180deg,rgb(var(--tint-rgb)/.11),rgb(var(--tint-rgb)/.055));color:var(--fg);box-shadow:0 1px 2px rgb(var(--shade-rgb)/.22),var(--inset)} +.top .tabs{min-width:0;overflow-x:auto;scrollbar-width:none} +.top .tabs::-webkit-scrollbar{display:none} +.spacer{flex:1} +.notify,.pill{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);background:rgb(var(--panel-rgb)/.86);border-radius:var(--radius-pill);padding:6px 10px;font-size:var(--fs-12);color:var(--dim);white-space:nowrap;box-shadow:var(--inset)} +.notify{cursor:pointer;user-select:none} +.sw{width:32px;height:18px;border-radius:var(--radius-pill);background:rgb(var(--tint-rgb)/.13);position:relative;box-shadow:inset 0 0 0 1px rgb(var(--tint-rgb)/.08)} +.sw:after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:var(--radius-round);background:var(--white);transition:left .16s;box-shadow:0 2px 6px rgb(var(--shade-rgb)/.32)} +.notify.on .sw{background:var(--good)} +.notify.on .sw:after{left:16px} +.notify.hasnew{border-color:rgb(var(--signal-rgb)/.52);box-shadow:0 0 0 1px rgb(var(--signal-rgb)/.12),var(--inset)} +.view{display:none} +.view.on{display:block} +.session-panel{display:none} +.session-panel.on{display:block} +.card{background:linear-gradient(180deg,rgb(var(--panel-rgb)/.95),rgb(var(--bg2-rgb)/.96));border:1px solid var(--line);border-radius:var(--radius-md);box-shadow:var(--shadow);position:relative;min-width:0} +.card:hover{border-color:rgb(var(--tint-rgb)/.12)} +.pad{padding:18px} +.label{font-size:var(--fs-11);text-transform:uppercase;color:var(--faint);font-weight:var(--fw-heavy)} +.section-h{display:flex;align-items:center;gap:10px;margin:24px 0 10px} +.section-h h2{font-size:var(--fs-14);margin:0;font-weight:var(--fw-heavy)} +.section-h .hint{font-size:var(--fs-12);color:var(--faint)} +.sessionhead{display:flex;align-items:center;gap:8px;margin-bottom:10px;min-width:0} +.sessionStart{display:grid;grid-template-columns:max-content minmax(0,1fr);gap:8px;align-items:baseline;margin-bottom:12px;padding:8px 10px;border:1px solid var(--line);border-left:3px solid var(--accent);border-radius:var(--radius-md);background:rgb(var(--signal-rgb)/.055);min-width:0} +.sessionStart[hidden]{display:none} +.sessionStart span{color:var(--faint);font-size:var(--fs-11);font-weight:var(--fw-heavy);text-transform:uppercase;white-space:nowrap} +.sessionStart strong{font-size:var(--fs-13);font-weight:var(--fw-semibold);line-height:1.4;overflow-wrap:anywhere} +.repo,.sessid{border:1px solid var(--line);background:rgb(var(--panel-rgb)/.76);border-radius:var(--radius-md);padding:6px 10px;font-size:var(--fs-12);min-width:0;box-shadow:var(--inset)} +.repo{font-weight:var(--fw-bold);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.sessid{color:var(--dim);max-width:360px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.sessionDesktopLink{display:inline-flex;align-items:center;justify-content:center;text-decoration:none;white-space:nowrap} +.sessionDesktopLink[hidden]{display:none} +.currentSessionSlot{position:relative;display:flex;min-width:0} +.currentSessionSlot>.currentSessionCard{flex:1 1 auto} +.currentSessionSlot[data-desktop] .currentSessionTop{padding-right:36px} +.currentSessionDesktop{position:absolute;top:14px;right:14px;z-index:2;display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;border:1px solid var(--line2);border-radius:var(--radius-sm);background:rgb(var(--bg-rgb)/.62);color:var(--dim);transition:color .16s,border-color .16s,background .16s} +.currentSessionDesktop:hover{color:var(--fg);border-color:var(--line3);background:rgb(var(--tint-rgb)/.07)} +.currentSessionDesktop:focus-visible{outline:2px solid var(--accent);outline-offset:2px} +.currentSessionDesktop svg{width:13px;height:13px;fill:none;stroke:currentColor;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round} +.sessionDelete{border:1px solid rgb(var(--red-rgb)/.36);background:rgb(var(--red-rgb)/.07);border-radius:var(--radius-md);color:var(--bad-text);font:inherit;font-size:var(--fs-12);font-weight:var(--fw-bold);padding:6px 10px;cursor:pointer;box-shadow:var(--inset);white-space:nowrap} +.sessionDelete:hover:not(:disabled){border-color:rgb(var(--red-rgb)/.68);background:rgb(var(--red-rgb)/.13);color:var(--bad-text-strong)} +.sessionDelete:disabled{opacity:.42;cursor:not-allowed} +.sessionDelete[hidden]{display:none} +.sessionDeleteIcon{display:inline-grid;place-items:center;width:28px;height:28px;padding:0} +.sessionDeleteIcon svg{width:15px;height:15px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round} +.smetrics .sessionDelete{font-size:var(--fs-11);padding:4px 6px;margin-top:4px} +.sessionModelAssign{border:1px solid rgb(var(--signal-rgb)/.42);background:rgb(var(--signal-rgb)/.08);border-radius:var(--radius-md);color:var(--signal-hi);font:inherit;font-size:var(--fs-11);font-weight:var(--fw-bold);padding:4px 6px;cursor:pointer;box-shadow:var(--inset);white-space:nowrap;margin-top:4px} +.sessionModelAssign:hover:not(:disabled){border-color:rgb(var(--signal-rgb)/.7);background:rgb(var(--signal-rgb)/.15);color:var(--fg)} +.sessionModelAssign:disabled{opacity:.42;cursor:not-allowed} +.previewRunMeta>.sessionModelAssign{font-size:var(--fs-12);padding:6px 10px;margin-top:0} +.hero{display:grid;grid-template-columns:minmax(410px,.86fr) minmax(620px,1.14fr);gap:12px;align-items:start} +.costcard{overflow:hidden;align-self:start;min-height:238px;padding:16px;display:flex;flex-direction:column;justify-content:flex-start;background: + linear-gradient(180deg,rgb(var(--panel2-rgb)/.98),rgb(var(--bg2-rgb)/.98)), + linear-gradient(90deg,rgb(var(--signal-rgb)/.16),rgb(var(--signal-mid-rgb)/.06));} +.costcard:before{content:"";position:absolute;inset:0 0 auto;height:3px;background:linear-gradient(90deg,var(--signal),var(--signal-mid),rgb(var(--signal-hi-rgb)/.72),transparent)} +.costcard:after{content:"";position:absolute;inset:auto 18px 18px auto;width:32%;height:1px;background:linear-gradient(90deg,transparent,rgb(var(--tint-rgb)/.24));opacity:.55} +.meter{font-size:var(--fs-56);font-weight:var(--fw-heavy);line-height:1.02;margin-top:8px;background:linear-gradient(90deg,var(--white),var(--signal-hi) 70%,var(--signal-pale));-webkit-background-clip:text;background-clip:text;color:transparent} +.costcard .meter{font-size:var(--fs-56);margin-top:4px} +.costcard .subline{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.costtip{display:none;position:absolute;z-index:8;right:14px;top:78px;width:min(390px,calc(100% - 28px));border:1px solid rgb(var(--signal-rgb)/.48);border-radius:var(--radius-md);background:linear-gradient(180deg,rgb(var(--bg-deep-rgb)/.98),rgb(var(--panel-rgb)/.98));box-shadow:0 18px 44px rgb(var(--shade-rgb)/.48);padding:10px 10px;color:var(--dim);font-size:var(--fs-12);line-height:1.35} +.costcard:hover .costtip,.costcard:focus-within .costtip{display:block} +.costtip .h{font-weight:var(--fw-heavy);color:var(--fg);margin-bottom:6px} +.costtip .r{display:grid;grid-template-columns:90px minmax(0,1fr) auto;gap:8px;align-items:baseline;padding:3px 0;border-top:1px solid rgb(var(--tint-rgb)/.06)} +.costtip .r:first-of-type{border-top:0} +.costtip .name{color:var(--fg);font-weight:var(--fw-bold)} +.costtip .formula{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.costtip .amt{color:var(--fg);font-weight:var(--fw-heavy)} +.costtip .total{margin-top:6px;padding-top:6px;border-top:1px solid var(--line2);display:flex;justify-content:space-between;gap:10px;color:var(--fg);font-weight:var(--fw-heavy)} +.costtip .note{margin-top:4px;color:var(--faint);font-size:var(--fs-11)} +.costmeta{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin-top:14px} +.costmeta>div{border:1px solid var(--line);border-radius:var(--radius-md);background:rgb(var(--tint-rgb)/.035);padding:8px 10px;box-shadow:var(--inset);min-width:0} +.costmeta .v{font-size:var(--fs-13);font-weight:var(--fw-heavy);margin-top:3px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.costmeta .sm{font-size:var(--fs-12);color:var(--faint)} +.costsplit{margin-top:14px;padding-top:12px;border-top:1px solid var(--line);min-width:0} +.costsplit .splithead{display:flex;align-items:baseline;gap:8px;margin-bottom:8px} +.costsplit .splithead .hint{font-size:var(--fs-12);color:var(--faint)} +.costsplit .bar{height:26px} +.costsplit .legend{gap:10px;margin-top:8px} +.meter.alert{background:none;color:var(--bad)} +.subline{margin-top:10px;color:var(--dim);font-size:var(--fs-13)} +.subline b{color:var(--fg)} +.frozen{display:none;margin-top:10px;font-size:var(--fs-12);color:var(--warn);font-weight:var(--fw-bold)} +.frozen.on{display:block} +.statgrid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px} +.hero>.statgrid{grid-template-columns:repeat(2,minmax(0,1fr))} +.usageDetails{margin-top:12px;border:1px solid var(--line2);border-radius:var(--radius-lg);background:rgb(var(--tint-rgb)/.018);box-shadow:var(--shadow-soft);overflow:hidden} +.usageDetails>summary{display:flex;align-items:baseline;gap:10px;padding:12px 14px;cursor:pointer;font-weight:var(--fw-heavy);list-style-position:inside} +.usageDetails>summary:hover{background:rgb(var(--tint-rgb)/.025)} +.usageDetailsBody{padding:0 14px 16px;border-top:1px solid var(--line)} +.usageSplit{margin-top:14px} +.usageSplit .splithead{display:flex;align-items:baseline;gap:8px;margin-bottom:8px} +.usageSplit .bar{height:26px} +.usageSplit .legend{gap:10px;margin-top:8px} +.usageDetailGrid{margin-top:10px} +.stat{background:linear-gradient(180deg,rgb(var(--tint-rgb)/.055),rgb(var(--tint-rgb)/.025));border:1px solid var(--line);border-radius:var(--radius-md);padding:12px 14px;box-shadow:var(--shadow-soft);min-width:0} +.stat .v{font-size:var(--fs-24);font-weight:var(--fw-heavy);margin-top:4px;overflow:hidden;text-overflow:ellipsis} +.stat .sm{font-size:var(--fs-12);color:var(--faint)} +.overview{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;position:relative;z-index:2} +.ov{padding:14px 14px;overflow:visible;min-height:92px;min-width:0} +.ov:hover,.ov:focus-within{z-index:24} +.ov:before{content:"";position:absolute;left:14px;right:14px;top:0;height:2px;background:linear-gradient(90deg,var(--signal),transparent);opacity:.78} +.ov .v{font-size:var(--fs-22);font-weight:var(--fw-heavy);margin-top:4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.ov.time .v{font-size:var(--fs-13);line-height:1.35;letter-spacing:0;white-space:normal} +.ov .sm{font-size:var(--fs-12);color:var(--dim);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.banner{display:none;background:linear-gradient(180deg,rgb(var(--bad-deep-rgb)/.72),rgb(var(--bad-ink-rgb)/.86));border:1px solid rgb(var(--red-rgb)/.38);color:var(--bad-text-strong);padding:10px 14px;border-radius:var(--radius-md);margin:0 0 14px;font-size:var(--fs-13);font-weight:var(--fw-bold);box-shadow:var(--shadow-soft);align-items:center;gap:12px;flex-wrap:wrap} +.banner.on{display:flex} +.banner .msg{flex:1 1 280px;min-width:0} +.budgetadd{display:none;align-items:center;gap:8px;flex-wrap:wrap;color:var(--dim);font-weight:var(--fw-semibold)} +.banner.budget .budgetadd{display:inline-flex} +.budgetadd label{display:flex;align-items:center;gap:6px} +.budgetadd input{width:78px;background:rgb(var(--tint-rgb)/.07);border:1px solid rgb(var(--tint-rgb)/.2);color:var(--fg);border-radius:var(--radius-md);padding:6px 8px;font:inherit} +.budgetadd input:focus{outline:none;border-color:var(--accent);box-shadow:var(--focus-ring)} +.bar{display:flex;height:32px;border-radius:var(--radius-md);overflow:hidden;background:rgb(var(--tint-rgb)/.04);border:1px solid var(--line);box-shadow:inset 0 1px 10px rgb(var(--shade-rgb)/.22)} +.bar span{display:flex;align-items:center;justify-content:center;font-size:var(--fs-11);color:var(--white);font-weight:var(--fw-heavy);min-width:0;transition:width .35s ease;white-space:nowrap} +#sembar span{color:var(--bg)} +.legend{display:flex;gap:14px;margin-top:10px;flex-wrap:wrap;font-size:var(--fs-12);color:var(--dim)} +.dot{width:9px;height:9px;border-radius:var(--radius-xs);display:inline-block;margin-right:6px;vertical-align:middle} +.fieldtip{position:relative;display:inline-flex;align-items:center;cursor:help;outline:none} +.currentSessionCard.fieldtip{display:grid} +.fieldtip:focus-visible{color:var(--fg)} +#fieldtip-popup{position:fixed;left:0;top:0;z-index:1000;box-sizing:border-box;width:min(320px,calc(100vw - 24px));pointer-events:none;border:1px solid rgb(var(--signal-rgb)/.42);border-radius:var(--radius-md);background:linear-gradient(180deg,rgb(var(--bg-deep-rgb)/.98),rgb(var(--panel-rgb)/.98));box-shadow:0 14px 32px rgb(var(--shade-rgb)/.46);padding:8px 10px;color:var(--dim);font-size:var(--fs-12);line-height:1.35;font-weight:var(--fw-medium);overflow-wrap:anywhere} +#fieldtip-popup[hidden]{display:none} +@keyframes enter{from{opacity:.3;transform:translateY(3px)}to{opacity:1;transform:none}} +.chartbar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap} +.chartbar .label{margin-right:auto} +.chartbar .seg{flex-wrap:wrap} +.chartwrap{position:relative} +.chart{height:252px;width:100%;display:block;background:rgb(var(--tint-rgb)/.025);border:1px solid var(--line);border-radius:var(--radius-md)} +.chart text{user-select:none} +.charttip{z-index:4;display:none;min-width:238px;overflow:hidden;font:var(--fs-12)/1.35 var(--font-mono)} +.charttip .h{padding:6px 10px;background:rgb(var(--signal-rgb)/.16);font-weight:var(--fw-heavy);color:var(--fg)} +.charttip .r{display:flex;justify-content:space-between;gap:16px;padding:3px 10px} +.charttip .prompt{display:block;padding:6px 10px;border-top:1px solid rgb(var(--tint-rgb)/.08);max-width:360px} +.charttip .prompt span{display:block;color:var(--chart-label);margin-bottom:2px} +.charttip .prompt b{display:block;color:var(--fg);font-weight:var(--fw-semibold);white-space:normal} +.charttip .in,.charttip .fresh{color:var(--c-input)} +.charttip .out{color:var(--c-output)} +.charttip .cacheRead{color:var(--c-cache-read)} +.charttip .cacheWrite{color:var(--c-cache-write)} +.charttip .cost{color:var(--c-cost)} +.charttip .context{color:var(--c-context)} +.charttip .tools{color:var(--toolcall)} +.mini{height:8px;border-radius:var(--radius-xs);background:rgb(var(--tint-rgb)/.045);overflow:hidden;margin:10px 0 4px;border:1px solid var(--line)} +.mini>i{display:block;height:100%;border-radius:var(--radius-xs);transition:width .35s ease} +.tbl{width:100%;border-collapse:collapse;font-size:var(--fs-13)} +.tbl th{text-align:left;color:var(--faint);font-weight:var(--fw-heavy);font-size:var(--fs-11);text-transform:uppercase;padding:8px;border-bottom:1px solid var(--line);background:rgb(var(--tint-rgb)/.02)} +.tbl td{padding:8px 8px;border-bottom:1px solid rgb(var(--tint-rgb)/.055);vertical-align:top} +.tbl tr:hover td{background:rgb(var(--tint-rgb)/.025)} +.tbl tr:last-child td{border-bottom:0} +.tbl .num{text-align:right;color:var(--dim)} +.tbl .nm{font-weight:var(--fw-bold)} +.chips{display:flex;gap:6px;flex-wrap:wrap} +.chip{font-size:var(--fs-11);border:1px solid var(--line2);background:rgb(var(--tint-rgb)/.045);border-radius:var(--radius-pill);padding:3px 6px;color:var(--dim);box-shadow:var(--inset)} +.chip.tone-warn{border-color:rgb(var(--orange-rgb)/.4);background:rgb(var(--orange-rgb)/.08);color:var(--warn)} +.chip.mcp{color:var(--aura-pale);border-color:rgb(var(--aura-glow-rgb)/.42);background:rgb(var(--aura-glow-rgb)/.12)} +.tbtn{border:1px solid var(--line2);background:linear-gradient(180deg,rgb(var(--signal-rgb)/.18),rgb(var(--signal-rgb)/.08));color:var(--fg);border-radius:var(--radius-md);padding:6px 10px;font:inherit;font-size:var(--fs-12);font-weight:var(--fw-bold);cursor:pointer} +.tbtn:hover{border-color:rgb(var(--signal-rgb)/.56);background:linear-gradient(180deg,rgb(var(--signal-rgb)/.24),rgb(var(--signal-rgb)/.1))} +#e-builder-recap{display:inline-flex;align-items:center;text-decoration:none} +.sessionEfficiency{display:grid;grid-template-columns:minmax(112px,.55fr) repeat(2,minmax(0,1fr));align-items:stretch;margin-top:12px;padding:10px 14px;box-shadow:var(--shadow-soft)} +.sessionEfficiencyIntro{display:flex;flex-direction:column;justify-content:center;padding-right:14px} +.sessionEfficiencyIntro .sm{margin-top:2px;color:var(--faint);font-size:var(--fs-11)} +.sessionEfficiencyMetric{min-width:0;padding:2px 16px;border-left:1px solid var(--line)} +.sessionEfficiencyMetric strong{margin-top:2px;font-size:var(--fs-22);line-height:1.15;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.sessionEfficiencyMetric span{display:block;color:var(--dim);margin-top:2px;font-size:var(--fs-11);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.sessionEfficiencyMetric .label{letter-spacing:0} +.previewStatus .previewRunEfficiencySlot{margin-top:14px;padding-top:10px;border-top:1px solid rgb(var(--signal-rgb)/.14)} +.previewStatus .previewRunEfficiencySlot .sessionEfficiency{grid-template-columns:repeat(2,minmax(0,1fr));margin:0;padding:0;border:0;border-radius:0;background:transparent;box-shadow:none} +.previewStatus .previewRunEfficiencySlot .sessionEfficiencyIntro{grid-column:1/-1;flex-direction:row;align-items:baseline;justify-content:space-between;padding:0 0 8px} +.previewStatus .previewRunEfficiencySlot .sessionEfficiencyIntro .sm{display:none} +.previewStatus .previewRunEfficiencySlot .sessionEfficiencyMetric{padding:0 10px 0 0;border-left:0} +.previewStatus .previewRunEfficiencySlot .sessionEfficiencyMetric+.sessionEfficiencyMetric{padding:0 0 0 10px;border-left:1px solid var(--line)} +.previewStatus .previewRunEfficiencySlot .sessionEfficiencyMetric strong{font-size:var(--fs-18)} +.sessionBudget{margin-top:12px;padding:12px 14px;display:grid;gap:8px;overflow:visible} +.sessionBudgetHead,.sessionBudgetMeta{display:flex;align-items:center;justify-content:space-between;gap:14px} +.sessionBudgetHead>div:first-child{min-width:0} +.sessionBudgetExact{display:flex;align-items:center;gap:6px;color:var(--dim);font-size:var(--fs-12);white-space:nowrap} +.sessionBudgetExact input{width:86px;background:rgb(var(--tint-rgb)/.05);border:1px solid var(--line2);border-radius:var(--radius-md);color:var(--fg);padding:6px 8px;font:var(--fw-bold) var(--fs-13) var(--font-mono)} +.sessionBudgetExact input:focus{outline:none;border-color:var(--accent);box-shadow:var(--focus-ring)} +.sessionBudgetTrack{display:grid;grid-template-columns:52px minmax(120px,1fr) 60px;align-items:center;gap:10px} +.sessionBudgetTrack>span{font-size:var(--fs-11);color:var(--faint)} +.sessionBudgetTrack>span:last-child{text-align:right} +.sessionBudgetTrack input[type=range]{--budget-fill:0%;appearance:none;-webkit-appearance:none;width:100%;height:22px;margin:0;background:transparent;cursor:pointer} +.sessionBudgetTrack input[type=range]::-webkit-slider-runnable-track{height:7px;border:1px solid var(--line2);border-radius:var(--radius-pill);background:linear-gradient(90deg,var(--accent) 0 var(--budget-fill),rgb(var(--tint-rgb)/.075) var(--budget-fill) 100%);box-shadow:var(--inset)} +.sessionBudgetTrack input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:18px;height:18px;margin-top:-6px;border:2px solid var(--signal-pale);border-radius:var(--radius-round);background:var(--accent);box-shadow:0 0 0 4px rgb(var(--signal-rgb)/.15),0 3px 10px rgb(var(--shade-rgb)/.45)} +.sessionBudgetTrack input[type=range]:focus-visible{outline:none} +.sessionBudgetTrack input[type=range]:focus-visible::-webkit-slider-thumb{box-shadow:0 0 0 5px rgb(var(--signal-rgb)/.28),0 3px 10px rgb(var(--shade-rgb)/.45)} +.sessionBudgetTrack input[type=range]::-moz-range-track{height:7px;border:1px solid var(--line2);border-radius:var(--radius-pill);background:rgb(var(--tint-rgb)/.075)} +.sessionBudgetTrack input[type=range]::-moz-range-progress{height:7px;border-radius:var(--radius-pill);background:var(--accent)} +.sessionBudgetTrack input[type=range]::-moz-range-thumb{width:16px;height:16px;border:2px solid var(--signal-pale);border-radius:var(--radius-round);background:var(--accent);box-shadow:0 0 0 4px rgb(var(--signal-rgb)/.15)} +.sessionBudgetMeta{font-size:var(--fs-12);color:var(--faint)} +.sessionBudgetMeta span:first-child{color:var(--dim);font-weight:var(--fw-semibold)} +.toastbox{position:fixed;right:18px;bottom:18px;z-index:80;display:flex;flex-direction:column;gap:8px;width:min(360px,calc(100vw - 28px));pointer-events:none;transition:bottom .18s ease} +.updateReady .toastbox{bottom:70px} +.toast{pointer-events:auto;border:1px solid rgb(var(--signal-rgb)/.38);border-left:4px solid var(--signal);border-radius:var(--radius-md);background:linear-gradient(180deg,rgb(var(--panel2-rgb)/.98),rgb(var(--bg2-rgb)/.98));box-shadow:0 16px 42px rgb(var(--shade-rgb)/.42);padding:10px 12px;animation:enter .18s ease} +.toast.warn{border-left-color:var(--warn);border-color:rgb(var(--orange-rgb)/.42)} +.toast.bad{border-left-color:var(--bad);border-color:rgb(var(--red-rgb)/.42)} +.toast .th{display:flex;align-items:flex-start;justify-content:space-between;gap:10px;font-size:var(--fs-13);font-weight:var(--fw-heavy)} +.toast .tb{font-size:var(--fs-12);color:var(--dim);margin-top:2px} +.toast button{border:0;background:transparent;color:var(--faint);font:inherit;font-size:var(--fs-16);line-height:1;cursor:pointer;padding:0} +.toast button:hover{color:var(--fg)} +.updateNotice{position:fixed;right:18px;bottom:18px;z-index:79;border:1px solid rgb(var(--signal-rgb)/.58);border-radius:var(--radius-md);background:linear-gradient(180deg,rgb(var(--signal-deep-rgb)/.98),rgb(var(--bg2-rgb)/.98));color:var(--fg);box-shadow:0 16px 42px rgb(var(--shade-rgb)/.48),0 0 26px rgb(var(--signal-rgb)/.12);padding:8px 12px;font:var(--fw-bold) var(--fs-12)/1.2 var(--font-sans);cursor:pointer;animation:enter .18s ease} +.updateNotice:hover{border-color:var(--accent);background:linear-gradient(180deg,rgb(var(--signal-rgb)/.2),transparent),linear-gradient(180deg,rgb(var(--signal-deep-rgb)/.98),rgb(var(--bg2-rgb)/.98))} +.updateNotice:disabled{cursor:wait;color:var(--dim);opacity:.84} +.updateNotice[hidden]{display:none} +.foot{margin-top:16px;font-size:var(--fs-11);color:var(--faint);text-align:center} +.spark{display:flex;align-items:flex-end;gap:4px;height:84px;margin-top:8px;padding:0 2px} +.spark .b{flex:1;background:linear-gradient(180deg,var(--signal),rgb(var(--signal-rgb)/.55));border-radius:var(--radius-xs) var(--radius-xs) 0 0;min-height:3px;position:relative;box-shadow:0 -8px 18px rgb(var(--signal-rgb)/.08)} +.spark .b.anom{background:linear-gradient(180deg,var(--bad),rgb(var(--red-rgb)/.58))} +.spark .b:hover:after{content:attr(data-l);position:absolute;bottom:100%;left:50%;transform:translateX(-50%);background:var(--panel);color:var(--fg);border:1px solid var(--line2);font-size:var(--fs-11);padding:3px 6px;border-radius:var(--radius-xs);white-space:nowrap;margin-bottom:4px;z-index:5;box-shadow:var(--shadow-soft)} +.reco{display:inline-flex;border:1px solid var(--line);border-radius:var(--radius-pill);padding:3px 6px;font-size:var(--fs-11);color:var(--dim);white-space:nowrap} +.reco.disable,.reco.fix_or_disable{color:var(--warn);border-color:rgb(var(--orange-rgb)/.36);background:rgb(var(--orange-rgb)/.08)} +.reco.narrow_results,.reco.reduce_repeats{color:var(--signal-hi);border-color:rgb(var(--signal-rgb)/.3);background:rgb(var(--signal-rgb)/.08)} +.mcpDialog{width:min(560px,calc(100vw - 28px));border:1px solid var(--line2);border-radius:var(--radius-lg);background:linear-gradient(180deg,var(--panel2),var(--bg2));color:var(--fg);padding:0;box-shadow:0 30px 90px rgb(var(--shade-rgb)/.7)} +.mcpDialog::backdrop{background:rgb(var(--shade-rgb)/.68);backdrop-filter:blur(4px)} +.mcpDialog .inner{padding:20px} +.mcpDialog h3{margin:0 0 8px;font-size:var(--fs-20)} +.mcpDialog .evidence{display:grid;gap:6px;margin:14px 0;padding:12px;border:1px solid var(--line);border-radius:var(--radius-md);background:rgb(var(--tint-rgb)/.03);color:var(--dim);font-size:var(--fs-12)} +.mcpDialog code{display:block;margin:12px 0;padding:10px;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--bg);color:var(--signal-hi);overflow:auto} +.mcpDialog .actions{display:flex;justify-content:flex-end;gap:8px;margin-top:16px} +.mcpDialog .danger{border-color:rgb(var(--orange-rgb)/.45);background:rgb(var(--orange-rgb)/.12);color:var(--warn)} +.commandPalette{width:min(660px,calc(100vw - 28px));max-height:min(680px,calc(100vh - 40px));border:1px solid rgb(var(--signal-hi-rgb)/.34);border-radius:var(--radius-lg);background:radial-gradient(ellipse 70% 40% at 12% 0,rgb(var(--signal-rgb)/calc(.16*var(--glow))),transparent 70%),linear-gradient(180deg,rgb(var(--panel2-rgb)/.9),rgb(var(--bg2-rgb)/.94));color:var(--fg);padding:0;overflow:hidden;box-shadow:0 34px 100px rgb(var(--shade-rgb)/.76),0 0 70px rgb(var(--signal-rgb)/calc(.16*var(--glow)));backdrop-filter:blur(24px) saturate(1.3)} +.commandPalette::backdrop{background:rgb(var(--shade-rgb)/.7);backdrop-filter:blur(6px)} +.commandShell{display:flex;max-height:min(680px,calc(100vh - 40px));flex-direction:column} +.commandSearch{display:grid;grid-template-columns:20px minmax(0,1fr) auto;align-items:center;gap:10px;padding:14px 16px;border-bottom:1px solid var(--line2);background:rgb(var(--tint-rgb)/.025)} +.commandSearch svg{width:19px;height:19px;stroke:var(--faint)} +.commandSearch input{min-width:0;border:0;outline:0;background:transparent;color:var(--fg);font:var(--fw-semibold) var(--fs-16)/1.4 var(--font-sans)} +.commandSearch input::placeholder{color:var(--faint)} +.commandSearch kbd,.commandFooter kbd{border:1px solid var(--line2);border-radius:var(--radius-xs);background:rgb(var(--tint-rgb)/.05);color:var(--dim);font:var(--fw-bold) var(--fs-11)/1.35 var(--font-mono);box-shadow:0 1px 0 rgb(var(--tint-rgb)/.07)} +.commandSearch kbd{padding:2px 6px} +.commandList{min-height:100px;overflow:auto;padding:8px;scrollbar-color:var(--line2) transparent} +.commandGroup{padding:8px 10px 4px;color:var(--faint);font-size:var(--fs-11);font-weight:var(--fw-heavy);text-transform:uppercase;letter-spacing:.06em} +.commandItem{width:100%;display:grid;grid-template-columns:30px minmax(0,1fr) auto;align-items:center;gap:10px;border:1px solid transparent;border-radius:var(--radius-md);background:transparent;color:var(--fg);padding:8px 10px;text-align:left;font:inherit;cursor:pointer} +.commandItem:hover,.commandItem.selected{border-color:rgb(var(--signal-rgb)/.3);background:linear-gradient(90deg,rgb(var(--signal-rgb)/.16),rgb(var(--aura-rgb)/.04));box-shadow:inset 2px 0 0 var(--signal-hi)} +.commandGlyph{width:28px;height:28px;display:grid;place-items:center;border:1px solid var(--line);border-radius:var(--radius-sm);background:rgb(var(--tint-rgb)/.04);color:var(--signal-hi);font:var(--fw-heavy) var(--fs-11) var(--font-mono)} +.commandCopy{min-width:0} +.commandLabel,.commandDescription{display:block} +.commandLabel{font-size:var(--fs-13);font-weight:var(--fw-heavy);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.commandDescription{font-size:var(--fs-12);color:var(--faint);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.commandEmpty{min-height:120px} +.commandFooter{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:8px 14px;border-top:1px solid var(--line);color:var(--faint);font-size:var(--fs-11)} +.commandFooter span{display:flex;align-items:center;gap:4px} +.commandFooter kbd{padding:1px 4px;color:var(--fg)} +.globalControls{display:grid;grid-template-columns:minmax(220px,1fr) minmax(190px,.78fr) minmax(150px,.52fr) auto auto;gap:10px;align-items:end;margin:12px 0} +.filterControl{display:grid;gap:4px;min-width:0} +.filterControl>span{font-size:var(--fs-11);line-height:1;text-transform:uppercase;color:var(--faint);font-weight:var(--fw-heavy)} +.searchbox,.filterSelect{width:100%;min-width:0;height:38px;border:1px solid var(--line2);background:var(--panel);color:var(--fg);border-radius:var(--radius-md);padding:8px 10px;font:inherit;font-size:var(--fs-13);font-weight:var(--fw-bold);box-shadow:var(--inset);color-scheme:dark} +.searchbox{background:rgb(var(--tint-rgb)/.045);font-weight:inherit} +.filterSelect{appearance:auto;padding-right:28px} +select{color-scheme:dark;font-weight:var(--fw-bold)} +select option{color:var(--fg);background-color:var(--panel2)} +select option:checked{background-color:var(--panel3)} +.searchbox:focus,.filterSelect:focus{outline:none;border-color:var(--accent);box-shadow:var(--focus-ring)} +.filterSort .seg{min-height:38px;align-items:center} +.filterClear{display:grid;gap:4px;justify-items:stretch;min-width:148px} +.clearFilters{height:38px;background:rgb(var(--tint-rgb)/.045);color:var(--dim)} +.clearFilters:hover:not(:disabled){color:var(--fg)} +.clearFilters:disabled{opacity:.42;cursor:not-allowed} +.filterCount{text-align:right} +.globalMeta{font-size:var(--fs-12);color:var(--faint);white-space:nowrap} +.logsSummary{display:grid;grid-template-columns:minmax(380px,.78fr) minmax(440px,1.22fr);gap:10px;margin:0 0 12px;align-items:stretch} +.logKpis{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px} +.logStat{padding:12px 14px;min-height:82px;overflow:hidden} +.logStat .v{font-size:var(--fs-24);line-height:1.1;margin-top:4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.logStat .subline{font-size:var(--fs-11);margin-top:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.logModelCount{font-size:var(--fs-11);color:var(--faint);white-space:nowrap} +.logModelList{display:grid;margin-top:6px} +.logModelRow{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:14px;align-items:center;padding:6px 0;border-top:1px solid var(--line);min-width:0} +.logModelRow:first-child{border-top:0} +.logModelName{font-size:var(--fs-13);font-weight:var(--fw-heavy);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.logModelMeta{font-size:var(--fs-11);color:var(--faint);margin-top:1px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.logModelValue{text-align:right} +.logModelValue b{display:block;font-size:var(--fs-13)} +.logModelValue span{display:block;font-size:var(--fs-11);color:var(--faint);margin-top:1px} +.logModelEmpty{font-size:var(--fs-12);color:var(--faint);padding:16px 0 6px} +.logsSummary{display:grid;grid-template-columns:minmax(380px,.78fr) minmax(440px,1.22fr);gap:10px;margin:0 0 12px;align-items:stretch} +.logKpis{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px} +.logStat{padding:12px 14px;min-height:82px;overflow:hidden} +.logStat.waitStat{grid-column:1/-1;min-height:72px} +.logStat .v{font-size:var(--fs-24);line-height:1.1;margin-top:4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.logStat .subline{font-size:var(--fs-11);margin-top:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.logModelCount{font-size:var(--fs-11);color:var(--faint);white-space:nowrap} +.logModelList{display:grid;margin-top:6px} +.logModelRow{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:14px;align-items:center;padding:6px 0;border-top:1px solid var(--line);min-width:0} +.logModelRow:first-child{border-top:0} +.logModelName{font-size:var(--fs-13);font-weight:var(--fw-heavy);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.logModelMeta{font-size:var(--fs-11);color:var(--faint);margin-top:1px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.logModelValue{text-align:right} +.logModelValue b{display:block;font-size:var(--fs-13)} +.logModelValue span{display:block;font-size:var(--fs-11);color:var(--faint);margin-top:1px} +.logModelEmpty{font-size:var(--fs-12);color:var(--faint);padding:16px 0 6px} +.outputPair{display:flex;align-items:flex-end;justify-content:space-between;gap:10px;min-width:0} +.outputPair .v{min-width:0} +.outputRate{display:grid;justify-items:end;line-height:1;padding-bottom:2px;white-space:nowrap} +.outputRate strong{font-size:var(--fs-16);color:var(--signal-hi);font-weight:var(--fw-heavy)} +.outputRate span{font-size:var(--fs-11);color:var(--faint);font-weight:var(--fw-bold);text-transform:uppercase;margin-top:3px} +.modelHead{display:flex;align-items:flex-end;justify-content:space-between;gap:18px;margin-bottom:14px} +.modelHead h1,.modelHead h2{font-size:var(--fs-24);line-height:1.2;margin:0} +.modelHead p{color:var(--dim);margin:4px 0 0;font-size:var(--fs-13);max-width:760px} +.modelControls{display:grid;grid-template-columns:minmax(220px,290px) minmax(150px,190px);gap:8px;align-items:end} +.modelPicker{position:relative;min-width:0} +.modelPicker>summary{list-style:none;display:flex;align-items:center;justify-content:space-between;gap:10px;width:100%;min-width:0;height:38px;border:1px solid var(--line2);background:rgb(var(--tint-rgb)/.045);color:var(--fg);border-radius:var(--radius-md);padding:8px 10px;font-size:var(--fs-13);box-shadow:var(--inset);cursor:pointer} +.modelPicker>summary::-webkit-details-marker{display:none} +.modelPicker>summary:after{content:"\25be";color:var(--faint);font-size:var(--fs-11);flex:0 0 auto} +.modelPicker>summary span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.modelPicker[open]>summary,.modelPicker>summary:focus-visible{outline:none;border-color:var(--accent);box-shadow:var(--focus-ring)} +.modelPickerMenu{position:absolute;right:0;top:calc(100% + 6px);z-index:60;width:max(100%,290px);max-height:min(360px,55vh);overflow:auto;border:1px solid var(--line2);border-radius:var(--radius-md);background:linear-gradient(180deg,var(--panel2),var(--bg2));box-shadow:0 18px 46px rgb(var(--shade-rgb)/.58);padding:6px} +.modelOption{display:grid;grid-template-columns:18px minmax(0,1fr);align-items:center;gap:8px;padding:8px 8px;border-radius:var(--radius-sm);color:var(--dim);font-size:var(--fs-13);cursor:pointer} +.modelOption:hover{background:rgb(var(--tint-rgb)/.055);color:var(--fg)} +.modelOption input{width:15px;height:15px;margin:0;accent-color:var(--accent)} +.modelOption span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.modelOption.all{border-bottom:1px solid var(--line);border-radius:var(--radius-sm) var(--radius-sm) 0 0;margin-bottom:4px;padding-bottom:10px;font-weight:var(--fw-heavy);color:var(--fg)} +.modelHero{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:10px} +.modelKpi{padding:14px;min-height:110px;overflow:visible} +.modelKpi .v{font-size:var(--fs-28);line-height:1.08;margin-top:6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.modelKpi .subline{font-size:var(--fs-12);margin-top:4px} +.modelDelta{display:inline-flex;align-items:center;gap:6px;color:var(--dim)} +.modelDelta:before{content:"";width:7px;height:7px;border-radius:var(--radius-round);background:var(--faint)} +.modelDelta.up{color:var(--good)} +.modelDelta.up:before{background:var(--good)} +.modelDelta.down{color:var(--warn)} +.modelDelta.down:before{background:var(--warn)} +.modelCoverage{height:7px;border:1px solid var(--line2);border-radius:var(--radius-pill);background:rgb(var(--tint-rgb)/.045);overflow:hidden;margin-top:8px} +.modelCoverage i{display:block;height:100%;width:0;background:linear-gradient(90deg,var(--accent),var(--signal-hi));border-radius:var(--radius-pill)} +.modelTrendCard{margin-top:12px;padding:16px 16px} +.modelTrendHead{display:flex;justify-content:space-between;align-items:flex-start;gap:14px} +.modelTrendHead h2{font-size:var(--fs-14);margin:0} +.modelTrendHead p{font-size:var(--fs-12);color:var(--faint);margin:2px 0 0} +.modelTrendControls{display:flex;gap:12px;align-items:center;justify-content:flex-end;flex-wrap:wrap} +.modelSwatch{display:inline-block;width:9px;height:9px;border-radius:var(--radius-xs);margin-right:4px;flex:0 0 auto} +.modelChartWrap{position:relative;margin-top:10px} +.modelChart{display:block;width:100%;height:270px;border:1px solid var(--line);border-radius:var(--radius-md);background:rgb(var(--tint-rgb)/.022)} +.modelChartTip{z-index:5;display:none;min-width:300px;max-width:min(440px,calc(100% - 16px));max-height:260px;overflow:auto;font:var(--fs-12)/1.35 var(--font-mono)} +.modelChartTip .h{padding:6px 10px;background:rgb(var(--signal-rgb)/.14);font-weight:var(--fw-heavy);color:var(--fg)} +.modelChartTip .r{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:18px;padding:4px 10px} +.modelChartTip .r span{display:flex;align-items:center;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.modelChartTip .r b{color:var(--fg);white-space:nowrap} +.modelChartTip .cols{color:var(--faint);font-size:var(--fs-11);border-bottom:1px solid var(--line);text-transform:uppercase} +.modelTipSeries{padding:6px 10px;border-top:1px solid var(--line)} +.modelTipSeries:first-of-type{border-top:0} +.modelTipName{display:flex;align-items:center;min-width:0;color:var(--fg);font-weight:var(--fw-bold);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.modelTipMetrics{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:8px;margin-top:4px} +.modelTipMetrics span{display:grid;gap:1px;min-width:0} +.modelTipMetrics small{color:var(--faint);font-size:var(--fs-11);text-transform:uppercase} +.modelTipMetrics b{color:var(--fg);font-size:var(--fs-11);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.modelTableWrap{margin-top:12px;overflow:auto;max-height:620px} +.modelTableWrap .tbl th{position:sticky;top:0;z-index:2;background:var(--panel);white-space:nowrap} +.modelTableWrap .tbl th .fieldtip:after{bottom:auto;top:calc(100% + 7px);left:auto;right:0;z-index:4} +.modelTableWrap .tbl td{vertical-align:middle} +.modelNameLine{display:flex;align-items:center;min-width:0} +.modelName{font-weight:var(--fw-heavy);max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.modelRuntime{font-size:var(--fs-11);color:var(--faint);margin-top:2px} +.modelEmpty{padding:36px 16px} +.efficiencyHead{display:flex;align-items:flex-end;justify-content:space-between;gap:18px} +.efficiencyRange{display:grid;gap:6px;min-width:260px} +.efficiencyRange>span{font-size:var(--fs-11);color:var(--faint);font-weight:var(--fw-heavy);text-transform:uppercase} +.efficiencyHero{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px} +.efficiencyHeadline{position:relative;min-height:184px;padding:14px 16px 12px;overflow:hidden} +.efficiencyHeadline:after{content:"";position:absolute;right:-54px;bottom:-88px;width:210px;height:210px;border-radius:var(--radius-round);background:radial-gradient(circle,rgb(var(--signal-rgb)/.15),transparent 68%);pointer-events:none} +.efficiencyEyebrow{color:var(--dim);font-size:var(--fs-11);font-weight:var(--fw-heavy);letter-spacing:.06em;text-transform:uppercase} +.efficiencyValueRow,.efficiencySupportValueRow{min-width:0} +.efficiencyValueRow{margin-top:6px} +.efficiencyHeadline .v{min-width:0;font-size:var(--fs-36);font-weight:var(--fw-heavy);line-height:1.02;letter-spacing:-.04em} +.efficiencyCoverage{position:relative;z-index:1;margin-top:4px;color:var(--faint);font-size:var(--fs-11)} +.efficiencyChartWrap{position:relative;z-index:2;margin-top:8px} +.efficiencyChart{display:block;width:100%;height:64px;margin:0;color:var(--signal-hi);overflow:visible;cursor:crosshair} +.efficiencyChartGrid{stroke:rgb(var(--tint-rgb)/.07);stroke-width:1} +.efficiencyChartLine{fill:none;stroke:currentColor;stroke-width:2.25;stroke-linecap:round;stroke-linejoin:round} +.efficiencyChartArea{fill:currentColor;opacity:.08} +.efficiencyChartDot,.efficiencyChartInspectDot{fill:var(--panel);stroke:currentColor;stroke-width:2} +.efficiencyChartInspect{pointer-events:none} +.efficiencyChartInspectLine{stroke:currentColor;stroke-width:1;stroke-dasharray:3 3;opacity:.48} +.efficiencyChartLabel{fill:var(--faint);font:var(--fs-11) var(--font-mono)} +.efficiencyChartEmpty{fill:var(--faint);font:var(--fs-11) var(--font-sans)} +.efficiencyChartTip{z-index:4;top:3px;transform:translateX(-50%);padding:4px 8px;color:var(--fg);font:var(--fw-bold) var(--fs-11)/1.2 var(--font-mono);white-space:nowrap} +.efficiencyChartTip[hidden]{display:none} +.efficiencySupport{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin-top:10px} +.efficiencySupportItem{min-height:184px;padding:14px 16px 12px;overflow:hidden} +.efficiencySupportValueRow{margin-top:6px} +.efficiencySupportValueRow .v{margin-top:0} +.efficiencySupportItem .v{font-size:var(--fs-28);font-weight:var(--fw-heavy);line-height:1.1;margin-top:6px} +.efficiencySupportItem .subline{font-size:var(--fs-11);line-height:1.35;margin-top:4px} +.efficiencySupportItem .efficiencyChart{height:64px} +.efficiencyModelCard{margin-top:12px;padding:0;overflow:hidden} +.efficiencyModelHead{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:14px 16px 10px;border-bottom:1px solid var(--line)} +.efficiencyModelHead h2{font-size:var(--fs-14);margin:0} +.efficiencyModelMeta{font-size:var(--fs-11);color:var(--faint);white-space:nowrap} +.efficiencyTableWrap{overflow:auto;max-height:580px} +.efficiencyTableWrap .tbl th{position:sticky;top:0;z-index:2;background:var(--panel);white-space:nowrap} +.efficiencyTableWrap .tbl td{vertical-align:middle} +.efficiencySort{display:inline-flex;align-items:center;justify-content:flex-end;gap:4px;width:100%;padding:0;border:0;background:none;color:inherit;font:inherit;font-weight:inherit;text-transform:inherit;letter-spacing:inherit;cursor:pointer} +.efficiencyTableWrap .tbl th:first-child .efficiencySort,.workBreakdownTable th:first-child .efficiencySort,.workScore th:first-child .efficiencySort{justify-content:flex-start} +.efficiencySort:hover,.efficiencySort:focus-visible{color:var(--fg)} +.efficiencySort:focus-visible{outline:1px solid var(--signal);outline-offset:3px;border-radius:var(--radius-2xs)} +.efficiencySortMark{width:9px;color:var(--signal-hi);font-size:var(--fs-11);text-align:center} +.efficiencyMetric strong{display:block;color:var(--fg);font-size:var(--fs-13)} +.efficiencyMetric span{display:block;color:var(--faint);font-size:var(--fs-11);margin-top:2px} +.efficiencyEvidence{padding:8px 16px;border-top:1px solid var(--line);color:var(--faint);font-size:var(--fs-11);line-height:1.35} +.efficiencyTableWrap .tbl th,.efficiencyTableWrap .tbl td{padding-left:6px;padding-right:6px} +.efficiencyChart{border-radius:var(--radius-xs)} +.efficiencyChart:focus-visible{outline:1px solid currentColor;outline-offset:3px} +.efficiencyChartStatus{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0} +.capHero{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px} +.currentCapHero{grid-template-columns:repeat(3,minmax(0,1fr));margin-top:10px} +.capHero .pad{padding:12px 14px;overflow:visible} +.capHero .card:hover,.capHero .card:focus-within{z-index:50} +.capHero .fieldtip:after{bottom:auto;top:calc(100% + 8px);z-index:40} +.capHero .card:last-child .fieldtip:after{left:auto;right:0} +.capHero .meter{font-size:var(--fs-28);margin-top:4px} +.capHero .subline{font-size:var(--fs-12);margin-top:4px} +.capStatBar{height:8px;margin-top:8px;border:1px solid var(--line2);border-radius:var(--radius-pill);background:rgb(var(--tint-rgb)/.055);overflow:hidden;box-shadow:inset 0 1px 3px rgb(var(--shade-rgb)/.42)} +.capStatBar i{display:block;width:0;height:100%;border-radius:var(--radius-pill);transition:width .35s ease;background:linear-gradient(90deg,var(--good),var(--accent));box-shadow:0 0 14px rgb(var(--green-rgb)/.3)} +.capStatBar.mcp i{background:linear-gradient(90deg,var(--aura-deep),var(--aura));box-shadow:0 0 14px rgb(var(--aura-glow-rgb)/.3)} +.capStatBar.skill i{background:linear-gradient(90deg,var(--accent),var(--signal-hi));box-shadow:0 0 14px rgb(var(--signal-rgb)/.3)} +.capStatBar.warn i{background:linear-gradient(90deg,var(--warn),var(--signal));box-shadow:0 0 14px rgb(var(--orange-rgb)/.3)} +.defBar{height:12px;margin-top:10px;background:rgb(var(--tint-rgb)/.05);border:1px solid var(--line);border-radius:var(--radius-pill);overflow:hidden;display:flex} +.defBar i{display:block;height:100%} +.defBar .used{background:var(--good)} +.defBar .unused{background:var(--warn)} +.defBar .deferred{background:rgb(var(--signal-hi-rgb)/.42)} +.capFilters{display:grid;grid-template-columns:minmax(220px,1fr) auto auto;gap:10px;align-items:center;margin:10px 0} +.capTableWrap{overflow:auto;max-height:620px} +.capTableWrap .tbl th{position:sticky;top:0;z-index:2;background:var(--panel)} +.capTableWrap .tbl th.sortable{cursor:pointer;user-select:none;white-space:nowrap} +.capTableWrap .tbl th.sortable:after{content:"\2195";display:inline-block;margin-left:6px;color:var(--faint);font-size:var(--fs-11)} +.capTableWrap .tbl th.sortable.sort-asc:after{content:"\2191";color:var(--accent)} +.capTableWrap .tbl th.sortable.sort-desc:after{content:"\2193";color:var(--accent)} +.capTableWrap .tbl th.sortable:focus-visible{outline:1px solid var(--accent);outline-offset:-2px} +.stateDot{display:inline-flex;align-items:center;gap:6px;white-space:nowrap} +.stateDot:before{content:"";width:8px;height:8px;border-radius:var(--radius-round);background:var(--faint)} +.stateDot.on:before{background:var(--good)} +.stateDot.off:before{background:var(--bad)} +.stateDot.lazy:before{background:var(--signal-hi)} +.stateDot.prompt:before{background:var(--accent)} +.capAction{border:1px solid var(--line2);background:rgb(var(--tint-rgb)/.045);color:var(--fg);border-radius:var(--radius-sm);padding:4px 8px;font:inherit;font-size:var(--fs-11);font-weight:var(--fw-bold);cursor:pointer;white-space:nowrap} +.capAction:hover{border-color:var(--accent)} +.capAction.disable{color:var(--warn);border-color:rgb(var(--orange-rgb)/.4);background:rgb(var(--orange-rgb)/.08)} +.capAction:disabled{opacity:.45;cursor:not-allowed} +.capPath{max-width:330px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--faint);font-size:var(--fs-11)} +.tbtn.danger{border-color:rgb(var(--orange-rgb)/.45);background:rgb(var(--orange-rgb)/.12);color:var(--warn)} +.tbtn.danger:hover{background:rgb(var(--orange-rgb)/.19)} +.dailyHead{display:flex;align-items:flex-end;justify-content:space-between;gap:18px;margin-bottom:16px} +.dailyHead h1,.learnHead h1{font-size:var(--fs-24);line-height:1.2;margin:0} +.dailyHead p,.learnHead p{color:var(--dim);margin:4px 0 0;font-size:var(--fs-13)} +.iconBtn:hover:not(:disabled){border-color:var(--accent);background:rgb(var(--signal-rgb)/.09)} +.learnHead{max-width:760px;margin-bottom:20px} +.bulkControls{max-height:220px;white-space:pre-wrap} +.agentDiscovery{display:flex;align-items:center;gap:14px;margin-bottom:12px;padding:12px 14px;border:1px solid rgb(var(--signal-rgb)/.32);border-left:3px solid var(--accent);border-radius:var(--radius-md);background:linear-gradient(90deg,rgb(var(--signal-rgb)/.11),rgb(var(--panel-rgb)/.88) 58%,rgb(var(--panel-rgb)/.94));box-shadow:var(--shadow-soft)} +.agentDiscovery[hidden]{display:none} +.agentDiscoveryMain{flex:1;min-width:0} +.agentDiscoveryTitle{font-weight:var(--fw-heavy)} +.agentDiscoveryText{color:var(--dim);font-size:var(--fs-13);margin-top:2px} +.agentDiscoveryClose{border:0;background:transparent;color:var(--faint);font:var(--fw-bold) var(--fs-18)/1 var(--font-sans);cursor:pointer;padding:6px} +.agentDiscoveryClose:hover{color:var(--fg)} +.settingsPageHead{margin-bottom:14px} +.settingsPageHead p{max-width:680px} +.agentAccess{overflow:hidden} +.agentAccessHead{display:flex;align-items:flex-start;justify-content:space-between;gap:16px} +.agentAccessHead h1,.agentAccessHead h2{font-size:var(--fs-22);line-height:1.15;margin:4px 0 0} +.agentAccessHead p{color:var(--dim);font-size:var(--fs-13);margin:4px 0 0;max-width:650px} +.agentAccessBadge{padding:4px 8px;white-space:nowrap} +.agentAccessHead .tbtn{text-decoration:none} +.agentClients{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin-top:16px} +.agentClient{display:grid;gap:10px;border:1px solid var(--line);border-radius:var(--radius-md);background:rgb(var(--tint-rgb)/.035);padding:10px 10px} +.agentClientPart{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:12px;align-items:center} +.agentClientSkills{border-top:1px solid var(--line);padding-top:10px} +.agentClientActions{display:flex;gap:8px} +.agentClientName{display:flex;align-items:center;gap:6px;font-weight:var(--fw-heavy)} +.agentClientState{font-size:var(--fs-12);color:var(--faint);margin-top:2px} +.agentClientState.connected{color:var(--good)} +.agentClientState.warn{color:var(--warn)} +.agentCommand{white-space:pre-wrap;overflow-wrap:anywhere} +.compareToggle{width:22px;height:22px;display:grid;place-items:center;padding:0;border:1px solid var(--line2);border-radius:var(--radius-sm);background:rgb(var(--tint-rgb)/.035);color:transparent;cursor:pointer;transition:border-color .16s,background .16s,color .16s} +.compareToggle svg{width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:2.2;stroke-linecap:round;stroke-linejoin:round} +.compareToggle:hover{border-color:rgb(var(--signal-rgb)/.6);color:var(--faint)} +.compareToggle:focus-visible{outline:0;box-shadow:0 0 0 3px rgb(var(--signal-rgb)/.22);border-color:rgb(var(--signal-rgb)/.7)} +.compareToggle[aria-pressed=true]{background:var(--compare-color,var(--accent));border-color:var(--compare-color,var(--accent));color:var(--bg-deep)} +.compareToggle[aria-disabled=true]{opacity:.35;cursor:not-allowed} +.srow.compareSelected{border-color:color-mix(in srgb,var(--compare-color,var(--accent)) 55%,transparent)} +.compareTabCount{margin-left:6px;min-width:18px;height:18px;padding:0 4px;border-radius:var(--radius-pill);background:var(--accent);color:var(--bg-deep);font-size:var(--fs-11);font-weight:var(--fw-heavy);line-height:18px;text-align:center} +.compareTabCount[hidden]{display:none} +.tab[aria-selected=true] .compareTabCount{background:var(--on-accent);color:var(--signal-hi)} +.compareTray{position:sticky;bottom:14px;z-index:6;display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:12px;padding:10px 12px;border:1px solid rgb(var(--signal-rgb)/.42);border-radius:var(--radius-lg);background:linear-gradient(180deg,rgb(var(--panel-rgb)/.97),rgb(var(--bg2-rgb)/.97));box-shadow:0 14px 40px rgb(var(--shade-rgb)/.45);backdrop-filter:blur(14px)} +.compareTray[hidden]{display:none} +.compareTrayItems{display:flex;gap:6px;flex-wrap:wrap;min-width:0} +.compareTrayActions{display:flex;align-items:center;gap:8px;flex:0 0 auto} +.compareTrayNote{color:var(--faint);font-size:var(--fs-12);max-width:280px} +.compareChip{display:inline-flex;align-items:center;gap:6px;max-width:240px;padding:3px 4px 3px 3px;border:1px solid var(--line2);border-radius:var(--radius-sm);background:rgb(var(--tint-rgb)/.04);font-size:var(--fs-12);color:var(--dim)} +.compareChip span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.compareChip button{border:0;background:transparent;color:var(--faint);font:inherit;font-size:var(--fs-14);line-height:1;padding:0 3px;cursor:pointer;border-radius:var(--radius-xs)} +.compareChip button:hover{color:var(--fg)} +.compareLetter{flex:0 0 auto;display:inline-grid;place-items:center;width:20px;height:20px;border-radius:var(--radius-xs);background:var(--compare-color,var(--accent));color:var(--bg-deep);font-size:var(--fs-11);font-weight:var(--fw-heavy)} +.compareHeadActions{display:flex;gap:8px;align-items:center} +.tbtn.compareClear{background:rgb(var(--tint-rgb)/.04)} +#compare-body{display:grid;gap:14px} +.compareEmpty{padding:28px 24px} +.compareEmpty h3{margin:0 0 6px;font-size:var(--fs-16);color:var(--fg)} +.compareEmpty p{margin:0 auto 14px;max-width:560px;font-size:var(--fs-13);line-height:1.55} +.compareNote{padding:8px 12px;border:1px solid rgb(var(--orange-rgb)/.35);border-radius:var(--radius-md);background:rgb(var(--orange-rgb)/.08);color:var(--warn);font-size:var(--fs-12)} +.compareCards{display:grid;gap:10px} +.compareCard{padding:12px 12px;border-top:3px solid var(--compare-color);display:flex;flex-direction:column;gap:4px;min-width:0} +.compareCardTop{display:flex;align-items:center;gap:6px;min-width:0} +.compareBaselineTag{margin-left:auto;font-size:var(--fs-11);font-weight:var(--fw-heavy);text-transform:uppercase;letter-spacing:.05em;color:var(--compare-color)} +.compareCardTitle{font-weight:var(--fw-bold);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.compareCardMeta{font-size:var(--fs-12);color:var(--faint);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.compareCardCost{font-size:var(--fs-22);font-weight:var(--fw-heavy);margin-top:2px} +.compareCardActions{display:flex;gap:6px;flex-wrap:wrap;margin-top:4px} +.compareCardActions button{border:1px solid var(--line2);background:rgb(var(--tint-rgb)/.04);color:var(--dim);border-radius:var(--radius-sm);padding:4px 8px;font:inherit;font-size:var(--fs-12);font-weight:var(--fw-bold);cursor:pointer} +.compareCardActions button:hover:not(:disabled){color:var(--fg);border-color:rgb(var(--signal-rgb)/.5)} +.compareCardActions button:disabled{opacity:.45;cursor:default} +.compareSection{padding:14px 16px} +.compareSection h3{margin:0 0 10px;font-size:var(--fs-14)} +.compareSectionHead{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:10px} +.compareSectionHead h3{margin:0} +.compareWinners{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px} +.compareWinners strong{display:flex;align-items:center;gap:8px} +.compareWinners small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.compareTableWrap{overflow-x:auto} +.compareTable{width:100%;border-collapse:collapse;font-size:var(--fs-13)} +.compareTable th,.compareTable td{padding:6px 10px;text-align:right;border-bottom:1px solid var(--line);white-space:nowrap} +.compareTable th:first-child,.compareTable td:first-child{text-align:left;color:var(--dim);font-weight:var(--fw-semibold)} +.compareTable thead th{font-size:var(--fs-11);color:var(--faint);font-weight:var(--fw-heavy);text-transform:uppercase;letter-spacing:.04em} +.compareTable thead th .compareLetter{margin-right:6px;vertical-align:middle} +.compareTable tr.compareGroup th{padding-top:14px;text-align:left;color:var(--signal-hi);font-size:var(--fs-11);text-transform:uppercase;letter-spacing:.06em;border-bottom-color:var(--line2)} +.compareTable td.text{max-width:150px;overflow:hidden;text-overflow:ellipsis} +.compareTable th,.compareTable td{padding-left:8px;padding-right:8px} +.compareTable tr.differs td:first-child:after{content:"differs";margin-left:8px;font-size:var(--fs-11);font-weight:var(--fw-heavy);text-transform:uppercase;color:var(--warn)} +.compareDelta{display:block;font-size:var(--fs-11);font-weight:var(--fw-bold);color:var(--faint)} +.compareDelta.better{color:var(--good)} +.compareDelta.worse{color:var(--bad)} +.compareBest{margin-left:6px;padding:1px 4px;border-radius:var(--radius-xs);background:rgb(var(--green-rgb)/.14);color:var(--good);font-size:var(--fs-11);font-weight:var(--fw-heavy);text-transform:uppercase;vertical-align:1px} +.compareUnavailable{color:var(--faint)} +.compareCaption{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap} +.compareChart{width:100%;height:auto;display:block} +.compareLegend{display:flex;gap:14px;flex-wrap:wrap;margin-top:8px;font-size:var(--fs-12);color:var(--dim)} +.compareLegend span{display:inline-flex;align-items:center;gap:6px} +.compareLegend i{width:12px;height:3px;border-radius:var(--radius-2xs);background:var(--compare-color)} +.compareLegend i.part{width:10px;height:10px;border-radius:var(--radius-xs)} +.compareBreakdown{display:grid;gap:8px} +.compareBreakdownRow{display:grid;grid-template-columns:28px minmax(0,1fr) 84px;gap:10px;align-items:center} +.compareBreakdownBar{display:flex;height:16px;border-radius:var(--radius-xs);overflow:hidden;background:rgb(var(--tint-rgb)/.04)} +.compareBreakdownBar i{display:block;height:100%} +.compareBreakdownRow b{text-align:right;font-size:var(--fs-13)} +.compareMatches{display:grid;gap:6px} +.compareMatch{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:12px;align-items:center;padding:8px 10px;border:1px solid var(--line);border-radius:var(--radius-md);background:rgb(var(--tint-rgb)/.025)} +.compareMatch .t{font-weight:var(--fw-semibold);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.compareMatch .meta{font-size:var(--fs-12);color:var(--faint)} +.compareMatchActions{display:flex;gap:6px} +.slist{display:flex;flex-direction:column;gap:8px} +.srow{display:grid;grid-template-columns:26px 132px minmax(0,1fr) auto auto 28px;gap:12px;align-items:center;background:linear-gradient(180deg,rgb(var(--panel-rgb)/.94),rgb(var(--bg2-rgb)/.94));border:1px solid var(--line);border-radius:var(--radius-md);padding:12px 14px;cursor:pointer;box-shadow:var(--shadow-soft);transition:border-color .16s,background .16s,transform .16s;min-width:0} +.srow.hasChildren{align-items:start} +.srow:hover{border-color:var(--line2);background:linear-gradient(180deg,rgb(var(--panel2-rgb)/.96),rgb(var(--bg2-rgb)/.96));transform:translateY(-1px)} +.srow.active,.srow.live{border-color:rgb(var(--signal-rgb)/.62);box-shadow:0 0 0 1px rgb(var(--signal-rgb)/.12),var(--shadow-soft)} +.srow .t{font-weight:var(--fw-bold);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.srow .meta{font-size:var(--fs-12);color:var(--faint);margin-top:2px} +.srow .c{font-weight:var(--fw-heavy);font-size:var(--fs-16);text-align:right} +.sMain{min-width:0} +.sbar{height:4px;border-radius:var(--radius-xs);background:rgb(var(--tint-rgb)/.045);overflow:hidden;margin-top:8px} +.sbar i{display:block;height:100%;background:linear-gradient(90deg,var(--accent),var(--signal));border-radius:var(--radius-xs)} +.smetrics{text-align:right;min-width:88px} +.sactions{display:flex;align-items:center;justify-content:flex-end} +.sactions .sessionDelete{margin-top:0} +.smetrics .meta{font-size:var(--fs-11)} +.badge{font-size:var(--fs-11);color:var(--dim);background:rgb(var(--tint-rgb)/.045);border:1px solid var(--line);padding:3px 6px;border-radius:var(--radius-sm);box-shadow:var(--inset)} diff --git a/dashboard/styles/tools.css b/dashboard/styles/tools.css new file mode 100644 index 00000000..321f9e1a --- /dev/null +++ b/dashboard/styles/tools.css @@ -0,0 +1,349 @@ +/* tools-review-workflow-v3 */ +.capStats{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));margin:0 0 12px;padding:0!important;overflow:hidden} +.capStat{display:grid;min-width:0;min-height:88px;align-content:center;gap:2px;padding:12px 16px;border:0;border-left:1px solid var(--line);background:transparent;color:var(--fg);font:inherit;text-align:left;cursor:pointer} +.capStat:first-child{border-left:0} +.capStat:hover{background:rgb(var(--signal-rgb)/.055)} +.capStat.on{background:linear-gradient(145deg,rgb(var(--signal-rgb)/.13),rgb(var(--signal-mid-rgb)/.045));box-shadow:inset 0 -2px 0 var(--accent)} +.capStatLabel{color:var(--dim);font-size:var(--fs-11);font-weight:var(--fw-heavy)} +.capStat strong{font:var(--fw-bold) var(--fs-22)/1.15 var(--font-mono)} +.capStatDetail{overflow-wrap:anywhere;color:var(--faint);font-size:var(--fs-11);line-height:1.35} +.capReviewQueue{margin:0;overflow:hidden;border-color:rgb(var(--signal-rgb)/.3);background:rgb(var(--panel-rgb)/.94)} +.capReviewQueue.good{border-color:rgb(var(--green-rgb)/.3)} +.capReviewQueue.warn{border-color:rgb(var(--orange-rgb)/.42)} +.capQueueHead{display:flex;align-items:flex-start;justify-content:space-between;gap:18px;padding:16px 18px 12px} +.capQueueHead h2{margin:0;font-size:var(--fs-18)} +.capQueueDetail{max-width:780px;margin:4px 0 0;color:var(--dim);font-size:var(--fs-12);line-height:1.45} +.capQueueCount{flex:none;padding-top:2px;color:var(--fg);font:var(--fw-bold) var(--fs-11)/1.4 var(--font-mono)} +.capReviewList{display:grid} +.capQueueLoading,.capQueueEmpty{padding:24px 18px;color:var(--dim);font-size:var(--fs-12)} +.capQueueEmpty h3{margin:0;color:var(--fg);font-size:var(--fs-16)} +.capQueueEmpty p{max-width:760px;margin:4px 0 0;line-height:1.5} +.capQueueEmpty .tbtn{margin-top:12px} +.capReviewHead,.capReviewItem{display:grid;grid-template-columns:minmax(0,1fr) 84px;align-items:start;gap:0 12px;padding:4px 18px;border-bottom:1px solid var(--line)} +.capReviewHead{padding-top:8px;padding-bottom:6px;color:var(--faint);font-size:var(--fs-11);font-weight:var(--fw-heavy);letter-spacing:.04em;text-transform:uppercase} +.capReviewHead>span:last-child{text-align:right} +.capReviewItem:last-child{border-bottom:0} +.capReviewItem>.capSelect{justify-self:end} +.capReviewCols{display:grid;grid-template-columns:12px minmax(0,1fr) 148px 92px 72px 124px;align-items:center;gap:12px;min-width:0} +.capReviewHead .capReviewCols:before{content:""} +.capReviewRow{min-width:0} +.capReviewRow>summary{min-height:36px;border-radius:var(--radius-sm);list-style:none;cursor:pointer} +.capReviewRow>summary::-webkit-details-marker{display:none} +.capReviewRow>summary:before{content:"\25B8";color:var(--faint);font-size:var(--fs-11);transition:transform .15s ease} +.capReviewRow[open]>summary:before{transform:rotate(90deg)} +.capReviewRow>summary:hover{background:rgb(var(--signal-rgb)/.035)} +.capReviewRow>summary:focus-visible{outline:2px solid var(--accent);outline-offset:2px} +.capReviewName{display:flex;align-items:baseline;gap:8px;min-width:0;overflow:hidden;white-space:nowrap} +.capReviewName strong{overflow:hidden;color:var(--fg);font-size:var(--fs-14);text-overflow:ellipsis} +.capReviewName span{flex:none;color:var(--faint);font-size:var(--fs-11)} +.capReviewNum{overflow:hidden;color:var(--fg);font:var(--fw-semibold) var(--fs-12)/1.4 var(--font-mono);font-variant-numeric:tabular-nums;text-overflow:ellipsis;white-space:nowrap} +.capReviewCols .stateDot{color:var(--dim);font-size:var(--fs-11)} +.capSelect{display:inline-flex;min-height:36px;align-items:center;gap:6px;padding:0 8px;border:1px solid var(--line2);border-radius:var(--radius-sm);background:rgb(var(--tint-rgb)/.035);color:var(--fg);font-size:var(--fs-11);font-weight:var(--fw-bold);cursor:pointer} +.capSelect input{width:15px;height:15px;accent-color:var(--warn)} +.capReviewFacts{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:0;margin:0;border-top:1px solid var(--line);border-bottom:1px solid var(--line)} +.capReviewFacts>div{padding:10px 12px;border-left:1px solid var(--line)} +.capReviewFacts>div:first-child{padding-left:0;border-left:0} +.capReviewFacts dt{color:var(--faint);font-size:var(--fs-11);font-weight:var(--fw-heavy);text-transform:uppercase} +.capReviewFacts dd{margin:4px 0 0;color:var(--fg);font-size:var(--fs-12);line-height:1.4} +.capReviewImpact{margin:0;color:var(--dim);font-size:var(--fs-12);line-height:1.5} +.capReviewDisclosureBody{display:grid;gap:6px;margin:4px 0 8px 24px;padding:10px 12px;border:1px solid var(--line);border-radius:var(--radius-md);background:rgb(var(--bg-rgb)/.28);color:var(--dim);font-size:var(--fs-11)} +.capReviewDisclosureBody b{color:var(--fg)} +.capReviewMembers{display:flex;flex-wrap:wrap;gap:4px} +.capReviewMembers span{padding:3px 6px;border:1px solid var(--line);border-radius:var(--radius-xs);background:rgb(var(--tint-rgb)/.025);color:var(--dim);font:var(--fw-semibold) var(--fs-11)/1.3 var(--font-mono)} +.capReviewControl{overflow-wrap:anywhere;color:var(--faint);font:var(--fw-semibold) var(--fs-11)/1.45 var(--font-mono)} +.capSelectionBar{display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:10px 18px;border-top:1px solid rgb(var(--orange-rgb)/.3);background:rgb(var(--orange-rgb)/.06)} +.capSelectionBar[hidden]{display:none} +.capSelectionBar span{margin-right:auto;color:var(--fg);font-size:var(--fs-12);font-weight:var(--fw-bold)} +.capSelectionBar .tbtn{min-height:36px} +.capInventory{margin-top:12px;padding:0!important;overflow:hidden} +.capInventoryHead{display:flex;align-items:center;gap:16px;padding:14px 16px} +.capInventorySummary{display:grid;gap:2px;min-width:0} +.capInventorySummary h2{margin:0;font-size:var(--fs-14)} +.capInventoryCount{margin-left:auto;color:var(--dim);font:var(--fw-bold) var(--fs-11)/1.4 var(--font-mono);white-space:nowrap} +.capInventoryBody{padding:14px 16px 16px;border-top:1px solid var(--line)} +.capCatalog{display:grid;grid-template-columns:minmax(220px,.42fr) minmax(0,1fr);align-items:center;gap:20px;margin:0 0 12px;padding:0 0 12px;border-bottom:1px solid var(--line)} +.capCatalogHead{display:grid;gap:2px} +.capCatalogHead b{font-size:var(--fs-12)} +.capCatalogHead small{color:var(--faint);font-size:var(--fs-11);font-weight:var(--fw-medium)} +.capCatalogHead .subline{margin:3px 0 0} +.capCatalogBody{min-width:0} +.capFilterControl{display:grid;gap:4px;min-width:0} +.capFilterControl>span{color:var(--faint);font-size:var(--fs-11);font-weight:var(--fw-heavy);text-transform:uppercase} +.capFilters{grid-template-columns:minmax(230px,1fr) auto auto minmax(130px,auto);align-items:end;padding:12px;border:1px solid var(--line);border-radius:var(--radius-md);background:rgb(var(--panel-rgb)/.66)} +.capFilters .searchbox,.capFilters select{width:100%;height:38px;background:rgb(var(--bg-rgb)/.58)} +.capFilterStatus{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;min-height:18px;margin:-3px 0 10px;padding:0 2px} +.capFilterStatus .clearFilters{padding:4px 8px} +.capTableWrap{overflow-x:auto;border:1px solid var(--line);border-radius:var(--radius-md)} +.capTableWrap .tbl{min-width:940px} +.capEvidence{display:grid;gap:2px} +.capEvidence strong{color:var(--fg);font-size:var(--fs-12)} +.capEvidence small{color:var(--faint);font-size:var(--fs-11);line-height:1.35} +.stateDot.unknown:before{background:var(--faint)} +.stateDot.observed:before{background:var(--accent)} +.capEmpty,.capMore{padding:28px!important;text-align:center;color:var(--faint)} +.capEmpty button{margin-left:8px} +.capPath{max-width:280px} +.capCatalogHead>span:first-child{display:grid;gap:2px} +@media(max-width:1400px){.capFilters{grid-template-columns:1fr 1fr}.capFilterControl:first-child{grid-column:1/-1}} +@media(max-width:800px){.capReviewFacts{grid-template-columns:1fr}.capReviewFacts>div,.capReviewFacts>div:first-child{padding:8px 0;border-left:0;border-top:1px solid var(--line)}.capReviewFacts>div:first-child{border-top:0}.capReviewHead{display:none}.capReviewCols{grid-template-columns:12px minmax(0,1fr)}.capReviewCols>:nth-child(n+2){grid-column:2}.capReviewDisclosureBody{margin-left:0}.capInventoryHead{align-items:flex-start}.capCatalog{grid-template-columns:1fr;gap:8px}.capCatalogHead{grid-template-columns:minmax(0,1fr) auto;align-items:start}.capCatalogHead .subline{margin:0}.capFilters{grid-template-columns:1fr;padding:10px}.capFilterControl:first-child{grid-column:1}.capFilters .seg{width:100%}.capFilters .seg button{flex:1}.capTableWrap{overflow:visible;border:0;border-radius:0}.capTableWrap .tbl{display:block;min-width:0}.capTableWrap thead{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}.capTableWrap tbody{display:grid;gap:8px}.capTableWrap tr{display:grid;padding:12px;border:1px solid var(--line);border-radius:var(--radius-md);background:var(--spectrum-card);box-shadow:var(--shadow-soft)}.capTableWrap td{display:grid;grid-template-columns:minmax(92px,.38fr) minmax(0,1fr);gap:10px;padding:6px 0;border-bottom:1px solid rgb(var(--tint-rgb)/.055);overflow-wrap:anywhere}.capTableWrap td:last-child{border-bottom:0}.capTableWrap td:before{content:attr(data-label);color:var(--faint);font-size:var(--fs-11);font-weight:var(--fw-heavy);text-transform:uppercase}.capTableWrap td.nm{display:block;padding-top:2px;font-size:var(--fs-16)}.capTableWrap td.nm:before{display:block;margin-bottom:4px}.capTableWrap .capPath{max-width:100%;white-space:normal}.capTableWrap .capEmpty{display:block}.capTableWrap .capEmpty:before{display:none}.capFilterStatus{align-items:stretch;flex-direction:column}.capFilterStatus .clearFilters{align-self:flex-start}.capCatalogBody .legend{gap:8px}} +.capMobileSort{display:none} +@media(max-width:800px){.capMobileSort{display:grid;grid-template-columns:auto minmax(150px,220px);align-items:center;justify-content:end;gap:8px;margin:10px 0;color:var(--faint);font-size:var(--fs-11);font-weight:var(--fw-heavy);text-transform:uppercase}.capMobileSort select{height:38px}.capTableWrap .capMore{display:block}.capTableWrap .capMore:before{display:none}} +@media(max-width:520px){.capStat{min-height:86px;padding:10px 8px}.capStat strong{font-size:var(--fs-20)}.capStatDetail{font-size:var(--fs-11)}.capQueueHead,.capReviewItem,.capQueueEmpty{padding-left:12px;padding-right:12px}.capQueueHead{display:grid}.capQueueCount{padding:0}.capSelect{min-height:44px;justify-content:center}.capSelectionBar{align-items:stretch;flex-wrap:wrap;padding:10px 12px}.capSelectionBar span{width:100%;margin:0}.capSelectionBar .tbtn{min-height:44px;flex:1}.capInventoryHead{padding:12px}.capInventoryBody{padding:12px}.capCatalogHead .subline{display:none}} +.modelChartTip{max-width:min(440px,calc(100% - 16px))} +.modelTipMetrics{grid-template-columns:repeat(2,minmax(0,1fr))} +.spendRangeControls{display:grid;justify-items:end;gap:8px;max-width:100%} +.spendCustom{display:flex;align-items:flex-end;justify-content:flex-end;gap:8px;flex-wrap:wrap} +.spendCustom[hidden]{display:none} +.spendCustom label{display:grid;gap:4px;color:var(--faint);font-size:var(--fs-11);font-weight:var(--fw-heavy);text-transform:uppercase} +.spendCustom input{height:36px;min-width:144px;border:1px solid var(--line2);border-radius:var(--radius-sm);background:rgb(var(--bg-rgb)/.62);padding:0 8px;color:var(--fg);color-scheme:dark;font:var(--fw-semibold) var(--fs-11)/1 var(--font-mono)} +.spendCustom input:focus-visible{border-color:var(--accent);outline:2px solid rgb(var(--signal-rgb)/.2);outline-offset:1px} +.spendCustom #s-range-error{max-width:270px;padding-bottom:8px;color:var(--bad);font-size:var(--fs-11)} +.spendKpis{display:grid;grid-template-columns:1.25fr repeat(3,minmax(0,1fr));gap:12px} +.spendKpi{min-width:0;padding:14px 16px} +.spendKpi .v{margin-top:4px;overflow-wrap:anywhere;font-size:var(--fs-24);letter-spacing:-.02em} +.spendKpi:first-child .v{font-size:var(--fs-28)} +.spendKpi .subline{min-height:16px;margin-top:4px;font-size:var(--fs-11)} +.spendChartCard{margin-top:12px;padding:16px 18px 14px;overflow:hidden} +.spendChartHead{display:flex;align-items:flex-start;justify-content:space-between;gap:18px} +.spendChartHead h2,.spendPanelHead h2,.spendPlatformCard h2{margin:0;font-size:var(--fs-14)} +.spendChartHead p{margin:3px 0 0;color:var(--faint);font-size:var(--fs-12)} +.spendChartControls{display:grid;justify-items:end;gap:8px} +.spendChartControls .seg button{padding:4px 12px;font-size:var(--fs-12)} +.spendChartControls .seg button[aria-disabled=true]{opacity:.42;cursor:not-allowed} +.spendLegend{display:flex;align-items:center;justify-content:flex-end;gap:12px;flex-wrap:wrap;color:var(--dim);font-size:var(--fs-11)} +.spendLegend span{display:inline-flex;align-items:center;gap:6px;white-space:nowrap} +.spendLegend i{width:9px;height:9px;border-radius:var(--radius-2xs);background:var(--spend-color)} +.spendLegend .spendEstimateKey i{border:1px solid rgb(var(--tint-rgb)/.28);background:repeating-linear-gradient(135deg,rgb(var(--tint-rgb)/.38) 0 1px,transparent 1px 4px),rgb(var(--tint-rgb)/.08)} +.spendLegend .spendPartialKey i{border:1px solid rgb(var(--tint-rgb)/.7)} +.spendLegend .spendIncompleteKey i{border:1px dashed rgb(var(--tint-rgb)/.5);background:rgb(var(--tint-rgb)/.14)} +.spendChart{min-height:270px;margin-top:12px;overflow:hidden;display:grid;grid-template-columns:58px minmax(0,1fr);--spend-day-head:22px;--spend-day-foot:24px;--spend-day-gap:3px;--spend-chart-h:260px} +.spendYAxis{height:var(--spend-chart-h);display:flex;flex-direction:column;justify-content:space-between;padding:calc(var(--spend-day-head) + var(--spend-day-gap)) 8px calc(var(--spend-day-foot) + var(--spend-day-gap)) 0;color:var(--faint);font-size:var(--fs-11);text-align:right} +.spendPlot{position:relative;min-width:0;overflow:hidden;border-bottom:1px solid var(--line)} +.spendYAxis span{line-height:0} +.spendGrid{position:absolute;top:calc(var(--spend-day-head) + var(--spend-day-gap));right:0;left:0;height:calc(var(--spend-chart-h) - var(--spend-day-head) - var(--spend-day-foot) - 2*var(--spend-day-gap));background:repeating-linear-gradient(to bottom,rgb(var(--tint-rgb)/.07) 0 1px,transparent 1px 25%);pointer-events:none} +.spendChartInner{position:relative;width:100%;height:var(--spend-chart-h);display:flex;align-items:stretch} +.spendDay{appearance:none;position:relative;display:grid;grid-template-columns:minmax(0,1fr);grid-template-rows:var(--spend-day-head) minmax(0,1fr) var(--spend-day-foot);flex:1 1 0;min-width:0;gap:var(--spend-day-gap);border:0;border-radius:var(--radius-sm);background:transparent;padding:0;color:var(--faint);font:inherit;cursor:pointer} +.spendDay:hover,.spendDay:focus-visible{background:rgb(var(--signal-rgb)/.055);outline:none} +.spendDay:focus-visible{box-shadow:inset 0 0 0 1px rgb(var(--signal-rgb)/.52)} +.spendBarValue{overflow:hidden;color:var(--dim);font-size:var(--fs-11);text-align:center;text-overflow:ellipsis;white-space:nowrap;opacity:0} +.spendDay:hover .spendBarValue{opacity:1} +.spendDayTrack .spendBarValue{flex:none;max-width:100%;padding-bottom:3px;line-height:1.3} +.spendChartInner[data-values=shown] .spendBarValue{opacity:1} +.spendDay[data-peak=true] .spendBarValue{position:relative;z-index:1;max-width:none;overflow:visible;color:var(--fg);font-weight:var(--fw-bold);opacity:1} +.spendDay[data-peak=true][data-peak-edge=first] .spendBarValue{align-self:flex-start} +.spendDay[data-peak=true][data-peak-edge=last] .spendBarValue{align-self:flex-end} +.spendDayTrack{grid-row:2;display:flex;height:100%;min-height:0;flex-direction:column;align-items:center;justify-content:flex-end;border-bottom:1px solid var(--line2)} +.spendDayStack{display:flex;flex:none;width:min(var(--spend-bar-max,24px),var(--spend-bar-share,64%));min-width:2px;height:var(--spend-height);min-height:0;flex-direction:column-reverse;overflow:hidden;transition:opacity .16s,filter .16s} +.spendChartInner[data-granularity] .spendDay:is(:hover,:focus-visible) .spendDayStack{filter:brightness(1.18)} +.spendChartInner[data-dense=true]{--spend-bar-share:52%} +.spendChartInner[data-granularity=week]{--spend-bar-max:44px} +.spendChartInner[data-granularity=month]{--spend-bar-max:64px} +.spendChartInner[data-granularity]:hover .spendDay:not(:hover) .spendDayStack,.spendChartInner[data-granularity]:has(.spendDay:focus-visible) .spendDay:not(:focus-visible) .spendDayStack{opacity:.45} +.spendDay[data-incomplete=true] .spendDayStack{opacity:.6;outline:1px dashed rgb(var(--tint-rgb)/.5);outline-offset:1px} +.spendChartInner[data-granularity] .spendDay[data-incomplete=true]:is(:hover,:focus-visible) .spendDayStack{opacity:.85} +.spendStackSegment{display:block;flex:none;height:var(--segment-share);min-height:1px;background:var(--spend-color)} +.spendStackSegment[data-estimated=true]{background:repeating-linear-gradient(135deg,rgb(var(--tint-rgb)/.32) 0 1px,transparent 1px 4px),var(--spend-color)} +.spendStackSegment[data-partial=true]{box-shadow:inset 0 0 0 1px rgb(var(--tint-rgb)/.32)} +.spendChartInner[data-granularity] .spendStackSegment{opacity:.82;transition:opacity .16s} +.spendChartInner[data-granularity] .spendStackSegment+.spendStackSegment{border-bottom:1px solid rgb(var(--bg2-rgb)/.9)} +.spendChartInner[data-granularity] .spendDay:is(:hover,:focus-visible) .spendStackSegment{opacity:1} +.spendDayLabel{position:absolute;bottom:0;left:50%;width:max-content;max-width:none;transform:translateX(-50%);font-size:var(--fs-11);line-height:20px;text-align:center;white-space:nowrap;pointer-events:none} +.spendDay[data-axis-edge=first] .spendDayLabel{left:0;transform:none;text-align:left} +.spendDay[data-axis-edge=last] .spendDayLabel{right:0;left:auto;transform:none} +.spendAverageLine{position:absolute;z-index:2;right:0;left:0;height:1px;border-top:1px solid rgb(var(--soft-rgb)/.62);pointer-events:none} +.spendAverageLine[hidden]{display:none} +.spendAverageLine span{position:absolute;right:3px;bottom:4px;border-radius:var(--radius-xs);background:rgb(var(--bg2-rgb)/.88);padding:1px 4px;color:var(--soft);font-size:var(--fs-11);line-height:1.35} +.spendChartTip{z-index:8;width:min(250px,calc(100% - 24px));padding:10px 10px;font-size:var(--fs-11);line-height:1.45} +.spendChartTip[hidden]{display:none} +.spendTipDate{color:var(--fg);font-weight:var(--fw-heavy)} +.spendTipTotal{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-top:4px;padding-bottom:6px;border-bottom:1px solid var(--line)} +.spendTipTotal b{color:var(--fg)} +.spendTipNote{margin-top:6px;color:var(--warn);font-size:var(--fs-11)} +.spendTipSpan{margin-top:2px;color:var(--faint);font-size:var(--fs-11)} +.spendTipRuntimes{display:grid;margin-top:4px} +.spendTipRuntime{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:1px 8px;padding:4px 0;border-top:1px solid var(--line)} +.spendTipRuntime:first-child{border-top:0} +.spendTipRuntime>span{display:flex;align-items:center;gap:6px;color:var(--fg);font-weight:var(--fw-bold)} +.spendTipRuntime i{width:8px;height:8px;border-radius:var(--radius-2xs);background:var(--spend-color)} +.spendTipRuntime b{color:var(--fg)} +.spendTipRuntime small{grid-column:1/-1;color:var(--faint);font-size:var(--fs-11)} +.spendTipEmpty{padding:6px 0;color:var(--faint)} +.spendEvidenceGrid{display:grid;grid-template-columns:minmax(0,1.3fr) minmax(300px,.7fr);align-items:stretch;gap:12px;margin-top:12px} +.spendLogsCard,.spendPlatformCard{height:360px;display:flex;min-height:0;flex-direction:column;padding:16px 18px} +.spendPanelHead{display:flex;align-items:flex-start;justify-content:space-between;gap:12px} +.spendPanelHead>div>span,.spendPanelHead>span{display:block;margin-top:2px;color:var(--faint);font-size:var(--fs-11)} +.spendPanelHead>span{flex:none} +.spendLogs{display:grid;min-height:0;flex:1;margin-top:6px;overflow-y:auto;overflow-x:hidden;scrollbar-gutter:stable;scrollbar-color:var(--line2) transparent} +.spendLogRow{height:68px;display:grid;grid-template-columns:28px minmax(0,1fr) auto auto;align-items:center;gap:10px;padding:0;border-top:1px solid var(--line)} +.spendLogRow:first-child{border-top:0} +.spendLogRank{width:24px;height:24px;display:grid;place-items:center;border:1px solid var(--line);border-radius:var(--radius-sm);background:rgb(var(--tint-rgb)/.035);color:var(--faint);font:var(--fw-bold) var(--fs-11)/1 var(--font-mono)} +.spendLogMain{min-width:0} +.spendLogName{overflow:hidden;color:var(--fg);font-weight:var(--fw-bold);text-overflow:ellipsis;white-space:nowrap} +.spendLogMeta{margin-top:1px;overflow:hidden;color:var(--faint);font-size:var(--fs-11);text-overflow:ellipsis;white-space:nowrap} +.spendLogTrack{height:4px;margin-top:6px;overflow:hidden;border-radius:var(--radius-pill);background:rgb(var(--tint-rgb)/.045)} +.spendLogTrack i{display:block;height:100%;border-radius:inherit;background:linear-gradient(90deg,var(--accent),var(--signal-hi))} +.spendLogValue{min-width:84px;text-align:right} +.spendLogValue strong{display:block;font-size:var(--fs-13)} +.spendLogValue span{display:block;margin-top:1px;color:var(--faint);font-size:var(--fs-11)} +.spendLogRow .tbtn{min-height:34px} +.spendLogEmpty,.spendEmpty{min-height:100%;padding:18px} +.spendPlatformBody{min-height:0;flex:1;overflow-y:auto;overflow-x:hidden;scrollbar-gutter:stable;scrollbar-color:var(--line2) transparent} +.spendPlatformRows{display:grid;margin-top:8px} +.spendPlatformRow{display:grid;grid-template-columns:minmax(78px,.48fr) minmax(72px,1fr) minmax(82px,auto);align-items:center;gap:10px;padding:8px 0;border-top:1px solid var(--line)} +.spendPlatformRow:first-child{border-top:0} +.spendPlatformName{display:flex;min-width:0;align-items:center;gap:8px;font-weight:var(--fw-bold)} +.spendPlatformName i{width:9px;height:9px;flex:none;border-radius:var(--radius-2xs);background:var(--spend-color)} +.spendPlatformTrack{height:7px;overflow:hidden;border-radius:var(--radius-pill);background:rgb(var(--tint-rgb)/.045)} +.spendPlatformTrack i{display:block;height:100%;border-radius:inherit;background:var(--spend-color)} +.spendPlatformValue{min-width:82px;text-align:right;font-weight:var(--fw-heavy)} +.spendPlatformValue span{display:block;margin-top:2px;color:var(--faint);font-size:var(--fs-11);font-weight:var(--fw-semibold)} +.spendInsightSection{margin-top:28px} +.spendInsightHead{display:flex;align-items:flex-end;justify-content:space-between;gap:18px;margin-bottom:10px;padding-top:20px;border-top:1px solid var(--line)} +.spendInsightHead h2{margin:0;font-size:var(--fs-18)} +.spendInsightHead p{max-width:760px;margin:4px 0 0;color:var(--dim);font-size:var(--fs-12);line-height:1.5} +.spendInsightRange{flex:none;color:var(--faint);font:var(--fw-semibold) var(--fs-11)/1.4 var(--font-mono)} +.spendInsightGrid{display:grid;grid-template-columns:minmax(290px,.72fr) minmax(0,1.28fr);gap:12px} +.spendConcentrationCard,.spendShapeCard,.spendScatterCard{padding:16px 18px} +.spendInsightCardHead{display:flex;align-items:flex-start;justify-content:space-between;gap:14px} +.spendInsightCardHead h3{margin:0;font-size:var(--fs-14)} +.spendInsightCardHead p{margin:3px 0 0;color:var(--faint);font-size:var(--fs-11);line-height:1.45} +.spendConcentrationValue{margin-top:18px;color:var(--fg);font:var(--fw-heavy) var(--fs-44)/1 var(--font-mono);letter-spacing:-.035em} +.spendConcentrationNote{min-height:34px;margin-top:6px;color:var(--dim);font-size:var(--fs-12);line-height:1.45} +.spendConcentrationBar{height:12px;margin-top:14px;overflow:hidden;border:1px solid var(--line2);border-radius:var(--radius-pill);background:rgb(var(--tint-rgb)/.035)} +.spendConcentrationBar i{display:block;height:100%;width:0;border-radius:inherit;background:linear-gradient(90deg,var(--signal-hi),var(--accent))} +.spendConcentrationLegend{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:6px;color:var(--faint);font-size:var(--fs-11)} +.spendImpactList{display:grid;margin-top:14px;border-top:1px solid var(--line)} +.spendImpactRow{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px;width:100%;align-items:center;border:0;border-bottom:1px solid var(--line);background:transparent;padding:8px 0;color:var(--fg);font:inherit;text-align:left;cursor:pointer} +.spendImpactRow:last-child{border-bottom:0} +.spendImpactRow:hover .spendImpactName,.spendImpactRow:focus-visible .spendImpactName{color:var(--accent)} +.spendImpactRow:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:var(--radius-xs)} +.spendImpactName{min-width:0;overflow:hidden;font-size:var(--fs-12);font-weight:var(--fw-bold);text-overflow:ellipsis;white-space:nowrap} +.spendImpactValue{text-align:right} +.spendImpactValue b{display:block;font-size:var(--fs-12)} +.spendImpactValue small{display:block;margin-top:1px;color:var(--faint);font-size:var(--fs-11)} +.spendShape{margin-top:10px} +.spendShapeHeader,.spendShapeRow{display:grid;grid-template-columns:118px 72px minmax(150px,1fr) minmax(280px,.9fr);align-items:center;gap:12px} +.spendShapeHeader{padding:0 0 6px;color:var(--faint);font-size:var(--fs-11);font-weight:var(--fw-heavy);text-transform:uppercase} +.spendShapeHeader span:last-child{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;text-align:right} +.spendShapeRow{min-height:62px;border-top:1px solid var(--line)} +.spendShapeIdentity b{display:block;font-size:var(--fs-12)} +.spendShapeIdentity small,.spendShapeCoverage{display:block;margin-top:2px;color:var(--faint);font-size:var(--fs-11)} +.spendShapeCoverage{margin:0} +.spendShapeRail{position:relative;height:22px} +.spendShapeRail:before{content:"";position:absolute;top:10px;right:0;left:0;height:2px;border-radius:var(--radius-pill);background:rgb(var(--tint-rgb)/.09)} +.spendShapeIqr{position:absolute;top:7px;left:var(--p25);width:calc(var(--p75) - var(--p25));height:8px;border-radius:var(--radius-pill);background:rgb(var(--signal-rgb)/.3)} +.spendShapeMedian,.spendShapeMean{position:absolute;top:4px;left:var(--median);width:2px;height:14px;border-radius:var(--radius-2xs);background:var(--accent);transform:translateX(-50%)} +.spendShapeMean{top:7px;left:var(--mean);width:7px;height:7px;border:1px solid var(--panel);background:var(--signal-hi);transform:translateX(-50%) rotate(45deg)} +.spendShapeValues{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;text-align:right} +.spendShapeValues span{min-width:0;overflow:hidden;color:var(--dim);font:var(--fw-semibold) var(--fs-11)/1.3 var(--font-mono);text-overflow:ellipsis;white-space:nowrap} +.spendShapeEmpty{min-height:228px} +.spendScatterCard{position:relative;margin-top:12px;overflow:hidden} +.spendScatterControls{display:flex;align-items:center;justify-content:flex-end;gap:12px;flex-wrap:wrap} +.spendScatterCoverage{color:var(--faint);font-size:var(--fs-11)} +.spendScatterWrap{position:relative;margin-top:10px} +.spendScatterSvg{display:block;width:100%;height:340px;border:1px solid var(--line);border-radius:var(--radius-md);background:rgb(var(--bg-rgb)/.28)} +.spendScatterPoint{cursor:pointer;transition:r .14s ease,stroke-width .14s ease} +.spendScatterPoint:hover,.spendScatterPoint:focus{r:8;stroke-width:3} +.spendScatterLink:focus{outline:none} +.spendScatterLink:focus .spendScatterPoint{r:8;stroke:var(--white);stroke-width:3} +.spendScatterTip{z-index:8;width:min(270px,calc(100% - 20px));padding:10px 10px;font-size:var(--fs-11);line-height:1.45} +.spendScatterTip[hidden]{display:none} +.spendScatterTip b{display:block;overflow:hidden;margin-bottom:4px;color:var(--fg);font-size:var(--fs-12);text-overflow:ellipsis;white-space:nowrap} +.spendScatterTip span{display:flex;justify-content:space-between;gap:12px} +.spendScatterTip strong{color:var(--fg);font-weight:var(--fw-bold)} +.spendScatterLegend{display:flex;align-items:center;gap:12px;margin-top:8px;color:var(--faint);font-size:var(--fs-11)} +.spendScatterLegend span{display:inline-flex;align-items:center;gap:4px} +.spendScatterLegend span[hidden]{display:none} +.spendScatterLegend i{width:8px;height:8px;border-radius:var(--radius-round);background:var(--accent)} +.spendScatterLegend i.top{border:2px solid var(--signal-hi);background:transparent} +.spendShapeHeader,.spendShapeRow{grid-template-columns:100px 64px minmax(104px,1fr) minmax(225px,.9fr);gap:8px} +.spendShapeHeader span:last-child i{font-style:normal} +.spendScatterLegend i.spendScatterTopDot{border:2px solid var(--signal-hi);background:transparent} +@media(max-width:900px){.spendRangeControls{width:100%;justify-items:start}.spendKpis{grid-template-columns:repeat(2,minmax(0,1fr))}.spendChartHead{display:grid}.spendLegend{justify-content:flex-start}.spendCustom{justify-content:flex-start}.spendEvidenceGrid,.spendInsightGrid{grid-template-columns:1fr}.spendInsightHead{align-items:flex-start;flex-direction:column}.spendInsightRange{white-space:normal}.spendScatterControls{justify-content:flex-start}} +@media(max-width:700px){.spendShapeHeader{display:none}.spendShapeRow{grid-template-columns:minmax(0,1fr) auto;gap:8px;padding:10px 0}.spendShapeRail,.spendShapeValues{grid-column:1/-1}.spendShapeValues span{display:grid;gap:2px;text-align:left}.spendShapeValues span:before{content:attr(data-label);color:var(--faint);font:var(--fw-bold) var(--fs-11)/1.2 var(--font-sans);text-transform:uppercase}.spendScatterSvg{height:300px}} +@media(max-width:520px){.spendKpis{grid-template-columns:1fr}.spendKpi .v,.spendKpi:first-child .v{font-size:var(--fs-22)}.spendCustom{display:grid;grid-template-columns:1fr 1fr;width:100%}.spendCustom label,.spendCustom input{width:100%;min-width:0}.spendCustom #s-range-error{grid-column:1/-1;padding:0}.spendPlatformRow{grid-template-columns:minmax(0,1fr) auto}.spendPlatformTrack{grid-column:1/-1;grid-row:2}.spendChartCard{padding-inline:12px}.spendChart{grid-template-columns:44px minmax(0,1fr)}.spendYAxis{padding-right:6px;font-size:var(--fs-11)}.spendLogsCard,.spendPlatformCard,.spendConcentrationCard,.spendShapeCard,.spendScatterCard{padding:14px 12px}.spendLogRow{height:86px;grid-template-columns:24px minmax(0,1fr) auto;gap:8px}.spendLogRank{width:22px;height:22px}.spendLogValue{grid-column:2/4;grid-row:2;display:flex;min-width:0;align-items:baseline;justify-content:space-between;gap:10px;text-align:left}.spendLogValue strong,.spendLogValue span{display:block;margin:0}.spendLogRow .tbtn{grid-column:3;grid-row:1;min-height:44px;padding-inline:12px}.spendConcentrationValue{font-size:var(--fs-36)}.spendInsightCardHead{display:grid}.spendScatterControls .seg{width:100%}.spendScatterControls .seg button{flex:1}.spendScatterSvg{height:270px}.spendScatterLegend{align-items:flex-start;flex-direction:column;gap:4px}} +@media(max-width:900px){.spectrumPageActions .modelControls{display:grid;width:100%;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px}.spectrumPageActions .modelControls .filterControl{min-width:0}.spectrumPageActions .spendRangeControls{display:block;width:100%;min-width:0}} +@media(max-width:520px){.spectrumPageActions .modelControls{grid-template-columns:repeat(3,minmax(0,1fr))}} +.currentSessionRuntime:before,.badge.app:before{content:none} +.runtimeMark{display:block;width:16px;height:16px;flex:0 0 auto;overflow:visible;color:currentColor} +.runtimeMark path,.runtimeMark circle,.runtimeMark polygon{vector-effect:non-scaling-stroke} +.currentSessionRuntime .runtimeMark{width:18px;height:18px} +.efficiencyHeadline{min-height:166px;padding:12px 14px 10px} +.efficiencySupportItem{min-height:166px;padding:12px 14px 10px} +.efficiencyHeadline .efficiencyChart{height:56px} +.efficiencySupportItem .efficiencyChart{height:56px} +.deliveryOverview{margin-top:14px;padding:0 20px;overflow:hidden;background:linear-gradient(180deg,rgb(var(--panel-rgb)/.98),rgb(var(--bg2-rgb)/.98));box-shadow:var(--shadow-soft)} +.deliveryOverview:before{content:"";position:absolute;inset:0 0 auto;height:2px;background:linear-gradient(90deg,var(--signal),rgb(var(--signal-hi-rgb)/.58) 42%,transparent 82%)} +.deliveryOverviewHead{min-height:68px;display:flex;align-items:center;justify-content:space-between;gap:20px;padding:14px 0 12px;border-bottom:1px solid var(--line)} +.deliveryEyebrow{display:flex;align-items:center;gap:6px;color:var(--faint);font:var(--fw-bold) var(--fs-11)/1.2 var(--font-mono);text-transform:uppercase;letter-spacing:.045em} +.deliveryOverview h2{display:flex;align-items:baseline;gap:8px;max-width:760px;margin:4px 0 0;color:var(--fg);font-size:var(--fs-20);font-weight:var(--fw-bold);line-height:1.24;letter-spacing:-.018em} +.deliveryOverview h2 .good{color:var(--good)} +.deliveryComparisonDivider{color:var(--line3);font-size:var(--fs-16)} +.deliveryCoverageState{display:inline-flex;flex:none;align-items:center;gap:6px;border:1px solid rgb(var(--green-rgb)/.24);border-radius:var(--radius-pill);background:rgb(var(--green-rgb)/.055);padding:4px 8px;color:var(--dim);font-size:var(--fs-11);font-weight:var(--fw-bold);white-space:nowrap} +.deliveryCoverageState:before{content:"";width:6px;height:6px;border-radius:var(--radius-round);background:var(--good)} +.deliveryCoverageState.partial{border-color:rgb(var(--orange-rgb)/.24);background:rgb(var(--orange-rgb)/.055)} +.deliveryCoverageState.partial:before{background:var(--warn)} +.deliveryMetricGrid{display:grid;grid-template-columns:minmax(0,1.04fr) minmax(0,.94fr) minmax(0,.94fr) minmax(196px,.92fr)} +.deliveryMetric{min-width:0;padding:14px 20px 12px 0} +.deliveryMetric+.deliveryMetric{border-left:1px solid var(--line);padding-right:0;padding-left:20px} +.deliveryMetricLabel{width:max-content;max-width:100%;color:var(--faint);font-size:var(--fs-11);font-weight:var(--fw-heavy);text-transform:uppercase;letter-spacing:.045em} +.deliveryMetricValue{margin-top:4px;overflow:hidden;color:var(--signal-hi);font:var(--fw-heavy) var(--fs-28)/1 var(--font-mono);letter-spacing:-.035em;text-overflow:ellipsis;white-space:nowrap} +.deliveryMetric .trendDelta>span{max-width:11ch} +@media(max-width:1360px){.deliveryMetric .deliveryMetricValue{font-size:var(--fs-24)}} +.deliveryMetricMeta{min-height:15px;margin-top:6px;color:var(--faint);font-size:var(--fs-11);white-space:nowrap} +.deliveryMetricMeta b{color:var(--dim);font-weight:var(--fw-bold)} +.deliveryMetricTop{display:flex;align-items:baseline;justify-content:space-between;gap:12px} +.deliveryMetricTop strong{color:var(--fg);font:var(--fw-heavy) var(--fs-22)/1 var(--font-mono)} +.deliveryCoverageBar{height:7px;margin-top:12px;overflow:hidden;border:1px solid var(--line);border-radius:var(--radius-pill);background:rgb(var(--tint-rgb)/.045)} +.deliveryCoverageBar i{display:block;width:calc(var(--delivery-coverage,0)*1%);height:100%;border-radius:inherit;background:linear-gradient(90deg,var(--signal-mid),var(--good));transition:width .28s ease} +.deliveryCoverageBar.partial i{background:linear-gradient(90deg,var(--signal-mid),var(--warn))} +.deliveryEvidenceStrip{display:flex;min-height:35px;align-items:center;border-top:1px solid var(--line);color:var(--faint);font-size:var(--fs-11)} +.deliveryEvidenceItem{display:flex;align-items:baseline;gap:6px;padding:0 18px;border-left:1px solid var(--line)} +.deliveryEvidenceItem:first-child{padding-left:0;border-left:0} +.deliveryEvidenceItem strong{color:var(--dim);font:var(--fw-bold) var(--fs-11)/1 var(--font-mono)} +.deliveryVisual{margin-top:12px;padding:16px 20px 14px;overflow:hidden;box-shadow:var(--shadow-soft)} +.deliveryVisualHead,.deliveryProjectHead{display:flex;align-items:center;justify-content:space-between;gap:18px} +.deliveryVisualHead h2,.deliveryProjectHead h2{margin:0;font-size:var(--fs-14)} +.deliveryLegend{display:flex;align-items:center;justify-content:flex-end;gap:14px;color:var(--dim);font-size:var(--fs-11)} +.deliveryLegend span{display:inline-flex;align-items:center;gap:6px;white-space:nowrap} +.deliveryLegend span[hidden]{display:none} +.deliveryLegend i{width:12px;height:5px;border-radius:var(--radius-2xs);background:var(--signal)} +.deliveryLegend .deleted{background:var(--c-deleted)} +.deliveryLegend .intensity{height:2px;border-radius:0;background:var(--good)} +.deliveryLegend .typical{height:0;border-top:1px dashed var(--dim);border-radius:0;background:none} +.deliveryLegend .quiet{width:5px;height:12px;border-radius:var(--radius-2xs);background:var(--warn)} +.deliveryChartWrap{position:relative;height:270px;margin-top:8px} +.deliveryChart{display:block;width:100%;height:270px} +.deliveryChart .grid{stroke:var(--line);stroke-width:1} +.deliveryChart .separator{stroke:var(--line2);stroke-width:1} +.deliveryChart .axis{fill:var(--faint);font:var(--fs-11) var(--font-mono)} +.deliveryChart .added{fill:var(--signal)} +.deliveryChart .deleted{fill:var(--c-deleted)} +.deliveryChart .typical{stroke:var(--dim);stroke-width:1;stroke-dasharray:4 4;opacity:.62} +.deliveryChart .quiet{fill:var(--warn);opacity:.85} +.deliveryHitGrid{position:absolute;display:grid;grid-template-columns:repeat(var(--delivery-days),1fr)} +.deliveryHitGrid button{min-width:0;border:0;background:transparent;cursor:crosshair} +.deliveryHitGrid button:focus-visible{outline:1px solid rgb(var(--signal-hi-rgb)/.52);outline-offset:-2px;border-radius:var(--radius-xs)} +.deliveryChartTip{z-index:5;min-width:190px;padding:8px 10px;font-size:var(--fs-11);line-height:1.35;transform:translateX(-50%)} +.deliveryChartTip[hidden]{display:none} +.deliveryChartTip strong{display:block;margin-bottom:4px;color:var(--fg);font-weight:var(--fw-bold)} +.deliveryTipRow{display:flex;justify-content:space-between;gap:18px;margin-top:3px} +.deliveryTipRow b{color:var(--fg);font-family:var(--font-mono);font-weight:var(--fw-bold)} +.deliveryProjects{margin-top:12px;overflow:hidden;box-shadow:var(--shadow-soft)} +.deliveryProjectHead{padding:14px 18px 10px} +.deliveryMobileSort{display:none} +.deliveryTableWrap{overflow:auto} +.deliveryTableWrap .tbl{min-width:720px} +.deliveryTableWrap .tbl th,.deliveryTableWrap .tbl td{padding:10px 16px} +.deliverySort{display:inline-flex;align-items:center;justify-content:flex-end;gap:4px;width:100%;padding:0;border:0;background:none;color:inherit;font:inherit;font-weight:inherit;text-transform:inherit;letter-spacing:inherit;cursor:pointer} +.deliveryTableWrap .tbl th:first-child .deliverySort{justify-content:flex-start} +.deliverySort:hover,.deliverySort:focus-visible{color:var(--fg)} +.deliverySort:focus-visible{outline:1px solid var(--signal);outline-offset:3px;border-radius:var(--radius-2xs)} +.deliverySortMark{width:9px;color:var(--signal-hi);font-size:var(--fs-11);text-align:center} +.deliveryEmpty{min-height:130px;padding:20px} +.deliveryClearStatus{color:var(--faint);font-size:var(--fs-11)} +.deliveryVisualHead p{margin:3px 0 0;color:var(--faint);font-size:var(--fs-11);line-height:1.4} +@media(max-width:1180px){.deliveryOverview{padding-inline:18px}.deliveryMetric{padding-right:16px}.deliveryMetric+.deliveryMetric{padding-left:16px}.deliveryMetricValue{font-size:var(--fs-24)}} +@media(max-width:900px){.deliveryMetricGrid{grid-template-columns:repeat(2,minmax(0,1fr))}.deliveryMetric:nth-child(odd){border-left:0;padding-left:0}.deliveryMetric:nth-child(n+3){border-top:1px solid var(--line)}.deliveryVisualHead{display:grid}.deliveryLegend{justify-content:flex-start}.deliveryChartWrap,.deliveryChart{height:260px}} +@media(max-width:700px){.deliveryMobileSort{display:grid;grid-template-columns:auto minmax(132px,180px);align-items:center;gap:8px;color:var(--faint);font-size:var(--fs-11);font-weight:var(--fw-heavy);text-transform:uppercase}.deliveryMobileSort select{height:32px}.deliveryTableWrap{overflow:visible;padding:0 12px 12px}.deliveryTableWrap .tbl{display:block;min-width:0}.deliveryTableWrap thead{display:none}.deliveryTableWrap tbody{display:grid;gap:8px}.deliveryTableWrap tr{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));padding:10px 12px;border:1px solid var(--line);border-radius:var(--radius-sm);background:rgb(var(--tint-rgb)/.018);box-shadow:var(--shadow-soft)}.deliveryTableWrap .tbl td{display:grid;gap:3px;min-width:0;padding:6px 0;border:0;text-align:left;overflow-wrap:anywhere}.deliveryTableWrap .tbl td:before{content:attr(data-label);color:var(--faint);font-size:var(--fs-11);font-weight:var(--fw-heavy);text-transform:uppercase}.deliveryTableWrap .tbl td:first-child{grid-column:1/-1;margin-bottom:3px;padding-bottom:8px;border-bottom:1px solid var(--line);color:var(--fg);font-weight:var(--fw-bold)}.deliveryTableWrap .tbl td:first-child:before{display:none}.deliveryTableWrap .tbl td[colspan]{display:block;grid-column:1/-1;margin:0;padding:0;border:0}.deliveryTableWrap .tbl td[colspan]:before{display:none}.deliveryTableWrap .deliveryEmpty{min-height:96px}} +@media(max-width:520px){#view-git .spectrumPageActions .modelControls{grid-template-columns:repeat(2,minmax(0,1fr))}.deliveryOverview{padding-inline:14px}.deliveryOverviewHead{align-items:flex-start;flex-direction:column;gap:8px}.deliveryMetricGrid{grid-template-columns:1fr}.deliveryMetric+.deliveryMetric,.deliveryCoverageMetric{border-top:1px solid var(--line);border-left:0;padding-left:0}.deliveryEvidenceStrip{align-items:flex-start;flex-direction:column;padding-block:8px}.deliveryEvidenceItem,.deliveryEvidenceItem:first-child{padding:4px 0;border:0}.deliveryVisual{padding-inline:14px}.deliveryProjectHead{padding-inline:14px}} diff --git a/dashboard/tail.html b/dashboard/tail.html new file mode 100644 index 00000000..0cc1e1b8 --- /dev/null +++ b/dashboard/tail.html @@ -0,0 +1 @@ + diff --git a/page.html b/page.html deleted file mode 100644 index 5200cdc5..00000000 --- a/page.html +++ /dev/null @@ -1,13233 +0,0 @@ - - - -Token Meter - - - - -
-
-
Token Meter
- -
-
- - - -
-
- - -
-
-
-

Sessions

-

Last 30 minutes

-
-
-
Waiting for evidence
-
- -
- - - - -
- -
-
-
-
Spend today--
-
Sessions today--
-
Vs yesterday--
-
- -
-
-
-
- - -
- Waiting -
-
-
-
- - - -
- -
- source - -- - -- - - - - - -
-
-
Started with
-
No user message captured.
-
-
-
-
-
-
-
- -
-
-
Output speed
--
-
Wait time
--
-
Context in use
--
-
Executions
0
-
Tool calls
0
-
Tool-result tokens
--
-
Cache rate
--
-
Cache saved
--
-
Burn / minute
--
-
Cost / task
--
-
- -
Local-only
-
-
- -
-
-
-

Subagents

Track the cost of your custom agents.

-
-
-
-
-
- -
-
-
-

Models

-
-
- -
Models
All models
- -
-
-
-
-

Where your spend goes

--
-
-
-

-
-
-

Model mix over time

-
-
-
-

Speed over time

Models
Top 5 by spend
-
-
-
-
#
-
- Speed & timing diagnostics -
-
Observed output pace
--
waiting for timing evidence
-
Matched pace
--
Select 2 models
-
Typical wait
--
Timing unavailable
-
-
-

Model trends

-
-
-
-
- -
-
-
-

Spend

-
-
- - -
-
-
-
-
-
-

Monthly budget

This month
-
Not configured
-
- -
-
-
- Spent this month -
$0of Not set
-
-
-
Waiting for spend data.
-
-
-
Remaining
--
-
Projected month
--
-
-
-
-
-
- - -
-
-
-
Selected-period spend
--
-
Daily average
--
-
Top platform
--
-
Highest day
--
-
-

Spend goals

-
-

Spend trend

Line = average

-
- -
-
-

Highest-cost logs

-

Platform split

-
-
-

Session economics

Concentration, typical sessions, and outliers.

-
-

Spend concentration

Top 10% of cost-covered sessions.

--
Loading range evidence…
Top decileRemaining sessions
-

Session shape

Loading session distributions…
-
-

Cost and input by active time

SessionTop-decile spend
-
-
- -
-
-
-

Efficiency

-
-
- -
Models
All models
- -
-
-
-
Output / $
--
No comparable prior period
Waiting for covered cost and output.
-
Reasoning ratio
--
No comparable prior period
Reasoning coverage unavailable.
-
-
-
Context load
--
No comparable prior period
input tokens per output token
-
Cache hit ratio
--
No comparable prior period
cache-read share of input
-
-
-
-

Efficiency goals

-
- -
-
-

By model

-
AppReasoning effort
-
-
-
-
-
-

Work

What the spend went into.

-
-
- - - -
-
-
-
Off
Work insights are offTurn on local classification in Settings to label sessions.
-
- - - - - -
-
- -
-

Right-sizing

Savings · est.
-
-
-

Pushback by reasoning effort

-
- -
    -

    -
    -
    View as tables -
    -
    -

    Model tier

    share of spend
    -

    Reasoning effort

    share of spend
    -
    -
    -
    -
    -
    -

    Work goals

    -
    -

    Work breakdown

    -
    -
    -
    -
    -
    -

    When you work

    -
    -

    Session starts

    local time
    -

    Pushback by time of day

    share of follow-ups
    -
    -
    -
    -
    -

    Pushback over time

    -
    -
    -
    -
    -

    Agent : human over time

    -
    -
    -
    -
    -
    -

    Model choices

    Top models by spend
    -
    -
    By kind of work
    -
    -
    -

    Session tags

    -
    -
    -
    Labels are local estimates · text goes only to local Ollama
    -
    -
    -
    -
    -

    Git

    Pushed code × covered spend · local Git only

    -
    -
    - - -
    -
    -
    -
    -
    7 days

    No comparable prior period

    - -
    -
    -
    Pushed lines
    --
    -- added -- deleted
    -
    Spend / 1K
    --
    Waiting for coverage.
    -
    Push yield
    --
    Waiting for output tokens.
    -
    Spend coverage
    --
    -
    -
    -
    Push days--
    -
    Commits--
    -
    Repositories--
    -
    Comparable--
    -
    Deleted share--
    -
    -
    - -

    Delivery goals

    -
    -

    Code pushed by day

    AddedDeleted
    - -
    - -
    -
    -

    Delivery economics

    -
    -

    Signals

    Reading local Git evidence…
    -

    Daily shape

    Reading daily distributions…
    -
    -
    -
    -

    Code by model

    -
    AppEffort
    -
    -
    -

    Projects

    -
    -
    -
    - -
    -

    Learn

    Spend tokens like money. Measure what you get back.

    -
    -
    - -
    -
    -
    -

    Tools

    Tools, MCP servers, and skills.

    -
    -
    -
    - - - -
    -
    -
    -

    Checking review candidates

    - Waiting for evidence -
    -
    Reading local capability evidence…
    - -
    -
    -

    All capabilities

    Waiting for inventory
    -
    -
    -
    Prompt-load evidenceTool definitions in the initial promptWaiting for evidence
    -
    -
    -
    - -
    Type
    -
    State
    - -
    - -
    Loading inventory…
    -
    CapabilityTypeRuntime / sourceConfigurationEvidenceLast observedControl
    Loading local capability inventory…
    -
    -
    -
    - -
    -

    Settings

    Connections, pricing, and updates.

    -
    - -
    -
    -

    Appearance

    Theme for this browser · ⌘K → Theme
    Classic
    -
    -
    -
    -

    Agent connection

    Learn more about skills
    -
    Loading connection status
    Checking local Codex and Claude Code configuration.
    -
    -
    -
    Cost estimates

    Model pricing

    Loading prices
    -
    Built-in rates reviewed Loading sources
    -
    Apply to selected models
    No models selected
    -
    -
    SelectProviderModel idInputOutputCache writeCached input
    Waiting for dashboard data.
    -
    - - - - - - - -
    -
    -
    - -
    -

    Git evidence history

    Scans local pushes every 5 min.
    -
    -
    - -
    -

    Software updates

    Waiting
    -
    - - -
    -
    Waiting for the first update check.
    -
    -
    -
    -
    -
    - - -
    Welcome to Token Meter
    -
    -
    - -
    -
    ESC
    -
    -
    ⌘K opens this anywhere↑↓ move ↵ open
    -
    -
    - -
    -

    New goal

    -
    -
    -
    Metric
    - - -
    Models
    All models
    - - -
    Target
    -
    - - -
    - -
    -
    -
    -
    - -
    -

    Change skill pack?

    -
    -
    -
    Exact control
    - -
    -
    -
    -
    - -
    -

    Disable selected skill packs?

    -
    -
    -
    Exact controls
    - -
    Only selected current review candidates are accepted. MCP servers, built-ins, standalone skills, used groups, and runtime-managed packs are excluded. Successful changes require restarting the IDE or agent.
    -
    -
    -
    - -
    -

    Connect agent?

    -
    -
    -
    Exact local command
    - -
    Only Token Meter’s exact tokenmeter entry is managed. Start a new agent session after the change.
    -
    -
    -
    - -
    -

    Delete session?

    -
    -
    -
    Session
    - -
    -
    -
    -
    - -
    -

    Assign model to this session

    -
    -
    - - -
    This affects only this saved session. It does not change model prices; configure a model rate separately in Settings → Model pricing.
    -
    -
    -
    - -
    - - diff --git a/runtime-manifest.txt b/runtime-manifest.txt index 07b7084c..d42bf020 100644 --- a/runtime-manifest.txt +++ b/runtime-manifest.txt @@ -1,7 +1,7 @@ # Token Meter staged runtime. Paths are relative to the repository root. required runtime-manifest.txt required meter.py -required page.html +required dashboard/page.manifest required performance.html required token_meter_mcp.py required menubar/TokenMeterMenuBar.swift @@ -25,4 +25,5 @@ python-tree token_meter tree menubar tree scripts tree assets +tree dashboard tree agent-skills diff --git a/scripts/check-page b/scripts/check-page new file mode 100755 index 00000000..a033ddf5 --- /dev/null +++ b/scripts/check-page @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Assemble the dashboard from dashboard/ parts and parse its inline JavaScript. +# Prints one line on success; on failure prints Node's parse error. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +page="$(mktemp "${TMPDIR:-/tmp}/token-meter-page.XXXXXX")" +trap 'rm -f "$page"' EXIT + +PYTHONPATH="$ROOT" python3 -m token_meter.dashboard_page "$page" +node -e "const fs=require('fs');const h=fs.readFileSync(process.argv[1],'utf8');const m=h.match(/", 0)), +] + +MANIFEST_HEADER = """\ +# Dashboard page parts, in serving order. The server concatenates these files +# byte for byte into the single page it serves; see token_meter/dashboard_page.py. +# Paths are relative to this directory. +""" + + +def cut_points(lines): + starts = [] + for name, anchor in PARTS: + if anchor is None: + starts.append(0) + continue + prefix, offset = anchor + matches = [index for index, line in enumerate(lines) if line.startswith(prefix)] + if len(matches) != 1: + raise SystemExit(f"{name}: anchor {prefix!r} matched {len(matches)} lines; expected 1") + start = matches[0] + offset + if offset == 0: + while start > 0 and lines[start - 1].lstrip().startswith("//"): + start -= 1 + if start <= starts[-1]: + raise SystemExit(f"{name}: anchor is not after the previous part") + starts.append(start) + return starts + + +def split(page_path, out_dir): + with open(page_path, encoding="utf-8", newline="") as handle: + original = handle.read() + lines = original.splitlines(keepends=True) + starts = cut_points(lines) + ends = starts[1:] + [len(lines)] + for (name, _), start, end in zip(PARTS, starts, ends): + path = os.path.join(out_dir, *name.split("/")) + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8", newline="") as handle: + handle.write("".join(lines[start:end])) + with open(os.path.join(out_dir, "page.manifest"), "w", encoding="utf-8") as handle: + handle.write(MANIFEST_HEADER + "".join(f"{name}\n" for name, _ in PARTS)) + rebuilt = "".join( + open(os.path.join(out_dir, *name.split("/")), encoding="utf-8", newline="").read() + for name, _ in PARTS + ) + if rebuilt != original: + raise SystemExit("parts do not reassemble to the input page") + for (name, _), start, end in zip(PARTS, starts, ends): + size = len("".join(lines[start:end]).encode("utf-8")) + print(f"{size:>8} {name}") + + +def main(argv): + if len(argv) != 3: + raise SystemExit(__doc__) + split(argv[1], argv[2]) + + +if __name__ == "__main__": + main(sys.argv) diff --git a/scripts/start-token-meter b/scripts/start-token-meter index 92c10e30..a556987c 100755 --- a/scripts/start-token-meter +++ b/scripts/start-token-meter @@ -7,9 +7,9 @@ HEALTH_URL="http://127.0.0.1:8722/health" LOG="$ROOT/meter.log" READINESS_TIMEOUT_SECONDS="${TOKEN_METER_READINESS_TIMEOUT_SECONDS:-600}" -if [[ ! -f "$ROOT/page.html" ]]; then - echo "Missing dashboard UI: $ROOT/page.html" >&2 - echo "Run from a full token-meter clone, or copy page.html next to meter.py." >&2 +if [[ ! -f "$ROOT/dashboard/page.manifest" ]]; then + echo "Missing dashboard UI: $ROOT/dashboard/page.manifest" >&2 + echo "Run from a full token-meter clone that includes the dashboard folder." >&2 exit 1 fi @@ -19,8 +19,8 @@ if [[ ! "$READINESS_TIMEOUT_SECONDS" =~ ^[0-9]+$ || "$READINESS_TIMEOUT_SECONDS" fi page_body="$(curl -s --max-time 2 "$ROOT_URL" 2>/dev/null || true)" -if [[ "$page_body" == *"page.html not found next to meter.py"* || "$page_body" == *"page.html is missing"* ]]; then - echo "A stale Token Meter server is already running without page.html." >&2 +if [[ "$page_body" == *"page.html not found next to meter.py"* || "$page_body" == *"page.html is missing"* || "$page_body" == *"The dashboard files are missing"* ]]; then + echo "A stale Token Meter server is already running without its dashboard files." >&2 echo "Stop the listener on port 8722, then rerun: $ROOT/scripts/start-token-meter" >&2 echo "Find it with: lsof -nP -iTCP:8722 -sTCP:LISTEN" >&2 echo "Then stop that PID with: kill " >&2 diff --git a/scripts/style-snapshot.js b/scripts/style-snapshot.js index a30bd017..6aa74b87 100644 --- a/scripts/style-snapshot.js +++ b/scripts/style-snapshot.js @@ -1,4 +1,4 @@ -// Computed-style regression harness for page.html. Paste into the browser +// Computed-style regression harness for the dashboard page. Paste into the browser // console (or a preview eval) on a running dashboard: // await __tmStyle.capture('base') -> stores a snapshot in localStorage // await __tmStyle.diff('base') -> compares the current page to it diff --git a/specs/AGENTS.md b/specs/AGENTS.md index 0268cd30..a643e3b0 100644 --- a/specs/AGENTS.md +++ b/specs/AGENTS.md @@ -22,7 +22,7 @@ Token Meter reads local agent traces, calculates clearly labeled usage estimates | `token_meter/domain/` | Runtime-neutral usage, timing, tools, insights, and aggregates | | `token_meter/domain/compare.py` | Content-free session comparison, cheapest/fastest picks, and same-title matching | | `token_meter/projections.py` | Explicit allowlisted public compatibility projections | -| `page.html` | Entire browser dashboard: markup, styles, routing, and JavaScript | +| `dashboard/` | Browser dashboard source, one file per view: `styles/*.css`, `markup/*.html`, `js/*.js`. `page.manifest` lists the serving order; the server concatenates the parts into one page. Read the one part you need, not the assembled page | | `specs/DESIGN.md` | Visual system: tokens, provider identity, components, readability floor, contract | | `tests/contracts/test_design_system.py` | Design-system contract: tokens, scales, contrast, components, dead CSS, provider colors | | `scripts/style-snapshot.js` | Browser computed-style before/after diff for stylesheet-wide changes | @@ -55,7 +55,7 @@ Token Meter reads local agent traces, calculates clearly labeled usage estimates | `python3 scripts/run-tests` | Run the same suite across one worker per CPU (`-j N` to limit, `-v` for per-test times) | | `python3 -m unittest tests.contracts.test_design_system -v` | Check dashboard styling against the design-system contract | | `PYTHONPYCACHEPREFIX=/private/tmp/token-meter-pycache python3 -m py_compile meter.py token_meter_mcp.py $(find token_meter -type f -name '*.py' -print)` | Compile Python without polluting the repo | -| `node -e "const fs=require('fs');const h=fs.readFileSync('page.html','utf8');const m=h.match(/")] EFFECTS = (Path(__file__).resolve().parents[2] / "assets" / "session-effects.js").read_text() diff --git a/tests/contracts/test_kiro_extension_budget.py b/tests/contracts/test_kiro_extension_budget.py index f983c5f0..b9e65988 100644 --- a/tests/contracts/test_kiro_extension_budget.py +++ b/tests/contracts/test_kiro_extension_budget.py @@ -3,6 +3,7 @@ from pathlib import Path import meter +from tests.dashboard_page import DASHBOARD_PAGE ROOT = Path(__file__).resolve().parents[2] @@ -14,7 +15,7 @@ def test_shared_domain_platform_http_and_generic_clients_need_no_kiro_branch(sel *(ROOT / "token_meter" / "domain").glob("*.py"), *(ROOT / "token_meter" / "platforms").glob("*.py"), ROOT / "token_meter" / "web" / "server.py", - ROOT / "page.html", + Path(DASHBOARD_PAGE), ROOT / "menubar" / "TokenMeterMenuBar.swift", ROOT / "menubar" / "token_meter_tray.py", ] diff --git a/tests/contracts/test_runtime_catalog.py b/tests/contracts/test_runtime_catalog.py index 170dc035..9193b15b 100644 --- a/tests/contracts/test_runtime_catalog.py +++ b/tests/contracts/test_runtime_catalog.py @@ -8,6 +8,7 @@ from token_meter.services.runtime_catalog import runtime_catalog from token_meter.runtimes.registry import RuntimeRegistry from tests.integration.test_application_composition import SyntheticAdapter +from tests.dashboard_page import DASHBOARD_PAGE def descriptor(runtime_id="synthetic", label="Synthetic", @@ -71,7 +72,7 @@ class CatalogAwareClientSourceTests(unittest.TestCase): @classmethod def setUpClass(cls): cls.root = Path(__file__).resolve().parents[2] - cls.page = (cls.root / "page.html").read_text() + cls.page = (Path(DASHBOARD_PAGE)).read_text() cls.swift = (cls.root / "menubar" / "TokenMeterMenuBar.swift").read_text() cls.tray = (cls.root / "menubar" / "token_meter_tray.py").read_text() diff --git a/tests/contracts/test_source_size_budget.py b/tests/contracts/test_source_size_budget.py index dfbc99a4..46705b7f 100644 --- a/tests/contracts/test_source_size_budget.py +++ b/tests/contracts/test_source_size_budget.py @@ -21,7 +21,6 @@ # so normal maintenance still fits. This dict may only shrink. LEGACY_CEILINGS = { "tests/test_meter.py": 1_500_000, - "page.html": 1_325_000, "token_meter/app.py": 663_000, "tests/test_work_insights.py": 236_000, "menubar/TokenMeterMenuBar.swift": 148_000, diff --git a/tests/dashboard_page.py b/tests/dashboard_page.py new file mode 100644 index 00000000..56dde2f8 --- /dev/null +++ b/tests/dashboard_page.py @@ -0,0 +1,37 @@ +"""The assembled dashboard page, for tests that read or execute page source. + +The dashboard source lives in ``dashboard/`` parts. Tests that assert on the +served page, or extract functions from it in Node, read this one assembled +copy, so they see exactly what the server sends. +""" + +import atexit +import os +import shutil +import sys +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from token_meter import dashboard_page # noqa: E402 + +DASHBOARD_MANIFEST = dashboard_page.manifest_path(str(ROOT)) + + +def page_text(): + return dashboard_page.assemble(DASHBOARD_MANIFEST) + + +def _write_assembled_page(): + directory = tempfile.mkdtemp(prefix="token-meter-page-") + atexit.register(shutil.rmtree, directory, True) + path = os.path.join(directory, "page.html") + with open(path, "w", encoding="utf-8", newline="") as handle: + handle.write(page_text()) + return path + + +DASHBOARD_PAGE = _write_assembled_page() diff --git a/tests/integration/test_model_pricing_compat.py b/tests/integration/test_model_pricing_compat.py index 6c5dbe1e..8a289875 100644 --- a/tests/integration/test_model_pricing_compat.py +++ b/tests/integration/test_model_pricing_compat.py @@ -11,6 +11,7 @@ GPT_56_SOL_PRICE_UPDATE_AT, settings_provider_for_model_provider, ) +from tests.dashboard_page import DASHBOARD_PAGE class ModelPricingCacheTests(unittest.TestCase): @@ -92,7 +93,7 @@ def test_google_prices_are_listed_and_editable_in_settings(self): self.assertIn("gemini-2.5-pro", stored["model_pricing"]["google"]) self.assertEqual(quote.input_per_million, 3.0) self.assertEqual(quote.output_per_million, 11.0) - page = (Path(meter.__file__).resolve().parent / "page.html").read_text() + page = (Path(DASHBOARD_PAGE)).read_text() self.assertIn("", page) def _quote(self, path, provider, model, at): diff --git a/tests/test_agent_skills.py b/tests/test_agent_skills.py index 6aba580f..cd7aebc9 100644 --- a/tests/test_agent_skills.py +++ b/tests/test_agent_skills.py @@ -11,6 +11,7 @@ import meter from token_meter import packaging from token_meter.services import agent_skills +from tests.dashboard_page import DASHBOARD_PAGE REPO = Path(__file__).resolve().parents[1] @@ -297,7 +298,7 @@ def test_package_ships_in_the_staged_runtime(self): self.assertIn(f"agent-skills/{SKILL}/SKILL.md", files) def test_dashboard_has_a_separate_skills_button(self): - page = (REPO / "page.html").read_text(encoding="utf-8") + page = (Path(DASHBOARD_PAGE)).read_text(encoding="utf-8") for marker in ("data-agent-skills", "/agent-skills/toggle", "Token Meter skills", "function openAgentSkillsDialog", "agentClientActions", "https://github.com/splunk/token-meter/blob/main/agent-skills/README.md"): diff --git a/tests/test_cache_countdown.py b/tests/test_cache_countdown.py index c72bada0..5a487823 100644 --- a/tests/test_cache_countdown.py +++ b/tests/test_cache_countdown.py @@ -6,6 +6,7 @@ from unittest import mock import meter +from tests.dashboard_page import DASHBOARD_PAGE def source(provider, model=None): @@ -291,7 +292,7 @@ def test_post_route_is_allowlisted_and_action_token_guarded(self): class SurfaceContractTests(unittest.TestCase): @classmethod def setUpClass(cls): - cls.page = (ROOT / "page.html").read_text() + cls.page = (Path(DASHBOARD_PAGE)).read_text() cls.swift = (ROOT / "menubar" / "TokenMeterMenuBar.swift").read_text() def test_session_card_renders_the_ring_before_status_and_ticks_locally(self): diff --git a/tests/test_claude_cost_correctness.py b/tests/test_claude_cost_correctness.py index 4cd001c2..307bcd10 100644 --- a/tests/test_claude_cost_correctness.py +++ b/tests/test_claude_cost_correctness.py @@ -26,6 +26,7 @@ from token_meter.mcp.schema import METRICS from token_meter.projections import projection_bundle from token_meter.runtimes.claude import ClaudeRuntimeAdapter, _normalized_usage +from tests.dashboard_page import DASHBOARD_PAGE def claude_usage( @@ -318,7 +319,7 @@ def test_unsupported_model_billing_dimension_is_unavailable_in_summary(self): self.assertTrue(summary["cost_approx"]) def test_dashboard_cost_breakdown_displays_server_tool_charge(self): - dashboard = Path(meter.__file__).with_name("page.html").read_text() + dashboard = Path(DASHBOARD_PAGE).read_text() self.assertIn("cost.server_tools", dashboard) self.assertIn("$0.01 / request", dashboard) diff --git a/tests/test_codex_model_identity.py b/tests/test_codex_model_identity.py index e728bc2d..d2502761 100644 --- a/tests/test_codex_model_identity.py +++ b/tests/test_codex_model_identity.py @@ -7,6 +7,7 @@ from unittest import mock import meter +from tests.dashboard_page import DASHBOARD_PAGE class CodexModelIdentityTests(unittest.TestCase): @@ -418,7 +419,7 @@ def test_public_projection_drops_untrusted_hint_fields(self): class CodexModelIdentityDashboardContractTests(unittest.TestCase): @classmethod def setUpClass(cls): - cls.page = (Path(__file__).resolve().parents[1] / "page.html").read_text( + cls.page = (Path(DASHBOARD_PAGE)).read_text( encoding="utf-8" ) diff --git a/tests/test_dashboard_page.py b/tests/test_dashboard_page.py new file mode 100644 index 00000000..33a99bd5 --- /dev/null +++ b/tests/test_dashboard_page.py @@ -0,0 +1,108 @@ +import os +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +import meter +from token_meter import dashboard_page +from tests.dashboard_page import DASHBOARD_MANIFEST, ROOT + + +def write_parts(root, parts, manifest_lines=None): + base = Path(root, "dashboard") + for name, text in parts.items(): + path = base / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + lines = manifest_lines if manifest_lines is not None else list(parts) + (base / "page.manifest").write_text("# comment\n\n" + "\n".join(lines) + "\n", encoding="utf-8") + return str(base / "page.manifest") + + +class DashboardAssemblyTests(unittest.TestCase): + def test_parts_concatenate_in_manifest_order_byte_for_byte(self): + with tempfile.TemporaryDirectory() as tmp: + manifest = write_parts(tmp, {"b.html": "\r\n", "a/one.js": "x=1;\n"}, + ["b.html", "a/one.js"]) + self.assertEqual(dashboard_page.assemble(manifest), "\r\nx=1;\n") + + def test_assembly_refreshes_when_a_part_changes(self): + with tempfile.TemporaryDirectory() as tmp: + manifest = write_parts(tmp, {"a.html": "one"}) + self.assertEqual(dashboard_page.assemble(manifest), "one") + part = Path(tmp, "dashboard", "a.html") + part.write_text("two!", encoding="utf-8") + os.utime(part, ns=(1, 1)) + self.assertEqual(dashboard_page.assemble(manifest), "two!") + + def test_manifest_rejects_paths_outside_the_dashboard_folder(self): + for entry in ("../meter.py", "/etc/passwd", "a/../../x", "a\\b.js", "./a.js"): + with self.subTest(entry=entry), tempfile.TemporaryDirectory() as tmp: + manifest = write_parts(tmp, {"a.html": "x"}, [entry]) + with self.assertRaises(ValueError): + dashboard_page.assemble(manifest) + + def test_manifest_rejects_duplicate_and_empty_part_lists(self): + with tempfile.TemporaryDirectory() as tmp: + with self.assertRaises(ValueError): + dashboard_page.assemble(write_parts(tmp, {"a.html": "x"}, ["a.html", "a.html"])) + with self.assertRaises(ValueError): + dashboard_page.assemble(write_parts(tmp, {"a.html": "x"}, [])) + + def test_repository_manifest_lists_every_dashboard_part_exactly_once(self): + base = Path(ROOT, "dashboard") + listed = dashboard_page.part_names(DASHBOARD_MANIFEST) + on_disk = sorted( + path.relative_to(base).as_posix() + for path in base.rglob("*") + if path.is_file() and path.name != "page.manifest" + ) + self.assertEqual(sorted(listed), on_disk) + + def test_assembled_page_is_one_document_with_one_inline_script(self): + page = dashboard_page.assemble(DASHBOARD_MANIFEST) + self.assertTrue(page.startswith("")) + self.assertTrue(page.endswith("\n")) + self.assertEqual(page.count("\n", self.page, re.S).group(1) @@ -25645,7 +25646,7 @@ class ReviewRound4PageTests(unittest.TestCase): @classmethod def setUpClass(cls): root = Path(meter.__file__).resolve().parent - cls.page = (root / "page.html").read_text(encoding="utf-8") + cls.page = Path(DASHBOARD_PAGE).read_text(encoding="utf-8") cls.design = (root / "specs" / "2026-10-29-cost-discounts-design.md").read_text(encoding="utf-8") def test_5_card_names_the_read_only_reason(self): @@ -25672,7 +25673,7 @@ def test_7_design_doc_states_what_a_save_keeps(self): class ReviewRound3PageTests(unittest.TestCase): @classmethod def setUpClass(cls): - cls.page = (Path(meter.__file__).resolve().parent / "page.html").read_text(encoding="utf-8") + cls.page = (Path(DASHBOARD_PAGE)).read_text(encoding="utf-8") def test_q3_first_render_happens_even_when_the_percent_field_has_focus(self): self.assertIn("if(xs?.cost_discounts&&!discountEditInProgress())renderDiscounts(xs.cost_discounts);", self.page) @@ -25693,7 +25694,7 @@ class ReviewRound2PageAndDocsTests(unittest.TestCase): @classmethod def setUpClass(cls): root = Path(meter.__file__).resolve().parent - cls.page = (root / "page.html").read_text(encoding="utf-8") + cls.page = Path(DASHBOARD_PAGE).read_text(encoding="utf-8") cls.docs = {name: (root / "specs" / name).read_text(encoding="utf-8") for name in ("ARCHITECTURE.md", "LOCAL-API.md", "2026-10-29-cost-discounts-design.md")} @@ -25721,7 +25722,7 @@ def test_r6_docs_describe_the_server_wide_host_check_and_vertex_ids(self): class ReviewFixPageTests(unittest.TestCase): @classmethod def setUpClass(cls): - cls.page = (Path(meter.__file__).resolve().parent / "page.html").read_text(encoding="utf-8") + cls.page = (Path(DASHBOARD_PAGE)).read_text(encoding="utf-8") def test_s6_live_refresh_keeps_the_notice_and_card_stable_and_accessible(self): # Round two moved role=status back to the stable container (see R5). @@ -25749,9 +25750,9 @@ class CostDiscountPageTests(unittest.TestCase): @classmethod def setUpClass(cls): - cls.page = (Path(meter.__file__).resolve().parent / "page.html").read_text(encoding="utf-8") + cls.page = (Path(DASHBOARD_PAGE)).read_text(encoding="utf-8") if "id=spend-discounts" not in cls.page: - cls.page = (Path(__file__).resolve().parents[1] / "page.html").read_text(encoding="utf-8") + cls.page = (Path(DASHBOARD_PAGE)).read_text(encoding="utf-8") def section(self, start, end): begin = self.page.index(start) @@ -25839,7 +25840,7 @@ class DeclutterCopyContractTests(unittest.TestCase): @classmethod def setUpClass(cls): - cls.page = Path(meter.__file__).with_name("page.html").read_text() + cls.page = Path(DASHBOARD_PAGE).read_text() def run_node(self, script): result = subprocess.run(["node", "-e", script], capture_output=True, text=True, check=True) @@ -25916,7 +25917,7 @@ class Node{constructor(){this.textContent='';this.innerHTML='';this.hidden=false """ def run_page_node(self, script): - page = str(Path(meter.__file__).with_name("page.html")) + page = str(Path(DASHBOARD_PAGE)) result = subprocess.run(["node", "-e", self.NODE_EXTRACT + script, page], capture_output=True, text=True) if result.returncode: diff --git a/tests/test_work_insights.py b/tests/test_work_insights.py index 2fb58fa1..1881abd4 100644 --- a/tests/test_work_insights.py +++ b/tests/test_work_insights.py @@ -15,6 +15,7 @@ import meter from token_meter.domain import work as domain from token_meter.services import work_insights as W +from tests.dashboard_page import DASHBOARD_PAGE REAL_MEMORY_PROBE = W._default_memory_probe _memory_patch = mock.patch.object(W, "_default_memory_probe", lambda: None) @@ -1751,7 +1752,7 @@ def test_platform_gate_follows_the_platform_service(self): self.assertEqual(meter.work_insights_supported(), meter._PLATFORM_SERVICES.platform_id == "macos") def test_page_hides_work_off_macos(self): - with open(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "page.html"), + with open(DASHBOARD_PAGE, encoding="utf-8") as handle: page = handle.read() for marker in ('id=tab-work data-label=Work aria-label=Work title=Work hidden>', @@ -1771,7 +1772,7 @@ def test_setup_script_refuses_other_platforms(self): self.assertLess(script.index('uname -s'), script.index("command -v python3")) def test_right_sizing_explains_each_flag(self): - with open(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "page.html"), + with open(DASHBOARD_PAGE, encoding="utf-8") as handle: page = handle.read() self.assertNotIn("ooks mismatched", page) @@ -1790,7 +1791,7 @@ def test_pace_choice_round_trips_and_a_chosen_five_survives(self): self.assertFalse(meter.set_work_insights_settings({"rate_per_minute": bad}, self.settings)["ok"]) self.assertIn("auto", meter.work_insights_public_settings(meter.work_insights_settings(self.settings)) ["choices"]["rate_per_minute"]) - with open(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "page.html"), + with open(DASHBOARD_PAGE, encoding="utf-8") as handle: page = handle.read() self.assertIn("", page) @@ -1938,7 +1939,7 @@ class SurfaceContractTests(unittest.TestCase): @classmethod def setUpClass(cls): root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - with open(os.path.join(root, "page.html"), encoding="utf-8") as handle: + with open(DASHBOARD_PAGE, encoding="utf-8") as handle: cls.page = handle.read() with open(os.path.join(root, "menubar", "TokenMeterMenuBar.swift"), encoding="utf-8") as handle: cls.swift = handle.read() @@ -2011,7 +2012,7 @@ def test_work_page_hosts_work_goals_with_kind_of_work_and_range_targets(self): @unittest.skipUnless(shutil.which("node"), "Node.js is required") def test_goal_targets_read_as_a_range_or_a_bound(self): - page = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "page.html") + page = DASHBOARD_PAGE script = """ const fs=require('fs');const page=fs.readFileSync(process.argv[1],'utf8'); function extract(name){let start=page.indexOf(`function ${name}(`);let i=page.indexOf('{',start),depth=0;for(;i', page) def test_a_toggle_save_keeps_unsaved_model_and_area_edits(self): - page_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "page.html") + page_path = DASHBOARD_PAGE script = """ const fs=require('fs');const page=fs.readFileSync(process.argv[1],'utf8'); function extract(name){let start=page.indexOf(`async function ${name}(`);if(start<0)start=page.indexOf(`function ${name}(`);let i=page.indexOf('{',start),depth=0;for(;inodes[id]||(nodes[id]={{innerHTML:''}}); const esc=v=>String(v??'').replace(/[&<>"]/g,c=>({{'&':'&','<':'<','>':'>','"':'"'}})[c]); @@ -2728,7 +2729,7 @@ def test_right_sizing_cards_show_models_reasoning_and_pushback(self): self.assertNotIn("unknown-model", pushback) def test_page_work_filter_ignores_stale_responses(self): - with open(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "page.html"), + with open(DASHBOARD_PAGE, encoding="utf-8") as handle: page = handle.read() apply = page[page.index("async function applyWorkSessionFilter("):page.index("async function loadDelivery(")] @@ -2885,7 +2886,7 @@ def test_labeled_session_with_never_labeled_follow_ups_is_unclear_not_single_sho self.assertEqual(snapshot[service.session_key("s2")]["follow_ups"], 1) def test_page_drill_cells_keep_table_semantics_with_inner_buttons(self): - with open(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "page.html"), + with open(DASHBOARD_PAGE, encoding="utf-8") as handle: page = handle.read() work = page[page.index("function renderWorkSizing("):page.index("function renderWork(payload)")] @@ -3224,7 +3225,7 @@ def test_developer_work_types(self): self.assertEqual(list(W.WORK_TYPES), ["feature", "debug", "refactor", "test", "review", "plan", "explore", "ops", "docs", "other"]) self.assertEqual(set(domain.WORK_TYPE_ORDER), set(W.WORK_TYPES) | {"unclear"}) - with open(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "page.html"), + with open(DASHBOARD_PAGE, encoding="utf-8") as handle: page = handle.read() for key in W.WORK_TYPES: @@ -3568,7 +3569,7 @@ def test_live_notification_setting_is_validated_and_public(self): def test_surfaces_render_hints_completion_and_notifications(self): root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - with open(os.path.join(root, "page.html"), encoding="utf-8") as handle: + with open(DASHBOARD_PAGE, encoding="utf-8") as handle: page = handle.read() with open(os.path.join(root, "menubar", "TokenMeterMenuBar.swift"), encoding="utf-8") as handle: swift = handle.read() @@ -3605,7 +3606,7 @@ def test_model_days_are_aggregated_projected_and_rendered(self): projected = projections.agent_usage_projection(usage) self.assertEqual(projected["model_days"][0]["model"], "gpt-5.6-terra") self.assertFalse(projected["model_days_truncated"]) - with open(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "page.html"), + with open(DASHBOARD_PAGE, encoding="utf-8") as handle: page = handle.read() for marker in ("

    Model trends

    ", "data-subagent-inspect-model", "function openSubagentModelRuns(", @@ -3626,7 +3627,7 @@ def test_low_confidence_area_guesses_count_in_their_area_and_are_tracked(self): class RightSizingColourTests(unittest.TestCase): def test_effort_is_folded_into_three_bands_and_drills_accept_a_level_list(self): - with open(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "page.html"), + with open(DASHBOARD_PAGE, encoding="utf-8") as handle: page = handle.read() self.assertIn("{key:'extra',label:'Extra high+',levels:['xhigh','max','ultra']}", page) diff --git a/token_meter/app.py b/token_meter/app.py index f4c98291..af59cbf4 100644 --- a/token_meter/app.py +++ b/token_meter/app.py @@ -53,6 +53,7 @@ IMPLEMENTATION_FILE = os.path.abspath(__file__) _SOURCE_ROOT = os.path.dirname(os.path.dirname(IMPLEMENTATION_FILE)) +from token_meter import dashboard_page from token_meter.compat import envelope_to_legacy_source, legacy_source_to_envelope from token_meter.contracts import ( AdapterFailure, @@ -14194,8 +14195,8 @@ def page_candidates(): if explicit: paths.append(os.path.abspath(os.path.expanduser(explicit))) paths.extend([ - os.path.join(_SOURCE_ROOT, "page.html"), - os.path.join(os.getcwd(), "page.html"), + dashboard_page.manifest_path(_SOURCE_ROOT), + dashboard_page.manifest_path(os.getcwd()), ]) out, seen = [], set() @@ -14210,12 +14211,28 @@ def page_candidates(): def page_path(): + """The file that identifies the dashboard: its parts manifest, or an explicit page.""" for path in PAGE_CANDIDATES: if os.path.isfile(path): return path return None +def page_root(path): + """Directory that holds the dashboard and its bundled assets.""" + if dashboard_page.is_manifest(path): + return dashboard_page.dashboard_root(path) + return os.path.dirname(path) + + +def page_bytes(path): + """Assembled dashboard bytes, or None when a part is missing or unreadable.""" + try: + return dashboard_page.read_page(path).encode("utf-8") + except (OSError, UnicodeDecodeError, ValueError): + return None + + def performance_page_candidates(): return [ os.path.join(_SOURCE_ROOT, "performance.html"), @@ -14267,7 +14284,7 @@ def dashboard_asset_path(req_path): dashboard = page_path() if not dashboard: return None - path = os.path.join(os.path.dirname(dashboard), *spec[0].split("/")) + path = os.path.join(page_root(dashboard), *spec[0].split("/")) return path if os.path.isfile(path) else None @@ -14291,9 +14308,9 @@ def missing_page_html(): -

    page.html is missing

    -

    Token Meter needs the dashboard file page.html alongside meter.py, or in the directory where you start the server.

    -

    Run from a full repository clone with ./scripts/start-token-meter, or copy page.html from the repo into the same folder as meter.py.

    +

    The dashboard files are missing

    +

    Token Meter needs the dashboard folder, including dashboard/page.manifest and every file it lists, alongside meter.py, or in the directory where you start the server.

    +

    Run from a full repository clone with ./scripts/start-token-meter, or reinstall Token Meter from a complete source checkout.

    Looked in:

      {candidates}
    @@ -14386,10 +14403,11 @@ def do_HEAD(self): self.end_headers() elif is_dashboard_page_path(req_path): path = page_path() - body = b"" if path else missing_page_html().encode() - self.send_response(200 if path else 503) + page = page_bytes(path) if path else None + body = page if page is not None else missing_page_html().encode() + self.send_response(200 if page is not None else 503) self.send_header("Content-Type", "text/html; charset=utf-8") - self.send_header("Content-Length", str(os.path.getsize(path) if path else len(body))) + self.send_header("Content-Length", str(len(body))) self.end_headers() elif (path := dashboard_asset_path(req_path)): self.send_response(200) @@ -14802,8 +14820,9 @@ def do_GET(self): self._send(missing_performance_html(), status=503) elif is_dashboard_page_path(req_path): path = page_path() - if path: - self._send(open(path, encoding="utf-8").read()) + page = page_bytes(path) if path else None + if page is not None: + self._send(page) else: self._send(missing_page_html(), status=503) elif (asset_path := dashboard_asset_path(req_path)): diff --git a/token_meter/dashboard_page.py b/token_meter/dashboard_page.py new file mode 100644 index 00000000..75a3be94 --- /dev/null +++ b/token_meter/dashboard_page.py @@ -0,0 +1,112 @@ +"""Assemble the browser dashboard from its ordered source parts. + +The dashboard ships as one HTML page, but its source lives in ``dashboard/`` +as small files so each view can be read and edited on its own. The order is +``dashboard/page.manifest``; the served page is the exact concatenation of the +listed parts, so no build step or third-party tool is involved. +""" + +import os +import threading + +MANIFEST_RELATIVE = os.path.join("dashboard", "page.manifest") +_MAX_PARTS = 200 + +_cache_lock = threading.Lock() +_cache = {"signature": None, "text": None} + + +def manifest_path(root): + return os.path.join(root, MANIFEST_RELATIVE) + + +def dashboard_root(manifest): + """Return the repository/runtime root that owns ``manifest``.""" + return os.path.dirname(os.path.dirname(os.path.abspath(manifest))) + + +def part_names(manifest): + """Read the ordered part list; reject anything that could leave dashboard/.""" + with open(manifest, encoding="utf-8") as handle: + lines = handle.read().splitlines() + names = [] + for raw in lines: + line = raw.strip() + if not line or line.startswith("#"): + continue + parts = line.split("/") + if ( + os.path.isabs(line) + or "\\" in line + or any(part in ("", ".", "..") for part in parts) + ): + raise ValueError("dashboard manifest entries must be relative paths inside dashboard/") + names.append(line) + if not names or len(names) > _MAX_PARTS: + raise ValueError("dashboard manifest must list between 1 and %d parts" % _MAX_PARTS) + if len(set(names)) != len(names): + raise ValueError("dashboard manifest lists a part twice") + return names + + +def part_paths(manifest): + base = os.path.dirname(os.path.abspath(manifest)) + return [os.path.join(base, *name.split("/")) for name in part_names(manifest)] + + +def _signature(manifest, paths): + stamp = [] + for path in [manifest, *paths]: + info = os.stat(path) + stamp.append((path, info.st_mtime_ns, info.st_size)) + return tuple(stamp) + + +def assemble(manifest): + """Return the full page text; cached until the manifest or any part changes.""" + paths = part_paths(manifest) + signature = _signature(manifest, paths) + with _cache_lock: + if _cache["signature"] == signature: + return _cache["text"] + chunks = [] + for path in paths: + with open(path, encoding="utf-8", newline="") as handle: + chunks.append(handle.read()) + text = "".join(chunks) + with _cache_lock: + _cache["signature"] = signature + _cache["text"] = text + return text + + +def is_manifest(path): + return bool(path) and os.path.basename(path) == os.path.basename(MANIFEST_RELATIVE) + + +def read_page(path): + """Serve either an assembled manifest or an explicit single-file page.""" + if is_manifest(path): + return assemble(path) + with open(path, encoding="utf-8", newline="") as handle: + return handle.read() + + +def main(argv): + """Write the assembled page to a file, or to stdout when no path is given.""" + import sys + + root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + text = assemble(manifest_path(root)) + if len(argv) > 1: + with open(argv[1], "w", encoding="utf-8", newline="") as handle: + handle.write(text) + else: + sys.stdout.write(text) + return 0 + + +if __name__ == "__main__": + import sys + + raise SystemExit(main(sys.argv)) From fa507a32d3d6ea8d580a01b3c44a49e1b8643481 Mon Sep 17 00:00:00 2001 From: Pratik Bhavsar Date: Fri, 6 Nov 2026 08:39:02 +0530 Subject: [PATCH 3/9] fix(work): count dashboard/ scripts as frontend files The dashboard now lives in dashboard/js/*.js. Work evidence classified .js outside a recognized frontend folder as backend, so dashboard edits would have read as backend work. Add dashboard(s)/ to the frontend folders. Co-Authored-By: Claude Code --- tests/test_work_requests.py | 1 + token_meter/domain/work_evidence.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_work_requests.py b/tests/test_work_requests.py index 2c879d2f..2fad753a 100644 --- a/tests/test_work_requests.py +++ b/tests/test_work_requests.py @@ -39,6 +39,7 @@ def test_file_kinds(self): ("tests/test_meter.py", "tests"), ("specs/WORK_INSIGHTS.md", "docs"), ("AGENTS.md", "tooling"), ("scripts/install", "tooling"), (".github/workflows/ci.yml", "infra"), ("src/components/Button.ts", "frontend"), + ("dashboard/js/spend.js", "frontend"), ("notebooks/x.ipynb", "data"), ("Dockerfile", "infra"), ("config.yaml", "config")): self.assertEqual(E.file_kind(path), kind, path) diff --git a/token_meter/domain/work_evidence.py b/token_meter/domain/work_evidence.py index f53a4969..f4a5c73c 100644 --- a/token_meter/domain/work_evidence.py +++ b/token_meter/domain/work_evidence.py @@ -82,7 +82,7 @@ **dict.fromkeys((".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".ico", ".mp4", ".mov", ".pptx", ".key"), "media"), } -_FRONTEND_DIR_RE = re.compile(r"(^|/)(components?|pages?|views?|ui|frontend|client|web|public|static|styles?|app/)" +_FRONTEND_DIR_RE = re.compile(r"(^|/)(components?|pages?|views?|ui|frontend|client|web|public|static|styles?|dashboards?|app/)" r"|\.(component|page|view)\.") _TEST_PATH_RE = re.compile(r"(^|/)(tests?|__tests__|spec)/|(^|/)test_[^/]+$|[._-](test|spec)\.[a-z]+$") _INFRA_PATH_RE = re.compile(r"(^|/)(\.github/workflows|\.circleci|k8s|kubernetes|helm|terraform|infra|deploy|ansible)" From 63eb35d4c92a2de558b7a4473733d68384c16cd4 Mon Sep 17 00:00:00 2001 From: Pratik Bhavsar Date: Fri, 6 Nov 2026 08:52:02 +0530 Subject: [PATCH 4/9] test: split test_meter.py into one module per surface tests/test_meter.py was 1.43 MB with 97 classes, so agents grepped it instead of opening the tests for the surface they changed. Its classes now live in 29 modules under 80 KB each (test_dashboard_*.py, test_pricing.py, test_menubar_tray.py, ...). The 230-method DashboardLayoutTests and the OpenCode tests are split by topic onto shared bases that hold their original setup and helpers (dashboard_layout_support.py, opencode_test_support.py); module-level fixtures move to meter_support.py. Coverage is unchanged: the same 1,034 test methods, byte-identical bodies, and the full suite passes serially and in parallel. Co-Authored-By: Claude Code --- AGENTS.md | 2 +- specs/AGENTS.md | 2 +- tests/contracts/test_source_size_budget.py | 1 - tests/dashboard_layout_support.py | 83 + tests/meter_support.py | 146 + tests/opencode_test_support.py | 156 + tests/runtimes/test_pi_adapter.py | 2 +- tests/test_agent_access.py | 1111 + tests/test_agent_data_contract.py | 1404 + tests/test_builder_recap.py | 992 + tests/test_builder_recap_page.py | 868 + tests/test_cost_discount_flows.py | 811 + tests/test_daily_budgets.py | 620 + tests/test_dashboard_contracts.py | 705 + tests/test_dashboard_efficiency.py | 1063 + tests/test_dashboard_models.py | 877 + tests/test_dashboard_sessions_1.py | 1195 + tests/test_dashboard_sessions_2.py | 296 + tests/test_dashboard_settings.py | 401 + tests/test_dashboard_shell.py | 876 + tests/test_dashboard_spend.py | 849 + tests/test_dashboard_subagents.py | 818 + tests/test_dashboard_tools.py | 210 + tests/test_dashboard_tour.py | 430 + tests/test_health_quota.py | 841 + tests/test_menubar_tray.py | 1036 + tests/test_meter.py | 26005 ------------------- tests/test_model_performance.py | 1417 + tests/test_opencode_sessions.py | 647 + tests/test_opencode_subagents.py | 889 + tests/test_pi_sessions.py | 1286 + tests/test_pricing.py | 1153 + tests/test_review_fixes.py | 928 + tests/test_session_live.py | 781 + tests/test_session_summaries.py | 1297 + tests/test_trace_parsing.py | 999 + tests/test_updates_install.py | 1230 + 37 files changed, 26418 insertions(+), 26009 deletions(-) create mode 100644 tests/dashboard_layout_support.py create mode 100644 tests/meter_support.py create mode 100644 tests/opencode_test_support.py create mode 100644 tests/test_agent_access.py create mode 100644 tests/test_agent_data_contract.py create mode 100644 tests/test_builder_recap.py create mode 100644 tests/test_builder_recap_page.py create mode 100644 tests/test_cost_discount_flows.py create mode 100644 tests/test_daily_budgets.py create mode 100644 tests/test_dashboard_contracts.py create mode 100644 tests/test_dashboard_efficiency.py create mode 100644 tests/test_dashboard_models.py create mode 100644 tests/test_dashboard_sessions_1.py create mode 100644 tests/test_dashboard_sessions_2.py create mode 100644 tests/test_dashboard_settings.py create mode 100644 tests/test_dashboard_shell.py create mode 100644 tests/test_dashboard_spend.py create mode 100644 tests/test_dashboard_subagents.py create mode 100644 tests/test_dashboard_tools.py create mode 100644 tests/test_dashboard_tour.py create mode 100644 tests/test_health_quota.py create mode 100644 tests/test_menubar_tray.py delete mode 100644 tests/test_meter.py create mode 100644 tests/test_model_performance.py create mode 100644 tests/test_opencode_sessions.py create mode 100644 tests/test_opencode_subagents.py create mode 100644 tests/test_pi_sessions.py create mode 100644 tests/test_pricing.py create mode 100644 tests/test_review_fixes.py create mode 100644 tests/test_session_live.py create mode 100644 tests/test_session_summaries.py create mode 100644 tests/test_trace_parsing.py create mode 100644 tests/test_updates_install.py diff --git a/AGENTS.md b/AGENTS.md index 3734aa70..5a8edf9f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,7 @@ Token Meter reads local agent traces, calculates clearly labeled usage estimates | `token_meter/domain/work.py` | Work page aggregation over cached summaries and content-free labels | | `token_meter/domain/work_evidence.py` | Per-request cost slices and action evidence for Work insights | | `token_meter_mcp.py` | Bounded MCP interface; evidence is read-only except explicit confirmed session-budget changes | -| `tests/test_meter.py` | Server, parser, UI-contract, installer, and Swift-source tests | +| `tests/test_*.py` | One module per surface (for example `test_dashboard_spend.py`, `test_menubar_tray.py`, `test_pricing.py`); shared fixtures live in `tests/*_support.py` and `tests/dashboard_page.py`. Run the one module you changed first | | `tests/test_mcp_server.py` | MCP contract and privacy tests | | `runtime-manifest.txt` | Shared source-to-runtime packaging contract | | `scripts/install` | Stage user runtime and install both macOS LaunchAgents | diff --git a/specs/AGENTS.md b/specs/AGENTS.md index a643e3b0..63861834 100644 --- a/specs/AGENTS.md +++ b/specs/AGENTS.md @@ -31,7 +31,7 @@ Token Meter reads local agent traces, calculates clearly labeled usage estimates | `token_meter/domain/work.py` | Work page aggregation over cached summaries and content-free labels | | `token_meter/domain/work_evidence.py` | Per-request cost slices and action evidence for Work insights | | `token_meter_mcp.py` | Bounded MCP interface; evidence is read-only except explicit confirmed session-budget changes | -| `tests/test_meter.py` | Server, parser, UI-contract, installer, and Swift-source tests | +| `tests/test_*.py` | One module per surface (for example `test_dashboard_spend.py`, `test_menubar_tray.py`, `test_pricing.py`); shared fixtures live in `tests/*_support.py` and `tests/dashboard_page.py`. Run the one module you changed first | | `tests/test_mcp_server.py` | MCP contract and privacy tests | | `runtime-manifest.txt` | Shared source-to-runtime packaging contract | | `scripts/install` | Stage user runtime and install both macOS LaunchAgents | diff --git a/tests/contracts/test_source_size_budget.py b/tests/contracts/test_source_size_budget.py index 46705b7f..1f876897 100644 --- a/tests/contracts/test_source_size_budget.py +++ b/tests/contracts/test_source_size_budget.py @@ -20,7 +20,6 @@ # Ceilings are about 5% above each file's size when the budget was introduced, # so normal maintenance still fits. This dict may only shrink. LEGACY_CEILINGS = { - "tests/test_meter.py": 1_500_000, "token_meter/app.py": 663_000, "tests/test_work_insights.py": 236_000, "menubar/TokenMeterMenuBar.swift": 148_000, diff --git a/tests/dashboard_layout_support.py b/tests/dashboard_layout_support.py new file mode 100644 index 00000000..4763c1b4 --- /dev/null +++ b/tests/dashboard_layout_support.py @@ -0,0 +1,83 @@ +"""Shared setup and helpers for the DashboardLayoutTests split.""" + +import unittest +import json +import subprocess +from pathlib import Path +import meter +from tests.dashboard_page import DASHBOARD_PAGE + + +class DashboardLayoutCase(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.page = Path(DASHBOARD_PAGE).read_text() + cls.session_effects = ( + Path(meter.__file__).with_name("assets") / "session-effects.js" + ).read_text() + + CHART_GAP_HARNESS = r""" +const fs=require('fs'); +const page=fs.readFileSync(__PAGE__,'utf8'); +function extract(name){let start=page.indexOf(`function ${name}(`);if(start<0)throw Error(`missing ${name}`);let i=page.indexOf('{',start),depth=0;for(;ieval(`globalThis.${name}=${extract(name)}`));} +function attrs(html,tag,cls){return [...html.matchAll(new RegExp(`<${tag} class=${cls} ([^>]*)/?>`,'g'))].map(match=>Object.fromEntries([...match[1].matchAll(/([\w-]+)="([^"]*)"/g)].map(pair=>[pair[1],pair[2]])));} +""" + + def run_chart_gap_harness(self, body): + script = self.CHART_GAP_HARNESS.replace( + "__PAGE__", json.dumps(str(Path(DASHBOARD_PAGE))), + ) + body + result = subprocess.run(["node", "-e", script], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + return json.loads(result.stdout) + + def _tour_script(self, body): + """Runs the tour's DOM-free block (storage, slide copy, frame markup) in Node with the page's own formatters.""" + page_path = json.dumps(str(Path(DASHBOARD_PAGE))) + return f""" +const fs=require('fs'); +const page=fs.readFileSync({page_path},'utf8'); +const helpers=page.match(/^const (f|compactNumber|money|wholeMoney|pct|countWord|esc)=.*$/gm).join('\\n').replace(/^const /gm,'var '); +eval(helpers); +var LATEST=null;Object.defineProperty(globalThis,'navigator',{{value:{{platform:'',userAgent:''}},configurable:true,writable:true}}); +var providerColor=id=>`var(--provider-${{id}})`,runtimeMarkSvg=()=>'',compareColor=i=>['var(--c-a)','var(--c-b)'][i%2]; +var modelRankedColors=groups=>new Map(groups.map((group,index)=>[group.key,`shade-${{index}}`])); +var stored=new Map(),blocked={{read:false,write:false}}; +var localStorage={{getItem:key=>{{if(blocked.read)throw Error('blocked');return stored.get(key)??null;}},setItem:(key,value)=>{{if(blocked.write)throw Error('quota');stored.set(key,String(value));}}}}; +const start=page.indexOf('// deck-pure-start'),end=page.indexOf('// deck-pure-end'); +if(start<0||end<0)throw Error('missing deck-pure block'); +eval(page.slice(start,end).replace(/^const /gm,'var ')); +const richState={{ + total_tokens:82702828,total_cost:38.59,turns:703,provider:'claude',primary_model:'claude-opus-5-5', + context:{{latest:112214,peak:325998,window:200000,latest_pct:.56}}, + xsession:{{ + total_sessions:2179,model_mix:[{{model:'gpt-5.6-sol',runtime:'Codex',providers:['codex'],cost:4278}}], + capabilities:{{summary:{{definitions:{{definition_tokens:120000,unused_eager_definition_tokens:83907}}}}}}, + agent_usage:{{totals:{{agents:646,cost_covered_agents:640,known_cost:1001.45}}}}, + budget:{{month:'2026-10',configured:true,budget:8940,spend:1792.7,percent:.2,projection_ready:true,projected_spend:3704.9,state:'on_track'}}, + }}, +}}; +const frameSlides=DECK_SLIDES.map((slide,index)=>({{slide,index}})).filter(row=>row.slide.frame); +{body} +""" + + def _run_tour(self, body): + completed = subprocess.run( + ["node", "-e", self._tour_script(body)], capture_output=True, text=True, + ) + self.assertEqual(completed.returncode, 0, completed.stderr) + return json.loads(completed.stdout) + + @staticmethod + def page_function(page, name): + start = page.index("function {}(".format(name)) + depth = 0 + for index in range(start, len(page)): + if page[index] == "{": + depth += 1 + elif page[index] == "}": + depth -= 1 + if depth == 0: + return page[start:index + 1] + raise AssertionError(name) diff --git a/tests/meter_support.py b/tests/meter_support.py new file mode 100644 index 00000000..e8189f3c --- /dev/null +++ b/tests/meter_support.py @@ -0,0 +1,146 @@ +"""Shared fixtures for the server, dashboard, and runtime test modules.""" + +import unittest +import contextlib +import http.client +import json +import sqlite3 +import tempfile +import threading +from pathlib import Path +from unittest import mock +import meter + + +class _TrackingSqliteConnection: + """Wrap a real sqlite3 connection and record whether close() ever ran.""" + + def __init__(self, connection): + object.__setattr__(self, "_connection", connection) + object.__setattr__(self, "closed", False) + + def __getattr__(self, name): + return getattr(self._connection, name) + + def __setattr__(self, name, value): + setattr(self._connection, name, value) + + def close(self): + object.__setattr__(self, "closed", True) + return self._connection.close() + + def __enter__(self): + self._connection.__enter__() + return self + + def __exit__(self, exc_type, exc, tb): + return self._connection.__exit__(exc_type, exc, tb) + + +def isolated_pricing_state(): + """Isolate module-level repricing state for tests that run the watcher. + + Another test can leave a stale pricing revision or a running recalculation; + the watcher would then start a real background reprice or skip its + cross-session refresh. Use as a context manager around ``meter.watcher()``. + """ + stack = contextlib.ExitStack() + stack.enter_context(mock.patch.dict( + meter._pricing_state, {"cache_revision": None, "fingerprint_key": None, "fingerprint": None})) + stack.enter_context(mock.patch.dict( + meter._pricing_recalc, {"running": False, "pending": False, "thread": None})) + stack.enter_context(mock.patch.object(meter, "pricing_watch_tick", return_value=False)) + return stack + + +class LocalApiHttpBase(unittest.TestCase): + """A loopback server with temp settings and API token for the local API tests.""" + + def setUp(self): + meter._reset_cost_discount_cache() + self.addCleanup(meter._reset_cost_discount_cache) + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + self.tmp = Path(self._tmp.name) + self.settings = self.tmp / "settings.json" + self.token_path = self.tmp / "api-token" + self.server = meter.TokenMeterHTTPServer(("127.0.0.1", 0), meter.H) + self.port = self.server.server_port + thread = threading.Thread(target=self.server.serve_forever, daemon=True) + thread.start() + + def stop(): + self.server.shutdown() + self.server.server_close() + thread.join(timeout=5) + self.addCleanup(stop) + self.scheduled = mock.Mock(return_value="scheduled") + for patcher in ( + mock.patch.object(meter, "TOKEN_METER_SETTINGS", str(self.settings)), + mock.patch.object(meter, "TOKEN_METER_API_TOKEN", str(self.token_path)), + mock.patch.object(meter, "PORT", self.port), + mock.patch.object(meter, "schedule_pricing_recalculation", self.scheduled), + mock.patch.object(meter, "STATE", {"ok": True, "xsession": {"pricing_revision": "p_old"}}), + mock.patch.dict(meter._pricing_recalc, {"running": False, "pending": False}), + ): + patcher.start() + self.addCleanup(patcher.stop) + self.token = meter.ensure_api_token() + + def request(self, method, path, body=None, *, bearer=True, action=False, host=None, + origin=None, client=None, raw=None, content_type="application/json"): + conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10) + data = raw if raw is not None else (json.dumps(body).encode() if body is not None else b"") + headers = {"Host": host or "127.0.0.1:{}".format(self.port)} + if data or method == "POST": + headers.update({"Content-Type": content_type, "Content-Length": str(len(data))}) + if bearer: + headers["Authorization"] = "Bearer " + (bearer if isinstance(bearer, str) else self.token) + if action: + headers["X-Token-Meter-Action"] = meter._ACTION_TOKEN + if origin: + headers["Origin"] = origin + if client is not None: + headers["X-Token-Meter-Client"] = client + conn.putrequest(method, path, skip_host=True) + for key, value in headers.items(): + conn.putheader(key, value) + conn.endheaders() + if data: + conn.send(data) + response = conn.getresponse() + text = response.read().decode() + conn.close() + try: + return response.status, json.loads(text), text + except json.JSONDecodeError: + return response.status, None, text + + +_REAL_SCHEDULE = meter.schedule_pricing_recalculation +_REAL_REFRESH = meter.refresh_cross_session_state + + +class DiscountPricingFixture(unittest.TestCase): + """Temp settings file with discount rules for pricing behaviour tests.""" + + T0 = 1_788_000_000 + + def setUp(self): + meter._reset_cost_discount_cache() + self.addCleanup(meter._reset_cost_discount_cache) + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + self.tmp = Path(self._tmp.name) + self.path = self.tmp / "settings.json" + patcher = mock.patch.object(meter, "TOKEN_METER_SETTINGS", str(self.path)) + patcher.start() + self.addCleanup(patcher.stop) + self.rules() + + def rules(self, *rules): + self.path.write_text(json.dumps({"cost_discounts": {"version": 1, "rules": [ + {"id": "d_rule{:04d}".format(index), "price_list": price_list, "channel": channel, + "percent": percent, "start": start, "end": None} + for index, (price_list, channel, percent, start) in enumerate(rules)]}})) + meter._reset_cost_discount_cache() diff --git a/tests/opencode_test_support.py b/tests/opencode_test_support.py new file mode 100644 index 00000000..501f24e7 --- /dev/null +++ b/tests/opencode_test_support.py @@ -0,0 +1,156 @@ +"""Shared setup and helpers for the OpenCodeTests split.""" + +import unittest +import json +import sqlite3 +import time +from pathlib import Path +from unittest import mock +import meter + + +class OpenCodeCase(unittest.TestCase): + """OpenCode is discovered from its read-only SQLite database.""" + + def _build_db(self, root): + db_path = Path(root) / "opencode.db" + conn = sqlite3.connect(db_path) + conn.execute("CREATE TABLE session (id TEXT PRIMARY KEY, project_id TEXT, parent_id TEXT, " + "slug TEXT, directory TEXT, title TEXT, version TEXT, time_created INTEGER, " + "time_updated INTEGER, time_archived INTEGER, agent TEXT, model TEXT, cost REAL, " + "tokens_input INTEGER, tokens_output INTEGER, tokens_reasoning INTEGER, " + "tokens_cache_read INTEGER, tokens_cache_write INTEGER)") + conn.execute("CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT, time_created INTEGER, " + "time_updated INTEGER, data TEXT)") + conn.execute("CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT, session_id TEXT, " + "time_created INTEGER, time_updated INTEGER, data TEXT)") + conn.execute("CREATE TABLE todo (id TEXT PRIMARY KEY, session_id TEXT)") + return conn + + def _session_row(self, sid, directory, title, model, cost, inp, out, rea, cr, cw, + updated, parent_id=None, archived=None, agent="build", + provider_id="opencode-go"): + return { + "id": sid, "project_id": "proj", "parent_id": parent_id, "slug": sid, + "directory": directory, "title": title, "version": "1.18.14", + "time_created": updated - 1000, "time_updated": updated, + "time_archived": archived, "agent": agent, + "model": json.dumps({"id": model, "providerID": provider_id, "variant": "high"}), + "cost": cost, "tokens_input": inp, "tokens_output": out, + "tokens_reasoning": rea, "tokens_cache_read": cr, "tokens_cache_write": cw, + } + + def _message(self, mid, sid, data, created): + return (mid, sid, created, created, json.dumps(data)) + + def _pricing_state(self, root, model, provider_id, cache_read=5000, + prices=None, cost=0.0123, messages=True): + root = Path(root) + base = 1_784_548_900_000 + conn = self._build_db(root) + row = self._session_row("ses_priced", "/repo", "Priced", model, + cost, 1000, 200, 0, cache_read, 0, + base, provider_id=provider_id) + conn.execute("INSERT INTO session VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + tuple(row.values())) + if messages: + conn.execute("INSERT INTO message VALUES (?,?,?,?,?)", ( + "msg_priced", "ses_priced", base, base + 500, json.dumps({ + "role": "assistant", "modelID": model, "providerID": provider_id, + "time": {"created": base, "completed": base + 500}, + "tokens": {"input": 1000, "output": 200, "reasoning": 0, + "cache": {"read": cache_read, "write": 0}}, + "cost": cost, "finish": "stop", + }), + )) + conn.commit() + conn.close() + settings = Path(root) / "settings.json" + settings.write_text(json.dumps({"model_pricing": prices or {}})) + with mock.patch.object(meter, "OPENCODE_DB", str(root / "opencode.db")), \ + mock.patch.object(meter, "TOKEN_METER_SETTINGS", str(settings)), \ + mock.patch.object(meter, "_opencode_native_adapters", {}): + source = meter.opencode_session_sources()[0] + return meter.recompute_opencode(source) + + def _insert_sessions(self, conn, rows): + for row in rows: + conn.execute( + "INSERT INTO session VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + (row["id"], row["project_id"], row["parent_id"], row["slug"], + row["directory"], row["title"], row["version"], row["time_created"], + row["time_updated"], row["time_archived"], row["agent"], row["model"], + row["cost"], row["tokens_input"], row["tokens_output"], + row["tokens_reasoning"], row["tokens_cache_read"], + row["tokens_cache_write"]), + ) + + def _child_agent_row(self, source): + """Return the single spawned agent record a child session emits.""" + meter._opencode_compatibility.cache_clear() \ + if hasattr(meter._opencode_compatibility, "cache_clear") else None + row = meter.session_summary(source) + records = row.get("_agent_records") or [] + self.assertEqual(len(records), 1, records) + return records[0] + + def _assistant(self, conn, mid, sid, cost, created, inp=10, out=5): + conn.execute("INSERT INTO message VALUES (?,?,?,?,?)", ( + mid, sid, created, created + 500, json.dumps({ + "role": "assistant", "modelID": "model-a", "providerID": "opencode-go", + "time": {"created": created, "completed": created + 500}, + "tokens": {"input": inp, "output": out, "reasoning": 0, + "cache": {"read": 0, "write": 0}}, + "cost": cost, + }), + )) + + def _family_db(self, root, base, rows, messages=True): + """Insert sessions plus one assistant message carrying each row's cost.""" + conn = self._build_db(root) + self._insert_sessions(conn, rows) + if messages: + for index, row in enumerate(rows): + self._assistant(conn, "m-" + row["id"], row["id"], row["cost"], + base + index) + conn.commit() + conn.close() + return root / "opencode.db" + + def _cross(self, db_path, sources=None): + saved = dict(meter._xsess) + try: + meter._xsess["data"], meter._xsess["at"] = None, 0 + meter._summary_cache.clear() + with mock.patch.object(meter, "OPENCODE_DB", str(db_path)), \ + mock.patch.object(meter, "capability_inventory", return_value={}): + sources = meter.opencode_session_sources() if sources is None else sources + cross = meter.cross_session(sources=sources) + family_rows = list(meter._xsess.get("sessions") or []) + return sources, cross, family_rows + finally: + meter._xsess.clear() + meter._xsess.update(saved) + meter._summary_cache.clear() + + def _sample_messages(self): + base = 1_784_548_800_000 + return [ + self._message("msg1", "ses_top", { + "role": "assistant", "agent": "build", "modelID": "deepseek-v4-pro", + "providerID": "opencode-go", + "tokens": {"input": 1000, "output": 200, "reasoning": 50, + "cache": {"write": 0, "read": 300}}, + "cost": 0.01, "finish": "tool-calls", + "time": {"created": base, "completed": base + 5000}, + }, base), + self._message("msg2", "ses_top", { + "role": "assistant", "agent": "build", "modelID": "deepseek-v4-pro", + "providerID": "opencode-go", + "tokens": {"input": 500, "output": 100, "reasoning": 0, + "cache": {"write": 0, "read": 0}}, + "cost": 0.005, "finish": "turn_complete", + "time": {"created": base + 60_000, "completed": base + 70_000}, + }, base + 60_000), + self._message("msg3", "ses_top", {"role": "user", "text": "hi"}, base + 10_000), + ] diff --git a/tests/runtimes/test_pi_adapter.py b/tests/runtimes/test_pi_adapter.py index 612d3bd0..31c849a5 100644 --- a/tests/runtimes/test_pi_adapter.py +++ b/tests/runtimes/test_pi_adapter.py @@ -342,7 +342,7 @@ def test_subagent_model_id_treats_a_bare_word_after_at_as_an_account(self): # A model id's "@" introduces a version or a tag, never an account, so # a bare word such as "user@host" is rejected. This is the shipped # sanitizer table's privacy invariant: see - # test_meter.PiSubagentTests.test_child_model_sanitizer_table. + # test_pi_sessions.PiSubagentTests.test_child_model_sanitizer_table. rejected = ( "user@host", "user@internal", diff --git a/tests/test_agent_access.py b/tests/test_agent_access.py new file mode 100644 index 00000000..f24608cf --- /dev/null +++ b/tests/test_agent_access.py @@ -0,0 +1,1111 @@ +import unittest +import json +import os +import subprocess +import tempfile +import time +from pathlib import Path +from unittest import mock +import meter +from tests.dashboard_page import DASHBOARD_PAGE + + +class AgentAccessTests(unittest.TestCase): + _real_app_running = staticmethod(meter.claude_app_running) + + def setUp(self): + patcher = mock.patch.object(meter, "claude_app_running", return_value=False) + patcher.start() + self.addCleanup(patcher.stop) + + def test_client_environment_prepends_wrapper_runtime_directory(self): + with mock.patch.dict(meter.os.environ, {"PATH": "/usr/bin:/bin"}, clear=False): + env = meter.agent_client_environment("/Users/test/.nvm/versions/node/v24/bin/codex") + self.assertEqual(env["PATH"].split(":" )[0], "/Users/test/.nvm/versions/node/v24/bin") + self.assertIn("/usr/bin", env["PATH"].split(":")) + + def test_client_environment_keeps_symlink_directory_for_sibling_node(self): + with tempfile.TemporaryDirectory() as tmp: + wrapper_dir = Path(tmp) / "node" / "bin" + target_dir = Path(tmp) / "package" / "bin" + wrapper_dir.mkdir(parents=True) + target_dir.mkdir(parents=True) + target = target_dir / "codex.js" + target.write_text("#!/usr/bin/env node\n") + wrapper = wrapper_dir / "codex" + wrapper.symlink_to(target) + env = meter.agent_client_environment(str(wrapper)) + self.assertEqual(env["PATH"].split(":")[0], str(wrapper_dir)) + + def test_client_discovery_checks_user_bin_when_service_path_is_minimal(self): + with tempfile.TemporaryDirectory() as tmp: + binary = Path(tmp) / ".local" / "bin" / "claude" + binary.parent.mkdir(parents=True) + binary.write_text("#!/bin/sh\n") + binary.chmod(0o755) + with mock.patch.object(meter.os.path, "expanduser", + side_effect=lambda value: tmp if value == "~" else value): + found = meter.agent_client_executable("claude", which=lambda name: None) + self.assertEqual(found, str(binary)) + + def test_connection_commands_use_fixed_vectors_and_user_scope(self): + launcher = "/Applications/Token Meter/bin/token-meter-mcp" + codex = meter.agent_access_command("codex", True, launcher=launcher, cli_path="/usr/bin/codex") + claude = meter.agent_access_command("claude", True, launcher=launcher, cli_path="/usr/bin/claude") + self.assertEqual(codex, ["/usr/bin/codex", "mcp", "add", "--env", + "TOKEN_METER_CALLER=codex", "tokenmeter", "--", launcher]) + self.assertEqual(claude, ["/usr/bin/claude", "mcp", "add", "--transport", "stdio", + "--scope", "user", "tokenmeter", "--env", + "TOKEN_METER_CALLER=claude", "--", launcher]) + + def test_codex_status_requires_exact_launcher_and_caller_environment(self): + with tempfile.TemporaryDirectory() as tmp: + launcher = Path(tmp) / "token-meter-mcp" + launcher.write_text("#!/bin/sh\n") + launcher.chmod(0o755) + + class Completed: + returncode = 0 + stdout = json.dumps({"enabled": True, "transport": { + "type": "stdio", "command": str(launcher), "args": [], + "env": {"TOKEN_METER_CALLER": "codex"}, + }}) + stderr = "" + + status = meter.agent_access_client_status( + "codex", launcher=str(launcher), runner=lambda *a, **k: Completed(), + which=lambda name: f"/usr/bin/{name}") + self.assertTrue(status["connected"]) + self.assertFalse(status["conflict"]) + self.assertNotIn("/usr/bin/codex", status["connect_command"]) + + def test_windows_codex_status_check_does_not_create_a_console_window(self): + with tempfile.TemporaryDirectory() as tmp: + launcher = Path(tmp) / "token-meter-mcp" + launcher.write_text("#!/bin/sh\n") + launcher.chmod(0o755) + observed = {} + + def runner(command, **kwargs): + observed.update(kwargs) + return meter.subprocess.CompletedProcess(command, 1, "", "missing") + + windows = meter.platform_services( + "windows", environment={"USERPROFILE": r"C:\Users\example"}, + home=r"C:\Users\example", + ) + with mock.patch.object(meter, "_PLATFORM_SERVICES", windows): + meter.agent_access_client_status( + "codex", launcher=str(launcher), runner=runner, + which=lambda name: fr"C:\Tools\{name}.exe", + ) + + self.assertEqual(observed["creationflags"], 0x08000000) + self.assertTrue(observed["close_fds"]) + + def test_windows_connection_change_does_not_create_a_console_window(self): + before = {"label": "Codex", "detected": True, "available": True, + "configured": False, "connected": False, "conflict": False} + after = {**before, "configured": True, "connected": True} + states = iter((before, after)) + observed = {} + + def runner(command, **kwargs): + observed.update(kwargs) + return meter.subprocess.CompletedProcess(command, 0, "", "") + + windows = meter.platform_services( + "windows", environment={"USERPROFILE": r"C:\Users\example"}, + home=r"C:\Users\example", + ) + with mock.patch.object(meter, "_PLATFORM_SERVICES", windows), \ + mock.patch.object(meter, "agent_access_launcher", + return_value=r"C:\Token Meter\token-meter-mcp.cmd"), \ + mock.patch.object(meter, "agent_client_executable", + return_value=r"C:\Tools\codex.exe"): + result = meter.set_agent_access( + "codex", True, runner=runner, + status_getter=lambda client: next(states), + ) + + self.assertTrue(result["ok"]) + self.assertEqual(observed["creationflags"], 0x08000000) + self.assertTrue(observed["close_fds"]) + + def test_claude_status_reads_only_the_user_scope_entry(self): + with tempfile.TemporaryDirectory() as tmp: + launcher = Path(tmp) / "token-meter-mcp" + launcher.write_text("#!/bin/sh\n") + launcher.chmod(0o755) + config = Path(tmp) / ".claude.json" + config.write_text(json.dumps({"mcpServers": {"tokenmeter": { + "type": "stdio", "command": str(launcher), "args": [], + "env": {"TOKEN_METER_CALLER": "claude"}, + }}})) + status = meter.agent_access_client_status( + "claude", launcher=str(launcher), claude_config_path=str(config), + which=lambda name: f"/usr/bin/{name}", claude_desktop_config_paths=[]) + self.assertTrue(status["connected"]) + self.assertEqual(status["label"], "Claude") + self.assertEqual(status["status"], "Connected to Claude Code") + + def test_different_existing_entry_is_reported_as_conflict(self): + with tempfile.TemporaryDirectory() as tmp: + launcher = Path(tmp) / "token-meter-mcp" + launcher.write_text("#!/bin/sh\n") + launcher.chmod(0o755) + config = Path(tmp) / ".claude.json" + config.write_text(json.dumps({"mcpServers": {"tokenmeter": { + "type": "stdio", "command": "/tmp/different", "args": [], "env": {}, + }}})) + status = meter.agent_access_client_status( + "claude", launcher=str(launcher), claude_config_path=str(config), + which=lambda name: f"/usr/bin/{name}", claude_desktop_config_paths=[]) + self.assertFalse(status["connected"]) + self.assertTrue(status["conflict"]) + self.assertEqual(status["status"], "Existing entry differs from this install") + + def test_conflicting_entry_requires_explicit_repair_confirmation(self): + before = {"label": "Codex", "detected": True, "available": True, + "configured": True, "connected": False, "conflict": True} + runner = mock.Mock() + result = meter.set_agent_access("codex", True, runner=runner, + status_getter=lambda client: before) + self.assertFalse(result["ok"]) + self.assertTrue(result["conflict"]) + self.assertIn("Confirm repair", result["error"]) + runner.assert_not_called() + + def test_repair_replaces_only_named_entry_and_verifies_connection(self): + before = {"label": "Codex", "detected": True, "available": True, + "configured": True, "connected": False, "conflict": True} + after = {**before, "configured": True, "connected": True, "conflict": False} + states = iter((before, after)) + commands = [] + + class Completed: + returncode = 0 + stdout = "" + stderr = "" + + def runner(argv, **kwargs): + commands.append(argv) + self.assertNotIn("shell", kwargs) + return Completed() + + with mock.patch.object(meter, "agent_access_launcher", return_value="/tmp/token-meter-mcp"), \ + mock.patch.object(meter, "agent_client_executable", return_value="/usr/bin/codex"): + result = meter.set_agent_access("codex", True, repair=True, runner=runner, + status_getter=lambda client: next(states)) + self.assertTrue(result["ok"]) + self.assertTrue(result["repaired"]) + self.assertTrue(result["restart_required"]) + self.assertEqual(commands, [ + ["/usr/bin/codex", "mcp", "remove", "tokenmeter"], + ["/usr/bin/codex", "mcp", "add", "--env", "TOKEN_METER_CALLER=codex", + "tokenmeter", "--", "/tmp/token-meter-mcp"], + ]) + + def test_repair_stops_when_existing_entry_cannot_be_removed(self): + before = {"label": "Claude Code", "detected": True, "available": True, + "configured": True, "connected": False, "conflict": True} + + class Completed: + returncode = 1 + stdout = "" + stderr = "entry is locked\n" + + runner = mock.Mock(return_value=Completed()) + absent = {"label": "Claude app", "detected": False} + with mock.patch.object(meter, "agent_client_executable", return_value="/usr/bin/claude"): + result = meter.set_agent_access("claude", True, repair=True, runner=runner, + status_getter=lambda client: absent if client == "claude_desktop" else before) + self.assertFalse(result["ok"]) + self.assertTrue(result["conflict"]) + self.assertIn("remove the existing tokenmeter entry", result["error"]) + self.assertIn("entry is locked", result["error"]) + runner.assert_called_once() + + def test_connection_change_is_verified_after_cli_success(self): + before = {"label": "Codex", "detected": True, "available": True, + "configured": False, "connected": False, "conflict": False} + after = {**before, "configured": True, "connected": True} + states = iter((before, after)) + observed = {} + + class Completed: + returncode = 0 + stdout = "" + stderr = "" + + def runner(argv, **kwargs): + observed["argv"] = argv + observed["kwargs"] = kwargs + return Completed() + + with mock.patch.object(meter, "agent_access_launcher", return_value="/tmp/token-meter-mcp"): + result = meter.set_agent_access("codex", True, runner=runner, + status_getter=lambda client: next(states)) + self.assertTrue(result["ok"]) + self.assertTrue(result["restart_required"]) + self.assertEqual(observed["argv"][-3:], ["tokenmeter", "--", "/tmp/token-meter-mcp"]) + self.assertNotIn("shell", observed["kwargs"]) + + def test_connection_failure_includes_bounded_cli_reason(self): + before = {"label": "Codex", "detected": True, "available": True, + "configured": False, "connected": False, "conflict": False} + + class Completed: + returncode = 127 + stdout = "" + stderr = "env: node: No such file or directory\n" + + with mock.patch.object(meter, "agent_access_launcher", return_value="/tmp/token-meter-mcp"), \ + mock.patch.object(meter, "agent_client_executable", return_value="/tmp/codex"): + result = meter.set_agent_access("codex", True, runner=lambda *a, **k: Completed(), + status_getter=lambda client: before) + self.assertFalse(result["ok"]) + self.assertIn("env: node", result["error"]) + + def _desktop_fixture(self, tmp, config=None, root_name="Claude"): + launcher = Path(tmp) / "token-meter-mcp" + if not launcher.exists(): + launcher.write_text("#!/bin/sh\n") + launcher.chmod(0o755) + root = Path(tmp) / root_name + root.mkdir() + path = root / "claude_desktop_config.json" + if isinstance(config, bytes): + path.write_bytes(config) + elif config is not None: + path.write_text(config if isinstance(config, str) else json.dumps(config, indent=2)) + return str(launcher), path + + def _desktop_toggle(self, launcher, paths, enabled, repair=False): + paths = [str(path) for path in paths] + status_getter = lambda client: meter.claude_desktop_access_status(launcher, paths) + with mock.patch.object(meter, "CLAUDE_DESKTOP_CONFIGS", paths), \ + mock.patch.object(meter, "agent_access_launcher", return_value=launcher): + return meter._set_agent_access_part("claude_desktop", enabled, repair, + mock.Mock(side_effect=AssertionError("no subprocess")), + status_getter) + + def test_status_lists_the_claude_app_beside_cli_clients(self): + with tempfile.TemporaryDirectory() as tmp: + launcher, path = self._desktop_fixture(tmp) + status = meter.agent_access_status( + launcher=launcher, which=lambda name: None, + claude_config_path=str(Path(tmp) / ".claude.json"), + claude_desktop_config_paths=[str(path)]) + self.assertEqual([row["id"] for row in status["clients"]], ["codex", "claude"]) + claude = status["clients"][1] + self.assertEqual(claude["label"], "Claude") + self.assertTrue(claude["detected"]) + self.assertTrue(claude["available"]) + self.assertFalse(claude["configured"]) + self.assertEqual(claude["status"], "Ready to connect") + self.assertTrue(status["any_detected"]) + + def test_claude_app_missing_is_not_detected(self): + with tempfile.TemporaryDirectory() as tmp: + status = meter.claude_desktop_access_status( + str(Path(tmp) / "launcher"), [str(Path(tmp) / "absent" / "claude_desktop_config.json")]) + self.assertFalse(status["detected"]) + self.assertFalse(status["available"]) + self.assertEqual(status["status"], "App not found") + + def test_claude_app_connect_changes_only_the_tokenmeter_entry(self): + existing = {"preferences": {"sidebarMode": "chat", "name": "caf\u00e9"}, + "mcpServers": {"other": {"command": "/bin/other", "args": ["x"], "env": {"K": "v"}}}} + with tempfile.TemporaryDirectory() as tmp: + launcher, path = self._desktop_fixture(tmp, existing) + path.chmod(0o600) + result = self._desktop_toggle(launcher, [path], True) + saved = json.loads(path.read_text(encoding="utf-8")) + mode = path.stat().st_mode & 0o777 + self.assertTrue(result["ok"]) + self.assertTrue(result["client"]["connected"]) + self.assertIn("Quit and reopen the Claude app", result["message"]) + self.assertEqual(saved["preferences"], existing["preferences"]) + self.assertEqual(saved["mcpServers"]["other"], existing["mcpServers"]["other"]) + self.assertEqual(saved["mcpServers"]["tokenmeter"], { + "command": launcher, "args": [], "env": {"TOKEN_METER_CALLER": "claude"}}) + self.assertEqual(mode, 0o600) + + def test_claude_app_connect_writes_every_app_config_including_third_party_mode(self): + with tempfile.TemporaryDirectory() as tmp: + launcher, first = self._desktop_fixture(tmp, {"preferences": {}}) + _, third_party = self._desktop_fixture(tmp, {"deploymentMode": "3p", "mcpServers": { + "other": {"command": "/bin/other"}}}, root_name="Claude-3p") + partial = meter.claude_desktop_access_status(launcher, [str(first), str(third_party)]) + meter.write_claude_desktop_access(True, launcher, [str(first)]) + half = meter.claude_desktop_access_status(launcher, [str(first), str(third_party)]) + result = self._desktop_toggle(launcher, [first, third_party], True) + saved = [json.loads(path.read_text()) for path in (first, third_party)] + self.assertFalse(partial["connected"]) + self.assertFalse(half["connected"]) + self.assertEqual(partial["status"], "Ready to connect") + self.assertEqual(half["status"], "Connected in only one Claude app config") + self.assertTrue(result["ok"]) + for config in saved: + self.assertEqual(config["mcpServers"]["tokenmeter"], meter.claude_desktop_access_entry(launcher)) + self.assertEqual(saved[1]["deploymentMode"], "3p") + self.assertEqual(saved[1]["mcpServers"]["other"], {"command": "/bin/other"}) + + def test_claude_app_only_third_party_root_is_detected(self): + with tempfile.TemporaryDirectory() as tmp: + launcher, third_party = self._desktop_fixture(tmp, {}, root_name="Claude-3p") + absent = Path(tmp) / "Claude" / "claude_desktop_config.json" + result = self._desktop_toggle(launcher, [absent, third_party], True) + self.assertFalse(absent.parent.exists()) + saved = json.loads(third_party.read_text()) + self.assertTrue(result["ok"]) + self.assertIn("tokenmeter", saved["mcpServers"]) + + def test_claude_app_connect_creates_a_missing_config(self): + with tempfile.TemporaryDirectory() as tmp: + launcher, path = self._desktop_fixture(tmp) + self.assertIsNone(meter.write_claude_desktop_access(True, launcher, [str(path)])) + status = meter.claude_desktop_access_status(launcher, [str(path)]) + saved = json.loads(path.read_text()) + self.assertTrue(status["connected"]) + self.assertEqual(list(saved), ["mcpServers"]) + + def test_claude_app_disconnect_removes_only_the_tokenmeter_entry(self): + with tempfile.TemporaryDirectory() as tmp: + launcher, path = self._desktop_fixture(tmp) + path.write_text(json.dumps({"keep": 1, "mcpServers": { + "other": {"command": "/bin/other"}, + "tokenmeter": meter.claude_desktop_access_entry(launcher)}})) + result = self._desktop_toggle(launcher, [path], False) + saved = json.loads(path.read_text()) + self.assertTrue(result["ok"]) + self.assertEqual(saved, {"keep": 1, "mcpServers": {"other": {"command": "/bin/other"}}}) + + def test_claude_app_conflict_needs_repair_then_replaces_only_that_entry(self): + with tempfile.TemporaryDirectory() as tmp: + launcher, path = self._desktop_fixture(tmp, {"mcpServers": { + "other": {"command": "/bin/other"}, + "tokenmeter": {"command": "/tmp/different", "args": [], "env": {}}}}) + refused = self._desktop_toggle(launcher, [path], True) + untouched = json.loads(path.read_text()) + repaired = self._desktop_toggle(launcher, [path], True, repair=True) + saved = json.loads(path.read_text()) + self.assertFalse(refused["ok"]) + self.assertTrue(refused["conflict"]) + self.assertEqual(untouched["mcpServers"]["tokenmeter"]["command"], "/tmp/different") + self.assertTrue(repaired["ok"]) + self.assertTrue(repaired["repaired"]) + self.assertEqual(saved["mcpServers"]["tokenmeter"], meter.claude_desktop_access_entry(launcher)) + self.assertEqual(saved["mcpServers"]["other"], {"command": "/bin/other"}) + + def test_claude_app_config_that_cannot_be_parsed_is_left_unchanged(self): + for raw in (b"{not json", b'["list"]', b'{"mcpServers": []}', b"", b'{"a": "\xff\xfe"}'): + with self.subTest(raw=raw), tempfile.TemporaryDirectory() as tmp: + launcher, path = self._desktop_fixture(tmp, raw) + status = meter.claude_desktop_access_status(launcher, [str(path)]) + error = meter.write_claude_desktop_access(True, launcher, [str(path)]) + toggled = self._desktop_toggle(launcher, [path], True) + self.assertEqual(status["status"], "Claude app config could not be read") + self.assertTrue(status["unreadable"]) + self.assertTrue(status["available"]) + self.assertIn("left", error) + self.assertNotIn(tmp, error) + self.assertFalse(toggled["ok"]) + self.assertIn("could not read", toggled["error"]) + self.assertEqual(path.read_bytes(), raw) + + def _claude_fixture(self, tmp, code_entry=None, app_config=None): + launcher, app_path = self._desktop_fixture(tmp, app_config) + code_path = Path(tmp) / ".claude.json" + code_path.write_text(json.dumps({"mcpServers": {"tokenmeter": code_entry}} if code_entry else {"keep": 1})) + calls = [] + + def runner(argv, **kwargs): + calls.append(argv) + data = json.loads(code_path.read_text()) + servers = data.setdefault("mcpServers", {}) + if argv[1:3] == ["mcp", "add"]: + servers["tokenmeter"] = {"type": "stdio", "command": argv[-1], "args": [], + "env": {"TOKEN_METER_CALLER": "claude"}} + elif argv[1:3] == ["mcp", "remove"]: + servers.pop("tokenmeter", None) + code_path.write_text(json.dumps(data)) + return meter.subprocess.CompletedProcess(argv, 0, "", "") + + def status_getter(client): + return meter.agent_access_client_status( + client, launcher=launcher, which=lambda name: f"/usr/bin/{name}", + claude_config_path=str(code_path), claude_desktop_config_paths=[str(app_path)]) + + return launcher, code_path, app_path, runner, calls, status_getter + + def _claude_toggle(self, launcher, app_path, runner, status_getter, enabled, repair=False): + with mock.patch.object(meter, "CLAUDE_DESKTOP_CONFIGS", [str(app_path)]), \ + mock.patch.object(meter, "agent_access_launcher", return_value=launcher), \ + mock.patch.object(meter, "agent_client_executable", return_value="/usr/bin/claude"): + return meter.set_agent_access("claude", enabled, repair=repair, runner=runner, + status_getter=status_getter) + + def test_one_claude_connect_sets_up_claude_code_and_the_claude_app(self): + with tempfile.TemporaryDirectory() as tmp: + launcher, code_path, app_path, runner, calls, getter = self._claude_fixture(tmp, app_config={"keep": 2}) + before = getter("claude") + result = self._claude_toggle(launcher, app_path, runner, getter, True) + app = json.loads(app_path.read_text()) + code = json.loads(code_path.read_text()) + self.assertEqual(before["status"], "Ready to connect") + self.assertEqual(before["connect_command"].count("\n"), 1) + self.assertTrue(result["ok"], result) + self.assertTrue(result["client"]["connected"]) + self.assertEqual(result["client"]["status"], "Connected to Claude Code and the Claude app") + self.assertEqual(result["message"], + "Claude connected. Start a new Claude Code session and reopen the Claude app.") + self.assertEqual([argv[1:3] for argv in calls], [["mcp", "add"]]) + self.assertEqual(app["keep"], 2) + self.assertEqual(app["mcpServers"]["tokenmeter"], meter.claude_desktop_access_entry(launcher)) + self.assertEqual(code["mcpServers"]["tokenmeter"]["command"], launcher) + + def test_one_claude_disconnect_removes_both(self): + with tempfile.TemporaryDirectory() as tmp: + launcher, code_path, app_path, runner, calls, getter = self._claude_fixture(tmp, app_config={}) + self._claude_toggle(launcher, app_path, runner, getter, True) + result = self._claude_toggle(launcher, app_path, runner, getter, False) + app = json.loads(app_path.read_text()) + code = json.loads(code_path.read_text()) + self.assertTrue(result["ok"], result) + self.assertFalse(result["client"]["configured"]) + self.assertNotIn("tokenmeter", app["mcpServers"]) + self.assertNotIn("tokenmeter", code["mcpServers"]) + + def test_running_claude_app_is_skipped_but_claude_code_still_connects(self): + with tempfile.TemporaryDirectory() as tmp: + launcher, code_path, app_path, runner, calls, getter = self._claude_fixture(tmp, app_config={}) + with mock.patch.object(meter, "claude_app_running", return_value=True): + status = getter("claude") + result = self._claude_toggle(launcher, app_path, runner, getter, True) + app = json.loads(app_path.read_text()) + code = json.loads(code_path.read_text()) + self.assertFalse(status["blocked"]) + self.assertEqual(status["status"], "Ready to connect. Quit the Claude app to change its connection") + self.assertTrue(result["ok"], result) + self.assertTrue(result["partial"]) + self.assertIn("Quit the Claude app first", result["message"]) + self.assertIn("click Connect again", result["message"]) + self.assertEqual(result["client"]["status"], + "Connected to Claude Code only. Quit the Claude app to change its connection") + self.assertTrue(result["client"]["blocked"]) + self.assertEqual(app, {}) + self.assertIn("tokenmeter", code["mcpServers"]) + + def test_running_claude_app_blocks_when_only_the_app_is_left(self): + with tempfile.TemporaryDirectory() as tmp: + launcher, code_path, app_path, runner, calls, getter = self._claude_fixture(tmp, app_config={}) + self._claude_toggle(launcher, app_path, runner, getter, True) + app_before = app_path.read_bytes() + code_before = code_path.read_bytes() + calls.clear() + with mock.patch.object(meter, "claude_app_running", return_value=True): + status = getter("claude") + result = self._claude_toggle(launcher, app_path, runner, getter, False) + self.assertEqual(code_path.read_bytes(), code_before) + self.assertEqual(app_path.read_bytes(), app_before) + self.assertTrue(status["connected"]) + self.assertTrue(status["blocked"]) + self.assertFalse(result["ok"]) + self.assertIn("Quit the Claude app first", result["error"]) + self.assertEqual(calls, []) + with tempfile.TemporaryDirectory() as tmp: + launcher, _, app_path, runner, calls, getter = self._claude_fixture(tmp, app_config={}) + with mock.patch.object(meter, "claude_app_running", return_value=True): + meter.write_claude_desktop_access(False, launcher, [str(app_path)]) + self._claude_toggle(launcher, app_path, runner, getter, True) + calls.clear() + refused = self._claude_toggle(launcher, app_path, runner, getter, True) + blocked = getter("claude") + self.assertFalse(refused["ok"]) + self.assertIn("Quit the Claude app first", refused["error"]) + self.assertEqual(calls, []) + self.assertTrue(blocked["blocked"]) + + def test_unreadable_claude_app_config_does_not_stop_claude_code(self): + with tempfile.TemporaryDirectory() as tmp: + launcher, code_path, app_path, runner, calls, getter = self._claude_fixture(tmp, app_config=b"{bad") + result = self._claude_toggle(launcher, app_path, runner, getter, True) + self.assertEqual(app_path.read_bytes(), b"{bad") + self.assertTrue(result["ok"], result) + self.assertTrue(result["partial"]) + self.assertIn("could not read", result["message"]) + self.assertEqual(result["client"]["status"], + "Connected to Claude Code only. The Claude app config could not be read") + self.assertTrue(result["client"]["blocked"]) + + def test_running_claude_app_does_not_block_finishing_claude_code(self): + with tempfile.TemporaryDirectory() as tmp: + launcher, _, app_path, runner, calls, getter = self._claude_fixture(tmp) + meter.write_claude_desktop_access(True, launcher, [str(app_path)]) + with mock.patch.object(meter, "claude_app_running", return_value=True): + partial = getter("claude") + result = self._claude_toggle(launcher, app_path, runner, getter, True) + self.assertEqual(partial["status"], "Connected to the Claude app only") + self.assertFalse(partial["blocked"]) + self.assertTrue(result["ok"], result) + self.assertEqual([argv[1:3] for argv in calls], [["mcp", "add"]]) + + def test_claude_code_only_machine_still_connects_with_one_button(self): + with tempfile.TemporaryDirectory() as tmp: + launcher, code_path, _, runner, calls, _ = self._claude_fixture(tmp) + getter = lambda client: meter.agent_access_client_status( + client, launcher=launcher, which=lambda name: f"/usr/bin/{name}", + claude_config_path=str(code_path), claude_desktop_config_paths=[]) + result = self._claude_toggle(launcher, Path(tmp) / "absent" / "c.json", runner, getter, True) + self.assertTrue(result["ok"], result) + self.assertEqual(result["message"], "Claude connected. Start a new Claude Code session.") + + def test_claude_app_running_is_checked_only_where_the_host_can_tell(self): + seen = [] + + def runner(argv, **kwargs): + seen.append(argv) + self.assertNotIn("shell", kwargs) + return meter.subprocess.CompletedProcess(argv, self._pgrep_code, "", "") + + for platform_id, code, expected in (("macos", 0, True), ("macos", 1, False), + ("windows", 0, None), ("linux", 0, None)): + self._pgrep_code = code + with self.subTest(platform_id=platform_id, code=code), \ + mock.patch.object(meter._PLATFORM_SERVICES, "platform_id", platform_id): + self.assertEqual(self._real_app_running(runner), expected) + self.assertEqual(seen, [["pgrep", "-a", "-x", "Claude"]] * 2) + + def test_disconnect_is_refused_while_a_claude_app_config_is_unreadable(self): + with tempfile.TemporaryDirectory() as tmp: + launcher, code_path, app_path, runner, calls, getter = self._claude_fixture(tmp, app_config=b"{bad") + self._claude_toggle(launcher, app_path, runner, getter, True) + code_before = code_path.read_bytes() + calls.clear() + result = self._claude_toggle(launcher, app_path, runner, getter, False) + self.assertEqual(code_path.read_bytes(), code_before) + self.assertEqual(app_path.read_bytes(), b"{bad") + self.assertFalse(result["ok"]) + self.assertIn("could not read", result["error"]) + self.assertEqual(calls, []) + + def test_repair_message_reflects_whether_anything_was_repaired(self): + with tempfile.TemporaryDirectory() as tmp: + launcher, _, app_path, runner, calls, getter = self._claude_fixture(tmp, app_config={"mcpServers": { + "tokenmeter": {"command": "/tmp/different", "args": [], "env": {}}}}) + with mock.patch.object(meter, "claude_app_running", return_value=True): + result = self._claude_toggle(launcher, app_path, runner, getter, True, repair=True) + self.assertTrue(result["ok"], result) + self.assertFalse(result["repaired"]) + self.assertTrue(result["message"].startswith("Claude Code connected.")) + + def test_malformed_tokenmeter_entries_are_conflicts_not_crashes(self): + for entry in ({"command": "c", "env": "x"}, {"command": "c", "args": 5, "env": {}}, + "oops", None, ["list"], {"env": {"TOKEN_METER_CALLER": "claude"}, "args": "x"}): + with self.subTest(entry=entry), tempfile.TemporaryDirectory() as tmp: + launcher, path = self._desktop_fixture(tmp, {"mcpServers": {"tokenmeter": entry}}) + before = path.read_bytes() + status = meter.agent_access_status( + launcher=launcher, which=lambda name: None, + claude_config_path=str(Path(tmp) / ".claude.json"), + claude_desktop_config_paths=[str(path)])["clients"][1] + refused = self._desktop_toggle(launcher, [path], True) + self.assertTrue(status["conflict"]) + self.assertFalse(status["connected"]) + self.assertFalse(refused["ok"]) + self.assertEqual(path.read_bytes(), before) + + def test_unreadable_config_wins_over_a_conflict_in_another_config(self): + with tempfile.TemporaryDirectory() as tmp: + launcher, conflicting = self._desktop_fixture(tmp, {"mcpServers": { + "tokenmeter": {"command": "/tmp/different", "args": [], "env": {}}}}) + _, unreadable = self._desktop_fixture(tmp, b"{bad", root_name="Claude-3p") + status = meter.claude_desktop_access_status(launcher, [str(conflicting), str(unreadable)]) + toggled = self._desktop_toggle(launcher, [conflicting, unreadable], True, repair=True) + saved = json.loads(conflicting.read_text()) + self.assertEqual(status["status"], "Claude app config could not be read") + self.assertFalse(toggled["ok"]) + self.assertIn("could not read", toggled["error"]) + self.assertEqual(saved["mcpServers"]["tokenmeter"]["command"], "/tmp/different") + + def test_settings_row_shows_unreadable_claude_app_config_instead_of_launcher_error(self): + page = Path(DASHBOARD_PAGE).read_text() + self.assertIn("const disabled=!row.connected&&!row.available;", page) + self.assertIn("try{refreshed=await loadAgentAccess();}finally{agentAccessRechecking=false;}", page) + self.assertIn("if(!button||button.disabled)return;", page) + self.assertIn("title:'Quit the Claude app first'", page) + self.assertIn("title:'Claude app settings could not be read'", page) + self.assertIn("const action=row.connected?'Disconnect':row.conflict?'Repair':'Connect';", page) + self.assertIn("if(row)openAgentAccessDialog(client,!row.connected);", page) + self.assertIn("!row.detected?(row.id==='claude'?row.status:'CLI not found'):" + "!row.available?'Token Meter launcher unavailable':row.status", page) + + def test_unreadable_claude_app_config_does_not_break_other_clients(self): + with tempfile.TemporaryDirectory() as tmp: + launcher, path = self._desktop_fixture(tmp, b"\xff\xfe\x00") + status = meter.agent_access_status( + launcher=launcher, which=lambda name: None, + claude_config_path=str(Path(tmp) / ".claude.json"), + claude_desktop_config_paths=[str(path)]) + self.assertTrue(status["ok"]) + self.assertIn("The Claude app config could not be read", status["clients"][1]["status"]) + self.assertEqual(status["clients"][0]["id"], "codex") + + def test_claude_app_write_keeps_a_symlinked_config_linked(self): + with tempfile.TemporaryDirectory() as tmp: + launcher, path = self._desktop_fixture(tmp) + real = Path(tmp) / "dotfiles.json" + real.write_text(json.dumps({"keep": True})) + path.symlink_to(real) + self.assertIsNone(meter.write_claude_desktop_access(True, launcher, [str(path)])) + self.assertTrue(path.is_symlink()) + saved = json.loads(real.read_text()) + self.assertTrue(saved["keep"]) + self.assertIn("tokenmeter", saved["mcpServers"]) + + def test_claude_app_refuses_to_overwrite_a_config_changed_mid_save(self): + with tempfile.TemporaryDirectory() as tmp: + launcher, path = self._desktop_fixture(tmp, {"preferences": {"v": 1}}) + original = meter._read_claude_desktop_config + + def app_writes_during_read(target): + result = original(target) + Path(target).write_text(json.dumps({"preferences": {"v": 2}, "extra": "app"})) + return result + + with mock.patch.object(meter, "_read_claude_desktop_config", side_effect=app_writes_during_read): + error = meter.write_claude_desktop_access(True, launcher, [str(path)]) + saved = json.loads(path.read_text()) + self.assertIn("changed its config", error) + self.assertEqual(saved, {"preferences": {"v": 2}, "extra": "app"}) + + def test_windows_claude_app_entry_runs_cmd_launcher_through_cmd_exe(self): + launcher = r"C:\Token Meter\scripts\run-token-meter-mcp.cmd" + entry = meter.claude_desktop_access_entry(launcher) + self.assertEqual(entry["command"], "cmd.exe") + self.assertEqual(entry["args"], ["/c", launcher]) + self.assertTrue(meter._claude_desktop_entry_matches(entry, launcher)) + self.assertFalse(meter._claude_desktop_entry_matches( + {"command": launcher, "args": [], "env": {"TOKEN_METER_CALLER": "claude"}}, launcher)) + + +class CapabilityConfigTests(unittest.TestCase): + def test_skill_catalog_cache_avoids_rescanning_but_refreshes_usage(self): + with tempfile.TemporaryDirectory() as tmp: + skill = Path(tmp) / "custom" / "SKILL.md" + skill.parent.mkdir() + skill.write_text("---\nname: custom\n---\n") + codex_pattern = os.path.join( + os.path.expanduser("~/.codex/skills"), "**", "SKILL.md", + ) + + def matching_glob(pattern, recursive=False): + del recursive + return [str(skill)] if pattern == codex_pattern else [] + + meter.invalidate_discovered_skill_cache() + try: + with mock.patch.object(meter.glob, "glob", side_effect=matching_glob), \ + mock.patch.object( + meter, "_skill_measurability", return_value="unknown", + ) as measurability: + first = meter.discovered_skills([{ + "name": "custom", "providers": ["codex"], + "activations": 1, "sessions_used": 1, + "last_used": "2026-08-18", + }]) + second = meter.discovered_skills([{ + "name": "custom", "providers": ["codex"], + "activations": 2, "sessions_used": 2, + "last_used": "2026-08-19", + }]) + finally: + meter.invalidate_discovered_skill_cache() + + self.assertEqual(measurability.call_count, 1) + self.assertEqual(first[0]["activations"], 1) + self.assertEqual(second[0]["activations"], 2) + self.assertEqual(second[0]["measurement"], "measurable") + + def scan_claude_skill_rows(self, root, sources): + projects = root / "claude-projects" + meter.invalidate_discovered_skill_cache() + try: + with mock.patch.object(meter, "CLAUDE_USER_SKILLS", str(root / "user-skills")), \ + mock.patch.object(meter, "CLAUDE_PROJECTS", str(projects)), \ + mock.patch.object(meter, "CLAUDE_DESKTOP_DATA_ROOTS", []), \ + mock.patch.object(meter, "claude_plugin_installations", return_value={}), \ + mock.patch.object(meter, "source_inventory_snapshot", + return_value={"sources": tuple(sources)}): + rows = meter.discovered_skills() + finally: + meter.invalidate_discovered_skill_cache() + return [row for row in rows if row["runtime"] == "Claude"] + + def test_inventory_lists_personal_and_session_project_claude_skills(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + for skill_dir in (root / "user-skills" / "personal-helper", + root / "repo" / ".claude" / "skills" / "repo-review", + root / "codex-repo" / ".claude" / "skills" / "not-claude"): + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text("---\nname: x\n---\n") + session = str(root / "claude-projects" / "p" / "s.jsonl") + rows = self.scan_claude_skill_rows(root, [ + {"provider": "claude", "path": session, "project": str(root / "repo")}, + {"provider": "claude", "path": session, "project": str(root / "repo")}, + {"provider": "claude", "path": session, "project": "local"}, + {"provider": "codex", "path": "/tmp/c.jsonl", "project": str(root / "codex-repo")}, + ]) + + by_name = {row["name"]: row for row in rows} + self.assertEqual(set(by_name), {"personal-helper", "repo-review"}) + self.assertEqual(by_name["personal-helper"]["source"], "Personal") + self.assertEqual(by_name["repo-review"]["source"], "Project") + self.assertEqual(by_name["repo-review"]["origin_id"], "claude:project") + self.assertTrue(by_name["repo-review"]["enabled"]) + self.assertFalse(by_name["repo-review"]["mutable"]) + + def test_project_skill_roots_are_bounded(self): + with tempfile.TemporaryDirectory() as tmp: + session = os.path.join(tmp, "claude-projects", "p", "s.jsonl") + sources = [ + {"provider": "claude", "path": session, "project": f"/work/repo-{index}"} + for index in range(meter.MAX_PROJECT_SKILL_ROOTS + 10) + ] + with mock.patch.object(meter, "CLAUDE_PROJECTS", os.path.join(tmp, "claude-projects")), \ + mock.patch.object(meter, "source_inventory_snapshot", + return_value={"sources": tuple(sources)}): + roots = meter.claude_project_skill_roots() + self.assertEqual(len(roots), meter.MAX_PROJECT_SKILL_ROOTS) + + def test_personal_skill_wins_name_clash_and_first_project_wins(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + for skill_dir in (root / "user-skills" / "review", + root / "a" / ".claude" / "skills" / "review", + root / "a" / ".claude" / "skills" / "deploy", + root / "b" / ".claude" / "skills" / "deploy"): + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text("---\nname: x\n---\n") + session = str(root / "claude-projects" / "p" / "s.jsonl") + rows = self.scan_claude_skill_rows(root, [ + {"provider": "claude", "path": session, "project": str(root / "a")}, + {"provider": "claude", "path": session, "project": str(root / "b")}, + ]) + + self.assertEqual(sorted((row["name"], row["source"]) for row in rows), + [("deploy", "Project"), ("review", "Personal")]) + deploy = next(row for row in rows if row["name"] == "deploy") + self.assertIn(os.path.join("a", ".claude", "skills", "deploy"), deploy["path"]) + + def test_skill_catalog_rescans_once_session_inventory_is_ready(self): + snapshot = {"ready": False, "sources": ()} + meter.invalidate_discovered_skill_cache() + try: + with mock.patch.object(meter, "source_inventory_snapshot", side_effect=lambda: snapshot), \ + mock.patch.object(meter, "_scan_discovered_skills", return_value=[]) as scan: + meter.discovered_skills() + meter.discovered_skills() + snapshot = {"ready": True, "sources": ()} + meter.discovered_skills() + meter.discovered_skills() + finally: + meter.invalidate_discovered_skill_cache() + self.assertEqual(scan.call_count, 2) + + def test_only_native_skill_tool_calls_count_as_native_activations(self): + evidence = meter.summarize_tool_evidence([ + {**meter.tool_identity("Skill"), "output_tokens": 0, "ts": 100, + "args_fingerprint": "a", "error": False, "skills": ["repo-review"]}, + {**meter.tool_identity("Read"), "output_tokens": 0, "ts": 101, + "args_fingerprint": "b", "error": False, + "skills": meter.skill_names_from_value( + {"file_path": "/r/.agents/skills/adapter-only/SKILL.md"}, "Read")}, + ]) + session = os.path.join(meter.CLAUDE_PROJECTS, "p", "s.jsonl") + waste = meter.global_tool_waste([{ + "id": "s", "provider": "claude", "project": "/r", "path": session, + "_tool_evidence": evidence, + }]) + hosts = {row["name"]: row["hosts"]["Claude"] for row in waste["skills"]} + self.assertEqual(hosts["repo-review"]["native_activations"], 1) + self.assertEqual(hosts["adapter-only"]["activations"], 1) + self.assertEqual(hosts["adapter-only"]["native_activations"], 0) + + meter.invalidate_discovered_skill_cache() + try: + with mock.patch.object(meter, "_scan_discovered_skills", return_value=[]): + rows = meter.discovered_skills(waste["skills"]) + finally: + meter.invalidate_discovered_skill_cache() + self.assertEqual([row["name"] for row in rows], ["repo-review"]) + + def test_unlisted_claude_skill_activations_become_observed_rows(self): + inventory = [{"name": "known", "runtime": "Claude", "measurement": "unknown"}] + usage = [ + {"name": "known", "hosts": {"Claude": {"activations": 2, "native_activations": 2}}}, + {"name": "repo-only", "hosts": {"Claude": { + "activations": 3, "native_activations": 3, + "sessions_used": 2, "last_used": "2026-10-27", + }}}, + {"name": "path-only", "hosts": {"Claude": {"activations": 2, "native_activations": 0}}}, + {"name": "codex-only", "hosts": {"Codex": {"activations": 4, "native_activations": 4}}}, + {"name": "legacy", "providers": ["claude"], "activations": 1}, + {"name": "${name}", "hosts": {"Claude": {"activations": 1, "native_activations": 1}}}, + ] + meter.invalidate_discovered_skill_cache() + try: + with mock.patch.object(meter, "_scan_discovered_skills", return_value=inventory): + rows = meter.discovered_skills(usage) + finally: + meter.invalidate_discovered_skill_cache() + + by_name = {row["name"]: row for row in rows} + self.assertEqual(set(by_name), {"known", "repo-only"}) + self.assertEqual(by_name["known"]["activations"], 2) + observed = by_name["repo-only"] + self.assertEqual(observed["source"], "Observed in traces") + self.assertEqual(observed["configuration"], "Not in config") + self.assertIsNone(observed["enabled"]) + self.assertEqual((observed["activations"], observed["sessions_used"]), (3, 2)) + self.assertEqual(observed["last_used"], "2026-10-27") + self.assertEqual(observed["measurement"], "measurable") + self.assertEqual(observed["id"], "skill:claude:claude:observed:repo-only") + + def test_skill_identity_separates_runtime_origin_and_plugin(self): + identities = { + meter.skill_identity("Codex", "browser", "codex:built-in"), + meter.skill_identity("Codex", "browser", "codex:user"), + meter.skill_identity("Codex", "browser", "codex:plugin:openai-bundled", "browser@openai-bundled"), + meter.skill_identity("Claude", "browser", "claude:plugin:skills-marketplace", "browser@skills-marketplace"), + } + self.assertEqual(len(identities), 4) + + def test_optional_summary_counts_only_mutable_skill_packs(self): + mcp_items = [ + {"name": "context7", "mutable": True, "enabled": True, "used": False, + "codex_enabled": True, "claude_enabled": False}, + {"name": "core", "mutable": False, "enabled": True, "used": False}, + ] + skill_items = [ + {"name": "browser", "runtime": "Codex", "plugin_id": "browser@bundled", + "mutable": True, "enabled": True, "used": True, "activations": 2}, + {"name": "local", "runtime": "Codex", "plugin_id": "", + "mutable": False, "enabled": True, "used": False}, + ] + groups = meter.capability_control_groups(mcp_items, skill_items) + summary = meter.optional_capability_summary(groups) + self.assertEqual(summary["enabled"], 1) + self.assertEqual(summary["used"], 1) + self.assertEqual(summary["unused"], 0) + self.assertEqual(summary["mcp_enabled"], 0) + self.assertEqual(summary["skill_packs_enabled"], 1) + + def test_runtime_skill_packs_are_not_review_candidates(self): + skill_items = [ + {"id": "runtime-skill", "name": "browser", "runtime": "Codex", + "plugin_id": "browser@openai-bundled", "mutable": True, "enabled": True, + "used": False, "reviewable": False, "origin": "runtime_pack"}, + {"id": "user-skill", "name": "custom", "runtime": "Codex", + "plugin_id": "custom@personal", "mutable": True, "enabled": True, + "used": False, "reviewable": True, "origin": "user_plugin"}, + ] + groups = meter.capability_control_groups([], skill_items) + summary = meter.optional_capability_summary(groups) + self.assertEqual(summary["enabled"], 1) + self.assertEqual(summary["review_candidates"], ["skill_pack:Codex:custom@personal"]) + + def test_observed_tools_do_not_claim_configuration_enabled(self): + waste = {"inventory_tools": [{ + "id": "codex::exec", "name": "exec", "display": "exec", + "kind": "tool", "runtime": "Codex", "namespace": "shell", + "calls": 2, "output_tokens": 120, "last_used": "2026-08-10", + "advertised_sessions": 0, "eager_sessions": 0, "deferred_sessions": 0, + }]} + with mock.patch.object(meter, "codex_mcp_states", return_value={}), \ + mock.patch.object(meter, "claude_mcp_states", return_value={}), \ + mock.patch.object(meter, "discovered_skills", return_value=[]), \ + mock.patch.object(meter, "claude_desktop_index", return_value={}), \ + mock.patch.object(meter, "claude_local_agent_sources", return_value=[]): + capabilities = meter.capability_inventory(waste) + row = capabilities["items"][0] + self.assertIsNone(row["enabled"]) + self.assertEqual(row["configuration"], "Unknown") + self.assertEqual(row["state"], "Observed only") + self.assertIsNone(capabilities["summary"]["tools"]["enabled"]) + self.assertEqual(capabilities["summary"]["tools"]["observed"], 1) + + def test_capability_revisions_are_stable_across_generation_time(self): + waste = {"inventory_tools": [{ + "id": "codex::exec", "name": "exec", "display": "exec", + "kind": "tool", "runtime": "Codex", "namespace": "shell", + "calls": 2, "output_tokens": 120, "last_used": "2026-08-10", + "advertised_sessions": 1, "eager_sessions": 1, "deferred_sessions": 0, + }]} + with mock.patch.object(meter, "codex_mcp_states", return_value={}), \ + mock.patch.object(meter, "claude_mcp_states", return_value={}), \ + mock.patch.object(meter, "discovered_skills", return_value=[]), \ + mock.patch.object(meter, "claude_desktop_index", return_value={}), \ + mock.patch.object(meter, "claude_local_agent_sources", return_value=[]), \ + mock.patch.object(meter.time, "time", side_effect=[100, 200, 300]): + first = meter.capability_inventory(waste) + second = meter.capability_inventory(waste) + changed = meter.capability_inventory({ + **waste, + "inventory_tools": [{**waste["inventory_tools"][0], "calls": 3}], + }) + self.assertNotEqual(first["generated_at"], second["generated_at"]) + self.assertEqual(first["review_revision"], second["review_revision"]) + self.assertEqual(first["inventory_revision"], second["inventory_revision"]) + self.assertEqual(first["revision"], second["revision"]) + self.assertNotEqual(second["inventory_revision"], changed["inventory_revision"]) + self.assertNotEqual(second["revision"], changed["revision"]) + + def test_dashboard_state_omits_heavy_capability_rows_and_tool_waste(self): + capabilities = { + "items": [{"id": "tool:exec"}], + "summary": {"tools": {"available": 1}}, + "control_groups": [], "inventory_revision": "inventory-a", + } + state = {"xsession": { + "capabilities": capabilities, + "tool_waste": {"inventory_tools": [{"private": "trace detail"}]}, + "total_sessions": 2, + }} + payload = meter.dashboard_state_payload(state) + public_capabilities = payload["xsession"]["capabilities"] + self.assertNotIn("items", public_capabilities) + self.assertEqual(public_capabilities["inventory_count"], 1) + self.assertNotIn("tool_waste", payload["xsession"]) + self.assertIn("items", state["xsession"]["capabilities"]) + self.assertIn("tool_waste", state["xsession"]) + self.assertIn( + 'elif req_path == "/capabilities/inventory":', + Path(meter.IMPLEMENTATION_FILE).read_text(), + ) + + def test_pack_without_runtime_logs_is_not_a_review_candidate(self): + base = { + "id": "skill_pack:Claude:custom@personal", "name": "custom@personal", + "control_type": "skill_pack", "enabled": True, "used": False, + "mutable": True, "reviewable": True, "unmeasurable": False, + } + unscanned = meter.optional_capability_summary([{**base, "scanned_sessions": 0}]) + scanned = meter.optional_capability_summary([{**base, "scanned_sessions": 3}]) + self.assertEqual(unscanned["review_candidates"], []) + self.assertEqual(unscanned["unscanned_packs"], 1) + self.assertEqual(scanned["review_candidates"], [base["id"]]) + self.assertEqual(scanned["unscanned_packs"], 0) + + def test_claude_desktop_and_3p_logs_do_not_cover_claude_code_packs(self): + skill = { + "id": "skill:claude:custom@personal:custom", "name": "custom", + "runtime": "Claude", "source": "User-installed plugin", + "plugin_id": "custom@personal", "mutable": True, "enabled": True, + "used": False, "reviewable": True, "measurement": "measurable", + "unmeasurable": False, "activations": 0, "last_used": "Never", + "setting_path": "~/.claude/settings.json", + } + for runtime in ("Claude Desktop", "Claude-3P"): + with self.subTest(runtime=runtime): + waste = meter.global_tool_waste([{ + "id": runtime, "provider": "claude", "runtime": runtime, + "project": "/repo", "_tool_evidence": {}, + }]) + with mock.patch.object(meter, "codex_mcp_states", return_value={}), \ + mock.patch.object(meter, "claude_mcp_states", return_value={}), \ + mock.patch.object(meter, "discovered_skills", return_value=[skill]), \ + mock.patch.object(meter, "claude_desktop_index", return_value={}), \ + mock.patch.object(meter, "claude_local_agent_sources", return_value=[]): + capabilities = meter.capability_inventory(waste) + self.assertEqual(capabilities["control_groups"][0]["scanned_sessions"], 0) + self.assertEqual( + capabilities["summary"]["optional"]["review_candidates"], [] + ) + + def test_bulk_disable_accepts_only_exact_review_candidates(self): + capabilities = { + "summary": {"optional": {"review_candidates": ["skill_pack:Claude:custom@personal"]}}, + "control_groups": [ + {"id": "skill_pack:Claude:custom@personal", "name": "custom@personal", + "control_type": "skill_pack", "enabled": True, "used": False, + "mutable": True, "reviewable": True}, + {"id": "skill_pack:Codex:browser@openai-bundled", "name": "browser@openai-bundled", + "control_type": "skill_pack", "enabled": True, "used": False, + "mutable": True, "reviewable": False}, + ], + } + changed = [] + + def fake_setter(control, enabled): + changed.append((control["id"], enabled)) + return {"ok": True, "verified": True} + + result = meter.disable_capability_controls( + ["skill_pack:Claude:custom@personal"], capabilities, fake_setter) + rejected = meter.disable_capability_controls( + ["skill_pack:Codex:browser@openai-bundled"], capabilities, fake_setter) + self.assertTrue(result["ok"]) + self.assertEqual(result["changed"], 1) + self.assertEqual(changed, [("skill_pack:Claude:custom@personal", False)]) + self.assertFalse(rejected["ok"]) + self.assertEqual(rejected["invalid_control_ids"], ["skill_pack:Codex:browser@openai-bundled"]) + + def test_session_optional_summary_excludes_mcp_and_default_activity(self): + capabilities = {"control_groups": [ + {"id": "skill_pack:Codex:browser@bundled", "control_type": "skill_pack", + "name": "browser@bundled", "runtime": "Codex", "enabled": True, + "mutable": True, "members": ["browser"]}, + ], "summary": {"optional": {"review_candidate_names": []}}} + state = {"provider": "codex", "tools": { + "by_name": [ + {"name": "exec", "namespace": "exec", "kind": "tool", "calls": 3}, + {"name": "mcp__context7__query", "namespace": "context7", "kind": "mcp", "calls": 1}, + ], + "skills": [{"name": "browser", "activations": 1}], + }} + summary = meter.session_optional_capabilities(state, capabilities) + self.assertEqual(summary["enabled"], 1) + self.assertEqual(summary["used"], 1) + self.assertEqual(summary["mcp_enabled"], 0) + self.assertEqual(summary["avoidable_eager_definition_tokens"], 0) + self.assertNotIn("context7", [row["name"] for row in summary["groups"]]) + + def test_mcp_parser_ignores_nested_env_section(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "config.toml" + path.write_text("[mcp_servers.node_repl]\nenabled = false\n[mcp_servers.node_repl.env]\nTOKEN = 'x'\n") + rows = meter.toml_named_sections(str(path), "mcp_servers") + self.assertEqual(list(rows), ["node_repl"]) + + def test_codex_plugin_toggle_changes_only_enabled_state(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "config.toml" + path.write_text('[plugins."docs@runtime"]\nenabled = true\nsource = "keep"\n[features]\nskills = true\n') + with mock.patch.object(meter, "CODEX_CONFIG", str(path)): + result = meter.set_codex_plugin_enabled("docs@runtime", False) + text = path.read_text() + self.assertTrue(result["ok"]) + self.assertTrue(result["verified"]) + self.assertIn("enabled = false", text) + self.assertIn('source = "keep"', text) + + def test_claude_plugin_toggle_verifies_written_setting(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "settings.json" + path.write_text(json.dumps({"enabledPlugins": {"docs@personal": True}})) + with (mock.patch.object(meter, "CLAUDE_SETTINGS", str(path)), + mock.patch.object(meter, "claude_plugin_installations", + return_value={"docs@personal": {"installPath": tmp}})): + result = meter.set_claude_plugin_enabled("docs@personal", False) + written = json.loads(path.read_text()) + self.assertTrue(result["ok"]) + self.assertTrue(result["verified"]) + self.assertFalse(written["enabledPlugins"]["docs@personal"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_agent_data_contract.py b/tests/test_agent_data_contract.py new file mode 100644 index 00000000..7742a2d3 --- /dev/null +++ b/tests/test_agent_data_contract.py @@ -0,0 +1,1404 @@ +import unittest +import contextlib +import datetime +import io +import json +import os +import shutil +import subprocess +import tempfile +import threading +import time +from pathlib import Path +from unittest import mock +import meter +from tests.dashboard_page import DASHBOARD_PAGE +from tests import test_builder_recap # module import: its test classes are not collected twice + + +class AgentDataContractTests(unittest.TestCase): + def setUp(self): + self.source = { + "id": "safe-session", "provider": "codex", "client": "codex", "label": "Codex", + "path": "/private/logs/secret.jsonl", "session": "secret.jsonl", + "project": "/Users/test/work/repository", "mtime": meter.time.time(), + } + self.state = { + "provider": "codex", "source": self.source, "session": "secret.jsonl", + "project": self.source["project"], "total_cost": 1.25, "cost_approx": True, + "total_tokens": 120000, "turns": 4, "last_turn_cost": 0.21, + "context": {"latest": 40000, "window": 200000, "latest_pct": 0.2}, + "tools": {"total_output_tokens": 9000, "flagged_tokens": 1000, "by_name": []}, + "executions": [{"idx": 4, "cost": 0.21, "tokens": { + "input": 40000, "output": 900, "retrieval": 1000, + }, "context_pct": 0.2}], + "trace": [ + {"execution": 4, "kind": "user", "label": "User message", "detail": "private prompt"}, + {"execution": 4, "kind": "tool_call", "label": "search", "detail": "private args"}, + {"execution": 4, "kind": "tool_result", "label": "search", "detail": "private result", "tokens": 1000}, + ], + "insights": [], "ended": False, + } + + def test_check_matches_runtime_and_project_and_omits_content(self): + with mock.patch.object(meter, "all_session_sources", return_value=[self.source]), \ + mock.patch.object(meter, "recompute", return_value=self.state): + result = meter.agent_check(execution=4, caller={ + "runtime": "codex", "project": "/Users/test/work/repository/subdir", + }) + encoded = json.dumps(result) + self.assertTrue(result["ok"]) + self.assertNotIn("project", result["selected_session"]) + self.assertNotIn("repository", encoded) + self.assertEqual(result["data_scope"], "matched_current_run") + self.assertNotIn("private prompt", encoded) + self.assertNotIn("private args", encoded) + self.assertNotIn("private result", encoded) + self.assertNotIn("/private/logs", encoded) + self.assertLessEqual(len(result["evidence"]), 3) + self.assertLessEqual(len(result["execution"]["activity"]), 5) + selected_tools = next(row for row in result["evidence"] if row["label"] == "Selected execution tool results") + self.assertEqual(selected_tools["value"], 1000) + + def test_default_check_does_not_present_run_wide_tool_tokens_as_latest(self): + with mock.patch.object(meter, "all_session_sources", return_value=[self.source]), \ + mock.patch.object(meter, "recompute", return_value=self.state): + result = meter.agent_check(caller={ + "runtime": "codex", "project": "/Users/test/work/repository", + }) + latest_tools = next(row for row in result["evidence"] if row["label"] == "Latest execution tool results") + self.assertEqual(latest_tools["value"], 1000) + self.assertNotEqual(latest_tools["value"], self.state["tools"]["total_output_tokens"]) + + def test_cost_check_answers_the_cost_question(self): + with mock.patch.object(meter, "all_session_sources", return_value=[self.source]), \ + mock.patch.object(meter, "recompute", return_value=self.state): + result = meter.agent_check(focus="cost", caller={ + "runtime": "codex", "project": "/Users/test/work/repository", + }) + + self.assertIn("$1.25", result["answer"]) + self.assertIn("cost", result["recommended_action"].lower()) + + def test_context_check_answers_the_context_question(self): + with mock.patch.object(meter, "all_session_sources", return_value=[self.source]), \ + mock.patch.object(meter, "recompute", return_value=self.state): + result = meter.agent_check(focus="context", caller={ + "runtime": "codex", "project": "/Users/test/work/repository", + }) + + self.assertIn("20%", result["answer"]) + self.assertIn("context", result["recommended_action"].lower()) + + def test_check_dashboard_url_targets_the_single_run_surface(self): + state = {**self.state, "context": {"latest": 180000, "window": 200000, + "latest_pct": 0.9}} + with mock.patch.object(meter, "all_session_sources", return_value=[self.source]), \ + mock.patch.object(meter, "recompute", return_value=state): + result = meter.agent_check(caller={ + "runtime": "codex", "project": "/Users/test/work/repository", + }) + + self.assertTrue(result["dashboard_url"].endswith("#summary")) + + def test_tools_check_answers_the_tool_volume_question(self): + with mock.patch.object(meter, "all_session_sources", return_value=[self.source]), \ + mock.patch.object(meter, "recompute", return_value=self.state): + result = meter.agent_check(focus="tools", caller={ + "runtime": "codex", "project": "/Users/test/work/repository", + }) + + self.assertIn("9,000", result["answer"]) + self.assertIn("tool", result["recommended_action"].lower()) + + def test_next_phase_check_summarizes_all_readiness_signals(self): + with mock.patch.object(meter, "all_session_sources", return_value=[self.source]), \ + mock.patch.object(meter, "recompute", return_value=self.state): + result = meter.agent_check(focus="next_phase", caller={ + "runtime": "codex", "project": "/Users/test/work/repository", + }) + + self.assertIn("20%", result["answer"]) + self.assertIn("$1.25", result["answer"]) + self.assertIn("9,000", result["answer"]) + self.assertIn("next phase", result["recommended_action"].lower()) + + def test_missing_context_percentage_is_not_described_as_zero(self): + state = {"context": {"latest_pct": None}, "last_turn_cost": 0, + "insights": [], "executions": [], "ended": False} + recommendation = meter.menubar_recommendation(state) + verdict = meter.menubar_verdict(state, recommendation) + self.assertIn("not reported", verdict["detail"]) + self.assertNotIn("Context is 0%", verdict["detail"]) + + def test_dashboard_omits_removed_session_tab_payloads(self): + state = { + "source": {"id": "session", "provider": "codex"}, + "trace": [{"kind": "usage"}], + "insights": [{"kind": "warn"}], + "tools": { + "total_calls": 7, + "total_output_tokens": 1000, + "unique_used": 1, + "by_namespace": [{"namespace": "shell"}], + "by_name": [{"name": "exec"}], + "by_execution": [{"execution": 2}], + }, + } + registry = mock.Mock() + registry.descriptors = () + with mock.patch.object(meter, "runtime_registry", return_value=registry): + projected = meter.dashboard_state_payload(state) + + self.assertNotIn("trace", projected) + self.assertNotIn("insights", projected) + self.assertEqual(projected["tools"]["total_calls"], 7) + self.assertEqual(projected["tools"]["total_output_tokens"], 1000) + self.assertEqual(projected["tools"]["unique_used"], 1) + for removed in ("by_namespace", "by_name", "by_execution"): + self.assertNotIn(removed, projected["tools"]) + self.assertEqual(state["trace"], [{"kind": "usage"}]) + + def test_dashboard_payload_replaces_trace_paths_with_opaque_keys(self): + from token_meter.domain.compare import trace_key + current = "/Users/private-user/.codex/sessions/rollout-1.jsonl" + other = "/Users/private-user/.claude/projects/p/two.jsonl" + state = { + "source": {"id": "one", "path": current, "project": "~/p"}, + "xsession": { + "sessions": [ + {"id": "one", "path": current, "project": "~/p"}, + {"id": "two", "path": other, "project": "~/p"}, + ], + "current_sessions": [{"id": "one", "project": "~/p"}], + "capabilities": { + "items": [], + "claude_desktop": { + "local_agent_sessions": 1, + "roots": ["~/Library/Application Support/Claude"], + }, + }, + }, + } + registry = mock.Mock() + registry.descriptors = () + with mock.patch.object(meter, "runtime_registry", return_value=registry): + projected = meter.dashboard_state_payload(state) + + encoded = json.dumps(projected) + self.assertNotIn("private-user", encoded) + self.assertNotIn("path", projected["source"]) + self.assertEqual(projected["source"]["key"], trace_key({"path": current})) + self.assertEqual(projected["source"]["project"], "~/p") + rows = projected["xsession"]["sessions"] + self.assertEqual([row["key"] for row in rows], + [trace_key({"path": current}), trace_key({"path": other})]) + self.assertTrue(all("path" not in row for row in rows)) + self.assertEqual([row["project"] for row in rows], ["~/p", "~/p"]) + self.assertEqual(projected["xsession"]["current_sessions"], [{"id": "one", "project": "~/p"}]) + desktop = projected["xsession"]["capabilities"]["claude_desktop"] + self.assertNotIn("roots", desktop) + self.assertEqual(desktop["local_agent_sessions"], 1) + self.assertEqual(state["source"]["path"], current) + self.assertEqual(state["xsession"]["sessions"][0]["path"], current) + self.assertIn("roots", state["xsession"]["capabilities"]["claude_desktop"]) + + def test_dashboard_reprojects_agent_data_at_the_browser_boundary(self): + state = { + "source": {"id": "root", "provider": "codex"}, + "agent_group": { + "root_session_id": "root", "selected_agent_id": "root", + "coverage": {}, "totals": {"agents": 2}, + "agents": [{ + "id": "root", "kind": "root", "depth": 0, + "agent_path": "/private/agent.md", "prompt": "secret", + }], + "_all_agents": [{"path": "/private/trace.jsonl"}], + }, + "xsession": { + "agent_usage": { + "totals": {"agents": 1}, + "models": [{ + "id": "model::codex", "runtime": "codex", + "model": "model", "agents": 1, + "session_id": "private-session", + }], + }, + }, + } + registry = mock.Mock() + registry.descriptors = () + + with mock.patch.object(meter, "runtime_registry", return_value=registry): + projected = meter.dashboard_state_payload(state) + + encoded = json.dumps(projected) + self.assertEqual(projected["agent_group"]["root_session_id"], "root") + self.assertEqual( + projected["xsession"]["agent_usage"]["models"][0]["id"], + "model::codex", + ) + for forbidden in ( + "/private", "secret", "_all_agents", "agent_path", + "private-session", "session_id\": \"private-session", + ): + self.assertNotIn(forbidden, encoded) + + def _live_state_with_cross(self, cross): + return { + "ok": True, + "source": {"id": "root", "provider": "codex"}, + "trace": [{"kind": "usage", "text": "PRIVATE-TRACE"}], + "insights": [{"text": "PRIVATE-INSIGHT"}], + "recommendation": "PRIVATE-RECOMMENDATION", + "verdict": "PRIVATE-VERDICT", + "tools": { + "total_calls": 3, + "by_name": [{"name": "PRIVATE-TOOL"}], + "by_execution": [{"execution": 1}], + }, + "agent_group": { + "root_session_id": "root", "selected_agent_id": "root", + "coverage": {}, "totals": {"agents": 1}, + "agents": [{"id": "root", "kind": "root", "depth": 0, + "agent_path": "/private/agent.md", "prompt": "PRIVATE-PROMPT"}], + "_all_agents": [{"path": "/private/trace.jsonl"}], + }, + "total_cost": 1.5, + "xsession": cross, + } + + def test_dashboard_state_body_replaces_trace_paths_on_cached_and_held_polls(self): + from token_meter.domain.compare import trace_key + current = "/Users/private-user/.codex/sessions/rollout-1.jsonl" + other = "C:\\Users\\private-user\\.claude\\projects\\p\\two.jsonl" + cross = { + "generated_at": 100, + "sessions": [ + {"id": "one", "path": current, "project": "~/p"}, + {"id": "two", "path": other, "project": "~/p"}, + ], + } + state = {"source": {"id": "one", "path": current}, "xsession": cross} + registry = mock.Mock() + registry.descriptors = () + with mock.patch.object(meter, "runtime_registry", return_value=registry): + first = meter.dashboard_state_body(state) + full = json.loads(first) + again = json.loads(meter.dashboard_state_body(state)) + held = json.loads(meter.dashboard_state_body(state, full["xsession_revision"])) + + self.assertNotIn("private-user", first) + self.assertEqual(full["source"]["key"], trace_key({"path": current})) + self.assertEqual([row["key"] for row in full["xsession"]["sessions"]], + [trace_key({"path": current}), trace_key({"path": other})]) + self.assertEqual(again["xsession"], full["xsession"]) + self.assertNotIn("xsession", held) + self.assertEqual(held["source"], full["source"]) + self.assertEqual(cross["sessions"][0]["path"], current) + + def test_dashboard_state_body_matches_projected_payload(self): + cross = { + "generated_at": 100, + "tool_waste": {"secret": True}, + "agent_usage": { + "totals": {"agents": 1}, + "models": [{"id": "model::codex", "runtime": "codex", "model": "model", + "agents": 1, "session_id": "private-session"}], + }, + "daily": [{"day": "2026-10-23", "cost": 2.0}], + } + state = self._live_state_with_cross(cross) + registry = mock.Mock() + registry.descriptors = () + with mock.patch.object(meter, "runtime_registry", return_value=registry): + expected = meter.dashboard_state_payload(state) + body = json.loads(meter.dashboard_state_body(state)) + + revision = body.pop("xsession_revision") + self.assertTrue(revision) + # Discounts are attached fresh from settings, outside the cached snapshot. + self.assertEqual(set(body.pop("cost_discounts")), set(meter.cost_discount_settings())) + self.assertEqual(body, expected) + self.assertNotIn("tool_waste", body["xsession"]) + encoded = json.dumps(body) + for forbidden in ("PRIVATE", "/private", "_all_agents", "agent_path", + "private-session", "by_name", "by_execution"): + self.assertNotIn(forbidden, encoded) + + def test_dashboard_state_body_omits_unchanged_cross_session_snapshot(self): + cross = {"generated_at": 100, "daily": [{"day": "2026-10-23"}]} + state = self._live_state_with_cross(cross) + registry = mock.Mock() + registry.descriptors = () + with mock.patch.object(meter, "runtime_registry", return_value=registry): + first = json.loads(meter.dashboard_state_body(state)) + revision = first["xsession_revision"] + state["total_cost"] = 2.5 + repeat = json.loads(meter.dashboard_state_body(state, revision)) + stale = json.loads(meter.dashboard_state_body(state, "older")) + + self.assertNotIn("xsession", repeat) + self.assertTrue(repeat["xsession_unchanged"]) + self.assertEqual(repeat["xsession_revision"], revision) + self.assertEqual(repeat["total_cost"], 2.5) + self.assertEqual(stale["xsession"], first["xsession"]) + self.assertNotIn("xsession_unchanged", stale) + + def test_dashboard_state_body_encodes_each_cross_snapshot_once(self): + cross = {"generated_at": 100, "daily": [{"day": "2026-10-23"}]} + rebuilt = {"generated_at": 100, "daily": [{"day": "2026-10-24"}]} + registry = mock.Mock() + registry.descriptors = () + with mock.patch.object(meter, "runtime_registry", return_value=registry): + with mock.patch.object( + meter, "_dashboard_public_cross", + wraps=meter._dashboard_public_cross) as project: + first = json.loads(meter.dashboard_state_body( + self._live_state_with_cross(cross))) + again = json.loads(meter.dashboard_state_body( + self._live_state_with_cross(cross))) + self.assertEqual(project.call_count, 1) + changed = json.loads(meter.dashboard_state_body( + self._live_state_with_cross(rebuilt), + first["xsession_revision"], + )) + self.assertEqual(project.call_count, 2) + + self.assertEqual(first["xsession_revision"], again["xsession_revision"]) + self.assertNotEqual(changed["xsession_revision"], first["xsession_revision"]) + self.assertEqual(changed["xsession"]["daily"], [{"day": "2026-10-24"}]) + + def test_dashboard_state_body_keeps_published_and_rebuilt_snapshots_encoded(self): + published = {"generated_at": 100, "daily": [{"day": "a"}]} + rebuilt = {"generated_at": 115, "daily": [{"day": "b"}]} + registry = mock.Mock() + registry.descriptors = () + previous_state, saved_cache = meter.STATE, dict(meter._xsess) + try: + meter.STATE = self._live_state_with_cross(published) + meter._xsess["data"] = rebuilt + with mock.patch.object(meter, "runtime_registry", return_value=registry), \ + mock.patch.object(meter, "_dashboard_public_cross", + wraps=meter._dashboard_public_cross) as project: + revisions = [ + json.loads(meter.dashboard_state_body( + self._live_state_with_cross(cross)))["xsession_revision"] + for cross in (published, rebuilt, published, rebuilt) + ] + self.assertEqual(project.call_count, 2) + self.assertEqual(revisions[0], revisions[2]) + self.assertEqual(revisions[1], revisions[3]) + self.assertNotEqual(revisions[0], revisions[1]) + + replacement = {"generated_at": 130, "daily": [{"day": "c"}]} + meter.STATE = self._live_state_with_cross(replacement) + meter._xsess["data"] = replacement + meter.dashboard_state_body(meter.STATE) + cached = [entry[0] for entry in meter._dashboard_cross_cache["entries"]] + self.assertEqual(len(cached), 1) + self.assertIs(cached[0], replacement) + finally: + meter.STATE = previous_state + meter._xsess.clear() + meter._xsess.update(saved_cache) + + def test_state_route_omits_cross_session_snapshot_the_client_holds(self): + cross = {"generated_at": 100, "daily": [{"day": "2026-10-23"}]} + previous = meter.STATE + try: + meter.STATE = self._live_state_with_cross(cross) + request = test_builder_recap.BuilderRecapEndpointTests.request + full, status, content_type = request(self, "/state") + self.assertEqual(status, 200) + self.assertEqual(content_type, "application/json") + revision = full["xsession_revision"] + self.assertEqual(full["xsession"]["daily"], cross["daily"]) + held, _, _ = request(self, "/state?xsession=" + revision) + stale, _, _ = request(self, "/state?xsession=older") + finally: + meter.STATE = previous + self.assertNotIn("xsession", held) + self.assertTrue(held["xsession_unchanged"]) + self.assertEqual(held["xsession_revision"], revision) + self.assertEqual(held["total_cost"], full["total_cost"]) + self.assertEqual(stale["xsession"], full["xsession"]) + for body in (full, held, stale): + self.assertNotIn("PRIVATE", json.dumps(body)) + + def test_session_route_omits_cross_session_snapshot_the_client_holds(self): + cross = {"generated_at": 100, "current_sessions": [{"id": "session-1"}]} + source = {"id": "session-1", "path": "/logs/exact.jsonl"} + saved_cache = dict(meter._xsess) + + def get(path): + handler = object.__new__(meter.H) + handler.path = path + handler.headers = {"Host": "127.0.0.1:8722"} + sent = [] + handler._send = lambda body, *_args, **_kwargs: sent.append(json.loads(body)) + state = {"source": {"id": "session-1"}, "timing": {}} + with mock.patch.object(meter, "find_session", return_value=source), \ + mock.patch.object(meter, "cached_session_state", return_value=state), \ + mock.patch.object(meter, "selected_session_stats", return_value={}): + handler.do_GET() + return sent[0] + + try: + meter._xsess["data"] = cross + full = get("/session?id=session-1&live=1") + held = get("/session?id=session-1&live=1&xsession=" + full["xsession_revision"]) + finally: + meter._xsess.clear() + meter._xsess.update(saved_cache) + self.assertEqual(full["xsession"]["current_sessions"], cross["current_sessions"]) + self.assertFalse(full["ended"]) + self.assertNotIn("xsession", held) + self.assertTrue(held["xsession_unchanged"]) + self.assertFalse(held["ended"]) + + @unittest.skipUnless(shutil.which("node"), "Node.js is required for live polling verification") + def test_live_polling_reuses_only_the_snapshot_matching_its_revision(self): + page_path = Path(DASHBOARD_PAGE) + script = f""" +const fs=require('fs'),page=fs.readFileSync({json.dumps(str(page_path))},'utf8'); +function extract(name){{let start=page.indexOf(`function ${{name}}(`);if(start<0)throw Error(`missing ${{name}}`);let i=page.indexOf('{{',start),depth=0;for(;i{{urls.push(url);const r=replies.shift();return {{ok:r.status===undefined,status:r.status||200,json:async()=>r}};}}; +(async()=>{{ + const holder={{snapshot:null,revision:''}}; + replies=[{{xsession_revision:'r1',xsession:{{n:1}}}}]; + const first=await fetchLiveJson('/state',holder,'State'); + replies=[{{xsession_revision:'r1',xsession_unchanged:true}}]; + const reused=await fetchLiveJson('/session?id=a&live=1',holder,'Session'); + holder.revision='r1';holder.snapshot={{n:1}}; + replies=[{{xsession_revision:'r2',xsession_unchanged:true}},{{xsession_revision:'r2',xsession:{{n:2}}}}]; + const mismatch=await fetchLiveJson('/state',holder,'State'); + replies=[{{status:503}}]; + let error='';try{{await fetchLiveJson('/state',holder,'State');}}catch(e){{error=e.message;}} + console.log(JSON.stringify({{urls,first:first.xsession,reused:reused.xsession,sameObject:reused.xsession===first.xsession, + mismatch:mismatch.xsession,held:holder.revision,error}})); +}})().catch(e=>{{console.error(e);process.exit(1);}}); +""" + result = subprocess.run(["node", "-e", script], capture_output=True, text=True, check=False) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(result.stdout), { + "urls": [ + "/state?xsession=", + "/session?id=a&live=1&xsession=r1", + "/state?xsession=r1", + "/state?xsession=", + "/state?xsession=r2", + ], + "first": {"n": 1}, + "reused": {"n": 1}, + "sameObject": True, + "mismatch": {"n": 2}, + "held": "r2", + "error": "State request failed (503).", + }) + + def test_dashboard_state_body_without_cross_session_has_no_revision(self): + registry = mock.Mock() + registry.descriptors = () + with mock.patch.object(meter, "runtime_registry", return_value=registry): + body = json.loads(meter.dashboard_state_body( + {"ok": False, "loading": True, "source": {}}, "stale", + )) + self.assertNotIn("xsession", body) + self.assertNotIn("xsession_unchanged", body) + self.assertEqual(body["xsession_revision"], "") + + def test_publish_skips_serialization_without_subscribers(self): + state = {"ok": True, "source": {"id": "s"}} + previous = meter.STATE + try: + with mock.patch.object(meter.json, "dumps") as dumps: + meter.publish(state) + dumps.assert_not_called() + self.assertIs(meter.STATE, state) + q_ = meter.queue.Queue(maxsize=2) + with meter.subscribers_lock: + meter.subscribers.append(q_) + try: + meter.publish(state) + finally: + with meter.subscribers_lock: + meter.subscribers.remove(q_) + self.assertEqual(q_.get_nowait(), "data: " + json.dumps(state) + "\n\n") + finally: + meter.STATE = previous + + def test_check_refuses_to_fall_back_across_projects(self): + with mock.patch.object(meter, "all_session_sources", return_value=[self.source]): + result = meter.agent_check(caller={"runtime": "codex", "project": "/another/repository"}) + self.assertFalse(result["ok"]) + self.assertIn("did not fall back", result["caveat"]) + + def test_claude_trace_cwd_preserves_hyphenated_project_name(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "session.jsonl" + path.write_text(json.dumps({ + "type": "user", "cwd": "/Users/test/Documents/github/token-meter", + }) + "\n") + cwd = meter.claude_trace_cwd(str(path)) + self.assertEqual(cwd, "/Users/test/Documents/github/token-meter") + + def test_current_run_resolution_prefers_exact_project_over_newer_parent(self): + now = meter.time.time() + exact = {**self.source, "id": "exact", "project": "/Users/test/work/repository", "mtime": now - 10} + parent = {**self.source, "id": "parent", "project": "/Users/test/work", "mtime": now} + selected, resolution = meter.resolve_agent_source( + caller={"runtime": "codex", "project": "/Users/test/work/repository"}, + sources=[parent, exact], + ) + self.assertEqual(selected["id"], "exact") + self.assertEqual(resolution, "matched") + + def test_current_run_resolution_rejects_stale_implicit_match(self): + stale = {**self.source, "mtime": meter.time.time() - meter.AGENT_CURRENT_MAX_AGE_S - 1} + selected, resolution = meter.resolve_agent_source( + caller={"runtime": "codex", "project": "/Users/test/work/repository"}, + sources=[stale], + ) + self.assertIsNone(selected) + self.assertIn("No recent Codex run", resolution) + + def test_usage_is_aggregate_only_and_ranked_categories_are_bounded(self): + cross = { + "daily": [{"day": time_day, "cost": 2.0, "sessions": 2, + "providers": [{"provider": "codex", "cost": 2.0}], + "tool_tokens": 1000, "flagged_tokens": 300} + for time_day in ("2026-07-01", "2026-06-30")], + "sessions": [{"cost_approx": True, "title": "private title", "project": "/private/repo"}], + "model_mix": [{"model": f"model-{idx}", "cost": idx, "tokens": idx * 100} + for idx in range(8)], + "tool_waste": {"by_name": [ + {"name": f"tool-{idx}", "namespace": "safe", "output_tokens": idx * 100, "calls": idx} + for idx in range(8) + ]}, + } + with mock.patch.dict(meter._xsess, { + "data": cross, "at": meter.time.time(), "internal_rows": (), + }): + result = meter.agent_usage(window="7d", focus="models") + encoded = json.dumps(result) + self.assertEqual(result["data_scope"], "anonymous_aggregate_history") + self.assertLessEqual(len(result["categories"]), 5) + self.assertTrue(result["dashboard_url"].endswith("/#spend")) + self.assertNotIn("private title", encoded) + self.assertNotIn("/private/repo", encoded) + + def test_usage_model_categories_are_scoped_to_calendar_window(self): + today = datetime.date.today() + recent = today - datetime.timedelta(days=1) + outside_seven_days = today - datetime.timedelta(days=7) + daily = [ + {"day": today.isoformat(), "cost": 2.0, "sessions": 1, + "providers": [{"provider": "codex", "cost": 2.0}]}, + {"day": recent.isoformat(), "cost": 3.0, "sessions": 1, + "providers": [{"provider": "codex", "cost": 3.0}]}, + {"day": outside_seven_days.isoformat(), "cost": 5.0, "sessions": 1, + "providers": [{"provider": "codex", "cost": 5.0}]}, + ] + internal = { + "id": "one", "provider": "codex", "runtime": "Codex", + "availability": {"cost": True, "tokens": True}, + "_model_daily": [ + {"model": "model-current", "day": today.isoformat(), "cost": 2.0, + "input_tokens": 100, "output_tokens": 20, "executions": 1}, + {"model": "model-week", "day": recent.isoformat(), "cost": 3.0, + "input_tokens": 200, "output_tokens": 30, "executions": 1}, + {"model": "model-fortnight", "day": outside_seven_days.isoformat(), "cost": 5.0, + "input_tokens": 300, "output_tokens": 50, "executions": 1}, + ], + } + cross = { + "daily": daily, "sessions": [internal], + "model_mix": [{"model": "all-time", "cost": 999.0, "tokens": 999999}], + "tool_waste": {"by_name": []}, + } + + with mock.patch.dict(meter._xsess, { + "data": cross, "at": meter.time.time(), "internal_rows": (internal,), + }): + today_result = meter.agent_usage(window="today", focus="models") + week_result = meter.agent_usage(window="7d", focus="models") + fortnight_result = meter.agent_usage(window="14d", focus="models") + + self.assertEqual([row["model"] for row in today_result["categories"]], ["model-current"]) + self.assertEqual( + {row["model"] for row in week_result["categories"]}, + {"model-current", "model-week"}, + ) + self.assertEqual( + {row["model"] for row in fortnight_result["categories"]}, + {"model-current", "model-week", "model-fortnight"}, + ) + self.assertEqual([today_result["days_observed"], week_result["days_observed"], + fortnight_result["days_observed"]], [1, 2, 3]) + + def test_usage_tool_categories_and_evidence_share_the_selected_window(self): + today = datetime.date.today() + recent = today - datetime.timedelta(days=1) + outside_seven_days = today - datetime.timedelta(days=7) + + def midday(day): + return int(time.mktime((*day.timetuple()[:3], 12, 0, 0, 0, 0, -1))) + + internal = { + "id": "one", "provider": "codex", "runtime": "Codex", + "availability": {"cost": True, "tokens": True}, + "_tool_evidence": meter.summarize_tool_evidence([ + {**meter.tool_identity("search"), "output_tokens": 120, + "ts": midday(today), "args_fingerprint": "today", "error": False}, + {**meter.tool_identity("fetch"), "output_tokens": 80, + "ts": midday(recent), "args_fingerprint": "recent", "error": False}, + {**meter.tool_identity("search"), "output_tokens": 500, + "ts": midday(outside_seven_days), "args_fingerprint": "older", "error": False}, + ]), + } + cross = { + "daily": [ + {"day": today.isoformat(), "cost": 2.0, "sessions": 1, + "providers": [{"provider": "codex", "cost": 2.0}]}, + {"day": recent.isoformat(), "cost": 3.0, "sessions": 1, + "providers": [{"provider": "codex", "cost": 3.0}]}, + {"day": outside_seven_days.isoformat(), "cost": 5.0, "sessions": 1, + "providers": [{"provider": "codex", "cost": 5.0}]}, + ], + "sessions": [internal], "model_mix": [], + "tool_waste": {"by_name": [ + {"name": "search", "namespace": "unknown", "output_tokens": 620, "calls": 2}, + {"name": "fetch", "namespace": "unknown", "output_tokens": 80, "calls": 1}, + ]}, + } + + with mock.patch.dict(meter._xsess, { + "data": cross, "at": meter.time.time(), "internal_rows": (internal,), + }): + today_result = meter.agent_usage(window="today", focus="tools") + week_result = meter.agent_usage(window="7d", focus="tools") + fortnight_result = meter.agent_usage(window="14d", focus="tools") + + def tool_evidence(result): + return next(row["value"] for row in result["evidence"] + if row["label"] == "Trace-observed tool results") + + self.assertEqual(today_result["categories"], [{ + "name": "search", "namespace": "search", "returned_tokens": 120, "calls": 1, + }]) + self.assertEqual(tool_evidence(today_result), 120) + self.assertEqual(tool_evidence(week_result), 200) + self.assertEqual(tool_evidence(fortnight_result), 700) + self.assertEqual( + {row["name"]: row["returned_tokens"] for row in week_result["categories"]}, + {"search": 120, "fetch": 80}, + ) + + def test_usage_headline_and_action_answer_the_requested_focus(self): + today = datetime.date.today() + recent = today - datetime.timedelta(days=1) + + def midday(day): + return int(time.mktime((*day.timetuple()[:3], 12, 0, 0, 0, 0, -1))) + + internal = { + "id": "one", "provider": "codex", "runtime": "Codex", + "availability": {"cost": True, "tokens": True}, + "_model_daily": [ + {"model": "model-top", "day": today.isoformat(), "cost": 5.0, + "input_tokens": 100, "output_tokens": 20, "executions": 1}, + {"model": "model-top", "day": recent.isoformat(), "cost": 5.0, + "input_tokens": 100, "output_tokens": 20, "executions": 1}, + ], + "_tool_evidence": meter.summarize_tool_evidence([ + {**meter.tool_identity("search"), "output_tokens": 9000, + "ts": midday(today), "args_fingerprint": "today", "error": False}, + ]), + } + cross = { + "daily": [ + {"day": today.isoformat(), "cost": 5.0, "sessions": 1, + "providers": [{"provider": "codex", "cost": 5.0}]}, + {"day": recent.isoformat(), "cost": 5.0, "sessions": 1, + "providers": [{"provider": "codex", "cost": 5.0}]}, + ], + "sessions": [internal], "model_mix": [], "tool_waste": {"by_name": []}, + } + + with mock.patch.dict(meter._xsess, { + "data": cross, "at": meter.time.time(), "internal_rows": (internal,), + }): + spend = meter.agent_usage(window="7d", focus="spend") + models = meter.agent_usage(window="7d", focus="models") + tools = meter.agent_usage(window="7d", focus="tools") + + self.assertIn("$10.00", spend["answer"]) + self.assertIn("spend", spend["recommended_action"].lower()) + self.assertIn("model-top", models["answer"]) + self.assertIn("model", models["recommended_action"].lower()) + self.assertIn("9,000", tools["answer"]) + self.assertIn("tool", tools["recommended_action"].lower()) + self.assertEqual(len({spend["answer"], models["answer"], tools["answer"]}), 3) + + def _windowed_usage_fixture(self): + today = datetime.date.today() + now = meter.time.time() + + def midday(days_ago): + day = today - datetime.timedelta(days=days_ago) + return day, time.mktime((*day.timetuple()[:3], 12, 0, 0, 0, 0, -1)) + + def source(session_id, provider, days_ago, **extra): + _day, ts = midday(days_ago) + return {"id": session_id, "provider": provider, "path": f"/missing/{session_id}.jsonl", + "mtime": min(ts, now), "signature_mtime": min(ts, now), **extra} + + sources = [ + source("recent-claude", "claude", 0), + source("old-claude", "claude", 30), + source("older-codex", "codex", 40, cost_approx=True), + source("old-cursor", "cursor", 50), + source("week-codex", "codex", 6), + ] + calls = [] + + def summary(row, opencode_conn=None): + calls.append(row["id"]) + day, ts = midday(int((today - datetime.date.fromtimestamp(row["mtime"])).days)) + key = day.isoformat() + return { + "id": row["id"], "provider": row["provider"], "runtime": row["provider"], + "mtime": row["mtime"], "turns": 1, "cost_approx": bool(row.get("cost_approx")), + "availability": {"cost": True, "tokens": True}, + "_day_cost": {key: 1.0}, "_model_daily": [{ + "model": "m-" + row["id"], "day": key, "cost": 1.0, + "input_tokens": 10, "output_tokens": 2, "executions": 1, + }], + } + + return sources, calls, summary + + def _windowed_usage(self, sources, summary, **patches): + with mock.patch.dict(meter._xsess, {"data": None, "at": 0.0}), \ + mock.patch.object(meter, "all_session_sources", return_value=list(sources)), \ + mock.patch.object(meter, "session_summary", side_effect=summary), \ + mock.patch.object(meter, "cross_session", side_effect=AssertionError), \ + contextlib.ExitStack() as stack: + for name, value in patches.items(): + stack.enter_context(mock.patch.object(meter, name, value)) + return { + (window, focus): meter.agent_usage(window=window, focus=focus) + for window in ("today", "7d", "14d") + for focus in ("spend", "models", "tools", "changes") + } + + def test_windowed_usage_skips_bounded_sessions_outside_the_window(self): + sources, calls, summary = self._windowed_usage_fixture() + with mock.patch.object(meter, "AGENT_USAGE_RECENT_SESSIONS", 2): + windowed = self._windowed_usage(sources, summary) + self.assertNotIn("old-claude", calls) + self.assertNotIn("older-codex", calls) + self.assertIn("old-cursor", calls) + self.assertEqual(set(calls), {"recent-claude", "week-codex", "old-cursor"}) + + calls.clear() + with mock.patch.object(meter, "AGENT_USAGE_RECENT_SESSIONS", 2): + unfiltered = self._windowed_usage( + sources, summary, source_activity_bound=lambda _source: None, + ) + self.assertEqual(set(calls), {source["id"] for source in sources}) + for key, result in windowed.items(): + with self.subTest(key=key): + expected = dict(unfiltered[key]) + actual = dict(result) + expected.pop("as_of") + actual.pop("as_of") + self.assertEqual(actual, expected) + self.assertEqual( + [row["model"] for row in windowed[("7d", "models")]["categories"]], + ["m-recent-claude", "m-week-codex"], + ) + self.assertEqual(windowed[("7d", "models")]["approximate_fields"], []) + + def test_windowed_usage_settles_the_recent_session_list(self): + sources, calls, summary = self._windowed_usage_fixture() + with mock.patch.object(meter, "AGENT_USAGE_RECENT_SESSIONS", 3): + windowed = self._windowed_usage(sources, summary) + # Three recent rows need the newest out-of-window bounded session, and only it. + self.assertIn("old-claude", calls) + self.assertNotIn("older-codex", calls) + self.assertEqual(windowed[("7d", "models")]["approximate_fields"], []) + + calls.clear() + with mock.patch.object(meter, "AGENT_USAGE_RECENT_SESSIONS", 4): + windowed = self._windowed_usage(sources, summary) + self.assertIn("older-codex", calls) + self.assertEqual(windowed[("7d", "models")]["approximate_fields"], ["cost"]) + + def test_usage_serves_the_previous_snapshot_while_history_reprices(self): + today = datetime.date.today().isoformat() + cross = {"daily": [{"day": today, "cost": 4.0, "sessions": 1, + "providers": [{"provider": "codex", "cost": 4.0}]}], + "sessions": []} + with mock.patch.dict(meter._xsess, {"data": cross, "at": 0.0, "internal_rows": ()}), \ + mock.patch.object(meter, "pricing_recalculation_running", return_value=True), \ + mock.patch.object(meter, "all_session_sources", side_effect=AssertionError): + result = meter.agent_usage(window="today", focus="spend") + self.assertIn("$4.00", result["answer"]) + + def test_activity_bound_uses_the_newest_tracked_time_and_only_bounded_runtimes(self): + with tempfile.TemporaryDirectory() as directory: + path = os.path.join(directory, "trace.jsonl") + with open(path, "w", encoding="utf-8") as handle: + handle.write("{}\n") + os.utime(path, (2_000.0, 2_000.0)) + self.assertEqual(meter.source_activity_bound( + {"provider": "claude", "path": path, "mtime": 1_000.0, "signature_mtime": 1_500.0}, + ), 2_000.0) + self.assertEqual(meter.source_activity_bound( + {"provider": "codex", "path": path, "mtime": 3_000.0}, + ), 3_000.0) + for source in ( + {"provider": "cursor", "mtime": 1.0}, + {"provider": "claude", "mtime": float("nan")}, + {"provider": "codex", "mtime": "bad"}, + {"provider": "claude"}, + None, + ): + with self.subTest(source=source): + self.assertIsNone(meter.source_activity_bound(source)) + + def test_capability_result_names_only_requested_evidence(self): + cross = {"capabilities": { + "summary": {"optional": { + "enabled": 2, "unused": 1, + "review_candidates": ["skill_pack:Codex:docs@personal"], + }}, + "control_groups": [ + {"id": "skill_pack:Codex:docs@personal", "name": "docs@personal", + "control_type": "skill_pack", "runtime": "Codex", "mutable": True, + "reviewable": True, "used": False, "enabled": True, "activations": 0, + "environment": {"TOKEN": "secret"}}, + {"name": "tokenmeter", "control_type": "skill_pack", "runtime": "Codex", + "used": False, "enabled": True}, + ], + }} + with mock.patch.object(meter, "agent_capability_evidence", + return_value=(cross["capabilities"], {"complete": True})): + result = meter.agent_capabilities(scope="all", limit=5) + encoded = json.dumps(result) + self.assertEqual([row["name"] for row in result["candidates"]], ["docs@personal"]) + self.assertNotIn("TOKEN", encoded) + self.assertNotIn("secret", encoded) + + def test_all_scope_capabilities_returns_only_counted_review_candidates(self): + candidate_id = "skill_pack:Codex:z-review" + cross = {"capabilities": { + "summary": {"optional": { + "enabled": 1, "unused": 1, "review_candidates": [candidate_id], + }}, + "control_groups": [ + {"id": "skill_pack:Codex:a-disabled", "name": "a-disabled", + "control_type": "skill_pack", "runtime": "Codex", "mutable": True, + "reviewable": True, "used": False, "enabled": False}, + {"id": candidate_id, "name": "z-review", "control_type": "skill_pack", + "runtime": "Codex", "mutable": True, "reviewable": True, + "used": False, "enabled": True}, + ], + }} + + with mock.patch.object(meter, "agent_capability_evidence", + return_value=(cross["capabilities"], {"complete": True})): + result = meter.agent_capabilities(scope="all", limit=5) + + self.assertEqual([row["name"] for row in result["candidates"]], ["z-review"]) + self.assertEqual(result["candidate_count"], 1) + self.assertEqual(result["candidates_returned"], 1) + + @contextlib.contextmanager + def cold_session_history(self, count, parse_s, discovery_s=0.0, discovery_error=None): + sources = [ + {"provider": "codex", "path": f"/synthetic/{index}.jsonl", "id": str(index), + "mtime": float(index)} + for index in range(count) + ] + parsed = [] + scanned_rows = [] + + def discover(): + time.sleep(discovery_s) + if discovery_error is not None: + raise discovery_error + return sources + + def summarize(source, opencode_conn=None): + if source["path"] in meter._summary_cache: + return meter._summary_cache[source["path"]]["row"] + time.sleep(parse_s) + parsed.append(source["path"]) + row = {"turns": 1, "path": source["path"], "mtime": source["mtime"]} + meter._summary_cache[source["path"]] = {"signature": source["path"], "row": row} + return row + + def waste(rows): + scanned_rows[:] = rows + return {} + + inventory = { + "summary": {"optional": {"enabled": 1, "review_candidates": ["skill_pack:Codex:docs"]}}, + "control_groups": [{"id": "skill_pack:Codex:docs", "name": "docs", + "control_type": "skill_pack", "runtime": "Codex", + "used": False, "enabled": True}], + } + with mock.patch.dict(meter._xsess, {"data": None}), \ + mock.patch.dict(meter._history_scan, {"progress": None, "last_complete": None}), \ + mock.patch.dict(meter._summary_cache, {}, clear=True), \ + mock.patch.object(meter, "all_session_sources", side_effect=discover), \ + mock.patch.object(meter, "canonical_aggregation_sources", side_effect=list), \ + mock.patch.object(meter, "session_summary", side_effect=summarize), \ + mock.patch.object(meter, "global_tool_waste", side_effect=waste), \ + mock.patch.object(meter, "capability_inventory", return_value=inventory), \ + mock.patch.object(meter, "cross_session", side_effect=AssertionError("full aggregate")): + try: + yield parsed, scanned_rows + finally: + # The scan thread must not outlive these patches. + progress = meter._history_scan["progress"] + if progress is not None: + self.assertTrue(progress["done"].wait(10)) + + def wait_for_history_scan(self): + self.assertTrue(meter._history_scan["progress"]["done"].wait(10)) + + def test_cold_capabilities_answers_inside_the_client_timeout_with_partial_coverage(self): + self.assertLess(meter.AGENT_HISTORY_BUDGET_S, 60) + with self.cold_session_history(count=40, parse_s=0.1) as (parsed, scanned_rows), \ + mock.patch.object(meter, "AGENT_HISTORY_BUDGET_S", 1.0): + started = time.monotonic() + result = meter.agent_capabilities(scope="all", limit=5) + elapsed = time.monotonic() - started + + self.assertLess(elapsed, 1.0 + 1.0) + coverage = result["coverage"] + self.assertFalse(coverage["complete"]) + self.assertEqual(coverage["sessions_total"], 40) + self.assertGreater(coverage["sessions_scanned"], 0) + self.assertLess(coverage["sessions_scanned"], 40) + self.assertEqual(result["assessment"], "Partial review") + self.assertIn("unscanned session", result["caveat"]) + self.assertIn(f"{coverage['sessions_scanned']} of 40", result["answer"]) + self.assertEqual(result["evidence"][-1]["label"], "Sessions scanned") + # Newest sources are scanned first. + self.assertEqual( + sorted(row["mtime"] for row in scanned_rows), + [float(index) for index in range(40 - len(scanned_rows), 40)], + ) + + self.wait_for_history_scan() + again = meter.agent_capabilities(scope="all", limit=5) + + self.assertEqual(again["coverage"], + {"complete": True, "sessions_scanned": 40, "sessions_total": 40}) + self.assertEqual(again["assessment"], "Review available") + self.assertNotIn("unscanned session", again["caveat"]) + self.assertEqual(len(again["evidence"]), 2) + # The background scan parsed each session once. + self.assertEqual(sorted(parsed), sorted(set(parsed))) + self.assertEqual(len(parsed), 40) + + def test_capabilities_answers_while_discovery_outlasts_the_budget(self): + with self.cold_session_history(count=5, parse_s=0, discovery_s=2.0) as (parsed, _rows), \ + mock.patch.object(meter, "AGENT_HISTORY_BUDGET_S", 0.05): + started = time.monotonic() + result = meter.agent_capabilities(scope="all", limit=5) + elapsed = time.monotonic() - started + + self.assertLess(elapsed, 1.5) + self.assertEqual(parsed, []) + self.assertFalse(result["ok"]) + self.assertEqual(result["candidates"], []) + self.assertNotIn("candidate_count", result) + self.assertEqual(result["evidence"], + [{"label": "Sessions scanned", "value": "0; discovery still running"}]) + self.assertEqual(result["coverage"], + {"complete": False, "sessions_scanned": 0, "sessions_total": None}) + + def test_cold_current_scope_answers_before_discovery_finishes(self): + with self.cold_session_history(count=3, parse_s=0, discovery_s=2.0) as (parsed, _rows), \ + mock.patch.object(meter, "AGENT_HISTORY_BUDGET_S", 0.05), \ + mock.patch.object(meter, "recompute", side_effect=AssertionError("no run yet")): + started = time.monotonic() + result = meter.agent_capabilities( + scope="current", limit=5, caller={"runtime": "codex", "project": "/synthetic"}, + ) + elapsed = time.monotonic() - started + discoveries = meter.all_session_sources.call_count + + self.assertLess(elapsed, 1.5) + # Run resolution waits for the scan's discovery instead of starting its own. + self.assertEqual(discoveries, 1) + self.assertFalse(result["ok"]) + self.assertEqual(result["assessment"], "History scan incomplete") + self.assertEqual(result["candidates"], []) + self.assertEqual(result["evidence"], + [{"label": "Sessions scanned", "value": "0; discovery still running"}]) + self.assertNotIn("0 of 0", json.dumps(result)) + + def test_current_scope_resolves_the_run_from_the_scan_discovery(self): + current = {"provider": "codex", "path": "/synthetic/2.jsonl", "id": "2"} + summary = {"enabled": 1, "groups": [ + {"id": "skill_pack:Codex:docs", "name": "docs", "control_type": "skill_pack", + "runtime": "Codex", "current_used": False}, + ]} + resolve = mock.Mock(return_value=(current, "")) + with self.cold_session_history(count=40, parse_s=0.1), \ + mock.patch.object(meter, "AGENT_HISTORY_BUDGET_S", 1.0), \ + mock.patch.object(meter, "resolve_agent_source", resolve), \ + mock.patch.object(meter, "recompute", return_value={"trace": []}), \ + mock.patch.object(meter, "agent_session_summary", return_value={"id": "2"}), \ + mock.patch.object(meter, "session_optional_capabilities", return_value=summary): + result = meter.agent_capabilities(scope="current", limit=5) + discoveries = meter.all_session_sources.call_count + + self.assertEqual(discoveries, 1) + self.assertEqual(len(resolve.call_args.kwargs["sources"]), 40) + self.assertTrue(result["ok"]) + self.assertEqual(result["assessment"], "Partial review") + scanned = result["coverage"]["sessions_scanned"] + self.assertGreater(scanned, 0) + self.assertEqual(result["evidence"][-1]["value"], f"{scanned} of 40") + self.assertEqual([row["name"] for row in result["candidates"]], ["docs"]) + + def test_a_scan_thread_that_cannot_start_is_retried(self): + with self.cold_session_history(count=3, parse_s=0): + with mock.patch.object(meter.threading.Thread, "start", + side_effect=RuntimeError("can't start new thread")): + with self.assertRaises(RuntimeError): + meter.agent_capabilities(scope="all", limit=5) + stuck = meter._history_scan["progress"] + self.assertTrue(stuck["done"].is_set()) + result = meter.agent_capabilities(scope="all", limit=5) + self.assertIsNot(meter._history_scan["progress"], stuck) + + self.assertTrue(result["ok"]) + self.assertEqual(result["coverage"], + {"complete": True, "sessions_scanned": 3, "sessions_total": 3}) + + def test_cold_git_stats_withholds_totals_until_the_history_scan_completes(self): + project = mock.Mock(return_value={"ok": True, "projected": True}) + spend_rows = [] + with self.cold_session_history(count=40, parse_s=0.1) as (_parsed, _rows), \ + mock.patch.object(meter, "AGENT_HISTORY_BUDGET_S", 1.0), \ + mock.patch.object(meter, "cached_git_delivery_candidates", return_value=()), \ + mock.patch.object(meter, "git_delivery_ledger_candidates", return_value=()), \ + mock.patch.object(meter, "delivery_spend_rows", + side_effect=lambda rows: spend_rows.append( + [row["mtime"] for row in rows]) or []), \ + mock.patch.object(meter, "delivery_model_spend_rows", return_value=[]), \ + mock.patch.object(meter, "git_delivery_service") as service, \ + mock.patch.object(meter._mcp_delivery, "git_stats_projection", project): + started = time.monotonic() + cold = meter.agent_git_stats(range="30") + elapsed = time.monotonic() - started + self.wait_for_history_scan() + warm = meter.agent_git_stats(range="30") + + self.assertLess(elapsed, 1.0 + 1.0) + self.assertTrue(cold["ok"]) + self.assertFalse(cold["available"]) + self.assertEqual(cold["reason"], "history_scan_incomplete") + self.assertFalse(cold["history"]["complete"]) + self.assertEqual(cold["history"]["sessions_total"], 40) + self.assertNotIn("current", cold) + self.assertEqual(warm, {"ok": True, "projected": True}) + # Totals are computed only from the complete scan, in discovery order like cross_session. + self.assertEqual(spend_rows, [[float(index) for index in range(40)]]) + service.return_value.query.assert_called_once() + + def test_cold_work_stats_withholds_totals_until_the_history_scan_completes(self): + settings = {"enabled": True, "paused_until": 0, "areas": [], "backfill_days": 0} + service = mock.Mock() + service.status.return_value = {"labels": 2, "pending": 0} + build = mock.Mock(return_value=({"insights": True}, None)) + with self.cold_session_history(count=3, parse_s=0, discovery_s=2.0) as (_parsed, _rows), \ + mock.patch.object(meter, "AGENT_HISTORY_BUDGET_S", 0.05), \ + mock.patch.object(meter, "work_insights_supported", return_value=True), \ + mock.patch.object(meter, "work_insights_settings", return_value=settings), \ + mock.patch.object(meter, "work_insights_service", return_value=service), \ + mock.patch.object(meter, "_work_insights_aggregate", build), \ + mock.patch.object(meter._mcp_delivery, "work_stats_projection", + return_value={"ok": True, "available": True}): + started = time.monotonic() + cold = meter.agent_work_stats(period="30d", view="suggestions") + elapsed = time.monotonic() - started + self.wait_for_history_scan() + warm = meter.agent_work_stats(period="30d", view="suggestions") + + self.assertLess(elapsed, 1.5) + self.assertFalse(cold["available"]) + self.assertEqual(cold["reason"], "history_scan_incomplete") + self.assertEqual(cold["history"], + {"complete": False, "sessions_scanned": 0, "sessions_total": None}) + self.assertIn("withheld instead of undercounted", cold["caveat"]) + self.assertTrue(warm["available"]) + build.assert_called_once() + self.assertEqual(len(build.call_args.kwargs["source_rows"]), 3) + + def test_history_scan_fills_newest_chunks_on_the_index_pool(self): + chunks = [] + prefetch = mock.Mock(side_effect=lambda sources: chunks.append( + [int(source["id"]) for source in sources])) + with self.cold_session_history(count=40, parse_s=0), \ + mock.patch.object(meter, "HISTORY_SCAN_CHUNK_FIRST", 16), \ + mock.patch.object(meter, "HISTORY_SCAN_CHUNK_MIN", 16), \ + mock.patch.object(meter, "HISTORY_SCAN_CHUNK_MAX", 16), \ + mock.patch.object(meter, "prefetch_session_summaries", prefetch): + result = meter.agent_capabilities(scope="all", limit=5) + + self.assertTrue(result["coverage"]["complete"]) + self.assertEqual(chunks, [list(range(39, 23, -1)), list(range(23, 7, -1)), list(range(7, -1, -1))]) + + def test_history_scan_shrinks_chunks_that_overrun_the_time_target(self): + sizes = [] + + def slow_fill(sources): + sizes.append(len(sources)) + time.sleep(0.05) + return len(sources) + + with self.cold_session_history(count=40, parse_s=0), \ + mock.patch.object(meter, "HISTORY_SCAN_CHUNK_FIRST", 16), \ + mock.patch.object(meter, "HISTORY_SCAN_CHUNK_MIN", 4), \ + mock.patch.object(meter, "HISTORY_SCAN_CHUNK_MAX", 64), \ + mock.patch.object(meter, "HISTORY_SCAN_CHUNK_TARGET_S", 0.01), \ + mock.patch.object(meter, "prefetch_session_summaries", side_effect=slow_fill): + result = meter.agent_capabilities(scope="all", limit=5) + + self.assertTrue(result["coverage"]["complete"]) + # 16 sessions parsed in ~0.05 s is ~320/s, so a 0.01 s target is 3, clamped to 4. + self.assertEqual(sizes, [16] + [4] * 6) + + def test_history_scan_never_grows_chunks_from_cached_or_tiny_fills(self): + sizes = [] + returns = iter([0, 4, 40] + [0] * 20) + + def fill(sources): + sizes.append(len(sources)) + return next(returns) + + with self.cold_session_history(count=60, parse_s=0), \ + mock.patch.object(meter, "HISTORY_SCAN_CHUNK_FIRST", 8), \ + mock.patch.object(meter, "HISTORY_SCAN_CHUNK_MIN", 4), \ + mock.patch.object(meter, "HISTORY_SCAN_CHUNK_MAX", 64), \ + mock.patch.object(meter, "prefetch_session_summaries", side_effect=fill): + result = meter.agent_capabilities(scope="all", limit=5) + + self.assertTrue(result["coverage"]["complete"]) + # A cached (0) or sub-pool (4) fill keeps the size; a fast real fill at most doubles it. + self.assertEqual(sizes[:4], [8, 8, 8, 16]) + + def test_a_refresh_serves_the_last_complete_scan_while_it_runs(self): + with self.cold_session_history(count=5, parse_s=0) as (_parsed, _rows), \ + mock.patch.object(meter, "AGENT_HISTORY_BUDGET_S", 5), \ + mock.patch.object(meter, "HISTORY_REFRESH_WAIT_S", 0.05): + first = meter.agent_capabilities(scope="all", limit=5) + self.assertTrue(first["coverage"]["complete"]) + self.expire_history_scan() + discovered = threading.Event() + release = threading.Event() + + def slow_discovery(): + discovered.set() + release.wait(5) + return [{"provider": "codex", "path": f"/synthetic/{index}.jsonl", "id": str(index), + "mtime": float(index)} for index in range(5)] + + with mock.patch.object(meter, "all_session_sources", side_effect=slow_discovery): + started = time.monotonic() + during = meter.agent_capabilities(scope="all", limit=5) + elapsed = time.monotonic() - started + release.set() + self.wait_for_history_scan() + + self.assertTrue(discovered.is_set()) + self.assertLess(elapsed, 1.5) + self.assertEqual(during["coverage"], { + "complete": True, "sessions_scanned": 5, "sessions_total": 5, "refreshing": True, + }) + self.assertEqual(during["assessment"], "Review available") + + def expire_history_scan(self): + meter._history_scan["progress"]["finished_at"] -= meter._XSESS_TTL + 1 + + def test_a_current_scope_refresh_resolves_the_run_from_the_last_complete_scan(self): + summary = {"enabled": 1, "groups": [ + {"id": "skill_pack:Codex:docs", "name": "docs", "control_type": "skill_pack", + "runtime": "Codex", "current_used": False}, + ]} + resolve = mock.Mock(return_value=({"provider": "codex", "path": "/synthetic/4.jsonl", "id": "4"}, "")) + release = threading.Event() + + def slow_discovery(): + release.wait(5) + return [] + + with self.cold_session_history(count=5, parse_s=0), \ + mock.patch.object(meter, "AGENT_HISTORY_BUDGET_S", 5), \ + mock.patch.object(meter, "HISTORY_REFRESH_WAIT_S", 0.05), \ + mock.patch.object(meter, "resolve_agent_source", resolve), \ + mock.patch.object(meter, "recompute", return_value={"trace": []}), \ + mock.patch.object(meter, "agent_session_summary", return_value={"id": "4"}), \ + mock.patch.object(meter, "session_optional_capabilities", return_value=summary): + meter.agent_capabilities(scope="current", limit=5) + self.expire_history_scan() + with mock.patch.object(meter, "all_session_sources", side_effect=slow_discovery): + started = time.monotonic() + during = meter.agent_capabilities(scope="current", limit=5) + elapsed = time.monotonic() - started + release.set() + self.wait_for_history_scan() + + self.assertLess(elapsed, 1.5) + self.assertTrue(during["ok"]) + self.assertEqual(len(resolve.call_args.kwargs["sources"]), 5) + self.assertTrue(during["coverage"]["refreshing"]) + + @contextlib.contextmanager + def warm_current_scope(self, previous_age_s=0.0): + """A process whose last complete scan finished and has expired; yields the resolve mock.""" + summary = {"enabled": 1, "groups": [ + {"id": "skill_pack:Codex:docs", "name": "docs", "control_type": "skill_pack", + "runtime": "Codex", "current_used": False}, + ]} + resolve = mock.Mock(return_value=({"provider": "codex", "path": "/synthetic/4.jsonl", "id": "4"}, "")) + with self.cold_session_history(count=5, parse_s=0), \ + mock.patch.object(meter, "AGENT_HISTORY_BUDGET_S", 1.0), \ + mock.patch.object(meter, "HISTORY_REFRESH_WAIT_S", 0.05), \ + mock.patch.object(meter, "resolve_agent_source", resolve), \ + mock.patch.object(meter, "recompute", return_value={"trace": []}), \ + mock.patch.object(meter, "agent_session_summary", return_value={"id": "4"}), \ + mock.patch.object(meter, "session_optional_capabilities", return_value=summary), \ + contextlib.redirect_stderr(io.StringIO()): + meter.agent_capabilities(scope="current", limit=5) + self.wait_for_history_scan() + previous = meter._history_scan["last_complete"] + self.assertIs(previous, meter._history_scan["progress"]) + self.expire_history_scan() + previous["discovered_at"] -= previous_age_s + resolve.reset_mock() + yield resolve + + @staticmethod + def fresh_sources(count): + return [{"provider": "codex", "path": f"/fresh/{index}.jsonl", "id": f"f{index}", + "mtime": float(100 + index)} for index in range(count)] + + def test_a_current_scope_refresh_that_fails_discovery_reuses_a_recent_scan_once(self): + with self.warm_current_scope() as resolve: + failing = mock.Mock(side_effect=OSError("gone")) + with mock.patch.object(meter, "all_session_sources", failing): + result = meter.agent_capabilities(scope="current", limit=5) + + self.assertTrue(result["ok"]) + self.assertEqual(len(resolve.call_args.kwargs["sources"]), 5) + self.assertTrue(result["coverage"]["refreshing"]) + # One call starts one refresh scan, even when that scan fails. + self.assertEqual(failing.call_count, 1) + + def test_a_current_scope_refresh_with_old_sources_waits_like_a_cold_call(self): + with self.warm_current_scope(previous_age_s=meter.HISTORY_SOURCE_REUSE_S + 1) as resolve: + fresh = self.fresh_sources(3) + + def discovery(): + time.sleep(0.3) # Longer than the refresh cap, shorter than the call budget. + return fresh + + with mock.patch.object(meter, "all_session_sources", side_effect=discovery): + result = meter.agent_capabilities(scope="current", limit=5) + self.wait_for_history_scan() + + self.assertTrue(result["ok"]) + self.assertEqual(resolve.call_args.kwargs["sources"], tuple(fresh)) + + def test_a_current_scope_refresh_never_resolves_from_old_sources(self): + release = threading.Event() + with self.warm_current_scope(previous_age_s=meter.HISTORY_SOURCE_REUSE_S + 1) as resolve: + with mock.patch.object(meter, "AGENT_HISTORY_BUDGET_S", 0.1), \ + mock.patch.object(meter, "all_session_sources", side_effect=lambda: release.wait(5) and []): + result = meter.agent_capabilities(scope="current", limit=5) + release.set() + self.wait_for_history_scan() + + self.assertFalse(result["ok"]) + self.assertEqual(result["assessment"], "History scan incomplete") + resolve.assert_not_called() + + def test_a_current_scope_refresh_with_old_sources_reports_failed_discovery(self): + with self.warm_current_scope(previous_age_s=meter.HISTORY_SOURCE_REUSE_S + 1) as resolve: + with mock.patch.object(meter, "all_session_sources", side_effect=OSError("gone")), \ + self.assertRaisesRegex(RuntimeError, "could not scan session history"): + meter.agent_capabilities(scope="current", limit=5) + resolve.assert_not_called() + + def test_a_refresh_that_fails_after_discovery_resolves_from_its_own_sources(self): + with self.warm_current_scope() as resolve: + fresh = self.fresh_sources(3) + summarize = meter.session_summary.side_effect + + def failing_summary(source, opencode_conn=None): + if source["path"].startswith("/fresh/"): + raise ValueError("unreadable") + return summarize(source, opencode_conn) + + with mock.patch.object(meter, "all_session_sources", return_value=fresh), \ + mock.patch.object(meter, "session_summary", side_effect=failing_summary): + result = meter.agent_capabilities(scope="current", limit=5) + + self.assertTrue(result["ok"]) + self.assertEqual(resolve.call_args.kwargs["sources"], tuple(fresh)) + self.assertTrue(result["coverage"]["refreshing"]) + + def test_a_failed_refresh_falls_back_to_the_last_complete_scan(self): + with self.cold_session_history(count=5, parse_s=0), \ + mock.patch.object(meter, "AGENT_HISTORY_BUDGET_S", 5), \ + contextlib.redirect_stderr(io.StringIO()): + meter.agent_capabilities(scope="all", limit=5) + self.expire_history_scan() + with mock.patch.object(meter, "all_session_sources", side_effect=OSError("gone")): + result = meter.agent_capabilities(scope="all", limit=5) + + self.assertTrue(result["ok"]) + self.assertEqual(result["coverage"], { + "complete": True, "sessions_scanned": 5, "sessions_total": 5, "refreshing": True, + }) + + def test_capabilities_reports_a_failed_history_scan_and_retries_it(self): + stderr = io.StringIO() + with self.cold_session_history(count=3, parse_s=0, discovery_error=OSError("/secret/path boom")), \ + mock.patch.object(meter, "AGENT_HISTORY_BUDGET_S", 5), \ + contextlib.redirect_stderr(stderr): + with self.assertRaisesRegex(RuntimeError, "could not scan session history"): + meter.agent_capabilities(scope="all", limit=5) + failed = meter._history_scan["progress"] + with self.assertRaises(RuntimeError): + meter.agent_capabilities(scope="all", limit=5) + self.assertIsNot(meter._history_scan["progress"], failed) + + self.assertIn("history scan: internal error at OSError at", stderr.getvalue()) + self.assertNotIn("secret", stderr.getvalue()) + self.assertNotIn("boom", stderr.getvalue()) + + def test_agent_result_has_a_hard_serialized_bound(self): + result = meter.bounded_agent_result({ + "answer": "a" * 10000, "evidence": [{"label": "x", "value": "y" * 10000}] * 10, + "recommended_action": "z" * 10000, "caveat": "c" * 10000, + }) + self.assertTrue(result["truncated"]) + self.assertLessEqual(len(result["evidence"]), 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_builder_recap.py b/tests/test_builder_recap.py new file mode 100644 index 00000000..cf4b0768 --- /dev/null +++ b/tests/test_builder_recap.py @@ -0,0 +1,992 @@ +import unittest +import datetime +import http.client +import json +import os +import sqlite3 +import tempfile +import threading +from pathlib import Path +from unittest import mock +import meter +from token_meter.services import git_delivery as git_delivery_service_module +from tests.meter_support import _TrackingSqliteConnection + + +class GitDeliveryLedgerConnectionTests(unittest.TestCase): + """The ledger must close every sqlite connection it opens.""" + + def test_ledger_operations_close_every_connection(self): + from token_meter.services.git_delivery import GitDeliveryLedger + + created = [] + real_connect = sqlite3.connect + + def tracking_connect(*args, **kwargs): + tracker = _TrackingSqliteConnection(real_connect(*args, **kwargs)) + created.append(tracker) + return tracker + + with tempfile.TemporaryDirectory() as directory: + path = os.path.join(directory, "ledger.sqlite3") + with mock.patch.object( + git_delivery_service_module.sqlite3, "connect", + side_effect=tracking_connect): + ledger = GitDeliveryLedger(path) + ledger.record("repo-key", "object-key", 1, 2, 3) + ledger.mark_seen("repo-key", "object-key") + self.assertTrue(ledger.has_seen("repo-key", "object-key")) + ledger.rows() + ledger.daily_rows(["repo-key"], "2026-09-01", "2026-09-30") + ledger.map_project("project-key", "repo-key") + ledger.repo_key_for_project("project-key") + ledger.set_repository_coverage("repo-key", 12, 1) + ledger.repository_coverage("repo-key") + ledger.set_last_checked(123) + ledger.last_checked() + ledger.baseline_at() + ledger.coalesce_repository("repo-key", "canonical-key") + ledger.clear(123) + self.assertTrue(created, "ledger operations should open connections") + leaked = [connection for connection in created if not connection.closed] + self.assertEqual( + [], leaked, + f"{len(leaked)} of {len(created)} ledger connections were never closed", + ) + + +class BuilderRecapDomainTests(unittest.TestCase): + def test_antigravity_is_a_public_recap_runtime(self): + from token_meter.domain.builder_recap import _safe_runtime + + self.assertEqual(_safe_runtime("Antigravity"), "Antigravity") + self.assertEqual(_safe_runtime("Antigravity private"), "") + + TODAY = datetime.date(2026, 9, 13) + + def row(self, last, daily=(), **values): + return { + "last": last, + "runtime": "Codex", + "duration_s": 0, + "duration_available": False, + "output_tokens": 0, + "availability": {}, + "_model_daily": list(daily), + "_performance_samples": [], + "_tool_evidence": {}, + **values, + } + + def recap(self, rows, days=7, git_days=(), previous_git_lines=None, + previous_git_commits=None): + from token_meter.domain.builder_recap import build_builder_recap + return build_builder_recap( + rows, None if git_days is None else list(git_days), days, + today=self.TODAY, generated_at=1_757_718_400, + previous_git_lines=previous_git_lines, + previous_git_commits=previous_git_commits, + ) + + def supporting(self, result, stat_id): + return next(stat for stat in result["supporting"] if stat["id"] == stat_id) + + def spotlight(self, result, stat_id): + return next(stat for stat in result["spotlights"] if stat["id"] == stat_id) + + def test_windows_are_inclusive_equal_and_local_calendar_based(self): + result = self.recap([], days=7) + self.assertEqual(result["current"], { + "start_day": "2026-09-07", "end_day": "2026-09-13", + }) + self.assertEqual(result["previous"], { + "start_day": "2026-08-31", "end_day": "2026-09-06", + }) + self.assertEqual(len(result["activity_days"]), 7) + + def test_invalid_range_is_rejected_before_aggregation(self): + with self.assertRaisesRegex(ValueError, "7, 30, or 90"): + self.recap([], days=14) + + def test_30_and_90_day_windows_remain_equal_length_and_inclusive(self): + recap_30 = self.recap([], days=30) + recap_90 = self.recap([], days=90) + self.assertEqual(recap_30["current"], { + "start_day": "2026-08-15", "end_day": "2026-09-13", + }) + self.assertEqual(recap_30["previous"], { + "start_day": "2026-07-16", "end_day": "2026-08-14", + }) + self.assertEqual(recap_90["current"], { + "start_day": "2026-06-16", "end_day": "2026-09-13", + }) + self.assertEqual(recap_90["previous"], { + "start_day": "2026-03-18", "end_day": "2026-06-15", + }) + + def test_core_aggregates_use_day_level_paired_coverage(self): + paired = lambda day, output, cost: { + "day": day, "model": "gpt-5", "output_tokens": output, + "cost_covered_output_tokens": output, "cost_covered_cost": cost, + } + mixed = { + "day": "2026-09-13", "model": "gpt-5", "output_tokens": 900, + "cost_covered_output_tokens": 0, "cost_covered_cost": 9, + } + result = self.recap([ + self.row("2026-09-13T18:00:00", [ + paired("2026-09-13", 200, 1), paired("2026-08-31", 100, 1), + ]), + self.row("2026-09-12T18:00:00", [ + paired("2026-09-12", 100, 0.5), paired("2026-09-10", 100, 0.5), + ]), + self.row("2026-09-01T18:00:00", [mixed]), + ]) + + self.assertEqual(result["activity_days"][-1], { + "day": "2026-09-13", "recorded_session": True, + }) + self.assertEqual(self.spotlight(result, "build_streak")["current"], 2) + self.assertEqual(self.spotlight(result, "efficiency_change")["current"], 200.0) + self.assertEqual(self.spotlight(result, "efficiency_change")["previous"], 100.0) + self.assertEqual(self.spotlight(result, "efficiency_change")["delta_pct"], 100.0) + + def test_covered_spend_sums_paired_cost_and_stays_unavailable_when_absent(self): + paired = lambda day, output, cost: { + "day": day, "model": "gpt-5", "output_tokens": output, + "cost_covered_output_tokens": output, "cost_covered_cost": cost, + } + result = self.recap([ + self.row("2026-09-13T18:00:00", [ + paired("2026-09-13", 200, 1.25), paired("2026-09-12", 100, 0.75), + paired("2026-08-31", 100, 2.5), + ]), + ]) + + spend = self.supporting(result, "covered_spend") + self.assertEqual(spend["family"], "cost") + self.assertTrue(spend["available"]) + self.assertAlmostEqual(spend["current"], 2.0) + self.assertAlmostEqual(spend["previous"], 2.5) + self.assertEqual(spend["unit"], "USD") + self.assertEqual(spend["basis"], "paired cost-covered output and cost") + self.assertEqual(spend["sample_count"], 2) + + # Output with no paired cost must not become a measured zero. + uncovered = self.recap([ + self.row("2026-09-13T18:00:00", [{ + "day": "2026-09-13", "model": "gpt-5", "output_tokens": 900, + "cost_covered_output_tokens": 0, "cost_covered_cost": 0, + }]), + ]) + self.assertEqual( + [stat["id"] for stat in uncovered["supporting"] + if stat["id"] == "covered_spend"], + [], + ) + + def test_every_supporting_stat_id_has_a_deterministic_sort_position(self): + paired = lambda day, output, cost: { + "day": day, "model": "gpt-5", "output_tokens": output, + "cost_covered_output_tokens": output, "cost_covered_cost": cost, + } + result = self.recap([ + self.row("2026-09-13T18:00:00", [ + paired("2026-09-13", 200, 1.25), paired("2026-08-31", 100, 2.5), + ], duration_s=120, duration_available=True, _performance_samples=[ + {"day": "2026-09-13", "output_tokens": 400, "generation_s": 4}, + {"day": "2026-08-31", "output_tokens": 300, "generation_s": 5}, + ], _tool_evidence={"tools": [{"daily": [ + {"day": "2026-09-13", "calls": 12}, + ]}]}), + ], git_days=[ + {"day": "2026-09-13", "available": True, "active": True, + "changed_lines": 400}, + ]) + + ordered = [stat["id"] for stat in result["supporting"]] + self.assertEqual(ordered, sorted(set(ordered), key=ordered.index)) + self.assertIn("covered_spend", ordered) + + def test_efficiency_comparison_is_unavailable_with_prior_zero_cost(self): + result = self.recap([ + self.row("2026-09-13T18:00:00", [{ + "day": "2026-09-13", "model": "gpt-5", "output_tokens": 100, + "cost_covered_output_tokens": 100, "cost_covered_cost": 1, + }, { + "day": "2026-08-31", "model": "gpt-5", "output_tokens": 100, + "cost_covered_output_tokens": 100, "cost_covered_cost": 0, + }]), + ]) + + efficiency = self.spotlight(result, "efficiency_change") + self.assertFalse(efficiency["available"]) + self.assertIsNone(efficiency["delta_pct"]) + + def test_malformed_non_finite_evidence_is_ignored(self): + result = self.recap([self.row("not-a-date", [{ + "day": "2026-09-13", "model": "gpt-5", "output_tokens": 100, + "cost_covered_output_tokens": float("nan"), + "cost_covered_cost": float("inf"), + }], mtime=float("inf"), _performance_samples=[{ + "day": "2026-09-13", "output_tokens": float("inf"), + "duration_s": float("nan"), + }], _tool_evidence={"tools": [{"daily": [{ + "day": "2026-09-13", "calls": float("inf"), + }]}]})]) + + self.assertEqual(result["activity_days"][-1], { + "day": "2026-09-13", "recorded_session": True, + }) + self.assertFalse(self.spotlight(result, "efficiency_change")["available"]) + self.assertNotIn("output_pace", [stat["id"] for stat in result["supporting"]]) + self.assertEqual(result["coverage"]["tools"]["eligible"], 0) + + def test_float_conversion_overflow_is_ignored_as_unavailable_evidence(self): + class OverflowingFloat: + def __float__(self): + raise OverflowError("malformed numeric evidence") + + overflow = OverflowingFloat() + result = self.recap([ + self.row("not-a-date", [], mtime=overflow), + self.row("2026-09-13T18:00:00", [{ + "day": "2026-09-13", "model": "gpt-5", + "cost_covered_output_tokens": overflow, + "cost_covered_cost": overflow, + }]), + ]) + + self.assertEqual(result["activity_days"][-1], { + "day": "2026-09-13", "recorded_session": True, + }) + self.assertFalse(self.spotlight(result, "efficiency_change")["available"]) + + def test_git_coverage_requires_explicit_available_evidence_and_counts_only_active_days(self): + unavailable = self.recap([], git_days=None) + no_measured_rows = self.recap([], git_days=()) + measured_inactive = self.recap([], git_days=[ + {"day": f"2026-09-{day:02d}", "available": True, "active": False} + for day in range(7, 14) + ] + [{"day": "2026-09-12", "available": False, "active": True}]) + + self.assertFalse(unavailable["coverage"]["git"]["available"]) + self.assertFalse(unavailable["coverage"]["git"]["projection_available"]) + self.assertFalse(no_measured_rows["coverage"]["git"]["available"]) + self.assertTrue(no_measured_rows["coverage"]["git"]["projection_available"]) + self.assertEqual(no_measured_rows["coverage"]["git"]["eligible"], 0) + self.assertFalse(self.spotlight(no_measured_rows, "lines_pushed")["available"]) + self.assertNotIn("delivery_active_days", [ + stat["id"] for stat in no_measured_rows["supporting"] + ]) + self.assertTrue(measured_inactive["coverage"]["git"]["available"]) + self.assertTrue(measured_inactive["coverage"]["git"]["projection_available"]) + self.assertEqual(measured_inactive["coverage"]["git"]["eligible"], 7) + self.assertEqual( + self.supporting(measured_inactive, "delivery_active_days")["current"], 0 + ) + delivery = self.supporting(measured_inactive, "delivery_active_days") + self.assertIsNone(delivery["previous"]) + self.assertIsNone(delivery["delta"]) + + def test_lines_pushed_sums_only_measured_nonnegative_integer_change_volume(self): + result = self.recap([], previous_git_lines=120_000, git_days=[ + {"day": "2026-09-13", "available": True, "active": True, + "changed_lines": 184_392}, + {"day": "2026-09-12", "available": True, "active": True, + "changed_lines": True}, + {"day": "2026-09-11", "available": True, "active": True, + "changed_lines": -1}, + {"day": "2026-09-10", "available": True, "active": True, + "changed_lines": 1.5}, + {"day": "2026-09-09", "available": True, "active": True, + "changed_lines": float("inf")}, + {"day": "2026-09-08", "available": True, "active": True, + "changed_lines": "999999"}, + {"day": "2026-09-08", "available": True, "active": True, + "changed_lines": 10 ** 1000}, + {"day": "2026-09-07", "available": False, "active": True, + "changed_lines": 999_999}, + ]) + + lines = self.spotlight(result, "lines_pushed") + self.assertTrue(lines["available"]) + self.assertEqual(lines["current"], 184_392) + self.assertEqual(lines["previous"], 120_000) + self.assertEqual(lines["delta"], 64_392) + self.assertAlmostEqual(lines["delta_pct"], 53.66, places=2) + self.assertEqual(lines["unit"], "lines") + self.assertEqual( + lines["basis"], + "added plus deleted text lines from successful local pushes", + ) + self.assertEqual(lines["sample_count"], 1) + + def test_commits_pushed_sums_only_measured_nonnegative_integer_commit_counts(self): + result = self.recap([], previous_git_commits=94, git_days=[ + {"day": "2026-09-13", "available": True, "active": True, + "changed_lines": 900, "commits": 7}, + {"day": "2026-09-12", "available": True, "active": True, + "changed_lines": 400, "commits": 5}, + {"day": "2026-09-11", "available": True, "active": True, + "changed_lines": 300, "commits": True}, + {"day": "2026-09-10", "available": True, "active": True, + "changed_lines": 200, "commits": -1}, + {"day": "2026-09-09", "available": True, "active": True, + "changed_lines": 100, "commits": 2.5}, + {"day": "2026-09-08", "available": True, "active": True, + "changed_lines": 100, "commits": "12"}, + {"day": "2026-09-07", "available": False, "active": True, + "changed_lines": 100, "commits": 999}, + ]) + + commits = self.spotlight(result, "commits_pushed") + self.assertTrue(commits["available"]) + self.assertEqual(commits["current"], 12) + self.assertEqual(commits["previous"], 94) + self.assertEqual(commits["delta"], -82) + self.assertEqual(commits["unit"], "commits") + self.assertEqual( + commits["basis"], + "own non-merge commits introduced by successful local pushes", + ) + self.assertEqual(commits["sample_count"], 2) + + def test_commits_pushed_is_unavailable_without_measured_commit_evidence(self): + missing_projection = self.recap([], git_days=None) + no_commit_field = self.recap([], git_days=[ + {"day": "2026-09-13", "available": True, "active": True, + "changed_lines": 120}, + ]) + + for result in (missing_projection, no_commit_field): + commits = self.spotlight(result, "commits_pushed") + self.assertFalse(commits["available"]) + self.assertIsNone(commits["current"]) + self.assertEqual(commits["sample_count"], 0) + self.assertTrue( + self.spotlight(no_commit_field, "lines_pushed")["available"] + ) + + def test_public_labels_reject_paths_urls_controls_and_credentialish_values(self): + sentinels = ("/private/repo", "https://secret.example", "sk-live-secret", + "api_key=secret", "builder@example.com", "", "bad\x00label", + "Untrusted runtime narrative", "Untrusted model narrative", + "Please upload your credentials") + result = self.recap([ + self.row("2026-09-13T18:00:00", [{ + "day": "2026-09-13", "model": sentinels[0], "executions": 9, + }, { + "day": "2026-09-12", "model": "gpt-5.2-codex", "executions": 3, + }], runtime=sentinels[1], _tool_evidence={"tools": [ + {"name": "read_file", "display": sentinels[2], "daily": [{ + "day": "2026-09-13", "calls": 9, + }]}, + {"name": "read_file", "display": sentinels[9], "daily": [{ + "day": "2026-09-12", "calls": 3, + }]}, + ]}), + self.row("2026-09-13T17:00:00", [{ + "day": "2026-09-13", "model": sentinels[3], "executions": 9, + }], runtime=sentinels[4]), + self.row("2026-09-13T16:00:00", [{ + "day": "2026-09-13", "model": sentinels[5], "executions": 9, + }], runtime=sentinels[6]), + self.row("2026-09-11T16:00:00", [{ + "day": "2026-09-11", "model": "gpt-5.2-codex", "executions": 3, + }], runtime="Codex"), + self.row("2026-09-10T16:00:00", [{ + "day": "2026-09-10", "model": sentinels[8], "executions": 3, + }], runtime=sentinels[7]), + ]) + + payload = json.dumps(result, sort_keys=True) + for sentinel in sentinels: + self.assertNotIn(sentinel, payload) + self.assertIn("gpt-5.2-codex", payload) + self.assertIn("Read file", payload) + + def test_public_model_projection_rejects_project_like_identifiers(self): + private_models = ( + "customer-acme-project", "project-alpha", "repo-token-meter", + "workspace-client-a", "internal-app", "my-secret-build", + "gpt-5.6-customer-acme-project", "claude-project-alpha", + "gpt5customer", "qwen3customer", "llama3project", + "gemma3tenant", "starcoder2internal", "gpt5acme", + ) + daily = [{ + "day": "2026-09-13", "model": model, "executions": 9, + } for model in private_models] + daily.append({ + "day": "2026-09-13", "model": "gpt-5.6-sol", "executions": 3, + }) + + result = self.recap([ + self.row("2026-09-13T18:00:00", daily, runtime="Codex"), + ]) + + payload = json.dumps(result, sort_keys=True) + for model in private_models: + self.assertNotIn(model, payload) + self.assertIn("gpt-5.6-sol", payload) + + def test_public_model_projection_preserves_versioned_family_labels(self): + public_models = ( + "qwen3-coder", "starcoder2", "llama3.3", "gemma3", + "deepseek-v3", "o3", "claude-sonnet-4-6", "gpt-5.6-sol", + ) + result = self.recap([ + self.row("2026-09-13T18:00:00", [{ + "day": "2026-09-13", "model": model, "executions": 1, + } for model in public_models], runtime="Codex"), + ]) + + self.assertEqual( + {row["model"] for row in result["stack"]["models"]}, + set(public_models), + ) + + def test_output_pace_models_and_dated_tools_are_aggregated_by_period(self): + result = self.recap([ + self.row("2026-09-13T18:00:00", [{ + "day": "2026-09-13", "model": "gpt-5.5", + }], runtime="Codex", _performance_samples=[{ + "day": "2026-09-13", "output_tokens": 300, "duration_s": 30, + }, { + "day": "2026-08-31", "output_tokens": 100, "duration_s": 20, + }], _tool_evidence={"tools": [{"name": "read_file", "display": "Read file", "daily": [{ + "day": "2026-09-13", "calls": 4, + }, {"day": "2026-08-31", "calls": 2}]}]}), + self.row("2026-09-12T18:00:00", [{ + "day": "2026-09-12", "model": "gpt-5.5", + }], runtime="Claude"), + ]) + + pace = self.supporting(result, "output_pace") + self.assertEqual(pace["current"], 10.0) + self.assertEqual(pace["previous"], 5.0) + self.assertEqual(pace["delta_pct"], 100.0) + self.assertEqual(result["stack"]["models"], [ + {"runtime": "Claude", "model": "gpt-5.5"}, + {"runtime": "Codex", "model": "gpt-5.5"}, + ]) + self.assertEqual(result["coverage"]["tools"]["eligible"], 1) + + def test_usage_rankings_are_capped_scoped_and_private(self): + rows = [] + fixtures = [ + ("Codex", "gpt-5.3-codex", (4, 3, 2, 1)), + ("Claude", "gpt-5.5", (3, 2, 1)), + ("Cursor", "cursor-small", (2, 2)), + ("OpenCode", "gpt-5.5", (3,)), + ] + serial = 0 + for runtime, model, executions in fixtures: + for count in executions: + serial += 1 + day = 13 - ((serial - 1) % 7) + rows.append(self.row( + f"2026-09-{day:02d}T12:00:00", + [{"day": f"2026-09-{day:02d}", "model": model, + "executions": count}], + id=f"private-session-{serial}", runtime=runtime, + )) + + result = self.recap(rows) + + self.assertEqual(result["usage"]["agents"], [ + {"label": "Codex", "count": 4, "share": 40.0}, + {"label": "Claude", "count": 3, "share": 30.0}, + {"label": "Cursor", "count": 2, "share": 20.0}, + ]) + self.assertEqual(result["usage"]["models"], [ + {"label": "gpt-5.3-codex", "runtime": "Codex", + "count": 10, "share": 43.47826086956522}, + {"label": "gpt-5.5", "runtime": "Claude", + "count": 6, "share": 26.08695652173913}, + {"label": "cursor-small", "runtime": "Cursor", + "count": 4, "share": 17.391304347826086}, + ]) + serialized = json.dumps(result["usage"], sort_keys=True) + self.assertNotIn("private-session", serialized) + self.assertNotIn("OpenCode", serialized) + + def test_spotlight_eligibility_uses_safe_period_evidence(self): + def daily(day, model, executions=1, output=0, cost=0): + return {"day": day, "model": model, "executions": executions, + "output_tokens": output, "cost_covered_output_tokens": output, + "cost_covered_cost": cost} + + result = self.recap([ + self.row("2026-09-13T18:00:00", [ + daily("2026-09-13", "gpt-5", 5, 900, 3), + daily("2026-09-12", "gpt-5", 2, 100, 1), + ], id="session-secret", runtime="Codex", duration_s=50, + wall_duration_s=9_999, duration_available=True, + _performance_samples=[ + {"day": "2026-09-13", "model": "gpt-5", "output_tokens": 600, "generation_s": 30}, + {"day": "2026-09-12", "model": "gpt-5", "output_tokens": 500, "generation_s": 20}, + {"day": "2026-09-11", "model": "gpt-5", "output_tokens": 400, "generation_s": 10}, + ], _tool_evidence={"tools": [ + {"name": "read_file", "display": "Read file", "daily": [{"day": "2026-09-13", "calls": 8}]}, + {"name": "Token Meter diagnostics", "daily": [{"day": "2026-09-13", "calls": 99}]}, + ]}), + self.row("2026-09-13T17:00:00", [daily("2026-09-13", "claude", 5, 600, 3)], + id="codex-second", runtime="Codex", duration_s=10, duration_available=True), + self.row("2026-09-12T17:00:00", [daily("2026-09-12", "claude", 5, 600, 3)], + id="claude-first", runtime="Claude", duration_s=20, duration_available=True), + self.row("2026-09-11T17:00:00", [daily("2026-09-11", "unknown-model", 9)], + id="claude-second", runtime="unknown", duration_s=30, duration_available=False, + wall_duration_s=10_000), + self.row("2026-09-10T17:00:00", [daily("2026-09-10", "unknown", 9)], + runtime="unknown", id="unknown-session"), + ]) + + self.assertEqual({row["id"] for row in result["spotlights"]}, { + "efficiency_record", "efficiency_change", "marathon_session", + "speed_champion", "build_streak", "daily_driver", "go_to_model", + "lines_pushed", "commits_pushed", "biggest_build", "tool_mvp", + "stack_explorer", + }) + self.assertEqual(self.spotlight(result, "marathon_session")["current"], 50) + self.assertEqual(self.spotlight(result, "speed_champion")["current"], 25.0) + self.assertEqual(self.spotlight(result, "speed_champion")["leaders"], [ + {"runtime": "Codex", "model": "gpt-5"}, + ]) + self.assertEqual(self.spotlight(result, "daily_driver")["leaders"], [ + {"runtime": "Codex"}, + ]) + self.assertEqual(self.spotlight(result, "go_to_model")["leaders"], [ + {"runtime": "Codex", "model": "gpt-5"}, + ]) + biggest = self.spotlight(result, "biggest_build") + self.assertEqual(biggest["current"], 1_000) + self.assertEqual(biggest["leaders"], [{"runtime": "Codex"}]) + self.assertEqual(self.spotlight(result, "tool_mvp")["leaders"], [ + {"tool": "Read file"}, + ]) + self.assertEqual(self.spotlight(result, "stack_explorer")["current"], 5) + + def test_record_ties_and_auto_selection_follow_the_contract(self): + def paired(day, output, cost): + return {"day": day, "model": "gpt-5", "executions": 1, + "cost_covered_output_tokens": output, "cost_covered_cost": cost} + + record_rows = [self.row("2026-09-13T12:00:00", [paired("2026-09-13", 400, 1)])] + for day in ("2026-09-06", "2026-08-30", "2026-08-23"): + record_rows[0]["_model_daily"].append(paired(day, 100, 1)) + record_result = self.recap(record_rows) + self.assertTrue(self.spotlight(record_result, "efficiency_record")["available"]) + self.assertEqual(record_result["spotlight_default"], "efficiency_change") + + tied = self.recap([ + self.row("2026-09-13T12:00:00", id="a", runtime="Codex"), + self.row("2026-09-12T12:00:00", id="b", runtime="Claude"), + ]) + self.assertEqual(self.spotlight(tied, "daily_driver")["leaders"], [ + {"runtime": "Claude"}, {"runtime": "Codex"}, + ]) + three_way = self.recap([ + self.row("2026-09-13T12:00:00", id="a", runtime="Codex"), + self.row("2026-09-12T12:00:00", id="b", runtime="Claude"), + self.row("2026-09-11T12:00:00", id="c", runtime="Cursor"), + ]) + self.assertFalse(self.spotlight(three_way, "daily_driver")["available"]) + + tied_record = self.recap([self.row("2026-09-13T12:00:00", [ + paired("2026-09-13", 100, 1), paired("2026-09-06", 100, 1), + paired("2026-08-30", 50, 1), paired("2026-08-23", 50, 1), + ])]) + self.assertFalse(self.spotlight(tied_record, "efficiency_record")["available"]) + + streak_result = self.recap([ + self.row("2026-09-13T12:00:00", id="a"), + self.row("2026-09-12T12:00:00", id="b"), + ]) + self.assertEqual(streak_result["spotlight_default"], "build_streak") + self.assertIsNone(self.recap([])["spotlight_default"]) + + def test_public_recap_is_a_recursive_privacy_allowlist(self): + def recursive_keys(value, path=()): + if isinstance(value, dict): + for key, item in value.items(): + yield path + (key,) + yield from recursive_keys(item, path + (key,)) + elif isinstance(value, list): + for item in value: + yield from recursive_keys(item, path + ("*",)) + + result = self.recap([self.row("2026-09-13T12:00:00", [{ + "day": "2026-09-13", "model": "gpt-5", "executions": 1, + "cost_covered_output_tokens": 100, "cost_covered_cost": 1, + "path": "/private/repo", "project": "SECRET_PROMPT", + }], id="session-secret", path="/private/repo", project="SECRET_PROMPT", + title="commit subject", session_name="session-secret", + desktop_session_id="session-secret", args_fingerprint="tool result secret", + raw_error="tool result secret", _tool_evidence={"tools": [{ + "name": "read_file", "id": "tool-secret", "args_fingerprint": "tool result secret", + "daily": [{"day": "2026-09-13", "calls": 1, "raw_error": "tool result secret"}], + }]} )]) + forbidden = {"path", "project", "title", "session_name", "desktop_session_id", + "args_fingerprint", "raw_error"} + for path in recursive_keys(result): + self.assertNotIn(path[-1], forbidden) + if path[-1] == "id": + self.assertIn(path[:-1], {("spotlights", "*"), ("supporting", "*"), + ("headlines", "*")}) + payload = json.dumps(result, sort_keys=True) + for sentinel in ("SECRET_PROMPT", "/private/repo", "session-secret", + "commit subject", "tool result secret"): + self.assertNotIn(sentinel, payload) + + def test_tool_mvp_excludes_token_meter_mcp_and_projects_safe_display(self): + result = self.recap([self.row("2026-09-13T12:00:00", [], _tool_evidence={ + "tools": [{ + "name": "mcp__tokenmeter__diagnose", "display": "diagnose", + "namespace": "tokenmeter", "kind": "mcp", + "daily": [{"day": "2026-09-13", "calls": 99}], + }, { + "name": "read_file_v2", "display": "Read file", "namespace": "filesystem", + "kind": "tool", "daily": [{"day": "2026-09-13", "calls": 3}], + }, { + "name": "secret", "display": "Read file", "daily": [{"day": "2026-09-13", "calls": 99}], + }, { + "name": "password", "display": "Read file", "daily": [{"day": "2026-09-13", "calls": 99}], + }, { + "name": "authorization", "display": "Read file", "daily": [{"day": "2026-09-13", "calls": 99}], + }, { + "name": "access_token", "display": "Read file", "daily": [{"day": "2026-09-13", "calls": 99}], + }, { + "name": "mcp__safe__access_token", "display": "Read file", "daily": [{"day": "2026-09-13", "calls": 99}], + }], + })]) + + tool = self.spotlight(result, "tool_mvp") + self.assertTrue(tool["available"]) + self.assertEqual(tool["leaders"], [{"tool": "Read file"}]) + self.assertEqual(result["coverage"]["tools"]["eligible"], 1) + payload = json.dumps(result, sort_keys=True) + for sentinel in ("mcp__tokenmeter__diagnose", "read_file_v2", "Secret", "Password", + "Authorization", "Access token"): + self.assertNotIn(sentinel, payload) + + def test_tool_mvp_projects_unknown_provider_identifiers_to_fixed_generic_labels(self): + private_names = ( + "mcp__repo__private-project-alpha", + "mcp__workspace__customer_acme", + "git_private_repo", + ) + result = self.recap([self.row("2026-09-13T12:00:00", [], _tool_evidence={ + "tools": [ + {"name": "read_file_v2", "display": "untrusted display", "daily": [ + {"day": "2026-09-13", "calls": 5}, + ]}, + *[{"name": name, "display": "untrusted display", "daily": [ + {"day": "2026-09-13", "calls": 1}, + ]} for name in private_names], + ], + })]) + + self.assertEqual(self.spotlight(result, "tool_mvp")["leaders"], [{"tool": "Read file"}]) + self.assertEqual(self.spotlight(result, "tool_mvp")["current"], 5) + self.assertEqual(result["coverage"]["tools"]["eligible"], 3) + payload = json.dumps(result, sort_keys=True) + for private_name in private_names: + self.assertNotIn(private_name, payload) + for fragment in ("private-project-alpha", "customer_acme", "private repo"): + self.assertNotIn(fragment, payload) + + def headline(self, result, headline_id): + return next((item for item in result["headlines"] if item["id"] == headline_id), None) + + def test_headlines_are_absent_without_evidence(self): + result = self.recap([], days=30) + self.assertEqual(result["headlines"], []) + # One session and one day prove no identity, streak, week, or cache claim. + single = self.recap([self.row("2026-09-13T12:00:00", [{ + "day": "2026-09-13", "model": "gpt-5", "executions": 1, + "input_tokens": 100, "cache_read_tokens": 0, + "cost_covered_output_tokens": 100, "cost_covered_cost": 1, + }], id="only")], days=30) + self.assertEqual(single["headlines"], []) + + def test_identity_headline_requires_sixty_percent_of_sessions(self): + rows = [self.row(f"2026-09-{13 - index:02d}T12:00:00", id=f"claude-{index}", + runtime="Claude Code") for index in range(6)] + rows += [self.row("2026-09-01T12:00:00", id=f"codex-{index}") for index in range(4)] + at_threshold = self.headline(self.recap(rows, days=30), "identity") + self.assertEqual(at_threshold, { + "id": "identity", "title": "A Claude person", "value": "60%", + "detail": "of sessions on Claude Code", + }) + below = self.recap(rows + [self.row("2026-09-02T12:00:00", id="codex-x")], days=30) + self.assertIsNone(self.headline(below, "identity")) + vowel = self.headline(self.recap([ + self.row("2026-09-13T12:00:00", id=f"oc-{index}", runtime="OpenCode") + for index in range(3) + ], days=7), "identity") + self.assertEqual((vowel["title"], vowel["value"]), ("An OpenCode person", "100%")) + # Rounding never overstates a share. + rows = [self.row("2026-09-13T12:00:00", id=f"c-{index}", runtime="Claude Code") + for index in range(2)] + [self.row("2026-09-13T12:00:00", id="x")] + self.assertEqual(self.headline(self.recap(rows), "identity")["value"], "66%") + + def test_streak_headline_reports_the_longest_run_of_three_or_more_days(self): + two = self.recap([self.row("2026-09-13T12:00:00", id="a"), + self.row("2026-09-12T12:00:00", id="b")]) + self.assertIsNone(self.headline(two, "streak")) + four = self.recap([self.row(f"2026-09-{day:02d}T12:00:00", id=str(day)) + for day in (13, 12, 11, 10, 8)]) + self.assertEqual(self.headline(four, "streak"), { + "id": "streak", "title": "Longest streak", "value": "4 days", + "detail": "consecutive active days", + }) + + def test_best_week_headline_compares_comparable_weeks_and_says_estimate(self): + def day(value, output, cost): + return {"day": value, "model": "gpt-5", "executions": 1, + "cost_covered_output_tokens": output, "cost_covered_cost": cost} + rows = [self.row("2026-09-13T12:00:00", [ + day("2026-09-13", 3000, 1), day("2026-09-05", 1000, 1), + day("2026-08-29", 1000, 1), + ], id="weeks")] + best = self.headline(self.recap(rows, days=30), "best_week") + self.assertEqual(best["title"], "Best week") + self.assertEqual(best["value"], "3.0×") + self.assertIn("estimate", best["detail"]) + # A 7-day period has one week, so there is nothing to compare against. + self.assertIsNone(self.headline(self.recap(rows, days=7), "best_week")) + flat = [self.row("2026-09-13T12:00:00", [ + day("2026-09-13", 1000, 1), day("2026-09-05", 1000, 1), + ], id="flat")] + self.assertIsNone(self.headline(self.recap(flat, days=30), "best_week")) + + def test_cache_share_headline_uses_only_cache_covered_input(self): + rows = [self.row("2026-09-13T12:00:00", [{ + "day": "2026-09-13", "model": "gpt-5", "executions": 2, + "input_tokens": 1000, "cache_read_tokens": 875, + }, { + "day": "2026-09-13", "model": "gpt-5-mini", "executions": 1, + "input_tokens": 5000, "cache_read_tokens": 0, + "availability": {"cache": False}, + }], id="cached")] + self.assertEqual(self.headline(self.recap(rows), "cache_share"), { + "id": "cache_share", "title": "Cache-powered", "value": "87%", + "detail": "of input tokens read from cache", + }) + uncovered = [self.row("2026-09-13T12:00:00", [{ + "day": "2026-09-13", "model": "gpt-5", "executions": 1, + "input_tokens": 1000, "cache_read_tokens": 900, + }], id="none", availability={"cache": False})] + self.assertIsNone(self.headline(self.recap(uncovered), "cache_share")) + + def test_headlines_are_bounded_ordered_and_content_free(self): + rows = [self.row(f"2026-09-{13 - index:02d}T12:00:00", [{ + "day": f"2026-09-{13 - index:02d}", "model": "claude-sonnet-4-5", "executions": 1, + "input_tokens": 1000, "cache_read_tokens": 900, + "cost_covered_output_tokens": 1000 * (index + 1), "cost_covered_cost": 1, + }], id=f"secret-session-{index}", runtime="Claude Code", + project="/private/repo", title="SECRET_PROMPT") for index in range(12)] + result = self.recap(rows, days=30) + self.assertEqual([item["id"] for item in result["headlines"]], + ["identity", "best_week", "streak", "cache_share"]) + self.assertLessEqual(len(result["headlines"]), 4) + for item in result["headlines"]: + self.assertEqual(sorted(item), ["detail", "id", "title", "value"]) + self.assertTrue(all(isinstance(item[key], str) and len(item[key]) <= 48 + for key in item)) + payload = json.dumps(result["headlines"]) + for sentinel in ("secret-session", "/private/repo", "SECRET_PROMPT", "percentile"): + self.assertNotIn(sentinel, payload) + + +class BuilderRecapEndpointTests(unittest.TestCase): + def setUp(self): + self.saved_cache = dict(meter._xsess) + + def tearDown(self): + meter._xsess.clear() + meter._xsess.update(self.saved_cache) + + def test_builder_recap_state_reduces_git_evidence_before_public_build(self): + expected = {"ok": True, "generated_at": 123, "privacy": { + "content_included": False, "project_identity_included": False, + }} + meter._xsess["internal_rows"] = [{"id": "private"}] + with mock.patch.object(meter, "cross_session", return_value={"generated_at": 123}), \ + mock.patch.object(meter, "git_delivery_state", return_value={ + "ok": True, + "previous": { + "changed_lines": 8, "commits": 2, + "availability": {"code_pushed": True}, + "project": "/private/previous", "subject": "secret previous", + }, + "days": [{ + "day": "2026-09-13", "changed_lines": 12, "commits": 3, + "availability": {"code_pushed": True}, + "project": "/private/repo", "subject": "secret", + }], + }), \ + mock.patch.object(meter, "_domain_build_builder_recap", return_value=expected) as build: + payload, status = meter.builder_recap_state("30") + + self.assertEqual(status, 200) + self.assertEqual(payload, expected) + build.assert_called_once() + self.assertEqual(build.call_args.args[0], [{"id": "private"}]) + self.assertEqual(build.call_args.args[1], [{ + "day": "2026-09-13", "available": True, "active": True, + "changed_lines": 12, "commits": 3, + }]) + self.assertEqual(build.call_args.args[2], 30) + self.assertEqual(build.call_args.kwargs["generated_at"], 123) + self.assertEqual(build.call_args.kwargs["previous_git_lines"], 8) + self.assertEqual(build.call_args.kwargs["previous_git_commits"], 2) + self.assertNotIn("/private/repo", json.dumps(build.call_args.args[1])) + self.assertNotIn("secret", json.dumps(build.call_args.args[1])) + self.assertNotIn("private/previous", json.dumps(build.call_args.kwargs)) + + def test_builder_recap_state_fails_closed_on_malformed_changed_line_counts(self): + meter._xsess["internal_rows"] = [] + malformed = (True, -1, 1.5, float("inf"), "999", 10 ** 1000) + days = [{ + "day": f"2026-09-{13 - index:02d}", "changed_lines": value, + "commits": value, "availability": {"code_pushed": True}, + } for index, value in enumerate(malformed)] + with mock.patch.object(meter, "cross_session", return_value={"generated_at": 123}), \ + mock.patch.object(meter, "git_delivery_state", return_value={ + "ok": True, "days": days, + "previous": { + "changed_lines": "999", "commits": 1.5, + "availability": {"code_pushed": True}, + }, + }), \ + mock.patch.object(meter, "_domain_build_builder_recap", return_value={"ok": True}) as build: + payload, status = meter.builder_recap_state("30") + + self.assertEqual((payload, status), ({"ok": True}, 200)) + projected = build.call_args.args[1] + self.assertEqual(len(projected), len(malformed)) + self.assertTrue(all(row["changed_lines"] is None for row in projected)) + self.assertTrue(all(row["commits"] is None for row in projected)) + self.assertIsNone(build.call_args.kwargs["previous_git_lines"]) + self.assertIsNone(build.call_args.kwargs["previous_git_commits"]) + + def test_builder_recap_state_marks_git_failures_unavailable(self): + meter._xsess["internal_rows"] = [{"id": "private"}] + with mock.patch.object(meter, "cross_session", return_value={"generated_at": 123}), \ + mock.patch.object(meter, "git_delivery_state", return_value={ + "ok": False, "error": "private git failure", + }), \ + mock.patch.object(meter, "_domain_build_builder_recap", return_value={"ok": True}) as build: + payload, status = meter.builder_recap_state("7") + + self.assertEqual((payload, status), ({"ok": True}, 200)) + self.assertIsNone(build.call_args.args[1]) + + def test_builder_recap_state_rejects_invalid_ranges_without_discovery(self): + for value in ("", "14", "30.0", "seven"): + with self.subTest(value=value), \ + mock.patch.object(meter, "cross_session") as cross, \ + mock.patch.object(meter, "git_delivery_state") as git, \ + mock.patch.object(meter, "_domain_build_builder_recap") as build: + payload, status = meter.builder_recap_state(value) + self.assertEqual(status, 400) + self.assertEqual(payload, { + "ok": False, "error": "A valid recap range is required.", + }) + cross.assert_not_called() + git.assert_not_called() + build.assert_not_called() + + def request(self, path): + server = meter.TokenMeterHTTPServer(("127.0.0.1", 0), meter.H) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + conn = http.client.HTTPConnection("127.0.0.1", server.server_port, timeout=5) + conn.request("GET", path) + response = conn.getresponse() + payload = json.loads(response.read()) + status = response.status + content_type = response.getheader("Content-Type") + conn.close() + return payload, status, content_type + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + def test_builder_recap_http_route_defaults_validates_and_returns_json(self): + meter._xsess["internal_rows"] = [{ + "id": "private-session", "path": "/private/trace.jsonl", + "project": "/private/repo", "title": "private title", + "last": "2026-09-13T12:00:00", "runtime": "Codex", + }] + with mock.patch.object(meter, "cross_session", return_value={"generated_at": 123}) as cross, \ + mock.patch.object(meter, "git_delivery_state", return_value={"ok": True, "days": []}) as git: + valid, valid_status, valid_type = self.request("/builder-recap?range=7") + default, default_status, _ = self.request("/builder-recap") + invalid, invalid_status, _ = self.request("/builder-recap?range=14") + repeated, repeated_status, _ = self.request("/builder-recap?range=7&range=30") + cross.reset_mock() + git.reset_mock() + blanks = [ + self.request("/builder-recap?range="), + self.request("/builder-recap?range=7&range="), + self.request("/builder-recap?range=&range=30"), + ] + + self.assertEqual((valid_status, valid_type), (200, "application/json")) + self.assertEqual(valid["range_days"], 7) + self.assertEqual(valid["privacy"], { + "content_included": False, "project_identity_included": False, + }) + for sentinel in ("private-session", "/private/trace.jsonl", "/private/repo", "private title"): + self.assertNotIn(sentinel, json.dumps(valid, sort_keys=True)) + self.assertEqual((default_status, default["range_days"]), (200, 30)) + for payload, status in ((invalid, invalid_status), (repeated, repeated_status)): + self.assertEqual(status, 400) + self.assertEqual(payload, { + "ok": False, "error": "A valid recap range is required.", + }) + self.assertNotIn("private", json.dumps(payload)) + for payload, status, _ in blanks: + self.assertEqual(status, 400) + self.assertEqual(payload, { + "ok": False, "error": "A valid recap range is required.", + }) + cross.assert_not_called() + git.assert_not_called() + + source = Path(meter.IMPLEMENTATION_FILE).read_text() + self.assertIn('elif req_path == "/builder-recap":', source) + self.assertIn("builder_recap_state(range_values[0])", source) + post_source = source[source.index("def do_POST"):source.index("def do_GET")] + self.assertNotIn('req_path == "/builder-recap"', post_source) + + def test_builder_recap_http_payload_rejects_unsafe_runtime_model_and_tool_labels(self): + sentinels = ("https://private.example", "/private/model", "sk-live-secret", + "api_key=private", "") + meter._xsess["internal_rows"] = [{ + "last": "2026-09-13T12:00:00", "runtime": sentinels[0], + "_model_daily": [{"day": "2026-09-13", "model": sentinels[1], "executions": 3}], + "_performance_samples": [{"day": "2026-09-13", "model": sentinels[2], + "output_tokens": 1000, "duration_s": 10}], + "_tool_evidence": {"tools": [{"name": "read_file", "display": sentinels[3], + "daily": [{"day": "2026-09-13", "calls": 2}]}, + {"name": "read_file", "display": sentinels[4], + "daily": [{"day": "2026-09-13", "calls": 2}]}]}, + }] + with mock.patch.object(meter, "cross_session", return_value={"generated_at": 123}), \ + mock.patch.object(meter, "git_delivery_state", return_value={"ok": True, "days": []}): + payload, status, _ = self.request("/builder-recap?range=7") + + self.assertEqual(status, 200) + serialized = json.dumps(payload, sort_keys=True) + for sentinel in sentinels: + self.assertNotIn(sentinel, serialized) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_builder_recap_page.py b/tests/test_builder_recap_page.py new file mode 100644 index 00000000..b2886284 --- /dev/null +++ b/tests/test_builder_recap_page.py @@ -0,0 +1,868 @@ +import unittest +import http.client +import json +import re +import shutil +import subprocess +import tempfile +import threading +from html.parser import HTMLParser +from pathlib import Path +from unittest import mock +import meter + + +class BuilderRecapStandalonePageTests(unittest.TestCase): + def request(self, method, path): + server = meter.TokenMeterHTTPServer(("127.0.0.1", 0), meter.H) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + conn = http.client.HTTPConnection( + "127.0.0.1", server.server_port, timeout=5, + ) + conn.request(method, path) + response = conn.getresponse() + body = response.read() + result = { + "status": response.status, + "type": response.getheader("Content-Type"), + "length": response.getheader("Content-Length"), + "cache": response.getheader("Cache-Control"), + "body": body, + } + conn.close() + return result + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + def test_performance_document_routes_and_manifest(self): + manifest = Path(meter.__file__).with_name("runtime-manifest.txt").read_text() + self.assertIn("required performance.html", manifest.splitlines()) + + with tempfile.TemporaryDirectory() as tmp: + page = Path(tmp) / "performance.html" + page.write_text("Builder recap") + with mock.patch.object( + meter, "performance_page_path", return_value=str(page), + create=True): + direct = self.request("GET", "/performance") + alias = self.request("GET", "/performance.html") + head = self.request("HEAD", "/performance") + + for response in (direct, alias): + self.assertEqual(response["status"], 200) + self.assertEqual(response["type"], "text/html; charset=utf-8") + self.assertEqual(response["cache"], "no-store, max-age=0") + self.assertEqual(response["body"], page.read_bytes()) + self.assertEqual(head["status"], 200) + self.assertEqual(head["body"], b"") + self.assertEqual(head["length"], str(page.stat().st_size)) + self.assertEqual(head["cache"], "no-store, max-age=0") + + with mock.patch.object( + meter, "performance_page_path", return_value=None, + create=True): + missing = self.request("GET", "/performance") + self.assertEqual(missing["status"], 503) + self.assertIn(b"performance.html is missing", missing["body"]) + self.assertNotIn(str(tmp).encode(), missing["body"]) + + def test_standalone_page_declares_inline_svg_favicon(self): + class IconInventory(HTMLParser): + def __init__(self): + super().__init__() + self.icons = [] + + def handle_starttag(self, tag, attrs): + attributes = dict(attrs) + if tag == "link" and "icon" in attributes.get("rel", "").split(): + self.icons.append(attributes) + + page = Path(meter.__file__).with_name("performance.html").read_text() + markup = IconInventory() + markup.feed(page) + + self.assertEqual(len(markup.icons), 1) + self.assertEqual(markup.icons[0]["type"], "image/svg+xml") + self.assertTrue(markup.icons[0]["href"].startswith("data:image/svg+xml,")) + + def test_standalone_studio_has_the_approved_controls_and_no_remote_assets(self): + page_path = Path(meter.__file__).with_name("performance.html") + self.assertTrue(page_path.is_file()) + page = page_path.read_text() + for marker in ( + 'href="/#subagents"', 'href="/#efficiency"', 'id=range-controls', 'data-range=7', + 'data-range=30', 'data-range=90', + 'NameDownload PNG', + 'id=poster width=1080 height=1350 role=img', + 'id=status role=status aria-live=polite', + 'function drawBuilderRecap(', 'function loadRecap(', + '/builder-recap?range=', 'github.com/splunk/token-meter', + '@media(max-width:1024px)', + ): + self.assertIn(marker, page) + self.assertEqual(page.count("function drawBuilderRecap("), 1) + self.assertNotRegex(page, r'<(?:script|img|link)[^>]+(?:src|href)=["\']https?://') + self.assertNotIn("foreignObject", page) + # Only the shared theme boot script may read storage; the studio never persists builder input. + self.assertNotIn("localStorage", re.sub(r"", "", page, flags=re.S)) + self.assertNotIn("Display name", page) + self.assertNotIn("id=spotlight", page) + self.assertNotIn("id=include-git", page) + + def test_standalone_studio_uses_the_spectrum_shell_and_compact_editor(self): + class MarkupInventory(HTMLParser): + def __init__(self): + super().__init__() + self.body_classes = set() + self.by_id = {} + + def handle_starttag(self, tag, attrs): + attributes = dict(attrs) + if tag == "body": + self.body_classes.update(attributes.get("class", "").split()) + if attributes.get("id"): + self.by_id[attributes["id"]] = (tag, attributes) + + page = Path(meter.__file__).with_name("performance.html").read_text() + markup = MarkupInventory() + markup.feed(page) + + self.assertTrue({"spectrumApp", "performanceApp"}.issubset(markup.body_classes)) + self.assertEqual(markup.by_id["performance-rail"][0], "aside") + self.assertEqual( + markup.by_id["performance-rail"][1]["aria-label"], + "Token Meter navigation", + ) + self.assertEqual(markup.by_id["performance-navigation"][0], "nav") + performance = markup.by_id["tab-performance"] + self.assertEqual(performance[0], "a") + self.assertEqual(performance[1]["href"], "/performance") + self.assertEqual(performance[1]["aria-current"], "page") + self.assertEqual(markup.by_id["performance-surface"][0], "main") + self.assertEqual(markup.by_id["performance-header"][0], "header") + self.assertIn("Token Meter — Performance card", page) + self.assertIn("

    Performance card

    ", page) + self.assertIn("Performance", page) + self.assertNotIn("headerMeta", page) + self.assertNotIn("4:5", page) + self.assertEqual(markup.by_id["performance-controls"][0], "section") + self.assertEqual( + markup.by_id["performance-controls"][1]["aria-label"], + "Performance controls", + ) + self.assertEqual(markup.by_id["performance-preview"][0], "section") + logo = markup.by_id["performance-card-logo"] + self.assertEqual(logo[0], "img") + self.assertEqual( + logo[1]["src"], "/assets/brand/logo-splunk-by-cisco-rgb-w.png", + ) + self.assertIn("hidden", logo[1]) + self.assertIn("
    ", page) + workspace_start = page.index("
    ") + controls_start = page.index( + "
    ", 1)[1].split("
    ", 1)[0] + self.assertLess(primary.index('href="/#models"'), primary.index('href="/#subagents"')) + self.assertLess(primary.index('href="/#subagents"'), primary.index('href="/#efficiency"')) + self.assertLess(primary.index('href="/#efficiency"'), primary.index('href="/#work"')) + self.assertLess(primary.index('href="/#work"'), primary.index('href="/#git"')) + self.assertLess(primary.index('href="/#git"'), primary.index("id=tab-performance")) + # Work appears only where the dashboard shows it. + self.assertIn('id=rail-work href="/#work" aria-label=Work title=Work hidden>', primary) + self.assertIn("$('rail-work').hidden=payload?.settings?.supported!==true;", page) + self.assertNotIn("Make it yours.", page) + self.assertNotIn("Builder Recap Studio", page) + + def assert_measured_grid(self, cells): + """Equal columns span 72-1008 exactly and share every text baseline.""" + self.assertTrue(cells) + self.assertEqual(cells[0]["x"], 72) + self.assertEqual(cells[-1]["x"] + cells[-1]["width"], 1008) + self.assertLessEqual(max(c["width"] for c in cells) - min(c["width"] for c in cells), 1) + gaps = [b["x"] - (a["x"] + a["width"]) for a, b in zip(cells, cells[1:])] + if gaps: + self.assertLessEqual(max(gaps) - min(gaps), 1) + for key in ("valueY", "labelY", "subY", "size"): + self.assertEqual(len({cell[key] for cell in cells}), 1, key) + for cell in cells: + self.assertIsNotNone(cell["value"], cell) + self.assertIsNotNone(cell["label"], cell) + self.assertIn(f"{cell['size']}px", cell["font"]) + + @unittest.skipUnless(shutil.which("node"), "Node.js is required for canvas verification") + def test_standalone_canvas_formats_and_aligns_metric_values(self): + page_path = Path(meter.__file__).with_name("performance.html") + script = f""" +const fs=require('fs'),page=fs.readFileSync({json.dumps(str(page_path))},'utf8'); +function extract(name){{let start=page.indexOf(`function ${{name}}(`);if(start<0)throw Error(`missing ${{name}}`);let i=page.indexOf('{{',start),depth=0;for(;i({{recorded_session:i<24}})),usage:{{agents:[],models:[]}}}}; +const canvas=new Canvas(); +function cellsOf(target,result){{return result.layout.stats.map(cell=>{{const at=(y)=>target.ctx.ops.find(op=>op[0]==='text'&&op[2]===cell.x&&op[3]===y);const value=at(cell.valueY),label=at(cell.labelY),sub=at(cell.subY);return {{id:cell.id,x:cell.x,width:cell.width,valueY:cell.valueY,labelY:cell.labelY,subY:cell.subY,size:cell.size,value:value&&value[1],font:value&&value[4],label:label&&label[1],sub:sub?sub[1]:''}};}});}} +const mainResult=drawBuilderRecap(canvas,payload,{{name:'Ada Builder',includeUsage:true,logo:{{src:'/assets/brand/logo-splunk-by-cisco-rgb-w.png',complete:true,naturalWidth:1024}}}}); +const textOps=canvas.ctx.ops.filter(op=>op[0]==='text'); +const missingPayload=JSON.parse(JSON.stringify(payload));missingPayload.supporting=[];const missingCanvas=new Canvas(),missingResult=drawBuilderRecap(missingCanvas,missingPayload,{{name:'',includeUsage:true}}); +const unavailableSpeedPayload=JSON.parse(JSON.stringify(payload));unavailableSpeedPayload.spotlights.find(stat=>stat.id==='speed_champion').available=false;const unavailableSpeedCanvas=new Canvas(),unavailableSpeedResult=drawBuilderRecap(unavailableSpeedCanvas,unavailableSpeedPayload,{{name:'',includeUsage:true}}); +const pickedCanvas=new Canvas(),pickedResult=drawBuilderRecap(pickedCanvas,payload,{{name:'',includeUsage:true,stats:['marathon_session','tool_calls','active_days']}}); +const sixCanvas=new Canvas(),sixResult=drawBuilderRecap(sixCanvas,payload,{{name:'',includeUsage:true,stats:['active_days','efficiency_change','speed_champion','sessions','covered_spend','marathon_session']}}); +const longPayload=JSON.parse(JSON.stringify(payload));longPayload.supporting.find(stat=>stat.id==='sessions').current=1.2345678901234567e20;const longCanvas=new Canvas(),longResult=drawBuilderRecap(longCanvas,longPayload,{{name:'',includeUsage:true}}); +class InkContext extends Context{{measureText(v){{return {{...super.measureText(v),actualBoundingBoxLeft:-6,actualBoundingBoxRight:-3}};}}}} +const inkCanvas=new Canvas();inkCanvas.ctx=new InkContext();drawBuilderRecap(inkCanvas,payload,{{name:'',includeUsage:true}});const inkOps=inkCanvas.ctx.ops.filter(op=>op[0]==='text'&&op[3]===486).map(op=>[op[1],op[2]]); +console.log(JSON.stringify({{texts:textOps.map(op=>op[1]),logos:canvas.ctx.ops.filter(op=>op[0]==='image'),cells:cellsOf(canvas,mainResult),missingCells:cellsOf(missingCanvas,missingResult),unavailableSpeedCells:cellsOf(unavailableSpeedCanvas,unavailableSpeedResult),pickedCells:cellsOf(pickedCanvas,pickedResult),pickedTexts:pickedCanvas.ctx.ops.filter(op=>op[0]==='text').map(op=>op[1]),sixCells:cellsOf(sixCanvas,sixResult),longCells:cellsOf(longCanvas,longResult),inkOps,ghost:textOps.filter(op=>String(op[4]).includes('330px')),aria:canvas.attrs['aria-label'],missingAria:missingCanvas.attrs['aria-label'],missingTexts:missingCanvas.ctx.ops.filter(op=>op[0]==='text').map(op=>op[1]),commitOp:textOps.find(op=>op[2]===1008&&op[3]===486)}})); +""" + result = subprocess.run( + ["node", "-e", script], capture_output=True, text=True, check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr) + rendered = json.loads(result.stdout) + cells = rendered["cells"] + self.assertEqual( + [(cell["label"], cell["value"]) for cell in cells], + [("ACTIVE DAYS", "24/30"), ("OUTPUT EFFICIENCY", "2.68 K/$"), + ("FASTEST MODEL", "42.8 TOK/S"), ("AI SESSIONS", "126"), + ("EQUIV. SPEND", "$2.18K")], + ) + self.assert_measured_grid(cells) + self.assertEqual([cell["sub"] for cell in cells], + ["14 DAY STREAK", "", "GPT-5.3", "", "ESTIMATE"]) + self.assertIn("30D", rendered["texts"]) + self.assertEqual(rendered["ghost"], []) + visible = " ".join(rendered["texts"]) + self.assertNotIn("LONGEST SESSION", visible) + self.assertNotIn("6H 42M", visible) + self.assertEqual(rendered["texts"].count("LINES PUSHED"), 1) + self.assertNotIn("LINES", rendered["texts"]) + self.assertEqual(rendered["logos"], [[ + "image", "/assets/brand/logo-splunk-by-cisco-rgb-w.png", + 72, 46, 148, 54, + ]]) + self.assertIn("TOKEN METER", rendered["texts"]) + self.assertIn("PERFORMANCE CARD", rendered["texts"]) + self.assertNotIn("TOKEN METER / PERFORMANCE", rendered["texts"]) + self.assertNotIn("2.68K", rendered["texts"]) + self.assertNotIn("Longest session", rendered["aria"]) + self.assertIn("AI sessions 126.", rendered["aria"]) + self.assertIn( + "Covered equivalent spend $2.18K, an estimate.", rendered["aria"], + ) + self.assertIn("AI sessions unavailable.", rendered["missingAria"]) + self.assertIn( + "Covered equivalent spend unavailable.", rendered["missingAria"], + ) + # Unavailable evidence is omitted, not drawn as a zero or a dash, and the + # remaining columns redistribute across the full content measure. + self.assertEqual([cell["id"] for cell in rendered["missingCells"]], + ["active_days", "efficiency_change", "speed_champion"]) + self.assert_measured_grid(rendered["missingCells"]) + self.assertNotIn("—", [cell["value"] for cell in rendered["missingCells"]]) + self.assertNotIn("EQUIV. SPEND", rendered["missingTexts"]) + self.assertEqual([cell["id"] for cell in rendered["unavailableSpeedCells"]], + ["active_days", "efficiency_change", "sessions", "covered_spend"]) + self.assert_measured_grid(rendered["unavailableSpeedCells"]) + # Toggles pick cells in catalog order; unavailable picks drop out. + self.assertEqual([(cell["label"], cell["value"]) for cell in rendered["pickedCells"]], + [("ACTIVE DAYS", "24/30"), ("LONGEST SESSION", "6H 42M")]) + self.assert_measured_grid(rendered["pickedCells"]) + self.assertNotIn("OUTPUT EFFICIENCY", rendered["pickedTexts"]) + self.assertEqual(len(rendered["sixCells"]), 5) + self.assertNotIn("marathon_session", [cell["id"] for cell in rendered["sixCells"]]) + # A value that cannot fit steps the whole row down to the floor before + # it ellipsizes; it never overflows its column. + long_cells = rendered["longCells"] + self.assertEqual({cell["size"] for cell in long_cells}, {24}) + self.assertTrue(long_cells[3]["value"].endswith("…")) + self.assertEqual(long_cells[0]["value"], "24/30") + # Display numerals align by measured ink, not by their advance box. + self.assertEqual(rendered["inkOps"], [["184,392", 66], ["—", 1011]]) + # Absent commit evidence must read as unavailable, never a measured zero. + self.assertEqual(rendered["commitOp"][1:4], ["—", 1008, 486]) + self.assertIn("Commits pushed unavailable.", rendered["aria"]) + + @unittest.skipUnless(shutil.which("node"), "Node.js is required for headline verification") + def test_standalone_headline_band_rhythm_and_controls(self): + page_path = Path(meter.__file__).with_name("performance.html") + names = ['safeArray', 'sanitizeBuilderName', 'compactNumber', 'durationLabel', 'formatStat', + 'formatSpend', 'ellipsize', 'fitText', 'gradientFill', 'radialFill', 'fillRoundedRect', + 'setTracking', 'cardPalette', 'ditherRegion', 'supportingStats', 'inkX', 'posterStatLimit', + 'defaultPosterStats', 'posterStatCatalog', 'findRecapStat', 'posterStatCells', + 'resolveHeadline', 'paperLayout', 'headlineBandHeight', 'statRowHeight', + 'usageBlockHeight', 'drawHeadlineBand', 'drawStatRow', 'drawUsageGroup', + 'drawBuilderRecap', 'renderControls', 'resetRecap'] + script = f""" +const fs=require('fs'),page=fs.readFileSync({json.dumps(str(page_path))},'utf8'); +function extract(name){{let start=page.indexOf(`function ${{name}}(`);if(start<0)throw Error(`missing ${{name}}`);let i=page.indexOf('{{',start),depth=0;for(;i({{recorded_session:i<24}})),usage:{{agents:[{{label:'Claude Code',share:92}},{{label:'Codex',share:8}}],models:[{{label:'sonnet-4.5',runtime:'Claude Code',share:70}},{{label:'opus-4.1',runtime:'Claude Code',share:20}},{{label:'gpt-5.3',runtime:'Codex',share:10}}]}}}}; +function draw(options){{const canvas=new Canvas(),result=drawBuilderRecap(canvas,payload,options);return {{result,texts:canvas.ctx.ops.filter(op=>op[0]==='text').map(op=>op[1]),aria:canvas.attrs['aria-label'],ops:canvas.ctx.ops}};}} +const auto=draw({{name:'',includeUsage:true}}),streak=draw({{name:'',includeUsage:true,headline:'streak'}}),none=draw({{name:'',includeUsage:true,headline:'none'}}),stale=draw({{name:'',includeUsage:true,headline:'best_week'}}),bare=draw({{name:'',includeUsage:false,headline:'none'}}); +const band=auto.result.layout.headline,bandRect=auto.ops.find(op=>op[0]==='round'&&op[1]===72&&op[3]===936); +class Control{{constructor(stat){{this.disabled=false;this.checked=false;this.value='';this.textContent='';this.children=[];this.dataset=stat?{{stat}}:{{}};}}appendChild(child){{this.children.push(child);}}}} +const statInputs=posterStatCatalog().map(entry=>new Control(entry.id)),controls={{'builder-name':new Control(),'include-usage':new Control(),'headline-select':new Control()}},$=id=>controls[id]; +const document={{querySelectorAll:selector=>selector==='[data-stat]'?statInputs:[],createElement:()=>new Control()}}; +const builderRecapState={{range:30,name:'',includeUsage:true,headline:'streak',stats:defaultPosterStats(),payload}}; +renderControls(payload); +const select=controls['headline-select'],controlState={{options:select.children.map(option=>[option.value,option.textContent]),value:select.value,stats:statInputs.map(input=>[input.dataset.stat,input.checked,input.disabled])}}; +builderRecapState.headline='gone';renderControls(payload);const staleValue=select.value; +builderRecapState.stats=['active_days'];builderRecapState.headline='none';let loads=0;function loadRecap(){{loads++;}}resetRecap(); +console.log(JSON.stringify({{auto:{{texts:auto.texts,aria:auto.aria,layout:auto.result.layout}},band,bandRect,streakTexts:streak.texts,noneLayout:none.result.layout,noneTexts:none.texts,staleTexts:stale.texts,bareLayout:bare.result.layout,controlState,staleValue,reset:{{headline:builderRecapState.headline,stats:builderRecapState.stats,loads}}}})); +""" + result = subprocess.run(["node", "-e", script], capture_output=True, text=True, check=False) + self.assertEqual(result.returncode, 0, result.stderr) + rendered = json.loads(result.stdout) + # Auto picks the first (highest-priority) candidate and draws it as a band. + for text in ("92%", "A CLAUDE PERSON", "OF SESSIONS ON CLAUDE CODE"): + self.assertIn(text, rendered["auto"]["texts"]) + self.assertIn("Headline A Claude person: 92% of sessions on Claude Code.", rendered["auto"]["aria"]) + band = rendered["band"] + self.assertEqual((band["left"], band["width"]), (72, 936)) + self.assertEqual(rendered["bandRect"][1:5], [72, band["top"], 936, 96]) + self.assertEqual(band["valueY"], band["detailY"]) + self.assertLess(band["titleY"], band["valueY"]) + self.assertGreater(band["textEdge"], band["valueEdge"]) + # One spacing-scale gap separates every paper block, and the stack is + # centered with equal padding no smaller than that gap. + layout = rendered["auto"]["layout"] + blocks = layout["blocks"] + self.assertIn(layout["gap"], (16, 24, 32, 40, 48)) + order = ["headline", "stats", "rule", "usage"] + self.assertEqual(sorted(blocks), sorted(order)) + for upper, lower in zip(order, order[1:]): + self.assertEqual( + blocks[lower]["top"] - (blocks[upper]["top"] + blocks[upper]["height"]), + layout["gap"], (upper, lower)) + top_pad = blocks["headline"]["top"] - 720 + bottom_pad = 1220 - (blocks["usage"]["top"] + blocks["usage"]["height"]) + self.assertLessEqual(abs(top_pad - bottom_pad), 1) + self.assertGreaterEqual(min(top_pad, bottom_pad), layout["gap"]) + self.assertLess(band["top"], layout["stats"][0]["valueY"]) + # Explicit choice, None, and a stale choice that is no longer available. + self.assertIn("14 days", rendered["streakTexts"]) + self.assertNotIn("A CLAUDE PERSON", rendered["streakTexts"]) + self.assertIsNone(rendered["noneLayout"]["headline"]) + self.assertNotIn("headline", rendered["noneLayout"]["blocks"]) + self.assertNotIn("A CLAUDE PERSON", rendered["noneTexts"]) + self.assertIn("A CLAUDE PERSON", rendered["staleTexts"]) + self.assertEqual(sorted(rendered["bareLayout"]["blocks"]), ["stats"]) + # Controls: Auto, each candidate, None; stat toggles cap at five. + self.assertEqual(rendered["controlState"]["options"], [ + ["auto", "Auto"], ["identity", "A Claude person · 92%"], + ["streak", "Longest streak · 14 days"], ["none", "None"], + ]) + self.assertEqual(rendered["controlState"]["value"], "streak") + self.assertEqual(rendered["staleValue"], "auto") + stats = {stat_id: (checked, disabled) for stat_id, checked, disabled in rendered["controlState"]["stats"]} + for stat_id in ("active_days", "efficiency_change", "speed_champion", "sessions", "covered_spend"): + self.assertEqual(stats[stat_id], (True, False), stat_id) + # Five shown: no sixth choice; stats without evidence cannot be picked. + for stat_id in ("marathon_session", "output_pace", "tool_calls", "models"): + self.assertEqual(stats[stat_id], (False, True), stat_id) + self.assertEqual(rendered["reset"], { + "headline": "auto", + "stats": ["active_days", "efficiency_change", "speed_champion", "sessions", "covered_spend"], + "loads": 1, + }) + + @unittest.skipUnless(shutil.which("node"), "Node.js is required for headline verification") + def test_standalone_auto_headline_skips_facts_the_poster_already_shows(self): + page_path = Path(meter.__file__).with_name("performance.html") + script = f""" +const fs=require('fs'),page=fs.readFileSync({json.dumps(str(page_path))},'utf8'); +function extract(name){{let start=page.indexOf(`function ${{name}}(`);let i=page.indexOf('{{',start),depth=0;for(;i(resolveHeadline(payload,choice,shown)||{{}}).id||null; +console.log(JSON.stringify({{ + fresh:pick('auto',[]), + skipOne:pick('auto',['identity']), + skipTwo:pick('auto',['identity','streak']), + allShown:pick('auto',['identity','streak','cache_share']), + explicit:pick('identity',['identity']), + none:pick('none',[]) +}})); +""" + result = subprocess.run(["node", "-e", script], capture_output=True, text=True, check=False) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(result.stdout), { + "fresh": "identity", "skipOne": "streak", "skipTwo": "cache_share", + "allShown": "identity", "explicit": "identity", "none": None, + }) + + @unittest.skipUnless(shutil.which("node"), "Node.js is required for texture verification") + def test_standalone_texture_helpers_dither_gradients_and_degrade_safely(self): + page_path = Path(meter.__file__).with_name("performance.html") + script = f""" +const fs=require('fs'),page=fs.readFileSync({json.dumps(str(page_path))},'utf8'); +function extract(name){{let start=page.indexOf(`function ${{name}}(`);if(start<0)throw Error(`missing ${{name}}`);let i=page.indexOf('{{',start),depth=0;for(;iindex%4!==3),alphas=[...new Set(put[0][2].filter((_,index)=>index%4===3))]; +const missing=ditherRegion({{}},0,0,4,4,4); +const tainted=ditherRegion({{getImageData(){{throw new Error('tainted');}},putImageData(){{}}}},0,0,4,4,4); +const roundedOps=[],rounded={{roundRect(...v){{roundedOps.push(['roundRect',...v]);}},beginPath(){{roundedOps.push(['beginPath']);}},fill(){{roundedOps.push(['fill']);}},fillRect(...v){{roundedOps.push(['fillRect',...v]);}}}}; +const usedRound=fillRoundedRect(rounded,10,20,200,12,6); +const plainOps=[],plain={{fillRect(...v){{plainOps.push(['fillRect',...v]);}}}}; +const usedPlain=fillRoundedRect(plain,10,20,200,12,6); +console.log(JSON.stringify({{ + dithered,target:[put[0][0],put[0][1]],levels:[...new Set(channels)].sort((a,b)=>a-b),alphas, + missing,tainted,usedRound,roundedOps,usedPlain,plainOps, + gradientFallback:gradientFill({{}},0,0,1,1,[[0,'#111111'],[1,'#222222']]), + radialFallback:radialFill({{}},0,0,10,[[0,'#333333'],[1,'#444444']]), +}})); +""" + result = subprocess.run( + ["node", "-e", script], capture_output=True, text=True, check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr) + rendered = json.loads(result.stdout) + self.assertTrue(rendered["dithered"]) + self.assertEqual(rendered["target"], [12, 34]) + self.assertEqual(rendered["levels"], [0, 85]) + self.assertEqual(rendered["alphas"], [255]) + self.assertFalse(rendered["missing"]) + self.assertFalse(rendered["tainted"]) + self.assertTrue(rendered["usedRound"]) + self.assertEqual(rendered["roundedOps"], [ + ["beginPath"], ["roundRect", 10, 20, 200, 12, 6], ["fill"], + ]) + self.assertFalse(rendered["usedPlain"]) + self.assertEqual(rendered["plainOps"], [["fillRect", 10, 20, 200, 12]]) + self.assertEqual(rendered["gradientFallback"], "#222222") + self.assertEqual(rendered["radialFallback"], "#444444") + + @unittest.skipUnless(shutil.which("node"), "Node.js is required for dither wiring verification") + def test_standalone_canvas_dithers_each_band_before_drawing_any_text(self): + page_path = Path(meter.__file__).with_name("performance.html") + script = f""" +const fs=require('fs'),page=fs.readFileSync({json.dumps(str(page_path))},'utf8'); +function extract(name){{let start=page.indexOf(`function ${{name}}(`);if(start<0)throw Error(`missing ${{name}}`);let i=page.indexOf('{{',start),depth=0;for(;ii%4!==3))].sort((a,b)=>a-b)]);}} + measureText(v){{const size=Number((/([0-9.]+)px/.exec(this.font)||[])[1])||14,narrow=/Narrow|Condensed/.test(this.font);return {{width:String(v).length*size*(narrow?.45:.55)}};}} + beginPath(){{}}arc(){{}}stroke(){{}}save(){{}}restore(){{}}translate(){{}}rotate(){{}} +}} +class Canvas{{constructor(){{this.ctx=new Context();this.attrs={{}};}}getContext(){{return this.ctx;}}setAttribute(k,v){{this.attrs[k]=String(v);}}}} +const payload={{range_days:30,current:{{end_day:'2026-09-13'}},spotlights:[{{id:'lines_pushed',available:true,current:39425,unit:'lines'}}],supporting:[],activity_days:Array.from({{length:30}},(_,i)=>({{recorded_session:i<26}})),usage:{{agents:[{{label:'Codex',share:71}}],models:[{{label:'gpt-5.6',runtime:'Codex',share:69}}]}}}}; +const canvas=new Canvas();drawBuilderRecap(canvas,payload,{{name:'Ada',includeUsage:true}}); +const kinds=canvas.ctx.ops.map(op=>op[0]); +console.log(JSON.stringify({{ + bands:canvas.ctx.bands, + lastDither:kinds.lastIndexOf('dither'), + firstText:kinds.indexOf('text'), +}})); +""" + result = subprocess.run( + ["node", "-e", script], capture_output=True, text=True, check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr) + rendered = json.loads(result.stdout) + self.assertEqual( + [band[:4] for band in rendered["bands"]], + [[0, 0, 1080, 720], [0, 720, 1080, 500], [0, 1220, 1080, 130]], + ) + # Distinct output values pin each band's quantization step, so changing a + # band's `levels` cannot pass silently. + self.assertEqual( + [band[4] for band in rendered["bands"]], + [[98, 118], [85, 102], [98, 118]], + ) + self.assertGreater(rendered["firstText"], -1) + self.assertLess(rendered["lastDither"], rendered["firstText"]) + + @unittest.skipUnless(shutil.which("node"), "Node.js is required for rhythm verification") + def test_standalone_canvas_groups_long_ranges_into_active_rhythm_bars(self): + page_path = Path(meter.__file__).with_name("performance.html") + script = f""" +const fs=require('fs'),page=fs.readFileSync({json.dumps(str(page_path))},'utf8'); +function extract(name){{let start=page.indexOf(`function ${{name}}(`);if(start<0)throw Error(`missing ${{name}}`);let i=page.indexOf('{{',start),depth=0;for(;i({{recorded_session:i<76}})),usage:{{agents:[],models:[]}}}}; +const canvas=new Canvas();drawBuilderRecap(canvas,payload,{{name:'',includeUsage:true}}); +const rhythmBars=canvas.ctx.ops.filter(op=>op[0]==='rect'&&op[2]>=72&&op[2]<1008&&op[3]+op[5]===694); +console.log(JSON.stringify({{ + total:rhythmBars.length, + active:rhythmBars.filter(op=>op[1]&&op[1].kind==='linear').length, + inactive:rhythmBars.filter(op=>op[1]==='rgba(159,180,189,.3)').map(op=>op[5]), + texts:canvas.ctx.ops.filter(op=>op[0]==='text').map(op=>op[1]), + aria:canvas.attrs['aria-label'], +}})); +""" + result = subprocess.run( + ["node", "-e", script], capture_output=True, text=True, check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr) + rendered = json.loads(result.stdout) + self.assertEqual(rendered["total"], 30) + self.assertEqual(rendered["active"], 26) + self.assertEqual(rendered["inactive"], [8, 8, 8, 8]) + self.assertIn("76/90", rendered["texts"]) + self.assertIn("LAST 90 DAYS", rendered["texts"]) + # Bucketed bars must disclose their grouping instead of implying one day each. + self.assertIn("Rhythm chart groups 3 days per bar.", rendered["aria"]) + + @unittest.skipUnless(shutil.which("node"), "Node.js is required for canvas verification") + def test_standalone_canvas_is_a_linkedin_scale_builder_signal(self): + page_path = Path(meter.__file__).with_name("performance.html") + script = f""" +const fs=require('fs'),page=fs.readFileSync({json.dumps(str(page_path))},'utf8'); +function extract(name){{let start=page.indexOf(`function ${{name}}(`);if(start<0)throw Error(`missing ${{name}}`);if(page.slice(start-6,start)==='async ')start-=6;let i=page.indexOf('{{',start),depth=0;for(;i({{recorded_session:i<24}})),usage:{{agents:[{{label:'Codex',count:68,share:54}},{{label:'Claude',count:39,share:31}},{{label:'Cursor',count:19,share:15}}],models:[{{label:'gpt-5.3',runtime:'Codex',count:74,share:49}},{{label:'sonnet-4.5',runtime:'Claude',count:48,share:32}},{{label:'gpt-5.2',runtime:'Codex',count:29,share:19}}]}},coverage:{{git:{{available:true}},cost:{{available:true,eligible:22,total:30}},output:{{available:true,eligible:22,total:30}},timing:{{available:true,eligible:14,total:18}}}},privacy:{{content_included:false,project_identity_included:false}}}}; + const canvas=new Canvas(),result=drawBuilderRecap(canvas,payload,{{name:' Ada\\n Builder ',includeUsage:true}}),texts=canvas.ctx.ops.filter(op=>op[0]==='text').map(op=>op[1]),arcs=canvas.ctx.ops.filter(op=>op[0]==='arc'),bars=canvas.ctx.ops.filter(op=>op[0]==='rect'&&(op[5]===12||op[5]===6)),rhythmBars=canvas.ctx.ops.filter(op=>op[0]==='rect'&&op[2]>=72&&op[2]<1008&&op[3]+op[5]===694),heroFields=canvas.ctx.ops.filter(op=>op[0]==='rect'&&op[1]&&op[1].kind==='linear'&&op[2]===0&&op[3]===0&&op[4]===1080&&op[5]>=700),lowerFields=canvas.ctx.ops.filter(op=>op[0]==='rect'&&op[1]&&op[1].kind==='linear'&&op[2]===0&&op[3]>=700&&op[4]===1080&&op[5]>=450),nameOp=canvas.ctx.ops.find(op=>op[0]==='text'&&op[1]==='ADA BUILDER'),heroOp=canvas.ctx.ops.find(op=>op[0]==='text'&&op[3]===486),efficiencyLabelOp=canvas.ctx.ops.find(op=>op[0]==='text'&&op[1]==='OUTPUT EFFICIENCY'&&op[3]===result.layout.stats[0].labelY),metricLabelOps=canvas.ctx.ops.filter(op=>op[0]==='text'&&op[3]===result.layout.stats[0].labelY),ghostPeriodOps=canvas.ctx.ops.filter(op=>op[0]==='text'&&String(op[4]).includes('330px')); + const negativePayload=JSON.parse(JSON.stringify(payload));negativePayload.spotlights.find(stat=>stat.id==='lines_pushed').delta_pct=-25;const negativeCanvas=new Canvas();drawBuilderRecap(negativeCanvas,negativePayload,{{name:'',includeUsage:true}}); + const unavailablePayload=JSON.parse(JSON.stringify(payload));Object.assign(unavailablePayload.spotlights.find(stat=>stat.id==='lines_pushed'),{{available:false,current:null,previous:null,delta_pct:null}});unavailablePayload.coverage.git.available=false;const unavailableCanvas=new Canvas();drawBuilderRecap(unavailableCanvas,unavailablePayload,{{name:'',includeUsage:true}}); + const flatPayload=JSON.parse(JSON.stringify(payload));flatPayload.spotlights.find(stat=>stat.id==='lines_pushed').delta_pct=0;const flatCanvas=new Canvas();drawBuilderRecap(flatCanvas,flatPayload,{{name:'',includeUsage:true}});const flatTexts=flatCanvas.ctx.ops.filter(op=>op[0]==='text').map(op=>op[1]); + const privateCanvas=new Canvas();drawBuilderRecap(privateCanvas,payload,{{name:'',includeUsage:false}});const privateTexts=privateCanvas.ctx.ops.filter(op=>op[0]==='text').map(op=>op[1]); + const noUsagePayload=JSON.parse(JSON.stringify(payload));noUsagePayload.usage={{agents:[],models:[]}};const noUsageCanvas=new Canvas();drawBuilderRecap(noUsageCanvas,noUsagePayload,{{name:'',includeUsage:true}});const noUsageTexts=noUsageCanvas.ctx.ops.filter(op=>op[0]==='text').map(op=>op[1]); + const gitOnlyPayload=JSON.parse(JSON.stringify(payload));gitOnlyPayload.activity_days=gitOnlyPayload.activity_days.map(day=>({{...day,recorded_session:false}}));const gitOnlyActivity=hasActivity(gitOnlyPayload); + class Control{{constructor(){{this.disabled=false;this.checked=false;this.value='';this.textContent='';this.children=[];}}appendChild(child){{this.children.push(child);}}}} + const controlElements={{'builder-name':new Control(),'include-usage':new Control()}},$=id=>controlElements[id],document={{querySelectorAll:()=>[],createElement:()=>new Control()}},builderRecapState={{range:30,name:'',includeUsage:true,payload:noUsagePayload}};function builderOptions(){{return {{name:builderRecapState.name,includeUsage:builderRecapState.includeUsage}};}}eval(extract('renderControls'));renderControls(noUsagePayload); + const heroRowLabels=canvas.ctx.ops.filter(op=>op[0]==='text'&&op[3]===314).map(op=>[op[1],op[2]]),heroRowValues=canvas.ctx.ops.filter(op=>op[0]==='text'&&op[3]===486).map(op=>[op[1],op[2],op[4]]),columnRules=canvas.ctx.ops.filter(op=>op[0]==='rect'&&op[3]===282&&op[5]===3).map(op=>[op[2],op[4]]); + console.log(JSON.stringify({{size:[canvas.width,canvas.height],texts,arcs,bars,rhythmBars,heroFields,lowerFields,heroRowLabels,heroRowValues,columnRules,aria:canvas.attrs['aria-label'],negativeAria:negativeCanvas.attrs['aria-label'],unavailableAria:unavailableCanvas.attrs['aria-label'],flatAria:flatCanvas.attrs['aria-label'],flatTexts,nameOp,heroOp,efficiencyLabelOp,metricLabelOps,ghostPeriodOps,summary:result.summary,privateTexts,privateAria:privateCanvas.attrs['aria-label'],noUsageTexts,noUsageAria:noUsageCanvas.attrs['aria-label'],gitOnlyActivity,noUsageControl:{{disabled:controlElements['include-usage'].disabled,checked:controlElements['include-usage'].checked}}}})); +""" + result = subprocess.run( + ["node", "-e", script], capture_output=True, text=True, check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr) + rendered = json.loads(result.stdout) + self.assertEqual(rendered["size"], [1080, 1350]) + for text in ( + "TOKEN METER", "PERFORMANCE CARD", "LAST 30 DAYS", "30D", + "ADA BUILDER", "LINES PUSHED", + "184,392", "OUTPUT EFFICIENCY", "2.68 K/$", "24/30", + "ACTIVE DAYS", "14 DAY STREAK", "FASTEST MODEL", "42.8 TOK/S", + "GPT-5.3", "AI SESSIONS", "126", + "MOST-USED AGENT", "GO-TO MODEL", "CODEX", "54%", "49%", + "BUILD YOURS →", "github.com/splunk/token-meter", + ): + self.assertIn(text, rendered["texts"]) + self.assertEqual(rendered["nameOp"][1:4], ["ADA BUILDER", 72, 250]) + self.assertIn("44px", rendered["nameOp"][4]) + self.assertEqual(rendered["heroOp"][1], "184,392") + # Lines and commits form one two-column hero: labels share a line, values + # share a baseline, and each column carries its own accent rule. + self.assertEqual( + [(op[0], op[1]) for op in rendered["heroRowLabels"]], + [("LINES PUSHED", 72), ("COMMITS", 1008)], + ) + self.assertEqual( + [(op[0], op[1]) for op in rendered["heroRowValues"]], + [("184,392", 72), ("1,284", 1008)], + ) + self.assertEqual(rendered["columnRules"], [[72, 108], [900, 108]]) + # Commits must stay visually subordinate to the lines hero. + hero_size = int(re.search(r"(\d+)px", rendered["heroRowValues"][0][2]).group(1)) + commit_size = int(re.search(r"(\d+)px", rendered["heroRowValues"][1][2]).group(1)) + self.assertLess(commit_size, hero_size) + self.assertIn("Commits pushed 1,284.", rendered["aria"]) + self.assertIsNotNone(rendered["efficiencyLabelOp"]) + # No covered-spend evidence: the spend cell is omitted and four equal + # columns span the content measure instead of leaving a gap. + self.assertEqual( + [(op[1], op[2]) for op in rendered["metricLabelOps"]], + [("ACTIVE DAYS", 72), ("OUTPUT EFFICIENCY", 314), + ("FASTEST MODEL", 556), ("AI SESSIONS", 798)], + ) + self.assertNotIn("EQUIV. SPEND", rendered["texts"]) + self.assertEqual(rendered["ghostPeriodOps"], []) + self.assertGreaterEqual(len(rendered["bars"]), 6) + self.assertEqual(len(rendered["rhythmBars"]), 30) + for index, bar in enumerate(rendered["rhythmBars"][:24]): + self.assertEqual(bar[1]["kind"], "linear") + self.assertEqual( + [stop[1] for stop in bar[1]["stops"]], + ["#8ee9f2", "#00bceb", "rgba(64,84,214,.72)"], + ) + self.assertEqual(bar[5], 28 + ((index * 19) % 66)) + for bar in rendered["rhythmBars"][24:]: + self.assertEqual((bar[1], bar[5]), ("rgba(159,180,189,.3)", 8)) + self.assertTrue(rendered["heroFields"]) + self.assertTrue(rendered["lowerFields"]) + self.assertEqual( + [stop[1] for stop in rendered["heroFields"][0][1]["stops"]], + ["#0d2c3d", "#081a27", "#040d14"], + ) + self.assertEqual( + [stop[1] for stop in rendered["lowerFields"][0][1]["stops"]], + ["#f8f5ee", "#f1eee4", "#e4e0d3"], + ) + self.assertFalse(rendered["arcs"]) + for raw_count in ("68", "39", "19", "74", "48", "29"): + self.assertNotIn(raw_count, rendered["texts"]) + visible = " ".join(rendered["texts"]) + for rejected in ( + "BUILDER FIELD SHEET", "DAY SIGNAL", "30-DAY RHYTHM", "AGENT MIX", + "MODEL MIX", "LOCAL / PARTIAL", + "PRIVATE LOCAL AGGREGATES", "AI BUILDER RECAP", "prompt://must-not-draw", + "VS PRIOR", "↑", "↓", "+54%", "−25%", "NO CHANGE", + "LONGEST SESSION", "6H 42M", + ): + self.assertNotIn(rejected, visible) + self.assertIn( + "Name Ada Builder. Current lines pushed 184,392 lines.", + rendered["aria"], + ) + self.assertIn( + "Current lines pushed 184,392 lines.", + rendered["negativeAria"], + ) + self.assertIn( + "Current lines pushed unavailable.", + rendered["unavailableAria"], + ) + self.assertIn( + "Current lines pushed 184,392 lines.", rendered["flatAria"], + ) + for output in ( + rendered["aria"], rendered["negativeAria"], + rendered["unavailableAria"], rendered["flatAria"], + rendered["summary"], " ".join(rendered["flatTexts"]), + ): + for rejected in ( + "Partial evidence", "Complete evidence", "increased", "decreased", + "unchanged", "versus prior", "comparison", "VS PRIOR", "↑", "↓", + ): + self.assertNotIn(rejected, output) + self.assertTrue(rendered["gitOnlyActivity"]) + self.assertIn("runtime-scoped model usage", rendered["aria"]) + self.assertNotIn("prompt://must-not-draw", rendered["aria"]) + private_visible = " ".join(rendered["privateTexts"]) + self.assertIn("LINES PUSHED", private_visible) + self.assertIn("184,392", rendered["privateTexts"]) + self.assertIn("OUTPUT EFFICIENCY", private_visible) + for excluded in ("MOST-USED AGENT", "GO-TO MODEL", "CODEX", "CLAUDE", + "CURSOR", "GPT-5.3", "SONNET-4.5", "GPT-5.2"): + self.assertNotIn(excluded, private_visible) + for excluded in ("Codex", "Claude", "Cursor", "gpt-5.3", "sonnet-4.5", "gpt-5.2"): + self.assertNotIn(excluded, rendered["privateAria"]) + self.assertIn("Usage charts excluded", rendered["privateAria"]) + no_usage_visible = " ".join(rendered["noUsageTexts"]) + self.assertNotIn("MOST-USED AGENT", no_usage_visible) + self.assertNotIn("GO-TO MODEL", no_usage_visible) + self.assertIn("Usage charts unavailable", rendered["noUsageAria"]) + self.assertEqual(rendered["noUsageControl"], { + "disabled": True, "checked": False, + }) + + @unittest.skipUnless(shutil.which("node"), "Node.js is required for preview verification") + def test_standalone_success_enables_download_without_export_ready_copy(self): + page_path = Path(meter.__file__).with_name("performance.html") + script = f""" +const fs=require('fs'),page=fs.readFileSync({json.dumps(str(page_path))},'utf8'); +function extract(name){{let start=page.indexOf(`function ${{name}}(`);if(start<0)throw Error(`missing ${{name}}`);let i=page.indexOf('{{',start),depth=0;for(;ielements[id]; +elements.poster.getContext=()=>({{}});elements.poster.setAttribute=()=>{{}}; +const builderRecapState={{payload:{{activity_days:[{{recorded_session:true}}]}},loading:false,error:'',rendered:false}}; +const logo={{complete:false,naturalWidth:0}};let shouldFail=false;function hasActivity(){{return true;}}function drawBuilderRecap(){{if(shouldFail)throw Error('private failure');}}function drawPlaceholder(){{}}function builderOptions(){{return {{logo}};}}function setStatus(message,kind=''){{elements.status.textContent=message;elements.status.dataset.kind=kind;}} +for(const name of ['showLogoFailure','renderPreview'])eval(extract(name)); +const waitingReady=renderPreview(),waiting={{ready:waitingReady,download:elements.download.disabled,status:elements.status.textContent}}; +logo.complete=true;logo.naturalWidth=1024; +const ready=renderPreview(),success={{ready,download:elements.download.disabled,status:elements.status.textContent,kind:elements.status.dataset.kind}}; +shouldFail=true;const failed=renderPreview(),failure={{failed,download:elements.download.disabled,retry:elements.retry.hidden,status:elements.status.textContent,kind:elements.status.dataset.kind}}; +console.log(JSON.stringify({{waiting,success,failure}})); +""" + result = subprocess.run( + ["node", "-e", script], capture_output=True, text=True, check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr) + rendered = json.loads(result.stdout) + self.assertEqual(rendered["waiting"], { + "ready": False, "download": True, + "status": "Loading local brand asset…", + }) + self.assertEqual(rendered["success"], { + "ready": True, "download": False, "status": "", "kind": "", + }) + self.assertEqual(rendered["failure"], { + "failed": False, "download": True, "retry": False, + "status": "Preview unavailable. Try again.", "kind": "error", + }) + + @unittest.skipUnless(shutil.which("node"), "Node.js is required for logo recovery verification") + def test_standalone_logo_error_retries_local_asset_before_enabling_download(self): + page_path = Path(meter.__file__).with_name("performance.html") + script = f""" +const fs=require('fs'),page=fs.readFileSync({json.dumps(str(page_path))},'utf8'); +function extract(name){{let start=page.indexOf(`function ${{name}}(`);if(start<0)return '';let i=page.indexOf('{{',start),depth=0;for(;ielements[id]; +elements.poster.getContext=()=>({{}});elements.poster.setAttribute=()=>{{}}; +const builderRecapState={{range:30,payload:{{activity_days:[{{recorded_session:true}}]}},loading:false,error:'',rendered:false,logoError:false}}; +let recapLoads=0;function loadRecap(){{recapLoads++;}}function resetRecap(){{}}function downloadRecap(){{}}function hasActivity(){{return true;}}function drawBuilderRecap(){{}}function drawPlaceholder(){{}}function builderOptions(){{return {{logo:elements['performance-card-logo']}};}}function setStatus(message,kind=''){{elements.status.textContent=message;elements.status.dataset.kind=kind;}} +for(const name of ['showLogoFailure','retryLogo','handleLogoLoad','renderPreview']){{const source=extract(name);if(source)eval(source);}} +for(const line of page.split('\\n').filter(line=>line.includes("$('retry').addEventListener")||line.includes("$('performance-card-logo').addEventListener")))eval(line); +renderPreview(); +const logo=elements['performance-card-logo'];logo.complete=true;logo.dispatch('error'); +const failure={{download:elements.download.disabled,retry:elements.retry.hidden,status:elements.status.textContent,kind:elements.status.dataset.kind}}; +elements.retry.dispatch('click'); +const retrying={{download:elements.download.disabled,retry:elements.retry.hidden,status:elements.status.textContent,kind:elements.status.dataset.kind,source:logo.src,sourceWrites:logo.sourceWrites,sourceRemovals:logo.sourceRemovals,recapLoads}}; +logo.complete=true;logo.naturalWidth=1024;logo.dispatch('load'); +const recovered={{rendered:builderRecapState.rendered,download:elements.download.disabled,retry:elements.retry.hidden,status:elements.status.textContent,kind:elements.status.dataset.kind}}; +console.log(JSON.stringify({{failure,retrying,recovered}})); +""" + result = subprocess.run( + ["node", "-e", script], capture_output=True, text=True, check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr) + rendered = json.loads(result.stdout) + self.assertEqual(rendered["failure"], { + "download": True, "retry": False, + "status": "Local brand asset unavailable. Try again.", + "kind": "error", + }) + self.assertEqual(rendered["retrying"], { + "download": True, "retry": True, + "status": "Loading local brand asset…", "kind": "", + "source": "/assets/brand/logo-splunk-by-cisco-rgb-w.png", + "sourceWrites": ["/assets/brand/logo-splunk-by-cisco-rgb-w.png"], + "sourceRemovals": 1, "recapLoads": 0, + }) + self.assertEqual(rendered["recovered"], { + "rendered": True, "download": False, "retry": True, + "status": "", "kind": "", + }) + + def test_standalone_export_contract_is_png_only(self): + page = Path(meter.__file__).with_name("performance.html").read_text() + for marker in ( + "canvas.width=1080", "canvas.height=1350", "canvas.toBlob(blob=>", + "'image/png'", "URL.revokeObjectURL", "function downloadRecap(", + "function resetRecap(", "new AbortController()", + ): + self.assertIn(marker, page) + self.assertNotIn("LOCAL / PARTIAL", page) + + @unittest.skipUnless(shutil.which("node"), "Node.js is required for input verification") + def test_standalone_name_input_preserves_spaces_while_typing(self): + page_path = Path(meter.__file__).with_name("performance.html") + script = f""" +const fs=require('fs'),page=fs.readFileSync({json.dumps(str(page_path))},'utf8'); +function extract(name){{let start=page.indexOf(`function ${{name}}(`);if(start<0)throw Error(`missing ${{name}}`);let i=page.indexOf('{{',start),depth=0;for(;iinput; +const builderRecapState={{name:''}};let renders=0;function renderPreview(){{renders++;}} +eval(extract('sanitizeBuilderName')); +const handlerSource=page.split('\\n').find(line=>line.startsWith("$('builder-name').addEventListener('input',"));eval(handlerSource); +for(const character of 'Ada Lovelace'){{input.value+=character;input.handler({{target:input}});}} +console.log(JSON.stringify({{field:input.value,state:builderRecapState.name,renders}})); +""" + result = subprocess.run( + ["node", "-e", script], capture_output=True, text=True, check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(result.stdout), { + "field": "Ada Lovelace", "state": "Ada Lovelace", "renders": 12, + }) + + @unittest.skipUnless(shutil.which("node"), "Node.js is required for request verification") + def test_standalone_request_lifecycle_is_latest_only_and_retryable(self): + page_path = Path(meter.__file__).with_name("performance.html") + script = f""" +const fs=require('fs'),page=fs.readFileSync({json.dumps(str(page_path))},'utf8'); +function extract(name){{let start=page.indexOf(`function ${{name}}(`);if(start<0)throw Error(`missing ${{name}}`);if(page.slice(start-6,start)==='async ')start-=6;let i=page.indexOf('{{',start),depth=0;for(;i[id,new Element()])), $=id=>elements[id]; +const builderRecapState={{range:30,name:'',includeUsage:true,payload:null,request:0,controller:null,loading:false,error:'',rendered:false}}; +class AbortController{{constructor(){{this.signal={{aborted:false}};}}abort(){{this.signal.aborted=true;}}}} +let requests=[],status='';function fetch(url,options){{return new Promise(resolve=>requests.push({{url,options,resolve}}));}}function renderControls(){{}}function drawPlaceholder(){{}}function setStatus(message){{status=message;}}function renderPreview(){{builderRecapState.rendered=true;$('download').disabled=false;return true;}} +eval(extract('sanitizeBuilderName'));eval(extract('defaultPosterStats'));eval(extract('loadRecap'));eval(extract('resetRecap')); +const payload=range=>({{ok:true,range_days:range,activity_days:[{{recorded_session:true}}],coverage:{{git:{{available:true}}}}}}); +;(async()=>{{ + const first=loadRecap(30);await Promise.resolve();const second=loadRecap(7);await Promise.resolve();const third=loadRecap(90);await Promise.resolve(); + requests[0].resolve({{ok:true,json:async()=>payload(30)}});requests[1].resolve({{ok:true,json:async()=>payload(7)}});requests[2].resolve({{ok:true,json:async()=>payload(90)}});await Promise.all([first,second,third]); + const latest={{range:builderRecapState.range,payload:builderRecapState.payload.range_days,aborted:requests[1].options.signal.aborted,download:$('download').disabled}}; + const sanitized=sanitizeBuilderName(' Ada\\n\\u0000 Lovelace '); + const failedPromise=loadRecap(7);await Promise.resolve();requests[3].resolve({{ok:false,json:async()=>({{}})}});await failedPromise;const failed={{download:$('download').disabled,retry:$('retry').hidden,status}}; + builderRecapState.name='Ada';builderRecapState.includeUsage=false;const resetPromise=resetRecap();await Promise.resolve();requests[4].resolve({{ok:true,json:async()=>payload(30)}});await resetPromise; + const reset={{range:builderRecapState.range,name:builderRecapState.name,usage:builderRecapState.includeUsage}}; + console.log(JSON.stringify({{latest,sanitized,failed,reset,urls:requests.map(item=>item.url)}})); +}})().catch(error=>{{console.error(error.stack||error);process.exitCode=1;}}); +""" + result = subprocess.run( + ["node", "-e", script], capture_output=True, text=True, check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr) + rendered = json.loads(result.stdout) + self.assertEqual(rendered["latest"], { + "range": 90, "payload": 90, "aborted": True, "download": False, + }) + self.assertEqual(rendered["sanitized"], "Ada Lovelace") + self.assertEqual(rendered["failed"], { + "download": True, "retry": False, + "status": "Unable to load the builder recap. Try again.", + }) + self.assertEqual(rendered["reset"], { + "range": 30, "name": "", "usage": True, + }) + self.assertEqual(rendered["urls"], [ + "/builder-recap?range=30", "/builder-recap?range=7", + "/builder-recap?range=90", "/builder-recap?range=7", + "/builder-recap?range=30", + ]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cost_discount_flows.py b/tests/test_cost_discount_flows.py new file mode 100644 index 00000000..0b038d7d --- /dev/null +++ b/tests/test_cost_discount_flows.py @@ -0,0 +1,811 @@ +import unittest +import json +import os +import sqlite3 +import tempfile +import threading +import time +from pathlib import Path +from unittest import mock +import meter +from token_meter.contracts import DiscoveryContext +from tests.meter_support import LocalApiHttpBase, _REAL_SCHEDULE, _REAL_REFRESH +from tests.opencode_test_support import OpenCodeCase + + +class CostDiscountTests(unittest.TestCase): + """Contracted discounts apply per request to list-price estimates only.""" + + T0 = 1_788_000_000 # 2026-08-29T14:40:00Z + + def setUp(self): + meter._reset_cost_discount_cache() + self.addCleanup(meter._reset_cost_discount_cache) + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + self.tmp = Path(self._tmp.name) + + @staticmethod + def rule(price_list, channel, percent, start=None, end=None, rule_id=None): + return {"id": rule_id or "d_" + (price_list + channel + str(percent)).replace(".", "")[:30].lower().ljust(8, "0"), + "price_list": price_list, "channel": channel, "percent": percent, + "start": start, "end": end} + + def settings(self, rules=(), runtime_channels=None, **other): + path = self.tmp / "settings-{}.json".format(len(list(self.tmp.iterdir()))) + payload = dict(other) + if rules is not None: + payload["cost_discounts"] = {"version": 1, "rules": list(rules), + **({"runtime_channels": runtime_channels} if runtime_channels else {})} + path.write_text(json.dumps(payload)) + meter._reset_cost_discount_cache() + return path + + def patched(self, path): + return mock.patch.object(meter, "TOKEN_METER_SETTINGS", str(path)) + + CODEX_USAGE = {"input_tokens": 1_000_000, "output_tokens": 100_000, + "cache_read_input_tokens": 0, "cache_creation_input_tokens": 0} + + # ---- persistence ------------------------------------------------- + + def test_absent_key_means_list_price_and_reading_never_seeds(self): + path = self.settings(rules=None, budgets={"monthly_total": 5}) + before = path.read_text() + with self.patched(path): + listed = meter.cost_of(self.CODEX_USAGE, "gpt-5.4", "codex", at=self.T0) + view = meter.cost_discount_settings() + self.assertEqual(listed.discount_usd, 0.0) + self.assertEqual(view["rules"], []) + self.assertEqual(view["runtime_channels"], {"claude_code": "auto", "claude_desktop": "auto"}) + self.assertEqual(path.read_text(), before) + self.assertNotIn("cost_discounts", meter.OBSOLETE_SETTINGS_KEYS) + + def test_ops_persist_atomically_and_preserve_other_settings(self): + path = self.settings(rules=None, budgets={"monthly_total": 5}) + original = meter.atomic_write_text + with mock.patch.object(meter, "atomic_write_text", side_effect=original) as write: + result = meter.apply_cost_discount_ops([{"op": "upsert", "rule": { + "external_id": "sync:a", "price_list": "anthropic", "channel": "direct", + "percent": 30, "start": "2026-09-01T00:00:00-07:00", "end": None, + "label": "Team plan"}}], path=str(path), now=self.T0) + saved = json.loads(path.read_text()) + self.assertTrue(result["ok"], result) + self.assertTrue(result["changed"]) + self.assertEqual(write.call_count, 1) + self.assertEqual(saved["budgets"], {"monthly_total": 5}) + stored = saved["cost_discounts"]["rules"][0] + self.assertRegex(stored["id"], r"^d_[a-z0-9]{8,40}$") + self.assertEqual((stored["start"], stored["set_by"], stored["source"]), (1788246000, "Settings", "dashboard")) + self.assertEqual(result["revision"], meter.cost_discount_settings(str(path))["revision"]) + self.assertEqual(result["rules"][0]["start_iso"], "2026-09-01T07:00:00Z") + + def test_invalid_stored_rule_is_ignored_without_rewrite(self): + path = self.settings([self.rule("openai", "any", 50), {"id": "broken", "percent": 500}]) + before, mtime = path.read_text(), path.stat().st_mtime_ns + with self.patched(path): + view = meter.cost_discount_settings() + cost = meter.cost_of(self.CODEX_USAGE, "gpt-5.4", "codex", at=self.T0) + self.assertEqual(view["invalid_rules"], 1) + self.assertEqual(len(view["rules"]), 1) + self.assertAlmostEqual(cost.discount_usd, cost.list_usd * 0.5) + self.assertEqual((path.read_text(), path.stat().st_mtime_ns), (before, mtime)) + + def test_upsert_delete_replace_scope_and_runtime_channels(self): + path = str(self.settings(rules=None)) + upsert = {"op": "upsert", "rule": {"external_id": "sync:a", "price_list": "anthropic", + "channel": "direct", "percent": 30}} + first = meter.apply_cost_discount_ops([upsert], path=path, now=1) + original = meter.atomic_write_text + with mock.patch.object(meter, "atomic_write_text", side_effect=original) as write: + again = meter.apply_cost_discount_ops([upsert], source="api", set_by="sync", path=path, now=2) + self.assertEqual((again["changed"], again["revision"], write.call_count), (False, first["revision"], 0)) + rule_id = first["rules"][0]["id"] + edited = meter.apply_cost_discount_ops([{"op": "upsert", "rule": { + "external_id": "sync:a", "percent": 25}}], source="api", set_by="contract-sync", path=path, now=3) + self.assertEqual((edited["rules"][0]["id"], edited["rules"][0]["percent"]), (rule_id, 25)) + self.assertEqual(edited["rules"][0]["set_by"], "contract-sync") + scoped = meter.apply_cost_discount_ops([{"op": "replace_scope", "price_list": "openai", "channel": "any", + "rules": [{"external_id": "sync:o", "percent": 62}]}], path=path) + self.assertEqual(sorted(r["price_list"] for r in scoped["rules"]), ["anthropic", "openai"]) + cleared = meter.apply_cost_discount_ops([{"op": "replace_scope", "price_list": "openai", + "channel": "any", "rules": []}], path=path) + self.assertEqual([r["id"] for r in cleared["rules"]], [rule_id]) + missing = meter.apply_cost_discount_ops([{"op": "delete", "external_id": "nope"}], path=path) + self.assertEqual((missing["ok"], missing["changed"]), (True, False)) + channels = meter.apply_cost_discount_ops([{"op": "set_runtime_channels", "claude_code": "bedrock"}], path=path) + self.assertTrue(channels["changed"]) + self.assertEqual(meter.cost_discount_settings(path)["runtime_channels"]["claude_code"], "bedrock") + deleted = meter.apply_cost_discount_ops([{"op": "delete", "id": rule_id}], path=path) + self.assertEqual(deleted["rules"], []) + + def test_batches_are_all_or_nothing_and_errors_are_plain(self): + path = str(self.settings([self.rule("anthropic", "direct", 30, start=100)])) + before = Path(path).read_text() + overlap = meter.apply_cost_discount_ops([ + {"op": "upsert", "rule": {"price_list": "openai", "percent": 10}}, + {"op": "upsert", "rule": {"price_list": "anthropic", "channel": "direct", "percent": 20, "start": 50}}, + ], path=path) + self.assertFalse(overlap["ok"]) + self.assertEqual(overlap["error"], "Anthropic billed by the provider already has 30% for overlapping dates.") + for ops in ([{"op": "rename"}], [{"op": "delete", "id": "x"}] * 65, + [{"op": "upsert", "rule": {"price_list": "openai", "percent": 5, "start": "2026-09-01"}}], + [{"op": "upsert", "rule": {"price_list": "cursor", "channel": "direct", "percent": 5}}]): + self.assertFalse(meter.apply_cost_discount_ops(ops, path=path)["ok"]) + self.assertEqual(Path(path).read_text(), before) + + def test_stale_expected_revision_is_a_conflict(self): + path = str(self.settings(rules=None)) + stale = meter.cost_discount_settings(path)["revision"] + meter.apply_cost_discount_ops([{"op": "upsert", "rule": {"price_list": "openai", "percent": 5}}], path=path) + result = meter.apply_cost_discount_ops( + [{"op": "upsert", "rule": {"price_list": "openai", "channel": "azure", "percent": 9}}], + expected_revision=stale, path=path) + self.assertEqual((result["ok"], result["conflict"]), (False, True)) + self.assertEqual(result["revision"], meter.cost_discount_settings(path)["revision"]) + + def test_concurrent_settings_writers_keep_each_others_keys(self): + path = str(self.settings(rules=None)) + original_load = meter.load_json + + def slow_load(*args, **kwargs): + value = original_load(*args, **kwargs) + time.sleep(0.003) + return value + + errors = [] + + def discounts(): + for index in range(15): + result = meter.apply_cost_discount_ops([{"op": "upsert", "rule": { + "external_id": "e{}".format(index), "price_list": "openai", "channel": "any", + "percent": 5, "start": index * 10, "end": index * 10 + 10}}], path=path) + errors.extend([result] if not result["ok"] else []) + + def other_features(): + for index in range(15): + meter.set_update_settings({"enabled": index % 2 == 0}, path=path) + meter.set_model_price("openai", "gpt-custom-{}".format(index), + {"input": 1, "output": 2, "cache_write": 0, "cache_read": 0}, path=path) + + with mock.patch.object(meter, "load_json", side_effect=slow_load): + threads = [threading.Thread(target=discounts), threading.Thread(target=other_features)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + saved = json.loads(Path(path).read_text()) + self.assertEqual(errors, []) + self.assertEqual(len(saved["cost_discounts"]["rules"]), 15) + self.assertEqual(len(saved["model_pricing"]["codex"]), 15) + self.assertIn("updates", saved) + + # ---- application ------------------------------------------------- + + def codex_rows(self, stamps): + rows = [{"type": "turn_context", "timestamp": stamps[0], "payload": {"model": "gpt-5.4"}}] + for stamp in stamps: + rows.append({"timestamp": stamp, "payload": {"type": "token_count", "info": { + "last_token_usage": {"input_tokens": 100_000, "output_tokens": 10_000, "total_tokens": 110_000}}}}) + return rows + + def codex_summary(self, path, stamps): + source = {"id": "s", "path": "/tmp/s.jsonl", "provider": "codex", "client": "codex", + "label": "Codex", "project": "/repo", "mtime": 1, "title": "t", "model": "gpt-5.4"} + with self.patched(path): + return meter.codex_summary(source, self.codex_rows(stamps)) + + def test_codex_discount_applies_only_inside_its_range(self): + stamps = ["2026-09-01T10:00:00.000Z", "2026-09-02T10:00:00.000Z", "2026-09-03T10:00:00.000Z"] + start = meter.parse_iso("2026-09-02T00:00:00") + end = meter.parse_iso("2026-09-03T00:00:00") + listed = self.codex_summary(self.settings(rules=None), stamps) + discounted = self.codex_summary(self.settings([self.rule("openai", "direct", 50, start, end)]), stamps) + each = listed["cost"] / 3 + self.assertAlmostEqual(discounted["cost"], each * 2.5) + self.assertAlmostEqual(discounted["discount_usd"], each * 0.5) + self.assertAlmostEqual(discounted["cost"] + discounted["discount_usd"], listed["cost"]) + self.assertAlmostEqual(discounted["model_stats"][0]["discount_usd"], each * 0.5) + self.assertAlmostEqual(sum(discounted["_day_discount"].values()), each * 0.5) + self.assertAlmostEqual(sum(discounted["_day_cost"].values()), discounted["cost"]) + azure = dict(listed) + self.assertEqual(azure["discount_usd"], 0.0) + + def test_mid_day_boundary_splits_one_days_cost(self): + stamps = ["2026-09-02T10:00:00.000Z", "2026-09-02T14:00:00.000Z"] + start = meter.parse_iso("2026-09-02T12:00:00") + listed = self.codex_summary(self.settings(rules=None), stamps) + row = self.codex_summary(self.settings([self.rule("openai", "any", 40, start)]), stamps) + (day, cost), = row["_day_cost"].items() + self.assertAlmostEqual(cost, listed["_day_cost"][day] * 0.8) + self.assertAlmostEqual(row["_day_discount"][day], listed["_day_cost"][day] * 0.2) + + def test_codex_on_azure_uses_azure_then_any_rule(self): + path = self.settings([self.rule("openai", "direct", 50), self.rule("openai", "azure", 20)]) + source = {"id": "s", "path": "/tmp/s.jsonl", "provider": "codex", "client": "codex", "label": "Codex", + "project": "/repo", "mtime": 1, "title": "t", "model": "gpt-5.4", "model_provider": "azure"} + with self.patched(path): + row = meter.codex_summary(source, self.codex_rows(["2026-09-02T10:00:00.000Z"])) + self.assertAlmostEqual(row["discount_usd"] / (row["cost"] + row["discount_usd"]), 0.2) + + def test_cursor_hosted_gpt_ignores_openai_rule(self): + usage = {"input_tokens": 1_000_000, "output_tokens": 100_000} + with self.patched(self.settings([self.rule("openai", "any", 60)])): + openai_only = meter.cost_of(usage, "gpt-5.6-sol", "cursor", at=self.T0) + with self.patched(self.settings([self.rule("cursor", "any", 10)])): + cursor_rule = meter.cost_of(usage, "gpt-5.6-sol", "cursor", at=self.T0) + self.assertGreater(openai_only.list_usd, 0) + self.assertEqual(openai_only.discount_usd, 0.0) + self.assertAlmostEqual(cursor_rule.discount_usd, cursor_rule.list_usd * 0.1) + + def test_kiro_takes_only_kiro_rules(self): + from token_meter.runtimes.kiro import KiroRuntimeAdapter + adapter = KiroRuntimeAdapter(self.tmp, quote_resolver=meter.price_quote, + compatibility=meter._kiro_compatibility()) + turn = {"input_tokens": 100_000, "output_tokens": 10_000, "end": self.T0, "model": "claude-sonnet-4-6"} + source = {"model": "claude-sonnet-4-6"} + with self.patched(self.settings([self.rule("anthropic", "any", 30)])): + _model, anthropic_only, available = adapter._legacy_turn(source, turn) + with self.patched(self.settings([self.rule("kiro", "any", 20)])): + _model, kiro_rule, _available = adapter._legacy_turn(source, turn) + self.assertTrue(available) + self.assertEqual(anthropic_only.discount_usd, 0.0) + self.assertAlmostEqual(kiro_rule.discount_usd, kiro_rule.list_usd * 0.2) + self.assertAlmostEqual(sum(kiro_rule.values()), sum(anthropic_only.values()) * 0.8) + + def pi_summary(self, path, provider, child=None): + root = self.tmp / "pi-{}".format(len(list(self.tmp.iterdir()))) + directory = root / "sessions" / "--repo--" + directory.mkdir(parents=True) + cost = {"input": 0.001, "output": 0.002, "cacheRead": 0.0001, "cacheWrite": 0.0002} + assistant = {"role": "assistant", "model": "claude-test", "provider": provider, + "content": [{"type": "toolCall", "id": "call", "name": "subagent" if child else "read", + "arguments": {"agent": "probe"}}], + "usage": {"input": 100, "output": 20, "cacheRead": 10, "cacheWrite": 5, "cost": cost}} + rows = [ + {"type": "session", "version": 3, "id": "pi", "timestamp": "2026-09-04T10:00:00Z", "cwd": "/repo"}, + {"type": "model_change", "timestamp": "2026-09-04T10:00:01Z", "provider": provider, "modelId": "claude-test"}, + {"type": "message", "timestamp": "2026-09-04T10:00:02Z", "message": {"role": "user", "content": []}}, + {"type": "message", "timestamp": "2026-09-04T10:00:05Z", "message": assistant}, + ] + if child: + rows.append({"type": "message", "timestamp": "2026-09-04T10:00:09Z", "message": { + "role": "toolResult", "toolCallId": "call", "toolName": "subagent", + "details": {"mode": "single", "results": [{ + "agent": "probe", "exitCode": 0, "stopReason": "stop", "model": child, + "usage": {"input": 1000, "output": 100, "cacheRead": 0, "cacheWrite": 0, + "cost": 0.25, "turns": 2}}]}}}) + (directory / "pi.jsonl").write_text("\n".join(json.dumps(row) for row in rows) + "\n") + with self.patched(path), mock.patch.object(meter, "PI_AGENT_DIR", str(root)), \ + mock.patch.object(meter, "_pi_native_adapters", {}), \ + mock.patch.object(meter, "_RUNTIME_REGISTRY", None): + return meter.pi_summary(meter.pi_session_sources()[0]) + + def test_pi_on_bedrock_uses_bedrock_then_any_never_direct(self): + own = 0.0033 + direct_only = self.pi_summary(self.settings([self.rule("anthropic", "direct", 90)]), "amazon-bedrock") + both = self.pi_summary(self.settings([self.rule("anthropic", "bedrock", 40), + self.rule("anthropic", "any", 10)]), "amazon-bedrock") + any_only = self.pi_summary(self.settings([self.rule("anthropic", "any", 10)]), "amazon-bedrock") + self.assertAlmostEqual(direct_only["cost"], own) + self.assertEqual(direct_only["discount_usd"], 0.0) + self.assertAlmostEqual(both["cost"], own * 0.6) + self.assertAlmostEqual(any_only["cost"], own * 0.9) + self.assertAlmostEqual(any_only["cost"] + any_only["discount_usd"], own) + + def test_pi_subagent_cost_is_discounted_once(self): + listed = self.pi_summary(self.settings(rules=None), "anthropic", child="anthropic/claude-sonnet-4-6") + row = self.pi_summary(self.settings([self.rule("anthropic", "any", 50)]), "anthropic", + child="anthropic/claude-sonnet-4-6") + self.assertAlmostEqual(listed["cost"], 0.2533) + self.assertAlmostEqual(row["cost"], 0.2533 * 0.5) + self.assertAlmostEqual(row["discount_usd"], 0.2533 * 0.5) + records = {record["kind"]: record for record in row["_agent_records"]} + self.assertAlmostEqual(records["root"]["cost"] + records["spawned"]["cost"], row["cost"]) + gateway = self.pi_summary(self.settings([self.rule("anthropic", "any", 50)]), "anthropic", + child="opencode-go/deepseek-v4.1-flash") + self.assertAlmostEqual(gateway["cost"], 0.0033 * 0.5 + 0.25) + + def test_opencode_vendor_messages_take_vendor_rules_and_gateway_takes_opencode(self): + helper = OpenCodeCase() + conn = helper._build_db(self.tmp) + base = 1_788_000_000_000 + conn.execute("INSERT INTO session VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", tuple( + helper._session_row("ses_mix", "/repo", "Mixed", "claude-sonnet-4-6", 1.11, + 300, 30, 0, 0, 0, base + 10_000, provider_id="anthropic").values())) + for index, (model, provider, cost) in enumerate(( + ("claude-sonnet-4-6", "anthropic", 1.0), ("gpt-5.4", "openai", 0.1), + ("kimi-k2", "opencode", 0.01), ("free-model", "openai", 0.0))): + created = base + index * 1000 + conn.execute("INSERT INTO message VALUES (?,?,?,?,?)", helper._message( + "m{}".format(index), "ses_mix", { + "role": "assistant", "modelID": model, "providerID": provider, "cost": cost, + "tokens": {"input": 100, "output": 10, "reasoning": 0, "cache": {"read": 0, "write": 0}}, + "time": {"created": created, "completed": created + 500}}, created)) + conn.commit() + conn.close() + path = self.settings([self.rule("anthropic", "direct", 10), self.rule("openai", "any", 50), + self.rule("opencode", "any", 20)]) + source = {"provider": "opencode", "id": "ses_mix", "model": "claude-sonnet-4-6", "label": "OpenCode", + "runtime": "OpenCode", "session": "ses_mix", "path": "opencode:ses_mix", + "project": "/repo", "title": "Mixed", "mtime": 1.0} + with self.patched(path), mock.patch.object(meter, "OPENCODE_DB", str(self.tmp / "opencode.db")), \ + mock.patch.object(meter, "_opencode_native_adapters", {}): + row = meter.opencode_summary(source) + costs = {stats["model"]: stats for stats in row["model_stats"]} + self.assertAlmostEqual(costs["claude-sonnet-4-6"]["cost"], 0.9) + self.assertAlmostEqual(costs["gpt-5.4"]["cost"], 0.05) + self.assertAlmostEqual(costs["kimi-k2"]["cost"], 0.008) + self.assertEqual(costs["free-model"]["cost"], 0.0) + self.assertAlmostEqual(row["discount_usd"], 0.1 + 0.05 + 0.002) + self.assertAlmostEqual(row["cost"] + row["discount_usd"], 1.11) + + def opencode_long_session(self, schema): + """A 10-message OpenCode session, 0.1 each, in a V1 or V2 store.""" + from tests.runtimes.test_opencode_adapter import V2_SCHEMA + db = self.tmp / "opencode-{}.db".format(schema) + con = sqlite3.connect(db) + base = 1_788_000_000_000 + model = json.dumps({"id": "gpt-5.4", "providerID": "openai"}) + if schema == "v2": + con.executescript(V2_SCHEMA) + con.execute( + "INSERT INTO session_v2 (id, project_id, slug, directory, title, version, cost, " + "tokens_input, tokens_output, agent, model, time_created, time_updated) " + "VALUES ('ses','p','ses','/repo','Long','2',1.0,1000,100,'build',?,?,?)", + (model, base, base + 20_000)) + else: + OpenCodeCase._build_db(None, self.tmp) + con.close() + (self.tmp / "opencode.db").rename(db) + con = sqlite3.connect(db) + con.execute("INSERT INTO session VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", tuple( + OpenCodeCase._session_row(None, "ses", "/repo", "Long", "gpt-5.4", 1.0, 1000, 100, 0, 0, 0, + base + 20_000, provider_id="openai").values())) + for index in range(10): + created = base + index * 1000 + data = {"role": "assistant", "modelID": "gpt-5.4", "providerID": "openai", "cost": 0.1, + "tokens": {"input": 100, "output": 10, "reasoning": 0, "cache": {"read": 0, "write": 0}}, + "time": {"created": created, "completed": created + 500}} + if schema == "v2": + data = dict(data, model={"id": "gpt-5.4", "providerID": "openai"}) + con.execute("INSERT INTO session_message VALUES (?,?,?,?,?,?,?)", + ("m{}".format(index), "ses", "assistant", index, created, created, json.dumps(data))) + else: + con.execute("INSERT INTO message VALUES (?,?,?,?,?)", + ("m{}".format(index), "ses", created, created, json.dumps(data))) + con.commit() + con.close() + return db + + def test_opencode_session_discount_covers_messages_beyond_the_load_cap(self): + from token_meter.runtimes import opencode as opencode_runtime + source = {"provider": "opencode", "id": "ses", "model": "gpt-5.4", "model_provider": "openai", + "label": "OpenCode", "runtime": "OpenCode", "session": "ses", "path": "opencode:ses", + "project": "/repo", "title": "Long", "mtime": 1.0} + path = self.settings([self.rule("openai", "any", 50)]) + for schema in ("v1", "v2"): + with self.subTest(schema=schema): + db = self.opencode_long_session(schema) + adapter = opencode_runtime.OpenCodeRuntimeAdapter( + db, None, compatibility=meter._opencode_compatibility(), detail_message_limit=3) + with self.patched(path), mock.patch.object(opencode_runtime, "SUMMARY_MESSAGE_LIMIT", 4): + row = adapter.summarize_legacy(dict(source)) + state = adapter.recompute_legacy(dict(source)) + self.assertEqual(row["turns"], 4) + self.assertAlmostEqual(row["discount_usd"], 0.5) + self.assertAlmostEqual(row["cost"], 0.5) + self.assertAlmostEqual(row["cost"] + row["discount_usd"], 1.0) + self.assertEqual(len(state["executions"]), 3) + self.assertAlmostEqual(state["total_cost"], 0.5) + with self.patched(self.settings(rules=None)): + listed = opencode_runtime.OpenCodeRuntimeAdapter( + db, None, compatibility=meter._opencode_compatibility()).summarize_legacy(dict(source)) + self.assertAlmostEqual(listed["cost"], 1.0) + self.assertEqual(listed["discount_usd"], 0.0) + + def hermes_cost(self, path, status, column): + db = self.tmp / "hermes-{}.db".format(len(list(self.tmp.iterdir()))) + con = sqlite3.connect(db) + con.execute("""CREATE TABLE sessions (id TEXT PRIMARY KEY, model TEXT, billing_provider TEXT, + started_at REAL, ended_at REAL, last_activity_at REAL, input_tokens INTEGER, output_tokens INTEGER, + cache_read_tokens INTEGER, cache_write_tokens INTEGER, reasoning_tokens INTEGER, + estimated_cost_usd REAL, actual_cost_usd REAL, cost_status TEXT, cost_source TEXT, + billing_mode TEXT, tool_call_count INTEGER, api_call_count INTEGER, cwd TEXT, title TEXT)""") + con.execute("INSERT INTO sessions VALUES ('h','claude-test','anthropic',?,?,?,100,20,10,5,3," + "?,?,?,'recorded',NULL,2,1,'/p','t')", + (self.T0, self.T0 + 10, self.T0 + 10, + 2.0 if column == "estimated" else None, 2.0 if column == "actual" else None, status)) + con.commit() + con.close() + from token_meter.contracts import DetailLevel, DiscoveryContext + from token_meter.runtimes.hermes import HermesRuntimeAdapter + with self.patched(path): + adapter = HermesRuntimeAdapter(db, compatibility={ + "price_for": meter.price_for, "cost_of": meter.cost_of, "discount_amount": meter.discount_amount}) + source = adapter.discover(DiscoveryContext(home="/home/test"))[0] + return adapter.load(source, DetailLevel.SUMMARY).usage.cost_usd.value + + def test_hermes_billed_cost_is_untouched_and_estimate_is_discounted(self): + path = self.settings([self.rule("anthropic", "any", 25)]) + self.assertAlmostEqual(self.hermes_cost(path, "actual", "actual"), 2.0) + self.assertAlmostEqual(self.hermes_cost(path, "estimated", "estimated"), 1.5) + + def claude_rows(self, request_id): + row = {"type": "assistant", "timestamp": "2026-09-02T10:00:00.000Z", "message": { + "id": "msg-1", "model": "claude-sonnet-4-6", "content": [], "stop_reason": "end_turn", + "usage": {"input_tokens": 100_000, "output_tokens": 10_000, + "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0}}} + if request_id: + row["requestId"] = request_id + return [row] + + def claude_fraction(self, path, request_id, client="claude_code"): + source = {"id": "c", "path": "/tmp/c.jsonl", "provider": "claude", "client": client, "label": "Claude", + "project": "/repo", "mtime": 1, "title": "t", "model": None} + with self.patched(path): + row = meter.claude_summary(source, self.claude_rows(request_id)) + self.assertGreater(row["cost"], 0) + return round(row["discount_usd"] / (row["cost"] + row["discount_usd"]), 6) + + def test_claude_channel_follows_request_id_shape_or_declaration(self): + uuid = "0b7c0a8e-3f7d-4c55-9b1e-6c2f6d1c9a10" + rules = [self.rule("anthropic", "direct", 30), self.rule("anthropic", "bedrock", 15)] + path = self.settings(rules) + self.assertEqual(self.claude_fraction(path, "req_011abc"), 0.30) + self.assertEqual(self.claude_fraction(path, uuid), 0.15) + self.assertEqual(self.claude_fraction(path, None), 0.0) + any_path = self.settings([*rules, self.rule("anthropic", "any", 5)]) + self.assertEqual(self.claude_fraction(any_path, None), 0.05) + declared = self.settings(rules, runtime_channels={"claude_code": "bedrock"}) + self.assertEqual(self.claude_fraction(declared, "req_011abc"), 0.15) + self.assertEqual(self.claude_fraction(declared, "req_011abc", client="claude_desktop"), 0.30) + + def test_cache_savings_are_discounted_by_the_same_rate(self): + execution = {"tokens": {"cache_read": 1_000_000, "fresh_input": 0, "cache_write": 0, "output": 0}, + "model": "gpt-5.4", "ts": self.T0, "cost": 0.6, "discount_usd": 0.4} + with self.patched(self.settings(rules=None)): + listed = meter.cache_savings({}, "codex", "gpt-5.4", [dict(execution, discount_usd=0.0)]) + plain_now = meter.cache_savings({"cache_read": 1_000_000}, "codex", "gpt-5.4") + with self.patched(self.settings([self.rule("openai", "any", 40)])): + discounted = meter.cache_savings({}, "codex", "gpt-5.4", [execution]) + now = meter.cache_savings({"cache_read": 1_000_000}, "codex", "gpt-5.4") + self.assertGreater(listed, 0) + self.assertAlmostEqual(discounted, listed * 0.6) + self.assertAlmostEqual(now, plain_now * 0.6) + + def test_discount_stacks_on_price_override_and_after_modifiers(self): + path = self.settings(rules=None) + meter.set_model_price("openai", "gpt-custom-1", + {"input": 2, "output": 8, "cache_write": 0, "cache_read": 0.2}, path=str(path), + apply_to_all_history=True) + rules = {"version": 1, "rules": [self.rule("openai", "any", 50), self.rule("anthropic", "any", 25)]} + data = json.loads(path.read_text()) + data["cost_discounts"] = rules + path.write_text(json.dumps(data)) + meter._reset_cost_discount_cache() + with self.patched(path): + override = meter.cost_of(self.CODEX_USAGE, "gpt-custom-1", "codex", at=self.T0) + usage = {"input_tokens": 100_000, "output_tokens": 10_000, "speed": "fast", "inference_geo": "us", + "server_tool_use": {"web_search_requests": 3}} + fast = meter.cost_of(usage, "claude-opus-5-5", "claude", at=self.T0) + plain = meter.cost_of({"input_tokens": 100_000, "output_tokens": 10_000}, + "claude-opus-5-5", "claude", at=self.T0) + self.assertAlmostEqual(sum(override.values()), (2 + 0.8) / 2) + self.assertGreater(fast.list_usd, plain.list_usd * 2) + self.assertAlmostEqual(sum(fast.values()), fast.list_usd * 0.75) + self.assertAlmostEqual(sum(fast.values()) + fast.discount_usd, fast.list_usd) + self.assertAlmostEqual(fast["server_tools"], 0.03 * 0.75) + + def test_unavailable_and_free_stay_as_they_are(self): + with self.patched(self.settings([self.rule("openai", "any", 50)])): + unpriced = meter.cost_of(self.CODEX_USAGE, "no-such-model", "codex", at=self.T0) + free = meter.cost_of({"input_tokens": 0, "output_tokens": 0}, "gpt-5.4", "codex", at=self.T0) + self.assertEqual(meter.discount_amount(None, "openai", "direct", self.T0), (None, 0.0)) + self.assertEqual(meter.discount_amount(0.0, "openai", "direct", self.T0), (0.0, 0.0)) + row = self.codex_summary(Path(meter.TOKEN_METER_SETTINGS), ["2026-09-02T10:00:00.000Z"]) + self.assertEqual((sum(unpriced.values()), unpriced.discount_usd), (0.0, 0.0)) + self.assertEqual((sum(free.values()), free.discount_usd), (0.0, 0.0)) + self.assertTrue(row["availability"]["cost"]) + + def test_cross_session_aggregates_carry_discount_beside_cost(self): + rows = [{ + "id": "a", "provider": "codex", "runtime": "Codex", "cost": 6.0, "discount_usd": 4.0, "turns": 1, + "availability": {"cost": True}, "_model_cost": {"gpt": 6.0}, "_model_discount": {"gpt": 4.0}, + "_model_tok": {"gpt": 10}, "_day_cost": {"2026-09-01": 2.0, "2026-09-02": 4.0}, + "_day_discount": {"2026-09-02": 4.0}, "mtime": 1, + }, { + "id": "b", "provider": "claude", "runtime": "Claude", "cost": 1.0, "discount_usd": 0.0, "turns": 1, + "availability": {"cost": True}, "_model_cost": {"opus": 1.0}, "_model_tok": {"opus": 5}, + "_day_cost": {"2026-09-02": 1.0}, "mtime": 2, + }] + aggregate = meter._domain_aggregate_cross_session_rows(rows) + daily = {row["day"]: row for row in meter.daily_summaries(rows, limit=None)} + months = meter.monthly_summaries(rows) + spend = meter.spend_projection(list(daily.values())) + self.assertEqual((aggregate["total_cost"], aggregate["discount_usd"]), (7.0, 4.0)) + self.assertEqual({item["model"]: item.get("discount_usd") for item in aggregate["model_mix"]}, + {"gpt": 4.0, "opus": None}) + self.assertEqual(daily["2026-09-02"]["discount_usd"], 4.0) + self.assertNotIn("discount_usd", daily["2026-09-01"]) + self.assertEqual({p["provider"]: p.get("discount_usd") for p in daily["2026-09-02"]["providers"]}, + {"codex": 4.0, "claude": None}) + listed = meter._domain_aggregate_cross_session_rows([rows[1]]) + self.assertNotIn("discount_usd", listed) + self.assertEqual((months[0]["cost"], months[0]["discount_usd"]), (7.0, 4.0)) + self.assertEqual(sum(day.get("discount_usd", 0.0) for day in spend), 4.0) + + def test_pricing_recalculation_is_single_flight_and_repeats_when_stale(self): + calls = [] + release = threading.Event() + + def runner(): + calls.append(1) + release.wait(5) + meter._pricing_state["cache_revision"] = meter.pricing_fingerprint() + + with mock.patch.dict(meter._pricing_recalc, {"running": False, "pending": False}), \ + mock.patch.dict(meter._pricing_state, {"cache_revision": None}): + self.assertEqual(meter.schedule_pricing_recalculation(runner), "scheduled") + self.assertEqual(meter.schedule_pricing_recalculation(runner), "scheduled") + release.set() + meter._pricing_recalc["thread"].join(5) + self.assertFalse(meter.pricing_recalculation_running()) + self.assertEqual(len(calls), 2) + + def test_invalidate_cost_caches_drops_cached_dollars(self): + meter._summary_cache["/x"] = {"signature": 1, "row": {}} + meter._xsess["data"], meter._xsess["at"] = {"total_cost": 1}, time.time() + path = self.settings([self.rule("openai", "any", 5)]) + with self.patched(path), mock.patch.dict(meter._pricing_state, {"cache_revision": "p_old"}): + meter._load_cost_discounts() + meter._cost_discount_cache["state"] = dict(meter._cost_discount_cache["state"], revision="stale") + meter.invalidate_cost_caches() + self.assertNotIn("/x", meter._summary_cache) + self.assertIsNone(meter._xsess["data"]) + self.assertNotEqual(meter._cost_discount_cache["state"]["revision"], "stale") + self.assertEqual(meter._pricing_state["cache_revision"], meter.pricing_fingerprint()) + + +class CostDiscountHttpTests(LocalApiHttpBase): + """The local discount API: Host/Origin gate, tokens, limits, and recalculation.""" + + UPSERT = {"ops": [{"op": "upsert", "rule": {"external_id": "sync:o", "price_list": "openai", + "channel": "any", "percent": 62}}]} + + def test_bearer_and_action_tokens_are_accepted_and_others_refused(self): + self.assertEqual(self.request("GET", "/api/v1/cost-discounts")[0], 200) + self.assertEqual(self.request("GET", "/api/v1/cost-discounts", bearer=False, action=True)[0], 200) + for bearer in (False, "x" * 43): + status, payload, _ = self.request("GET", "/api/v1/cost-discounts", bearer=bearer) + self.assertEqual((status, payload), (401, {"ok": False, "error": "Unauthorized."})) + status, payload, _ = self.request("POST", "/api/v1/cost-discounts", self.UPSERT, bearer=False) + self.assertEqual(status, 401) + + def test_host_and_origin_must_be_loopback(self): + for host in ("evil.example:{}".format(self.port), "127.0.0.1:1", "127.0.0.1"): + status, payload, _ = self.request("GET", "/api/v1/cost-discounts", host=host) + self.assertEqual((status, payload), (403, {"ok": False, "error": "Forbidden."})) + self.assertEqual(self.request("GET", "/api/v1/cost-discounts", + host="localhost:{}".format(self.port))[0], 200) + status, _, _ = self.request("POST", "/api/v1/cost-discounts", self.UPSERT, origin="https://evil.example") + self.assertEqual(status, 403) + self.assertFalse(self.settings.exists()) + + def test_limits_and_validation_errors(self): + status, payload, _ = self.request("POST", "/api/v1/cost-discounts", raw=b"{" + b" " * 9000 + b"}") + self.assertEqual((status, payload["error"]), (413, "Request too large.")) + self.assertEqual(self.request("POST", "/api/v1/cost-discounts", raw=b"{nope")[0], 400) + status, payload, _ = self.request("POST", "/api/v1/cost-discounts", {"ops": [{"op": "upsert", "rule": { + "price_list": "openai", "percent": 100}}]}) + self.assertEqual((status, payload["error"]), + (400, "Discount percent must be more than 0 and less than 100.")) + self.scheduled.assert_not_called() + + def test_write_records_client_conflicts_and_idempotent_repeat(self): + status, payload, _ = self.request("POST", "/api/v1/cost-discounts", self.UPSERT, client="contract-sync") + self.assertEqual(status, 200, payload) + self.assertEqual((payload["changed"], payload["recalculation"]), (True, "scheduled")) + self.assertEqual(payload["rules"][0]["set_by"], "contract-sync") + self.assertEqual(payload["pricing_revision"], meter.pricing_fingerprint()) + self.assertEqual(self.scheduled.call_count, 1) + status, again, _ = self.request("POST", "/api/v1/cost-discounts", self.UPSERT, client="") + self.assertEqual((status, again["changed"], again["recalculation"]), (200, False, "complete")) + self.assertEqual(self.scheduled.call_count, 1) + status, stale, _ = self.request("POST", "/api/v1/cost-discounts", { + "expected_revision": "r_0000000000000000", **self.UPSERT}) + self.assertEqual((status, stale["revision"]), (409, payload["revision"])) + status, other, _ = self.request("POST", "/api/v1/cost-discounts", {"ops": [{"op": "upsert", "rule": { + "price_list": "anthropic", "percent": 5}}]}, client="") + self.assertEqual(other["rules"][0]["set_by"], "Another tool") + + def test_resolve_endpoint(self): + self.request("POST", "/api/v1/cost-discounts", self.UPSERT) + status, payload, _ = self.request( + "GET", "/api/v1/cost-discounts/resolve?price_list=openai&channel=azure&at=2026-10-01T12:00:00Z") + self.assertEqual((status, payload["percent"]), (200, 62)) + self.assertRegex(payload["rule_id"], r"^d_") + status, none, _ = self.request("GET", "/api/v1/cost-discounts/resolve?price_list=cursor&channel=any&at=1") + self.assertEqual((status, none["percent"], none["rule_id"]), (200, None, None)) + self.assertEqual(self.request("GET", "/api/v1/cost-discounts/resolve?price_list=google&channel=any")[0], 400) + self.assertEqual(self.request("GET", "/api/v1/cost-discounts/resolve?price_list=openai&channel=x")[0], 400) + self.assertEqual(self.request( + "GET", "/api/v1/cost-discounts/resolve?price_list=openai&channel=any&at=2026-10-01")[0], 400) + + def test_rotate_replaces_token_and_old_token_stops_working(self): + old = self.token + status, payload, text = self.request("POST", "/api/v1/api-token/rotate", raw=b"") + self.assertEqual((status, payload), (200, {"ok": True})) + new = self.token_path.read_text().strip() + self.assertNotEqual(new, old) + self.assertNotIn(new, text) + self.assertEqual(self.request("GET", "/api/v1/cost-discounts", bearer=old)[0], 401) + self.assertEqual(self.request("GET", "/api/v1/cost-discounts", bearer=new)[0], 200) + self.assertEqual(self.request("POST", "/api/v1/api-token/rotate", raw=b"", bearer=False)[0], 401) + + def test_token_file_is_private_and_reused_across_restarts(self): + self.assertEqual(self.token_path.stat().st_mode & 0o777, 0o600) + self.assertEqual(meter.ensure_api_token(), self.token) + os.chmod(self.token_path, 0o644) + self.assertEqual(meter.ensure_api_token(), self.token) + self.assertEqual(self.token_path.stat().st_mode & 0o777, 0o600) + + def test_token_never_appears_in_dashboard_or_health_payloads(self): + with mock.patch.dict(meter._xsess, {"data": {"pricing_revision": "p_old"}, "at": time.time()}), \ + mock.patch.object(meter, "provider_quota_snapshots", return_value={}): + for path in ("/state", "/health", "/menubar", "/api/v1/cost-discounts"): + with self.subTest(path=path): + _status, _payload, text = self.request("GET", path) + self.assertNotIn(self.token, text) + self.assertNotIn(self.token, json.dumps(meter.health_state()[0])) + + def test_dashboard_alias_needs_the_action_token(self): + status, payload, _ = self.request("POST", "/settings/cost-discounts", self.UPSERT) + self.assertEqual(status, 403) + status, payload, _ = self.request("POST", "/settings/cost-discounts", self.UPSERT, + bearer=False, action=True) + self.assertEqual(status, 200, payload) + self.assertEqual((payload["rules"][0]["source"], payload["rules"][0]["set_by"]), ("dashboard", "Settings")) + + def test_health_reports_new_pricing_revision_once_recalculation_completes(self): + self.request("POST", "/api/v1/cost-discounts", self.UPSERT) + expected = meter.pricing_fingerprint() + self.assertEqual(meter.health_state()[0]["pricing_revision"], "p_old") + with mock.patch.object(meter, "schedule_pricing_recalculation", _REAL_SCHEDULE), \ + mock.patch.object(meter, "all_session_sources", return_value=[]), \ + mock.patch.object(meter, "newest_source", return_value=None), \ + mock.patch.dict(meter._xsess, {"data": None, "at": 0.0}), \ + mock.patch.dict(meter._pricing_state, {"cache_revision": None}): + meter.schedule_pricing_recalculation() + meter._pricing_recalc["thread"].join(30) + self.assertFalse(meter.pricing_recalculation_running()) + # Once the rebuilt snapshot is the one served, health reports its revision. + meter.STATE["xsession"] = meter._xsess["data"] + self.assertEqual(meter.health_state()[0]["pricing_revision"], expected) + + def test_watcher_reprices_after_a_direct_settings_edit(self): + self.settings.write_text(json.dumps({"updates": {"enabled": True}})) + meter._reset_cost_discount_cache() + with mock.patch.dict(meter._pricing_state, {"cache_revision": meter.pricing_fingerprint()}): + self.assertFalse(meter.pricing_watch_tick()) + self.settings.write_text(json.dumps({"cost_discounts": {"version": 1, "rules": [{ + "id": "d_handedit1", "price_list": "openai", "channel": "any", "percent": 10}]}})) + meter._reset_cost_discount_cache() + self.assertTrue(meter.pricing_watch_tick()) + self.scheduled.assert_called_once_with() + + +class BudgetApiHttpTests(LocalApiHttpBase): + """Local tools can read and partially update monthly budgets; session caps stay private.""" + + def setUp(self): + super().setUp() + self.settings.write_text(json.dumps({"budgets": { + "allocations": {"claude": 100, "codex": 50}, + "session_budgets": {"private-session-id": 3}, + }, "updates": {"enabled": False}})) + self.refresh = mock.Mock(return_value={}) + patcher = mock.patch.object(meter, "refresh_cross_session_state", self.refresh) + patcher.start() + self.addCleanup(patcher.stop) + + def saved(self): + return json.loads(self.settings.read_text()) + + def test_gate_matches_the_discount_api(self): + self.assertEqual(self.request("GET", "/api/v1/budgets")[0], 200) + self.assertEqual(self.request("GET", "/api/v1/budgets", bearer=False, action=True)[0], 200) + self.assertEqual(self.request("GET", "/api/v1/budgets", bearer=False)[0], 401) + self.assertEqual(self.request("GET", "/api/v1/budgets", bearer="x" * 43)[0], 401) + self.assertEqual(self.request("GET", "/api/v1/budgets", host="evil.example:{}".format(self.port))[0], 403) + self.assertEqual(self.request("POST", "/api/v1/budgets", {"thresholds": [50]}, + origin="https://evil.example")[0], 403) + self.assertEqual(self.request("POST", "/api/v1/budgets", raw=b"{" + b" " * 9000 + b"}")[0], 413) + self.assertEqual(self.request("POST", "/api/v1/budgets", raw=b"{nope")[0], 400) + self.assertEqual(self.request("POST", "/api/v1/budgets", {"thresholds": [50]}, + content_type="text/plain")[0], 415) + self.assertEqual(self.saved()["budgets"]["allocations"], {"claude": 100, "codex": 50}) + + def test_get_shape_never_includes_session_budgets(self): + status, payload, text = self.request("GET", "/api/v1/budgets") + self.assertEqual(status, 200) + self.assertEqual(set(payload), {"ok", "revision", "budgets", "runtimes", "limits", "month"}) + self.assertRegex(payload["revision"], r"^b_[0-9a-f]{16}$") + self.assertEqual(set(payload["budgets"]), {"currency", "allocations", "monthly_total", "thresholds", + "native_notifications", "default_session_budget"}) + self.assertEqual(set(payload["budgets"]["allocations"]), set(meter.BUDGET_PROVIDERS)) + self.assertEqual(payload["budgets"]["monthly_total"], 150) + self.assertEqual(payload["runtimes"], list(meter.BUDGET_PROVIDERS)) + self.assertEqual(payload["limits"], {"max_monthly_budget": meter.MAX_MONTHLY_BUDGET, + "min_session_budget": meter.MIN_SESSION_BUDGET}) + self.assertNotIn("private-session-id", text) + self.assertNotIn("session_budgets", text) + + def test_month_reports_spend_and_list_spend_from_served_state(self): + cross = {"budget": {"month": "2026-10", "spend": 70.0, "partial": True}, + "monthly": [{"month": "2026-10", "cost": 70.0, "discount_usd": 30.0}]} + with mock.patch.object(meter, "STATE", {"ok": True, "xsession": cross}): + payload = self.request("GET", "/api/v1/budgets")[1] + self.assertEqual(payload["month"], {"month": "2026-10", "spent": 70.0, "list_spent": 100.0, + "coverage_partial": True}) + + def test_partial_allocation_merge_keeps_other_runtimes_and_session_caps(self): + status, payload, _ = self.request("POST", "/api/v1/budgets", {"allocations": {"cursor": 25}}, + client="budget-sync") + self.assertEqual(status, 200, payload) + self.assertTrue(payload["changed"]) + self.assertEqual(payload["budgets"]["allocations"]["claude"], 100) + self.assertEqual(payload["budgets"]["allocations"]["cursor"], 25) + self.assertEqual(payload["budgets"]["monthly_total"], 175) + saved = self.saved() + self.assertEqual(saved["budgets"]["session_budgets"], {"private-session-id": 3.0}) + self.assertEqual(saved["updates"], {"enabled": False}) + self.refresh.assert_called_once_with() + self.assertEqual(self.request("GET", "/api/v1/budgets")[1]["revision"], payload["revision"]) + + def test_validation_messages(self): + cases = ( + ({"allocations": {"claude": -1}}, "Claude allocation must be between 0 and 100,000,000."), + ({"allocations": {"claude": 200_000_000}}, "Claude allocation must be between 0 and 100,000,000."), + ({"allocations": {"gemini": 5}}, "Unknown runtime: gemini"), + ({"thresholds": [90, 80]}, "Use 1 to 10 unique budget thresholds in increasing order."), + ({"thresholds": [0]}, "Budget thresholds must be whole percentages from 1 to 100."), + ({"session_budgets": {"x": 1}}, "Unknown field: session_budgets"), + ({"monthly_total": 5}, "Unknown field: monthly_total"), + ({"native_notifications": "yes"}, "Budget notifications must be on or off."), + ) + before = self.settings.read_text() + for body, message in cases: + with self.subTest(body=body): + status, payload, _ = self.request("POST", "/api/v1/budgets", body) + self.assertEqual((status, payload["error"]), (400, message)) + self.assertEqual(self.settings.read_text(), before) + self.refresh.assert_not_called() + + def test_stale_revision_conflicts_and_repeat_is_a_no_op(self): + stale = self.request("GET", "/api/v1/budgets")[1]["revision"] + first = self.request("POST", "/api/v1/budgets", {"thresholds": [50, 100]})[1] + status, conflict, _ = self.request("POST", "/api/v1/budgets", { + "expected_revision": stale, "thresholds": [60]}) + self.assertEqual((status, conflict["revision"]), (409, first["revision"])) + mtime = self.settings.stat().st_mtime_ns + status, again, _ = self.request("POST", "/api/v1/budgets", { + "expected_revision": first["revision"], "thresholds": [50, 100]}) + self.assertEqual((status, again["changed"], again["revision"]), (200, False, first["revision"])) + self.assertEqual(self.settings.stat().st_mtime_ns, mtime) + self.assertEqual(self.refresh.call_count, 1) + + def test_budget_status_after_post_reflects_the_new_total(self): + with mock.patch.object(meter, "refresh_cross_session_state", _REAL_REFRESH), \ + mock.patch.object(meter, "all_session_sources", return_value=[]), \ + mock.patch.dict(meter._xsess, {"data": None, "at": 0.0}): + self.request("POST", "/api/v1/budgets", {"allocations": {"claude": 400}}) + budget = meter._xsess["data"]["budget"] + self.assertEqual(budget["budget"], 450) + self.assertTrue(budget["configured"]) + month = self.request("GET", "/api/v1/budgets")[1]["month"] + self.assertEqual(month["spent"], 0.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_daily_budgets.py b/tests/test_daily_budgets.py new file mode 100644 index 00000000..10b731e8 --- /dev/null +++ b/tests/test_daily_budgets.py @@ -0,0 +1,620 @@ +import unittest +import json +import tempfile +import threading +from pathlib import Path +from unittest import mock +import meter + + +class DailySummaryTests(unittest.TestCase): + def test_compare_sessions_state_projects_selected_sessions_without_content(self): + from token_meter.domain.compare import trace_key + pool = [ + {"id": "one", "path": "/private/traces/ws/one/messages.jsonl", "provider": "kiro"}, + {"id": "two", "path": "/private/traces/ws/two/messages.jsonl", "provider": "kiro"}, + {"id": "root", "path": "/private/traces/rollout-9-root.jsonl", "provider": "codex"}, + ] + key = {source["id"]: trace_key(source) for source in pool} + + def summary(source): + return { + "id": source["id"], "path": source["path"], "title": "Same prompt", + "label": "Kiro", "provider": source["provider"], "models": ["m"], + "usage_basis": "reported", "availability": {"cost": True}, + } + + def state(source): + cost = 1.0 if source["id"] == "one" else 2.5 + return { + "availability": {"cost": True, "tokens": True}, + "total_cost": cost, "turns": 4, "total_tokens": 1000, + "timing": {"duration_s": 60, "duration_available": True}, + "series": [{"i": 1, "cost": cost, "in": 10, "out": 5, + "user_message": "PRIVATE PROMPT TEXT"}], + "source": {"path": source["path"]}, + } + + saved_cache = dict(meter._xsess) + try: + meter._xsess["sessions"] = [ + {"id": "one", "title": "Same prompt", "path": pool[0]["path"]}, + {"id": "root", "title": "same prompt", "mtime": 5, "cost": 0.4, "path": pool[2]["path"]}, + {"id": "four", "title": "Different", "path": "/private/traces/four.jsonl"}, + ] + with mock.patch.object(meter, "cached_session_sources", return_value=(pool, True)), \ + mock.patch.object(meter, "session_summary", side_effect=summary), \ + mock.patch.object(meter, "cached_session_state", side_effect=state): + payload, status = meter.compare_sessions_state(f"{key['one']},{key['two']},tgone") + single, single_status = meter.compare_sessions_state(key["one"]) + empty, empty_status = meter.compare_sessions_state("") + missing, missing_status = meter.compare_sessions_state("tgone,talsogone") + finally: + meter._xsess.clear() + meter._xsess.update(saved_cache) + + self.assertEqual(status, 200) + self.assertTrue(payload["ok"]) + self.assertEqual([row["letter"] for row in payload["sessions"]], ["A", "B"]) + self.assertEqual([row["id"] for row in payload["sessions"]], ["one", "two"]) + # Repeated file names fall back to the session id; unique stems open the exact trace. + self.assertEqual([row["open_id"] for row in payload["sessions"]], ["one", "two"]) + self.assertEqual(payload["missing"], ["tgone"]) + self.assertTrue(payload["same_title"]) + self.assertEqual(payload["best"]["cost"], key["one"]) + self.assertEqual([row["key"] for row in payload["matches"]], [key["root"]]) + self.assertEqual(payload["matches"][0]["open_id"], "rollout-9-root") + encoded = json.dumps(payload) + self.assertNotIn("PRIVATE PROMPT TEXT", encoded) + self.assertNotIn("/private/traces", encoded) + self.assertEqual(single_status, 200) + self.assertNotIn("insights", single) + self.assertNotIn("insights", payload) + self.assertEqual((empty_status, empty["ok"]), (400, False)) + self.assertEqual((missing_status, missing["ok"]), (404, False)) + self.assertIn( + 'elif req_path == "/session/compare":', + Path(meter.IMPLEMENTATION_FILE).read_text(), + ) + self.assertFalse(meter.is_dashboard_page_path("/session/compare")) + + def test_spend_logs_state_validates_and_returns_full_range(self): + sessions = ( + { + "id": "multi-day", "title": "Multi day", "project": "/repo/a", + "provider": "codex", "label": "Codex", + "availability": {"cost": True}, + "input_tokens": 12000, "output_tokens": 600, "turns": 6, + "duration_s": 420, "duration_available": True, + "duration_basis": "observed", "token_estimate": True, + "_day_cost": { + "2026-08-01": 1.25, + "2026-08-02": 2.75, + "2026-08-03": 8.0, + }, + }, + { + "id": "second", "title": "Second", "project": "/repo/b", + "provider": "claude", "label": "Claude", + "availability": {"cost": True}, + "_day_cost": {"2026-08-02": 2.0}, + }, + ) + saved_cache = dict(meter._xsess) + try: + meter._xsess["internal_rows"] = sessions + with mock.patch.object( + meter, "cross_session", return_value={"generated_at": 123}, + ): + payload, status = meter.spend_logs_state( + "2026-08-01", "2026-08-02", + ) + missing, missing_status = meter.spend_logs_state("", "2026-08-02") + malformed, malformed_status = meter.spend_logs_state( + "2026-08-01", "not-a-date", + ) + reversed_range, reversed_status = meter.spend_logs_state( + "2026-08-03", "2026-08-02", + ) + finally: + meter._xsess.clear() + meter._xsess.update(saved_cache) + + self.assertEqual(status, 200) + self.assertTrue(payload["ok"]) + self.assertEqual(payload["generated_at"], 123) + self.assertEqual(payload["from"], "2026-08-01") + self.assertEqual(payload["to"], "2026-08-02") + self.assertEqual(payload["total_sessions"], 2) + self.assertEqual(payload["total_cost"], 6.0) + self.assertEqual(payload["sessions"][0]["id"], "multi-day") + self.assertEqual(payload["sessions"][0]["input_tokens"], 12000) + self.assertEqual(payload["sessions"][0]["output_tokens"], 600) + self.assertEqual(payload["sessions"][0]["turns"], 6) + self.assertEqual(payload["sessions"][0]["duration_s"], 420) + self.assertTrue(payload["sessions"][0]["duration_available"]) + self.assertEqual(payload["sessions"][0]["duration_basis"], "observed") + self.assertEqual(payload["sessions"][0]["usage_basis"], "local_estimate") + for error, error_status in ( + (missing, missing_status), + (malformed, malformed_status), + (reversed_range, reversed_status), + ): + self.assertEqual(error_status, 400) + self.assertFalse(error["ok"]) + self.assertIn( + 'elif req_path == "/spend/logs":', + Path(meter.IMPLEMENTATION_FILE).read_text(), + ) + + def test_aggregates_daily_spend_providers_and_logs(self): + sessions = [ + {"id": "one", "title": "One", "project": "/repo/a", "provider": "codex", + "label": "Codex", "_day_cost": {"2026-07-01": 1.25}, + "_wait_samples": [ + {"day": "2026-07-01", "duration_s": 20}, + {"day": "2026-07-01", "duration_s": 40}, + ]}, + {"id": "two", "title": "Two", "project": "/repo/b", "provider": "claude", + "label": "Claude Code", "_day_cost": {"2026-07-01": 0.75, "2026-06-30": 0.5}, + "_wait_samples": [{"day": "2026-07-01", "duration_s": 30}]}, + ] + days = meter.daily_summaries(sessions) + self.assertEqual(days[0]["day"], "2026-07-01") + self.assertEqual(days[0]["cost"], 2.0) + self.assertEqual(days[0]["sessions"], 2) + self.assertEqual(days[0]["projects"], 2) + self.assertNotIn("tool_tokens", days[0]) + self.assertNotIn("flagged_tokens", days[0]) + provider = days[0]["providers"][0] + self.assertEqual(provider["provider"], "codex") + self.assertEqual(provider["cost"], 1.25) + self.assertEqual(provider["wait_s"], 60.0) + self.assertEqual(provider["wait_samples"], 2) + self.assertEqual(provider["usage_basis"], "reported") + self.assertEqual(days[0]["wait_time"]["total_s"], 90) + self.assertEqual(days[0]["wait_time"]["avg_s"], 30) + self.assertEqual(days[0]["wait_time"]["max_s"], 40) + + def test_unbounded_daily_history_has_compact_spend_projection(self): + sessions = [{ + "id": "one", "title": "Private title", "project": "/private/repo", + "provider": "codex", "label": "Codex", + "_day_cost": { + f"2026-07-{day:02d}": float(day) for day in range(1, 32) + }, + }] + legacy = meter.daily_summaries(sessions) + complete = meter.daily_summaries(sessions, limit=None) + spend = meter.spend_projection(complete) + self.assertEqual(len(legacy), 30) + self.assertEqual(len(complete), 31) + self.assertEqual(spend[0]["day"], "2026-07-31") + self.assertEqual(spend[0]["providers"][0]["provider"], "codex") + self.assertEqual(spend[0]["providers"][0]["cost"], 31.0) + self.assertEqual( + set(spend[0]), + {"day", "cost", "providers", "coverage", "provenance", + "usage_basis", "availability"}, + ) + serialized = json.dumps(spend) + self.assertNotIn("Private title", serialized) + self.assertNotIn("/private/repo", serialized) + self.assertNotIn("top_sessions", serialized) + + def test_cross_session_publishes_spend_history_and_keeps_daily(self): + saved_cache = dict(meter._xsess) + try: + meter._xsess.update({"data": None, "at": 0, "sessions": []}) + result = meter.cross_session(sources=[]) + finally: + meter._xsess.clear() + meter._xsess.update(saved_cache) + self.assertEqual(result["spend"], {"days": []}) + self.assertEqual(result["daily"], []) + + +class MonthlyBudgetTests(unittest.TestCase): + def test_budget_settings_persist_session_overrides_without_changing_defaults(self): + """A session cap survives settings writes and remains distinct from its default.""" + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "settings.json" + result = meter.set_budget_settings({ + "default_session_budget": 10, + "session_budgets": {"session-1": 25.5}, + "allocations": {}, + "thresholds": [80, 90, 100], + "native_notifications": True, + }, str(path)) + stored = json.loads(path.read_text()) + + self.assertTrue(result["ok"]) + self.assertEqual(result["budgets"].get("default_session_budget"), 10.0) + self.assertEqual(result["budgets"].get("session_budgets"), {"session-1": 25.5}) + self.assertEqual(stored["budgets"].get("session_budgets"), {"session-1": 25.5}) + + def test_general_budget_save_preserves_existing_session_overrides(self): + """The Settings form must not erase caps it does not display or submit.""" + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "settings.json" + meter.set_session_budget_override("session-1", 25, path=str(path)) + result = meter.set_budget_settings({ + "default_session_budget": 12, + "allocations": {}, + "thresholds": [80, 90, 100], + "native_notifications": True, + }, str(path)) + stored = json.loads(path.read_text()) + + self.assertTrue(result["ok"]) + self.assertEqual(stored["budgets"]["default_session_budget"], 12.0) + self.assertEqual(stored["budgets"]["session_budgets"], {"session-1": 25.0}) + + def test_concurrent_session_compare_and_set_allows_one_writer(self): + """Competing confirmed writes must serialize to success plus stale response.""" + with tempfile.TemporaryDirectory() as tmp: + path = str(Path(tmp) / "settings.json") + ready = threading.Barrier(2) + results = [] + + def change(budget): + ready.wait() + results.append(meter.set_session_budget_override( + "session-1", budget, expected_current_budget_usd=10, path=path, + )) + + first = threading.Thread(target=change, args=(12,)) + second = threading.Thread(target=change, args=(15,)) + first.start() + second.start() + first.join() + second.join() + + self.assertEqual(sum(result["ok"] for result in results), 1) + self.assertEqual(sum("changed" in result.get("error", "") for result in results), 1) + + def test_default_session_budget_compare_and_set_rejects_a_stale_budget(self): + with tempfile.TemporaryDirectory() as tmp: + path = str(Path(tmp) / "settings.json") + first = meter.set_default_session_budget_value( + 12, expected_current_budget_usd=10, path=path, + ) + stale = meter.set_default_session_budget_value( + 15, expected_current_budget_usd=10, path=path, + ) + + self.assertTrue(first["ok"]) + self.assertFalse(stale["ok"]) + self.assertIn("changed", stale["error"]) + + def test_agent_budget_projection_excludes_unrequested_global_configuration(self): + source = {"id": "session-1", "provider": "codex", "client": "Codex"} + state = {"total_cost": 3, "availability": {"cost": True}} + settings = meter.normalize_budget_settings({ + "default_session_budget": 10, + "session_budgets": {"session-1": 5}, + "allocations": {}, "thresholds": [80, 90, 100], + "native_notifications": True, + }) + with mock.patch.object(meter, "resolve_agent_source", return_value=(source, "explicit")), \ + mock.patch.object(meter, "recompute", return_value=state), \ + mock.patch.object(meter, "budget_settings", return_value=settings): + result = meter.agent_budget(session_id="session-1") + + self.assertEqual(result["session"]["budget_usd"], 5) + self.assertNotIn("default_session_budget_usd", result) + self.assertNotIn("session_budget_overrides", result) + self.assertNotIn("lower bound", result["caveat"]) + + def test_agent_budget_caveat_marks_partial_family_spend_as_lower_bound(self): + source = {"id": "ses_root", "provider": "opencode", "client": "OpenCode"} + state = {"total_cost": 1.0, "availability": {"cost": True}} + family = [ + {"id": "ses_root", "provider": "opencode", "cost": 1.0, + "availability": {"cost": True}}, + {"id": "ses_child", "provider": "opencode", "cost": 0.0, + "is_child_session": True, "root_session_id": "ses_root", + "availability": {"cost": False}}, + ] + settings = meter.normalize_budget_settings({}) + with mock.patch.object(meter, "resolve_agent_source", return_value=(source, "explicit")), \ + mock.patch.object(meter, "recompute", return_value=state), \ + mock.patch.object(meter, "budget_settings", return_value=settings), \ + mock.patch.dict(meter._xsess, {"sessions": family}): + result = meter.agent_budget(session_id="ses_root") + + self.assertTrue(result["session"]["cost_partial"]) + self.assertIn("Spend is a lower bound", result["caveat"]) + + def test_session_override_compare_and_set_rejects_a_stale_budget(self): + """A delayed agent must not silently replace a newer session cap.""" + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "settings.json" + first = meter.set_session_budget_override( + "session-1", 12, expected_current_budget_usd=10, path=str(path), + ) + stale = meter.set_session_budget_override( + "session-1", 15, expected_current_budget_usd=10, path=str(path), + ) + effective, source = meter.effective_session_budget( + "session-1", meter.budget_settings(str(path)), + ) + + self.assertTrue(first["ok"]) + self.assertFalse(stale["ok"]) + self.assertIn("changed", stale["error"]) + self.assertEqual((effective, source), (12.0, "session_override")) + + def test_missing_default_session_budget_migrates_to_ten(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "settings.json" + path.write_text(json.dumps({ + "budgets": { + "currency": "USD", + "allocations": {}, + "thresholds": [80, 90, 100], + "native_notifications": True, + }, + })) + + loaded = meter.budget_settings(str(path)) + + self.assertEqual(loaded.get("default_session_budget"), 10) + + def test_default_session_budget_is_validated_and_persisted(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "settings.json" + path.write_text(json.dumps({"model_pricing": {"claude": {}}})) + too_small = meter.set_budget_settings({ + "default_session_budget": 0, + "allocations": {}, + "thresholds": [80, 90, 100], + "native_notifications": True, + }, str(path)) + result = meter.set_budget_settings({ + "default_session_budget": 25.5, + "allocations": {}, + "thresholds": [80, 90, 100], + "native_notifications": True, + }, str(path)) + stored = json.loads(path.read_text()) + + self.assertFalse(too_small["ok"]) + self.assertIn("Default session budget", too_small["error"]) + self.assertTrue(result["ok"]) + self.assertEqual(result["budgets"]["default_session_budget"], 25.5) + self.assertEqual(stored["budgets"]["default_session_budget"], 25.5) + self.assertIn("model_pricing", stored) + + def test_settings_derive_total_from_allocations_and_preserve_other_machine_settings(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "settings.json" + path.write_text(json.dumps({"model_pricing": {"claude": {}}})) + invalid = meter.set_budget_settings({ + "allocations": {"claude": 60_000_000, "codex": 50_000_000}, + "thresholds": [80, 90, 100], + "native_notifications": True, + }, str(path)) + result = meter.set_budget_settings({ + "allocations": {"claude": 50, "codex": 30, "cursor": 0}, + "thresholds": [75, 90, 100], + "native_notifications": False, + }, str(path)) + stored = json.loads(path.read_text()) + self.assertFalse(invalid["ok"]) + self.assertTrue(result["ok"]) + self.assertEqual(stored["budgets"]["monthly_total"], 80) + self.assertEqual( + stored["budgets"]["allocations"], + {"claude": 50, "codex": 30, "cursor": 0, "opencode": 0, "kiro": 0, "pi": 0, "hermes": 0, "agy": 0}, + ) + self.assertIn("model_pricing", stored) + + def test_missing_runtime_budgets_default_to_zero_and_explicit_values_are_preserved(self): + legacy = { + "currency": "USD", + "monthly_total": 100, + "allocations": {}, + "thresholds": [80, 90, 100], + "native_notifications": True, + } + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "settings.json" + path.write_text(json.dumps({"budgets": legacy})) + loaded = meter.budget_settings(str(path)) + saved = meter.set_budget_settings({ + "allocations": {"claude": 0, "codex": 1490, "cursor": 0}, + "thresholds": [80, 90, 100], + "native_notifications": True, + }, str(path)) + self.assertEqual(loaded["monthly_total"], 0) + self.assertEqual( + loaded["allocations"], + {"claude": 0, "codex": 0, "cursor": 0, "opencode": 0, "kiro": 0, "pi": 0, "hermes": 0, "agy": 0}, + ) + self.assertTrue(saved["ok"]) + self.assertEqual(saved["budgets"]["monthly_total"], 1490) + self.assertEqual( + saved["budgets"]["allocations"], + {"claude": 0, "codex": 1490, "cursor": 0, "opencode": 0, "kiro": 0, "pi": 0, "hermes": 0, "agy": 0}, + ) + + def test_monthly_rollup_keeps_runtime_costs_and_partial_coverage(self): + sessions = [ + { + "id": "reported", "provider": "claude", + "_day_cost": {"2026-07-01": 20, "2026-07-03": 10}, + "_model_daily": [{"day": "2026-07-01"}], + "availability": {"cost": True}, + }, + { + "id": "estimated", "provider": "cursor", "token_estimate": True, + "_day_cost": {"2026-07-02": 5}, + "_model_daily": [{"day": "2026-07-02"}], + "availability": {"cost": True}, + }, + { + "id": "missing", "provider": "codex", + "_model_daily": [{"day": "2026-07-04"}], + "availability": {"cost": False}, + }, + ] + rows = meter.monthly_summaries(sessions) + self.assertEqual(rows[0]["month"], "2026-07") + self.assertEqual(rows[0]["cost"], 35) + self.assertEqual(rows[0]["active_days"], 3) + self.assertEqual(rows[0]["sessions"], 3) + self.assertFalse(rows[0]["coverage"]["cost"]["complete"]) + self.assertEqual(rows[0]["provenance"]["estimated_cost"], 5) + self.assertEqual( + {row["provider"]: row["cost"] for row in rows[0]["providers"]}, + {"claude": 30, "cursor": 5, "codex": 0}, + ) + + def test_budget_status_projects_after_three_spend_days_and_marks_lower_bound(self): + months = [{ + "month": "2026-07", "cost": 60, "active_days": 3, "observed_days": 4, + "providers": [ + {"provider": "claude", "cost": 40}, + {"provider": "codex", "cost": 20}, + ], + "coverage": {"cost": { + "covered_sessions": 2, "total_sessions": 3, "complete": False, + }}, + "provenance": { + "estimated_sessions": 0, "estimated_cost": 0, + "usage_basis": "reported", + }, + }] + status = meter.monthly_budget_status(months, { + "allocations": {"claude": 50, "codex": 30, "cursor": 0}, + "thresholds": [50, 80, 100], + "native_notifications": True, + }, now=meter.datetime.datetime(2026, 7, 10, tzinfo=meter.datetime.timezone.utc)) + self.assertEqual(status["spend"], 60) + self.assertEqual(status["budget"], 80) + self.assertEqual(status["remaining"], 20) + self.assertEqual(status["unallocated"], 0) + self.assertAlmostEqual(status["projected_spend"], 186) + self.assertTrue(status["lower_bound"]) + self.assertEqual(status["thresholds_crossed"], [50]) + self.assertEqual(status["next_threshold"], 80) + claude = next(row for row in status["runtimes"] if row["provider"] == "claude") + self.assertEqual(claude["percent"], 0.8) + + def test_antigravity_character_estimates_are_shown_but_do_not_drive_alerts(self): + def month(agy_estimated_cost): + return [{ + "month": "2026-07", "cost": 110, "active_days": 4, + "providers": [ + {"provider": "agy", "cost": 90, + "provenance": {"estimated_sessions": 1, "estimated_cost": agy_estimated_cost}}, + {"provider": "codex", "cost": 20, "provenance": {"estimated_sessions": 0}}, + ], + "coverage": {"cost": {"covered_sessions": 2, "total_sessions": 2, "complete": True}}, + "provenance": {"estimated_sessions": 1}, + }] + + settings = { + "allocations": {"agy": 50, "codex": 50}, + "thresholds": [50, 80, 100], + "native_notifications": True, + } + now = meter.datetime.datetime(2026, 7, 10, tzinfo=meter.datetime.timezone.utc) + estimated = meter.monthly_budget_status(month(80), settings, now=now) + measured = meter.monthly_budget_status(month(0), settings, now=now) + + # The budget card shows the full spend, estimates included. + self.assertEqual(estimated["spend"], 110) + self.assertAlmostEqual(estimated["percent"], 1.1) + agy = next(row for row in estimated["runtimes"] if row["provider"] == "agy") + self.assertEqual(agy["spend"], 90) + # Alerts are measured without the character estimates. + self.assertEqual(estimated["alert_excluded_spend"], 80) + self.assertAlmostEqual(estimated["alert_percent"], 0.3) + self.assertAlmostEqual(agy["alert_percent"], 0.2) + self.assertEqual(estimated["thresholds_crossed"], []) + # Everything on the card follows the full spend shown. + self.assertEqual(estimated["state"], "over") + self.assertIsNone(estimated["next_threshold"]) + self.assertEqual([row["provider"] for row in estimated["exceeded_runtimes"]], ["agy"]) + self.assertEqual(measured["alert_excluded_spend"], 0) + self.assertAlmostEqual(measured["alert_percent"], 1.1) + self.assertEqual(measured["thresholds_crossed"], [50, 80, 100]) + + def test_shown_spend_drives_the_warning_state_while_notifications_wait(self): + months = [{ + "month": "2026-07", "cost": 60, "active_days": 4, + "providers": [ + {"provider": "agy", "cost": 40, + "provenance": {"estimated_sessions": 1, "estimated_cost": 40}}, + {"provider": "codex", "cost": 20, "provenance": {"estimated_sessions": 0}}, + ], + "coverage": {"cost": {"covered_sessions": 2, "total_sessions": 2, "complete": True}}, + "provenance": {"estimated_sessions": 1}, + }] + status = meter.monthly_budget_status(months, { + "allocations": {"agy": 50, "codex": 50}, + "thresholds": [50, 80, 100], + "native_notifications": True, + }, now=meter.datetime.datetime(2026, 7, 10, tzinfo=meter.datetime.timezone.utc)) + + self.assertEqual(status["state"], "warning") + self.assertEqual(status["thresholds_crossed"], []) + self.assertEqual(status["next_threshold"], 80) + + def test_runtime_overrun_is_reported_while_overall_budget_is_on_track(self): + months = [{ + "month": "2026-07", "cost": 1501, "active_days": 4, + "providers": [{"provider": "codex", "cost": 1501}], + "coverage": {"cost": { + "covered_sessions": 1, "total_sessions": 1, "complete": True, + }}, + "provenance": {"estimated_sessions": 0}, + }] + status = meter.monthly_budget_status(months, { + "allocations": {"claude": 1000, "codex": 1490, "cursor": 1000}, + "thresholds": [80, 90, 100], + "native_notifications": True, + }, now=meter.datetime.datetime(2026, 7, 10, tzinfo=meter.datetime.timezone.utc)) + self.assertEqual(status["state"], "on_track") + self.assertTrue(status["runtime_exceeded"]) + self.assertTrue(status["attention"]) + self.assertEqual(len(status["exceeded_runtimes"]), 1) + self.assertEqual(status["exceeded_runtimes"][0]["provider"], "codex") + self.assertEqual(status["exceeded_runtimes"][0]["over_by"], 11) + codex = next(row for row in status["runtimes"] if row["provider"] == "codex") + self.assertTrue(codex["exceeded"]) + + +class McpDocumentationContractTests(unittest.TestCase): + def test_maintained_docs_describe_the_read_only_query_surface(self): + root = Path(__file__).resolve().parents[1] + documents = { + path: (root / path).read_text() + for path in ( + "README.md", + "specs/USER_GUIDE.md", + "specs/ARCHITECTURE.md", + "specs/SECURITY.md", + ) + } + for path, content in documents.items(): + with self.subTest(path=path): + self.assertIn("read-only", content.lower()) + for tool in ("sessions", "trace", "stats", "schema"): + self.assertIn(tool, content) + self.assertIn("not raw trace content", content.lower()) + + combined = "\n".join(documents.values()).lower() + for prohibited_claim in ( + "returns raw prompts", + "returns raw responses", + "raw tool payloads are available", + "trace paths are available", + ): + self.assertNotIn(prohibited_claim, combined) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_dashboard_contracts.py b/tests/test_dashboard_contracts.py new file mode 100644 index 00000000..3de8777d --- /dev/null +++ b/tests/test_dashboard_contracts.py @@ -0,0 +1,705 @@ +import unittest +import datetime +import http.client +import json +import os +import re +import subprocess +import time +from pathlib import Path +from unittest import mock +import meter +from tests.dashboard_page import DASHBOARD_PAGE + + +class ToolUsageEvidenceRealityTests(unittest.TestCase): + """Tools-tab usage evidence must match what the raw traces contain.""" + + @staticmethod + def inventory(waste, *, codex_mcp=None, claude_mcp=None, skills=None): + with mock.patch.object(meter, "codex_mcp_states", return_value=codex_mcp or {}), \ + mock.patch.object(meter, "claude_mcp_states", return_value=claude_mcp or {}), \ + mock.patch.object(meter, "discovered_skills", + side_effect=lambda usage=None: [dict(row) for row in (skills or [])]), \ + mock.patch.object(meter, "claude_desktop_index", return_value={}), \ + mock.patch.object(meter, "claude_local_agent_sources", return_value=[]): + return meter.capability_inventory(waste) + + @staticmethod + def codex_exec(code, call_id="exec-1"): + return {"timestamp": "2026-10-01T00:00:00.000Z", "payload": { + "type": "custom_tool_call", "name": "exec", "call_id": call_id, "input": code, + }} + + def test_codex_exec_records_nested_mcp_and_app_tool_calls(self): + code = ( + "const a = await tools.mcp__sharepoint__search({q: 'x'});\n" + "await tools.mcp__sharepoint__search({q: 'y'});\n" + "await tools.codex_app__load_workspace_dependencies({});\n" + "await tools.exec_command({cmd: 'ls'});\n" + "await tools.web__run({q: 'z'});\n" + ) + calls = meter.codex_tool_call_evidence([self.codex_exec(code)]) + nested = {row["name"]: row for row in calls if row.get("nested")} + self.assertEqual( + set(nested), + {"mcp__sharepoint__search", "mcp__codex_app__load_workspace_dependencies"}, + ) + self.assertEqual(nested["mcp__sharepoint__search"]["kind"], "mcp") + self.assertEqual(nested["mcp__sharepoint__search"]["namespace"], "sharepoint") + evidence = meter.summarize_tool_evidence(calls) + rows = {row["name"]: row for row in evidence["tools"]} + self.assertEqual(rows["mcp__sharepoint__search"]["calls"], 2) + self.assertEqual(rows["exec"]["calls"], 1) + self.assertEqual(evidence["total_calls"], 1) + + def test_codex_app_namespace_keeps_one_canonical_identity(self): + from token_meter.runtimes.codex import codex_mcp_tool_name + self.assertEqual( + codex_mcp_tool_name("read_thread", "codex_app"), "mcp__codex_app__read_thread", + ) + self.assertEqual( + codex_mcp_tool_name("read_thread", "mcp__codex_app"), "mcp__codex_app__read_thread", + ) + for builtin in ("collaboration", "clock", "web", "multi_agent_v1"): + self.assertEqual(codex_mcp_tool_name("wait_agent", builtin), "wait_agent") + + def test_codex_dynamic_catalog_matches_code_mode_calls(self): + from token_meter.runtimes.codex import _catalog + catalog = _catalog([ + {"name": "codex_app", "tools": [ + {"name": "wait_threads", "description": "wait"}, + {"name": "share_thread", "description": "share"}, + ]}, + {"name": "exec_command", "description": "flat"}, + ]) + names = {row["name"]: row for row in catalog} + normalized = meter.normalize_dynamic_tools([ + {"name": "codex_app", "tools": [{"name": "wait_threads"}]}, + {"name": "exec_command"}, + ]) + self.assertEqual( + [(row["name"], row["kind"]) for row in normalized], + [("mcp__codex_app__wait_threads", "mcp"), ("exec_command", "tool")], + ) + self.assertIn("mcp__codex_app__wait_threads", names) + self.assertEqual(names["mcp__codex_app__wait_threads"]["kind"], "mcp") + self.assertEqual(names["mcp__codex_app__wait_threads"]["namespace"], "codex_app") + self.assertIn("exec_command", names) + calls = meter.codex_tool_call_evidence([ + self.codex_exec("await tools.mcp__codex_app__wait_threads({});"), + ]) + evidence = meter.summarize_tool_evidence(calls, catalog) + share = names["mcp__codex_app__share_thread"]["definition_tokens"] + flat = names["exec_command"]["definition_tokens"] + self.assertEqual(evidence["unused_eager_definition_tokens"], share + flat) + + def test_duplicated_claude_transcript_copies_count_each_tool_once(self): + def assistant(block_id, name="mcp__workspace__bash"): + return { + "type": "assistant", "timestamp": "2026-10-01T00:00:00Z", + "message": {"id": "msg-1", "usage": {"output_tokens": 5}, "content": [ + {"type": "tool_use", "id": block_id, "name": name, "input": {}}, + ]}, + } + objs = [assistant("tool-1"), assistant("tool-1"), assistant("tool-2")] + calls = meter.claude_tool_call_evidence(objs) + self.assertEqual(len(calls), 2) + + def test_claude_tool_rows_keep_their_own_runtime(self): + call = {**meter.tool_identity("Bash"), "output_tokens": 1, "ts": 100, + "args_fingerprint": "x", "error": False} + rows = [ + {"id": "a", "provider": "claude", "runtime": "Claude-3P", "project": "/r", + "_tool_evidence": meter.summarize_tool_evidence([call])}, + {"id": "b", "provider": "claude", "runtime": "Claude Code", "project": "/r", + "_tool_evidence": meter.summarize_tool_evidence([call, call])}, + ] + waste = meter.global_tool_waste(rows) + bash = {row["runtime"]: row["calls"] for row in waste["inventory_tools"] + if row["name"] == "Bash"} + self.assertEqual(bash, {"Claude-3P": 1, "Claude Code": 2}) + + def test_desktop_code_tab_sessions_cover_claude_plugin_packs(self): + skill = { + "id": "skill:claude:custom@personal:custom", "name": "custom", + "runtime": "Claude", "source": "User-installed plugin", + "plugin_id": "custom@personal", "mutable": True, "enabled": True, + "used": False, "reviewable": True, "measurement": "measurable", + "unmeasurable": False, "activations": 0, "last_used": "Never", + "setting_path": "~/.claude/settings.json", + } + code_tab = os.path.join(meter.CLAUDE_PROJECTS, "-repo", "session.jsonl") + cowork = "/tmp/Claude-3p/local-agent-mode-sessions/x/.claude/projects/s/a.jsonl" + waste = meter.global_tool_waste([ + {"id": "tab", "provider": "claude", "runtime": "Claude-3P", + "path": code_tab, "project": "/r", "_tool_evidence": {}}, + {"id": "cli", "provider": "claude", "runtime": "Claude Code", + "path": code_tab.replace("session", "cli"), "project": "/r", "_tool_evidence": {}}, + {"id": "cowork", "provider": "claude", "runtime": "Claude-3P", + "path": cowork, "project": "/r", "_tool_evidence": {}}, + ]) + capabilities = self.inventory(waste, skills=[skill]) + self.assertEqual(capabilities["control_groups"][0]["scanned_sessions"], 2) + + def test_skill_activations_stay_with_the_runtime_that_used_them(self): + evidence = meter.summarize_tool_evidence([{ + **meter.tool_identity("exec"), "output_tokens": 0, "ts": 100, + "args_fingerprint": "x", "error": False, "skills": ["brainstorming"], + }]) + cowork = "/tmp/Claude-3p/local-agent-mode-sessions/x/.claude/projects/s/a.jsonl" + waste = meter.global_tool_waste([ + {"id": "codex", "provider": "codex", "runtime": "Codex", "project": "/r", + "path": "/tmp/codex.jsonl", "_tool_evidence": evidence}, + {"id": "cowork", "provider": "claude", "runtime": "Claude-3P", "project": "/r", + "path": cowork, "_tool_evidence": evidence}, + ]) + rows = [ + {"name": "brainstorming", "runtime": "Codex", "measurement": "measurable"}, + {"name": "brainstorming", "runtime": "Claude", "measurement": "measurable"}, + {"name": "brainstorming", "runtime": "Claude Desktop", "measurement": "measurable"}, + ] + meter.invalidate_discovered_skill_cache() + try: + with mock.patch.object(meter, "_scan_discovered_skills", return_value=rows): + skills = meter.discovered_skills(waste["skills"]) + finally: + meter.invalidate_discovered_skill_cache() + used = {row["runtime"]: row["activations"] for row in skills} + self.assertEqual(used, {"Codex": 1, "Claude": 0, "Claude Desktop": 1}) + + def test_skill_usage_is_not_truncated_and_rejects_template_names(self): + self.assertEqual( + meter.skill_names_from_value( + "cat skills/${n}/SKILL.md skills/{name}/SKILL.md " + "skills/token-meter-*/SKILL.md skills/docx/SKILL.md" + ), + ["docx"], + ) + calls = [{**meter.tool_identity("exec"), "output_tokens": 0, "ts": 100, + "args_fingerprint": str(index), "error": False, + "skills": [f"skill-{index:03d}"]} for index in range(120)] + waste = meter.global_tool_waste([{ + "id": "codex", "provider": "codex", "runtime": "Codex", "project": "/r", + "_tool_evidence": meter.summarize_tool_evidence(calls), + }]) + self.assertEqual(len(waste["skills"]), 120) + + def test_mcp_without_configuration_is_not_reported_disabled(self): + waste = {"inventory_tools": [{ + "id": "mcp__workspace__bash", "name": "mcp__workspace__bash", + "display": "bash", "kind": "mcp", "namespace": "workspace", + "mcp_server": "workspace", "runtime": "Claude-3P", "providers": ["claude"], + "calls": 9, "output_tokens": 10, "last_ts": 100, "last_used": "2026-10-01", + }]} + row = next(item for item in self.inventory(waste)["items"] if item["type"] == "mcp") + self.assertEqual(row["configuration"], "Not in config") + self.assertEqual(row["state"], "Not in config") + self.assertIsNone(row["enabled"]) + self.assertEqual(row["runtime"], "Claude") + + def test_codex_host_tool_groups_are_not_mcp_disable_candidates(self): + from token_meter.domain.tools import session_capabilities + catalog = meter.normalize_dynamic_tools([ + {"name": "codex_app", "tools": [{"name": "navigate_to_codex_page"}]}, + {"name": "plugin_management", "tools": [{"name": "uninstall_plugin"}]}, + ]) + self.assertTrue(all(row.get("host_provided") for row in catalog)) + rows = [{ + "id": f"s{index}", "provider": "codex", "runtime": "Codex", "project": "/r", + "path": f"/tmp/s{index}.jsonl", + "_tool_evidence": meter.summarize_tool_evidence([], catalog), + } for index in range(6)] + waste = meter.global_tool_waste(rows) + self.assertEqual( + [row["name"] for row in waste["inventory_tools"] if row["recommendation"] == "disable"], + [], + ) + self.assertNotIn("MCP disable candidate", [row.get("title") for row in waste["insights"]]) + self.assertEqual( + session_capabilities(rows[0]["_tool_evidence"])["mcp_servers"], + {"loaded": None, "used": 0}, + ) + items = self.inventory(waste)["items"] + self.assertEqual([row["name"] for row in items if row["type"] == "mcp"], []) + tools = {row["identity"]: row for row in items if row["type"] == "tool"} + self.assertEqual(tools["mcp__codex_app__navigate_to_codex_page"]["runtime"], "Codex") + + def test_codex_host_tool_group_calls_are_host_provided(self): + calls = meter.codex_tool_call_evidence([ + self.codex_exec("await tools.mcp__codex_app__wait_threads({});"), + {"timestamp": "2026-10-01T00:00:01Z", "payload": { + "type": "function_call", "name": "read_thread", "namespace": "codex_app", + "call_id": "c2", "arguments": "{}", + }}, + ]) + hosted = {row["name"]: row.get("host_provided") for row in calls if row["kind"] == "mcp"} + self.assertEqual(hosted, { + "mcp__codex_app__wait_threads": True, "mcp__codex_app__read_thread": True, + }) + + def test_advertised_only_mcp_has_runtime_and_no_call_claim(self): + catalog = [{"name": "mcp__jira__search", "namespace": "jira", "kind": "mcp", + "defer_loading": True, "definition_tokens": 10}] + waste = meter.global_tool_waste([{ + "id": "s", "provider": "codex", "runtime": "Codex", "project": "/r", + "_tool_evidence": meter.summarize_tool_evidence([], catalog), + }]) + row = next(item for item in self.inventory(waste)["items"] if item["type"] == "mcp") + self.assertEqual((row["name"], row["runtime"], row["calls"]), ("jira", "Codex", 0)) + script = Path(DASHBOARD_PAGE).read_text() + self.assertIn("Advertised in traces; not in Codex or Claude config", script) + + def test_nested_calls_are_daily_and_labelled_as_call_sites(self): + calls = meter.codex_tool_call_evidence([ + self.codex_exec("await tools.mcp__sharepoint__search({});"), + ]) + evidence = meter.summarize_tool_evidence(calls) + row = next(item for item in evidence["tools"] if item["name"] == "mcp__sharepoint__search") + self.assertEqual(row["nested_calls"], 1) + self.assertEqual([day["calls"] for day in row["daily"]], [1]) + waste = meter.global_tool_waste([{ + "id": "s", "provider": "codex", "runtime": "Codex", "project": "/r", + "_tool_evidence": evidence, + }]) + mcp = next(item for item in self.inventory(waste)["items"] if item["type"] == "mcp") + self.assertEqual(mcp["nested_calls"], 1) + script = Path(DASHBOARD_PAGE).read_text() + self.assertIn("code-mode call site", script) + + def test_host_tools_keep_non_disable_advice(self): + call = {**meter.tool_identity("mcp__codex_app__read_thread"), "output_tokens": 30000, + "ts": 100, "args_fingerprint": "x", "error": False, "host_provided": True} + waste = meter.global_tool_waste([{ + "id": "s", "provider": "codex", "runtime": "Codex", "project": "/r", + "_tool_evidence": meter.summarize_tool_evidence([call]), + }]) + row = next(item for item in waste["inventory_tools"] + if item["name"] == "mcp__codex_app__read_thread") + self.assertEqual(row["recommendation"], "narrow_results") + + def test_host_tools_get_no_project_scope_advice(self): + def call(index): + return {**meter.tool_identity("mcp__codex_app__wait_threads"), "output_tokens": 1, + "ts": 100 + index, "args_fingerprint": str(index), "error": False, + "host_provided": True} + waste = meter.global_tool_waste([{ + "id": "s", "provider": "codex", "runtime": "Codex", "project": "/r", + "_tool_evidence": meter.summarize_tool_evidence([call(i) for i in range(6)]), + }]) + row = next(item for item in waste["inventory_tools"] + if item["name"] == "mcp__codex_app__wait_threads") + self.assertEqual(row["recommendation"], "keep") + self.assertEqual(row["reason"], "Provided by the runtime itself; not a configurable MCP server.") + + def test_session_catalog_host_groups_are_not_used_mcp_servers(self): + from token_meter.domain.tools import session_capabilities + catalog = meter.normalize_dynamic_tools([ + {"name": "newapp", "tools": [{"name": "open"}]}, + ]) + evidence = meter.summarize_tool_evidence(meter.codex_tool_call_evidence([ + self.codex_exec("await tools.mcp__newapp__open({});"), + ]), catalog) + self.assertEqual( + session_capabilities(evidence)["mcp_servers"], {"loaded": None, "used": 0}, + ) + + def test_builder_recap_tool_total_excludes_code_mode_call_sites(self): + from token_meter.domain.builder_recap import _period_rollup + evidence = meter.summarize_tool_evidence(meter.codex_tool_call_evidence([ + self.codex_exec("await tools.mcp__sharepoint__search({});"), + ])) + day = datetime.date.fromtimestamp(evidence["tools"][0]["last_ts"] or 1790812800) + rollup = _period_rollup([{ + "id": "s", "provider": "codex", "runtime": "Codex", "_tool_evidence": evidence, + }], day, day) + self.assertEqual(rollup["tool_calls"], 1) + self.assertEqual(sorted(rollup["tools"].values()), [1, 1]) + + def test_codex_multi_part_mcp_namespace_keeps_server(self): + from token_meter.runtimes.codex import codex_mcp_tool_name + self.assertEqual(codex_mcp_tool_name("x", "mcp__a__b"), "mcp__a__x") + self.assertEqual(codex_mcp_tool_name("x", "foo__bar"), "x") + + def test_tools_page_renders_unconfigured_mcp_without_off_claims(self): + script = Path(DASHBOARD_PAGE).read_text() + start = script.index("function capabilityConfigurationText(row){") + body = script[start:script.index("\n}", start)] + self.assertNotIn("row.enabled?'Enabled':'Disabled'", body.replace(" ", "")) + self.assertIn("row.configured===false", script) + for handler in ("$('c-type-filter').onclick", "$('c-state-filter').onclick"): + line = script[script.index(handler):].split("\n", 1)[0] + self.assertIn("paintCapabilityFilters()", line) + + def test_codex_sanitized_mcp_names_match_configured_server(self): + waste = {"inventory_tools": [{ + "id": "mcp__ghost_mcp_proxy__slack", "name": "mcp__ghost_mcp_proxy__slack", + "display": "slack", "kind": "mcp", "namespace": "ghost_mcp_proxy", + "mcp_server": "ghost_mcp_proxy", "runtime": "Codex", "providers": ["codex"], + "calls": 4, "output_tokens": 0, "last_ts": 100, "last_used": "2026-10-01", + }]} + items = self.inventory(waste, codex_mcp={"ghost-mcp-proxy": True})["items"] + mcps = {row["name"]: row for row in items if row["type"] == "mcp"} + self.assertEqual(set(mcps), {"ghost-mcp-proxy"}) + self.assertEqual(mcps["ghost-mcp-proxy"]["calls"], 4) + self.assertTrue(mcps["ghost-mcp-proxy"]["used"]) + self.assertEqual(mcps["ghost-mcp-proxy"]["configuration"], "Enabled") + + +class DashboardThemeTests(unittest.TestCase): + """Theme choice is a browser preference applied before first paint and offered in Settings and ⌘K.""" + + def setUp(self): + self.page = Path(DASHBOARD_PAGE).read_text(encoding="utf-8") + + def run_boot(self, stored, throws=False): + boot = re.search(r"", self.page, re.S).group(1) + script = ( + "const document={documentElement:{dataset:{theme:'classic'}}};const window={};" + f"const localStorage={{getItem(){{if({json.dumps(throws)})throw new Error('blocked');return {json.dumps(stored)};}}}};" + f"{boot};console.log(document.documentElement.dataset.theme);" + ) + return subprocess.run(["node", "-e", script], capture_output=True, text=True, check=True).stdout.strip() + + def test_boot_script_applies_only_known_stored_themes(self): + self.assertIn("", self.page) + self.assertLess(self.page.index("", re.S) + self.assertEqual(boot.search(perf).group(0), boot.search(self.page).group(0)) + self.assertIn("", perf) + block = re.compile(r"\n((?::root,)?\[data-theme=(\w+)\])\{([^{}]*)\}") + dashboard = {m.group(2): dict(re.findall(r"--([\w-]+):([^;]+);", m.group(3))) for m in block.finditer(self.page)} + performance = {m.group(2): dict(re.findall(r"--([\w-]+):([^;]+);", m.group(3))) for m in block.finditer(perf)} + self.assertEqual(sorted(performance), sorted(dashboard)) + self.assertIn(":root,[data-theme=classic]{", perf) + for theme, tokens in performance.items(): + with self.subTest(theme=theme): + self.assertTrue(tokens) + shared = {k: v for k, v in tokens.items() if not k.startswith("card-")} + self.assertEqual(shared, {k: dashboard[theme][k] for k in shared}) + card = sorted(k for k in tokens if k.startswith("card-")) + self.assertEqual(card, ["card-a-rgb", "card-b-rgb", "card-c-rgb", "card-foot", "card-glow-rgb", + "card-hero", "card-hi-rgb", "card-idle-rgb", "card-paper-a-rgb", + "card-paper-b-rgb"]) + style = perf[perf.index("")] + chrome = block.sub("", style) + self.assertNotIn("#00bceb", chrome) + self.assertNotIn("rgba(0,188,235", chrome) + script = perf[perf.index("")] + draw = script[script.index("function cardPalette("):script.index("function setStatus(")] + draw = draw.replace(draw[draw.index("const CLASSIC_CARD="):draw.index("\n", draw.index("const CLASSIC_CARD="))], "") + for literal in ("#00bceb", "#4054d6", "#8ee9f2", "#8f6ce6", "rgba(0,188,235", "rgba(64,84,214", "rgba(127,219,242", "#0d2c3d"): + with self.subTest(card_literal=literal): + self.assertNotIn(literal, draw) + self.assertIn("window.addEventListener('storage',event=>{", script) + + def test_performance_rail_lists_the_same_destinations_as_the_dashboard(self): + perf = Path(__file__).resolve().parents[1].joinpath("performance.html").read_text(encoding="utf-8") + def groups(source, primary, secondary, label): + top = source[source.index(f"
    "):source.index(f"
    ")] + bottom = source[source.index(f"
    "):source.index("", source.index(f"
    "))] + return re.findall(label, top), re.findall(label, bottom) + dashboard = groups(self.page, "navPrimary", "navSecondary", r'class="?tab[^>]*data-label=(\w+)') + performance = groups(perf, "railPrimary", "railSecondary", r"(\w+)") + self.assertEqual(performance, dashboard) + self.assertEqual(dashboard[1][0], "Read") + tokenomics = re.search(r"const TOKENOMICS_SEARCH_URL='([^']+)'", self.page).group(1) + self.assertIn(f'id=tab-read href="{tokenomics}" target=_blank rel="noopener noreferrer"', perf) + + def test_appearance_settings_section_and_route(self): + self.assertIn('", source.index(f"
    "))] - return re.findall(label, top), re.findall(label, bottom) - dashboard = groups(self.page, "navPrimary", "navSecondary", r'class="?tab[^>]*data-label=(\w+)') - performance = groups(perf, "railPrimary", "railSecondary", r"(\w+)") - self.assertEqual(performance, dashboard) - self.assertEqual(dashboard[1][0], "Read") - tokenomics = re.search(r"const TOKENOMICS_SEARCH_URL='([^']+)'", self.page).group(1) - self.assertIn(f'id=tab-read href="{tokenomics}" target=_blank rel="noopener noreferrer"', perf) - - def test_appearance_settings_section_and_route(self): - self.assertIn('