Preserve activity stats contrast across desktop themes

The activity heatmap was reusing broad primary and inverse tokens whose
meaning changes by theme. The usage tooltip became unreadable in dark mode,
and nearby status badges referenced semantic container tokens that were not
available in every theme. This introduces activity-specific theme tokens and
locks the token surface with focused regression coverage.

Constraint: Desktop supports light, white, and dark themes from globals.css
Rejected: Change global primary or inverse tokens | broader blast radius across unrelated controls
Confidence: high
Scope-risk: narrow
Directive: Keep activity heatmap colors behind activity-specific tokens before changing palette behavior
Tested: cd desktop && bun run test ActivitySettings.test.tsx globals.test.ts
Tested: bun run check:desktop
Tested: Browser smoke on dark Token usage tooltip at http://127.0.0.1:5179
Not-tested: Native packaged app visual smoke
This commit is contained in:
程序员阿江(Relakkes) 2026-05-13 18:48:00 +08:00
parent 523db62b3b
commit f1e3f4bd20
4 changed files with 121 additions and 11 deletions

View File

@ -112,6 +112,9 @@ describe('ActivitySettings', () => {
expect(tooltip).toHaveTextContent('May 9, 2026')
expect(tooltip).toHaveTextContent('4 sessions · 128K tokens')
expect(tooltip).not.toHaveTextContent(/messages|tools/i)
expect(tooltip.className).toContain('--color-activity-tooltip-surface')
expect(tooltip.className).toContain('--color-activity-tooltip-border')
expect(todayCell.className).toContain('--color-activity-cell-border')
expect(screen.queryByText('Selected day')).not.toBeInTheDocument()
})
})

View File

