From a763b1280d41cf040026d1664f26ce84ae9aac21 Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Sun, 26 Apr 2026 12:13:32 -0400 Subject: [PATCH 01/25] fix(banner): copy + light/dark + focus ring [GET-13] Title moves from "Saar: Enable token tracking for Claude?" to "Enable Saar on Claude?" plus the privacy subtitle, matching the 2026-04-19 thesis lock that repositions Saar as a workflow layer rather than a token tracker. Banner colors are themed via prefers-color-scheme so the panel reads correctly when the user is on a light OS. Hover-only button feedback was the audit L4 finding; :focus-visible now gives keyboard users a 2px terra cotta ring on both buttons. Color hover state moved from JS listeners to CSS so the cascade is single-sourced. --- ui/enable-banner.ts | 102 +++++++++++++++++++++++++++++++++----------- 1 file changed, 77 insertions(+), 25 deletions(-) diff --git a/ui/enable-banner.ts b/ui/enable-banner.ts index 5760db8..40d788e 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,28 +140,24 @@ 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(); From a9bbe8994b72d3dc43fa093ec959bebef2ab5932 Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Sun, 26 Apr 2026 12:13:58 -0400 Subject: [PATCH 02/25] fix(inject): skip tee + decoder on non-SSE responses [GET-13] The completion endpoint occasionally returns non-SSE payloads: 429 rate limits, 5xx errors, or captcha/CDN HTML pages. Previously we teed and decoded those anyway, which caused decodeSSEStream to sit silent until the 120s watchdog killed it; meanwhile the overlay stayed pinned on whatever the last healthy turn looked like. Gate the tee on status 200 + content-type containing event-stream. Non-SSE responses pass through untouched, claude.ai surfaces its own error UI, and the overlay holds its last healthy state instead of misleading the user. --- entrypoints/inject.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/entrypoints/inject.ts b/entrypoints/inject.ts index c09aa53..4beb3e0 100644 --- a/entrypoints/inject.ts +++ b/entrypoints/inject.ts @@ -553,7 +553,18 @@ export default defineUnlistedScript(() => { const response = await nativeFetch.call(this, input, init); - if (response.body) { + // Only tee + decode when the response is an actual SSE stream. + // claude.ai may return 429 (rate limit), 5xx, or a captcha/CDN + // HTML page through the same endpoint. Feeding non-SSE bytes + // into decodeSSEStream silently fails: the decoder finds no + // event lines, the watchdog fires after 120s, and the overlay + // sits stuck on the previous turn's state. Returning the + // original response unmodified lets claude.ai handle the + // error itself and leaves the overlay in its last healthy state. + const contentType = response.headers.get('content-type') ?? ''; + const isSseStream = response.status === 200 && contentType.includes('event-stream'); + + if (response.body && isSseStream) { const [pageStream, monitorStream] = response.body.tee(); const cleanResponse = new Response(pageStream, { status: response.status, From a793c9c402e8718eb219714e0272f2e006b4fe29 Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Sun, 26 Apr 2026 12:14:34 -0400 Subject: [PATCH 03/25] test(inject): cover non-SSE response branch [GET-13] Mirrors the new SSE-gate predicate from inject.ts the same way sse-decoder.test.ts mirrors the decoder logic. Cases: - 200 + text/event-stream (with and without charset) -> tee allowed - 429 -> skipped - 500 -> skipped - 200 + text/html (captcha / CDN page) -> skipped - 200 + missing content-type -> skipped - missing body even with SSE header -> skipped Satisfies the new-test acceptance criterion on GET-13. --- tests/unit/inject-non-sse.test.ts | 64 +++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 tests/unit/inject-non-sse.test.ts diff --git a/tests/unit/inject-non-sse.test.ts b/tests/unit/inject-non-sse.test.ts new file mode 100644 index 0000000..69980dc --- /dev/null +++ b/tests/unit/inject-non-sse.test.ts @@ -0,0 +1,64 @@ +// tests/unit/inject-non-sse.test.ts +// Asserts that the fetch interceptor in inject.ts skips tee + decoder when +// the response is not an SSE stream. inject.ts has no exports (it is an IIFE +// injected into claude.ai's main world), so we mirror the gate predicate +// here and exercise it the same way the existing sse-decoder.test.ts mirrors +// decode logic. +// +// The gate decides whether decodeSSEStream should be invoked at all. Before +// this fix, claude.ai 429s and captcha HTML pages were piped through the +// SSE decoder, which silently failed and left the overlay frozen on the +// previous turn until the 120s watchdog tripped. + +import { describe, it, expect } from 'vitest'; + +/** + * Mirrors the predicate in entrypoints/inject.ts that guards the tee call. + * If this signature ever drifts, update both places at once. + */ +function shouldTeeAndDecode(response: { status: number; headers: Headers; body: unknown }): boolean { + const contentType = response.headers.get('content-type') ?? ''; + const isSseStream = response.status === 200 && contentType.includes('event-stream'); + return Boolean(response.body) && isSseStream; +} + +function makeResponse(status: number, contentType: string | null, hasBody: boolean = true) { + const headers = new Headers(); + if (contentType !== null) headers.set('content-type', contentType); + return { status, headers, body: hasBody ? {} : null }; +} + +describe('inject.ts non-SSE response gate', () => { + it('tees a 200 response with text/event-stream + charset', () => { + expect(shouldTeeAndDecode(makeResponse(200, 'text/event-stream; charset=utf-8'))).toBe(true); + }); + + it('tees a 200 response with bare text/event-stream', () => { + expect(shouldTeeAndDecode(makeResponse(200, 'text/event-stream'))).toBe(true); + }); + + it('skips tee on a 429 rate-limit response', () => { + expect(shouldTeeAndDecode(makeResponse(429, 'application/json'))).toBe(false); + }); + + it('skips tee on a 500 server-error response', () => { + expect(shouldTeeAndDecode(makeResponse(500, 'text/event-stream'))).toBe(false); + }); + + it('skips tee on a 200 response that is captcha/CDN HTML', () => { + // 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(makeResponse(200, 'text/html; charset=utf-8'))).toBe(false); + }); + + it('skips tee on a 200 response with no content-type header', () => { + expect(shouldTeeAndDecode(makeResponse(200, null))).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). + expect(shouldTeeAndDecode(makeResponse(200, 'text/event-stream', false))).toBe(false); + }); +}); From 52d1925bbcc552814a24f180eec69f59f657b26e Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Sun, 26 Apr 2026 12:14:51 -0400 Subject: [PATCH 04/25] chore(overlay): init nudge className at mount [GET-13] Audit L2: the nudge element was created without lco-nudge, so the first showNudge() call briefly rendered an unstyled box before the severity class assignment kicked in. Setting the base class at mount means base layout / padding / font rules apply immediately. --- ui/overlay.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/ui/overlay.ts b/ui/overlay.ts index 3cb4685..4f81439 100644 --- a/ui/overlay.ts +++ b/ui/overlay.ts @@ -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'); From 5b18bf4658bba0ce4f6f8432ed3614787a743d69 Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Sun, 26 Apr 2026 12:18:07 -0400 Subject: [PATCH 05/25] style: purge emdashes from ui/ + lib/ comments [GET-13] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLAUDE.md bans emdashes in code, comments, docs, and commit messages. Comments only here: the audit (L1) called out about two dozen drift instances across ui/overlay.ts, ui/overlay-styles.ts, and the lib/ agent files. UI placeholder strings (overlay.ts lines 26, 140, 175, 213, 233, 256: '—', '—% ctx', etc.) are the user- visible 'no data yet' marker and are deliberately left untouched per the issue: 'Do not touch string literals without review.' Substituted with colons or semicolons; sentences rephrased where the emdash was carrying real semantic weight. --- lib/context-intelligence.ts | 10 +++++----- lib/message-types.ts | 2 +- lib/overlay-state.ts | 2 +- lib/token-economics.ts | 2 +- lib/usage-budget.ts | 8 ++++---- lib/usage-limits-parser.ts | 2 +- ui/overlay-styles.ts | 14 +++++++------- ui/overlay.ts | 26 +++++++++++++------------- 8 files changed, 33 insertions(+), 33 deletions(-) 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/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/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/ui/overlay-styles.ts b/ui/overlay-styles.ts index e9b8bbf..708df63 100644 --- a/ui/overlay-styles.ts +++ b/ui/overlay-styles.ts @@ -18,11 +18,11 @@ export const OVERLAY_CSS = ` --lco-warn-bg: rgba(245, 158, 11, 0.09); /* 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,7 +34,7 @@ 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); @@ -42,7 +42,7 @@ export const OVERLAY_CSS = ` --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 */ + --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); } } @@ -292,7 +292,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); @@ -391,7 +391,7 @@ 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--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; } diff --git a/ui/overlay.ts b/ui/overlay.ts index 4f81439..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'; @@ -280,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'; @@ -290,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; @@ -351,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) { @@ -456,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); } From 49c7c7eeb0fb0d3165633a20c58ec45bd98e0509 Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Sun, 26 Apr 2026 12:20:45 -0400 Subject: [PATCH 06/25] feat(theme): workshop palette tokens [GET-13] Two-layer color system. The new workshop palette layer carries semantic names (terracotta, sienna, patina, brass, ember, rust, bone, linen, charcoal, slate, ash) so CSS reads like a paint receipt instead of a hex grab-bag. Surface tokens (--lco-bg, --lco-text, etc.) now resolve through that palette so a single token swap propagates everywhere. Health and zone classes move from generic Material colors (#4caf50 / #f5a623 / #ff9800 / #e53935) to the workshop earth tones, both in the side panel and inside the overlay's Shadow DOM. The overlay's on-dark variants are tinted up so contrast holds against the charcoal background. Light-mode warn fill in the overlay drops to a darker brass for readability against bone. Critical pulse keyframes also re-cite the new color so the animation glow matches the dot fill. --- entrypoints/sidepanel/dashboard.css | 122 ++++++++++++++++++---------- ui/overlay-styles.ts | 54 +++++++----- 2 files changed, 113 insertions(+), 63 deletions(-) diff --git a/entrypoints/sidepanel/dashboard.css b/entrypoints/sidepanel/dashboard.css index 7a5b110..617fa3c 100644 --- a/entrypoints/sidepanel/dashboard.css +++ b/entrypoints/sidepanel/dashboard.css @@ -2,24 +2,62 @@ 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; + + /* 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); + --lco-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; --lco-font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; --lco-radius: 8px; @@ -29,16 +67,16 @@ @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); } } @@ -284,9 +322,11 @@ 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; } .lco-dash-health-label { font-size: 10px; @@ -328,9 +368,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; @@ -399,10 +439,10 @@ 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-size: 10px; @@ -453,10 +493,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 +732,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 { diff --git a/ui/overlay-styles.ts b/ui/overlay-styles.ts index 708df63..f9aa35d 100644 --- a/ui/overlay-styles.ts +++ b/ui/overlay-styles.ts @@ -8,14 +8,16 @@ 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 */ @@ -39,9 +41,11 @@ export const OVERLAY_CSS = ` --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); + /* 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; @@ -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; @@ -392,8 +402,8 @@ export const OVERLAY_CSS = ` } .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--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; From 5426358ebc96d0f9592fc025a85c516f79c8bc96 Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Sun, 26 Apr 2026 12:22:31 -0400 Subject: [PATCH 07/25] feat(typography): hierarchy + theme overrides [GET-13] Four utility classes (.lco-hero / .lco-section / .lco-label / .lco-data) define a 3x+ size jump with weight extremes (200 for hero, 700 for title, 400 italic for sections). Section headers move from uppercase letterspaced gray to lowercase italic display face: editorial voice rather than corporate dashboard chrome. Title bumps to 24px display weight 700; subtitle drops to 9px mono uppercase letterspaced so the size jump reads. Active conversation subject upgrades to display italic 14px so it anchors the card. Health and zone labels shift to mono uppercase letterspaced for a status-bar 'verdict' feel. Budget status line moves to display 15px so the dollar/percentage figure is the de facto headline without restructuring markup. Font stack lists Fraunces / IBM Plex first with system fallbacks; no webfont files are loaded in this PR (deferred to GET-32). The hierarchy works on system fonts today; dropping the woff2 files in later picks up automatically. data-theme on documentElement beats prefers-color-scheme. Four themes wired: system (passthrough), dawn (light), dusk (standard dark), void (OLED true black with slate cards). --- entrypoints/sidepanel/dashboard.css | 164 ++++++++++++++++++++++++---- 1 file changed, 144 insertions(+), 20 deletions(-) diff --git a/entrypoints/sidepanel/dashboard.css b/entrypoints/sidepanel/dashboard.css index 617fa3c..7737ec1 100644 --- a/entrypoints/sidepanel/dashboard.css +++ b/entrypoints/sidepanel/dashboard.css @@ -58,13 +58,68 @@ --lco-context-fill: var(--lco-terracotta); --lco-context-track: rgba(28, 26, 24, 0.10); - --lco-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - --lco-font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; + /* 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: var(--lco-charcoal); @@ -80,6 +135,47 @@ } } +/* ── 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; @@ -147,17 +243,24 @@ body { flex-shrink: 0; } +/* Title in the display face at a real size jump from the subtitle below. + Letterspacing comes in slightly tighter than default to feel set, not typed. */ .lco-dash-title { - font-size: 18px; + font-family: var(--lco-font-display); + font-size: 24px; font-weight: 700; - letter-spacing: -0.02em; + letter-spacing: -0.03em; color: var(--lco-text); + line-height: 1.05; } .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 ───────────────────────────────────────────────────── */ @@ -175,12 +278,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); } @@ -328,18 +434,28 @@ body { .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 reads as the chapter title of the active conversation. Display + italic gives it editorial weight without shouting; size bumps from 12px + to 14px so it anchors the card. */ .lco-dash-active-subject { - font-size: 12px; - font-weight: 500; + font-family: var(--lco-font-display); + font-style: italic; + font-weight: 400; + font-size: 14px; color: var(--lco-text); margin-bottom: 10px; - line-height: 1.4; + line-height: 1.35; } .lco-dash-context-bar-container { @@ -445,18 +561,26 @@ body { .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". + Bumped to display face at 15px so the dollar / percentage figure reads as + the headline of the card without needing a separate hero markup. */ .lco-dash-budget-status { - font-size: 11px; - font-weight: 500; + font-family: var(--lco-font-display); + font-weight: 400; + font-size: 15px; color: var(--lco-text); - margin-bottom: 10px; - line-height: 1.4; + margin-bottom: 12px; + line-height: 1.35; + letter-spacing: -0.01em; + font-variant-numeric: tabular-nums; } /* Session and weekly bar rows */ From 1bac4f9aaad6d298ea85c0ff9551dcd1628cbe47 Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Sun, 26 Apr 2026 12:23:38 -0400 Subject: [PATCH 08/25] feat(header): saar sigil + settings trigger [GET-13] Replaces the dashed-border logo placeholder with an inline SVG sigil: a terra-cotta-framed square holding a heavy serif S in the display face. Pure SVG so it scales at any pixel ratio and inherits theme color via currentColor. Adds a quiet gear button on the right (muted glyph, warms to terra cotta on hover, accent-color focus ring). App.tsx tracks the drawer's open state at the root; the drawer component itself lands in the next commit and reads the same state. --- entrypoints/sidepanel/App.tsx | 12 ++- entrypoints/sidepanel/components/Header.tsx | 97 ++++++++++++++++++++- entrypoints/sidepanel/dashboard.css | 40 ++++++++- 3 files changed, 139 insertions(+), 10 deletions(-) diff --git a/entrypoints/sidepanel/App.tsx b/entrypoints/sidepanel/App.tsx index c609087..e8d317d 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'; @@ -36,10 +36,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,7 +53,7 @@ export default function App() { return (
-
+
setSettingsOpen(true)} /> {/* Today: historical, always visible regardless of active tab */} diff --git a/entrypoints/sidepanel/components/Header.tsx b/entrypoints/sidepanel/components/Header.tsx index 3a3f151..e7d5b51 100644 --- a/entrypoints/sidepanel/components/Header.tsx +++ b/entrypoints/sidepanel/components/Header.tsx @@ -1,16 +1,107 @@ // entrypoints/sidepanel/components/Header.tsx -// Logo placeholder + title. Clean, minimal header. +// Side panel header. Renders the Saar sigil + product name on the left and +// a settings trigger on the right. The sigil is an inline SVG so it scales +// crisply at any pixel ratio and inherits theme colors via currentColor; no +// PNG asset, no extension manifest icon dependency. 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

AI Usage Coach

+
); } + +/** + * Sigil mark. Letterpress feel: a square frame with the Saar "S" set in + * the display face. Pure currentColor so the mark recolors with the theme. + * 32px to match the previous placeholder; the inner glyph carries the + * brand recognition while the frame holds the visual weight. + */ +function SaarSigil(): React.ReactElement { + return ( + + {/* Subtle frame: terra cotta at low alpha so the sigil reads as + placed, not stamped. */} + + {/* Glyph: heavy serif S in terra cotta. Centered optically rather + than mathematically; the S has a top-heavy bowl, so we nudge + it down by ~0.5px to balance. */} + + S + + + ); +} + +/** + * 16px gear glyph. 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/dashboard.css b/entrypoints/sidepanel/dashboard.css index 7737ec1..692e799 100644 --- a/entrypoints/sidepanel/dashboard.css +++ b/entrypoints/sidepanel/dashboard.css @@ -235,13 +235,45 @@ body { margin-bottom: 12px; } +/* Inline SVG sigil. Sits left of the title, no border because the SVG + carries its own frame. The dashed-border placeholder this replaces was + the most visible "this is a side project" tell in the previous build. */ .lco-dash-logo { - width: 36px; - height: 36px; - border-radius: var(--lco-radius); - border: 2px dashed var(--lco-border); + width: 32px; + height: 32px; + flex-shrink: 0; + display: block; +} + +/* 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; +} /* Title in the display face at a real size jump from the subtitle below. Letterspacing comes in slightly tighter than default to feel set, not typed. */ From b7d1b876df48450182175973bad038e90f206186 Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Sun, 26 Apr 2026 12:25:25 -0400 Subject: [PATCH 09/25] feat(settings): theme + density drawer [GET-13] User preferences live behind the gear icon in the header. Renders as a native so the browser owns focus trap, Escape, and inert backdrop. The drawer fills the panel rather than sliding from the side: at 320-400px wide, a side-slide is a takeover. Two settings ship now: theme (system / dawn / dusk / void) and density (comfortable / compact). The theme tokens were laid down in the typography commit; this commit wires the radiogroup UI, persistence, and live application via documentElement.dataset. Compact density tightens vertical rhythm for power users without touching type sizes. Coaching-flavored settings (notification thresholds, currency, cost display preferences) stay out of this PR. They land with the issues that introduce the underlying coaching content (GET-21 / GET-22 / GET-28). useSettings stores under a single key so future preferences add without schema migrations: missing fields fall back to defaults, writes merge. --- entrypoints/sidepanel/App.tsx | 9 + .../sidepanel/components/SettingsDrawer.tsx | 147 ++++++++++++ entrypoints/sidepanel/dashboard.css | 220 ++++++++++++++++++ entrypoints/sidepanel/hooks/useSettings.ts | 76 ++++++ 4 files changed, 452 insertions(+) create mode 100644 entrypoints/sidepanel/components/SettingsDrawer.tsx create mode 100644 entrypoints/sidepanel/hooks/useSettings.ts diff --git a/entrypoints/sidepanel/App.tsx b/entrypoints/sidepanel/App.tsx index e8d317d..9e20cc3 100644 --- a/entrypoints/sidepanel/App.tsx +++ b/entrypoints/sidepanel/App.tsx @@ -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 { @@ -87,6 +88,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/SettingsDrawer.tsx b/entrypoints/sidepanel/components/SettingsDrawer.tsx new file mode 100644 index 0000000..3ea37f6 --- /dev/null +++ b/entrypoints/sidepanel/components/SettingsDrawer.tsx @@ -0,0 +1,147 @@ +// 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]); + + // Cmd+, on Mac and Ctrl+, elsewhere is the platform-standard shortcut for + // opening preferences. We only listen while the drawer is closed; once + // open, Esc handles dismissal natively via . + useEffect(() => { + if (open) return; + function onKey(event: KeyboardEvent): void { + const isAccelerator = (event.metaKey || event.ctrlKey) && event.key === ','; + if (isAccelerator) { + event.preventDefault(); + // The trigger lives on the parent (header), so we surface this + // by dispatching a focus on the gear if available, or rely on + // the parent to bind its own listener. Simpler: do nothing + // here and let the header listener handle it. Kept as a hook + // for future wiring without changing the public API. + } + } + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [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/dashboard.css b/entrypoints/sidepanel/dashboard.css index 692e799..2c9231c 100644 --- a/entrypoints/sidepanel/dashboard.css +++ b/entrypoints/sidepanel/dashboard.css @@ -932,4 +932,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..88d49bc --- /dev/null +++ b/entrypoints/sidepanel/hooks/useSettings.ts @@ -0,0 +1,76 @@ +// 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', +}; + +/** + * 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. + useEffect(() => { + chrome.storage.local.get(STORAGE_KEY).then((result) => { + const stored = result[STORAGE_KEY]; + if (stored && typeof stored === 'object') { + setSettings({ ...DEFAULTS, ...stored as Partial }); + } + 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. + useEffect(() => { + if (!ready) return; + 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 }; +} From 765b00859da2c1735f87679affae164fbab02929 Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Sun, 26 Apr 2026 12:26:45 -0400 Subject: [PATCH 10/25] feat(ticker): per-turn cost bars on active conversation [GET-13] One bar per recorded turn, height encodes percentage of session (or monthly, on credit accounts) consumed by that turn. Driven straight from TurnRecord.deltaUtilization which we already record on every STREAM_COMPLETE, so the ticker requires no new data plumbing: it surfaces what conversation-store already has. Bars climb across the row as the conversation grows: this is context rot made literal. The first turn is small (just the prompt), each subsequent turn pulls in more history, the bar climbs. The product's whole education is one component. Single terra-cotta color for now. Health-zone coloring (patina / brass / ember / rust per turn) lands with GET-28, which adds the per-model thresholds that decide when a delta crosses into 'degrading'. Decoupling color from this commit lets the visual ship without coupling to the threshold work. Trend indicator at the right shows up/down change between the last two turns, suppressed under 1% to avoid noise. Each bar is individually tabbable with an aria-label so screen-reader users get the same per-turn breakdown sighted users do. --- .../components/ActiveConversation.tsx | 6 ++ .../sidepanel/components/TurnTicker.tsx | 94 +++++++++++++++++++ entrypoints/sidepanel/dashboard.css | 60 ++++++++++++ 3 files changed, 160 insertions(+) create mode 100644 entrypoints/sidepanel/components/TurnTicker.tsx diff --git a/entrypoints/sidepanel/components/ActiveConversation.tsx b/entrypoints/sidepanel/components/ActiveConversation.tsx index 6e1e137..c4d7dac 100644 --- a/entrypoints/sidepanel/components/ActiveConversation.tsx +++ b/entrypoints/sidepanel/components/ActiveConversation.tsx @@ -6,6 +6,7 @@ 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 TurnTicker from './TurnTicker'; interface Props { conv: ConversationRecord | null; @@ -77,6 +78,11 @@ 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 diff --git a/entrypoints/sidepanel/components/TurnTicker.tsx b/entrypoints/sidepanel/components/TurnTicker.tsx new file mode 100644 index 0000000..0f62348 --- /dev/null +++ b/entrypoints/sidepanel/components/TurnTicker.tsx @@ -0,0 +1,94 @@ +// 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(0)}% + + )} +
+ ); +} + +/** 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; +} + +/** + * Relative change between the last two turns. We report percent of previous + * (so 0.05% -> 0.10% reads as +100%) rather than absolute delta because the + * absolute number for a single turn is too small to communicate growth. + * Suppressed when the previous turn was zero or missing. + */ +function computeTrend(previous: number | null, current: number | null): { direction: 'up' | 'down'; percent: number } | null { + if (previous === null || current === null || previous <= 0) return null; + const change = ((current - previous) / previous) * 100; + if (Math.abs(change) < 1) return null; // tiny moves read as noise + 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 2c9231c..983a220 100644 --- a/entrypoints/sidepanel/dashboard.css +++ b/entrypoints/sidepanel/dashboard.css @@ -534,6 +534,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: ' · '; From f282962fc9d1a1640ebf2498592aa12f3b4f781e Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Sun, 26 Apr 2026 12:28:07 -0400 Subject: [PATCH 11/25] feat(format): label api-equivalent cost on flat-rate plans [GET-13] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pro / Max / Free users pay a flat monthly fee, not per token; the dollar amount Saar computes against API rates is a useful relative anchor but it is not a charge. Showing a bare '$0.012' on those accounts misleads. New formatApiRateCost helper renders '≈$0.012 API rate' on session, unsupported, and unknown tiers, and the plain dollar figure on credit (Enterprise) accounts where spend is real. Wired through TodayCard and ActiveConversation; UsageBudgetCard already speaks tier-correct language via its existing variant split. Existing formatCost is left untouched so handoff-summary and other surfaces don't drift; the helper sits alongside it. --- entrypoints/sidepanel/App.tsx | 9 +++-- .../components/ActiveConversation.tsx | 9 +++-- .../sidepanel/components/TodayCard.tsx | 13 +++++-- lib/format.ts | 34 +++++++++++++++++++ 4 files changed, 56 insertions(+), 9 deletions(-) diff --git a/entrypoints/sidepanel/App.tsx b/entrypoints/sidepanel/App.tsx index 9e20cc3..4d2fe0f 100644 --- a/entrypoints/sidepanel/App.tsx +++ b/entrypoints/sidepanel/App.tsx @@ -56,9 +56,12 @@ export default function App() {
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. @@ -79,7 +82,7 @@ export default function App() { - + {/* History: org-scoped, always visible regardless of active tab */} diff --git a/entrypoints/sidepanel/components/ActiveConversation.tsx b/entrypoints/sidepanel/components/ActiveConversation.tsx index c4d7dac..9a00fb5 100644 --- a/entrypoints/sidepanel/components/ActiveConversation.tsx +++ b/entrypoints/sidepanel/components/ActiveConversation.tsx @@ -5,15 +5,18 @@ 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 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); @@ -88,7 +91,7 @@ export default function ActiveConversation({ conv, health }: Props) { {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/TodayCard.tsx b/entrypoints/sidepanel/components/TodayCard.tsx index 8ac0cae..f8fada7 100644 --- a/entrypoints/sidepanel/components/TodayCard.tsx +++ b/entrypoints/sidepanel/components/TodayCard.tsx @@ -1,15 +1,22 @@ // 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); @@ -20,7 +27,7 @@ export default function TodayCard({ summary }: Props) {
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/lib/format.ts b/lib/format.ts index 9a4ed1c..2fd75e7 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. + * + * Showing a bare "$0.012" on a Pro account misleads: the user paid $20 for + * the month, not $0.012 for that turn. We label these readings with a + * leading "≈" and a trailing "API rate" note so the meaning is clear. + * + * 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} API rate`; // session, unsupported, or unknown +} + /** * Format a model identifier for human display. * "claude-sonnet-4-6" -> "Sonnet 4.6" From 4d72796fc6f6ca3813a49462609321148362588c Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Sun, 26 Apr 2026 13:35:29 -0400 Subject: [PATCH 12/25] fix(typography): demote subject to sans, lift budget hero [GET-13] First-look review: italic display face on the active-conversation subject competed with the budget hero ($X of $Y) and the section heads, all set in the same italic Georgia. The eye lost its anchor because the card had three lines all claiming hero weight. Subject reverts to sans medium 13px so it reads as the conversation's working title without grabbing display weight that belongs to the dollar figure. Budget status line bumps from 15px to 17px upright display so it now stands alone as the only true hero on its card. Section heads (today / usage budget / active conversation / history) keep their lowercase italic display face. Confirmed in review that those read well; the body content was the problem. --- entrypoints/sidepanel/dashboard.css | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/entrypoints/sidepanel/dashboard.css b/entrypoints/sidepanel/dashboard.css index 983a220..7240e29 100644 --- a/entrypoints/sidepanel/dashboard.css +++ b/entrypoints/sidepanel/dashboard.css @@ -477,17 +477,19 @@ body { color: var(--lco-text-secondary); } -/* Subject reads as the chapter title of the active conversation. Display - italic gives it editorial weight without shouting; size bumps from 12px - to 14px so it anchors the card. */ +/* 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-family: var(--lco-font-display); - font-style: italic; - font-weight: 400; - font-size: 14px; + font-family: var(--lco-font); + font-weight: 500; + font-size: 13px; color: var(--lco-text); margin-bottom: 10px; - line-height: 1.35; + line-height: 1.4; } .lco-dash-context-bar-container { @@ -662,16 +664,17 @@ body { } /* Primary status line: "11% used; resets in 53 min" / "$83.77 of $300 spent". - Bumped to display face at 15px so the dollar / percentage figure reads as - the headline of the card without needing a separate hero markup. */ + 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-family: var(--lco-font-display); font-weight: 400; - font-size: 15px; + font-size: 17px; color: var(--lco-text); margin-bottom: 12px; - line-height: 1.35; - letter-spacing: -0.01em; + line-height: 1.3; + letter-spacing: -0.015em; font-variant-numeric: tabular-nums; } From 2de078851e8e5394b477cd022e3526f76dbfaa93 Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Sun, 26 Apr 2026 13:35:35 -0400 Subject: [PATCH 13/25] fix(format): drop API rate suffix, let the approx symbol carry it [GET-13] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First-look review: the trailing "API rate" label competed with the figure and read as jargon. The leading "≈" already carries the intended meaning (the value is approximate because it is computed from API rates rather than billed against the user's plan), so the suffix is redundant and noisy. Pro/Max/Free now render as "≈$0.012". Enterprise still renders plain because the figure is real spend. The flat-rate vs credit distinction stays intact, just quieter on the eye. --- lib/format.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/lib/format.ts b/lib/format.ts index 2fd75e7..e42af02 100644 --- a/lib/format.ts +++ b/lib/format.ts @@ -38,18 +38,18 @@ export function formatCost(cost: number | null, decimals: number = 2): string { /** * 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* + * the user per token; the dollar figure we display is the API-equivalent * cost, useful as a relative anchor but technically not a charge. * - * Showing a bare "$0.012" on a Pro account misleads: the user paid $20 for - * the month, not $0.012 for that turn. We label these readings with a - * leading "≈" and a trailing "API rate" note so the meaning is clear. + * 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. + * 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) @@ -64,7 +64,7 @@ export function formatApiRateCost( 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} API rate`; // session, unsupported, or unknown + return `≈${base}`; // session, unsupported, or unknown } /** From f6455d06f69d030ca236cc73eed17da75ecbf38a Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Sun, 26 Apr 2026 13:35:43 -0400 Subject: [PATCH 14/25] fix(header): bump gear to 18px outline glyph [GET-13] First-look review: the prior 16px filled gear sat too quietly against the 24px Saar wordmark and read as generic Material chrome. Switched to an 18px stroked outline (Lucide-style) which holds its own at the header size and reads as a deliberate icon rather than a placeholder. Still uses currentColor so it picks up muted text by default and warms to terra cotta on hover (existing rules in dashboard.css). --- entrypoints/sidepanel/components/Header.tsx | 29 ++++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/entrypoints/sidepanel/components/Header.tsx b/entrypoints/sidepanel/components/Header.tsx index e7d5b51..93c368a 100644 --- a/entrypoints/sidepanel/components/Header.tsx +++ b/entrypoints/sidepanel/components/Header.tsx @@ -81,26 +81,35 @@ function SaarSigil(): React.ReactElement { } /** - * 16px gear glyph. Uses currentColor so it picks up muted text by default - * and accent on hover (rules in dashboard.css). + * 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 ( ); From a0130e90ef069d6e5b70597f6393b039a4f9c671 Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Sun, 26 Apr 2026 16:29:51 -0400 Subject: [PATCH 15/25] fix(header): drop sigil for SAAR wordmark placeholder [GET-13] The framed-S sigil was reading as a finished mark when it was always meant to be a placeholder. Real logo is being designed separately (AA-merger custom letterform); shipping any sigil now would compete with that direction. Wordmark carries the brand alone: thin geometric all-caps in the user's system display sans, generous tracking. Side panel now reads SAAR matching the overlay (was Saar mixed-case, an inconsistency). No webfont added. --- entrypoints/sidepanel/components/Header.tsx | 62 +++------------------ entrypoints/sidepanel/dashboard.css | 30 +++++----- 2 files changed, 21 insertions(+), 71 deletions(-) diff --git a/entrypoints/sidepanel/components/Header.tsx b/entrypoints/sidepanel/components/Header.tsx index 93c368a..cc8876d 100644 --- a/entrypoints/sidepanel/components/Header.tsx +++ b/entrypoints/sidepanel/components/Header.tsx @@ -1,8 +1,11 @@ // entrypoints/sidepanel/components/Header.tsx -// Side panel header. Renders the Saar sigil + product name on the left and -// a settings trigger on the right. The sigil is an inline SVG so it scales -// crisply at any pixel ratio and inherits theme colors via currentColor; no -// PNG asset, no extension manifest icon dependency. +// 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'; @@ -15,9 +18,8 @@ interface Props { export default function Header({ onOpenSettings }: Props) { return (
-
-

Saar

+

SAAR

AI Usage Coach