Files
sencho/frontend/public/theme-init.js
Anso 8d9e6574cc 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).
2026-06-22 00:19:57 -04:00

111 lines
4.8 KiB
JavaScript

(function () {
// Pre-paint: apply the saved theme/accent/knobs/type to <html> before first
// paint so there is no flash. Mirrors the apply logic in src/hooks/use-theme.ts;
// keep the two in sync. Dependency-free on purpose (runs in <head>).
var STORAGE_KEY = 'sencho.appearance.theme';
var LEGACY_KEY = 'sencho-theme';
var MODES = { dim: 1, oled: 1, light: 1, auto: 1 };
var ACCENTS = {
cyan: 1, blue: 1, violet: 1, magenta: 1,
orange: 1, amber: 1, lime: 1, steel: 1,
};
var UI_FONTS = { 'Geist': 1, 'IBM Plex Sans': 1, 'Hanken Grotesk': 1 };
var MONO_FONTS = { 'Geist Mono': 1, 'IBM Plex Mono': 1, 'Fira Code': 1 };
var STYLES = { calm: 1, signature: 1 };
var HEADINGS = { clean: 1, signature: 1 };
var CHARTS = { muted: 1, heat: 1, signature: 1 };
// Calm/Readability: a fresh user (absent key) gets CALM via DEFAULTS; an
// existing stored object fills missing appearance fields from SIGNATURE so a
// returning user looks unchanged. Mirrors the presets in src/hooks/use-theme.ts.
var SIG = {
visualStyle: 'signature', headingStyle: 'signature', chartStyle: 'signature',
reducedEffects: false, readability: false,
};
var DEFAULTS = {
theme: 'dim', accent: 'cyan', borderBoost: 0, glow: 0.16, contrast: 0,
uiFont: 'Geist', monoFont: 'Geist Mono', typeScale: 1,
visualStyle: 'calm', headingStyle: 'clean', chartStyle: 'muted',
reducedEffects: true, readability: false,
};
function num(v, min, max, def) {
if (typeof v !== 'number' || !isFinite(v)) return def;
return Math.min(max, Math.max(min, v));
}
function bool(v, def) {
return typeof v === 'boolean' ? v : def;
}
function read() {
try {
var raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
var p = JSON.parse(raw);
if (p && typeof p === 'object') {
return {
theme: MODES[p.theme] ? p.theme : DEFAULTS.theme,
accent: ACCENTS[p.accent] ? p.accent : DEFAULTS.accent,
borderBoost: num(p.borderBoost, -0.06, 0.12, DEFAULTS.borderBoost),
glow: num(p.glow, 0, 0.4, DEFAULTS.glow),
contrast: num(p.contrast, -0.6, 1.2, DEFAULTS.contrast),
uiFont: UI_FONTS[p.uiFont] ? p.uiFont : DEFAULTS.uiFont,
monoFont: MONO_FONTS[p.monoFont] ? p.monoFont : DEFAULTS.monoFont,
typeScale: num(p.typeScale, 0.88, 1.2, DEFAULTS.typeScale),
visualStyle: STYLES[p.visualStyle] ? p.visualStyle : SIG.visualStyle,
headingStyle: HEADINGS[p.headingStyle] ? p.headingStyle : SIG.headingStyle,
chartStyle: CHARTS[p.chartStyle] ? p.chartStyle : SIG.chartStyle,
reducedEffects: bool(p.reducedEffects, SIG.reducedEffects),
readability: bool(p.readability, SIG.readability),
};
}
}
// Legacy migration: old string key held only the mode (dark mapped to dim).
// A legacy key is a returning user, so the appearance axes fill from Signature.
var legacy = localStorage.getItem(LEGACY_KEY);
var legacyTheme = legacy === 'light' || legacy === 'auto' ? legacy : legacy === 'dark' ? 'dim' : null;
if (legacyTheme) {
var migrated = {};
for (var k in DEFAULTS) migrated[k] = DEFAULTS[k];
for (var a in SIG) migrated[a] = SIG[a];
migrated.theme = legacyTheme;
return migrated;
}
} catch (e) { /* ignore */ }
var out = {};
for (var d in DEFAULTS) out[d] = DEFAULTS[d];
return out;
}
function resolveTheme(theme) {
if (theme === 'auto') {
var dark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
return dark ? 'dim' : 'light';
}
return theme;
}
try {
var s = read();
var resolved = resolveTheme(s.theme);
var root = document.documentElement;
// Readability is a sticky master that forces the calm resolution + a contrast
// lift; otherwise the stored sub-axes apply. Resolved here only (no write-back).
var rd = s.readability;
var headings = rd ? 'clean' : s.headingStyle;
var chart = rd ? 'muted' : s.chartStyle;
var reduced = rd || s.reducedEffects;
root.dataset.theme = resolved;
root.dataset.accent = s.accent;
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));
root.classList.toggle('dark', resolved !== 'light');
} catch (e) { /* ignore */ }
})();