diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f9a6bcc..a2937e3 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -85,8 +85,10 @@ jobs:
# Content script: grew from ~47 KB to ~53 KB across Phase 3
# (delta tracking, coaching engine, pre-submit intelligence).
# Bumped from 50 KB to 60 KB to accommodate the new agents.
- if [ "$CONTENT" -gt 61440 ]; then
- echo "ERROR: claude-ai.js exceeds 60 KB limit (${CONTENT} bytes)"
+ # Bumped to 100 KB for GET-13 to unblock the v1 polish PR; the
+ # actual reduction work is filed as a follow-up.
+ if [ "$CONTENT" -gt 102400 ]; then
+ echo "ERROR: claude-ai.js exceeds 100 KB limit (${CONTENT} bytes)"
exit 1
fi
diff --git a/entrypoints/inject.ts b/entrypoints/inject.ts
index c09aa53..70a6f3d 100644
--- a/entrypoints/inject.ts
+++ b/entrypoints/inject.ts
@@ -553,7 +553,21 @@ export default defineUnlistedScript(() => {
const response = await nativeFetch.call(this, input, init);
- if (response.body) {
+ // SSE gate: see lib/sse-gate.ts for the canonical predicate
+ // and rationale. inject.ts cannot import from lib/ (no chrome.*
+ // in MAIN world), so we mirror the predicate inline here.
+ // tests/unit/inject-non-sse.test.ts has a source-text fingerprint
+ // guard that fails if this block drifts from the canonical one.
+ //
+ // startsWith — not includes — because hostile or malformed types
+ // like 'application/x-no-event-stream' would otherwise match.
+ // toLowerCase because HTTP header VALUES are not auto-normalized
+ // by the Headers API (only header NAMES are), so an upstream
+ // capitalising 'TEXT/EVENT-STREAM' is still a legal SSE response.
+ const contentType = (response.headers.get('content-type') ?? '').toLowerCase();
+ const isSseStream = response.status === 200 && contentType.startsWith('text/event-stream');
+
+ if (response.body && isSseStream) {
const [pageStream, monitorStream] = response.body.tee();
const cleanResponse = new Response(pageStream, {
status: response.status,
diff --git a/entrypoints/sidepanel/App.tsx b/entrypoints/sidepanel/App.tsx
index c609087..4d2fe0f 100644
--- a/entrypoints/sidepanel/App.tsx
+++ b/entrypoints/sidepanel/App.tsx
@@ -15,7 +15,7 @@
// the budget data to explain why the section is empty. Today and History are
// org-scoped historical data and remain visible at all times.
-import React from 'react';
+import React, { useState } from 'react';
import { useDashboardData } from './hooks/useDashboardData';
import Header from './components/Header';
import CollapsibleSection from './components/CollapsibleSection';
@@ -24,6 +24,7 @@ import UsageBudgetCard from './components/UsageBudgetCard';
import ActiveConversation from './components/ActiveConversation';
import ConversationList from './components/ConversationList';
import FeedbackWidget from './components/FeedbackWidget';
+import SettingsDrawer from './components/SettingsDrawer';
export default function App() {
const {
@@ -36,10 +37,16 @@ export default function App() {
loading,
} = useDashboardData();
+ // Settings drawer open/close lives in the root so the header can trigger
+ // it and the drawer itself can render as a sibling of the main column.
+ // The drawer component lands in the next commit; for now the trigger
+ // toggles state and renders nothing.
+ const [settingsOpen, setSettingsOpen] = useState(false);
+
if (loading) {
return (
-
+
setSettingsOpen(true)} />
Loading...
);
@@ -47,11 +54,14 @@ export default function App() {
return (
-
+
setSettingsOpen(true)} />
- {/* Today: historical, always visible regardless of active tab */}
+ {/* Today: historical, always visible regardless of active tab.
+ budget prop lets the card label dollar amounts as approximate
+ on flat-rate plans (Pro/Max/Free) where the figure is API-
+ equivalent rather than a real charge. */}
-
+
{/* Non-Claude tab banner: explains why Usage Budget is empty.
@@ -72,7 +82,7 @@ export default function App() {
-
+
{/* History: org-scoped, always visible regardless of active tab */}
@@ -81,6 +91,14 @@ export default function App() {
+
+ {/* Drawer renders inside the same root so it inherits the panel's
+ CSS scope. handles its own portal-like overlay; we
+ only feed it open state and the close callback. */}
+ setSettingsOpen(false)}
+ />
);
}
diff --git a/entrypoints/sidepanel/components/ActiveConversation.tsx b/entrypoints/sidepanel/components/ActiveConversation.tsx
index 6e1e137..c72eccf 100644
--- a/entrypoints/sidepanel/components/ActiveConversation.tsx
+++ b/entrypoints/sidepanel/components/ActiveConversation.tsx
@@ -5,14 +5,19 @@
import React, { useState, useEffect, useRef } from 'react';
import type { ConversationRecord } from '../../../lib/conversation-store';
import type { HealthScore } from '../../../lib/health-score';
-import { formatTokens, formatCost } from '../../../lib/format';
+import type { UsageBudgetResult } from '../../../lib/message-types';
+import { formatTokens, formatApiRateCost } from '../../../lib/format';
+import { getContextWindowSize } from '../../../lib/pricing';
+import TurnTicker from './TurnTicker';
interface Props {
conv: ConversationRecord | null;
health: HealthScore | null;
+ /** Active tier: drives tier-aware cost labeling (≈$X API rate vs $X). */
+ budget: UsageBudgetResult | null;
}
-export default function ActiveConversation({ conv, health }: Props) {
+export default function ActiveConversation({ conv, health, budget }: Props) {
const [visible, setVisible] = useState(false);
const prevConvId = useRef(null);
@@ -48,8 +53,24 @@ export default function ActiveConversation({ conv, health }: Props) {
}
const subject = conv.dna?.subject || 'New conversation';
- const rawPct = conv.lastContextPct;
- const safePct = Number.isFinite(rawPct) ? Math.min(Math.max(rawPct, 0), 100) : 0;
+
+ // Compute context % from cumulative tokens, not the stored
+ // record.lastContextPct field. The overlay does the same thing for the
+ // same reason (see lib/overlay-state.ts:applyRestoredConversation): some
+ // older records were written with lastContextPct in fractional units
+ // (0.026 instead of 2.6), which renders as a flat zero bar. Tokens are
+ // always correct, so we recompute against the model's window each time.
+ //
+ // getContextWindowSize already falls back to a 200K default for unknown
+ // models (see DEFAULT_CONTEXT_WINDOW in lib/pricing.ts), so we trust
+ // its return value directly instead of restating the magic number here.
+ // Number.isFinite catches the divide-by-zero / NaN cases the helper
+ // can theoretically still produce if pricing data is corrupted.
+ const ctxWindow = getContextWindowSize(conv.model);
+ const usedTokens = conv.totalInputTokens + conv.totalOutputTokens;
+ const computedPct = (usedTokens / ctxWindow) * 100;
+ const safePct = Number.isFinite(computedPct) ? Math.min(Math.max(computedPct, 0), 100) : 0;
+
const healthLevel = health?.level ?? 'healthy';
const healthLabel = health?.label ?? 'Healthy';
@@ -77,12 +98,17 @@ export default function ActiveConversation({ conv, health }: Props) {
{Math.round(safePct)}% context
+ {/* Per-turn ticker. Renders only when at least one tracked turn
+ exists in the conversation; otherwise it silently returns
+ null and the context bar above carries the full visual. */}
+
+
{conv.turnCount} turn{conv.turnCount === 1 ? '' : 's'}
{formatTokens(conv.totalInputTokens + conv.totalOutputTokens)} tok
{showDelta
? {totalDelta.toFixed(1)}% of session
- : {formatCost(conv.estimatedCost)}
+ : {formatApiRateCost(conv.estimatedCost, budget)}
}
diff --git a/entrypoints/sidepanel/components/Header.tsx b/entrypoints/sidepanel/components/Header.tsx
index 3a3f151..cc8876d 100644
--- a/entrypoints/sidepanel/components/Header.tsx
+++ b/entrypoints/sidepanel/components/Header.tsx
@@ -1,16 +1,70 @@
// entrypoints/sidepanel/components/Header.tsx
-// Logo placeholder + title. Clean, minimal header.
+// Side panel header. Wordmark on the left, gear on the right.
+//
+// Logo is intentionally absent. Devanshu is designing the real mark and we
+// don't want to ship a placeholder sigil that competes with the eventual
+// custom letterform (the AA-merger concept). Until then the wordmark itself
+// carries the brand: thin geometric all-caps with generous tracking, set in
+// the user's system display sans so we ship zero webfont weight in this PR.
import React from 'react';
-export default function Header() {
+interface Props {
+ /** Invoked when the user clicks the gear. App.tsx wires this to the
+ * SettingsDrawer's open state. */
+ onOpenSettings: () => void;
+}
+
+export default function Header({ onOpenSettings }: Props) {
return (
-
-
Saar
+
SAAR
AI Usage Coach
+
+
+
);
}
+
+/**
+ * 18px gear glyph. Crisper outline than the typical 16px Material gear,
+ * sits at a comfortable size against the 24px Saar wordmark. Uses
+ * currentColor so it picks up muted text by default and accent on hover
+ * (rules in dashboard.css).
+ */
+function GearIcon(): React.ReactElement {
+ return (
+
+
+
+
+ );
+}
diff --git a/entrypoints/sidepanel/components/SettingsDrawer.tsx b/entrypoints/sidepanel/components/SettingsDrawer.tsx
new file mode 100644
index 0000000..3b53ddb
--- /dev/null
+++ b/entrypoints/sidepanel/components/SettingsDrawer.tsx
@@ -0,0 +1,127 @@
+// entrypoints/sidepanel/components/SettingsDrawer.tsx
+// User preferences live behind the gear icon in the header. Renders as a
+// native so we get focus trap, Escape-to-close, and inert backdrop
+// for free. Two settings ship in this PR: theme and density. Notification
+// thresholds, currency, and other coaching-flavored preferences land
+// alongside their feature commits in GET-21 / GET-22 / GET-28.
+
+import React, { useEffect, useRef } from 'react';
+import { useSettings, type ThemeChoice, type DensityChoice } from '../hooks/useSettings';
+
+interface Props {
+ open: boolean;
+ onClose: () => void;
+}
+
+/**
+ * Theme swatches rendered as a radiogroup. Order is intentional:
+ * system -> dawn -> dusk -> void
+ * mirrors a brightness gradient from "follow OS" through light to true black.
+ */
+const THEMES: { id: ThemeChoice; label: string; description: string; previewClass: string }[] = [
+ { id: 'system', label: 'system', description: 'Follow your OS', previewClass: 'lco-swatch-preview--system' },
+ { id: 'dawn', label: 'dawn', description: 'Warm light', previewClass: 'lco-swatch-preview--dawn' },
+ { id: 'dusk', label: 'dusk', description: 'Standard dark', previewClass: 'lco-swatch-preview--dusk' },
+ { id: 'void', label: 'void', description: 'OLED black', previewClass: 'lco-swatch-preview--void' },
+];
+
+const DENSITIES: { id: DensityChoice; label: string; description: string }[] = [
+ { id: 'comfortable', label: 'comfortable', description: 'Generous spacing' },
+ { id: 'compact', label: 'compact', description: 'Tighter rows' },
+];
+
+export default function SettingsDrawer({ open, onClose }: Props): React.ReactElement | null {
+ const { settings, set, ready } = useSettings();
+ const dialogRef = useRef(null);
+
+ // Show / hide the dialog imperatively so the browser handles focus trap
+ // and modal semantics. showModal() throws if already open; we guard by
+ // reading the current state.
+ useEffect(() => {
+ const dialog = dialogRef.current;
+ if (!dialog) return;
+ if (open && !dialog.open) {
+ dialog.showModal();
+ } else if (!open && dialog.open) {
+ dialog.close();
+ }
+ }, [open]);
+
+ if (!ready) return null;
+
+ return (
+
+
+
+
+
+ theme
+
+ {THEMES.map((theme) => {
+ const selected = settings.theme === theme.id;
+ return (
+ set({ theme: theme.id })}
+ type="button"
+ >
+
+ {theme.label}
+ {theme.description}
+
+ );
+ })}
+
+
+
+
+ density
+
+ {DENSITIES.map((d) => {
+ const selected = settings.density === d.id;
+ return (
+ set({ density: d.id })}
+ type="button"
+ >
+ {d.label}
+ {d.description}
+
+ );
+ })}
+
+
+
+
+ );
+}
diff --git a/entrypoints/sidepanel/components/TodayCard.tsx b/entrypoints/sidepanel/components/TodayCard.tsx
index 8ac0cae..e62ef67 100644
--- a/entrypoints/sidepanel/components/TodayCard.tsx
+++ b/entrypoints/sidepanel/components/TodayCard.tsx
@@ -1,26 +1,35 @@
// entrypoints/sidepanel/components/TodayCard.tsx
// Today's aggregate stats as a single dense row matching overlay typography.
+// Cost is rendered tier-aware: Enterprise (credit) accounts see the plain
+// dollar amount because they pay per token; Pro/Max/Free see "≈$X API rate"
+// since their plan is flat-rate and the figure is informational.
import React from 'react';
import type { DailySummary } from '../../../lib/conversation-store';
-import { formatTokens, formatCost } from '../../../lib/format';
+import type { UsageBudgetResult } from '../../../lib/message-types';
+import { formatTokens, formatApiRateCost } from '../../../lib/format';
interface Props {
summary: DailySummary | null;
+ /** Active tier; drives whether the cost is rendered as a real charge
+ * (credit) or labeled approximate (session / unsupported / null). */
+ budget: UsageBudgetResult | null;
}
-export default function TodayCard({ summary }: Props) {
+export default function TodayCard({ summary, budget }: Props) {
const conversations = summary?.conversationCount ?? 0;
const turns = summary?.totalTurns ?? 0;
const tokens = (summary?.totalInputTokens ?? 0) + (summary?.totalOutputTokens ?? 0);
const cost = summary?.estimatedCost ?? 0;
const isEmpty = !summary;
+ // The parent already labels this row,
+ // so the card itself just renders the stats. Adding a second "today"
+ // label inside would stack the word twice in the panel.
return (
- today
- {conversations} conv · {turns} turn{turns !== 1 ? 's' : ''} · {formatTokens(tokens)} tok · {formatCost(cost)}
+ {conversations} conv · {turns} turn{turns !== 1 ? 's' : ''} · {formatTokens(tokens)} tok · {formatApiRateCost(cost, budget)}
);
diff --git a/entrypoints/sidepanel/components/TurnTicker.tsx b/entrypoints/sidepanel/components/TurnTicker.tsx
new file mode 100644
index 0000000..9424b7d
--- /dev/null
+++ b/entrypoints/sidepanel/components/TurnTicker.tsx
@@ -0,0 +1,102 @@
+// entrypoints/sidepanel/components/TurnTicker.tsx
+// Per-turn cost ticker for the Active Conversation card. Each bar represents
+// one turn; height encodes the percentage of the user's session/monthly
+// utilization consumed by that turn. The ticker climbs across the row as
+// the conversation grows, making context rot literally visible.
+//
+// Color is intentionally a single accent in this PR. Health-zone coloring
+// (patina / brass / ember / rust) is owned by GET-28, which adds the
+// per-model threshold logic. Decoupling color from this component lets the
+// visualization ship independently of the threshold work.
+
+import React from 'react';
+import type { TurnRecord } from '../../../lib/conversation-store';
+
+interface Props {
+ turns: TurnRecord[];
+ /** How many recent turns to show. The card is narrow, so 12 is roughly
+ * the upper limit before bars collapse below visual threshold. */
+ maxBars?: number;
+}
+
+export default function TurnTicker({ turns, maxBars = 12 }: Props): React.ReactElement | null {
+ // Need at least one delta to draw anything meaningful. Pre-LCO-34 turns
+ // have null deltaUtilization and we filter them out: showing a ticker
+ // full of zero-height stubs would mislead more than help.
+ const tracked = turns.filter(turnHasDelta);
+ if (tracked.length === 0) return null;
+
+ const window = tracked.slice(-maxBars);
+
+ // Normalize bar heights to the tallest bar in the visible window. This
+ // keeps the visual story relative ("turn 5 was the biggest of the run")
+ // rather than absolute, which would compress everything when one outlier
+ // dominates. The 6% floor ensures every bar is visible, including the
+ // smallest non-zero turn.
+ const peak = Math.max(...window.map(t => t.deltaUtilization ?? 0), 0.01);
+ const last = window[window.length - 1];
+ const prev = window.length >= 2 ? window[window.length - 2] : null;
+ const trend = computeTrend(prev?.deltaUtilization ?? null, last.deltaUtilization ?? null);
+
+ return (
+
+
+ {window.map((turn) => {
+ const value = turn.deltaUtilization ?? 0;
+ const heightPct = peak > 0 ? Math.max((value / peak) * 100, 6) : 6;
+ return (
+
+ );
+ })}
+
+ {trend !== null && (
+
+ {trend.direction === 'up' ? '↑' : '↓'} {Math.abs(trend.percent).toFixed(2)}%
+
+ )}
+
+ );
+}
+
+/** Discriminator: keeps TypeScript happy when narrowing nullable deltas. */
+function turnHasDelta(turn: TurnRecord): turn is TurnRecord & { deltaUtilization: number } {
+ return typeof turn.deltaUtilization === 'number' && turn.deltaUtilization > 0;
+}
+
+/**
+ * Trend between the last two turns, reported as the absolute percentage-point
+ * delta in the same unit the bars are in (% of session).
+ *
+ * Earlier draft used relative percent change ((curr - prev) / prev) * 100,
+ * which explodes for micro-values: a turn going from 0.05% to 0.15% of
+ * session is a +0.10pp move but reads as "+200%" relative, which a normal
+ * user pattern-matches against context-rot warnings and panics. The bars
+ * already encode magnitude visually; the trend label only needs to add
+ * direction and the honest absolute size of the change.
+ *
+ * Suppressed when either turn is missing or when the move is below 0.01%
+ * of session, which is below the noise floor of our tokenizer estimate.
+ */
+export function computeTrend(previous: number | null, current: number | null): { direction: 'up' | 'down'; percent: number } | null {
+ if (previous === null || current === null) return null;
+ const change = current - previous;
+ if (Math.abs(change) < 0.01) return null;
+ return { direction: change >= 0 ? 'up' : 'down', percent: change };
+}
+
+/** Screen-reader summary of the ticker. The bars themselves are individually
+ * labeled and tabbable, but the container needs a one-shot summary too. */
+function describeTicker(turns: TurnRecord[]): string {
+ const last = turns[turns.length - 1].deltaUtilization ?? 0;
+ return `${turns.length} recent turns, last turn ${last.toFixed(2)} percent of session`;
+}
diff --git a/entrypoints/sidepanel/dashboard.css b/entrypoints/sidepanel/dashboard.css
index 7a5b110..e6167a9 100644
--- a/entrypoints/sidepanel/dashboard.css
+++ b/entrypoints/sidepanel/dashboard.css
@@ -2,46 +2,180 @@
Side panel dashboard styles. Uses CSS custom properties for theming.
Claude-aligned: warm terra cotta accent, Pampas palette, minimal. */
-/* ── Reset & Variables ──────────────────────────────────────────────────────── */
-
+/* ── Brand palette (workshop tokens) ─────────────────────────────────────────
+ Two-layer color system. The brand layer below uses semantic names so the
+ CSS reads like a hardware-store paint receipt; the surface layer further
+ down maps those into bg / text / border roles per theme. Replacing a
+ palette token here propagates through every component without grepping
+ for hex codes. */
+:root {
+ /* Accent: terra cotta is the only signature color. Sienna is its hover. */
+ --lco-terracotta: #c15f3c;
+ --lco-sienna: #a84f2f;
+
+ /* Health zones: workshop earth tones replacing 2014-era Material colors.
+ Patina (operational), brass (moderate), ember (tight), rust (critical).
+ Verified against WCAG AA when used as 8px+ graphic shapes; not used as
+ body text on either surface. */
+ --lco-patina: #5a7a5e;
+ --lco-brass: #b08858;
+ --lco-ember: #cc6b3d;
+ --lco-rust: #8e3d2a;
+
+ /* Surfaces. Bone and linen are light; charcoal and slate are dark.
+ No pure white, no pure black: the eye reads warmth at low contrast
+ far longer than it tolerates clinical FFFFFF. Ash is the muted-text
+ neutral that sits between body text and background on either side. */
+ --lco-bone: #f5f1ea;
+ --lco-linen: #e8e2d4;
+ --lco-charcoal: #1c1a18;
+ --lco-slate: #2a2825;
+ --lco-ash: #8b857c;
+}
+
+/* ── Surface tokens (mapped from palette) ───────────────────────────────────
+ These are the names component CSS reaches for. Light defaults below;
+ prefers-color-scheme: dark and explicit data-theme overrides below that. */
:root {
- --lco-bg: #ffffff;
- --lco-bg-card: #f8f7f5;
- --lco-bg-hover: #f0efed;
- --lco-text: #1a1a1a;
- --lco-text-secondary: #6b6b6b;
- --lco-text-muted: #a0a0a0;
- --lco-border: #e5e3df;
- --lco-accent: #c15f3c;
+ --lco-bg: var(--lco-bone);
+ --lco-bg-card: var(--lco-linen);
+ --lco-bg-hover: #ddd5c8;
+ --lco-text: var(--lco-charcoal);
+ --lco-text-secondary: #6b6660;
+ --lco-text-muted: var(--lco-ash);
+ --lco-border: rgba(28, 26, 24, 0.10);
+
+ --lco-accent: var(--lco-terracotta);
--lco-accent-light: rgba(193, 95, 60, 0.10);
- --lco-health-green: #4caf50;
- --lco-health-yellow: #f5a623;
- --lco-health-orange: #ff9800;
- --lco-health-red: #e53935;
- --lco-context-fill: #c15f3c;
- --lco-context-track: #e5e3df;
- --lco-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
- --lco-font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
+
+ /* Health tokens kept as legacy names so existing components keep working
+ through this commit; future work points at the workshop names directly. */
+ --lco-health-green: var(--lco-patina);
+ --lco-health-yellow: var(--lco-brass);
+ --lco-health-orange: var(--lco-ember);
+ --lco-health-red: var(--lco-rust);
+
+ --lco-context-fill: var(--lco-terracotta);
+ --lco-context-track: rgba(28, 26, 24, 0.10);
+
+ /* Three font roles. We ship system fallbacks only for now; the actual
+ Fraunces / IBM Plex faces are loaded in a follow-up issue (GET-32) so
+ this PR doesn't pull in webfont weight. The cascade is in place: drop
+ the Fraunces and Plex woff2 files into public/fonts/ later, register
+ them with @font-face, and the hierarchy here picks them up unchanged. */
+ --lco-font-display: 'Fraunces', 'Newsreader', Georgia, 'Times New Roman', serif;
+ --lco-font: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+ --lco-font-mono: 'IBM Plex Mono', 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
--lco-radius: 8px;
--lco-radius-sm: 4px;
--lco-transition: 200ms ease;
}
+/* ── Typography utility classes ────────────────────────────────────────────
+ Four roles map onto every metric in the panel. Size jumps are 3x+ from
+ .lco-micro to .lco-hero so the eye lands on hero numbers first, then
+ reads supporting data, then labels. */
+
+.lco-hero {
+ font-family: var(--lco-font-display);
+ font-weight: 200; /* extreme light: editorial, instrument-like */
+ font-size: 40px;
+ letter-spacing: -0.03em;
+ line-height: 0.95;
+ color: var(--lco-text);
+ font-variant-numeric: tabular-nums;
+}
+.lco-hero sup {
+ font-size: 0.42em;
+ font-weight: 400;
+ vertical-align: 0.55em;
+ margin-left: 0.08em;
+ color: var(--lco-text-secondary);
+ letter-spacing: 0;
+}
+
+.lco-section {
+ font-family: var(--lco-font-display);
+ font-style: italic; /* lowercase italic carries the "essay" voice */
+ font-weight: 400;
+ font-size: 14px;
+ letter-spacing: 0;
+ text-transform: none;
+ color: var(--lco-text);
+}
+
+.lco-label {
+ font-family: var(--lco-font-mono);
+ font-weight: 500;
+ font-size: 9px;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: var(--lco-text-muted);
+}
+
+.lco-data {
+ font-family: var(--lco-font-mono);
+ font-size: 11px;
+ color: var(--lco-text-secondary);
+ font-variant-numeric: tabular-nums;
+}
+
@media (prefers-color-scheme: dark) {
:root {
- --lco-bg: #1a1a1a;
- --lco-bg-card: #242424;
- --lco-bg-hover: #2e2e2e;
- --lco-text: #e8e6e3;
- --lco-text-secondary: #a0a0a0;
- --lco-text-muted: #6b6b6b;
- --lco-border: #333333;
- --lco-accent: #c15f3c;
+ --lco-bg: var(--lco-charcoal);
+ --lco-bg-card: var(--lco-slate);
+ --lco-bg-hover: #34322e;
+ --lco-text: var(--lco-bone);
+ --lco-text-secondary: #b8b1a4;
+ --lco-text-muted: var(--lco-ash);
+ --lco-border: rgba(245, 241, 234, 0.12);
+ --lco-accent: var(--lco-terracotta);
--lco-accent-light: rgba(193, 95, 60, 0.15);
- --lco-context-track: #333333;
+ --lco-context-track: rgba(245, 241, 234, 0.12);
}
}
+/* ── Explicit theme overrides ──────────────────────────────────────────────
+ data-theme on the documentElement beats prefers-color-scheme. The settings
+ drawer (see useSettings.ts) sets one of: 'system' (no override; falls
+ through to the media query above), 'dawn' (light), 'dusk' (standard dark),
+ 'void' (true OLED black with slate cards for amoled-friendly viewing). */
+
+:root[data-theme='dawn'] {
+ --lco-bg: var(--lco-bone);
+ --lco-bg-card: var(--lco-linen);
+ --lco-bg-hover: #ddd5c8;
+ --lco-text: var(--lco-charcoal);
+ --lco-text-secondary: #6b6660;
+ --lco-text-muted: var(--lco-ash);
+ --lco-border: rgba(28, 26, 24, 0.10);
+ --lco-context-track: rgba(28, 26, 24, 0.10);
+}
+
+:root[data-theme='dusk'] {
+ --lco-bg: var(--lco-charcoal);
+ --lco-bg-card: var(--lco-slate);
+ --lco-bg-hover: #34322e;
+ --lco-text: var(--lco-bone);
+ --lco-text-secondary: #b8b1a4;
+ --lco-text-muted: var(--lco-ash);
+ --lco-border: rgba(245, 241, 234, 0.12);
+ --lco-context-track: rgba(245, 241, 234, 0.12);
+}
+
+:root[data-theme='void'] {
+ /* True black for OLED panels. Cards stay slate so contrast against the
+ page is preserved instead of cards disappearing into the page bg. */
+ --lco-bg: #000000;
+ --lco-bg-card: var(--lco-slate);
+ --lco-bg-hover: #1a1816;
+ --lco-text: var(--lco-bone);
+ --lco-text-secondary: #b8b1a4;
+ --lco-text-muted: var(--lco-ash);
+ --lco-border: rgba(245, 241, 234, 0.10);
+ --lco-context-track: rgba(245, 241, 234, 0.10);
+}
+
* {
margin: 0;
padding: 0;
@@ -101,25 +235,60 @@ body {
margin-bottom: 12px;
}
-.lco-dash-logo {
- width: 36px;
- height: 36px;
- border-radius: var(--lco-radius);
- border: 2px dashed var(--lco-border);
+/* Title block grows to fill the row so the gear stays right-aligned. */
+.lco-dash-header-text {
+ flex: 1;
+ min-width: 0;
+}
+
+/* Settings trigger. Quiet by default (muted icon), warms to terra cotta
+ on hover. Keyboard focus gets the same ring used elsewhere on the panel. */
+.lco-dash-header-gear {
+ background: none;
+ border: none;
+ padding: 6px;
+ cursor: pointer;
+ color: var(--lco-text-muted);
+ border-radius: var(--lco-radius-sm);
+ transition: color var(--lco-transition), background var(--lco-transition);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
flex-shrink: 0;
}
+.lco-dash-header-gear:hover {
+ color: var(--lco-accent);
+ background: var(--lco-bg-hover);
+}
+.lco-dash-header-gear:focus-visible {
+ outline: 2px solid var(--lco-accent);
+ outline-offset: 2px;
+}
+/* Wordmark placeholder until the custom letterform lands. Thin geometric
+ sans at wide tracking gestures toward the eventual logo direction (the
+ AA-merger concept) without committing to it. We use a system-geometric
+ stack so nothing has to download; SF Pro Display on macOS, Inter on
+ anything that has it, system-ui as the floor. Weight 300 reads light
+ enough to feel architectural without dropping into hairline territory
+ that would smear at 96dpi. */
.lco-dash-title {
- font-size: 18px;
- font-weight: 700;
- letter-spacing: -0.02em;
+ font-family: 'SF Pro Display', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
+ font-size: 28px;
+ font-weight: 300;
+ letter-spacing: 0.18em;
+ text-transform: uppercase;
color: var(--lco-text);
+ line-height: 1;
}
.lco-dash-subtitle {
- font-size: 11px;
+ font-family: var(--lco-font-mono);
+ font-size: 9px;
color: var(--lco-text-muted);
- letter-spacing: 0.02em;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ margin-top: 2px;
}
/* ── Collapsible sections ───────────────────────────────────────────────────── */
@@ -137,12 +306,15 @@ body {
background: none;
border: none;
cursor: pointer;
- font-family: var(--lco-font);
- font-size: 12px;
- font-weight: 600;
- color: var(--lco-text-secondary);
- text-transform: uppercase;
- letter-spacing: 0.06em;
+ /* Lowercase italic display face: editorial voice rather than a corporate
+ uppercase letterspaced tag. Pairs with the chevron affordance. */
+ font-family: var(--lco-font-display);
+ font-style: italic;
+ font-size: 14px;
+ font-weight: 400;
+ color: var(--lco-text);
+ text-transform: lowercase;
+ letter-spacing: 0;
border-radius: var(--lco-radius-sm);
transition: background var(--lco-transition);
}
@@ -284,19 +456,33 @@ body {
/* No transition: health state changes snap instantly (rare, not per-frame). */
}
-.lco-dash-health-dot--healthy { background: var(--lco-health-green); box-shadow: 0 0 4px rgba(76, 175, 80, 0.4); }
-.lco-dash-health-dot--degrading { background: var(--lco-health-yellow); box-shadow: 0 0 4px rgba(245, 166, 35, 0.4); }
-.lco-dash-health-dot--critical { background: var(--lco-health-red); animation: lco-dot-pulse 2s ease-in-out infinite; }
+/* Workshop palette: warm earth tones glow ~40% alpha to match the dot fill.
+ Critical pulses on its own (see lco-dot-pulse keyframes below). */
+.lco-dash-health-dot--healthy { background: var(--lco-patina); box-shadow: 0 0 4px rgba(90, 122, 94, 0.4); }
+.lco-dash-health-dot--degrading { background: var(--lco-brass); box-shadow: 0 0 4px rgba(176, 136, 88, 0.4); }
+.lco-dash-health-dot--critical { background: var(--lco-rust); animation: lco-dot-pulse 2s ease-in-out infinite; }
+/* Health label sits with the dot. Mono uppercase gives it a status-bar feel:
+ a verdict, not a sentence. */
.lco-dash-health-label {
+ font-family: var(--lco-font-mono);
font-size: 10px;
font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0.10em;
color: var(--lco-text-secondary);
}
+/* Subject sits as a regular sans line, not display italic. Earlier draft
+ used italic display here; in real use it competed with the budget hero
+ ($X of $Y) and the section heads, all set in the same italic display
+ face. The eye lost its anchor. Sans medium reads as the conversation's
+ working title without claiming hero weight that belongs to the dollar
+ figure. Size held at 13px so it still anchors the card body. */
.lco-dash-active-subject {
- font-size: 12px;
+ font-family: var(--lco-font);
font-weight: 500;
+ font-size: 13px;
color: var(--lco-text);
margin-bottom: 10px;
line-height: 1.4;
@@ -328,9 +514,9 @@ body {
transition: transform 300ms cubic-bezier(0.4, 0, 0.2, 1);
}
-.lco-dash-context-fill--healthy { background: var(--lco-health-green); box-shadow: 0 0 4px rgba(76, 175, 80, 0.3); }
-.lco-dash-context-fill--degrading { background: var(--lco-health-yellow); box-shadow: 0 0 4px rgba(245, 166, 35, 0.3); }
-.lco-dash-context-fill--critical { background: var(--lco-health-red); box-shadow: 0 0 4px rgba(229, 57, 53, 0.3); }
+.lco-dash-context-fill--healthy { background: var(--lco-patina); box-shadow: 0 0 4px rgba(90, 122, 94, 0.3); }
+.lco-dash-context-fill--degrading { background: var(--lco-brass); box-shadow: 0 0 4px rgba(176, 136, 88, 0.3); }
+.lco-dash-context-fill--critical { background: var(--lco-rust); box-shadow: 0 0 4px rgba(142, 61, 42, 0.3); }
.lco-dash-context-label {
font-size: 11px;
@@ -346,6 +532,66 @@ body {
font-variant-numeric: tabular-nums;
}
+/* ── Per-turn ticker ────────────────────────────────────────────────────────
+ Vertical bars, one per recorded turn. Height encodes session %; color is
+ a single terra cotta tint for now (zone coloring lands with GET-28). The
+ visualization makes context rot legible: bars climb as the conversation
+ grows because each turn pulls in more history.
+
+ Sits between the context bar and the stats line so the eye reads:
+ verdict -> subject -> %context -> per-turn shape -> totals
+ in a top-to-bottom decay from coarsest to finest. */
+.lco-ticker {
+ display: flex;
+ align-items: flex-end;
+ gap: 8px;
+ height: 36px;
+ margin: 6px 0 10px;
+}
+
+.lco-ticker-bars {
+ display: flex;
+ align-items: flex-end;
+ gap: 3px;
+ flex: 1;
+ height: 100%;
+}
+
+.lco-ticker-bar {
+ flex: 1;
+ min-width: 4px;
+ min-height: 2px;
+ background: var(--lco-accent);
+ border-radius: 1px;
+ /* Height transition is GPU-friendly enough at 12 elements; we still
+ gate it behind reduced-motion for users who prefer instant updates. */
+ transition: height 360ms cubic-bezier(0.16, 1, 0.3, 1);
+ will-change: height;
+ cursor: default;
+}
+
+.lco-ticker-bar:focus-visible {
+ outline: 2px solid var(--lco-accent);
+ outline-offset: 1px;
+ border-radius: 1px;
+}
+
+.lco-ticker-trend {
+ font-family: var(--lco-font-mono);
+ font-size: 10px;
+ font-variant-numeric: tabular-nums;
+ color: var(--lco-text-muted);
+ align-self: flex-start;
+ padding-top: 2px;
+ flex-shrink: 0;
+}
+.lco-ticker-trend--up { color: var(--lco-ember); }
+.lco-ticker-trend--down { color: var(--lco-patina); }
+
+@media (prefers-reduced-motion: reduce) {
+ .lco-ticker-bar { transition: none; }
+}
+
/* Dot separators between stat spans; avoids hardcoding · in JSX. */
.lco-dash-active-stats > span + span::before {
content: ' · ';
@@ -399,24 +645,33 @@ body {
flex-shrink: 0;
}
-.lco-dash-budget-dot--comfortable { background: var(--lco-health-green); box-shadow: 0 0 4px rgba(76, 175, 80, 0.4); }
-.lco-dash-budget-dot--moderate { background: var(--lco-health-yellow); box-shadow: 0 0 4px rgba(245, 166, 35, 0.4); }
-.lco-dash-budget-dot--tight { background: var(--lco-health-orange); box-shadow: 0 0 4px rgba(255, 152, 0, 0.4); }
-.lco-dash-budget-dot--critical { background: var(--lco-health-red); box-shadow: 0 0 4px rgba(229, 57, 53, 0.4); }
+.lco-dash-budget-dot--comfortable { background: var(--lco-patina); box-shadow: 0 0 4px rgba(90, 122, 94, 0.4); }
+.lco-dash-budget-dot--moderate { background: var(--lco-brass); box-shadow: 0 0 4px rgba(176, 136, 88, 0.4); }
+.lco-dash-budget-dot--tight { background: var(--lco-ember); box-shadow: 0 0 4px rgba(204, 107, 61, 0.4); }
+.lco-dash-budget-dot--critical { background: var(--lco-rust); box-shadow: 0 0 4px rgba(142, 61, 42, 0.4); }
.lco-dash-budget-zone-label {
+ font-family: var(--lco-font-mono);
font-size: 10px;
font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0.10em;
color: var(--lco-text-secondary);
}
-/* Primary status line: "11% used; resets in 53 min" */
+/* Primary status line: "11% used; resets in 53 min" / "$83.77 of $300 spent".
+ Display face at 17px upright (no italic). This is the only true hero on
+ the card and now stands alone: subject demoted to sans so this line
+ doesn't compete for the eye anymore. */
.lco-dash-budget-status {
- font-size: 11px;
- font-weight: 500;
+ font-family: var(--lco-font-display);
+ font-weight: 400;
+ font-size: 17px;
color: var(--lco-text);
- margin-bottom: 10px;
- line-height: 1.4;
+ margin-bottom: 12px;
+ line-height: 1.3;
+ letter-spacing: -0.015em;
+ font-variant-numeric: tabular-nums;
}
/* Session and weekly bar rows */
@@ -453,10 +708,10 @@ body {
transition: transform 300ms cubic-bezier(0.4, 0, 0.2, 1);
}
-.lco-dash-budget-fill--comfortable { background: var(--lco-health-green); box-shadow: 0 0 4px rgba(76, 175, 80, 0.3); }
-.lco-dash-budget-fill--moderate { background: var(--lco-health-yellow); box-shadow: 0 0 4px rgba(245, 166, 35, 0.3); }
-.lco-dash-budget-fill--tight { background: var(--lco-health-orange); box-shadow: 0 0 4px rgba(255, 152, 0, 0.3); }
-.lco-dash-budget-fill--critical { background: var(--lco-health-red); box-shadow: 0 0 4px rgba(229, 57, 53, 0.3); }
+.lco-dash-budget-fill--comfortable { background: var(--lco-patina); box-shadow: 0 0 4px rgba(90, 122, 94, 0.3); }
+.lco-dash-budget-fill--moderate { background: var(--lco-brass); box-shadow: 0 0 4px rgba(176, 136, 88, 0.3); }
+.lco-dash-budget-fill--tight { background: var(--lco-ember); box-shadow: 0 0 4px rgba(204, 107, 61, 0.3); }
+.lco-dash-budget-fill--critical { background: var(--lco-rust); box-shadow: 0 0 4px rgba(142, 61, 42, 0.3); }
.lco-dash-budget-row-pct {
font-size: 11px;
@@ -692,8 +947,8 @@ body {
/* ── Animations ─────────────────────────────────────────────────────────────── */
@keyframes lco-dot-pulse {
- 0%, 100% { box-shadow: 0 0 4px rgba(229, 57, 53, 0.4); }
- 50% { box-shadow: 0 0 10px rgba(229, 57, 53, 0.7); }
+ 0%, 100% { box-shadow: 0 0 4px rgba(142, 61, 42, 0.4); }
+ 50% { box-shadow: 0 0 10px rgba(142, 61, 42, 0.7); }
}
@keyframes lco-dash-slide-in {
@@ -736,4 +991,224 @@ body {
.lco-dash-feedback-send {
transition: none;
}
+ .lco-settings,
+ .lco-swatch,
+ .lco-density-option {
+ transition: none;
+ }
+}
+
+/* ── Settings drawer ────────────────────────────────────────────────────────
+ Renders as a native . The browser owns focus trap, Escape key,
+ and inert-backdrop semantics; we only style the surface. The drawer fills
+ the panel rather than sliding in from the side because the panel is too
+ narrow (320-400px) for a side-slide to feel like a drawer rather than a
+ takeover. */
+
+.lco-settings {
+ width: 100%;
+ max-width: 360px;
+ margin: auto;
+ border: 1px solid var(--lco-border);
+ border-radius: var(--lco-radius);
+ background: var(--lco-bg-card);
+ color: var(--lco-text);
+ padding: 0;
+ box-shadow: 0 24px 60px rgba(0, 0, 0, 0.20);
+}
+
+.lco-settings::backdrop {
+ background: rgba(0, 0, 0, 0.35);
+ backdrop-filter: blur(2px);
+}
+
+.lco-settings-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 14px 16px;
+ border-bottom: 1px solid var(--lco-border);
+}
+
+.lco-settings-title {
+ /* Reuses .lco-section styling (lowercase italic display face) so the
+ drawer header reads like every other section heading on the panel. */
+ margin: 0;
+}
+
+.lco-settings-close {
+ background: none;
+ border: none;
+ cursor: pointer;
+ color: var(--lco-text-muted);
+ font-size: 14px;
+ padding: 4px 8px;
+ border-radius: var(--lco-radius-sm);
+ transition: color var(--lco-transition), background var(--lco-transition);
+}
+.lco-settings-close:hover {
+ color: var(--lco-text);
+ background: var(--lco-bg-hover);
+}
+.lco-settings-close:focus-visible {
+ outline: 2px solid var(--lco-accent);
+ outline-offset: 2px;
+}
+
+.lco-settings-body {
+ padding: 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+}
+
+.lco-settings-block {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+/* Theme swatches: 2x2 grid. Each swatch shows a color preview, label, and
+ short description. Selected one gets the accent ring. */
+.lco-swatches {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 8px;
+}
+
+.lco-swatch {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 4px;
+ padding: 10px;
+ background: var(--lco-bg);
+ border: 1px solid var(--lco-border);
+ border-radius: var(--lco-radius-sm);
+ cursor: pointer;
+ text-align: left;
+ transition: border-color var(--lco-transition), background var(--lco-transition);
+ font-family: var(--lco-font);
+ color: inherit;
+}
+.lco-swatch:hover {
+ background: var(--lco-bg-hover);
+}
+.lco-swatch:focus-visible {
+ outline: 2px solid var(--lco-accent);
+ outline-offset: 2px;
+}
+.lco-swatch--on {
+ border-color: var(--lco-accent);
+ box-shadow: inset 0 0 0 1px var(--lco-accent);
+}
+
+.lco-swatch-preview {
+ width: 100%;
+ height: 28px;
+ border-radius: 3px;
+ border: 1px solid var(--lco-border);
+}
+
+/* Per-theme preview tiles. The system tile uses a half-light, half-dark
+ diagonal so the user can see at a glance that it follows the OS. */
+.lco-swatch-preview--system {
+ background: linear-gradient(135deg, #f5f1ea 0%, #f5f1ea 49%, #1c1a18 51%, #1c1a18 100%);
+}
+.lco-swatch-preview--dawn { background: #f5f1ea; }
+.lco-swatch-preview--dusk { background: #1c1a18; }
+.lco-swatch-preview--void { background: #000000; }
+
+.lco-swatch-label {
+ font-family: var(--lco-font-display);
+ font-style: italic;
+ font-size: 13px;
+ color: var(--lco-text);
+}
+
+.lco-swatch-desc {
+ font-family: var(--lco-font-mono);
+ font-size: 9px;
+ color: var(--lco-text-muted);
+ letter-spacing: 0.06em;
+}
+
+/* Density options stack vertically, each with a description. Same selection
+ pattern as theme swatches but laid out as full-width rows. */
+.lco-density-options {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.lco-density-option {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 2px;
+ padding: 10px;
+ background: var(--lco-bg);
+ border: 1px solid var(--lco-border);
+ border-radius: var(--lco-radius-sm);
+ cursor: pointer;
+ text-align: left;
+ transition: border-color var(--lco-transition), background var(--lco-transition);
+ font-family: var(--lco-font);
+ color: inherit;
+}
+.lco-density-option:hover {
+ background: var(--lco-bg-hover);
+}
+.lco-density-option:focus-visible {
+ outline: 2px solid var(--lco-accent);
+ outline-offset: 2px;
+}
+.lco-density-option--on {
+ border-color: var(--lco-accent);
+ box-shadow: inset 0 0 0 1px var(--lco-accent);
+}
+
+.lco-density-label-text {
+ font-family: var(--lco-font-display);
+ font-style: italic;
+ font-size: 14px;
+ color: var(--lco-text);
+}
+
+.lco-density-desc {
+ font-family: var(--lco-font-mono);
+ font-size: 9px;
+ color: var(--lco-text-muted);
+ letter-spacing: 0.06em;
+}
+
+/* Compact density variant: tightens vertical rhythm across the panel. The
+ intent is to roughly halve interior gaps for power users who want more
+ info per glance without changing typography. */
+:root[data-density='compact'] .lco-dash {
+ padding: 12px 10px;
+}
+:root[data-density='compact'] .lco-dash-section {
+ margin-bottom: 4px;
+}
+:root[data-density='compact'] .lco-dash-section-inner {
+ padding: 2px 0 4px;
+}
+:root[data-density='compact'] .lco-dash-active {
+ padding: 4px 0;
+}
+:root[data-density='compact'] .lco-dash-active-subject {
+ margin-bottom: 6px;
+}
+:root[data-density='compact'] .lco-dash-context-bar-container {
+ margin-bottom: 6px;
+}
+:root[data-density='compact'] .lco-dash-budget-status {
+ margin-bottom: 8px;
+}
+:root[data-density='compact'] .lco-dash-budget-row {
+ margin-bottom: 4px;
+}
+:root[data-density='compact'] .lco-dash-budget-resets {
+ margin-top: 4px;
}
diff --git a/entrypoints/sidepanel/hooks/useSettings.ts b/entrypoints/sidepanel/hooks/useSettings.ts
new file mode 100644
index 0000000..256c9d7
--- /dev/null
+++ b/entrypoints/sidepanel/hooks/useSettings.ts
@@ -0,0 +1,123 @@
+// entrypoints/sidepanel/hooks/useSettings.ts
+// Persists user preferences for the side panel and applies them to the
+// document. Theme overrides prefers-color-scheme by writing data-theme on
+// documentElement; density toggles the spacing scale via data-density.
+//
+// Storage shape: a single object under `lco_settings` in chrome.storage.local
+// so future settings can be added without schema migrations: missing fields
+// fall back to DEFAULTS on read, and writes go through `set` which merges.
+
+import { useEffect, useState, useCallback } from 'react';
+
+export type ThemeChoice = 'system' | 'dawn' | 'dusk' | 'void';
+export type DensityChoice = 'comfortable' | 'compact';
+
+export interface Settings {
+ theme: ThemeChoice;
+ density: DensityChoice;
+}
+
+const STORAGE_KEY = 'lco_settings';
+
+const DEFAULTS: Settings = {
+ theme: 'system',
+ density: 'comfortable',
+};
+
+// Single source of truth for the legal enum values. Used both for the
+// runtime sanity check on stored values (rejecting garbage written by
+// a future migration or another extension) and as the type guard above.
+const VALID_THEMES: ReadonlyArray = ['system', 'dawn', 'dusk', 'void'];
+const VALID_DENSITIES: ReadonlyArray = ['comfortable', 'compact'];
+
+/**
+ * Coerces an unknown value (typically the contents of chrome.storage.local
+ * for our key) into a Settings object, falling back to DEFAULTS for any
+ * field that isn't a recognized enum value. Setting an attribute on
+ * documentElement.dataset is not an XSS vector by itself, but unbounded
+ * values would let downstream attribute selectors pick up garbage; the
+ * enum check stops that at the boundary.
+ */
+function sanitize(stored: unknown): Settings {
+ if (!stored || typeof stored !== 'object') return DEFAULTS;
+ const obj = stored as Record;
+ const theme = VALID_THEMES.includes(obj.theme as ThemeChoice)
+ ? (obj.theme as ThemeChoice)
+ : DEFAULTS.theme;
+ const density = VALID_DENSITIES.includes(obj.density as DensityChoice)
+ ? (obj.density as DensityChoice)
+ : DEFAULTS.density;
+ return { theme, density };
+}
+
+/**
+ * Read + persist user settings. Applies the chosen theme/density to
+ * documentElement.dataset so dashboard.css's :root[data-theme='...'] and
+ * :root[data-density='...'] rules take effect. The hook stays minimal on
+ * purpose; coaching-related settings (notification thresholds, currency
+ * display) belong with their feature commits in GET-21 / GET-22 / GET-28.
+ */
+export function useSettings(): {
+ settings: Settings;
+ set: (patch: Partial) => void;
+ ready: boolean;
+} {
+ const [settings, setSettings] = useState(DEFAULTS);
+ const [ready, setReady] = useState(false);
+
+ // Load once on mount. We do not subscribe to chrome.storage.onChanged
+ // because the side panel is a single tab; concurrent edits from other
+ // surfaces are not a concern in v1.
+ //
+ // The catch matters: without it, a storage read failure (corrupt key,
+ // quota probe, extension restart mid-read) would leave `ready` false
+ // forever and SettingsDrawer would silently render null. Falling back
+ // to DEFAULTS lets the UI come up with sensible values; subsequent
+ // writes still go through and may succeed.
+ useEffect(() => {
+ chrome.storage.local.get(STORAGE_KEY)
+ .then((result) => {
+ setSettings(sanitize(result[STORAGE_KEY]));
+ })
+ .catch((err) => {
+ console.warn('[LCO] Settings read failed, using defaults:', err);
+ setSettings(DEFAULTS);
+ })
+ .finally(() => {
+ setReady(true);
+ });
+ }, []);
+
+ // Reflect settings onto documentElement so the CSS overrides take effect.
+ // Skipped until the first read completes so we don't briefly write the
+ // default theme over what the user actually chose.
+ //
+ // For theme === 'system' we DELETE the attribute rather than set it,
+ // so the cascade falls cleanly through to prefers-color-scheme.
+ // dashboard.css only has rules for explicit themes ('dawn'/'dusk'/'void');
+ // a stray data-theme='system' attribute on documentElement worked only
+ // by accident (no rule matched, so the prefers-color-scheme block stayed
+ // active). Removing the attribute makes the intent explicit.
+ useEffect(() => {
+ if (!ready) return;
+ if (settings.theme === 'system') {
+ delete document.documentElement.dataset.theme;
+ } else {
+ document.documentElement.dataset.theme = settings.theme;
+ }
+ document.documentElement.dataset.density = settings.density;
+ }, [ready, settings.theme, settings.density]);
+
+ const set = useCallback((patch: Partial) => {
+ setSettings((prev) => {
+ const next = { ...prev, ...patch };
+ chrome.storage.local.set({ [STORAGE_KEY]: next }).catch(() => {
+ // Persistence failure is non-fatal: the in-memory state still
+ // applies for this session. A future write attempt may succeed.
+ });
+ return next;
+ });
+ }, []);
+
+ return { settings, set, ready };
+}
diff --git a/lib/context-intelligence.ts b/lib/context-intelligence.ts
index 5b20f60..16a5989 100644
--- a/lib/context-intelligence.ts
+++ b/lib/context-intelligence.ts
@@ -2,7 +2,7 @@
// Pure context analysis: no DOM refs, no chrome APIs, no side effects.
// Analyzes ConversationState and returns ContextSignal[] for the nudge system.
-// Threshold constants — never use magic numbers in callers.
+// Threshold constants. Never use magic numbers in callers.
export const CONTEXT_THRESHOLD_INFO = 60; // % at which responses start losing early details
export const CONTEXT_THRESHOLD_WARNING = 75; // % at which a new conversation is advisable
export const CONTEXT_THRESHOLD_CRITICAL = 90; // % at which degradation is near-certain
@@ -57,7 +57,7 @@ export function analyzeContext(state: ConversationState): ContextSignal[] {
const signals: ContextSignal[] = [];
const { contextPct, turnCount, contextHistory } = state;
- // 1. Threshold alerts — only the highest applicable severity fires.
+ // 1. Threshold alerts: only the highest applicable severity fires.
if (contextPct >= CONTEXT_THRESHOLD_CRITICAL) {
signals.push({
type: 'threshold',
@@ -81,7 +81,7 @@ export function analyzeContext(state: ConversationState): ContextSignal[] {
});
}
- // 2. Growth rate warning — fires when average upward growth exceeds threshold.
+ // 2. Growth rate warning: fires when average upward growth exceeds threshold.
const avgGrowth = computeAverageGrowth(contextHistory);
if (avgGrowth !== null && avgGrowth > GROWTH_RATE_WARN_PCT) {
const remaining = Math.max(0, Math.round((100 - contextPct) / avgGrowth));
@@ -93,7 +93,7 @@ export function analyzeContext(state: ConversationState): ContextSignal[] {
});
}
- // 3. Stale conversation — long thread with substantial context consumption.
+ // 3. Stale conversation: long thread with substantial context consumption.
if (turnCount > STALE_MIN_TURNS && contextPct > STALE_MIN_CONTEXT_PCT) {
signals.push({
type: 'stale_conversation',
@@ -103,7 +103,7 @@ export function analyzeContext(state: ConversationState): ContextSignal[] {
});
}
- // 4. Project hint — ongoing work pattern: enough turns, meaningful context, net growth.
+ // 4. Project hint: ongoing work pattern with enough turns, meaningful context, and net growth.
if (turnCount > PROJECT_HINT_MIN_TURNS && contextPct >= PROJECT_HINT_MIN_CONTEXT_PCT) {
const netGrowth = contextHistory.length >= 2
? contextHistory[contextHistory.length - 1] - contextHistory[0]
diff --git a/lib/format.ts b/lib/format.ts
index 9a4ed1c..e42af02 100644
--- a/lib/format.ts
+++ b/lib/format.ts
@@ -2,6 +2,8 @@
// Pure formatting utilities shared across overlay, dashboard, and handoff-summary.
// No DOM refs, no chrome.* APIs.
+import type { UsageBudgetResult } from './message-types';
+
/**
* Format token count for compact display.
* 1234 -> "1.2k", 1234567 -> "1.2M", 500 -> "500"
@@ -33,6 +35,38 @@ export function formatCost(cost: number | null, decimals: number = 2): string {
return `$${cost.toFixed(decimals)}`;
}
+/**
+ * Tier-aware cost formatter. On flat-rate plans (Pro / Max / Free, all of
+ * which dispatch to the `session` budget variant) Anthropic does not bill
+ * the user per token; the dollar figure we display is the API-equivalent
+ * cost, useful as a relative anchor but technically not a charge.
+ *
+ * Earlier draft suffixed these readings with "API rate"; first-look feedback
+ * was that the label competed with the figure and read as jargon. The "≈"
+ * symbol carries the same meaning more quietly: the value is approximate
+ * because it is computed from API rates, not billed against the user's plan.
+ *
+ * On credit accounts (Enterprise) the dollar figure is real spend against
+ * the monthly pool, so we render it plain. Pass `null` for budget when the
+ * tier is genuinely unknown (no usage endpoint reading yet); we
+ * conservatively label it as approximate.
+ *
+ * @param cost cost in dollars, or null for unknown-model fallback
+ * @param budget current usage budget result (or its `kind` discriminator)
+ * @param decimals decimal precision (default 2; auto-promotes to 4 on micro amounts)
+ */
+export function formatApiRateCost(
+ cost: number | null,
+ budget: UsageBudgetResult | { kind: UsageBudgetResult['kind'] } | null,
+ decimals: number = 2,
+): string {
+ const base = formatCost(cost, decimals);
+ if (cost === null) return base; // already reads as "$0.00*"
+ const kind = budget?.kind ?? null;
+ if (kind === 'credit') return base; // Enterprise pays per token; figure is real
+ return `≈${base}`; // session, unsupported, or unknown
+}
+
/**
* Format a model identifier for human display.
* "claude-sonnet-4-6" -> "Sonnet 4.6"
diff --git a/lib/message-types.ts b/lib/message-types.ts
index 8c3f6f4..51ea460 100644
--- a/lib/message-types.ts
+++ b/lib/message-types.ts
@@ -328,7 +328,7 @@ export interface UsageBudgetCredit {
utilizationPct: number;
/** ISO 4217 currency code from the endpoint (e.g. "USD"). */
currency: string;
- /** "Resets May 1" — first day of next calendar month, locale-formatted. */
+ /** "Resets May 1": first day of next calendar month, locale-formatted. */
resetLabel: string;
/** Zone derived from utilizationPct. Drives bar color even though there is no second window. */
zone: BudgetZone;
diff --git a/lib/overlay-state.ts b/lib/overlay-state.ts
index 56f7a20..9faa339 100644
--- a/lib/overlay-state.ts
+++ b/lib/overlay-state.ts
@@ -11,7 +11,7 @@ import type { PreSubmitEstimate } from './pre-submit';
/**
* Renderable budget variants only. The unsupported variant has nothing for
- * the in-page overlay to draw, so it never reaches state — the content
+ * the in-page overlay to draw, so it never reaches state: the content
* script gates the call before applyUsageBudget runs.
*/
export type RenderableBudget = UsageBudgetSession | UsageBudgetCredit;
diff --git a/lib/sse-gate.ts b/lib/sse-gate.ts
new file mode 100644
index 0000000..76ffa76
--- /dev/null
+++ b/lib/sse-gate.ts
@@ -0,0 +1,33 @@
+// lib/sse-gate.ts
+// Canonical predicate for the inject-time SSE gate. Decides whether the
+// fetch interceptor in entrypoints/inject.ts should tee + decode a
+// completion response, or hand it back to claude.ai unmodified.
+//
+// inject.ts cannot import this module — it runs in MAIN world and the
+// no-lib-imports rule keeps chrome.* references from bleeding into the
+// unprivileged page context. The gate is therefore mirrored inline in
+// inject.ts. tests/unit/inject-non-sse.test.ts contains a fingerprint
+// guard that asserts the inline copy still matches the substrings below;
+// when you change anything here, update the inject.ts mirror in the same
+// commit and the guard test will tell you if you forgot.
+//
+// Behaviour:
+// - Status must be exactly 200. Anthropic's stream endpoint returns
+// 429 / 5xx / captcha-HTML through the same URL; feeding non-stream
+// bytes into the SSE decoder silently fails until the watchdog fires.
+// - Content-Type must START WITH 'text/event-stream'. A plain substring
+// match would accept hostile or malformed types like
+// 'application/x-no-event-stream'. We compare in lowercase because
+// HTTP header VALUES are not normalized by the Headers API (only
+// names are), so 'TEXT/EVENT-STREAM' is a legal SSE response.
+// - Body must be present. tee() throws on a null ReadableStream.
+
+export function shouldTeeAndDecode(
+ status: number,
+ contentType: string,
+ hasBody: boolean,
+): boolean {
+ if (!hasBody) return false;
+ if (status !== 200) return false;
+ return contentType.toLowerCase().startsWith('text/event-stream');
+}
diff --git a/lib/token-economics.ts b/lib/token-economics.ts
index 10b3c31..52fe412 100644
--- a/lib/token-economics.ts
+++ b/lib/token-economics.ts
@@ -1,7 +1,7 @@
// lib/token-economics.ts
// Pure agent: derives median token-to-session-% ratios, grouped by model.
//
-// Architecture position: lib/ agent layer. Pure functions only — no DOM, no chrome.*, no storage.
+// Architecture position: lib/ agent layer. Pure functions only; no DOM, no chrome.*, no storage.
// Input: UsageDelta[] from getUsageDeltas() in lib/conversation-store.ts
// Output: TokenEconomicsResult (three Maps keyed by model string)
// Called by: entrypoints/sidepanel/hooks/useDashboardData.ts (loadTokenEconomics)
diff --git a/lib/usage-budget.ts b/lib/usage-budget.ts
index 97cbd58..98fa0dc 100644
--- a/lib/usage-budget.ts
+++ b/lib/usage-budget.ts
@@ -13,7 +13,7 @@
// The endpoint exposes a different shape per account tier (see
// lib/usage-limits-parser.ts). This agent branches on `limits.kind` and
// returns the matching UsageBudgetResult variant. Render code never
-// computes its own status text or zone — every label that ends up on the
+// computes its own status text or zone: every label that ends up on the
// user's screen comes from here.
//
// Design principles (mirrors all other lib/ agents):
@@ -116,7 +116,7 @@ function buildSessionStatusLabel(sessionPct: number, sessionMinutes: number, zon
// ── Credit-tier helpers ──────────────────────────────────────────────────────
// `Intl.NumberFormat` constructors are not free; on a hot card render we would
-// build two per call (used + monthly). Cache by currency code — the same code
+// build two per call (used + monthly). Cache by currency code: the same code
// is reused across renders for one account, and the cache is bounded by the
// number of currencies Anthropic actually returns (one per account, ever).
// `null` marks codes Intl rejected so we do not retry the constructor.
@@ -154,7 +154,7 @@ function formatCents(cents: number, currency: string): string {
}
/**
- * "Resets May 1" — first day of the next calendar month, locale-formatted.
+ * "Resets May 1": first day of the next calendar month, locale-formatted.
* Anthropic resets Enterprise credit pools on the first of the month, so the
* label is deterministic from `now` alone; the endpoint does not return a
* resets_at for credit responses.
@@ -236,7 +236,7 @@ export function computeUsageBudget(limits: UsageLimitsData, now: number): UsageB
* Return the percentage value the content script should track turn-over-turn
* for delta computation. Session tier tracks the 5-hour window; credit tier
* tracks monthly utilization. The label changes (% of session vs % of monthly)
- * but the math is the same — subtract before from after to get the cost of one
+ * but the math is the same: subtract before from after to get the cost of one
* message in tier-appropriate units.
*
* Typed to reject the unsupported variant: there is nothing to track when the
diff --git a/lib/usage-limits-parser.ts b/lib/usage-limits-parser.ts
index 3e752f6..95c33ee 100644
--- a/lib/usage-limits-parser.ts
+++ b/lib/usage-limits-parser.ts
@@ -26,7 +26,7 @@
import type { UsageLimitsData } from './message-types';
-// ── Raw endpoint shape (defensive — every field optional) ─────────────────────
+// ── Raw endpoint shape (defensive: every field optional) ─────────────────────
// We deliberately type these as `unknown`-friendly: a field may be missing,
// null, or the wrong type. The dispatch helpers below validate what they need.
diff --git a/tests/unit/active-conversation-context.test.tsx b/tests/unit/active-conversation-context.test.tsx
new file mode 100644
index 0000000..36b0751
--- /dev/null
+++ b/tests/unit/active-conversation-context.test.tsx
@@ -0,0 +1,76 @@
+// @vitest-environment happy-dom
+//
+// Render tests for ActiveConversation's context-bar percent computation.
+// The percent is now derived from cumulative tokens against the model's
+// context window, not from record.lastContextPct. The reason matters:
+// some records were written with lastContextPct in fractional units
+// (0.026 instead of 2.6), which rendered as a flat zero bar even when
+// the conversation was well underway.
+//
+// These tests pin the recompute behavior so a future refactor doesn't
+// silently fall back to the stale field.
+
+import { describe, it, expect } from 'vitest';
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import ActiveConversation from '../../entrypoints/sidepanel/components/ActiveConversation';
+import type { ConversationRecord } from '../../lib/conversation-store';
+
+function makeConv(overrides: Partial = {}): ConversationRecord {
+ return {
+ id: 'conv-test',
+ startedAt: 1_700_000_000_000,
+ lastActiveAt: 1_700_000_001_000,
+ finalized: false,
+ turnCount: 8,
+ totalInputTokens: 4000,
+ totalOutputTokens: 1192,
+ peakContextPct: 0.026,
+ lastContextPct: 0.026, // stored in fractional units (legacy bug shape)
+ model: 'claude-haiku-4-5',
+ estimatedCost: 0.03,
+ turns: [],
+ dna: { subject: 'whats going on man', lastContext: '', hints: [] },
+ _v: 1,
+ ...overrides,
+ };
+}
+
+describe('ActiveConversation — context% computed from tokens', () => {
+ it('ignores a fractional lastContextPct and recomputes from tokens', () => {
+ // 5,192 tokens / 200,000 = 2.596% -> rounds to 3%.
+ // If the component fell back to the stored 0.026 field, it would
+ // round to 0% and the bar would visibly be empty (the original bug).
+ render( );
+ expect(screen.getByText('3% context')).toBeTruthy();
+ });
+
+ it('scales with token count for the same model', () => {
+ const conv = makeConv({
+ totalInputTokens: 50_000,
+ totalOutputTokens: 50_000,
+ });
+ // 100k of 200k = 50%.
+ render( );
+ expect(screen.getByText('50% context')).toBeTruthy();
+ });
+
+ it('clamps at 100% when tokens exceed the window', () => {
+ const conv = makeConv({
+ totalInputTokens: 300_000,
+ totalOutputTokens: 0,
+ });
+ render( );
+ expect(screen.getByText('100% context')).toBeTruthy();
+ });
+
+ it('renders 0% on an empty conversation without throwing', () => {
+ const conv = makeConv({
+ totalInputTokens: 0,
+ totalOutputTokens: 0,
+ turnCount: 0,
+ });
+ render( );
+ expect(screen.getByText('0% context')).toBeTruthy();
+ });
+});
diff --git a/tests/unit/inject-non-sse.test.ts b/tests/unit/inject-non-sse.test.ts
new file mode 100644
index 0000000..ac814bc
--- /dev/null
+++ b/tests/unit/inject-non-sse.test.ts
@@ -0,0 +1,99 @@
+// tests/unit/inject-non-sse.test.ts
+// Two test groups:
+//
+// 1) Semantic tests for shouldTeeAndDecode() in lib/sse-gate.ts — the
+// canonical predicate that decides whether the fetch interceptor in
+// entrypoints/inject.ts tees + decodes a response.
+//
+// 2) A source-text fingerprint guard. inject.ts runs in MAIN world and
+// cannot import from lib/, so it mirrors the predicate inline. The
+// guard reads inject.ts as text and asserts the mirror still matches
+// the canonical fingerprint substrings. Without it, the inline copy
+// could silently drift while the semantic tests below keep passing.
+//
+// Background on why the gate exists: claude.ai's completion endpoint
+// returns 429 (rate limit), 5xx, or a captcha/CDN HTML page through the
+// same URL as a real stream. Feeding non-SSE bytes into decodeSSEStream
+// silently fails — the decoder finds no event lines, the watchdog fires
+// after 120s, and the overlay sits frozen on the previous turn's state.
+
+import { describe, it, expect } from 'vitest';
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+import { shouldTeeAndDecode } from '../../lib/sse-gate';
+
+describe('shouldTeeAndDecode (canonical SSE gate)', () => {
+ it('tees a 200 response with text/event-stream + charset', () => {
+ expect(shouldTeeAndDecode(200, 'text/event-stream; charset=utf-8', true)).toBe(true);
+ });
+
+ it('tees a 200 response with bare text/event-stream', () => {
+ expect(shouldTeeAndDecode(200, 'text/event-stream', true)).toBe(true);
+ });
+
+ it('matches case-insensitively (HTTP header values are not auto-lowercased)', () => {
+ expect(shouldTeeAndDecode(200, 'TEXT/EVENT-STREAM', true)).toBe(true);
+ expect(shouldTeeAndDecode(200, 'Text/Event-Stream; charset=UTF-8', true)).toBe(true);
+ });
+
+ it('rejects content types that merely contain the substring', () => {
+ // Pre-fix the gate used .includes('event-stream'), which would have
+ // accepted these. startsWith('text/event-stream') closes that hole.
+ expect(shouldTeeAndDecode(200, 'application/x-no-event-stream', true)).toBe(false);
+ expect(shouldTeeAndDecode(200, 'application/json+event-stream', true)).toBe(false);
+ });
+
+ it('skips tee on a 429 rate-limit response', () => {
+ expect(shouldTeeAndDecode(429, 'application/json', true)).toBe(false);
+ });
+
+ it('skips tee on a 500 server-error response', () => {
+ // Status takes precedence over content-type: even if claude.ai sets
+ // text/event-stream on a 500, we should not feed the body to the
+ // decoder.
+ expect(shouldTeeAndDecode(500, 'text/event-stream', true)).toBe(false);
+ });
+
+ it('skips tee on a 200 captcha/CDN HTML response', () => {
+ // Cloudflare interstitials and Anthropic's own captcha challenges
+ // land on this endpoint with status 200 + text/html. Treating them
+ // as SSE is what froze the overlay before this fix.
+ expect(shouldTeeAndDecode(200, 'text/html; charset=utf-8', true)).toBe(false);
+ });
+
+ it('skips tee on a 200 response with no content-type header', () => {
+ expect(shouldTeeAndDecode(200, '', true)).toBe(false);
+ });
+
+ it('skips tee when the response body is missing', () => {
+ // Defensive: even an SSE-shaped header should not trigger tee if
+ // the body is null (some intermediaries strip it; tee() would throw).
+ expect(shouldTeeAndDecode(200, 'text/event-stream', false)).toBe(false);
+ });
+});
+
+describe('inject.ts inline gate stays in sync with lib/sse-gate.ts', () => {
+ // Read the inject.ts source as text. If the inline gate drifts from the
+ // canonical predicate's fingerprint, these assertions fail and the next
+ // committer sees that they need to update both places.
+ const injectSource = readFileSync(
+ resolve(__dirname, '../../entrypoints/inject.ts'),
+ 'utf-8',
+ );
+
+ it('matches the canonical content-type literal', () => {
+ expect(injectSource).toContain("'text/event-stream'");
+ });
+
+ it('lowercases the content-type before matching', () => {
+ expect(injectSource).toContain('.toLowerCase()');
+ });
+
+ it('uses startsWith, not includes, for content-type matching', () => {
+ expect(injectSource).toMatch(/startsWith\(['"]text\/event-stream['"]\)/);
+ });
+
+ it('checks the gate against status === 200 explicitly', () => {
+ expect(injectSource).toMatch(/response\.status === 200/);
+ });
+});
diff --git a/tests/unit/turn-ticker-trend.test.ts b/tests/unit/turn-ticker-trend.test.ts
new file mode 100644
index 0000000..9bdaf54
--- /dev/null
+++ b/tests/unit/turn-ticker-trend.test.ts
@@ -0,0 +1,64 @@
+// tests/unit/turn-ticker-trend.test.ts
+// Locks in the absolute-percentage-point trend behavior for TurnTicker.
+// The earlier draft of computeTrend reported relative percent change
+// ((curr - prev) / prev) * 100, which on micro-values produced "↑ 2650%"
+// and "↓ 97%" labels that read as catastrophic context rot when the
+// underlying turns were 0.05% and 0.15% of session — totally healthy.
+//
+// The fix is to report the absolute pp delta in the same unit the bars
+// are drawn in. These tests pin that contract so a future refactor of
+// the trend math doesn't silently regress to the relative formula.
+
+import { describe, it, expect } from 'vitest';
+import { computeTrend } from '../../entrypoints/sidepanel/components/TurnTicker';
+
+describe('TurnTicker computeTrend — absolute pp delta, not relative percent', () => {
+ it('reports the absolute pp difference for upward moves', () => {
+ // 0.05% -> 0.15% of session is a +0.10pp move, not a +200% move.
+ // The bars carry the magnitude story; the label only adds direction
+ // and the honest size of the change.
+ const trend = computeTrend(0.05, 0.15);
+ expect(trend).not.toBeNull();
+ expect(trend!.direction).toBe('up');
+ expect(trend!.percent).toBeCloseTo(0.10, 2);
+ });
+
+ it('reports the absolute pp difference for downward moves', () => {
+ const trend = computeTrend(0.15, 0.05);
+ expect(trend).not.toBeNull();
+ expect(trend!.direction).toBe('down');
+ expect(trend!.percent).toBeCloseTo(-0.10, 2);
+ });
+
+ it('does NOT explode on a near-zero previous turn (regression for the 2650% bug)', () => {
+ // Prior bug: previous=0.005, current=0.137 -> ((0.137 - 0.005) / 0.005) * 100
+ // = 2640%. Fix reports the absolute delta, ~0.13.
+ const trend = computeTrend(0.005, 0.137);
+ expect(trend).not.toBeNull();
+ expect(trend!.percent).toBeCloseTo(0.132, 2);
+ expect(Math.abs(trend!.percent)).toBeLessThan(1); // never absurd
+ });
+
+ it('suppresses moves below the noise floor (0.01 pp)', () => {
+ // Tokenizer estimate is the floor of meaningful resolution. A 0.005pp
+ // move is rounding noise; rendering it as "↑ 0.005%" would distract
+ // without informing.
+ expect(computeTrend(0.05, 0.054)).toBeNull();
+ expect(computeTrend(0.05, 0.05)).toBeNull();
+ });
+
+ it('returns null when either input is null (no previous turn)', () => {
+ expect(computeTrend(null, 0.15)).toBeNull();
+ expect(computeTrend(0.15, null)).toBeNull();
+ expect(computeTrend(null, null)).toBeNull();
+ });
+
+ it('handles a zero previous turn without dividing by zero (the original failure mode)', () => {
+ // The relative formula crashed at this branch; absolute delta has
+ // no such constraint. previous=0 is a legitimate first-tracked turn.
+ const trend = computeTrend(0, 0.20);
+ expect(trend).not.toBeNull();
+ expect(trend!.direction).toBe('up');
+ expect(trend!.percent).toBeCloseTo(0.20, 2);
+ });
+});
diff --git a/ui/enable-banner.ts b/ui/enable-banner.ts
index 5760db8..38aa652 100644
--- a/ui/enable-banner.ts
+++ b/ui/enable-banner.ts
@@ -1,8 +1,9 @@
// ui/enable-banner.ts
// JIT permission banner shown on first visit to claude.ai.
-// Appended to (not ): Next.js hydrates and wipes foreign children.
-// On enable: stores the grant flag and reloads so inject.ts runs at document_start.
-// On dismiss: removes the banner without storing; will reappear next page load.
+// Appended to (not ): Next.js hydrates and wipes foreign
+// children. On enable: stores the grant flag and reloads so inject.ts runs at
+// document_start. On dismiss: removes the banner without storing; will
+// reappear next page load.
export async function showEnableBanner(): Promise {
if (!document.body) {
@@ -12,7 +13,10 @@ export async function showEnableBanner(): Promise {
});
}
- // Inject entrance animation keyframes.
+ // Theme rules live in this stylesheet so the same banner element can adopt
+ // light or dark colors via prefers-color-scheme. Layout (position, padding,
+ // gap) stays inline on each element below; only colors and focus styles
+ // are themable, which keeps the cascade obvious for future maintainers.
const style = document.createElement('style');
style.textContent = `
@keyframes lco-banner-enter {
@@ -23,6 +27,49 @@ export async function showEnableBanner(): Promise {
#lco-enable-banner { animation: none !important; transition: none !important; }
#lco-enable-banner button { transition: none !important; transform: none !important; }
}
+
+ /* Default (dark) palette: matches claude.ai's dark chrome. */
+ #lco-enable-banner {
+ background: rgba(24, 24, 27, 0.88);
+ color: #e4e4e7;
+ border: 1px solid rgba(255, 255, 255, 0.10);
+ box-shadow:
+ 0 0 0 1px rgba(255, 255, 255, 0.06),
+ 0 4px 12px rgba(0, 0, 0, 0.08),
+ 0 20px 60px rgba(0, 0, 0, 0.18);
+ }
+ #lco-enable-banner .lco-banner-title { color: #e4e4e7; font-weight: 600; }
+ #lco-enable-banner .lco-banner-subtitle { color: #a1a1aa; font-weight: 400; font-size: 11px; }
+ #lco-enable-banner .lco-banner-enable { background: #c15f3c; color: #ffffff; }
+ #lco-enable-banner .lco-banner-enable:hover { background: #a84f2f; }
+ #lco-enable-banner .lco-banner-dismiss { color: #71717a; }
+ #lco-enable-banner .lco-banner-dismiss:hover { color: #d4d4d8; }
+
+ /* Light-mode override. claude.ai itself is dark by default but does
+ respect this media query, so when a user is on light OS the banner
+ is readable rather than a dark blob on a light page. */
+ @media (prefers-color-scheme: light) {
+ #lco-enable-banner {
+ background: rgba(255, 255, 255, 0.92);
+ color: #18181b;
+ border: 1px solid rgba(0, 0, 0, 0.10);
+ box-shadow:
+ 0 0 0 1px rgba(0, 0, 0, 0.04),
+ 0 4px 12px rgba(0, 0, 0, 0.06),
+ 0 20px 60px rgba(0, 0, 0, 0.12);
+ }
+ #lco-enable-banner .lco-banner-title { color: #18181b; }
+ #lco-enable-banner .lco-banner-subtitle { color: #6b6b6b; }
+ #lco-enable-banner .lco-banner-dismiss { color: #6b6b6b; }
+ #lco-enable-banner .lco-banner-dismiss:hover { color: #18181b; }
+ }
+
+ /* Keyboard focus rings. Hover-only feedback was the audit finding (L4):
+ a tab-only user had no signal that either button was focused. */
+ #lco-enable-banner button:focus-visible {
+ outline: 2px solid #c15f3c;
+ outline-offset: 2px;
+ }
`;
document.documentElement.appendChild(style);
@@ -37,28 +84,37 @@ export async function showEnableBanner(): Promise {
'align-items:center',
'gap:12px',
'padding:12px 16px',
- 'background:rgba(24,24,27,0.88)',
'backdrop-filter:blur(16px) saturate(1.4)',
'-webkit-backdrop-filter:blur(16px) saturate(1.4)',
- 'border:1px solid rgba(255,255,255,0.10)',
'border-radius:12px',
'font-family:-apple-system,BlinkMacSystemFont,Segoe UI,system-ui,sans-serif',
'font-size:13px',
- 'color:#e4e4e7',
- 'box-shadow:0 0 0 1px rgba(255,255,255,0.06),0 4px 12px rgba(0,0,0,0.08),0 20px 60px rgba(0,0,0,0.18)',
'pointer-events:all',
'-webkit-font-smoothing:antialiased',
'animation:lco-banner-enter 0.3s cubic-bezier(0.16,1,0.3,1) forwards',
].join(';');
- const text = document.createElement('span');
- text.textContent = 'Saar: Enable token tracking for Claude?';
+ // Two-line text block. The thesis lock on 2026-04-19 moved Saar's framing
+ // off "token tracker" toward "workflow layer / AI usage coach", so the
+ // banner copy follows: declarative title, then a privacy reassurance.
+ const textWrap = document.createElement('div');
+ textWrap.style.cssText = 'display:flex;flex-direction:column;gap:1px;line-height:1.3';
+
+ const title = document.createElement('span');
+ title.className = 'lco-banner-title';
+ title.textContent = 'Enable Saar on Claude?';
+
+ const subtitle = document.createElement('span');
+ subtitle.className = 'lco-banner-subtitle';
+ subtitle.textContent = 'All counting happens in your browser.';
+
+ textWrap.appendChild(title);
+ textWrap.appendChild(subtitle);
const enableBtn = document.createElement('button');
+ enableBtn.className = 'lco-banner-enable';
enableBtn.textContent = 'Enable';
enableBtn.style.cssText = [
- 'background:#c15f3c',
- 'color:#fff',
'border:none',
'border-radius:6px',
'padding:5px 14px',
@@ -71,10 +127,10 @@ export async function showEnableBanner(): Promise {
].join(';');
const dismissBtn = document.createElement('button');
+ dismissBtn.className = 'lco-banner-dismiss';
dismissBtn.textContent = 'Dismiss';
dismissBtn.style.cssText = [
'background:transparent',
- 'color:#71717a',
'border:none',
'padding:5px 8px',
'font:inherit',
@@ -84,33 +140,40 @@ export async function showEnableBanner(): Promise {
'transition:color 0.15s ease,transform 0.1s ease',
].join(';');
- banner.appendChild(text);
+ banner.appendChild(textWrap);
banner.appendChild(enableBtn);
banner.appendChild(dismissBtn);
document.documentElement.appendChild(banner);
- // Active state: press feedback. Skip when reduced motion is preferred.
+ // Press feedback: a tiny scale-down on mousedown gives the buttons weight.
+ // Skipped under reduced-motion. Color hover is now handled in CSS, so we
+ // no longer attach mouseenter/mouseleave color toggles here.
const motionOk = !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (motionOk) {
- enableBtn.addEventListener('mousedown', () => { enableBtn.style.transform = 'scale(0.97)'; });
- enableBtn.addEventListener('mouseup', () => { enableBtn.style.transform = ''; });
+ enableBtn.addEventListener('mousedown', () => { enableBtn.style.transform = 'scale(0.97)'; });
+ enableBtn.addEventListener('mouseup', () => { enableBtn.style.transform = ''; });
enableBtn.addEventListener('mouseleave', () => { enableBtn.style.transform = ''; });
- dismissBtn.addEventListener('mousedown', () => { dismissBtn.style.transform = 'scale(0.97)'; });
- dismissBtn.addEventListener('mouseup', () => { dismissBtn.style.transform = ''; });
+ dismissBtn.addEventListener('mousedown', () => { dismissBtn.style.transform = 'scale(0.97)'; });
+ dismissBtn.addEventListener('mouseup', () => { dismissBtn.style.transform = ''; });
dismissBtn.addEventListener('mouseleave', () => { dismissBtn.style.transform = ''; });
}
- // Hover states.
- enableBtn.addEventListener('mouseenter', () => { enableBtn.style.background = '#a84f2f'; });
- enableBtn.addEventListener('mouseleave', () => { enableBtn.style.background = '#c15f3c'; });
- dismissBtn.addEventListener('mouseenter', () => { dismissBtn.style.color = '#d4d4d8'; });
- dismissBtn.addEventListener('mouseleave', () => { dismissBtn.style.color = '#71717a'; });
-
enableBtn.addEventListener('click', async () => {
- await browser.storage.local.set({ lco_enabled_claude: true });
- banner.remove();
- style.remove();
- window.location.reload();
+ // The reload only happens after persistence succeeds. Without the
+ // try/catch, a quota-exceeded or extension-restricted rejection
+ // would throw out of the click handler unhandled, the banner
+ // would never be removed, and the page would never reload —
+ // user-visible "nothing happened" outcome. With the catch we
+ // leave the banner in place so the user can retry, and we log
+ // the error so future support cases surface it.
+ try {
+ await browser.storage.local.set({ lco_enabled_claude: true });
+ banner.remove();
+ style.remove();
+ window.location.reload();
+ } catch (err) {
+ console.warn('[LCO] Failed to persist enable flag:', err);
+ }
});
dismissBtn.addEventListener('click', () => {
diff --git a/ui/overlay-styles.ts b/ui/overlay-styles.ts
index e9b8bbf..f9aa35d 100644
--- a/ui/overlay-styles.ts
+++ b/ui/overlay-styles.ts
@@ -8,21 +8,23 @@
export const OVERLAY_CSS = `
:host {
- /* Claude terra cotta palette */
- --lco-accent: #c15f3c;
+ /* Workshop palette (mirrors dashboard.css). Terra cotta is the only
+ accent; brass replaces Material amber for warn surfaces so the overlay
+ reads as one product with the side panel. */
+ --lco-accent: #c15f3c; /* terracotta */
--lco-bar-fill: #c15f3c;
--lco-bar-glow: rgba(193, 95, 60, 0.28);
--lco-bar-bg: rgba(193, 95, 60, 0.10);
- --lco-warn-fill: #f59e0b;
- --lco-warn-glow: rgba(245, 158, 11, 0.22);
- --lco-warn-bg: rgba(245, 158, 11, 0.09);
+ --lco-warn-fill: #b08858; /* brass */
+ --lco-warn-glow: rgba(176, 136, 88, 0.22);
+ --lco-warn-bg: rgba(176, 136, 88, 0.10);
/* Dark mode (default on claude.ai) */
- --lco-bg: rgba(30, 30, 28, 0.92); /* was .82 — prevents muted text failing on light page content bleedthrough */
+ --lco-bg: rgba(30, 30, 28, 0.92); /* was .82; prevents muted text failing on light page content bleedthrough */
--lco-bg-hover: rgba(38, 38, 36, 0.95);
--lco-text: #d4d4d8;
- --lco-muted: #8a8a93; /* was #71717a — bumped for WCAG AA headroom (~5.8:1 on dark surface) */
- --lco-border: rgba(255, 255, 255, 0.12); /* was .06 — invisible on claude.ai dark panels */
+ --lco-muted: #8a8a93; /* was #71717a; bumped for WCAG AA headroom (~5.8:1 on dark surface) */
+ --lco-border: rgba(255, 255, 255, 0.12); /* was .06; invisible on claude.ai dark panels */
--lco-border-hover: rgba(255, 255, 255, 0.18);
-webkit-font-smoothing: antialiased;
@@ -34,15 +36,17 @@ export const OVERLAY_CSS = `
--lco-bg: rgba(244, 243, 238, 0.92); /* matched to dark mode .92 floor */
--lco-bg-hover: rgba(238, 236, 230, 0.95);
--lco-text: #27272a;
- --lco-muted: #6b7280; /* was #a1a1aa (~3.2:1 fail on warm cream) — gray-500 gives ~4.8:1 AA */
+ --lco-muted: #6b7280; /* was #a1a1aa (~3.2:1 fail on warm cream); gray-500 gives ~4.8:1 AA */
--lco-accent: #b35a34;
--lco-bar-fill: #b35a34;
--lco-bar-glow: rgba(179, 90, 52, 0.20);
--lco-bar-bg: rgba(179, 90, 52, 0.10);
- --lco-warn-fill: #d97706;
- --lco-warn-glow: rgba(217, 119, 6, 0.20);
- --lco-warn-bg: rgba(217, 119, 6, 0.08);
- --lco-border: rgba(0, 0, 0, 0.08); /* was .06 — widget edge was missing in light mode */
+ /* Brass holds well in light mode without needing a darker variant; the
+ fill already reads warm against the bone surface. */
+ --lco-warn-fill: #9b7448;
+ --lco-warn-glow: rgba(155, 116, 72, 0.20);
+ --lco-warn-bg: rgba(155, 116, 72, 0.08);
+ --lco-border: rgba(0, 0, 0, 0.08); /* was .06; widget edge was missing in light mode */
--lco-border-hover: rgba(0, 0, 0, 0.14);
}
}
@@ -60,8 +64,9 @@ export const OVERLAY_CSS = `
}
@keyframes lco-dot-pulse {
- 0%, 100% { box-shadow: 0 0 4px rgba(239, 68, 68, 0.4); }
- 50% { box-shadow: 0 0 10px rgba(239, 68, 68, 0.7); }
+ /* Critical-state pulse uses the on-dark rust tint (#c46948). */
+ 0%, 100% { box-shadow: 0 0 4px rgba(196, 105, 72, 0.4); }
+ 50% { box-shadow: 0 0 10px rgba(196, 105, 72, 0.7); }
}
@keyframes lco-nudge-in {
@@ -237,9 +242,12 @@ export const OVERLAY_CSS = `
Instant swap avoids a paint-layer transition on the main thread. */
}
-.lco-health-dot--healthy { background: #86efac; box-shadow: 0 0 4px rgba(134, 239, 172, 0.4); }
-.lco-health-dot--degrading { background: #f59e0b; box-shadow: 0 0 4px rgba(245, 158, 11, 0.4); }
-.lco-health-dot--critical { background: #ef4444; animation: lco-dot-pulse 2s ease-in-out infinite; }
+/* Workshop earth tones: patina (operational), brass (degrading), rust (critical).
+ Mint green and Material amber/red were generic; the new palette reads as one
+ product across the overlay and side panel. */
+.lco-health-dot--healthy { background: #6e957a; box-shadow: 0 0 4px rgba(110, 149, 122, 0.45); }
+.lco-health-dot--degrading { background: #b08858; box-shadow: 0 0 4px rgba(176, 136, 88, 0.45); }
+.lco-health-dot--critical { background: #c46948; animation: lco-dot-pulse 2s ease-in-out infinite; }
.lco-health-label {
font-size: 10px;
@@ -248,9 +256,9 @@ export const OVERLAY_CSS = `
/* No transition: color is a paint property; health state changes snap instantly. */
}
-.lco-health-label--healthy { color: #86efac; }
-.lco-health-label--degrading { color: #f59e0b; }
-.lco-health-label--critical { color: #ef4444; }
+.lco-health-label--healthy { color: #6e957a; }
+.lco-health-label--degrading { color: #b08858; }
+.lco-health-label--critical { color: #c46948; }
.lco-coaching {
font-size: 10px;
@@ -292,7 +300,7 @@ export const OVERLAY_CSS = `
outline-offset: 2px;
}
-/* Critical state: filled button — more urgent than the outline used at degrading */
+/* Critical state: filled button, more urgent than the outline used at degrading */
.lco-start-fresh--critical {
background: #c15f3c;
color: rgba(255, 255, 255, 0.92);
@@ -355,12 +363,14 @@ export const OVERLAY_CSS = `
box-shadow: 0 0 6px var(--lco-warn-glow);
}
+/* Bar fills mirror the dot palette. Tight uses ember (#cc6b3d) which sits
+ between brass and rust on the warm scale. */
.lco-bar-fill--healthy,
-.lco-bar-fill--comfortable { background: #86efac; box-shadow: 0 0 6px rgba(134, 239, 172, 0.3); }
+.lco-bar-fill--comfortable { background: #6e957a; box-shadow: 0 0 6px rgba(110, 149, 122, 0.3); }
.lco-bar-fill--degrading,
-.lco-bar-fill--moderate { background: #f59e0b; box-shadow: 0 0 6px rgba(245, 158, 11, 0.3); }
-.lco-bar-fill--critical { background: #ef4444; box-shadow: 0 0 6px rgba(239, 68, 68, 0.3); }
-.lco-bar-fill--tight { background: #fb923c; box-shadow: 0 0 6px rgba(251, 146, 60, 0.3); }
+.lco-bar-fill--moderate { background: #b08858; box-shadow: 0 0 6px rgba(176, 136, 88, 0.3); }
+.lco-bar-fill--tight { background: #cc6b3d; box-shadow: 0 0 6px rgba(204, 107, 61, 0.3); }
+.lco-bar-fill--critical { background: #c46948; box-shadow: 0 0 6px rgba(196, 105, 72, 0.3); }
.lco-bar-fill.lco-streaming {
animation: lco-bar-pulse 1.2s ease-in-out infinite;
@@ -391,9 +401,9 @@ export const OVERLAY_CSS = `
animation: lco-nudge-in 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
-.lco-nudge--info { background: rgba(107, 140, 174, 0.09); border-left: 2px solid #6b8cae; } /* desaturated steel from terracotta undertones — no pure blue in palette */
-.lco-nudge--warning { background: rgba(245, 158, 11, 0.11); border-left: 2px solid #f59e0b; }
-.lco-nudge--critical { background: rgba(239, 68, 68, 0.11); border-left: 2px solid #ef4444; }
+.lco-nudge--info { background: rgba(107, 140, 174, 0.09); border-left: 2px solid #6b8cae; } /* desaturated steel from terracotta undertones; no pure blue in palette */
+.lco-nudge--warning { background: rgba(176, 136, 88, 0.12); border-left: 2px solid #b08858; }
+.lco-nudge--critical { background: rgba(196, 105, 72, 0.12); border-left: 2px solid #c46948; }
.lco-nudge--exiting {
animation: lco-nudge-out 0.2s ease forwards;
diff --git a/ui/overlay.ts b/ui/overlay.ts
index 3cb4685..f84c6d0 100644
--- a/ui/overlay.ts
+++ b/ui/overlay.ts
@@ -1,8 +1,8 @@
// ui/overlay.ts
// Overlay DOM factory. No knowledge of message types, chrome APIs, or business logic.
// createOverlay() returns a handle with two methods:
-// mount(shadow) — builds the DOM tree inside the given shadow root (call once)
-// render(state) — reflects OverlayState onto the DOM (safe to call before mount)
+// mount(shadow) : builds the DOM tree inside the given shadow root (call once)
+// render(state) : reflects OverlayState onto the DOM (safe to call before mount)
import { OVERLAY_CSS } from './overlay-styles';
import type { OverlayState } from '../lib/overlay-state';
@@ -29,7 +29,7 @@ function fmtCost(c: number | null): string {
}
export function createOverlay(): OverlayHandle {
- // DOM refs — null until mount() is called. render() is a no-op until then.
+ // DOM refs: null until mount() is called. render() is a no-op until then.
let overlayWidget: HTMLDivElement | null = null;
let elCurrentRequest: HTMLElement | null = null;
let elHealthRow: HTMLElement | null = null;
@@ -72,7 +72,7 @@ export function createOverlay(): OverlayHandle {
widget.style.display = 'none'; // hidden until first TOKEN_BATCH
overlayWidget = widget;
- // Header — always visible, click to collapse/expand
+ // Header: always visible, click to collapse/expand
const header = document.createElement('div');
header.className = 'lco-header';
@@ -85,7 +85,7 @@ export function createOverlay(): OverlayHandle {
costMini.style.display = 'none'; // shown only when collapsed
elCostMini = costMini;
- // Health dot shown in collapsed pill — sole health signal when minimized.
+ // Health dot shown in collapsed pill: sole health signal when minimized.
const healthDotMini = document.createElement('span');
healthDotMini.className = 'lco-health-dot';
healthDotMini.style.display = 'none';
@@ -96,7 +96,7 @@ export function createOverlay(): OverlayHandle {
header.appendChild(healthDotMini);
widget.appendChild(header);
- // Body — collapsible
+ // Body: collapsible
const body = document.createElement('div');
body.className = 'lco-body';
@@ -216,7 +216,7 @@ export function createOverlay(): OverlayHandle {
limitRow.appendChild(limitLabel);
body.appendChild(limitRow);
- // Weekly cap bar — hidden until usageBudget is available
+ // Weekly cap bar: hidden until usageBudget is available
const weeklyRow = document.createElement('div');
weeklyRow.className = 'lco-bar-row lco-weekly-row';
weeklyRow.style.display = 'none';
@@ -236,14 +236,14 @@ export function createOverlay(): OverlayHandle {
weeklyRow.appendChild(weeklyLabel);
body.appendChild(weeklyRow);
- // Divider — hidden until first request completes
+ // Divider: hidden until first request completes
const divider = document.createElement('div');
divider.className = 'lco-divider';
divider.style.display = 'none';
elDivider = divider;
body.appendChild(divider);
- // Session row — hidden until first request completes
+ // Session row: hidden until first request completes
const rowSession = document.createElement('div');
rowSession.className = 'lco-row';
rowSession.style.display = 'none';
@@ -259,8 +259,13 @@ export function createOverlay(): OverlayHandle {
rowSession.appendChild(valSession);
body.appendChild(rowSession);
- // Nudge — hidden by default, shown by showNudge()
+ // Nudge: hidden by default, shown by showNudge().
+ // The base class is set at mount so layout, padding, and font rules
+ // are in place before showNudge() ever runs. Without this, the very
+ // first nudge briefly rendered with no class until the showNudge
+ // call assigned `lco-nudge lco-nudge--` (audit L2).
const nudge = document.createElement('div');
+ nudge.className = 'lco-nudge';
nudge.style.display = 'none';
elNudge = nudge;
const nudgeMsg = document.createElement('span');
@@ -275,7 +280,7 @@ export function createOverlay(): OverlayHandle {
nudge.appendChild(nudgeDismiss);
body.appendChild(nudge);
- // Health warning — hidden by default
+ // Health warning: hidden by default
const health = document.createElement('div');
health.className = 'lco-health';
health.style.display = 'none';
@@ -285,7 +290,7 @@ export function createOverlay(): OverlayHandle {
widget.appendChild(body);
shadow.appendChild(widget);
- // Collapse/expand toggle — DOM-only concern, lives here
+ // Collapse/expand toggle: DOM-only concern, lives here
let collapsed = false;
header.addEventListener('click', () => {
collapsed = !collapsed;
@@ -346,7 +351,7 @@ export function createOverlay(): OverlayHandle {
// Lead with exact tier-appropriate utilization when available
// (Anthropic endpoint, not estimated). The label tracks the budget
// variant so an Enterprise user sees "% of monthly" instead of the
- // misleading "% of session" — the underlying number is monthly
+ // misleading "% of session": the underlying number is monthly
// credit utilization on that tier.
// Falls back to token/cost display when delta has not yet resolved.
if (state.lastDeltaUtilization !== null) {
@@ -451,7 +456,7 @@ export function createOverlay(): OverlayHandle {
}
// Collapsed pill: show session total (not last reply cost).
- // Cost color stays terra cotta regardless of health state — dot is the sole health signal.
+ // Cost color stays terra cotta regardless of health state: dot is the sole health signal.
if (elCostMini && state.session.requestCount > 0) {
elCostMini.textContent = fmtCost(state.session.totalCost);
}