mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-19 14:56:27 +00:00
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:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user