Files
sencho/frontend/src/components/ui/segmented-control.tsx
T
Anso 8d9e6574cc feat(appearance): add Calm/Signature visual style, readability mode, and chart palette (#1407)
* feat(appearance): add Calm/Signature visual style, readability mode, and chart palette

Turn the "too intense / italic headers hurt / the security graph fights my
eyes" feedback into a token-driven Visual style with Calm as the new default
and Signature one click back to the prior look.

- Heading family routes through a `.font-heading` utility driven by
  `--font-heading`/`--heading-style`: operational headings render upright in the
  interface face under Calm and italic Instrument Serif under Signature. Base
  rule sets family + style only, so each call site keeps its own weight/tracking
  and Signature stays a true no-op; the Calm lift is a `[data-headings="clean"]`
  descendant rule. Brand lockup, empty-state heroes, and onboarding stay serif.
- Severity charts resolve through `--sev-*` tokens with Muted, Heat, and
  Signature palettes; FindingsByType routes its series through the severity ramp
  plus a neutral so no brand-cyan sits next to rose. The risk trend flattens its
  gradient under Muted/Heat/reduced and keeps the gradient under Signature.
- Appearance settings gain Visual style cards, a Security visualization palette,
  a Readability master toggle, a Motion & effects group, and a "Reset to default"
  button (restores the Calm axes, disabled while readability is on). Contrast
  moves under Readability and Ambient glow under Motion & effects. A card is
  selected only while the stored sub-axes match its preset, so a custom
  combination de-selects both.
- The topbar Theme quick-switch swaps the interface/data font pickers for a
  Visual style switch and a Readability toggle (text size kept); its footer
  Settings link jumps straight to Appearance.
- Readability is a sticky master that forces the calm resolution and a contrast
  lift at apply time without mutating the stored sub-axes.
- New users default to Calm; any pre-existing persisted appearance state keeps
  the Signature look. The pre-paint script mirrors the store.
- SegmentedControl gains a `disabled` prop and a nullable value (no active
  segment for a custom combination, with a roving-tabindex keyboard anchor).
  Adds unit/component coverage for the store, migration, chart shape logic, the
  disabled control, the reset/de-selection, and the quick-switch.

* fix(appearance): migrate Blueprint serif headings and surface readability locks

- Migrate the two operational Blueprint headings (catalog tile name, drift-policy
  option title) from font-serif italic to the .font-heading utility; the first
  pass only covered font-display, so Calm still left these italic. font-serif and
  font-display both resolve to the same display face, so this is the same fix.
- Lock the Visual style cards under Readability (parity with the topbar switch and
  the on-screen guidance to turn Readability off to choose a style by hand).
- Lock the Border brightness slider under Readability and show its forced +0.03
  readout, since Readability overrides the stored value; dragging it previously
  appeared to do nothing.
- Correct the Appearance docs sentence for the topbar quick switch (it listed
  fonts; the quick switch now carries visual style, readability, and text size).
2026-06-22 00:19:57 -04:00

133 lines
4.2 KiB
TypeScript

import { useRef } from 'react';
import type { KeyboardEvent } from 'react';
import type { LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
export interface SegmentedControlOption<T extends string> {
value: T;
label: string;
icon?: LucideIcon;
badge?: string | number;
}
interface SegmentedControlProps<T extends string> {
/** 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;
}
export function SegmentedControl<T extends string>({
value,
options,
onChange,
ariaLabel,
iconOnly,
fullWidth,
disabled,
className,
}: SegmentedControlProps<T>) {
const buttonsRef = useRef<(HTMLButtonElement | null)[]>([]);
const focusIndex = (index: number) => {
const clamped = (index + options.length) % options.length;
const target = buttonsRef.current[clamped];
const opt = options[clamped];
if (target && opt) {
target.focus();
onChange(opt.value);
}
};
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>, current: number) => {
if (disabled) return;
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
event.preventDefault();
focusIndex(current + 1);
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
event.preventDefault();
focusIndex(current - 1);
} else if (event.key === 'Home') {
event.preventDefault();
focusIndex(0);
} else if (event.key === 'End') {
event.preventDefault();
focusIndex(options.length - 1);
}
};
// 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,
)}
>
{options.map((opt, index) => {
const active = value === opt.value;
const Icon = opt.icon;
const hasBadge = opt.badge !== undefined && opt.badge !== '';
const a11yLabel = iconOnly
? opt.label
: hasBadge
? `${opt.label}, ${opt.badge}`
: undefined;
return (
<button
key={opt.value}
ref={(el) => {
buttonsRef.current[index] = el;
}}
type="button"
role="radio"
aria-checked={active}
aria-label={a11yLabel}
title={iconOnly ? opt.label : undefined}
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',
fullWidth && 'flex-1 justify-center',
active
? 'bg-brand/10 text-brand'
: 'text-stat-subtitle hover:text-stat-value',
)}
>
{Icon ? <Icon className="h-3.5 w-3.5" strokeWidth={1.5} /> : null}
{iconOnly ? null : <span>{opt.label}</span>}
{hasBadge ? (
<span
aria-hidden="true"
className={cn(
'ml-0.5 rounded-sm px-1 text-[9px] tabular-nums',
active ? 'bg-brand/20 text-brand' : 'bg-muted text-stat-subtitle',
)}
>
{opt.badge}
</span>
) : null}
</button>
);
})}
</div>
);
}