mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 10:21:03 +00:00
8d9e6574cc
* 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).
206 lines
9.7 KiB
TypeScript
206 lines
9.7 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import { renderHook, act } from '@testing-library/react';
|
|
import { useTheme, useChartStyle, initializeTheme, THEME_MODES, ACCENTS, UI_FONTS, MONO_FONTS, TYPE_SIZES } from './use-theme';
|
|
|
|
const STORAGE_KEY = 'sencho.appearance.theme';
|
|
|
|
function readBlob(): Record<string, unknown> {
|
|
return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}');
|
|
}
|
|
|
|
describe('useTheme', () => {
|
|
beforeEach(() => {
|
|
// Sync <html> to the current store state (the pre-paint script does this
|
|
// in the browser; tests have no pre-paint).
|
|
initializeTheme();
|
|
});
|
|
|
|
afterEach(() => {
|
|
// 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');
|
|
result.current.setAccent('cyan');
|
|
result.current.setBorderBoost(0);
|
|
result.current.setGlow(0.16);
|
|
result.current.setContrast(0);
|
|
result.current.setUiFont('Geist');
|
|
result.current.setMonoFont('Geist Mono');
|
|
result.current.setTypeScale(1);
|
|
result.current.setReadability(false);
|
|
result.current.setVisualStyle('signature');
|
|
});
|
|
});
|
|
|
|
it('exposes four modes and the distinct eight accents (no teal/indigo)', () => {
|
|
expect(THEME_MODES.map((m) => m.id)).toEqual(['dim', 'oled', 'light', 'auto']);
|
|
expect(ACCENTS).toHaveLength(8);
|
|
expect(ACCENTS.map((a) => a.id)).toEqual(
|
|
expect.arrayContaining(['cyan', 'blue', 'violet', 'magenta', 'orange', 'amber', 'lime', 'steel']),
|
|
);
|
|
expect(ACCENTS.map((a) => a.id)).not.toContain('teal');
|
|
expect(ACCENTS.map((a) => a.id)).not.toContain('indigo');
|
|
});
|
|
|
|
it('defaults to Dim with the dark class applied', () => {
|
|
const { result } = renderHook(() => useTheme());
|
|
expect(result.current.theme).toBe('dim');
|
|
expect(result.current.accent).toBe('cyan');
|
|
expect(result.current.isDarkMode).toBe(true);
|
|
expect(document.documentElement.dataset.theme).toBe('dim');
|
|
expect(document.documentElement.classList.contains('dark')).toBe(true);
|
|
});
|
|
|
|
it('light mode drops the dark class and persists', () => {
|
|
const { result } = renderHook(() => useTheme());
|
|
act(() => result.current.setTheme('light'));
|
|
expect(result.current.isDarkMode).toBe(false);
|
|
expect(document.documentElement.dataset.theme).toBe('light');
|
|
expect(document.documentElement.classList.contains('dark')).toBe(false);
|
|
expect(readBlob().theme).toBe('light');
|
|
});
|
|
|
|
it('OLED keeps the dark class', () => {
|
|
const { result } = renderHook(() => useTheme());
|
|
act(() => result.current.setTheme('oled'));
|
|
expect(result.current.isDarkMode).toBe(true);
|
|
expect(document.documentElement.dataset.theme).toBe('oled');
|
|
expect(document.documentElement.classList.contains('dark')).toBe(true);
|
|
});
|
|
|
|
it('applies accent and the three knobs to <html> and persists them', () => {
|
|
const { result } = renderHook(() => useTheme());
|
|
act(() => {
|
|
result.current.setAccent('violet');
|
|
result.current.setBorderBoost(0.05);
|
|
result.current.setGlow(0.3);
|
|
result.current.setContrast(0.6);
|
|
});
|
|
expect(document.documentElement.dataset.accent).toBe('violet');
|
|
expect(document.documentElement.style.getPropertyValue('--border-boost')).toBe('0.05');
|
|
expect(document.documentElement.style.getPropertyValue('--glow')).toBe('0.3');
|
|
expect(document.documentElement.style.getPropertyValue('--contrast')).toBe('0.6');
|
|
const blob = readBlob();
|
|
expect(blob.accent).toBe('violet');
|
|
expect(blob.borderBoost).toBe(0.05);
|
|
expect(blob.glow).toBe(0.3);
|
|
expect(blob.contrast).toBe(0.6);
|
|
});
|
|
|
|
it('exposes 3 interface faces, 3 data faces, and 4 size presets with Geist defaults', () => {
|
|
expect(UI_FONTS.map((f) => f.id)).toEqual(['Geist', 'IBM Plex Sans', 'Hanken Grotesk']);
|
|
expect(MONO_FONTS.map((f) => f.id)).toEqual(['Geist Mono', 'IBM Plex Mono', 'Fira Code']);
|
|
expect(TYPE_SIZES.map((s) => s.id)).toEqual(['S', 'M', 'L', 'XL']);
|
|
const { result } = renderHook(() => useTheme());
|
|
expect(result.current.uiFont).toBe('Geist');
|
|
expect(result.current.monoFont).toBe('Geist Mono');
|
|
expect(result.current.typeScale).toBe(1);
|
|
});
|
|
|
|
it('applies font faces and text scale to <html> and persists them', () => {
|
|
const { result } = renderHook(() => useTheme());
|
|
act(() => {
|
|
result.current.setUiFont('IBM Plex Sans');
|
|
result.current.setMonoFont('Fira Code');
|
|
result.current.setTypeScale(1.16);
|
|
});
|
|
expect(document.documentElement.style.getPropertyValue('--ui-font')).toBe("'IBM Plex Sans'");
|
|
expect(document.documentElement.style.getPropertyValue('--mono-font')).toBe("'Fira Code'");
|
|
expect(document.documentElement.style.getPropertyValue('--type-scale')).toBe('1.16');
|
|
const blob = readBlob();
|
|
expect(blob.uiFont).toBe('IBM Plex Sans');
|
|
expect(blob.monoFont).toBe('Fira Code');
|
|
expect(blob.typeScale).toBe(1.16);
|
|
});
|
|
|
|
it('shares state across two consumers (one store)', () => {
|
|
const a = renderHook(() => useTheme());
|
|
const b = renderHook(() => useTheme());
|
|
act(() => a.result.current.setAccent('lime'));
|
|
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');
|
|
});
|
|
});
|