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
+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');
});
});