feat(appearance): add Calm/Signature visual style, readability mode, and chart palette (#1407)

* feat(appearance): add Calm/Signature visual style, readability mode, and chart palette

Turn the "too intense / italic headers hurt / the security graph fights my
eyes" feedback into a token-driven Visual style with Calm as the new default
and Signature one click back to the prior look.

- Heading family routes through a `.font-heading` utility driven by
  `--font-heading`/`--heading-style`: operational headings render upright in the
  interface face under Calm and italic Instrument Serif under Signature. Base
  rule sets family + style only, so each call site keeps its own weight/tracking
  and Signature stays a true no-op; the Calm lift is a `[data-headings="clean"]`
  descendant rule. Brand lockup, empty-state heroes, and onboarding stay serif.
- Severity charts resolve through `--sev-*` tokens with Muted, Heat, and
  Signature palettes; FindingsByType routes its series through the severity ramp
  plus a neutral so no brand-cyan sits next to rose. The risk trend flattens its
  gradient under Muted/Heat/reduced and keeps the gradient under Signature.
- Appearance settings gain Visual style cards, a Security visualization palette,
  a Readability master toggle, a Motion & effects group, and a "Reset to default"
  button (restores the Calm axes, disabled while readability is on). Contrast
  moves under Readability and Ambient glow under Motion & effects. A card is
  selected only while the stored sub-axes match its preset, so a custom
  combination de-selects both.
- The topbar Theme quick-switch swaps the interface/data font pickers for a
  Visual style switch and a Readability toggle (text size kept); its footer
  Settings link jumps straight to Appearance.
- Readability is a sticky master that forces the calm resolution and a contrast
  lift at apply time without mutating the stored sub-axes.
- New users default to Calm; any pre-existing persisted appearance state keeps
  the Signature look. The pre-paint script mirrors the store.
- SegmentedControl gains a `disabled` prop and a nullable value (no active
  segment for a custom combination, with a roving-tabindex keyboard anchor).
  Adds unit/component coverage for the store, migration, chart shape logic, the
  disabled control, the reset/de-selection, and the quick-switch.

* fix(appearance): migrate Blueprint serif headings and surface readability locks

- Migrate the two operational Blueprint headings (catalog tile name, drift-policy
  option title) from font-serif italic to the .font-heading utility; the first
  pass only covered font-display, so Calm still left these italic. font-serif and
  font-display both resolve to the same display face, so this is the same fix.
- Lock the Visual style cards under Readability (parity with the topbar switch and
  the on-screen guidance to turn Readability off to choose a style by hand).
- Lock the Border brightness slider under Readability and show its forced +0.03
  readout, since Readability overrides the stored value; dragging it previously
  appeared to do nothing.
- Correct the Appearance docs sentence for the topbar quick switch (it listed
  fonts; the quick switch now carries visual style, readability, and text size).
This commit is contained in:
Anso
2026-06-22 00:19:57 -04:00
committed by GitHub
parent 31aa1d2d37
commit 8d9e6574cc
39 changed files with 1191 additions and 166 deletions
@@ -1,3 +1,5 @@
import { Check, Info } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Combobox } from '@/components/ui/combobox';
import { Slider } from '@/components/ui/slider';
import { SegmentedControl } from '@/components/ui/segmented-control';
@@ -6,7 +8,10 @@ import { useDensity } from '@/hooks/use-density';
import type { Density } from '@/hooks/use-density';
import { useTopNavLabels } from '@/hooks/use-top-nav-labels';
import { useTopNavAlign, type TopNavAlign } from '@/hooks/use-top-nav-align';
import { useTheme, THEME_MODE_OPTIONS, ACCENTS, CONTRAST, BORDER_BOOST, GLOW, TYPE_SCALE } from '@/hooks/use-theme';
import {
useTheme, activeVisualStyle, THEME_MODE_OPTIONS, ACCENTS, CONTRAST, BORDER_BOOST, GLOW, TYPE_SCALE,
type VisualStyle, type HeadingStyle, type ChartStyle,
} from '@/hooks/use-theme';
import { AccentPicker } from '@/components/theme/AccentPicker';
import { ThemePreview } from '@/components/theme/ThemePreview';
import { TypeChips } from '@/components/theme/TypeChips';
@@ -30,20 +35,246 @@ const TOP_NAV_ALIGN_OPTIONS: { value: TopNavAlign; label: string }[] = [
{ value: 'center', label: 'Center' },
];
const CHART_STYLE_OPTIONS: { value: ChartStyle; label: string }[] = [
{ value: 'muted', label: 'Muted' },
{ value: 'heat', label: 'Heat' },
{ value: 'signature', label: 'Signature' },
];
const HEADING_STYLE_OPTIONS: { value: HeadingStyle; label: string }[] = [
{ value: 'clean', label: 'Clean' },
{ value: 'signature', label: 'Signature' },
];
const fmtSigned = (v: number) => `${v > 0 ? '+' : ''}${v.toFixed(2)}`;
// Preview swatches for the Visual style cards. Calm uses the muted ramp; Signature
// deliberately puts the brand bar next to destructive to show the clash it fixes.
interface VisualCardData {
kind: VisualStyle;
name: string;
blurb: string;
bars: string[];
}
const VISUAL_CARDS: VisualCardData[] = [
{
kind: 'calm',
name: 'Calm',
blurb: 'Upright headings, muted flat charts. The readable default.',
bars: ['oklch(0.605 0.105 28)', 'oklch(0.715 0.085 70)', 'oklch(0.675 0.045 88)', 'oklch(0.565 0.018 250)'],
},
{
kind: 'signature',
name: 'Signature',
blurb: "Italic serif headings, saturated charts. Today's look.",
bars: ['var(--destructive)', 'var(--warning)', 'var(--brand)', 'color-mix(in oklch, var(--warning) 50%, var(--stat-icon))'],
},
];
const VISUAL_BAR_HEIGHTS = [16, 11, 13, 8];
function VisualCard({
card, selected, disabled, onSelect,
}: {
card: VisualCardData;
selected: boolean;
disabled?: boolean;
onSelect: () => void;
}) {
const { kind, name, blurb, bars } = card;
const calm = kind === 'calm';
return (
<button
type="button"
onClick={onSelect}
disabled={disabled}
aria-pressed={selected}
className={cn(
'flex flex-col overflow-hidden rounded-lg border bg-well text-left transition-colors',
selected
? 'border-brand/55 ring-1 ring-brand/40'
: 'border-card-border border-t-card-border-top hover:border-t-card-border-hover',
disabled && 'opacity-50',
)}
>
<div className="flex h-[74px] flex-col justify-center gap-1.5 border-b border-hairline px-3.5">
<span
className={cn(
'text-[22px] leading-none text-stat-value',
calm ? 'font-sans font-semibold not-italic' : 'font-display font-normal italic',
)}
>
Critical
</span>
<span className="flex items-end gap-1" style={{ height: 16 }}>
{bars.map((b, i) => (
<span
key={i}
className="block w-[9px] rounded-sm"
style={{ background: b, height: VISUAL_BAR_HEIGHTS[i] }}
/>
))}
</span>
</div>
<div className="px-3.5 pb-3 pt-2.5">
<div className="flex items-center gap-2 text-[13px] font-semibold text-stat-value">
{name}
{selected ? <Check className="h-3.5 w-3.5 text-brand" strokeWidth={2.5} /> : null}
</div>
<p className="mt-1 text-[11px] leading-snug text-stat-subtitle">{blurb}</p>
</div>
</button>
);
}
export function AppearanceSection() {
const [density, setDensity] = useDensity();
const [topNavLabels, setTopNavLabels] = useTopNavLabels();
const [topNavAlign, setTopNavAlign] = useTopNavAlign();
const {
theme, accent, borderBoost, glow, contrast, uiFont, monoFont, typeScale,
headingStyle, chartStyle, reducedEffects, readability,
setTheme, setAccent, setBorderBoost, setGlow, setContrast, setUiFont, setMonoFont, setTypeScale,
setVisualStyle, setHeadingStyle, setChartStyle, setReducedEffects, setReadability,
} = useTheme();
const accentLabel = ACCENTS.find((a) => a.id === accent)?.label ?? 'Cyan';
// Readability is a sticky master: it forces the calm resolution at apply time
// without mutating the stored sub-axes, so the controls reflect the forced
// value and lock while it is on. Effects are reduced under readability too.
const effectiveReduced = readability || reducedEffects;
const effectiveContrast = contrast + (readability ? 0.18 : 0);
// A card is selected only while the stored sub-axes still match its preset, so
// a custom combination de-selects both (and readability resolves to null).
// Shared with the topbar quick-switch via activeVisualStyle so both surfaces
// agree on which style is active.
const activeVisual = activeVisualStyle({ headingStyle, chartStyle, reducedEffects, readability });
return (
<div className="flex flex-col gap-10">
<SettingsSection title="Visual style" kicker="the master switch">
<div className="grid grid-cols-2 gap-2.5 pt-3">
{VISUAL_CARDS.map((c) => (
<VisualCard
key={c.kind}
card={c}
selected={activeVisual === c.kind}
disabled={readability}
onSelect={() => setVisualStyle(c.kind)}
/>
))}
</div>
{readability ? (
<div className="mt-3 flex items-start gap-2.5 rounded-md border border-brand/30 bg-brand/10 px-3 py-2.5">
<Info className="mt-0.5 h-4 w-4 shrink-0 text-brand" strokeWidth={1.5} />
<p className="text-sm leading-relaxed text-stat-title">
<span className="font-medium text-brand">Readability mode is on.</span> It forces the calmest
settings and a small contrast lift. Turn it off below to choose a style by hand.
</p>
</div>
) : null}
</SettingsSection>
<SettingsSection title="Security visualization" kicker="the chart fix">
<SettingsField
label="Chart palette"
helper="Muted desaturates the severity colors; Heat is one warm ramp with no red/blue clash; Signature is the saturated set."
align="start"
>
<SegmentedControl
value={readability ? 'muted' : chartStyle}
options={CHART_STYLE_OPTIONS}
onChange={setChartStyle}
disabled={readability}
ariaLabel="Chart palette"
/>
</SettingsField>
</SettingsSection>
<SettingsSection title="Readability" kicker="this browser">
<SettingsField
label="Readability mode"
helper="One switch: upright headings, muted flat charts, reduced effects, and a contrast lift."
>
<TogglePill checked={readability} onChange={setReadability} aria-label="Readability mode" />
</SettingsField>
<SettingsField
label="Header style"
helper="Clean uses the upright interface face; Signature keeps the italic display serif."
align="start"
>
<SegmentedControl
value={readability ? 'clean' : headingStyle}
options={HEADING_STYLE_OPTIONS}
onChange={setHeadingStyle}
disabled={readability}
ariaLabel="Header style"
/>
</SettingsField>
<SettingsField
label="Contrast"
helper="Master contrast: spreads the page, ink, and borders together. Pair high contrast with OLED for the crispest panel."
>
<div className="flex items-center gap-3">
<Slider
value={[contrast]}
min={CONTRAST.min}
max={CONTRAST.max}
step={CONTRAST.step}
onValueChange={([v]) => setContrast(v)}
aria-label="Contrast"
/>
<span className="w-12 shrink-0 text-right font-mono text-xs tabular-nums text-stat-subtitle">
{fmtSigned(effectiveContrast)}
</span>
</div>
</SettingsField>
</SettingsSection>
<SettingsSection title="Motion & effects" kicker="this browser">
<SettingsField
label="Reduced effects"
helper="Flattens card bevels, the accent glow, and chart gradients for a calmer surface."
>
<TogglePill
checked={effectiveReduced}
onChange={setReducedEffects}
disabled={readability}
aria-label="Reduced effects"
/>
</SettingsField>
<SettingsField
label="Ambient glow"
helper="Intensity of the accent-tinted glow behind the page."
>
<div className="flex items-center gap-3">
<Slider
value={[glow]}
min={GLOW.min}
max={GLOW.max}
step={GLOW.step}
onValueChange={([v]) => setGlow(v)}
disabled={effectiveReduced}
aria-label="Ambient glow"
/>
<span className="w-12 shrink-0 text-right font-mono text-xs tabular-nums text-stat-subtitle">
{glow.toFixed(2)}
</span>
</div>
</SettingsField>
<SettingsActions>
<SettingsSecondaryButton
onClick={() => setVisualStyle('calm')}
disabled={readability}
>
Reset to default
</SettingsSecondaryButton>
</SettingsActions>
</SettingsSection>
<SettingsSection title="Theme" kicker="this browser">
<div className="pt-3">
<ThemePreview />
@@ -69,25 +300,6 @@ export function AppearanceSection() {
<AccentPicker value={accent} onChange={setAccent} />
</SettingsField>
<SettingsField
label="Contrast"
helper="Master contrast: spreads the page, ink, and borders together. Pair high contrast with OLED for the crispest panel."
>
<div className="flex items-center gap-3">
<Slider
value={[contrast]}
min={CONTRAST.min}
max={CONTRAST.max}
step={CONTRAST.step}
onValueChange={([v]) => setContrast(v)}
aria-label="Contrast"
/>
<span className="w-12 shrink-0 text-right font-mono text-xs tabular-nums text-stat-subtitle">
{fmtSigned(contrast)}
</span>
</div>
</SettingsField>
<SettingsField
label="Border brightness"
helper="Lift or soften every hairline so separation reads exactly how you like it."
@@ -99,29 +311,11 @@ export function AppearanceSection() {
max={BORDER_BOOST.max}
step={BORDER_BOOST.step}
onValueChange={([v]) => setBorderBoost(v)}
disabled={readability}
aria-label="Border brightness"
/>
<span className="w-12 shrink-0 text-right font-mono text-xs tabular-nums text-stat-subtitle">
{fmtSigned(borderBoost)}
</span>
</div>
</SettingsField>
<SettingsField
label="Ambient glow"
helper="Intensity of the accent-tinted glow behind the page."
>
<div className="flex items-center gap-3">
<Slider
value={[glow]}
min={GLOW.min}
max={GLOW.max}
step={GLOW.step}
onValueChange={([v]) => setGlow(v)}
aria-label="Ambient glow"
/>
<span className="w-12 shrink-0 text-right font-mono text-xs tabular-nums text-stat-subtitle">
{glow.toFixed(2)}
{fmtSigned(readability ? 0.03 : borderBoost)}
</span>
</div>
</SettingsField>
@@ -142,7 +336,7 @@ export function AppearanceSection() {
<SettingsSection title="Typography" kicker="this browser">
<SettingsField
label="Interface font"
helper="The sans face for body, labels, nav, and buttons. Display headings stay Instrument Serif."
helper="The sans face for body, labels, navigation, and buttons. Heading style follows your Visual style choice."
align="start"
>
<TypeChips value={uiFont} options={UI_FONT_OPTIONS} onChange={setUiFont} ariaLabel="Interface font" />
@@ -0,0 +1,102 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { render, screen, fireEvent, act, renderHook } from '@testing-library/react';
import { AppearanceSection } from '../AppearanceSection';
import { useTheme } from '@/hooks/use-theme';
// AppearanceSection drives the shared theme store. Reset it to a known Signature
// baseline (readability off, effects full) before each test so the disabled-state
// assertions start from a clean, undimmed state.
function resetTheme() {
const { result } = renderHook(() => useTheme());
act(() => {
result.current.setReadability(false);
result.current.setVisualStyle('signature');
result.current.setContrast(0);
result.current.setGlow(0.16);
});
}
describe('AppearanceSection', () => {
beforeEach(() => resetTheme());
it('renders the four refresh sections above Theme', () => {
render(<AppearanceSection />);
expect(screen.getByText('Visual style')).toBeTruthy();
expect(screen.getByText('Security visualization')).toBeTruthy();
expect(screen.getByText('Readability')).toBeTruthy();
expect(screen.getByText('Motion & effects')).toBeTruthy();
});
it('selecting the Calm card applies the calm resolution to <html>', () => {
render(<AppearanceSection />);
fireEvent.click(screen.getByRole('button', { name: /Calm/i }));
expect(document.documentElement.dataset.headings).toBe('clean');
expect(document.documentElement.dataset.chartStyle).toBe('muted');
});
it('readability locks the header + chart controls and disables the glow slider', () => {
const { container } = render(<AppearanceSection />);
// Baseline: nothing reduced, so no slider is disabled.
expect(container.querySelectorAll('[data-disabled]').length).toBe(0);
expect(screen.getByRole('radiogroup', { name: 'Header style' }).getAttribute('aria-disabled')).toBeNull();
fireEvent.click(screen.getByRole('switch', { name: 'Readability mode' }));
expect(screen.getByRole('radiogroup', { name: 'Header style' }).getAttribute('aria-disabled')).toBe('true');
expect(screen.getByRole('radiogroup', { name: 'Chart palette' }).getAttribute('aria-disabled')).toBe('true');
expect((screen.getByRole('switch', { name: 'Reduced effects' }) as HTMLButtonElement).disabled).toBe(true);
// Effective reduced (readability || reducedEffects) disables the glow slider
// even though reducedEffects itself is still off.
expect(container.querySelectorAll('[data-disabled]').length).toBeGreaterThan(0);
});
it('readability also locks the Visual style cards and the Border brightness slider', () => {
const { container } = render(<AppearanceSection />);
const calmCard = () => screen.getByRole('button', { name: /readable default/i }) as HTMLButtonElement;
const sigCard = () => screen.getByRole('button', { name: /Today's look/i }) as HTMLButtonElement;
const borderLocked = () => !!container.querySelector('[aria-label="Border brightness"][data-disabled]');
expect(calmCard().disabled).toBe(false);
expect(borderLocked()).toBe(false);
fireEvent.click(screen.getByRole('switch', { name: 'Readability mode' }));
// Both cards lock (the topbar disables the same control), matching the
// "turn readability off to choose a style" guidance.
expect(calmCard().disabled).toBe(true);
expect(sigCard().disabled).toBe(true);
// Border brightness is forced to +0.03 under readability, so its slider locks.
expect(borderLocked()).toBe(true);
});
it('de-selects both visual-style cards when a custom sub-axis is chosen', () => {
render(<AppearanceSection />);
// Baseline is Signature, so the Signature card reads selected.
expect(screen.getByRole('button', { name: /Today's look/i }).getAttribute('aria-pressed')).toBe('true');
// A custom chart palette (Heat) makes the trio match no preset.
fireEvent.click(screen.getByRole('radio', { name: 'Heat' }));
expect(screen.getByRole('button', { name: /Today's look/i }).getAttribute('aria-pressed')).toBe('false');
expect(screen.getByRole('button', { name: /readable default/i }).getAttribute('aria-pressed')).toBe('false');
});
it('de-selects when only the header style diverges (not just the chart palette)', () => {
render(<AppearanceSection />);
// Baseline Signature; flipping only Header style to Clean breaks the match.
fireEvent.click(screen.getByRole('radio', { name: 'Clean' }));
expect(screen.getByRole('button', { name: /Today's look/i }).getAttribute('aria-pressed')).toBe('false');
expect(screen.getByRole('button', { name: /readable default/i }).getAttribute('aria-pressed')).toBe('false');
});
it('reset to default restores Calm and locks while readability is on', () => {
render(<AppearanceSection />);
fireEvent.click(screen.getByRole('radio', { name: 'Heat' }));
expect(document.documentElement.dataset.chartStyle).toBe('heat');
fireEvent.click(screen.getByRole('button', { name: 'Reset to default' }));
expect(document.documentElement.dataset.headings).toBe('clean');
expect(document.documentElement.dataset.chartStyle).toBe('muted');
expect(screen.getByRole('button', { name: /readable default/i }).getAttribute('aria-pressed')).toBe('true');
fireEvent.click(screen.getByRole('switch', { name: 'Readability mode' }));
expect((screen.getByRole('button', { name: 'Reset to default' }) as HTMLButtonElement).disabled).toBe(true);
});
});
@@ -99,6 +99,13 @@ describe('settings registry', () => {
expect(SETTINGS_ITEMS.find(i => i.id === 'appearance')?.scope).toBe('browser');
expect(SETTINGS_ITEMS.find(i => i.id === 'stacks')?.scope).toBe('browser');
});
it('exposes the Calm/Signature appearance keywords for search', () => {
const appearance = SETTINGS_ITEMS.find(i => i.id === 'appearance');
for (const term of ['calm', 'signature', 'readability', 'heading', 'chart', 'motion', 'effects']) {
expect(appearance?.keywords, `keyword ${term}`).toContain(term);
}
});
});
describe('scopeLabel', () => {
+2 -2
View File
@@ -61,8 +61,8 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
id: 'appearance',
group: 'personal',
label: 'Appearance',
description: 'Theme, accent, density, and display preferences saved to this browser.',
keywords: ['theme', 'dim', 'oled', 'light', 'dark', 'accent', 'color', 'glow', 'border', 'contrast', 'density', 'comfortable', 'compact', 'spacing', 'display'],
description: 'Visual style, readability, theme, accent, charts, and display preferences saved to this browser.',
keywords: ['theme', 'dim', 'oled', 'light', 'dark', 'accent', 'color', 'glow', 'border', 'contrast', 'density', 'comfortable', 'compact', 'spacing', 'display', 'calm', 'signature', 'readability', 'heading', 'chart', 'motion', 'effects'],
tier: null,
scope: 'browser',
},