mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-17 22:17:50 +00:00
8d9e6574cc
* 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).
133 lines
4.2 KiB
TypeScript
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>
|
|
);
|
|
}
|