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
+1 -1
View File
@@ -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]')}>
+1 -1
View File
@@ -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',
+1 -1
View File
@@ -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>