mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +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:
@@ -1,16 +1,45 @@
|
||||
---
|
||||
title: Appearance
|
||||
description: Personalize Sencho's look. Choose a theme and accent, fine-tune contrast, borders, and glow, swap the interface and data fonts, and set the text size. Every choice is saved to the current browser.
|
||||
description: Personalize Sencho's look. Choose a visual style, tune readability and the security charts, pick a theme and accent, swap the interface and data fonts, and set the text size. Every choice is saved to the current browser.
|
||||
---
|
||||
|
||||
**Settings → Appearance** is where you make Sencho look the way you want. Pick a theme and accent color, fine-tune contrast and the ambient glow, choose the interface and data fonts, and scale the text. A live preview at the top of the section reflects every change as you make it.
|
||||
**Settings → Appearance** is where you make Sencho look the way you want. Choose between the Calm and Signature visual styles, dial in readability, set how the security charts are colored, pick a theme and accent, choose the interface and data fonts, and scale the text. A live preview reflects every change as you make it.
|
||||
|
||||
Most of these controls are also one click away from anywhere in the app: the **palette button** in the top bar, between the search and notification icons, opens a quick switcher for the theme mode, accent, and fonts.
|
||||
Several of these controls are also one click away from anywhere in the app: the **palette button** in the top bar, between the search and notification icons, opens a quick switcher for the theme mode, accent, visual style, readability, and text size.
|
||||
|
||||
<Note>
|
||||
Appearance choices are saved to **this browser only**. They follow you across tabs on the same browser, and every device remembers its own look. Nothing here changes what other operators see.
|
||||
</Note>
|
||||
|
||||
## Visual style
|
||||
|
||||
The **Visual style** is the master switch for how intense the interface feels:
|
||||
|
||||
- **Calm** (the default) keeps headings upright in the interface font, colors the security charts with a muted palette, and reduces material effects. It is the most readable setting.
|
||||
- **Signature** uses italic Instrument Serif headings and the fully saturated chart palette for a more editorial look.
|
||||
|
||||
Picking a style sets the heading, chart, and effects controls below in one move. You are free to adjust any of them afterward to build your own combination. When **Readability mode** is on it overrides these until you turn it off.
|
||||
|
||||
## Readability
|
||||
|
||||
**Readability mode** is a single switch for the most legible result: upright headings, the muted chart palette, reduced effects, and a small contrast lift, all at once. While it is on, the heading and chart controls are locked to their calm values. Turn it off to set them by hand.
|
||||
|
||||
- **Header style** chooses **Clean** (the upright interface face) or **Signature** (italic Instrument Serif). Decorative moments such as the Sencho wordmark stay in the serif either way.
|
||||
- **Contrast** is a master control that spreads the page tone, every border, and the text tiers together. Higher contrast darkens the page, brightens borders, and crisps up secondary text. Pairing high contrast with the OLED theme gives the sharpest reading on a true-black panel.
|
||||
|
||||
## Security visualization
|
||||
|
||||
The **Chart palette** sets the colors used on the Security page charts:
|
||||
|
||||
- **Muted** desaturates the severity colors so critical, high, medium, and low read as a calm progression with no jarring color pairings.
|
||||
- **Heat** is a single warm ramp from a deep red through amber, with no competing hues.
|
||||
- **Signature** is the saturated severity set.
|
||||
|
||||
## Motion & effects
|
||||
|
||||
- **Reduced effects** flattens card bevels, the accent glow, and chart gradients for a calmer, flatter surface.
|
||||
- **Ambient glow** sets the intensity of the soft, accent-tinted glow behind the page. Slide it to zero for a completely flat background. (Reduced effects already dims it.)
|
||||
|
||||
## Theme
|
||||
|
||||
The **Mode** control sets the overall surface palette:
|
||||
@@ -20,30 +49,18 @@ The **Mode** control sets the overall surface palette:
|
||||
- **Light** is the bright theme, with the same directional, opaque borders inverted for a white surface.
|
||||
- **Auto** follows your operating system, switching between Dim and Light as your OS does, and re-resolving live when the OS flips.
|
||||
|
||||
## Accent
|
||||
The **accent** is Sencho's one data color. It drives charts, sparklines, progress bars, focus rings, active states, the masthead and sidebar rails, and the ambient page glow, so changing it recolors the whole product cohesively. Eight well-spaced hues are available: **Cyan** (the default and the signature), **Blue**, **Violet**, **Magenta**, **Orange**, **Amber**, **Lime**, and **Steel** (a deliberate near-neutral). Status colors (green for healthy, amber for warning, rose for error) stay constant regardless of accent so state always reads the same.
|
||||
|
||||
The **accent** is Sencho's one data color. It drives charts, sparklines, progress bars, focus rings, active states, the masthead and sidebar rails, and the ambient page glow, so changing it recolors the whole product cohesively.
|
||||
|
||||
Eight well-spaced hues are available: **Cyan** (the default and the signature), **Blue**, **Violet**, **Magenta**, **Orange**, **Amber**, **Lime**, and **Steel** (a deliberate near-neutral). Status colors (green for healthy, amber for warning, rose for error) stay constant regardless of accent so state always reads the same.
|
||||
|
||||
## Fine-tune
|
||||
|
||||
Three sliders shape the surfaces and atmosphere. A **Reset fine-tune** button returns all three to their defaults.
|
||||
|
||||
- **Contrast** is a master control that spreads the page tone, every border, and the text tiers together in one move. Higher contrast darkens the page, brightens borders, and crisps up secondary text. Pairing high contrast with the OLED theme gives the sharpest reading on a true-black panel.
|
||||
- **Border brightness** lifts or softens every hairline on its own, so the separation between cards reads exactly how you like it.
|
||||
- **Ambient glow** sets the intensity of the soft, accent-tinted glow behind the page. Slide it to zero for a completely flat background.
|
||||
**Border brightness** lifts or softens every hairline on its own, so the separation between cards reads exactly how you like it. A **Reset fine-tune** button returns contrast, border brightness, and ambient glow to their defaults.
|
||||
|
||||
## Typography
|
||||
|
||||
Sencho uses three type contexts. The display face for hero headings stays fixed as the signature; the other two are yours to change.
|
||||
Sencho uses three type contexts. The interface and data faces are yours to change; the heading style follows your Visual style choice.
|
||||
|
||||
- **Interface font** sets the sans face used for body text, labels, navigation, and buttons. Choose **Geist** (default), **IBM Plex Sans**, or **Hanken Grotesk**.
|
||||
- **Data font** sets the monospace face used for the terminal, stat values, codes, and timestamps. Choose **Geist Mono** (default), **IBM Plex Mono**, or **Fira Code**.
|
||||
- **Text size** scales the entire interface from a single root multiplier. In Settings this is a continuous slider (default `1.00×`); the top-bar quick switcher offers **S / M / L / XL** presets. Both stay in sync.
|
||||
|
||||
Hero headings and featured names always render in Instrument Serif so the editorial voice stays consistent no matter which interface font you pick.
|
||||
|
||||
## Display
|
||||
|
||||
The **Display** group holds the remaining per-browser preferences:
|
||||
|
||||
@@ -97,12 +97,33 @@ See [Two-Factor Authentication](/features/two-factor-authentication) for the enr
|
||||
|
||||
**Scope:** This browser (preferences are saved to local storage)
|
||||
|
||||
Control how dense and how interactive the workspace feels. Each browser remembers its own choices so a compact laptop setup does not force the same rhythm on a larger desktop.
|
||||
Control how Sencho looks and how dense the workspace feels. Each browser remembers its own choices so a compact laptop setup does not force the same rhythm on a larger desktop. See [Appearance](/features/appearance) for the full walkthrough.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/settings/appearance-density.png" alt="Appearance settings showing the Density selector" />
|
||||
</Frame>
|
||||
|
||||
### Visual style and readability
|
||||
|
||||
| Control | What it does |
|
||||
|---------|--------------|
|
||||
| **Visual style** | Master switch between **Calm** (upright headings, muted charts, reduced effects, the default) and **Signature** (italic Instrument Serif headings, saturated charts). |
|
||||
| **Readability mode** | One switch for the most legible result: upright headings, muted charts, reduced effects, and a contrast lift. Locks the heading and chart controls while on. |
|
||||
| **Header style** | Clean (upright interface face) or Signature (italic serif). |
|
||||
| **Contrast** | Spreads the page tone, borders, and text tiers together. |
|
||||
|
||||
### Charts and effects
|
||||
|
||||
| Control | What it does |
|
||||
|---------|--------------|
|
||||
| **Chart palette** | Colors the Security page charts: **Muted**, **Heat** (one warm ramp), or **Signature** (saturated). |
|
||||
| **Reduced effects** | Flattens card bevels, the accent glow, and chart gradients. |
|
||||
| **Ambient glow** | Intensity of the accent-tinted page glow. |
|
||||
|
||||
### Theme and typography
|
||||
|
||||
The **Mode** (Dim, OLED, Light, Auto) and **Accent** (one of eight hues) set the surface palette and data color; **Border brightness** tunes every hairline. The **Interface font**, **Data font**, and **Text size** are yours to change; the heading style follows your Visual style choice.
|
||||
|
||||
### Density
|
||||
|
||||
| Value | When to pick it |
|
||||
|
||||
@@ -11,15 +11,30 @@
|
||||
};
|
||||
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 {
|
||||
@@ -36,15 +51,22 @@
|
||||
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;
|
||||
}
|
||||
@@ -66,11 +88,20 @@
|
||||
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.style.setProperty('--border-boost', String(s.borderBoost));
|
||||
root.style.setProperty('--glow', String(s.glow));
|
||||
root.style.setProperty('--contrast', String(s.contrast));
|
||||
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));
|
||||
|
||||
@@ -573,7 +573,7 @@ function StreamView({ stats, showStats, loading, groups, now, page, totalPages,
|
||||
{tile.label}
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<span className={`font-display italic text-3xl leading-none tabular-nums ${SEVERITY_TEXT[tile.severity]}`}>
|
||||
<span className={`font-heading text-3xl leading-none tabular-nums ${SEVERITY_TEXT[tile.severity]}`}>
|
||||
{tile.value === null
|
||||
? '·'
|
||||
: idx === 2
|
||||
|
||||
@@ -206,7 +206,7 @@ function StackReadinessCard({
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle/80">
|
||||
Stack
|
||||
</span>
|
||||
<span className="font-display italic text-2xl leading-tight tracking-tight text-stat-value truncate">
|
||||
<span className="font-heading text-2xl leading-tight tracking-tight text-stat-value truncate">
|
||||
{stack}
|
||||
</span>
|
||||
</div>
|
||||
@@ -334,7 +334,7 @@ function ReadinessHero({
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.22em] text-brand">
|
||||
Fleet readiness
|
||||
</span>
|
||||
<span className="font-display italic text-3xl leading-tight tracking-tight text-stat-value">
|
||||
<span className="font-heading text-3xl leading-tight tracking-tight text-stat-value">
|
||||
{headline}
|
||||
</span>
|
||||
{total > 0 && (
|
||||
@@ -389,7 +389,7 @@ function NodeGroupSection({
|
||||
<section className="flex flex-col gap-4">
|
||||
<div className="flex items-baseline gap-3 border-b border-card-border/60 pb-2">
|
||||
<TypeIcon className="h-4 w-4 text-stat-subtitle self-center" strokeWidth={1.5} aria-hidden="true" />
|
||||
<span className="font-display italic text-xl leading-tight tracking-tight text-stat-value truncate">
|
||||
<span className="font-heading text-xl leading-tight tracking-tight text-stat-value truncate">
|
||||
{group.nodeName}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4 shrink-0 self-center">
|
||||
@@ -428,7 +428,7 @@ export function MobileReadinessCard({ card, onApply }: { card: StackCard; onAppl
|
||||
<div className="flex items-start justify-between gap-[10px]">
|
||||
<div className="min-w-0 flex-1">
|
||||
<Kicker>stack</Kicker>
|
||||
<div className="mt-px truncate font-display italic text-[23px] leading-[26px] text-stat-value">{stack}</div>
|
||||
<div className="mt-px truncate font-heading text-[23px] leading-[26px] text-stat-value">{stack}</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{!autoUpdateEnabled && (
|
||||
@@ -483,7 +483,7 @@ function MobileNodeSection({ group, onApply }: { group: NodeGroup; onApply: (sta
|
||||
return (
|
||||
<section>
|
||||
<div className="mb-[13px] flex items-baseline gap-2 border-b border-hairline pb-2">
|
||||
<span className="truncate font-display italic text-[19px] leading-tight text-stat-value">{group.nodeName}</span>
|
||||
<span className="truncate font-heading text-[19px] leading-tight text-stat-value">{group.nodeName}</span>
|
||||
<span className="shrink-0 rounded-[5px] border border-card-border px-1.5 py-px font-mono text-[10px] uppercase tracking-[0.1em] text-stat-subtitle">{group.nodeType}</span>
|
||||
<span className="shrink-0 font-mono text-[11px] text-stat-icon">{group.cards.length} {group.cards.length === 1 ? 'stack' : 'stacks'}</span>
|
||||
</div>
|
||||
|
||||
@@ -669,7 +669,7 @@ export default function EditorLayout() {
|
||||
onNavigate={stackActions.navigateToNotification}
|
||||
/>
|
||||
);
|
||||
const themeSwitchEl = <ThemeQuickSwitch />;
|
||||
const themeSwitchEl = <ThemeQuickSwitch onOpenAppearance={() => openSettings('appearance')} />;
|
||||
const userMenuEl = <UserProfileDropdown onOpenSettings={() => openSettings('account')} />;
|
||||
|
||||
const topBarEl = (
|
||||
@@ -951,7 +951,7 @@ function MobileDetailLoading({ name, onBack, headerActions }: { name: string; on
|
||||
<ChevronLeft className="h-4 w-4" strokeWidth={1.6} />
|
||||
Stacks
|
||||
</button>
|
||||
<span className="min-w-0 flex-1 truncate font-display text-2xl italic text-stat-value">
|
||||
<span className="min-w-0 flex-1 truncate font-heading text-2xl text-stat-value">
|
||||
{name.replace(/\.(ya?ml)$/, '')}
|
||||
</span>
|
||||
{headerActions ? <div className="shrink-0">{headerActions}</div> : null}
|
||||
|
||||
@@ -163,7 +163,7 @@ export function StackIdentityHeader({
|
||||
{(activeNode?.name || 'local')} <span className="text-muted-foreground/60">›</span> stacks <span className="text-muted-foreground/60">›</span> {stackName}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<CardTitle className="font-display italic text-3xl leading-none tracking-tight">{stackName}</CardTitle>
|
||||
<CardTitle className="font-heading text-3xl leading-none tracking-tight">{stackName}</CardTitle>
|
||||
{(() => {
|
||||
const pill = getStackStatePill(safeContainers);
|
||||
if (!pill) return null;
|
||||
|
||||
@@ -114,7 +114,7 @@ export function NodeSwitcher({ onManageNodes, compact = false }: NodeSwitcherPro
|
||||
<div className="absolute inset-y-0 left-0 w-[2px] bg-brand/60" />
|
||||
<div className="relative flex items-center justify-between px-[var(--density-row-x)] py-[var(--density-tile-y)]">
|
||||
<div className="flex items-baseline gap-2.5">
|
||||
<span className="font-display text-xl italic leading-none text-stat-value">
|
||||
<span className="font-heading text-xl leading-none text-stat-value">
|
||||
Connected
|
||||
</span>
|
||||
<span className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] tabular-nums text-brand">
|
||||
|
||||
@@ -224,7 +224,7 @@ export function NotificationPanel({
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-r from-brand/[0.05] via-transparent to-transparent" />
|
||||
<div className="absolute inset-y-0 left-0 w-[2px] bg-brand/60" />
|
||||
<div className="relative flex items-center justify-between px-[var(--density-row-x)] py-[var(--density-tile-y)]">
|
||||
<span className="font-display text-xl italic leading-none text-stat-value">
|
||||
<span className="font-heading text-xl leading-none text-stat-value">
|
||||
Notifications
|
||||
</span>
|
||||
{unreadCount > 0 ? (
|
||||
|
||||
@@ -443,7 +443,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
<div className="text-[10px] font-mono uppercase tracking-[0.22em] text-stat-subtitle mb-1">
|
||||
Next 24 hours
|
||||
</div>
|
||||
<div className="font-display italic text-3xl text-foreground leading-tight">
|
||||
<div className="font-heading text-3xl text-foreground leading-tight">
|
||||
Next <em className="not-italic text-brand">24 hours</em>
|
||||
</div>
|
||||
<div className="text-xs font-mono text-stat-subtitle mt-1 tabular-nums">
|
||||
|
||||
@@ -130,7 +130,7 @@ function BlueprintTile({ blueprint, onClick }: { blueprint: BlueprintListItem; o
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className={`inline-block w-2 h-2 rounded-full shrink-0 ${statusDot(dom)}`} aria-hidden />
|
||||
<span className="font-serif italic text-base tracking-[-0.01em] text-stat-value truncate">
|
||||
<span className="font-heading text-base tracking-[-0.01em] text-stat-value truncate">
|
||||
{blueprint.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -311,7 +311,7 @@ export function BlueprintEditor({ initial, distinctLabels, onCancel, onSubmit, s
|
||||
<span className={`inline-block w-2 h-2 rounded-full ${selected ? 'bg-brand' : 'border border-stat-icon'}`} />
|
||||
{opt.kicker}
|
||||
</div>
|
||||
<p className="font-serif italic text-sm mt-1.5 text-stat-value">{opt.title}</p>
|
||||
<p className="font-heading text-sm mt-1.5 text-stat-value">{opt.title}</p>
|
||||
<p className="text-[10px] text-stat-subtitle mt-0.5">{opt.tagline}</p>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -100,7 +100,7 @@ export function HealthStatusBar({
|
||||
className={`h-2.5 w-2.5 rounded-full ${config.dotClass} ${level === 'healthy' ? '' : 'animate-[pulse_2.4s_ease-in-out_infinite]'}`}
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className={`font-display italic text-3xl leading-none tracking-tight ${config.textClass}`}>
|
||||
<span className={`font-heading text-3xl leading-none tracking-tight ${config.textClass}`}>
|
||||
{config.label}
|
||||
</span>
|
||||
<span className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle">
|
||||
|
||||
@@ -134,7 +134,7 @@ export function StackHealthTable({
|
||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel max-md:overflow-x-auto">
|
||||
<div className="flex items-center justify-between gap-4 px-5 py-4">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<h2 className="font-display italic text-xl leading-none tracking-tight text-stat-value">
|
||||
<h2 className="font-heading text-xl leading-none tracking-tight text-stat-value">
|
||||
Stack health
|
||||
</h2>
|
||||
<span className="font-mono text-[11px] uppercase tracking-[0.22em] text-stat-subtitle">
|
||||
|
||||
@@ -110,7 +110,7 @@ export function FleetMasthead({
|
||||
className={`h-2.5 w-2.5 rounded-full ${config.dotClass} ${level === 'healthy' ? '' : 'animate-[pulse_2.4s_ease-in-out_infinite]'}`}
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className={`font-display italic text-3xl leading-none tracking-tight ${config.textClass}`}>
|
||||
<span className={`font-heading text-3xl leading-none tracking-tight ${config.textClass}`}>
|
||||
{config.label}
|
||||
</span>
|
||||
<span className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle">
|
||||
|
||||
@@ -267,7 +267,7 @@ function RoutingMasthead({ meshedNodes, reachableNodes, totalAliases, onShowActi
|
||||
return (
|
||||
<div className="flex items-center justify-between rounded-lg border border-card-border bg-card p-4 shadow-card-bevel">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="font-display italic text-2xl">{stateWord}</div>
|
||||
<div className="font-heading text-2xl">{stateWord}</div>
|
||||
<div className="grid grid-cols-3 gap-4 text-xs">
|
||||
<div>
|
||||
<div className="text-[10px] leading-3 tracking-[0.18em] uppercase text-stat-subtitle font-mono">meshed</div>
|
||||
|
||||
@@ -198,7 +198,7 @@ function NodeDetail({
|
||||
<span aria-hidden className={`absolute left-0 top-1 bottom-[15px] w-[3px] ${tone === 'destructive' ? 'bg-destructive' : tone === 'warning' ? 'bg-warning' : 'bg-brand'}`} />
|
||||
<div className="mb-1"><Kicker>{`fleet › node › ${node.name}`}</Kicker></div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="min-w-0 truncate font-display italic text-[30px] leading-[34px] text-stat-value">{node.name}</span>
|
||||
<span className="min-w-0 truncate font-heading text-[30px] leading-[34px] text-stat-value">{node.name}</span>
|
||||
<StatePill tone={tone} live={!online}>{node.cordoned ? 'cordoned' : online ? (isCritical(node) ? 'degraded' : 'online') : 'offline'}</StatePill>
|
||||
</div>
|
||||
<div className="mt-[7px] font-mono text-[12px] text-stat-subtitle">
|
||||
|
||||
@@ -51,7 +51,7 @@ export function MobileSettings({ headerActions }: MobileSettingsProps) {
|
||||
<div className="relative border-b border-hairline px-4 pb-[15px] pt-1">
|
||||
<span aria-hidden className="absolute left-0 top-1 bottom-[15px] w-[3px] bg-brand" />
|
||||
<div className="mb-1"><Kicker>{`settings · ${group?.label ?? ''} · ${item.label}`}</Kicker></div>
|
||||
<span className="font-display italic text-[30px] leading-[34px] text-stat-value">{item.label}</span>
|
||||
<span className="font-heading text-[30px] leading-[34px] text-stat-value">{item.label}</span>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden px-4 pb-8 pt-4 flex flex-col gap-6">
|
||||
<SettingsSectionContent sectionId={activeSection} onDirtyChange={NOOP} showDescription />
|
||||
|
||||
@@ -194,7 +194,7 @@ export function Masthead({
|
||||
<div className="mb-1">{kickerSlot ?? <Kicker>{kicker}</Kicker>}</div>
|
||||
<div className="flex items-center gap-[11px]">
|
||||
<StateDot tone={stateTone} size={11} pulse={live} />
|
||||
<span className={cn('font-display italic text-[38px] leading-[40px] text-stat-value', stateClassName)}>{state}</span>
|
||||
<span className={cn('font-heading text-[38px] leading-[40px] text-stat-value', stateClassName)}>{state}</span>
|
||||
</div>
|
||||
</div>
|
||||
{right ? <div className="shrink-0 text-right">{right}</div> : null}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, act, renderHook } from '@testing-library/react';
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
|
||||
// recharts paints nothing at 0x0 in jsdom, so stub every export with a prop-
|
||||
// capturing element. The real ChartContainer still runs (it injects the
|
||||
// --color-* vars from SEVERITY_CONFIG), so the token mapping is observable.
|
||||
vi.mock('recharts', async () => {
|
||||
const React = await import('react');
|
||||
const stub = (tag: string) => (props: Record<string, unknown>) =>
|
||||
React.createElement(
|
||||
'div',
|
||||
{
|
||||
'data-rc': tag,
|
||||
'data-fill': props.fill as string | undefined,
|
||||
'data-fillopacity':
|
||||
props.fillOpacity === undefined ? undefined : String(props.fillOpacity),
|
||||
'data-strokewidth':
|
||||
props.strokeWidth === undefined ? undefined : String(props.strokeWidth),
|
||||
'data-chartdata': props.data ? JSON.stringify(props.data) : undefined,
|
||||
},
|
||||
props.children as React.ReactNode,
|
||||
);
|
||||
// Explicit named exports (vitest validates named imports against real keys,
|
||||
// so a Proxy namespace will not do). Covers what SecurityCharts and the shared
|
||||
// ChartContainer (ResponsiveContainer / Tooltip / Legend) reference.
|
||||
return {
|
||||
ResponsiveContainer: stub('ResponsiveContainer'),
|
||||
Tooltip: stub('Tooltip'),
|
||||
Legend: stub('Legend'),
|
||||
PieChart: stub('PieChart'),
|
||||
Pie: stub('Pie'),
|
||||
AreaChart: stub('AreaChart'),
|
||||
Area: stub('Area'),
|
||||
BarChart: stub('BarChart'),
|
||||
Bar: stub('Bar'),
|
||||
XAxis: stub('XAxis'),
|
||||
YAxis: stub('YAxis'),
|
||||
CartesianGrid: stub('CartesianGrid'),
|
||||
LabelList: stub('LabelList'),
|
||||
};
|
||||
});
|
||||
|
||||
import { RiskTrendChart, FindingsByTypeChart, SeverityDonutChart } from './SecurityCharts';
|
||||
import type { ScanSummary, SecurityRiskTrendPoint } from '@/types/security';
|
||||
|
||||
const TREND: SecurityRiskTrendPoint[] = [
|
||||
{ date: '2026-06-01', critical: 2, high: 5 },
|
||||
{ date: '2026-06-02', critical: 1, high: 3 },
|
||||
];
|
||||
|
||||
const SUMMARY: ScanSummary = {
|
||||
image_ref: 'nginx:1.27',
|
||||
highest_severity: 'CRITICAL',
|
||||
scanned_at: 0,
|
||||
scan_id: 1,
|
||||
total: 11,
|
||||
critical: 2, high: 5, medium: 3, low: 1, unknown: 0,
|
||||
fixable: 3,
|
||||
secret_count: 1, misconfig_count: 4,
|
||||
};
|
||||
|
||||
function configureChart(opts: { chartStyle?: 'muted' | 'heat' | 'signature'; reducedEffects?: boolean; readability?: boolean } = {}) {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
act(() => {
|
||||
result.current.setReadability(false);
|
||||
result.current.setVisualStyle('signature');
|
||||
if (opts.chartStyle) result.current.setChartStyle(opts.chartStyle);
|
||||
if (opts.reducedEffects) result.current.setReducedEffects(true);
|
||||
if (opts.readability) result.current.setReadability(true);
|
||||
});
|
||||
}
|
||||
|
||||
describe('SecurityCharts palette routing', () => {
|
||||
beforeEach(() => configureChart());
|
||||
|
||||
it('routes FindingsByType through --sev-* / neutral (no destructive/warning/brand)', () => {
|
||||
const { container } = render(<FindingsByTypeChart summaries={[SUMMARY]} />);
|
||||
const chart = container.querySelector('[data-rc="BarChart"]');
|
||||
const data = JSON.parse(chart!.getAttribute('data-chartdata')!) as { fill: string }[];
|
||||
expect(data.map((d) => d.fill)).toEqual(['var(--sev-vuln)', 'var(--sev-critical)', 'var(--stat-icon)']);
|
||||
for (const d of data) {
|
||||
expect(d.fill).not.toMatch(/--(destructive|warning|brand)\)/);
|
||||
}
|
||||
});
|
||||
|
||||
it('maps the four donut severities to the --sev-* tokens', () => {
|
||||
const { container } = render(<SeverityDonutChart summaries={[SUMMARY]} />);
|
||||
const css = container.querySelector('style')?.textContent ?? '';
|
||||
expect(css).toContain('--color-critical: var(--sev-critical)');
|
||||
expect(css).toContain('--color-high: var(--sev-high)');
|
||||
expect(css).toContain('--color-medium: var(--sev-medium)');
|
||||
expect(css).toContain('--color-low: var(--sev-low)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('RiskTrendChart gradient vs flat', () => {
|
||||
beforeEach(() => configureChart());
|
||||
|
||||
it('keeps the gradient fill and stroke 1.5 in Signature (the no-op baseline)', () => {
|
||||
configureChart({ chartStyle: 'signature' });
|
||||
const { container } = render(<RiskTrendChart trend={TREND} />);
|
||||
const areas = [...container.querySelectorAll('[data-rc="Area"]')];
|
||||
expect(areas).toHaveLength(2);
|
||||
for (const a of areas) {
|
||||
expect(a.getAttribute('data-fill')).toMatch(/^url\(#risk/);
|
||||
expect(a.getAttribute('data-strokewidth')).toBe('1.5');
|
||||
expect(a.getAttribute('data-fillopacity')).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('drops the gradient for a solid low-opacity fill under Muted', () => {
|
||||
configureChart({ chartStyle: 'muted' });
|
||||
const { container } = render(<RiskTrendChart trend={TREND} />);
|
||||
const areas = [...container.querySelectorAll('[data-rc="Area"]')];
|
||||
for (const a of areas) {
|
||||
expect(a.getAttribute('data-fill')).toMatch(/^var\(--color-/);
|
||||
expect(a.getAttribute('data-strokewidth')).toBe('1.9');
|
||||
expect(a.getAttribute('data-fillopacity')).toBe('0.16');
|
||||
}
|
||||
});
|
||||
|
||||
it('uses the heat fill (0.15) with no gradient under Heat', () => {
|
||||
configureChart({ chartStyle: 'heat' });
|
||||
const { container } = render(<RiskTrendChart trend={TREND} />);
|
||||
const areas = [...container.querySelectorAll('[data-rc="Area"]')];
|
||||
expect(areas).toHaveLength(2);
|
||||
for (const a of areas) {
|
||||
expect(a.getAttribute('data-fill')).toMatch(/^var\(--color-/);
|
||||
expect(a.getAttribute('data-fillopacity')).toBe('0.15');
|
||||
expect(a.getAttribute('data-strokewidth')).toBe('1.9');
|
||||
}
|
||||
});
|
||||
|
||||
it('dims the fill further and flattens under reduced effects, even in Signature', () => {
|
||||
configureChart({ chartStyle: 'signature', reducedEffects: true });
|
||||
const { container } = render(<RiskTrendChart trend={TREND} />);
|
||||
const areas = [...container.querySelectorAll('[data-rc="Area"]')];
|
||||
for (const a of areas) {
|
||||
expect(a.getAttribute('data-fill')).toMatch(/^var\(--color-/);
|
||||
expect(a.getAttribute('data-strokewidth')).toBe('1.9');
|
||||
// 0.30 * 0.62
|
||||
expect(Number(a.getAttribute('data-fillopacity'))).toBeCloseTo(0.186, 5);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,28 @@
|
||||
import { useMemo } from 'react';
|
||||
import { PieChart, Pie, AreaChart, Area, BarChart, Bar, XAxis, YAxis, CartesianGrid, LabelList } from 'recharts';
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart';
|
||||
import { useChartStyle, type ChartStyle } from '@/hooks/use-theme';
|
||||
import type { ScanSummary, SecurityRiskTrendPoint } from '@/types/security';
|
||||
|
||||
// Severity palette stays within the design's semantic tokens: --destructive
|
||||
// (critical), --warning (high), a muted --warning (medium), --muted-foreground
|
||||
// (low). No new chart hue.
|
||||
// Severity colours resolve through the --sev-* tokens, which the appearance
|
||||
// chart-style switches (Signature keeps today's saturated semantics; Muted and
|
||||
// Heat are calmer ramps). ChartContainer injects them as --color-* for recharts.
|
||||
const SEVERITY_CONFIG = {
|
||||
critical: { label: 'Critical', color: 'var(--destructive)' },
|
||||
high: { label: 'High', color: 'var(--warning)' },
|
||||
medium: { label: 'Medium', color: 'color-mix(in oklch, var(--warning) 55%, var(--muted))' },
|
||||
low: { label: 'Low', color: 'var(--muted-foreground)' },
|
||||
critical: { label: 'Critical', color: 'var(--sev-critical)' },
|
||||
high: { label: 'High', color: 'var(--sev-high)' },
|
||||
medium: { label: 'Medium', color: 'var(--sev-medium)' },
|
||||
low: { label: 'Low', color: 'var(--sev-low)' },
|
||||
} satisfies ChartConfig;
|
||||
|
||||
// Area fill opacity, gradient on/off, and stroke per chart-style. Colours stay in
|
||||
// the --sev-* tokens; only these shape values vary. Reduced effects flattens
|
||||
// (no gradient) and dims the fill, matching the calm material direction.
|
||||
const TREND_SHAPE: Record<ChartStyle, { fill: number; gradient: boolean; stroke: number }> = {
|
||||
signature: { fill: 0.30, gradient: true, stroke: 1.5 },
|
||||
muted: { fill: 0.16, gradient: false, stroke: 1.9 },
|
||||
heat: { fill: 0.15, gradient: false, stroke: 1.9 },
|
||||
};
|
||||
|
||||
// The trend and top-exposed charts both plot only the Critical + High slots.
|
||||
const CRITICAL_HIGH_CONFIG = {
|
||||
critical: SEVERITY_CONFIG.critical,
|
||||
@@ -57,29 +67,53 @@ export function SeverityDonutChart({ summaries }: { summaries: ScanSummary[] })
|
||||
|
||||
/** Stacked area of Critical + High findings by scan-day (days with no scans are omitted). */
|
||||
export function RiskTrendChart({ trend }: { trend: SecurityRiskTrendPoint[] }) {
|
||||
const { chartStyle, reduced } = useChartStyle();
|
||||
if (trend.length === 0) return <EmptyChart label="No scan history yet" height={220} />;
|
||||
|
||||
const fmtDate = (d: string) => d.slice(5); // MM-DD
|
||||
|
||||
const shape = TREND_SHAPE[chartStyle];
|
||||
// Signature (gradient, stroke 1.5) is the no-op baseline. Flat styles and
|
||||
// reduced effects drop the gradient for a solid low-opacity fill + thicker line.
|
||||
const gradient = shape.gradient && !reduced;
|
||||
const fillOpacity = reduced ? shape.fill * 0.62 : shape.fill;
|
||||
const stroke = reduced ? 1.9 : shape.stroke;
|
||||
|
||||
return (
|
||||
<ChartContainer config={CRITICAL_HIGH_CONFIG} className="h-[220px] w-full">
|
||||
<AreaChart data={trend} margin={{ left: 4, right: 8, top: 8 }}>
|
||||
<defs>
|
||||
<linearGradient id="riskHigh" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="var(--color-high)" stopOpacity={0.35} />
|
||||
<stop offset="95%" stopColor="var(--color-high)" stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
<linearGradient id="riskCritical" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="var(--color-critical)" stopOpacity={0.4} />
|
||||
<stop offset="95%" stopColor="var(--color-critical)" stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{gradient && (
|
||||
<defs>
|
||||
<linearGradient id="riskHigh" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="var(--color-high)" stopOpacity={0.35} />
|
||||
<stop offset="95%" stopColor="var(--color-high)" stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
<linearGradient id="riskCritical" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="var(--color-critical)" stopOpacity={0.4} />
|
||||
<stop offset="95%" stopColor="var(--color-critical)" stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
)}
|
||||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" tickFormatter={fmtDate} tickLine={false} axisLine={false} fontSize={10} minTickGap={24} />
|
||||
<YAxis tickLine={false} axisLine={false} fontSize={10} width={28} allowDecimals={false} />
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Area dataKey="high" stackId="risk" stroke="var(--color-high)" fill="url(#riskHigh)" strokeWidth={1.5} />
|
||||
<Area dataKey="critical" stackId="risk" stroke="var(--color-critical)" fill="url(#riskCritical)" strokeWidth={1.5} />
|
||||
<Area
|
||||
dataKey="high"
|
||||
stackId="risk"
|
||||
stroke="var(--color-high)"
|
||||
fill={gradient ? 'url(#riskHigh)' : 'var(--color-high)'}
|
||||
fillOpacity={gradient ? undefined : fillOpacity}
|
||||
strokeWidth={stroke}
|
||||
/>
|
||||
<Area
|
||||
dataKey="critical"
|
||||
stackId="risk"
|
||||
stroke="var(--color-critical)"
|
||||
fill={gradient ? 'url(#riskCritical)' : 'var(--color-critical)'}
|
||||
fillOpacity={gradient ? undefined : fillOpacity}
|
||||
strokeWidth={stroke}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
);
|
||||
@@ -141,10 +175,14 @@ export function FindingsByTypeChart({ summaries }: { summaries: ScanSummary[] })
|
||||
secrets += s.secret_count;
|
||||
misconfigs += s.misconfig_count;
|
||||
}
|
||||
// Route every series through the severity ramp (or a neutral for misconfigs)
|
||||
// so no two complementary hues sit adjacent (the old cyan-next-to-rose clash).
|
||||
// --stat-icon is palette-invariant by design: misconfigs stay neutral across
|
||||
// Muted/Heat rather than picking up a severity hue.
|
||||
return [
|
||||
{ type: 'Vulnerabilities', value: vulnerabilities, fill: 'var(--brand)' },
|
||||
{ type: 'Secrets', value: secrets, fill: 'var(--destructive)' },
|
||||
{ type: 'Misconfigs', value: misconfigs, fill: 'var(--warning)' },
|
||||
{ type: 'Vulnerabilities', value: vulnerabilities, fill: 'var(--sev-vuln)' },
|
||||
{ type: 'Secrets', value: secrets, fill: 'var(--sev-critical)' },
|
||||
{ type: 'Misconfigs', value: misconfigs, fill: 'var(--stat-icon)' },
|
||||
];
|
||||
}, [summaries]);
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Check, Info } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { SegmentedControl } from '@/components/ui/segmented-control';
|
||||
@@ -6,7 +8,10 @@ import { useDensity } from '@/hooks/use-density';
|
||||
import type { Density } from '@/hooks/use-density';
|
||||
import { useTopNavLabels } from '@/hooks/use-top-nav-labels';
|
||||
import { useTopNavAlign, type TopNavAlign } from '@/hooks/use-top-nav-align';
|
||||
import { useTheme, THEME_MODE_OPTIONS, ACCENTS, CONTRAST, BORDER_BOOST, GLOW, TYPE_SCALE } from '@/hooks/use-theme';
|
||||
import {
|
||||
useTheme, activeVisualStyle, THEME_MODE_OPTIONS, ACCENTS, CONTRAST, BORDER_BOOST, GLOW, TYPE_SCALE,
|
||||
type VisualStyle, type HeadingStyle, type ChartStyle,
|
||||
} from '@/hooks/use-theme';
|
||||
import { AccentPicker } from '@/components/theme/AccentPicker';
|
||||
import { ThemePreview } from '@/components/theme/ThemePreview';
|
||||
import { TypeChips } from '@/components/theme/TypeChips';
|
||||
@@ -30,20 +35,246 @@ const TOP_NAV_ALIGN_OPTIONS: { value: TopNavAlign; label: string }[] = [
|
||||
{ value: 'center', label: 'Center' },
|
||||
];
|
||||
|
||||
const CHART_STYLE_OPTIONS: { value: ChartStyle; label: string }[] = [
|
||||
{ value: 'muted', label: 'Muted' },
|
||||
{ value: 'heat', label: 'Heat' },
|
||||
{ value: 'signature', label: 'Signature' },
|
||||
];
|
||||
|
||||
const HEADING_STYLE_OPTIONS: { value: HeadingStyle; label: string }[] = [
|
||||
{ value: 'clean', label: 'Clean' },
|
||||
{ value: 'signature', label: 'Signature' },
|
||||
];
|
||||
|
||||
const fmtSigned = (v: number) => `${v > 0 ? '+' : ''}${v.toFixed(2)}`;
|
||||
|
||||
// Preview swatches for the Visual style cards. Calm uses the muted ramp; Signature
|
||||
// deliberately puts the brand bar next to destructive to show the clash it fixes.
|
||||
interface VisualCardData {
|
||||
kind: VisualStyle;
|
||||
name: string;
|
||||
blurb: string;
|
||||
bars: string[];
|
||||
}
|
||||
|
||||
const VISUAL_CARDS: VisualCardData[] = [
|
||||
{
|
||||
kind: 'calm',
|
||||
name: 'Calm',
|
||||
blurb: 'Upright headings, muted flat charts. The readable default.',
|
||||
bars: ['oklch(0.605 0.105 28)', 'oklch(0.715 0.085 70)', 'oklch(0.675 0.045 88)', 'oklch(0.565 0.018 250)'],
|
||||
},
|
||||
{
|
||||
kind: 'signature',
|
||||
name: 'Signature',
|
||||
blurb: "Italic serif headings, saturated charts. Today's look.",
|
||||
bars: ['var(--destructive)', 'var(--warning)', 'var(--brand)', 'color-mix(in oklch, var(--warning) 50%, var(--stat-icon))'],
|
||||
},
|
||||
];
|
||||
const VISUAL_BAR_HEIGHTS = [16, 11, 13, 8];
|
||||
|
||||
function VisualCard({
|
||||
card, selected, disabled, onSelect,
|
||||
}: {
|
||||
card: VisualCardData;
|
||||
selected: boolean;
|
||||
disabled?: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const { kind, name, blurb, bars } = card;
|
||||
const calm = kind === 'calm';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
disabled={disabled}
|
||||
aria-pressed={selected}
|
||||
className={cn(
|
||||
'flex flex-col overflow-hidden rounded-lg border bg-well text-left transition-colors',
|
||||
selected
|
||||
? 'border-brand/55 ring-1 ring-brand/40'
|
||||
: 'border-card-border border-t-card-border-top hover:border-t-card-border-hover',
|
||||
disabled && 'opacity-50',
|
||||
)}
|
||||
>
|
||||
<div className="flex h-[74px] flex-col justify-center gap-1.5 border-b border-hairline px-3.5">
|
||||
<span
|
||||
className={cn(
|
||||
'text-[22px] leading-none text-stat-value',
|
||||
calm ? 'font-sans font-semibold not-italic' : 'font-display font-normal italic',
|
||||
)}
|
||||
>
|
||||
Critical
|
||||
</span>
|
||||
<span className="flex items-end gap-1" style={{ height: 16 }}>
|
||||
{bars.map((b, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="block w-[9px] rounded-sm"
|
||||
style={{ background: b, height: VISUAL_BAR_HEIGHTS[i] }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-3.5 pb-3 pt-2.5">
|
||||
<div className="flex items-center gap-2 text-[13px] font-semibold text-stat-value">
|
||||
{name}
|
||||
{selected ? <Check className="h-3.5 w-3.5 text-brand" strokeWidth={2.5} /> : null}
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] leading-snug text-stat-subtitle">{blurb}</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppearanceSection() {
|
||||
const [density, setDensity] = useDensity();
|
||||
const [topNavLabels, setTopNavLabels] = useTopNavLabels();
|
||||
const [topNavAlign, setTopNavAlign] = useTopNavAlign();
|
||||
const {
|
||||
theme, accent, borderBoost, glow, contrast, uiFont, monoFont, typeScale,
|
||||
headingStyle, chartStyle, reducedEffects, readability,
|
||||
setTheme, setAccent, setBorderBoost, setGlow, setContrast, setUiFont, setMonoFont, setTypeScale,
|
||||
setVisualStyle, setHeadingStyle, setChartStyle, setReducedEffects, setReadability,
|
||||
} = useTheme();
|
||||
const accentLabel = ACCENTS.find((a) => a.id === accent)?.label ?? 'Cyan';
|
||||
// Readability is a sticky master: it forces the calm resolution at apply time
|
||||
// without mutating the stored sub-axes, so the controls reflect the forced
|
||||
// value and lock while it is on. Effects are reduced under readability too.
|
||||
const effectiveReduced = readability || reducedEffects;
|
||||
const effectiveContrast = contrast + (readability ? 0.18 : 0);
|
||||
// A card is selected only while the stored sub-axes still match its preset, so
|
||||
// a custom combination de-selects both (and readability resolves to null).
|
||||
// Shared with the topbar quick-switch via activeVisualStyle so both surfaces
|
||||
// agree on which style is active.
|
||||
const activeVisual = activeVisualStyle({ headingStyle, chartStyle, reducedEffects, readability });
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
<SettingsSection title="Visual style" kicker="the master switch">
|
||||
<div className="grid grid-cols-2 gap-2.5 pt-3">
|
||||
{VISUAL_CARDS.map((c) => (
|
||||
<VisualCard
|
||||
key={c.kind}
|
||||
card={c}
|
||||
selected={activeVisual === c.kind}
|
||||
disabled={readability}
|
||||
onSelect={() => setVisualStyle(c.kind)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{readability ? (
|
||||
<div className="mt-3 flex items-start gap-2.5 rounded-md border border-brand/30 bg-brand/10 px-3 py-2.5">
|
||||
<Info className="mt-0.5 h-4 w-4 shrink-0 text-brand" strokeWidth={1.5} />
|
||||
<p className="text-sm leading-relaxed text-stat-title">
|
||||
<span className="font-medium text-brand">Readability mode is on.</span> It forces the calmest
|
||||
settings and a small contrast lift. Turn it off below to choose a style by hand.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Security visualization" kicker="the chart fix">
|
||||
<SettingsField
|
||||
label="Chart palette"
|
||||
helper="Muted desaturates the severity colors; Heat is one warm ramp with no red/blue clash; Signature is the saturated set."
|
||||
align="start"
|
||||
>
|
||||
<SegmentedControl
|
||||
value={readability ? 'muted' : chartStyle}
|
||||
options={CHART_STYLE_OPTIONS}
|
||||
onChange={setChartStyle}
|
||||
disabled={readability}
|
||||
ariaLabel="Chart palette"
|
||||
/>
|
||||
</SettingsField>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Readability" kicker="this browser">
|
||||
<SettingsField
|
||||
label="Readability mode"
|
||||
helper="One switch: upright headings, muted flat charts, reduced effects, and a contrast lift."
|
||||
>
|
||||
<TogglePill checked={readability} onChange={setReadability} aria-label="Readability mode" />
|
||||
</SettingsField>
|
||||
|
||||
<SettingsField
|
||||
label="Header style"
|
||||
helper="Clean uses the upright interface face; Signature keeps the italic display serif."
|
||||
align="start"
|
||||
>
|
||||
<SegmentedControl
|
||||
value={readability ? 'clean' : headingStyle}
|
||||
options={HEADING_STYLE_OPTIONS}
|
||||
onChange={setHeadingStyle}
|
||||
disabled={readability}
|
||||
ariaLabel="Header style"
|
||||
/>
|
||||
</SettingsField>
|
||||
|
||||
<SettingsField
|
||||
label="Contrast"
|
||||
helper="Master contrast: spreads the page, ink, and borders together. Pair high contrast with OLED for the crispest panel."
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Slider
|
||||
value={[contrast]}
|
||||
min={CONTRAST.min}
|
||||
max={CONTRAST.max}
|
||||
step={CONTRAST.step}
|
||||
onValueChange={([v]) => setContrast(v)}
|
||||
aria-label="Contrast"
|
||||
/>
|
||||
<span className="w-12 shrink-0 text-right font-mono text-xs tabular-nums text-stat-subtitle">
|
||||
{fmtSigned(effectiveContrast)}
|
||||
</span>
|
||||
</div>
|
||||
</SettingsField>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Motion & effects" kicker="this browser">
|
||||
<SettingsField
|
||||
label="Reduced effects"
|
||||
helper="Flattens card bevels, the accent glow, and chart gradients for a calmer surface."
|
||||
>
|
||||
<TogglePill
|
||||
checked={effectiveReduced}
|
||||
onChange={setReducedEffects}
|
||||
disabled={readability}
|
||||
aria-label="Reduced effects"
|
||||
/>
|
||||
</SettingsField>
|
||||
|
||||
<SettingsField
|
||||
label="Ambient glow"
|
||||
helper="Intensity of the accent-tinted glow behind the page."
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Slider
|
||||
value={[glow]}
|
||||
min={GLOW.min}
|
||||
max={GLOW.max}
|
||||
step={GLOW.step}
|
||||
onValueChange={([v]) => setGlow(v)}
|
||||
disabled={effectiveReduced}
|
||||
aria-label="Ambient glow"
|
||||
/>
|
||||
<span className="w-12 shrink-0 text-right font-mono text-xs tabular-nums text-stat-subtitle">
|
||||
{glow.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</SettingsField>
|
||||
|
||||
<SettingsActions>
|
||||
<SettingsSecondaryButton
|
||||
onClick={() => setVisualStyle('calm')}
|
||||
disabled={readability}
|
||||
>
|
||||
Reset to default
|
||||
</SettingsSecondaryButton>
|
||||
</SettingsActions>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Theme" kicker="this browser">
|
||||
<div className="pt-3">
|
||||
<ThemePreview />
|
||||
@@ -69,25 +300,6 @@ export function AppearanceSection() {
|
||||
<AccentPicker value={accent} onChange={setAccent} />
|
||||
</SettingsField>
|
||||
|
||||
<SettingsField
|
||||
label="Contrast"
|
||||
helper="Master contrast: spreads the page, ink, and borders together. Pair high contrast with OLED for the crispest panel."
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Slider
|
||||
value={[contrast]}
|
||||
min={CONTRAST.min}
|
||||
max={CONTRAST.max}
|
||||
step={CONTRAST.step}
|
||||
onValueChange={([v]) => setContrast(v)}
|
||||
aria-label="Contrast"
|
||||
/>
|
||||
<span className="w-12 shrink-0 text-right font-mono text-xs tabular-nums text-stat-subtitle">
|
||||
{fmtSigned(contrast)}
|
||||
</span>
|
||||
</div>
|
||||
</SettingsField>
|
||||
|
||||
<SettingsField
|
||||
label="Border brightness"
|
||||
helper="Lift or soften every hairline so separation reads exactly how you like it."
|
||||
@@ -99,29 +311,11 @@ export function AppearanceSection() {
|
||||
max={BORDER_BOOST.max}
|
||||
step={BORDER_BOOST.step}
|
||||
onValueChange={([v]) => setBorderBoost(v)}
|
||||
disabled={readability}
|
||||
aria-label="Border brightness"
|
||||
/>
|
||||
<span className="w-12 shrink-0 text-right font-mono text-xs tabular-nums text-stat-subtitle">
|
||||
{fmtSigned(borderBoost)}
|
||||
</span>
|
||||
</div>
|
||||
</SettingsField>
|
||||
|
||||
<SettingsField
|
||||
label="Ambient glow"
|
||||
helper="Intensity of the accent-tinted glow behind the page."
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Slider
|
||||
value={[glow]}
|
||||
min={GLOW.min}
|
||||
max={GLOW.max}
|
||||
step={GLOW.step}
|
||||
onValueChange={([v]) => setGlow(v)}
|
||||
aria-label="Ambient glow"
|
||||
/>
|
||||
<span className="w-12 shrink-0 text-right font-mono text-xs tabular-nums text-stat-subtitle">
|
||||
{glow.toFixed(2)}
|
||||
{fmtSigned(readability ? 0.03 : borderBoost)}
|
||||
</span>
|
||||
</div>
|
||||
</SettingsField>
|
||||
@@ -142,7 +336,7 @@ export function AppearanceSection() {
|
||||
<SettingsSection title="Typography" kicker="this browser">
|
||||
<SettingsField
|
||||
label="Interface font"
|
||||
helper="The sans face for body, labels, nav, and buttons. Display headings stay Instrument Serif."
|
||||
helper="The sans face for body, labels, navigation, and buttons. Heading style follows your Visual style choice."
|
||||
align="start"
|
||||
>
|
||||
<TypeChips value={uiFont} options={UI_FONT_OPTIONS} onChange={setUiFont} ariaLabel="Interface font" />
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, act, renderHook } from '@testing-library/react';
|
||||
import { AppearanceSection } from '../AppearanceSection';
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
|
||||
// AppearanceSection drives the shared theme store. Reset it to a known Signature
|
||||
// baseline (readability off, effects full) before each test so the disabled-state
|
||||
// assertions start from a clean, undimmed state.
|
||||
function resetTheme() {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
act(() => {
|
||||
result.current.setReadability(false);
|
||||
result.current.setVisualStyle('signature');
|
||||
result.current.setContrast(0);
|
||||
result.current.setGlow(0.16);
|
||||
});
|
||||
}
|
||||
|
||||
describe('AppearanceSection', () => {
|
||||
beforeEach(() => resetTheme());
|
||||
|
||||
it('renders the four refresh sections above Theme', () => {
|
||||
render(<AppearanceSection />);
|
||||
expect(screen.getByText('Visual style')).toBeTruthy();
|
||||
expect(screen.getByText('Security visualization')).toBeTruthy();
|
||||
expect(screen.getByText('Readability')).toBeTruthy();
|
||||
expect(screen.getByText('Motion & effects')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('selecting the Calm card applies the calm resolution to <html>', () => {
|
||||
render(<AppearanceSection />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /Calm/i }));
|
||||
expect(document.documentElement.dataset.headings).toBe('clean');
|
||||
expect(document.documentElement.dataset.chartStyle).toBe('muted');
|
||||
});
|
||||
|
||||
it('readability locks the header + chart controls and disables the glow slider', () => {
|
||||
const { container } = render(<AppearanceSection />);
|
||||
// Baseline: nothing reduced, so no slider is disabled.
|
||||
expect(container.querySelectorAll('[data-disabled]').length).toBe(0);
|
||||
expect(screen.getByRole('radiogroup', { name: 'Header style' }).getAttribute('aria-disabled')).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Readability mode' }));
|
||||
|
||||
expect(screen.getByRole('radiogroup', { name: 'Header style' }).getAttribute('aria-disabled')).toBe('true');
|
||||
expect(screen.getByRole('radiogroup', { name: 'Chart palette' }).getAttribute('aria-disabled')).toBe('true');
|
||||
expect((screen.getByRole('switch', { name: 'Reduced effects' }) as HTMLButtonElement).disabled).toBe(true);
|
||||
// Effective reduced (readability || reducedEffects) disables the glow slider
|
||||
// even though reducedEffects itself is still off.
|
||||
expect(container.querySelectorAll('[data-disabled]').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('readability also locks the Visual style cards and the Border brightness slider', () => {
|
||||
const { container } = render(<AppearanceSection />);
|
||||
const calmCard = () => screen.getByRole('button', { name: /readable default/i }) as HTMLButtonElement;
|
||||
const sigCard = () => screen.getByRole('button', { name: /Today's look/i }) as HTMLButtonElement;
|
||||
const borderLocked = () => !!container.querySelector('[aria-label="Border brightness"][data-disabled]');
|
||||
expect(calmCard().disabled).toBe(false);
|
||||
expect(borderLocked()).toBe(false);
|
||||
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Readability mode' }));
|
||||
|
||||
// Both cards lock (the topbar disables the same control), matching the
|
||||
// "turn readability off to choose a style" guidance.
|
||||
expect(calmCard().disabled).toBe(true);
|
||||
expect(sigCard().disabled).toBe(true);
|
||||
// Border brightness is forced to +0.03 under readability, so its slider locks.
|
||||
expect(borderLocked()).toBe(true);
|
||||
});
|
||||
|
||||
it('de-selects both visual-style cards when a custom sub-axis is chosen', () => {
|
||||
render(<AppearanceSection />);
|
||||
// Baseline is Signature, so the Signature card reads selected.
|
||||
expect(screen.getByRole('button', { name: /Today's look/i }).getAttribute('aria-pressed')).toBe('true');
|
||||
// A custom chart palette (Heat) makes the trio match no preset.
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Heat' }));
|
||||
expect(screen.getByRole('button', { name: /Today's look/i }).getAttribute('aria-pressed')).toBe('false');
|
||||
expect(screen.getByRole('button', { name: /readable default/i }).getAttribute('aria-pressed')).toBe('false');
|
||||
});
|
||||
|
||||
it('de-selects when only the header style diverges (not just the chart palette)', () => {
|
||||
render(<AppearanceSection />);
|
||||
// Baseline Signature; flipping only Header style to Clean breaks the match.
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Clean' }));
|
||||
expect(screen.getByRole('button', { name: /Today's look/i }).getAttribute('aria-pressed')).toBe('false');
|
||||
expect(screen.getByRole('button', { name: /readable default/i }).getAttribute('aria-pressed')).toBe('false');
|
||||
});
|
||||
|
||||
it('reset to default restores Calm and locks while readability is on', () => {
|
||||
render(<AppearanceSection />);
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Heat' }));
|
||||
expect(document.documentElement.dataset.chartStyle).toBe('heat');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reset to default' }));
|
||||
expect(document.documentElement.dataset.headings).toBe('clean');
|
||||
expect(document.documentElement.dataset.chartStyle).toBe('muted');
|
||||
expect(screen.getByRole('button', { name: /readable default/i }).getAttribute('aria-pressed')).toBe('true');
|
||||
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Readability mode' }));
|
||||
expect((screen.getByRole('button', { name: 'Reset to default' }) as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -99,6 +99,13 @@ describe('settings registry', () => {
|
||||
expect(SETTINGS_ITEMS.find(i => i.id === 'appearance')?.scope).toBe('browser');
|
||||
expect(SETTINGS_ITEMS.find(i => i.id === 'stacks')?.scope).toBe('browser');
|
||||
});
|
||||
|
||||
it('exposes the Calm/Signature appearance keywords for search', () => {
|
||||
const appearance = SETTINGS_ITEMS.find(i => i.id === 'appearance');
|
||||
for (const term of ['calm', 'signature', 'readability', 'heading', 'chart', 'motion', 'effects']) {
|
||||
expect(appearance?.keywords, `keyword ${term}`).toContain(term);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('scopeLabel', () => {
|
||||
|
||||
@@ -61,8 +61,8 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
|
||||
id: 'appearance',
|
||||
group: 'personal',
|
||||
label: 'Appearance',
|
||||
description: 'Theme, accent, density, and display preferences saved to this browser.',
|
||||
keywords: ['theme', 'dim', 'oled', 'light', 'dark', 'accent', 'color', 'glow', 'border', 'contrast', 'density', 'comfortable', 'compact', 'spacing', 'display'],
|
||||
description: 'Visual style, readability, theme, accent, charts, and display preferences saved to this browser.',
|
||||
keywords: ['theme', 'dim', 'oled', 'light', 'dark', 'accent', 'color', 'glow', 'border', 'contrast', 'density', 'comfortable', 'compact', 'spacing', 'display', 'calm', 'signature', 'readability', 'heading', 'chart', 'motion', 'effects'],
|
||||
tier: null,
|
||||
scope: 'browser',
|
||||
},
|
||||
|
||||
@@ -24,7 +24,7 @@ export function ThemePreview() {
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-brand">
|
||||
fleet · preview
|
||||
</div>
|
||||
<div className="font-display text-2xl italic leading-none text-stat-value">
|
||||
<div className="font-heading text-2xl leading-none text-stat-value">
|
||||
Healthy
|
||||
</div>
|
||||
<div className="mt-1.5 font-mono text-[11px] text-stat-subtitle">
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, act, renderHook } from '@testing-library/react';
|
||||
import { ThemeQuickSwitch } from './ThemeQuickSwitch';
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
|
||||
function resetTheme() {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
act(() => {
|
||||
result.current.setReadability(false);
|
||||
result.current.setVisualStyle('calm');
|
||||
});
|
||||
}
|
||||
|
||||
describe('ThemeQuickSwitch', () => {
|
||||
beforeEach(() => resetTheme());
|
||||
|
||||
it('replaces the font pickers with a Visual style switch and a Readability toggle', () => {
|
||||
render(<ThemeQuickSwitch />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Theme' }));
|
||||
expect(screen.getByRole('radiogroup', { name: 'Visual style' })).toBeTruthy();
|
||||
expect(screen.getByRole('switch', { name: 'Readability mode' })).toBeTruthy();
|
||||
// Interface / Data font pickers are gone; Text size stays.
|
||||
expect(screen.queryByText('Interface')).toBeNull();
|
||||
expect(screen.getByText('Text size')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('switching the visual style from the panel applies it', () => {
|
||||
render(<ThemeQuickSwitch />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Theme' }));
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Signature' }));
|
||||
expect(document.documentElement.dataset.headings).toBe('signature');
|
||||
});
|
||||
|
||||
it('locks the Visual style switch while readability is on', () => {
|
||||
render(<ThemeQuickSwitch />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Theme' }));
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Readability mode' }));
|
||||
expect(screen.getByRole('radiogroup', { name: 'Visual style' }).getAttribute('aria-disabled')).toBe('true');
|
||||
});
|
||||
|
||||
it('the Settings link calls onOpenAppearance', () => {
|
||||
const onOpenAppearance = vi.fn();
|
||||
render(<ThemeQuickSwitch onOpenAppearance={onOpenAppearance} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Theme' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Settings' }));
|
||||
expect(onOpenAppearance).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,32 +1,53 @@
|
||||
import { useState } from 'react';
|
||||
import { Palette } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { SegmentedControl } from '@/components/ui/segmented-control';
|
||||
import { TogglePill } from '@/components/ui/toggle-pill';
|
||||
import { AccentPicker } from './AccentPicker';
|
||||
import { TypeChips } from './TypeChips';
|
||||
import { UI_FONT_OPTIONS, MONO_FONT_OPTIONS, SIZE_OPTIONS, sizeIdForScale } from './typeOptions';
|
||||
import { useTheme, THEME_MODES, THEME_MODE_OPTIONS, ACCENTS, TYPE_SIZES } from '@/hooks/use-theme';
|
||||
import { SIZE_OPTIONS, sizeIdForScale } from './typeOptions';
|
||||
import { useTheme, activeVisualStyle, THEME_MODES, THEME_MODE_OPTIONS, ACCENTS, TYPE_SIZES, type VisualStyle } from '@/hooks/use-theme';
|
||||
|
||||
const VISUAL_STYLE_OPTIONS: { value: VisualStyle; label: string }[] = [
|
||||
{ value: 'calm', label: 'Calm' },
|
||||
{ value: 'signature', label: 'Signature' },
|
||||
];
|
||||
|
||||
interface ThemeQuickSwitchProps {
|
||||
/** Jump straight to Settings → Appearance (closes the popover first). */
|
||||
onOpenAppearance?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Topbar quick theme switch (between search and notifications). Opens a small
|
||||
* popover to flip the mode and accent; the fine-tune sliders live in
|
||||
* Settings → Appearance. Reads/writes the shared theme store directly. Mirrors
|
||||
* the masthead + bordered-section chrome of the notification and profile panels.
|
||||
* popover to flip the mode, accent, visual style, readability, and text size; the
|
||||
* fine-tune sliders live in Settings → Appearance. Reads/writes the shared theme
|
||||
* store directly. Mirrors the masthead + bordered-section chrome of the
|
||||
* notification and profile panels.
|
||||
*/
|
||||
export function ThemeQuickSwitch() {
|
||||
export function ThemeQuickSwitch({ onOpenAppearance }: ThemeQuickSwitchProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const {
|
||||
theme, accent, uiFont, monoFont, typeScale,
|
||||
setTheme, setAccent, setUiFont, setMonoFont, setTypeScale,
|
||||
theme, accent, typeScale, headingStyle, chartStyle, reducedEffects, readability,
|
||||
setTheme, setAccent, setTypeScale, setVisualStyle, setReadability,
|
||||
} = useTheme();
|
||||
const themeLabel = THEME_MODES.find((m) => m.id === theme)?.label ?? 'Dim';
|
||||
const accentLabel = ACCENTS.find((a) => a.id === accent)?.label ?? 'Cyan';
|
||||
// Highlight the matched preset, or none for a custom combination, so the
|
||||
// quick-switch agrees with the Appearance cards.
|
||||
const activeVisual = activeVisualStyle({ headingStyle, chartStyle, reducedEffects, readability });
|
||||
const onSize = (id: string) => {
|
||||
const match = TYPE_SIZES.find((s) => s.id === id);
|
||||
if (match) setTypeScale(match.scale);
|
||||
};
|
||||
const openAppearance = () => {
|
||||
setOpen(false);
|
||||
onOpenAppearance?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -44,7 +65,7 @@ export function ThemeQuickSwitch() {
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-r from-brand/[0.05] via-transparent to-transparent" />
|
||||
<div className="absolute inset-y-0 left-0 w-[2px] bg-brand/60" />
|
||||
<div className="relative flex items-center justify-between px-[var(--density-row-x)] py-[var(--density-tile-y)]">
|
||||
<span className="font-display text-xl italic leading-none text-stat-value">
|
||||
<span className="font-heading text-xl leading-none text-stat-value">
|
||||
Theme
|
||||
</span>
|
||||
<span className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle">
|
||||
@@ -81,41 +102,52 @@ export function ThemeQuickSwitch() {
|
||||
<AccentPicker value={accent} onChange={setAccent} />
|
||||
</div>
|
||||
|
||||
{/* Type */}
|
||||
<div className="space-y-3 border-t border-card-border/60 px-[var(--density-row-x)] py-[var(--density-row-y)]">
|
||||
{/* Visual style + Readability */}
|
||||
<div className="space-y-2.5 border-t border-card-border/60 px-[var(--density-row-x)] py-[var(--density-row-y)]">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
|
||||
Type
|
||||
Visual style
|
||||
</span>
|
||||
<div className="space-y-1.5">
|
||||
<span className="block font-mono text-[9px] uppercase tracking-[0.16em] text-stat-icon">
|
||||
Interface
|
||||
<SegmentedControl
|
||||
value={activeVisual}
|
||||
options={VISUAL_STYLE_OPTIONS}
|
||||
onChange={setVisualStyle}
|
||||
disabled={readability}
|
||||
ariaLabel="Visual style"
|
||||
fullWidth
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
|
||||
Readability
|
||||
</span>
|
||||
<TypeChips value={uiFont} options={UI_FONT_OPTIONS} onChange={setUiFont} ariaLabel="Interface font" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<span className="block font-mono text-[9px] uppercase tracking-[0.16em] text-stat-icon">
|
||||
Data
|
||||
</span>
|
||||
<TypeChips value={monoFont} options={MONO_FONT_OPTIONS} onChange={setMonoFont} ariaLabel="Data font" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<span className="block font-mono text-[9px] uppercase tracking-[0.16em] text-stat-icon">
|
||||
Size
|
||||
</span>
|
||||
<TypeChips
|
||||
value={sizeIdForScale(typeScale)}
|
||||
options={SIZE_OPTIONS}
|
||||
onChange={onSize}
|
||||
columns={4}
|
||||
ariaLabel="Text size"
|
||||
/>
|
||||
<TogglePill checked={readability} onChange={setReadability} aria-label="Readability mode" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer hint */}
|
||||
{/* Text size */}
|
||||
<div className="space-y-2 border-t border-card-border/60 px-[var(--density-row-x)] py-[var(--density-row-y)]">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
|
||||
Text size
|
||||
</span>
|
||||
<TypeChips
|
||||
value={sizeIdForScale(typeScale)}
|
||||
options={SIZE_OPTIONS}
|
||||
onChange={onSize}
|
||||
columns={4}
|
||||
ariaLabel="Text size"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-card-border/60 px-[var(--density-row-x)] py-[var(--density-row-y)]">
|
||||
<p className="font-mono text-[10px] leading-4 uppercase tracking-[0.14em] text-stat-subtitle/70">
|
||||
Saved to this browser · fine-tune borders & glow in Settings
|
||||
Saved to this browser · fine-tune borders & glow in{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={openAppearance}
|
||||
className="uppercase tracking-[0.14em] text-brand transition-colors hover:text-brand/80 focus-visible:underline focus-visible:outline-none"
|
||||
>
|
||||
Settings
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
|
||||
@@ -100,7 +100,7 @@ export function PageMasthead({
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'font-display italic tracking-[-0.01em]',
|
||||
'font-heading tracking-[-0.01em]',
|
||||
size === 'hero' ? 'text-3xl leading-none' : 'text-[22px] leading-7',
|
||||
config.stateTextClass,
|
||||
)}
|
||||
|
||||
@@ -131,7 +131,7 @@ export function FleetActionCard(props: FleetActionCardProps) {
|
||||
))}
|
||||
<span className="text-stat-title">{lastCrumb}</span>
|
||||
</div>
|
||||
<h3 className="mt-2 font-display italic text-[22px] leading-[28px] text-stat-value">
|
||||
<h3 className="mt-2 font-heading text-[22px] leading-[28px] text-stat-value">
|
||||
{name}
|
||||
</h3>
|
||||
<div className={cn('mt-1.5 font-mono text-[11px] leading-none text-stat-subtitle tracking-[0.04em]')}>
|
||||
|
||||
@@ -93,7 +93,7 @@ function HeaderShell({
|
||||
<div className={cn(KICKER_CLASS, 'whitespace-nowrap', v.kicker)}>
|
||||
{kicker}
|
||||
</div>
|
||||
<TitleComponent className="mt-1 font-display text-[1.75rem] italic leading-tight text-stat-value">
|
||||
<TitleComponent className="mt-1 font-heading text-[1.75rem] leading-tight text-stat-value">
|
||||
{title}
|
||||
</TitleComponent>
|
||||
<DescriptionComponent className="sr-only">
|
||||
|
||||
@@ -194,7 +194,7 @@ function ComfortableBody(props: ComfortableProps) {
|
||||
{crumb.join(' › ')}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<h3 className="font-display italic text-[22px] leading-[28px] text-stat-value">
|
||||
<h3 className="font-heading text-[22px] leading-[28px] text-stat-value">
|
||||
{name}
|
||||
</h3>
|
||||
{isLocal && (
|
||||
@@ -256,7 +256,7 @@ function CompactBody(props: BodyChrome) {
|
||||
return (
|
||||
<>
|
||||
<header className="flex items-center gap-2 px-4 py-2.5 pl-5">
|
||||
<h3 className="font-display italic text-[18px] leading-[22px] text-stat-value mr-1">
|
||||
<h3 className="font-heading text-[18px] leading-[22px] text-stat-value mr-1">
|
||||
{name}
|
||||
</h3>
|
||||
{isLocal && (
|
||||
@@ -517,7 +517,7 @@ function EmptyState({ nodeState, name, offlineReason, onAddStack, onRetry, onTog
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-2 py-3">
|
||||
<div className="font-display italic text-[18px] leading-[24px] text-stat-value">
|
||||
<div className="font-heading text-[18px] leading-[24px] text-stat-value">
|
||||
{headline}
|
||||
</div>
|
||||
<div className="font-mono text-[11px] leading-snug text-stat-subtitle">
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { SegmentedControl } from './segmented-control';
|
||||
|
||||
const OPTS = [
|
||||
{ value: 'a', label: 'A' },
|
||||
{ value: 'b', label: 'B' },
|
||||
];
|
||||
|
||||
describe('SegmentedControl disabled', () => {
|
||||
it('marks the group aria-disabled and ignores clicks when disabled', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<SegmentedControl value="a" options={OPTS} onChange={onChange} disabled ariaLabel="Mode" />);
|
||||
expect(screen.getByRole('radiogroup', { name: 'Mode' }).getAttribute('aria-disabled')).toBe('true');
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'B' }));
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores arrow-key navigation when disabled', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<SegmentedControl value="a" options={OPTS} onChange={onChange} disabled ariaLabel="Mode" />);
|
||||
fireEvent.keyDown(screen.getByRole('radio', { name: 'A' }), { key: 'ArrowRight' });
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still fires onChange when enabled', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<SegmentedControl value="a" options={OPTS} onChange={onChange} ariaLabel="Mode" />);
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'B' }));
|
||||
expect(onChange).toHaveBeenCalledWith('b');
|
||||
});
|
||||
|
||||
it('commits onChange on arrow-key navigation when enabled', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<SegmentedControl value="a" options={OPTS} onChange={onChange} ariaLabel="Mode" />);
|
||||
fireEvent.keyDown(screen.getByRole('radio', { name: 'A' }), { key: 'ArrowRight' });
|
||||
expect(onChange).toHaveBeenCalledWith('b');
|
||||
});
|
||||
|
||||
it('keeps a keyboard entry point when no option is active (value=null)', () => {
|
||||
render(<SegmentedControl value={null} options={OPTS} onChange={() => {}} ariaLabel="Mode" />);
|
||||
const radios = screen.getAllByRole('radio');
|
||||
expect(radios.some((r) => r.getAttribute('aria-checked') === 'true')).toBe(false);
|
||||
// The first option anchors the roving tabindex so Tab still reaches the group.
|
||||
expect(radios[0].getAttribute('tabindex')).toBe('0');
|
||||
expect(radios[1].getAttribute('tabindex')).toBe('-1');
|
||||
});
|
||||
});
|
||||
@@ -11,12 +11,14 @@ export interface SegmentedControlOption<T extends string> {
|
||||
}
|
||||
|
||||
interface SegmentedControlProps<T extends string> {
|
||||
value: T;
|
||||
/** Pass null to show no active segment (e.g. a custom, off-preset combination). */
|
||||
value: T | null;
|
||||
options: SegmentedControlOption<T>[];
|
||||
onChange: (next: T) => void;
|
||||
ariaLabel?: string;
|
||||
iconOnly?: boolean;
|
||||
fullWidth?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -27,6 +29,7 @@ export function SegmentedControl<T extends string>({
|
||||
ariaLabel,
|
||||
iconOnly,
|
||||
fullWidth,
|
||||
disabled,
|
||||
className,
|
||||
}: SegmentedControlProps<T>) {
|
||||
const buttonsRef = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
@@ -42,6 +45,7 @@ export function SegmentedControl<T extends string>({
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>, current: number) => {
|
||||
if (disabled) return;
|
||||
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
focusIndex(current + 1);
|
||||
@@ -57,13 +61,21 @@ export function SegmentedControl<T extends string>({
|
||||
}
|
||||
};
|
||||
|
||||
// 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 (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={ariaLabel}
|
||||
aria-disabled={disabled || undefined}
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-md border border-card-border bg-card p-0.5',
|
||||
fullWidth && 'flex w-full',
|
||||
disabled && 'opacity-50',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -87,8 +99,9 @@ export function SegmentedControl<T extends string>({
|
||||
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',
|
||||
|
||||
@@ -178,7 +178,7 @@ function SheetHeaderBand({ crumb, name, meta, onDismiss }: SheetHeaderBandProps)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<SheetTitle className="mt-2 font-display italic text-[1.5rem] leading-tight text-stat-value text-left">
|
||||
<SheetTitle className="mt-2 font-heading text-[1.5rem] leading-tight text-stat-value text-left">
|
||||
{name}
|
||||
</SheetTitle>
|
||||
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 <html> 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
|
||||
───────────────────────────────────────────────────────────── */
|
||||
|
||||
Reference in New Issue
Block a user