From acbfaccb2d9beae07f6d46a5f07573f16d250513 Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Tue, 21 Apr 2026 21:10:39 -0400 Subject: [PATCH 1/6] refactor(overlay): promote session total to hero, remove compound rows [GET-16] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite expanded widget DOM: session total becomes the hero number (20px tabular mono), sub-label 'session total' sits below, and a single 'last reply $X.XXXX' row replaces the ~N in / ~N out / $X compound. Three rows are removed entirely: the 'Healthy/Degrading/Critical' text label (dot color is the sole signal), the divider, and the 'total N req · ~N tok · $X' session footer. Context and message-limit bars restructure to a stacked head+track layout: label left, percent right (health-colored for context, terra cotta for the limit), with the thin track rendered underneath. Header dot is now always visible; collapsed pill contract from GET-15 stays unchanged (SAAR + session total + dot). --- ui/overlay.ts | 227 ++++++++++++++++++++++---------------------------- 1 file changed, 100 insertions(+), 127 deletions(-) diff --git a/ui/overlay.ts b/ui/overlay.ts index 37eaee0..31d57c7 100644 --- a/ui/overlay.ts +++ b/ui/overlay.ts @@ -30,22 +30,20 @@ function fmtCost(c: number | null): string { export function createOverlay(): OverlayHandle { // 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; - let elHealthDot: HTMLElement | null = null; - let elHealthLabel: HTMLElement | null = null; + let elHeroCost: HTMLElement | null = null; + let elLastReplyRow: HTMLElement | null = null; + let elLastReplyValue: HTMLElement | null = null; + let elContextHead: HTMLElement | null = null; + let elContextHeadPct: HTMLElement | null = null; let elContextRow: HTMLElement | null = null; let elContextFill: HTMLElement | null = null; - let elContextLabel: HTMLElement | null = null; let elCoaching: HTMLElement | null = null; let elStartFresh: HTMLButtonElement | null = null; let startFreshCallback: (() => void) | null = null; + let elLimitHead: HTMLElement | null = null; + let elLimitHeadPct: HTMLElement | null = null; let elLimitRow: HTMLElement | null = null; let elLimitFill: HTMLElement | null = null; - let elLimitLabel: HTMLElement | null = null; - let elDivider: HTMLElement | null = null; - let elSessionRow: HTMLElement | null = null; - let elSession: HTMLElement | null = null; let elNudge: HTMLElement | null = null; let elNudgeMsg: HTMLElement | null = null; let elNudgeDismiss: HTMLButtonElement | null = null; @@ -68,7 +66,8 @@ export function createOverlay(): OverlayHandle { widget.style.display = 'none'; // hidden until first TOKEN_BATCH overlayWidget = widget; - // Header — always visible, click to collapse/expand + // Header: brand left, session-total mini (collapsed only), health dot right. + // Dot stays visible expanded and collapsed: it is the sole health signal. const header = document.createElement('div'); header.className = 'lco-header'; @@ -81,10 +80,8 @@ 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. const healthDotMini = document.createElement('span'); healthDotMini.className = 'lco-health-dot'; - healthDotMini.style.display = 'none'; elHealthDotMini = healthDotMini; header.appendChild(title); @@ -92,11 +89,11 @@ export function createOverlay(): OverlayHandle { header.appendChild(healthDotMini); widget.appendChild(header); - // Body — collapsible + // Body — collapsible. const body = document.createElement('div'); body.className = 'lco-body'; - // Draft estimate row: pre-submit cost preview (above "this reply") + // Draft estimate rows: pre-submit preview, above the hero. const draftRow = document.createElement('div'); draftRow.className = 'lco-draft-row'; draftRow.style.display = 'none'; @@ -111,50 +108,62 @@ export function createOverlay(): OverlayHandle { draftRow.appendChild(valDraft); body.appendChild(draftRow); - // Draft model comparison (hidden unless cost > 5%) const draftCompare = document.createElement('div'); draftCompare.className = 'lco-draft-compare'; draftCompare.style.display = 'none'; elDraftCompare = draftCompare; body.appendChild(draftCompare); - // Draft warning (hidden unless projected total >= 90%) const draftWarning = document.createElement('div'); draftWarning.className = 'lco-draft-warning'; draftWarning.style.display = 'none'; elDraftWarning = draftWarning; body.appendChild(draftWarning); - // Last request row - const rowLast = document.createElement('div'); - rowLast.className = 'lco-row'; + // Hero: session total cost dominates the widget. + const heroCost = document.createElement('div'); + heroCost.className = 'lco-hero-cost'; + heroCost.textContent = '$0.00'; + elHeroCost = heroCost; + body.appendChild(heroCost); + + const subLabel = document.createElement('div'); + subLabel.className = 'lco-sub-label'; + subLabel.textContent = 'session total'; + body.appendChild(subLabel); + + // Last reply: muted label + terra cotta value. Hidden until first reply. + const lastReply = document.createElement('div'); + lastReply.className = 'lco-last-reply'; + lastReply.style.display = 'none'; + elLastReplyRow = lastReply; const lblLast = document.createElement('span'); - lblLast.className = 'lco-label'; - lblLast.textContent = 'this reply'; + lblLast.className = 'lco-last-reply__label'; + lblLast.textContent = 'last reply'; const valLast = document.createElement('span'); - valLast.className = 'lco-value lco-accent'; + valLast.className = 'lco-last-reply__value'; valLast.textContent = '—'; - elCurrentRequest = valLast; - rowLast.appendChild(lblLast); - rowLast.appendChild(valLast); - body.appendChild(rowLast); - - // Health indicator: colored dot + label - const healthRow = document.createElement('div'); - healthRow.className = 'lco-health-row'; - healthRow.style.display = 'none'; - elHealthRow = healthRow; - const healthDot = document.createElement('span'); - healthDot.className = 'lco-health-dot'; - elHealthDot = healthDot; - const healthLabel = document.createElement('span'); - healthLabel.className = 'lco-health-label'; - elHealthLabel = healthLabel; - healthRow.appendChild(healthDot); - healthRow.appendChild(healthLabel); - body.appendChild(healthRow); - - // Context window bar (now below health indicator) + elLastReplyValue = valLast; + lastReply.appendChild(lblLast); + lastReply.appendChild(valLast); + body.appendChild(lastReply); + + // Context bar: stacked head (label + percent) + thin track underneath. + const ctxHead = document.createElement('div'); + ctxHead.className = 'lco-bar-head'; + ctxHead.style.display = 'none'; + elContextHead = ctxHead; + const ctxHeadLabel = document.createElement('span'); + ctxHeadLabel.className = 'lco-bar-head__label'; + ctxHeadLabel.textContent = 'context'; + const ctxHeadPct = document.createElement('span'); + ctxHeadPct.className = 'lco-bar-head__value'; + ctxHeadPct.textContent = '—%'; + elContextHeadPct = ctxHeadPct; + ctxHead.appendChild(ctxHeadLabel); + ctxHead.appendChild(ctxHeadPct); + body.appendChild(ctxHead); + const ctxRow = document.createElement('div'); ctxRow.className = 'lco-bar-row'; ctxRow.style.display = 'none'; @@ -166,22 +175,18 @@ export function createOverlay(): OverlayHandle { ctxFill.style.transform = 'scaleX(0)'; elContextFill = ctxFill; ctxTrack.appendChild(ctxFill); - const ctxLabel = document.createElement('span'); - ctxLabel.className = 'lco-bar-label'; - ctxLabel.textContent = '—% ctx'; - elContextLabel = ctxLabel; ctxRow.appendChild(ctxTrack); - ctxRow.appendChild(ctxLabel); body.appendChild(ctxRow); - // Coaching text (below context bar, from health score) + // Coaching text: full opacity, 10px, slide-up + fade on mount. const coaching = document.createElement('div'); - coaching.className = 'lco-coaching'; + coaching.className = 'lco-coaching-text'; coaching.style.display = 'none'; elCoaching = coaching; body.appendChild(coaching); - // "Start fresh" button (visible when Degrading or Critical) + // "Start fresh" button: visible when Degrading or Critical. + // Critical gets a filled variant; degrading keeps the outline. const freshBtn = document.createElement('button'); freshBtn.className = 'lco-start-fresh'; freshBtn.textContent = 'Start fresh'; @@ -192,7 +197,22 @@ export function createOverlay(): OverlayHandle { elStartFresh = freshBtn; body.appendChild(freshBtn); - // Message limit bar + // Message limit bar: same stacked layout, always terra cotta warn. + const limitHead = document.createElement('div'); + limitHead.className = 'lco-bar-head lco-bar-head--warn'; + limitHead.style.display = 'none'; + elLimitHead = limitHead; + const limitHeadLabel = document.createElement('span'); + limitHeadLabel.className = 'lco-bar-head__label'; + limitHeadLabel.textContent = 'message limit'; + const limitHeadPct = document.createElement('span'); + limitHeadPct.className = 'lco-bar-head__value'; + limitHeadPct.textContent = '—%'; + elLimitHeadPct = limitHeadPct; + limitHead.appendChild(limitHeadLabel); + limitHead.appendChild(limitHeadPct); + body.appendChild(limitHead); + const limitRow = document.createElement('div'); limitRow.className = 'lco-bar-row'; limitRow.style.display = 'none'; @@ -204,38 +224,10 @@ export function createOverlay(): OverlayHandle { limitFill.style.transform = 'scaleX(0)'; elLimitFill = limitFill; limitTrack.appendChild(limitFill); - const limitLabel = document.createElement('span'); - limitLabel.className = 'lco-bar-label'; - limitLabel.textContent = '—% limit'; - elLimitLabel = limitLabel; limitRow.appendChild(limitTrack); - limitRow.appendChild(limitLabel); body.appendChild(limitRow); - // 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 - const rowSession = document.createElement('div'); - rowSession.className = 'lco-row'; - rowSession.style.display = 'none'; - elSessionRow = rowSession; - const lblSession = document.createElement('span'); - lblSession.className = 'lco-label'; - lblSession.textContent = 'total'; - const valSession = document.createElement('span'); - valSession.className = 'lco-value'; - valSession.textContent = '—'; - elSession = valSession; - rowSession.appendChild(lblSession); - rowSession.appendChild(valSession); - body.appendChild(rowSession); - - // Nudge — hidden by default, shown by showNudge() + // Nudge — hidden by default, shown by showNudge(). const nudge = document.createElement('div'); nudge.style.display = 'none'; elNudge = nudge; @@ -251,7 +243,7 @@ export function createOverlay(): OverlayHandle { nudge.appendChild(nudgeDismiss); body.appendChild(nudge); - // Health warning — hidden by default + // Broken-state health warning (not the three-state label). const health = document.createElement('div'); health.className = 'lco-health'; health.style.display = 'none'; @@ -261,13 +253,12 @@ export function createOverlay(): OverlayHandle { widget.appendChild(body); shadow.appendChild(widget); - // Collapse/expand toggle — DOM-only concern, lives here + // Collapse/expand toggle. Dot stays visible in both states. let collapsed = false; header.addEventListener('click', () => { collapsed = !collapsed; body.classList.toggle('lco-body--collapsed', collapsed); costMini.style.display = collapsed ? '' : 'none'; - healthDotMini.style.display = collapsed ? '' : 'none'; widget.classList.toggle('lco-collapsed', collapsed); }); } @@ -317,47 +308,40 @@ export function createOverlay(): OverlayHandle { } } - if (elCurrentRequest && state.lastRequest) { - const { inputTokens, outputTokens, cost } = state.lastRequest; - // Lead with exact session % when available (Anthropic endpoint, not estimated). - // Falls back to token/cost display when delta has not yet resolved. - if (state.lastDeltaUtilization !== null) { - elCurrentRequest.textContent = - `${state.lastDeltaUtilization.toFixed(1)}% of session · ${fmtCost(cost)}`; - } else { - elCurrentRequest.textContent = - `~${fmt(inputTokens)} in · ~${fmt(outputTokens)} out · ${fmtCost(cost)}`; - } + // Hero: session total dominates. Critical health swaps terra cotta for red. + if (elHeroCost) { + const total = state.session.totalCost; + elHeroCost.textContent = total !== null && total > 0 ? fmtCost(total) : '$0.00'; + elHeroCost.classList.toggle('lco-hero-cost--critical', state.health?.level === 'critical'); } - // Health indicator: show the three-state label with colored dot. - if (elHealthRow && elHealthDot && elHealthLabel) { - const hasHealth = state.health !== null; - elHealthRow.style.display = hasHealth ? '' : 'none'; - if (hasHealth) { - const { level, label } = state.health!; - elHealthDot.className = `lco-health-dot lco-health-dot--${level}`; - elHealthLabel.textContent = label; - elHealthLabel.className = `lco-health-label lco-health-label--${level}`; + // Last reply: show when first reply lands. + if (elLastReplyRow && elLastReplyValue) { + if (state.lastRequest) { + elLastReplyValue.textContent = fmtCost(state.lastRequest.cost); + elLastReplyRow.style.display = ''; + } else { + elLastReplyRow.style.display = 'none'; } } - // Context bar: still shows the raw percentage for users who want detail. - if (elContextRow && elContextFill && elContextLabel) { + // Context bar: stacked head (label + health-colored percent) + track below. + if (elContextHead && elContextHeadPct && elContextRow && elContextFill) { const visible = state.contextPct !== null && state.contextPct > 0.1; + elContextHead.style.display = visible ? '' : 'none'; elContextRow.style.display = visible ? '' : 'none'; if (visible) { const pct = Math.min(state.contextPct!, 100); - elContextFill.style.transform = `scaleX(${pct / 100})`; - elContextLabel.textContent = `${pct.toFixed(0)}%`; - // Color the bar based on health level. const level = state.health?.level ?? 'healthy'; + elContextFill.style.transform = `scaleX(${pct / 100})`; elContextFill.className = `lco-bar-fill lco-bar-fill--${level}`; elContextFill.classList.toggle('lco-streaming', state.streaming); + elContextHeadPct.textContent = `${pct.toFixed(0)}%`; + elContextHeadPct.className = `lco-bar-head__value lco-bar-head__value--${level}`; } } - // Coaching text from the health score. + // Coaching text: from health score, rendered only when not healthy. if (elCoaching) { if (state.health && state.health.level !== 'healthy') { elCoaching.textContent = state.health.coaching; @@ -367,34 +351,25 @@ export function createOverlay(): OverlayHandle { } } - // "Start fresh" button: visible when Degrading or Critical. - // Critical gets a filled variant; degrading keeps the outline. + // "Start fresh" button: degrading outline, critical filled. if (elStartFresh) { const showFresh = state.health !== null && state.health.level !== 'healthy'; elStartFresh.style.display = showFresh ? '' : 'none'; elStartFresh.classList.toggle('lco-start-fresh--critical', state.health?.level === 'critical'); } - if (elLimitRow && elLimitFill && elLimitLabel) { + // Message limit bar: stacked head + track, always terra cotta warn tint. + if (elLimitHead && elLimitHeadPct && elLimitRow && elLimitFill) { const visible = state.messageLimitUtilization !== null; + elLimitHead.style.display = visible ? '' : 'none'; elLimitRow.style.display = visible ? '' : 'none'; if (visible) { const pct = Math.min(state.messageLimitUtilization! * 100, 100); elLimitFill.style.transform = `scaleX(${pct / 100})`; - elLimitLabel.textContent = `${pct.toFixed(0)}% limit`; + elLimitHeadPct.textContent = `${pct.toFixed(0)}%`; } } - const sessionVisible = state.session.requestCount > 0; - if (elDivider) elDivider.style.display = sessionVisible ? '' : 'none'; - if (elSessionRow) elSessionRow.style.display = sessionVisible ? '' : 'none'; - if (elSession && sessionVisible) { - const { requestCount, totalInputTokens, totalOutputTokens, totalCost } = state.session; - const total = totalInputTokens + totalOutputTokens; - elSession.textContent = - `${requestCount} req · ~${fmt(total)} tok · ${fmtCost(totalCost)}`; - } - if (elHealth) { if (state.healthBroken) { elHealth.textContent = `⚠ ${state.healthBroken}`; @@ -404,13 +379,11 @@ 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. + // Collapsed pill: SAAR + session total + health dot (GET-15 contract). if (elCostMini && state.session.requestCount > 0) { elCostMini.textContent = fmtCost(state.session.totalCost); } - // Collapsed health dot: mirrors the expanded dot color. if (elHealthDotMini) { const level = state.health?.level ?? 'healthy'; elHealthDotMini.className = `lco-health-dot lco-health-dot--${level}`; From 10b253772c04fbdbfa02560d408ff359e66844dd Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Tue, 21 Apr 2026 21:10:49 -0400 Subject: [PATCH 2/6] style(overlay): hero cost, sub-label, stacked bars, spring coaching [GET-16] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add .lco-hero-cost (20px tabular mono, -0.02em tracking, 0.2s color transition) and its --critical modifier that animates terra cotta → red. Add .lco-sub-label (8px uppercase, 0.06em tracking, 0.7 opacity) for the 'session total' caption. Add .lco-last-reply (10px flex row, muted label + accent value) for the single-line reply cost. Restructure bar styles: new .lco-bar-head pattern (label left, percent right) with --healthy / --degrading / --critical recolors for context and a --warn variant for the message-limit bar. Track grows to 4px to carry its own row underneath the head. Add .lco-coaching-text (10px muted, full opacity, no italic) and a matching lco-coaching-in keyframe (6px slide-up + fade over 0.2s spring). prefers-reduced-motion disables both the hero color transition and the coaching entrance. Remove classes no longer referenced: .lco-row, .lco-accent, .lco-divider, .lco-health-row, .lco-health-label (and its level modifiers), and the old .lco-coaching. --- ui/overlay-styles.ts | 152 ++++++++++++++++++++++++++----------------- 1 file changed, 94 insertions(+), 58 deletions(-) diff --git a/ui/overlay-styles.ts b/ui/overlay-styles.ts index a8a8c24..5fe63b7 100644 --- a/ui/overlay-styles.ts +++ b/ui/overlay-styles.ts @@ -74,6 +74,11 @@ export const OVERLAY_CSS = ` to { opacity: 0; transform: translateY(-4px); } } +@keyframes lco-coaching-in { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} + /* ── Widget container ── */ .lco-widget { @@ -132,6 +137,7 @@ export const OVERLAY_CSS = ` text-transform: uppercase; color: var(--lco-accent); opacity: 0.75; + flex: 1; } .lco-cost-mini { @@ -157,14 +163,19 @@ export const OVERLAY_CSS = ` pointer-events: none; } -/* ── Data rows ── */ +/* ── Draft estimate (pre-submit) ── */ -.lco-row { +.lco-draft-row { display: flex; align-items: baseline; gap: 5px; white-space: nowrap; overflow: hidden; + opacity: 0.7; +} + +.lco-draft-row .lco-label { + font-style: italic; } .lco-label { @@ -181,29 +192,6 @@ export const OVERLAY_CSS = ` font-variant-numeric: tabular-nums; } -.lco-accent { color: var(--lco-accent); } - -.lco-divider { - height: 1px; - background: var(--lco-border); - margin: 5px 0; -} - -/* ── Draft estimate (pre-submit) ── */ - -.lco-draft-row { - display: flex; - align-items: baseline; - gap: 5px; - white-space: nowrap; - overflow: hidden; - opacity: 0.7; -} - -.lco-draft-row .lco-label { - font-style: italic; -} - .lco-draft-compare { font-size: 9px; line-height: 1.3; @@ -219,15 +207,54 @@ export const OVERLAY_CSS = ` color: var(--lco-warn-fill); } -/* ── Health indicator ── */ +/* ── Hero cost ── */ + +.lco-hero-cost { + font-size: 20px; + font-variant-numeric: tabular-nums; + letter-spacing: -0.02em; + line-height: 1; + color: var(--lco-text); + margin: 6px 0 0; + transition: color 0.2s ease; +} + +.lco-hero-cost--critical { + color: #ef4444; +} -.lco-health-row { +.lco-sub-label { + font-size: 8px; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--lco-muted); + opacity: 0.7; + margin: 2px 0 8px; +} + +/* ── Last reply ── */ + +.lco-last-reply { display: flex; - align-items: center; - gap: 5px; - margin: 4px 0 2px; + align-items: baseline; + justify-content: space-between; + gap: 8px; + font-size: 10px; + line-height: 1.4; + margin: 4px 0; + font-variant-numeric: tabular-nums; +} + +.lco-last-reply__label { + color: var(--lco-muted); } +.lco-last-reply__value { + color: var(--lco-accent); +} + +/* ── Health dots (collapsed + expanded header) ── */ + .lco-health-dot { width: 6px; height: 6px; @@ -241,22 +268,14 @@ export const OVERLAY_CSS = ` .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; } -.lco-health-label { - font-size: 10px; - font-weight: 600; - line-height: 1.4; - /* 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; } +/* ── Coaching text ── */ -.lco-coaching { - font-size: 9px; +.lco-coaching-text { + font-size: 10px; line-height: 1.4; color: var(--lco-muted); - margin: 2px 0 3px; + margin: 6px 0; + animation: lco-coaching-in 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards; } /* ── Start fresh button ── */ @@ -318,16 +337,42 @@ export const OVERLAY_CSS = ` /* ── Progress bars ── */ +.lco-bar-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + font-size: 10px; + line-height: 1.4; + margin: 6px 0 2px; +} + +.lco-bar-head__label { + color: var(--lco-muted); +} + +.lco-bar-head__value { + font-variant-numeric: tabular-nums; + color: var(--lco-text); +} + +.lco-bar-head__value--healthy { color: #86efac; } +.lco-bar-head__value--degrading { color: #f59e0b; } +.lco-bar-head__value--critical { color: #ef4444; } + +.lco-bar-head--warn .lco-bar-head__value { + color: var(--lco-warn-fill); +} + .lco-bar-row { display: flex; align-items: center; - gap: 6px; - margin: 2px 0; + margin: 0 0 4px; } .lco-bar-track { flex: 1; - height: 3px; + height: 4px; background: var(--lco-bar-bg); border-radius: 99px; overflow: hidden; @@ -363,17 +408,6 @@ export const OVERLAY_CSS = ` animation: lco-bar-pulse 1.2s ease-in-out infinite; } -.lco-bar-label { - font-size: 9px; - line-height: 1.4; - color: var(--lco-muted); - white-space: nowrap; - font-variant-numeric: tabular-nums; - flex-shrink: 0; - min-width: 46px; - text-align: right; -} - /* ── Nudge ── */ .lco-nudge { @@ -420,7 +454,7 @@ export const OVERLAY_CSS = ` outline-offset: 2px; } -/* ── Health warning ── */ +/* ── Health warning (broken state) ── */ .lco-health { margin-top: 4px; @@ -439,6 +473,8 @@ export const OVERLAY_CSS = ` .lco-bar-fill { transition: none; } .lco-bar-fill.lco-streaming { animation: none; } .lco-health-dot--critical { animation: none; } + .lco-hero-cost { transition: none; } + .lco-coaching-text { animation: none; } .lco-start-fresh, .lco-start-fresh--critical { transition: none; } .lco-nudge, From 07144728f9c1a686dd2aa67d8689212d818b7bb5 Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Tue, 21 Apr 2026 21:12:39 -0400 Subject: [PATCH 3/6] fix(overlay): replay coaching animation on state transition, not at mount [GET-16] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pipeline MAJOR: the coaching-in animation lived on the base class with display: none at mount, so the 0.2s slide-up fired invisibly at first paint. By the time health flipped to degrading and the element became visible, the animation had already completed — users never saw the entrance. Scope the animation to a new .lco-coaching-text--entering modifier. render() only applies it when transitioning from hidden to visible, with a forced reflow between remove and add so the browser restarts the animation. Reduced-motion fallback updated to match the new class. --- ui/overlay-styles.ts | 6 +++++- ui/overlay.ts | 12 +++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/ui/overlay-styles.ts b/ui/overlay-styles.ts index 5fe63b7..15f31ae 100644 --- a/ui/overlay-styles.ts +++ b/ui/overlay-styles.ts @@ -275,6 +275,10 @@ export const OVERLAY_CSS = ` line-height: 1.4; color: var(--lco-muted); margin: 6px 0; +} + +/* Animation runs only on the transition from hidden to visible, not at mount. */ +.lco-coaching-text--entering { animation: lco-coaching-in 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards; } @@ -474,7 +478,7 @@ export const OVERLAY_CSS = ` .lco-bar-fill.lco-streaming { animation: none; } .lco-health-dot--critical { animation: none; } .lco-hero-cost { transition: none; } - .lco-coaching-text { animation: none; } + .lco-coaching-text--entering { animation: none; } .lco-start-fresh, .lco-start-fresh--critical { transition: none; } .lco-nudge, diff --git a/ui/overlay.ts b/ui/overlay.ts index 31d57c7..5cdc6db 100644 --- a/ui/overlay.ts +++ b/ui/overlay.ts @@ -342,12 +342,22 @@ export function createOverlay(): OverlayHandle { } // Coaching text: from health score, rendered only when not healthy. + // Animation is scoped to an --entering class so it replays on + // healthy→not-healthy transitions, not at mount while hidden. if (elCoaching) { if (state.health && state.health.level !== 'healthy') { + const wasHidden = elCoaching.style.display === 'none'; elCoaching.textContent = state.health.coaching; - elCoaching.style.display = ''; + if (wasHidden) { + elCoaching.style.display = ''; + elCoaching.classList.remove('lco-coaching-text--entering'); + // Force reflow so the class re-add restarts the animation. + void elCoaching.offsetWidth; + elCoaching.classList.add('lco-coaching-text--entering'); + } } else { elCoaching.style.display = 'none'; + elCoaching.classList.remove('lco-coaching-text--entering'); } } From 99be705b252cf2aa1c823d525dc3625107aa9f73 Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Tue, 21 Apr 2026 21:40:03 -0400 Subject: [PATCH 4/6] revert(overlay): drop hero layout, restore awareness-first rows [GET-16] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier commits (acbfacc, 10b2537, 0714472) rebuilt the expanded widget around a session-total hero and removed the per-reply token breakdown, the explicit Healthy/Degrading/Critical label, the divider, and the session footer — all driven by an approved design spec. Reviewing the rendered result side-by-side with the prior layout made it clear the spec was chasing visual hierarchy at the cost of user awareness. For Pro and free users (our Wave 1 target) the dollar figure is pennies and un-actionable; the information it displaced was the actual coaching surface: this reply ~N in · ~N out · $X — answers 'where are my tokens going?' • Healthy — self-documenting state 67% ctx with bar — context fullness at a glance 23% limit with bar — cap proximity 3 req · ~N tok · $X — session scope Reverting ui/overlay.ts and ui/overlay-styles.ts to their state on main restores all five. GET-15's shipped wins (semantic palette, collapsed pill with session total + dot, filled critical button) live on main and are unaffected. --- ui/overlay-styles.ts | 154 +++++++++++----------------- ui/overlay.ts | 239 +++++++++++++++++++++++-------------------- 2 files changed, 185 insertions(+), 208 deletions(-) diff --git a/ui/overlay-styles.ts b/ui/overlay-styles.ts index 15f31ae..a8a8c24 100644 --- a/ui/overlay-styles.ts +++ b/ui/overlay-styles.ts @@ -74,11 +74,6 @@ export const OVERLAY_CSS = ` to { opacity: 0; transform: translateY(-4px); } } -@keyframes lco-coaching-in { - from { opacity: 0; transform: translateY(6px); } - to { opacity: 1; transform: translateY(0); } -} - /* ── Widget container ── */ .lco-widget { @@ -137,7 +132,6 @@ export const OVERLAY_CSS = ` text-transform: uppercase; color: var(--lco-accent); opacity: 0.75; - flex: 1; } .lco-cost-mini { @@ -163,19 +157,14 @@ export const OVERLAY_CSS = ` pointer-events: none; } -/* ── Draft estimate (pre-submit) ── */ +/* ── Data rows ── */ -.lco-draft-row { +.lco-row { display: flex; align-items: baseline; gap: 5px; white-space: nowrap; overflow: hidden; - opacity: 0.7; -} - -.lco-draft-row .lco-label { - font-style: italic; } .lco-label { @@ -192,6 +181,29 @@ export const OVERLAY_CSS = ` font-variant-numeric: tabular-nums; } +.lco-accent { color: var(--lco-accent); } + +.lco-divider { + height: 1px; + background: var(--lco-border); + margin: 5px 0; +} + +/* ── Draft estimate (pre-submit) ── */ + +.lco-draft-row { + display: flex; + align-items: baseline; + gap: 5px; + white-space: nowrap; + overflow: hidden; + opacity: 0.7; +} + +.lco-draft-row .lco-label { + font-style: italic; +} + .lco-draft-compare { font-size: 9px; line-height: 1.3; @@ -207,54 +219,15 @@ export const OVERLAY_CSS = ` color: var(--lco-warn-fill); } -/* ── Hero cost ── */ - -.lco-hero-cost { - font-size: 20px; - font-variant-numeric: tabular-nums; - letter-spacing: -0.02em; - line-height: 1; - color: var(--lco-text); - margin: 6px 0 0; - transition: color 0.2s ease; -} - -.lco-hero-cost--critical { - color: #ef4444; -} +/* ── Health indicator ── */ -.lco-sub-label { - font-size: 8px; - letter-spacing: 0.06em; - text-transform: uppercase; - color: var(--lco-muted); - opacity: 0.7; - margin: 2px 0 8px; -} - -/* ── Last reply ── */ - -.lco-last-reply { +.lco-health-row { display: flex; - align-items: baseline; - justify-content: space-between; - gap: 8px; - font-size: 10px; - line-height: 1.4; - margin: 4px 0; - font-variant-numeric: tabular-nums; -} - -.lco-last-reply__label { - color: var(--lco-muted); -} - -.lco-last-reply__value { - color: var(--lco-accent); + align-items: center; + gap: 5px; + margin: 4px 0 2px; } -/* ── Health dots (collapsed + expanded header) ── */ - .lco-health-dot { width: 6px; height: 6px; @@ -268,18 +241,22 @@ export const OVERLAY_CSS = ` .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; } -/* ── Coaching text ── */ - -.lco-coaching-text { +.lco-health-label { font-size: 10px; + font-weight: 600; line-height: 1.4; - color: var(--lco-muted); - margin: 6px 0; + /* No transition: color is a paint property; health state changes snap instantly. */ } -/* Animation runs only on the transition from hidden to visible, not at mount. */ -.lco-coaching-text--entering { - animation: lco-coaching-in 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards; +.lco-health-label--healthy { color: #86efac; } +.lco-health-label--degrading { color: #f59e0b; } +.lco-health-label--critical { color: #ef4444; } + +.lco-coaching { + font-size: 9px; + line-height: 1.4; + color: var(--lco-muted); + margin: 2px 0 3px; } /* ── Start fresh button ── */ @@ -341,42 +318,16 @@ export const OVERLAY_CSS = ` /* ── Progress bars ── */ -.lco-bar-head { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 8px; - font-size: 10px; - line-height: 1.4; - margin: 6px 0 2px; -} - -.lco-bar-head__label { - color: var(--lco-muted); -} - -.lco-bar-head__value { - font-variant-numeric: tabular-nums; - color: var(--lco-text); -} - -.lco-bar-head__value--healthy { color: #86efac; } -.lco-bar-head__value--degrading { color: #f59e0b; } -.lco-bar-head__value--critical { color: #ef4444; } - -.lco-bar-head--warn .lco-bar-head__value { - color: var(--lco-warn-fill); -} - .lco-bar-row { display: flex; align-items: center; - margin: 0 0 4px; + gap: 6px; + margin: 2px 0; } .lco-bar-track { flex: 1; - height: 4px; + height: 3px; background: var(--lco-bar-bg); border-radius: 99px; overflow: hidden; @@ -412,6 +363,17 @@ export const OVERLAY_CSS = ` animation: lco-bar-pulse 1.2s ease-in-out infinite; } +.lco-bar-label { + font-size: 9px; + line-height: 1.4; + color: var(--lco-muted); + white-space: nowrap; + font-variant-numeric: tabular-nums; + flex-shrink: 0; + min-width: 46px; + text-align: right; +} + /* ── Nudge ── */ .lco-nudge { @@ -458,7 +420,7 @@ export const OVERLAY_CSS = ` outline-offset: 2px; } -/* ── Health warning (broken state) ── */ +/* ── Health warning ── */ .lco-health { margin-top: 4px; @@ -477,8 +439,6 @@ export const OVERLAY_CSS = ` .lco-bar-fill { transition: none; } .lco-bar-fill.lco-streaming { animation: none; } .lco-health-dot--critical { animation: none; } - .lco-hero-cost { transition: none; } - .lco-coaching-text--entering { animation: none; } .lco-start-fresh, .lco-start-fresh--critical { transition: none; } .lco-nudge, diff --git a/ui/overlay.ts b/ui/overlay.ts index 5cdc6db..37eaee0 100644 --- a/ui/overlay.ts +++ b/ui/overlay.ts @@ -30,20 +30,22 @@ function fmtCost(c: number | null): string { export function createOverlay(): OverlayHandle { // DOM refs — null until mount() is called. render() is a no-op until then. let overlayWidget: HTMLDivElement | null = null; - let elHeroCost: HTMLElement | null = null; - let elLastReplyRow: HTMLElement | null = null; - let elLastReplyValue: HTMLElement | null = null; - let elContextHead: HTMLElement | null = null; - let elContextHeadPct: HTMLElement | null = null; + let elCurrentRequest: HTMLElement | null = null; + let elHealthRow: HTMLElement | null = null; + let elHealthDot: HTMLElement | null = null; + let elHealthLabel: HTMLElement | null = null; let elContextRow: HTMLElement | null = null; let elContextFill: HTMLElement | null = null; + let elContextLabel: HTMLElement | null = null; let elCoaching: HTMLElement | null = null; let elStartFresh: HTMLButtonElement | null = null; let startFreshCallback: (() => void) | null = null; - let elLimitHead: HTMLElement | null = null; - let elLimitHeadPct: HTMLElement | null = null; let elLimitRow: HTMLElement | null = null; let elLimitFill: HTMLElement | null = null; + let elLimitLabel: HTMLElement | null = null; + let elDivider: HTMLElement | null = null; + let elSessionRow: HTMLElement | null = null; + let elSession: HTMLElement | null = null; let elNudge: HTMLElement | null = null; let elNudgeMsg: HTMLElement | null = null; let elNudgeDismiss: HTMLButtonElement | null = null; @@ -66,8 +68,7 @@ export function createOverlay(): OverlayHandle { widget.style.display = 'none'; // hidden until first TOKEN_BATCH overlayWidget = widget; - // Header: brand left, session-total mini (collapsed only), health dot right. - // Dot stays visible expanded and collapsed: it is the sole health signal. + // Header — always visible, click to collapse/expand const header = document.createElement('div'); header.className = 'lco-header'; @@ -80,8 +81,10 @@ 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. const healthDotMini = document.createElement('span'); healthDotMini.className = 'lco-health-dot'; + healthDotMini.style.display = 'none'; elHealthDotMini = healthDotMini; header.appendChild(title); @@ -89,11 +92,11 @@ export function createOverlay(): OverlayHandle { header.appendChild(healthDotMini); widget.appendChild(header); - // Body — collapsible. + // Body — collapsible const body = document.createElement('div'); body.className = 'lco-body'; - // Draft estimate rows: pre-submit preview, above the hero. + // Draft estimate row: pre-submit cost preview (above "this reply") const draftRow = document.createElement('div'); draftRow.className = 'lco-draft-row'; draftRow.style.display = 'none'; @@ -108,62 +111,50 @@ export function createOverlay(): OverlayHandle { draftRow.appendChild(valDraft); body.appendChild(draftRow); + // Draft model comparison (hidden unless cost > 5%) const draftCompare = document.createElement('div'); draftCompare.className = 'lco-draft-compare'; draftCompare.style.display = 'none'; elDraftCompare = draftCompare; body.appendChild(draftCompare); + // Draft warning (hidden unless projected total >= 90%) const draftWarning = document.createElement('div'); draftWarning.className = 'lco-draft-warning'; draftWarning.style.display = 'none'; elDraftWarning = draftWarning; body.appendChild(draftWarning); - // Hero: session total cost dominates the widget. - const heroCost = document.createElement('div'); - heroCost.className = 'lco-hero-cost'; - heroCost.textContent = '$0.00'; - elHeroCost = heroCost; - body.appendChild(heroCost); - - const subLabel = document.createElement('div'); - subLabel.className = 'lco-sub-label'; - subLabel.textContent = 'session total'; - body.appendChild(subLabel); - - // Last reply: muted label + terra cotta value. Hidden until first reply. - const lastReply = document.createElement('div'); - lastReply.className = 'lco-last-reply'; - lastReply.style.display = 'none'; - elLastReplyRow = lastReply; + // Last request row + const rowLast = document.createElement('div'); + rowLast.className = 'lco-row'; const lblLast = document.createElement('span'); - lblLast.className = 'lco-last-reply__label'; - lblLast.textContent = 'last reply'; + lblLast.className = 'lco-label'; + lblLast.textContent = 'this reply'; const valLast = document.createElement('span'); - valLast.className = 'lco-last-reply__value'; + valLast.className = 'lco-value lco-accent'; valLast.textContent = '—'; - elLastReplyValue = valLast; - lastReply.appendChild(lblLast); - lastReply.appendChild(valLast); - body.appendChild(lastReply); - - // Context bar: stacked head (label + percent) + thin track underneath. - const ctxHead = document.createElement('div'); - ctxHead.className = 'lco-bar-head'; - ctxHead.style.display = 'none'; - elContextHead = ctxHead; - const ctxHeadLabel = document.createElement('span'); - ctxHeadLabel.className = 'lco-bar-head__label'; - ctxHeadLabel.textContent = 'context'; - const ctxHeadPct = document.createElement('span'); - ctxHeadPct.className = 'lco-bar-head__value'; - ctxHeadPct.textContent = '—%'; - elContextHeadPct = ctxHeadPct; - ctxHead.appendChild(ctxHeadLabel); - ctxHead.appendChild(ctxHeadPct); - body.appendChild(ctxHead); - + elCurrentRequest = valLast; + rowLast.appendChild(lblLast); + rowLast.appendChild(valLast); + body.appendChild(rowLast); + + // Health indicator: colored dot + label + const healthRow = document.createElement('div'); + healthRow.className = 'lco-health-row'; + healthRow.style.display = 'none'; + elHealthRow = healthRow; + const healthDot = document.createElement('span'); + healthDot.className = 'lco-health-dot'; + elHealthDot = healthDot; + const healthLabel = document.createElement('span'); + healthLabel.className = 'lco-health-label'; + elHealthLabel = healthLabel; + healthRow.appendChild(healthDot); + healthRow.appendChild(healthLabel); + body.appendChild(healthRow); + + // Context window bar (now below health indicator) const ctxRow = document.createElement('div'); ctxRow.className = 'lco-bar-row'; ctxRow.style.display = 'none'; @@ -175,18 +166,22 @@ export function createOverlay(): OverlayHandle { ctxFill.style.transform = 'scaleX(0)'; elContextFill = ctxFill; ctxTrack.appendChild(ctxFill); + const ctxLabel = document.createElement('span'); + ctxLabel.className = 'lco-bar-label'; + ctxLabel.textContent = '—% ctx'; + elContextLabel = ctxLabel; ctxRow.appendChild(ctxTrack); + ctxRow.appendChild(ctxLabel); body.appendChild(ctxRow); - // Coaching text: full opacity, 10px, slide-up + fade on mount. + // Coaching text (below context bar, from health score) const coaching = document.createElement('div'); - coaching.className = 'lco-coaching-text'; + coaching.className = 'lco-coaching'; coaching.style.display = 'none'; elCoaching = coaching; body.appendChild(coaching); - // "Start fresh" button: visible when Degrading or Critical. - // Critical gets a filled variant; degrading keeps the outline. + // "Start fresh" button (visible when Degrading or Critical) const freshBtn = document.createElement('button'); freshBtn.className = 'lco-start-fresh'; freshBtn.textContent = 'Start fresh'; @@ -197,22 +192,7 @@ export function createOverlay(): OverlayHandle { elStartFresh = freshBtn; body.appendChild(freshBtn); - // Message limit bar: same stacked layout, always terra cotta warn. - const limitHead = document.createElement('div'); - limitHead.className = 'lco-bar-head lco-bar-head--warn'; - limitHead.style.display = 'none'; - elLimitHead = limitHead; - const limitHeadLabel = document.createElement('span'); - limitHeadLabel.className = 'lco-bar-head__label'; - limitHeadLabel.textContent = 'message limit'; - const limitHeadPct = document.createElement('span'); - limitHeadPct.className = 'lco-bar-head__value'; - limitHeadPct.textContent = '—%'; - elLimitHeadPct = limitHeadPct; - limitHead.appendChild(limitHeadLabel); - limitHead.appendChild(limitHeadPct); - body.appendChild(limitHead); - + // Message limit bar const limitRow = document.createElement('div'); limitRow.className = 'lco-bar-row'; limitRow.style.display = 'none'; @@ -224,10 +204,38 @@ export function createOverlay(): OverlayHandle { limitFill.style.transform = 'scaleX(0)'; elLimitFill = limitFill; limitTrack.appendChild(limitFill); + const limitLabel = document.createElement('span'); + limitLabel.className = 'lco-bar-label'; + limitLabel.textContent = '—% limit'; + elLimitLabel = limitLabel; limitRow.appendChild(limitTrack); + limitRow.appendChild(limitLabel); body.appendChild(limitRow); - // Nudge — hidden by default, shown by showNudge(). + // 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 + const rowSession = document.createElement('div'); + rowSession.className = 'lco-row'; + rowSession.style.display = 'none'; + elSessionRow = rowSession; + const lblSession = document.createElement('span'); + lblSession.className = 'lco-label'; + lblSession.textContent = 'total'; + const valSession = document.createElement('span'); + valSession.className = 'lco-value'; + valSession.textContent = '—'; + elSession = valSession; + rowSession.appendChild(lblSession); + rowSession.appendChild(valSession); + body.appendChild(rowSession); + + // Nudge — hidden by default, shown by showNudge() const nudge = document.createElement('div'); nudge.style.display = 'none'; elNudge = nudge; @@ -243,7 +251,7 @@ export function createOverlay(): OverlayHandle { nudge.appendChild(nudgeDismiss); body.appendChild(nudge); - // Broken-state health warning (not the three-state label). + // Health warning — hidden by default const health = document.createElement('div'); health.className = 'lco-health'; health.style.display = 'none'; @@ -253,12 +261,13 @@ export function createOverlay(): OverlayHandle { widget.appendChild(body); shadow.appendChild(widget); - // Collapse/expand toggle. Dot stays visible in both states. + // Collapse/expand toggle — DOM-only concern, lives here let collapsed = false; header.addEventListener('click', () => { collapsed = !collapsed; body.classList.toggle('lco-body--collapsed', collapsed); costMini.style.display = collapsed ? '' : 'none'; + healthDotMini.style.display = collapsed ? '' : 'none'; widget.classList.toggle('lco-collapsed', collapsed); }); } @@ -308,78 +317,84 @@ export function createOverlay(): OverlayHandle { } } - // Hero: session total dominates. Critical health swaps terra cotta for red. - if (elHeroCost) { - const total = state.session.totalCost; - elHeroCost.textContent = total !== null && total > 0 ? fmtCost(total) : '$0.00'; - elHeroCost.classList.toggle('lco-hero-cost--critical', state.health?.level === 'critical'); + if (elCurrentRequest && state.lastRequest) { + const { inputTokens, outputTokens, cost } = state.lastRequest; + // Lead with exact session % when available (Anthropic endpoint, not estimated). + // Falls back to token/cost display when delta has not yet resolved. + if (state.lastDeltaUtilization !== null) { + elCurrentRequest.textContent = + `${state.lastDeltaUtilization.toFixed(1)}% of session · ${fmtCost(cost)}`; + } else { + elCurrentRequest.textContent = + `~${fmt(inputTokens)} in · ~${fmt(outputTokens)} out · ${fmtCost(cost)}`; + } } - // Last reply: show when first reply lands. - if (elLastReplyRow && elLastReplyValue) { - if (state.lastRequest) { - elLastReplyValue.textContent = fmtCost(state.lastRequest.cost); - elLastReplyRow.style.display = ''; - } else { - elLastReplyRow.style.display = 'none'; + // Health indicator: show the three-state label with colored dot. + if (elHealthRow && elHealthDot && elHealthLabel) { + const hasHealth = state.health !== null; + elHealthRow.style.display = hasHealth ? '' : 'none'; + if (hasHealth) { + const { level, label } = state.health!; + elHealthDot.className = `lco-health-dot lco-health-dot--${level}`; + elHealthLabel.textContent = label; + elHealthLabel.className = `lco-health-label lco-health-label--${level}`; } } - // Context bar: stacked head (label + health-colored percent) + track below. - if (elContextHead && elContextHeadPct && elContextRow && elContextFill) { + // Context bar: still shows the raw percentage for users who want detail. + if (elContextRow && elContextFill && elContextLabel) { const visible = state.contextPct !== null && state.contextPct > 0.1; - elContextHead.style.display = visible ? '' : 'none'; elContextRow.style.display = visible ? '' : 'none'; if (visible) { const pct = Math.min(state.contextPct!, 100); - const level = state.health?.level ?? 'healthy'; elContextFill.style.transform = `scaleX(${pct / 100})`; + elContextLabel.textContent = `${pct.toFixed(0)}%`; + // Color the bar based on health level. + const level = state.health?.level ?? 'healthy'; elContextFill.className = `lco-bar-fill lco-bar-fill--${level}`; elContextFill.classList.toggle('lco-streaming', state.streaming); - elContextHeadPct.textContent = `${pct.toFixed(0)}%`; - elContextHeadPct.className = `lco-bar-head__value lco-bar-head__value--${level}`; } } - // Coaching text: from health score, rendered only when not healthy. - // Animation is scoped to an --entering class so it replays on - // healthy→not-healthy transitions, not at mount while hidden. + // Coaching text from the health score. if (elCoaching) { if (state.health && state.health.level !== 'healthy') { - const wasHidden = elCoaching.style.display === 'none'; elCoaching.textContent = state.health.coaching; - if (wasHidden) { - elCoaching.style.display = ''; - elCoaching.classList.remove('lco-coaching-text--entering'); - // Force reflow so the class re-add restarts the animation. - void elCoaching.offsetWidth; - elCoaching.classList.add('lco-coaching-text--entering'); - } + elCoaching.style.display = ''; } else { elCoaching.style.display = 'none'; - elCoaching.classList.remove('lco-coaching-text--entering'); } } - // "Start fresh" button: degrading outline, critical filled. + // "Start fresh" button: visible when Degrading or Critical. + // Critical gets a filled variant; degrading keeps the outline. if (elStartFresh) { const showFresh = state.health !== null && state.health.level !== 'healthy'; elStartFresh.style.display = showFresh ? '' : 'none'; elStartFresh.classList.toggle('lco-start-fresh--critical', state.health?.level === 'critical'); } - // Message limit bar: stacked head + track, always terra cotta warn tint. - if (elLimitHead && elLimitHeadPct && elLimitRow && elLimitFill) { + if (elLimitRow && elLimitFill && elLimitLabel) { const visible = state.messageLimitUtilization !== null; - elLimitHead.style.display = visible ? '' : 'none'; elLimitRow.style.display = visible ? '' : 'none'; if (visible) { const pct = Math.min(state.messageLimitUtilization! * 100, 100); elLimitFill.style.transform = `scaleX(${pct / 100})`; - elLimitHeadPct.textContent = `${pct.toFixed(0)}%`; + elLimitLabel.textContent = `${pct.toFixed(0)}% limit`; } } + const sessionVisible = state.session.requestCount > 0; + if (elDivider) elDivider.style.display = sessionVisible ? '' : 'none'; + if (elSessionRow) elSessionRow.style.display = sessionVisible ? '' : 'none'; + if (elSession && sessionVisible) { + const { requestCount, totalInputTokens, totalOutputTokens, totalCost } = state.session; + const total = totalInputTokens + totalOutputTokens; + elSession.textContent = + `${requestCount} req · ~${fmt(total)} tok · ${fmtCost(totalCost)}`; + } + if (elHealth) { if (state.healthBroken) { elHealth.textContent = `⚠ ${state.healthBroken}`; @@ -389,11 +404,13 @@ export function createOverlay(): OverlayHandle { } } - // Collapsed pill: SAAR + session total + health dot (GET-15 contract). + // Collapsed pill: show session total (not last reply cost). + // 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); } + // Collapsed health dot: mirrors the expanded dot color. if (elHealthDotMini) { const level = state.health?.level ?? 'healthy'; elHealthDotMini.className = `lco-health-dot lco-health-dot--${level}`; From 7f7c089b3b7abbb2af4001c5d4051f06f408ee01 Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Tue, 21 Apr 2026 21:40:32 -0400 Subject: [PATCH 5/6] feat(overlay): name session scope in turns, bump coaching to 10px [GET-16] Two small awareness wins on top of the revert: - Session footer renders 'N turns' (not 'N req'). 'req' was dev jargon; 'turn' is the word Claude itself uses for a user+assistant exchange, and it matches how users count their own progress through a chat. Pluralization handles 1 turn vs 2+ turns. - Coaching text bumps from 9px to 10px. At default claude.ai zoom the 9px line read as noise. 10px is scannable without dominating the surrounding rows. --- ui/overlay-styles.ts | 2 +- ui/overlay.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/ui/overlay-styles.ts b/ui/overlay-styles.ts index a8a8c24..9839255 100644 --- a/ui/overlay-styles.ts +++ b/ui/overlay-styles.ts @@ -253,7 +253,7 @@ export const OVERLAY_CSS = ` .lco-health-label--critical { color: #ef4444; } .lco-coaching { - font-size: 9px; + font-size: 10px; line-height: 1.4; color: var(--lco-muted); margin: 2px 0 3px; diff --git a/ui/overlay.ts b/ui/overlay.ts index 37eaee0..f1eba3c 100644 --- a/ui/overlay.ts +++ b/ui/overlay.ts @@ -391,8 +391,9 @@ export function createOverlay(): OverlayHandle { if (elSession && sessionVisible) { const { requestCount, totalInputTokens, totalOutputTokens, totalCost } = state.session; const total = totalInputTokens + totalOutputTokens; + const turnLabel = requestCount === 1 ? 'turn' : 'turns'; elSession.textContent = - `${requestCount} req · ~${fmt(total)} tok · ${fmtCost(totalCost)}`; + `${requestCount} ${turnLabel} · ~${fmt(total)} tok · ${fmtCost(totalCost)}`; } if (elHealth) { From 8bc9dfe0b1b02ea47a21b708ee75c2edee15b4b7 Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Tue, 21 Apr 2026 22:22:56 -0400 Subject: [PATCH 6/6] fix(format): auto-promote formatCost to 4 decimals for fractional amounts [GET-16] Same conversation was rendering as $0.00 in the side panel (2 decimals) and $0.0029 in the overlay (4 decimals). Users saw 'free' in one place and 'costs money' in another for the exact same number. formatCost now promotes to 4 decimals when a positive cost is under $0.01 and the caller is using the default precision. Large amounts stay at 2 decimals ($1.50, $100.50) and explicit decimals arguments still win (decimals: 6 for the fuzz tests, decimals: 4 for per-request surfaces). Null and zero behavior unchanged. Three new unit tests cover the promotion, the $0.01 boundary, and the explicit-override precedence. --- lib/format.ts | 13 ++++++++++++- tests/audit/format-audit.test.ts | 13 +++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/lib/format.ts b/lib/format.ts index 2d3b171..9a4ed1c 100644 --- a/lib/format.ts +++ b/lib/format.ts @@ -14,11 +14,22 @@ export function formatTokens(n: number): string { /** * Format cost in dollars. - * null -> "$0.00*" (unknown model), 0.0073 -> "$0.0073", 1.5 -> "$1.50" + * null -> "$0.00*" (unknown model), 1.5 -> "$1.50", 100 -> "$100.00". + * + * Auto-promotes to 4 decimals when a positive fractional amount would otherwise + * round to "$0.00" at the default 2-decimal precision. Keeps displayed cost + * consistent across surfaces: a $0.0029 session reads as $0.0029 in both the + * overlay and the side panel, never $0.00 on one and $0.0029 on the other. + * Only activates when the caller accepts the default precision; explicit + * decimals arguments (e.g. decimals: 6) are respected as-is. + * * @param decimals - number of decimal places (default 2 for dashboard, use 4 for per-request overlay) */ export function formatCost(cost: number | null, decimals: number = 2): string { if (cost === null) return '$0.00*'; + if (decimals === 2 && cost > 0 && cost < 0.01) { + return `$${cost.toFixed(4)}`; + } return `$${cost.toFixed(decimals)}`; } diff --git a/tests/audit/format-audit.test.ts b/tests/audit/format-audit.test.ts index 0b74de4..986a951 100644 --- a/tests/audit/format-audit.test.ts +++ b/tests/audit/format-audit.test.ts @@ -57,6 +57,19 @@ describe('formatCost', () => { expect(formatCost(0.0073, 4)).toBe('$0.0073'); }); + test('fractional cost under $0.01 auto-promotes to 4 decimals at default', () => { + expect(formatCost(0.0029)).toBe('$0.0029'); + expect(formatCost(0.009)).toBe('$0.0090'); + }); + + test('fractional cost at $0.01 boundary stays 2 decimals', () => { + expect(formatCost(0.01)).toBe('$0.01'); + }); + + test('explicit decimals override wins over auto-promotion', () => { + expect(formatCost(0.0029, 4)).toBe('$0.0029'); + }); + test('large cost', () => { expect(formatCost(100.5)).toBe('$100.50'); });