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 ( + +
+ settings + +
+ +
+
+ theme +
+ {THEMES.map((theme) => { + const selected = settings.theme === theme.id; + return ( + + ); + })} +
+
+ +
+ density +
+ {DENSITIES.map((d) => { + const selected = settings.density === d.id; + return ( + + ); + })} +
+
+
+
+ ); +} 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); }