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:
Anso
2026-06-22 00:19:57 -04:00
committed by GitHub
parent 31aa1d2d37
commit 8d9e6574cc
39 changed files with 1191 additions and 166 deletions
@@ -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 &amp; glow in Settings
Saved to this browser · fine-tune borders &amp; 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>