({
}
};
+ // Roving tabindex: the active option is the keyboard entry point, but when no
+ // option is active (value is null for a custom, off-preset combination) anchor
+ // on the first option so the group stays Tab-reachable.
+ const activeIndex = options.findIndex((o) => o.value === value);
+ const rovingIndex = activeIndex === -1 ? 0 : activeIndex;
+
return (
@@ -87,8 +99,9 @@ export function SegmentedControl({
aria-checked={active}
aria-label={a11yLabel}
title={iconOnly ? opt.label : undefined}
- tabIndex={active ? 0 : -1}
- onClick={() => onChange(opt.value)}
+ disabled={disabled}
+ tabIndex={disabled ? -1 : index === rovingIndex ? 0 : -1}
+ onClick={() => { if (!disabled) onChange(opt.value); }}
onKeyDown={(e) => handleKeyDown(e, index)}
className={cn(
'flex items-center gap-1.5 rounded px-2.5 py-1 font-mono text-[10px] uppercase tracking-[0.14em] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/50',
diff --git a/frontend/src/components/ui/system-sheet.tsx b/frontend/src/components/ui/system-sheet.tsx
index cc4b58f4..bfbaf892 100644
--- a/frontend/src/components/ui/system-sheet.tsx
+++ b/frontend/src/components/ui/system-sheet.tsx
@@ -178,7 +178,7 @@ function SheetHeaderBand({ crumb, name, meta, onDismiss }: SheetHeaderBandProps)
})}
-
+
{name}
diff --git a/frontend/src/hooks/use-theme.read.test.ts b/frontend/src/hooks/use-theme.read.test.ts
index e3c99589..00958f58 100644
--- a/frontend/src/hooks/use-theme.read.test.ts
+++ b/frontend/src/hooks/use-theme.read.test.ts
@@ -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');
+ });
});
diff --git a/frontend/src/hooks/use-theme.test.ts b/frontend/src/hooks/use-theme.test.ts
index 43210823..51bbce32 100644
--- a/frontend/src/hooks/use-theme.test.ts
+++ b/frontend/src/hooks/use-theme.test.ts
@@ -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 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');
+ });
});
diff --git a/frontend/src/hooks/use-theme.ts b/frontend/src/hooks/use-theme.ts
index 3fe84243..06669376 100644
--- a/frontend/src/hooks/use-theme.ts
+++ b/frontend/src/hooks/use-theme.ts
@@ -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(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(VISUAL_STYLES);
+const HEADING_STYLE_IDS = new Set(HEADING_STYLES);
+const CHART_STYLE_IDS = new Set(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) {
@@ -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],
+ );
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 56c3f135..dffbdc7a 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -84,6 +84,15 @@
--chart-2: var(--warning);
--chart-3: var(--destructive);
+ /* Severity / chart palette (Signature default = today's saturated semantics).
+ Colours only; per-palette fill/gradient/stroke live in JS (SecurityCharts).
+ [data-chart-style="muted"|"heat"] override these; signature needs no rule. */
+ --sev-critical: var(--destructive);
+ --sev-high: var(--warning);
+ --sev-medium: color-mix(in oklch, var(--warning) 52%, var(--stat-icon));
+ --sev-low: color-mix(in oklch, var(--stat-icon) 72%, var(--well));
+ --sev-vuln: var(--brand);
+
/* Sparkline / data-line styling (centralized) */
--data-line-width: 1.25px;
--data-peak: var(--warning);
@@ -224,6 +233,13 @@
--font-serif: 'Instrument Serif', Georgia, serif;
--font-display: 'Instrument Serif', Georgia, serif;
+ /* Operational heading family. Signature default = the display serif, italic;
+ family + style ONLY so migrated headings keep their own weight/tracking and
+ stay a true no-op in Signature. [data-headings="clean"] flips to the sans
+ face and the Calm weight/tracking lift lands via the .font-heading rule. */
+ --font-heading: var(--font-display);
+ --heading-style: italic;
+
/* Shape */
--radius: 0.5rem;
@@ -388,6 +404,57 @@
--terminal-bg: oklch(0.12 0 0);
}
+/* ─────────────────────────────────────────────────────────────
+ CALM / READABILITY REFRESH
+ Three appearance attributes on switch the heading family, the
+ severity-chart palette, and material effects. Signature (the defaults in
+ :root) needs no rule; Calm flips them. Placed after the theme blocks so the
+ reduced-effects --card-bevel override wins in every theme.
+───────────────────────────────────────────────────────────── */
+/* Clean headings: the readability fix. Operational headings drop the display
+ serif for the upright interface face. */
+[data-headings="clean"] {
+ --font-heading: var(--font-sans);
+ --heading-style: normal;
+}
+
+/* Muted: desaturated severity semantics, no brand/destructive clash. */
+[data-chart-style="muted"] {
+ --sev-critical: oklch(0.605 0.105 28);
+ --sev-high: oklch(0.715 0.085 70);
+ --sev-medium: oklch(0.675 0.045 88);
+ --sev-low: oklch(0.565 0.018 250);
+ --sev-vuln: oklch(0.605 0.045 235);
+}
+
+/* Heat: a single warm sequential ramp with zero complementary vibration. */
+[data-chart-style="heat"] {
+ --sev-critical: oklch(0.565 0.135 30);
+ --sev-high: oklch(0.675 0.115 48);
+ --sev-medium: oklch(0.770 0.080 70);
+ --sev-low: oklch(0.825 0.040 82);
+ --sev-vuln: oklch(0.660 0.100 45);
+}
+
+/* Reduced effects: flatten the material (Calm only). Charts read the reduced
+ flag from the store; here we flatten the real card top-bevel token. */
+[data-effects="reduced"] {
+ --card-bevel: none;
+}
+
+/* Heading utility: every operational title routes through this. Base sets only
+ family + style (Signature parity); the Calm-only descendant rule lifts weight
+ and tightens tracking for the sans face, beating any single tracking or weight
+ utility on the element. */
+.font-heading {
+ font-family: var(--font-heading);
+ font-style: var(--heading-style);
+}
+[data-headings="clean"] .font-heading {
+ font-weight: 600;
+ letter-spacing: -0.006em;
+}
+
/* ─────────────────────────────────────────────────────────────
TAILWIND THEME MAPPING
───────────────────────────────────────────────────────────── */