@ -30,11 +30,11 @@ const HEAT_CELL_MIN = 6
const HEAT_CELL_MAX = 22
const TOOLTIP_WIDTH = 172
const HEAT_COLORS = [
'var(--color-surface-container)',
'var(--color-primary-fixed)',
'var(--color-primary-fixed-dim)',
'var(--color-primary-container)',
'var(--color-primary)',
'var(--color-activity-heat-0)',
'var(--color-activity-heat-1)',
'var(--color-activity-heat-2)',
'var(--color-activity-heat-3)',
'var(--color-activity-heat-4)',
]
const DATE_LOCALES: Record<Locale, string> = {
en: 'en-US',
@ -380,8 +380,8 @@ export function ActivitySettings() {
aria-describedby={activeTooltipDate === day.date ? tooltipId : undefined}
className={`rounded-[3px] border transition-[border-color,transform] hover:scale-110 focus:outline-none focus:ring-2 focus:ring-[var(--color-brand)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface)] ${
isSelected
? 'border-[var(--color-text-primary)]'
: 'border-[var(--color-border)]/30 hover:border-[var(--color-text-secondary)]'
? 'border-[var(--color-activity-cell-border-active)]'
: 'border-[var(--color-activity-cell-border)] hover:border-[var(--color-activity-cell-border-hover)]'
}`}
style={{
width: heatCellSize,
@ -401,11 +401,11 @@ export function ActivitySettings() {
<div
id={`activity-day-tooltip-${tooltipDay.date}`}
role="tooltip"
className="pointer-events-none absolute z-20 min-w-[172px] rounded-md border border-[var(--color-inverse-surface)] bg-[var(--color-inverse-surface)] px-3 py-2 text-xs shadow-xl"
className="pointer-events-none absolute z-20 min-w-[172px] rounded-md border border-[var(--color-activity-tooltip-border)] bg-[var(--color-activity-tooltip-surface)] px-3 py-2 text-xs shadow-xl"
style={tooltipStyle}
>
<div className="font-medium text-[var(--color-inverse-on-surface)]">{formatDateLabel(tooltipDay.date, locale)}</div>
<div className="mt-1 text-[var(--color-primary-fixed)]">
<div className="font-medium text-[var(--color-activity-tooltip-text)]">{formatDateLabel(tooltipDay.date, locale)}</div>
<div className="mt-1 text-[var(--color-activity-tooltip-muted)]">
{formatSessionCount(tooltipDay.sessionCount, t)} · {formatTokens(tooltipDay.tokens)} tokens
</div>
</div>
@ -419,7 +419,7 @@ export function ActivitySettings() {
<span
key={color}
aria-hidden="true"
className="rounded-[3px] border border-[var(--color-border)]/30"
className="rounded-[3px] border border-[var(--color-activity-cell-border)]"
style={{ width: heatCellSize, height: heatCellSize, backgroundColor: color }}
/>
))}

View File

@ -148,7 +148,11 @@
--color-inverse-primary: #FFB59D;
--color-success: #16A34A;
--color-success-container: #E8F5E2;
--color-info: #2D628F;
--color-info-container: #E4F1FF;
--color-warning: #CA8A04;
--color-warning-container: #FFF3D6;
--font-headline: 'Manrope', sans-serif;
--font-body: 'Inter', sans-serif;
@ -175,6 +179,22 @@
--color-surface-selected: var(--color-surface-container);
--color-model-option-selected-bg: var(--color-primary-fixed);
--color-model-option-selected-border: rgba(143, 72, 47, 0.2);
--color-activity-heat-0: var(--color-surface-container);
--color-activity-heat-1: #FFE6DE;
--color-activity-heat-2: #FFBCA8;
--color-activity-heat-3: #E57A58;
--color-activity-heat-4: #8F482F;
--color-activity-cell-border: rgba(135, 115, 109, 0.3);
--color-activity-cell-border-hover: rgba(84, 67, 62, 0.72);
--color-activity-cell-border-active: var(--color-text-primary);
--color-activity-tooltip-surface: #2F312E;
--color-activity-tooltip-border: rgba(255, 255, 255, 0.12);
--color-activity-tooltip-text: #F2F1ED;
--color-activity-tooltip-muted: #FFB59D;
--color-success-container: #E8F5E2;
--color-info: #2D628F;
--color-info-container: #E4F1FF;
--color-warning-container: #FFF3D6;
--color-inspector-surface: #FBFAF6;
--color-inspector-panel: #F4F2ED;
--color-inspector-chip: #EBE7DF;
@ -335,13 +355,29 @@
--color-inverse-primary: #FFB59D;
--color-success: #087E4F;
--color-success-container: #DCFAE6;
--color-info: #0F5C8F;
--color-info-container: #D9ECFF;
--color-warning: #B54708;
--color-warning-container: #FEF0C7;
--color-surface-sidebar: #F1F4F7;
--color-surface-hover: #F2F5F8;
--color-surface-selected: #E9EEF3;
--color-model-option-selected-bg: #FFF0EA;
--color-model-option-selected-border: rgba(143, 72, 47, 0.2);
--color-activity-heat-0: #EEF2F6;
--color-activity-heat-1: #FFE9E1;
--color-activity-heat-2: #FFC4B1;
--color-activity-heat-3: #E57A58;
--color-activity-heat-4: #8F482F;
--color-activity-cell-border: rgba(139, 152, 167, 0.32);
--color-activity-cell-border-hover: rgba(75, 85, 99, 0.72);
--color-activity-cell-border-active: var(--color-text-primary);
--color-activity-tooltip-surface: #111827;
--color-activity-tooltip-border: rgba(255, 255, 255, 0.14);
--color-activity-tooltip-text: #F9FAFB;
--color-activity-tooltip-muted: #FFC4B1;
--color-inspector-surface: #FFFFFF;
--color-inspector-panel: #F7F9FB;
--color-inspector-chip: #EEF3F6;
@ -483,13 +519,29 @@
--color-inverse-primary: #8F482F;
--color-success: #7EDB8B;
--color-success-container: rgba(126, 219, 139, 0.16);
--color-info: #74E0F7;
--color-info-container: rgba(116, 224, 247, 0.16);
--color-warning: #F7C46C;
--color-warning-container: rgba(247, 196, 108, 0.16);
--color-surface-sidebar: #171615;
--color-surface-hover: var(--color-surface-container-highest);
--color-surface-selected: var(--color-surface-container);
--color-model-option-selected-bg: rgba(255, 181, 159, 0.13);
--color-model-option-selected-border: rgba(255, 181, 159, 0.34);
--color-activity-heat-0: #242322;
--color-activity-heat-1: #4A2B24;
--color-activity-heat-2: #85432F;
--color-activity-heat-3: #C85F3C;
--color-activity-heat-4: #FF8A5C;
--color-activity-cell-border: rgba(229, 226, 225, 0.08);
--color-activity-cell-border-hover: rgba(255, 181, 159, 0.48);
--color-activity-cell-border-active: #FBE7E1;
--color-activity-tooltip-surface: #F2F1ED;
--color-activity-tooltip-border: rgba(255, 181, 159, 0.32);
--color-activity-tooltip-text: #1B1C1A;
--color-activity-tooltip-muted: #8F3217;
--color-inspector-surface: #131211;
--color-inspector-panel: #1C1B1B;
--color-inspector-chip: #252120;

View File

@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest'
import css from './globals.css?raw'
function getThemeBlock(selector: ':root,\n[data-theme="light"]' | '[data-theme="white"]' | '[data-theme="dark"]') {
const start = css.indexOf(`${selector} {`)
expect(start).toBeGreaterThanOrEqual(0)
const bodyStart = css.indexOf('{', start)
let depth = 0
for (let index = bodyStart; index < css.length; index += 1) {
const char = css[index]
if (char === '{') depth += 1
if (char === '}') {
depth -= 1
if (depth === 0) {
return css.slice(bodyStart + 1, index)
}
}
}
throw new Error(`Theme block not closed: ${selector}`)
}
describe('desktop theme tokens', () => {
const themes = [':root,\n[data-theme="light"]', '[data-theme="white"]', '[data-theme="dark"]'] as const
const requiredTokens = [
'--color-activity-heat-0',
'--color-activity-heat-1',
'--color-activity-heat-2',
'--color-activity-heat-3',
'--color-activity-heat-4',
'--color-activity-cell-border',
'--color-activity-cell-border-hover',
'--color-activity-cell-border-active',
'--color-activity-tooltip-surface',
'--color-activity-tooltip-border',
'--color-activity-tooltip-text',
'--color-activity-tooltip-muted',
'--color-success-container',
'--color-info',
'--color-info-container',
'--color-warning-container',
]
it('defines activity and status tokens for every supported theme', () => {
for (const theme of themes) {
const block = getThemeBlock(theme)
for (const token of requiredTokens) {
expect(block, `${theme} should define ${token}`).toContain(`${token}:`)
}
}
})
})