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..462f3677 --- /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..9e51e400 --- /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..3f1b2dc4 --- /dev/null +++ b/dashboard/styles/tools.css @@ -0,0 +1,361 @@ +/* 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} +.deliveryTypes .workMixLegend{margin-top:10px} +.deliveryTypes .workMixLegend b{color:var(--dim);font-weight:var(--fw-bold)} +.deliveryTypes :is(.workMixLegend,.deliveryTypeTipRow) i.unlabeled{background:repeating-linear-gradient(135deg,var(--dim) 0 2px,var(--w-pending) 2px 4px);box-shadow:inset 0 0 0 1px var(--dim)} +.deliveryChart .typeSeg{stroke:var(--panel);stroke-width:1} +.deliveryChart .typeSeg.unlabeled{stroke:var(--dim)} +.deliveryChart .hatchBase{fill:var(--w-pending)} +.deliveryChart .hatchStripe{fill:var(--dim)} +.deliveryChart:focus-visible{outline:2px solid var(--signal);outline-offset:2px} +.deliveryChart .typeHover{fill:var(--fg);opacity:.04} +.deliveryTypeTipRow{display:flex;align-items:center;justify-content:space-between;gap:14px} +.deliveryTypeTipRow span{display:inline-flex;align-items:center;gap:6px} +.deliveryTypeTipRow i{display:inline-block;width:8px;height:8px;border-radius:var(--radius-2xs);background:var(--swatch)} +@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 f6050b12..00000000 --- a/page.html +++ /dev/null @@ -1,13321 +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
    - -
    - -
    -
    -

    Work pushed by type

    Reading labels…

    -
    -
    -
    -
    -

    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/performance.html b/performance.html index a78fa14d..a9dbab6d 100644 --- a/performance.html +++ b/performance.html @@ -10,7 +10,7 @@ ", 0)), + ("markup/subagents-models.html", ("\t
    ", 0)), + ("markup/spend.html", ("\t
    ", 0)), + ("markup/efficiency-work.html", ("\t
    ", 0)), + ("markup/git-learn-tools.html", ("\t
    ", 0)), + ("markup/settings.html", ("\t
    ", 0)), + ("js/core.js", ("", 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: + # Leading comments and blank lines move with the anchor, so no part + # ends in a blank line (git diff --check rejects that). + while start > 0 and ( + lines[start - 1].lstrip().startswith("//") or not lines[start - 1].strip() + ): + 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__) + if os.path.exists(os.path.join(argv[2], "page.manifest")): + raise SystemExit( + f"{argv[2]} already holds dashboard parts; split into an empty folder " + "and compare (see specs/CONTRIBUTING.md, Porting a page.html change)" + ) + 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 97431c6b..d94fd549 100644 --- a/specs/AGENTS.md +++ b/specs/AGENTS.md @@ -17,12 +17,13 @@ Token Meter reads local agent traces, calculates clearly labeled usage estimates |---|---| | `meter.py` | Small executable/import compatibility facade for `token_meter.app` | | `token_meter/app.py` | Composition, compatibility exports, settings, and application lifecycle | +| `token_meter/app_helpers/` | Leaf helpers moved out of `app.py`; `app.py` re-exports them. Find a name's home from `app.py`'s `app_helpers` imports | | `token_meter/runtimes/` | Registered runtime discovery, parsing, revisions, and safe projections | | `token_meter/platforms/` | Host paths, process/update policy, and trash behavior | | `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 | @@ -32,7 +33,7 @@ Token Meter reads local agent traces, calculates clearly labeled usage estimates | `token_meter/domain/work_evidence.py` | Per-request cost slices and action evidence for Work insights | | `token_meter/services/git_delivery.py` | Local push reflog scan, hashed Git ledger, commit work-type labels, Git payload | | `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 | @@ -56,8 +57,9 @@ 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..597d346e 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", ] @@ -23,7 +24,8 @@ def test_shared_domain_platform_http_and_generic_clients_need_no_kiro_branch(sel self.assertNotRegex(source, r"\b(?:if|elif|case|switch)\b[^\n]{0,120}\bkiro\b", path) def test_composition_contains_no_kiro_runtime_dispatch_condition(self): - source = (ROOT / "token_meter" / "app.py").read_text().lower() + composition = [ROOT / "token_meter" / "app.py", *(ROOT / "token_meter" / "app_helpers").glob("*.py")] + source = "\n".join(path.read_text().lower() for path in composition) self.assertIsNone(re.search( r"\b(?:if|elif)\b[^\n]{0,120}(?:provider|runtime|client)[^\n]{0,80}\bkiro\b", source, 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 new file mode 100644 index 00000000..f54ac62d --- /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 +# (measured after merging main at 577c44d), so normal maintenance still fits. +# This dict may only shrink. +LEGACY_CEILINGS = { + "token_meter/app.py": 510_000, + "tests/test_work_insights.py": 236_000, + "menubar/TokenMeterMenuBar.swift": 148_000, + "tests/test_git_delivery.py": 166_000, + "docs/talk/index.html": 123_000, + "token_meter/services/work_insights.py": 120_000, + "token_meter/services/git_delivery.py": 86_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() 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/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/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_agent_helpers.py b/tests/test_agent_helpers.py new file mode 100644 index 00000000..f08ebbc7 --- /dev/null +++ b/tests/test_agent_helpers.py @@ -0,0 +1,44 @@ +import shutil +import subprocess +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def run(*args): + return subprocess.run(args, cwd=ROOT, capture_output=True, text=True, timeout=60) + + +class ShowHelperTests(unittest.TestCase): + def test_prints_a_dashboard_function_with_its_location(self): + result = run(sys.executable, "scripts/show", "renderTrendDelta") + self.assertEqual(result.returncode, 0, result.stderr) + first, *body = result.stdout.splitlines() + self.assertRegex(first, r"^dashboard/js/[\w-]+\.js:\d+-\d+$") + self.assertIn("function renderTrendDelta(", body[0]) + self.assertTrue(body[-1].strip().endswith("}")) + + def test_prints_a_python_definition_from_one_file(self): + result = run(sys.executable, "scripts/show", "token_meter/dashboard_page.py", "part_names") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue(result.stdout.startswith("token_meter/dashboard_page.py:")) + self.assertIn("def part_names(manifest):", result.stdout) + + def test_missing_name_fails_without_output(self): + result = run(sys.executable, "scripts/show", "noSuchDashboardFunctionAnywhere") + self.assertEqual(result.returncode, 1) + self.assertEqual(result.stdout, "") + + +@unittest.skipUnless(shutil.which("node"), "Node.js is required to parse dashboard JavaScript") +class CheckPageTests(unittest.TestCase): + def test_assembles_and_parses_the_dashboard(self): + result = run("bash", "scripts/check-page") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.strip(), "js ok") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_agent_skills.py b/tests/test_agent_skills.py index 2e8b54d7..839da43b 100644 --- a/tests/test_agent_skills.py +++ b/tests/test_agent_skills.py @@ -17,6 +17,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] @@ -581,7 +582,11 @@ def test_debug_skill_paths_match_the_installers_and_platforms(self): install, install_linux = read("scripts", "install"), read("scripts", "install-linux") install_windows, start_windows = read("scripts", "install-windows.ps1"), read("scripts", "start-token-meter.ps1") launch_agent, systemd = read("scripts", "install-launch-agent"), read("scripts", "install-systemd-user") - app = read("token_meter", "app.py") + # app.py re-exports helpers moved to token_meter/app_helpers/; search both. + app = "\n".join([read("token_meter", "app.py")] + [ + path.read_text(encoding="utf-8") + for path in sorted((REPO / "token_meter" / "app_helpers").glob("*.py")) + ]) runtimes = "".join(read("token_meter", "runtimes", name) for name in ("agy.py", "kiro.py", "claude.py")) self.assertIn('"$HOME/Library/Application Support/Token Meter/runtime"', install) self.assertIn("~/Library/Application Support/Token Meter/runtime", text) @@ -649,7 +654,7 @@ def test_debug_skill_paths_match_the_installers_and_platforms(self): self.assertIn("systemctl --user show -p ActiveState,SubState,Result,ExecMainStatus token-meter-server", text) self.assertIn("-o cat | python3 -I \"$d/tm_debug.py\" log -", text) self.assertNotIn("systemctl --user status", text) - page = read("page.html") + page = Path(DASHBOARD_PAGE).read_text(encoding="utf-8") self.assertIn("Most read numbers through the tokenmeter MCP", page) self.assertIn("Usage review, budget pacing, skill hygiene, and debugging", page) self.assertNotIn("The skills need the MCP connection", read("specs", "USER_GUIDE.md")) @@ -665,7 +670,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_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_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_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('