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
+47
View File
@@ -14,6 +14,9 @@ describe('use-theme read / validate / migrate', () => {
root.removeAttribute('style');
root.removeAttribute('data-theme');
root.removeAttribute('data-accent');
root.removeAttribute('data-headings');
root.removeAttribute('data-chart-style');
root.removeAttribute('data-effects');
root.classList.remove('dark');
});
@@ -52,4 +55,48 @@ describe('use-theme read / validate / migrate', () => {
expect(root.dataset.theme).toBe('light');
expect(root.classList.contains('dark')).toBe(false);
});
// ── Calm/Signature migration contract ──────────────────────────────────
// A fresh install (no key) gets the full Calm default; any existing stored
// object (even {}) is a returning user and fills missing appearance fields
// from Signature so its look is unchanged.
it('a fresh install (no stored key) defaults to the full Calm look', async () => {
const root = await applyStored();
expect(root.dataset.headings).toBe('clean');
expect(root.dataset.chartStyle).toBe('muted');
expect(root.dataset.effects).toBe('reduced');
});
it('the legacy sencho-theme key is a returning user and keeps the Signature look', async () => {
localStorage.setItem('sencho-theme', 'dark');
const root = await applyStored();
expect(root.dataset.theme).toBe('dim');
expect(root.dataset.headings).toBe('signature');
expect(root.dataset.effects).toBeUndefined();
});
it('an existing stored object fills missing appearance fields from Signature', async () => {
localStorage.setItem(KEY, JSON.stringify({ theme: 'oled' }));
const root = await applyStored();
expect(root.dataset.theme).toBe('oled');
expect(root.dataset.headings).toBe('signature');
expect(root.dataset.chartStyle).toBe('signature');
expect(root.dataset.effects).toBeUndefined();
});
it('an empty stored object {} is a returning user (Signature), not a fresh one (Calm)', async () => {
localStorage.setItem(KEY, '{}');
const root = await applyStored();
expect(root.dataset.headings).toBe('signature');
expect(root.dataset.chartStyle).toBe('signature');
expect(root.dataset.effects).toBeUndefined();
});
it('rejects an invalid persisted appearance enum, falling back to the Signature default', async () => {
localStorage.setItem(KEY, JSON.stringify({ headingStyle: 'bogus', chartStyle: 'rainbow' }));
const root = await applyStored();
expect(root.dataset.headings).toBe('signature');
expect(root.dataset.chartStyle).toBe('signature');
});
});
+86 -2
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useTheme, initializeTheme, THEME_MODES, ACCENTS, UI_FONTS, MONO_FONTS, TYPE_SIZES } from './use-theme';
import { useTheme, useChartStyle, initializeTheme, THEME_MODES, ACCENTS, UI_FONTS, MONO_FONTS, TYPE_SIZES } from './use-theme';
const STORAGE_KEY = 'sencho.appearance.theme';
@@ -16,7 +16,9 @@ describe('useTheme', () => {
});
afterEach(() => {
// Reset the shared module store so tests stay order-independent.
// Reset the shared module store so tests stay order-independent. The
// appearance axes reset to Signature (readability off, effects full) so
// the raw-knob assertions are not dampened by the Calm default.
const { result } = renderHook(() => useTheme());
act(() => {
result.current.setTheme('dim');
@@ -27,6 +29,8 @@ describe('useTheme', () => {
result.current.setUiFont('Geist');
result.current.setMonoFont('Geist Mono');
result.current.setTypeScale(1);
result.current.setReadability(false);
result.current.setVisualStyle('signature');
});
});
@@ -118,4 +122,84 @@ describe('useTheme', () => {
expect(a.result.current.accent).toBe('lime');
expect(b.result.current.accent).toBe('lime');
});
it('setVisualStyle("calm") writes the three calm sub-axes, applies them, and persists', () => {
const { result } = renderHook(() => useTheme());
act(() => result.current.setVisualStyle('calm'));
expect(result.current.visualStyle).toBe('calm');
expect(result.current.headingStyle).toBe('clean');
expect(result.current.chartStyle).toBe('muted');
expect(result.current.reducedEffects).toBe(true);
const root = document.documentElement;
expect(root.dataset.headings).toBe('clean');
expect(root.dataset.chartStyle).toBe('muted');
expect(root.dataset.effects).toBe('reduced');
expect(readBlob().chartStyle).toBe('muted');
});
it('useChartStyle resolves the effective palette and memoizes a stable result', () => {
const theme = renderHook(() => useTheme());
act(() => {
theme.result.current.setReadability(false);
theme.result.current.setChartStyle('heat');
theme.result.current.setReducedEffects(false);
});
const chart = renderHook(() => useChartStyle());
expect(chart.result.current).toEqual({ chartStyle: 'heat', reduced: false });
const first = chart.result.current;
chart.rerender();
expect(chart.result.current).toBe(first); // stable identity, no per-render churn
act(() => theme.result.current.setReadability(true));
expect(chart.result.current).toEqual({ chartStyle: 'muted', reduced: true }); // readability forces
});
it('the visual-style macro never clears readability (sticky master)', () => {
const { result } = renderHook(() => useTheme());
act(() => result.current.setReadability(true));
act(() => result.current.setVisualStyle('signature'));
expect(result.current.visualStyle).toBe('signature');
expect(result.current.headingStyle).toBe('signature');
// readability stays on, so the effective resolution is still Calm.
expect(result.current.readability).toBe(true);
expect(document.documentElement.dataset.headings).toBe('clean');
});
it('readability forces the calm resolution on <html> without mutating stored sub-axes', () => {
const { result } = renderHook(() => useTheme());
act(() => {
result.current.setVisualStyle('signature');
result.current.setContrast(0.2);
result.current.setGlow(0.3);
result.current.setReadability(true);
});
const root = document.documentElement;
expect(root.dataset.headings).toBe('clean');
expect(root.dataset.chartStyle).toBe('muted');
expect(root.dataset.effects).toBe('reduced');
expect(Number(root.style.getPropertyValue('--contrast'))).toBeCloseTo(0.38, 5);
expect(Number(root.style.getPropertyValue('--glow'))).toBeCloseTo(0.12, 5);
expect(root.style.getPropertyValue('--border-boost')).toBe('0.03');
// Stored sub-axes are untouched: readability is resolved at apply time only.
expect(result.current.headingStyle).toBe('signature');
expect(result.current.chartStyle).toBe('signature');
expect(result.current.contrast).toBe(0.2);
expect(readBlob().headingStyle).toBe('signature');
expect(readBlob().readability).toBe(true);
});
it('signature (readability off) applies the stored sub-axes and raw knobs', () => {
const { result } = renderHook(() => useTheme());
act(() => {
result.current.setReadability(false);
result.current.setVisualStyle('signature');
result.current.setGlow(0.25);
result.current.setContrast(0.1);
});
const root = document.documentElement;
expect(root.dataset.headings).toBe('signature');
expect(root.dataset.chartStyle).toBe('signature');
expect(root.dataset.effects).toBeUndefined();
expect(root.style.getPropertyValue('--glow')).toBe('0.25');
expect(root.style.getPropertyValue('--contrast')).toBe('0.1');
});
});
+138 -8
View File
@@ -1,4 +1,4 @@
import { useCallback, useSyncExternalStore } from 'react';
import { useCallback, useMemo, useSyncExternalStore } from 'react';
import { Moon, Zap, Sun, Monitor } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
@@ -19,6 +19,18 @@ export type AccentId =
export type UiFont = 'Geist' | 'IBM Plex Sans' | 'Hanken Grotesk';
export type MonoFont = 'Geist Mono' | 'IBM Plex Mono' | 'Fira Code';
// Calm / Readability refresh. `visualStyle` is a macro that writes the three
// sub-axes below; `readability` is an independent sticky master that forces the
// calm resolution at apply time without mutating the stored sub-axes. The member
// tuples are the single source of truth for both the union and its runtime guard
// (mirrors the THEME_MODES/ACCENTS convention below).
const VISUAL_STYLES = ['calm', 'signature'] as const;
const HEADING_STYLES = ['clean', 'signature'] as const;
const CHART_STYLES = ['muted', 'heat', 'signature'] as const;
export type VisualStyle = (typeof VISUAL_STYLES)[number];
export type HeadingStyle = (typeof HEADING_STYLES)[number];
export type ChartStyle = (typeof CHART_STYLES)[number];
export interface ThemeState {
theme: ThemeMode;
accent: AccentId;
@@ -28,6 +40,11 @@ export interface ThemeState {
uiFont: UiFont;
monoFont: MonoFont;
typeScale: number;
visualStyle: VisualStyle;
headingStyle: HeadingStyle;
chartStyle: ChartStyle;
reducedEffects: boolean;
readability: boolean;
}
export const THEME_MODES: { id: ThemeMode; label: string; icon: LucideIcon }[] = [
@@ -81,9 +98,44 @@ export const TYPE_SIZES: { id: string; scale: number; px: number }[] = [
const STORAGE_KEY = 'sencho.appearance.theme';
const LEGACY_KEY = 'sencho-theme';
// The five appearance axes the Calm/Signature refresh adds, as two presets.
// `setVisualStyle` writes the macro + the three sub-axes (not readability);
// migration fills missing fields on an existing stored object from SIGNATURE so
// returning users look unchanged, while a fresh user gets CALM via DEFAULT_STATE.
export const CALM_PRESET = {
visualStyle: 'calm', headingStyle: 'clean', chartStyle: 'muted',
reducedEffects: true, readability: false,
} as const;
export const SIGNATURE_PRESET = {
visualStyle: 'signature', headingStyle: 'signature', chartStyle: 'signature',
reducedEffects: false, readability: false,
} as const;
/** Which visual-style preset the stored sub-axes currently match, or null for a
* custom combination. Readability forces its own resolution, so it always reads
* as null. The Appearance cards and the topbar quick-switch both derive their
* highlight from this so they agree on which style is active. */
export function activeVisualStyle(s: {
headingStyle: HeadingStyle;
chartStyle: ChartStyle;
reducedEffects: boolean;
readability: boolean;
}): VisualStyle | null {
if (s.readability) return null;
for (const kind of VISUAL_STYLES) {
const p = kind === 'calm' ? CALM_PRESET : SIGNATURE_PRESET;
if (s.headingStyle === p.headingStyle && s.chartStyle === p.chartStyle && s.reducedEffects === p.reducedEffects) {
return kind;
}
}
return null;
}
const DEFAULT_STATE: ThemeState = {
theme: 'dim', accent: 'cyan', borderBoost: 0, glow: 0.16, contrast: 0,
uiFont: 'Geist', monoFont: 'Geist Mono', typeScale: 1,
...CALM_PRESET,
};
const MODE_IDS = new Set<string>(THEME_MODES.map((m) => m.id));
@@ -103,6 +155,21 @@ function isUiFont(v: unknown): v is UiFont {
function isMonoFont(v: unknown): v is MonoFont {
return typeof v === 'string' && MONO_FONT_IDS.has(v);
}
const VISUAL_STYLE_IDS = new Set<string>(VISUAL_STYLES);
const HEADING_STYLE_IDS = new Set<string>(HEADING_STYLES);
const CHART_STYLE_IDS = new Set<string>(CHART_STYLES);
function isVisualStyle(v: unknown): v is VisualStyle {
return typeof v === 'string' && VISUAL_STYLE_IDS.has(v);
}
function isHeadingStyle(v: unknown): v is HeadingStyle {
return typeof v === 'string' && HEADING_STYLE_IDS.has(v);
}
function isChartStyle(v: unknown): v is ChartStyle {
return typeof v === 'string' && CHART_STYLE_IDS.has(v);
}
function isBool(v: unknown): v is boolean {
return typeof v === 'boolean';
}
// Numeric knobs: a persisted value must be finite and in range, otherwise fall
// back to the default (a NaN/Infinity/out-of-range value would silently no-op
// in CSS, which is harder to diagnose than a reset to default).
@@ -127,13 +194,23 @@ function readStored(): ThemeState {
uiFont: isUiFont(p.uiFont) ? p.uiFont : DEFAULT_STATE.uiFont,
monoFont: isMonoFont(p.monoFont) ? p.monoFont : DEFAULT_STATE.monoFont,
typeScale: readNumber(p.typeScale, TYPE_SCALE),
// An existing stored object is a returning user: any appearance
// field it predates fills from SIGNATURE so its look is unchanged.
visualStyle: isVisualStyle(p.visualStyle) ? p.visualStyle : SIGNATURE_PRESET.visualStyle,
headingStyle: isHeadingStyle(p.headingStyle) ? p.headingStyle : SIGNATURE_PRESET.headingStyle,
chartStyle: isChartStyle(p.chartStyle) ? p.chartStyle : SIGNATURE_PRESET.chartStyle,
reducedEffects: isBool(p.reducedEffects) ? p.reducedEffects : SIGNATURE_PRESET.reducedEffects,
readability: isBool(p.readability) ? p.readability : SIGNATURE_PRESET.readability,
};
}
}
// Legacy migration: the old key held only the mode string (dark → dim).
// Legacy migration: the old key held only the mode string (dark → dim). A
// legacy key is still a returning user, so the appearance axes fill from
// Signature (unchanged look); only the total absence of any key is a fresh
// user and falls through to the Calm DEFAULT_STATE below.
const legacy = window.localStorage.getItem(LEGACY_KEY);
if (legacy === 'light' || legacy === 'auto') return { ...DEFAULT_STATE, theme: legacy };
if (legacy === 'dark') return { ...DEFAULT_STATE, theme: 'dim' };
if (legacy === 'light' || legacy === 'auto') return { ...DEFAULT_STATE, ...SIGNATURE_PRESET, theme: legacy };
if (legacy === 'dark') return { ...DEFAULT_STATE, ...SIGNATURE_PRESET, theme: 'dim' };
} catch {
// ignore; localStorage may be unavailable (private mode, quota)
}
@@ -164,9 +241,20 @@ function applyToDom(s: ThemeState, systemDark: boolean) {
const resolved = resolveWith(s.theme, systemDark);
root.dataset.theme = resolved;
root.dataset.accent = s.accent;
root.style.setProperty('--border-boost', String(s.borderBoost));
root.style.setProperty('--glow', String(s.glow));
root.style.setProperty('--contrast', String(s.contrast));
// Calm/Readability: readability is a sticky master that forces the calm
// resolution plus a contrast lift; otherwise the stored sub-axes apply.
// Effective values are resolved here only, never written back to storage.
const rd = s.readability;
const headings = rd ? 'clean' : s.headingStyle;
const chart = rd ? 'muted' : s.chartStyle;
const reduced = rd || s.reducedEffects;
root.dataset.headings = headings;
root.dataset.chartStyle = chart;
if (reduced) root.dataset.effects = 'reduced';
else delete root.dataset.effects;
root.style.setProperty('--border-boost', String(rd ? 0.03 : s.borderBoost));
root.style.setProperty('--glow', String(reduced ? s.glow * 0.4 : s.glow));
root.style.setProperty('--contrast', String(s.contrast + (rd ? 0.18 : 0)));
root.style.setProperty('--ui-font', `'${s.uiFont}'`);
root.style.setProperty('--mono-font', `'${s.monoFont}'`);
root.style.setProperty('--type-scale', String(s.typeScale));
@@ -193,7 +281,10 @@ function emit() {
function sameState(a: ThemeState, b: ThemeState): boolean {
return a.theme === b.theme && a.accent === b.accent
&& a.borderBoost === b.borderBoost && a.glow === b.glow && a.contrast === b.contrast
&& a.uiFont === b.uiFont && a.monoFont === b.monoFont && a.typeScale === b.typeScale;
&& a.uiFont === b.uiFont && a.monoFont === b.monoFont && a.typeScale === b.typeScale
&& a.visualStyle === b.visualStyle && a.headingStyle === b.headingStyle
&& a.chartStyle === b.chartStyle && a.reducedEffects === b.reducedEffects
&& a.readability === b.readability;
}
function setState(patch: Partial<ThemeState>) {
@@ -261,6 +352,21 @@ export function useTheme() {
const setUiFont = useCallback((uiFont: UiFont) => setState({ uiFont }), []);
const setMonoFont = useCallback((monoFont: MonoFont) => setState({ monoFont }), []);
const setTypeScale = useCallback((typeScale: number) => setState({ typeScale }), []);
// Macro: writes visualStyle + the three sub-axes from the preset. It does
// NOT touch readability, which stays a sticky master the user releases by hand.
const setVisualStyle = useCallback((visualStyle: VisualStyle) => {
const preset = visualStyle === 'calm' ? CALM_PRESET : SIGNATURE_PRESET;
setState({
visualStyle,
headingStyle: preset.headingStyle,
chartStyle: preset.chartStyle,
reducedEffects: preset.reducedEffects,
});
}, []);
const setHeadingStyle = useCallback((headingStyle: HeadingStyle) => setState({ headingStyle }), []);
const setChartStyle = useCallback((chartStyle: ChartStyle) => setState({ chartStyle }), []);
const setReducedEffects = useCallback((reducedEffects: boolean) => setState({ reducedEffects }), []);
const setReadability = useCallback((readability: boolean) => setState({ readability }), []);
const resolvedTheme = resolveWith(s.theme, s.systemDark);
return {
theme: s.theme,
@@ -271,6 +377,11 @@ export function useTheme() {
uiFont: s.uiFont,
monoFont: s.monoFont,
typeScale: s.typeScale,
visualStyle: s.visualStyle,
headingStyle: s.headingStyle,
chartStyle: s.chartStyle,
reducedEffects: s.reducedEffects,
readability: s.readability,
resolvedTheme,
isDarkMode: resolvedTheme !== 'light',
setTheme,
@@ -281,5 +392,24 @@ export function useTheme() {
setUiFont,
setMonoFont,
setTypeScale,
setVisualStyle,
setHeadingStyle,
setChartStyle,
setReducedEffects,
setReadability,
} as const;
}
/** Effective chart palette + reduced flag for the security charts. A thin
* useMemo wrapper over the same store snapshot (not a second external store) so
* it returns a stable object reference unless a chart-relevant axis changes. */
export function useChartStyle(): { chartStyle: ChartStyle; reduced: boolean } {
const s = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
return useMemo(
() => ({
chartStyle: s.readability ? 'muted' : s.chartStyle,
reduced: s.readability || s.reducedEffects,
}),
[s.readability, s.chartStyle, s.reducedEffects],
);
}