mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 04:06:59 +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:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user