mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 19:57:37 +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:
@@ -573,7 +573,7 @@ function StreamView({ stats, showStats, loading, groups, now, page, totalPages,
|
||||
{tile.label}
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<span className={`font-display italic text-3xl leading-none tabular-nums ${SEVERITY_TEXT[tile.severity]}`}>
|
||||
<span className={`font-heading text-3xl leading-none tabular-nums ${SEVERITY_TEXT[tile.severity]}`}>
|
||||
{tile.value === null
|
||||
? '·'
|
||||
: idx === 2
|
||||
|
||||
@@ -206,7 +206,7 @@ function StackReadinessCard({
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle/80">
|
||||
Stack
|
||||
</span>
|
||||
<span className="font-display italic text-2xl leading-tight tracking-tight text-stat-value truncate">
|
||||
<span className="font-heading text-2xl leading-tight tracking-tight text-stat-value truncate">
|
||||
{stack}
|
||||
</span>
|
||||
</div>
|
||||
@@ -334,7 +334,7 @@ function ReadinessHero({
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.22em] text-brand">
|
||||
Fleet readiness
|
||||
</span>
|
||||
<span className="font-display italic text-3xl leading-tight tracking-tight text-stat-value">
|
||||
<span className="font-heading text-3xl leading-tight tracking-tight text-stat-value">
|
||||
{headline}
|
||||
</span>
|
||||
{total > 0 && (
|
||||
@@ -389,7 +389,7 @@ function NodeGroupSection({
|
||||
<section className="flex flex-col gap-4">
|
||||
<div className="flex items-baseline gap-3 border-b border-card-border/60 pb-2">
|
||||
<TypeIcon className="h-4 w-4 text-stat-subtitle self-center" strokeWidth={1.5} aria-hidden="true" />
|
||||
<span className="font-display italic text-xl leading-tight tracking-tight text-stat-value truncate">
|
||||
<span className="font-heading text-xl leading-tight tracking-tight text-stat-value truncate">
|
||||
{group.nodeName}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4 shrink-0 self-center">
|
||||
@@ -428,7 +428,7 @@ export function MobileReadinessCard({ card, onApply }: { card: StackCard; onAppl
|
||||
<div className="flex items-start justify-between gap-[10px]">
|
||||
<div className="min-w-0 flex-1">
|
||||
<Kicker>stack</Kicker>
|
||||
<div className="mt-px truncate font-display italic text-[23px] leading-[26px] text-stat-value">{stack}</div>
|
||||
<div className="mt-px truncate font-heading text-[23px] leading-[26px] text-stat-value">{stack}</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{!autoUpdateEnabled && (
|
||||
@@ -483,7 +483,7 @@ function MobileNodeSection({ group, onApply }: { group: NodeGroup; onApply: (sta
|
||||
return (
|
||||
<section>
|
||||
<div className="mb-[13px] flex items-baseline gap-2 border-b border-hairline pb-2">
|
||||
<span className="truncate font-display italic text-[19px] leading-tight text-stat-value">{group.nodeName}</span>
|
||||
<span className="truncate font-heading text-[19px] leading-tight text-stat-value">{group.nodeName}</span>
|
||||
<span className="shrink-0 rounded-[5px] border border-card-border px-1.5 py-px font-mono text-[10px] uppercase tracking-[0.1em] text-stat-subtitle">{group.nodeType}</span>
|
||||
<span className="shrink-0 font-mono text-[11px] text-stat-icon">{group.cards.length} {group.cards.length === 1 ? 'stack' : 'stacks'}</span>
|
||||
</div>
|
||||
|
||||
@@ -669,7 +669,7 @@ export default function EditorLayout() {
|
||||
onNavigate={stackActions.navigateToNotification}
|
||||
/>
|
||||
);
|
||||
const themeSwitchEl = <ThemeQuickSwitch />;
|
||||
const themeSwitchEl = <ThemeQuickSwitch onOpenAppearance={() => openSettings('appearance')} />;
|
||||
const userMenuEl = <UserProfileDropdown onOpenSettings={() => openSettings('account')} />;
|
||||
|
||||
const topBarEl = (
|
||||
@@ -951,7 +951,7 @@ function MobileDetailLoading({ name, onBack, headerActions }: { name: string; on
|
||||
<ChevronLeft className="h-4 w-4" strokeWidth={1.6} />
|
||||
Stacks
|
||||
</button>
|
||||
<span className="min-w-0 flex-1 truncate font-display text-2xl italic text-stat-value">
|
||||
<span className="min-w-0 flex-1 truncate font-heading text-2xl text-stat-value">
|
||||
{name.replace(/\.(ya?ml)$/, '')}
|
||||
</span>
|
||||
{headerActions ? <div className="shrink-0">{headerActions}</div> : null}
|
||||
|
||||
@@ -163,7 +163,7 @@ export function StackIdentityHeader({
|
||||
{(activeNode?.name || 'local')} <span className="text-muted-foreground/60">›</span> stacks <span className="text-muted-foreground/60">›</span> {stackName}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<CardTitle className="font-display italic text-3xl leading-none tracking-tight">{stackName}</CardTitle>
|
||||
<CardTitle className="font-heading text-3xl leading-none tracking-tight">{stackName}</CardTitle>
|
||||
{(() => {
|
||||
const pill = getStackStatePill(safeContainers);
|
||||
if (!pill) return null;
|
||||
|
||||
@@ -114,7 +114,7 @@ export function NodeSwitcher({ onManageNodes, compact = false }: NodeSwitcherPro
|
||||
<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)]">
|
||||
<div className="flex items-baseline gap-2.5">
|
||||
<span className="font-display text-xl italic leading-none text-stat-value">
|
||||
<span className="font-heading text-xl leading-none text-stat-value">
|
||||
Connected
|
||||
</span>
|
||||
<span className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] tabular-nums text-brand">
|
||||
|
||||
@@ -224,7 +224,7 @@ export function NotificationPanel({
|
||||
<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">
|
||||
Notifications
|
||||
</span>
|
||||
{unreadCount > 0 ? (
|
||||
|
||||
@@ -443,7 +443,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
<div className="text-[10px] font-mono uppercase tracking-[0.22em] text-stat-subtitle mb-1">
|
||||
Next 24 hours
|
||||
</div>
|
||||
<div className="font-display italic text-3xl text-foreground leading-tight">
|
||||
<div className="font-heading text-3xl text-foreground leading-tight">
|
||||
Next <em className="not-italic text-brand">24 hours</em>
|
||||
</div>
|
||||
<div className="text-xs font-mono text-stat-subtitle mt-1 tabular-nums">
|
||||
|
||||
@@ -130,7 +130,7 @@ function BlueprintTile({ blueprint, onClick }: { blueprint: BlueprintListItem; o
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className={`inline-block w-2 h-2 rounded-full shrink-0 ${statusDot(dom)}`} aria-hidden />
|
||||
<span className="font-serif italic text-base tracking-[-0.01em] text-stat-value truncate">
|
||||
<span className="font-heading text-base tracking-[-0.01em] text-stat-value truncate">
|
||||
{blueprint.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -311,7 +311,7 @@ export function BlueprintEditor({ initial, distinctLabels, onCancel, onSubmit, s
|
||||
<span className={`inline-block w-2 h-2 rounded-full ${selected ? 'bg-brand' : 'border border-stat-icon'}`} />
|
||||
{opt.kicker}
|
||||
</div>
|
||||
<p className="font-serif italic text-sm mt-1.5 text-stat-value">{opt.title}</p>
|
||||
<p className="font-heading text-sm mt-1.5 text-stat-value">{opt.title}</p>
|
||||
<p className="text-[10px] text-stat-subtitle mt-0.5">{opt.tagline}</p>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -100,7 +100,7 @@ export function HealthStatusBar({
|
||||
className={`h-2.5 w-2.5 rounded-full ${config.dotClass} ${level === 'healthy' ? '' : 'animate-[pulse_2.4s_ease-in-out_infinite]'}`}
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className={`font-display italic text-3xl leading-none tracking-tight ${config.textClass}`}>
|
||||
<span className={`font-heading text-3xl leading-none tracking-tight ${config.textClass}`}>
|
||||
{config.label}
|
||||
</span>
|
||||
<span className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle">
|
||||
|
||||
@@ -134,7 +134,7 @@ export function StackHealthTable({
|
||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel max-md:overflow-x-auto">
|
||||
<div className="flex items-center justify-between gap-4 px-5 py-4">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<h2 className="font-display italic text-xl leading-none tracking-tight text-stat-value">
|
||||
<h2 className="font-heading text-xl leading-none tracking-tight text-stat-value">
|
||||
Stack health
|
||||
</h2>
|
||||
<span className="font-mono text-[11px] uppercase tracking-[0.22em] text-stat-subtitle">
|
||||
|
||||
@@ -110,7 +110,7 @@ export function FleetMasthead({
|
||||
className={`h-2.5 w-2.5 rounded-full ${config.dotClass} ${level === 'healthy' ? '' : 'animate-[pulse_2.4s_ease-in-out_infinite]'}`}
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className={`font-display italic text-3xl leading-none tracking-tight ${config.textClass}`}>
|
||||
<span className={`font-heading text-3xl leading-none tracking-tight ${config.textClass}`}>
|
||||
{config.label}
|
||||
</span>
|
||||
<span className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle">
|
||||
|
||||
@@ -267,7 +267,7 @@ function RoutingMasthead({ meshedNodes, reachableNodes, totalAliases, onShowActi
|
||||
return (
|
||||
<div className="flex items-center justify-between rounded-lg border border-card-border bg-card p-4 shadow-card-bevel">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="font-display italic text-2xl">{stateWord}</div>
|
||||
<div className="font-heading text-2xl">{stateWord}</div>
|
||||
<div className="grid grid-cols-3 gap-4 text-xs">
|
||||
<div>
|
||||
<div className="text-[10px] leading-3 tracking-[0.18em] uppercase text-stat-subtitle font-mono">meshed</div>
|
||||
|
||||
@@ -198,7 +198,7 @@ function NodeDetail({
|
||||
<span aria-hidden className={`absolute left-0 top-1 bottom-[15px] w-[3px] ${tone === 'destructive' ? 'bg-destructive' : tone === 'warning' ? 'bg-warning' : 'bg-brand'}`} />
|
||||
<div className="mb-1"><Kicker>{`fleet › node › ${node.name}`}</Kicker></div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="min-w-0 truncate font-display italic text-[30px] leading-[34px] text-stat-value">{node.name}</span>
|
||||
<span className="min-w-0 truncate font-heading text-[30px] leading-[34px] text-stat-value">{node.name}</span>
|
||||
<StatePill tone={tone} live={!online}>{node.cordoned ? 'cordoned' : online ? (isCritical(node) ? 'degraded' : 'online') : 'offline'}</StatePill>
|
||||
</div>
|
||||
<div className="mt-[7px] font-mono text-[12px] text-stat-subtitle">
|
||||
|
||||
@@ -51,7 +51,7 @@ export function MobileSettings({ headerActions }: MobileSettingsProps) {
|
||||
<div className="relative border-b border-hairline px-4 pb-[15px] pt-1">
|
||||
<span aria-hidden className="absolute left-0 top-1 bottom-[15px] w-[3px] bg-brand" />
|
||||
<div className="mb-1"><Kicker>{`settings · ${group?.label ?? ''} · ${item.label}`}</Kicker></div>
|
||||
<span className="font-display italic text-[30px] leading-[34px] text-stat-value">{item.label}</span>
|
||||
<span className="font-heading text-[30px] leading-[34px] text-stat-value">{item.label}</span>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden px-4 pb-8 pt-4 flex flex-col gap-6">
|
||||
<SettingsSectionContent sectionId={activeSection} onDirtyChange={NOOP} showDescription />
|
||||
|
||||
@@ -194,7 +194,7 @@ export function Masthead({
|
||||
<div className="mb-1">{kickerSlot ?? <Kicker>{kicker}</Kicker>}</div>
|
||||
<div className="flex items-center gap-[11px]">
|
||||
<StateDot tone={stateTone} size={11} pulse={live} />
|
||||
<span className={cn('font-display italic text-[38px] leading-[40px] text-stat-value', stateClassName)}>{state}</span>
|
||||
<span className={cn('font-heading text-[38px] leading-[40px] text-stat-value', stateClassName)}>{state}</span>
|
||||
</div>
|
||||
</div>
|
||||
{right ? <div className="shrink-0 text-right">{right}</div> : null}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, act, renderHook } from '@testing-library/react';
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
|
||||
// recharts paints nothing at 0x0 in jsdom, so stub every export with a prop-
|
||||
// capturing element. The real ChartContainer still runs (it injects the
|
||||
// --color-* vars from SEVERITY_CONFIG), so the token mapping is observable.
|
||||
vi.mock('recharts', async () => {
|
||||
const React = await import('react');
|
||||
const stub = (tag: string) => (props: Record<string, unknown>) =>
|
||||
React.createElement(
|
||||
'div',
|
||||
{
|
||||
'data-rc': tag,
|
||||
'data-fill': props.fill as string | undefined,
|
||||
'data-fillopacity':
|
||||
props.fillOpacity === undefined ? undefined : String(props.fillOpacity),
|
||||
'data-strokewidth':
|
||||
props.strokeWidth === undefined ? undefined : String(props.strokeWidth),
|
||||
'data-chartdata': props.data ? JSON.stringify(props.data) : undefined,
|
||||
},
|
||||
props.children as React.ReactNode,
|
||||
);
|
||||
// Explicit named exports (vitest validates named imports against real keys,
|
||||
// so a Proxy namespace will not do). Covers what SecurityCharts and the shared
|
||||
// ChartContainer (ResponsiveContainer / Tooltip / Legend) reference.
|
||||
return {
|
||||
ResponsiveContainer: stub('ResponsiveContainer'),
|
||||
Tooltip: stub('Tooltip'),
|
||||
Legend: stub('Legend'),
|
||||
PieChart: stub('PieChart'),
|
||||
Pie: stub('Pie'),
|
||||
AreaChart: stub('AreaChart'),
|
||||
Area: stub('Area'),
|
||||
BarChart: stub('BarChart'),
|
||||
Bar: stub('Bar'),
|
||||
XAxis: stub('XAxis'),
|
||||
YAxis: stub('YAxis'),
|
||||
CartesianGrid: stub('CartesianGrid'),
|
||||
LabelList: stub('LabelList'),
|
||||
};
|
||||
});
|
||||
|
||||
import { RiskTrendChart, FindingsByTypeChart, SeverityDonutChart } from './SecurityCharts';
|
||||
import type { ScanSummary, SecurityRiskTrendPoint } from '@/types/security';
|
||||
|
||||
const TREND: SecurityRiskTrendPoint[] = [
|
||||
{ date: '2026-06-01', critical: 2, high: 5 },
|
||||
{ date: '2026-06-02', critical: 1, high: 3 },
|
||||
];
|
||||
|
||||
const SUMMARY: ScanSummary = {
|
||||
image_ref: 'nginx:1.27',
|
||||
highest_severity: 'CRITICAL',
|
||||
scanned_at: 0,
|
||||
scan_id: 1,
|
||||
total: 11,
|
||||
critical: 2, high: 5, medium: 3, low: 1, unknown: 0,
|
||||
fixable: 3,
|
||||
secret_count: 1, misconfig_count: 4,
|
||||
};
|
||||
|
||||
function configureChart(opts: { chartStyle?: 'muted' | 'heat' | 'signature'; reducedEffects?: boolean; readability?: boolean } = {}) {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
act(() => {
|
||||
result.current.setReadability(false);
|
||||
result.current.setVisualStyle('signature');
|
||||
if (opts.chartStyle) result.current.setChartStyle(opts.chartStyle);
|
||||
if (opts.reducedEffects) result.current.setReducedEffects(true);
|
||||
if (opts.readability) result.current.setReadability(true);
|
||||
});
|
||||
}
|
||||
|
||||
describe('SecurityCharts palette routing', () => {
|
||||
beforeEach(() => configureChart());
|
||||
|
||||
it('routes FindingsByType through --sev-* / neutral (no destructive/warning/brand)', () => {
|
||||
const { container } = render(<FindingsByTypeChart summaries={[SUMMARY]} />);
|
||||
const chart = container.querySelector('[data-rc="BarChart"]');
|
||||
const data = JSON.parse(chart!.getAttribute('data-chartdata')!) as { fill: string }[];
|
||||
expect(data.map((d) => d.fill)).toEqual(['var(--sev-vuln)', 'var(--sev-critical)', 'var(--stat-icon)']);
|
||||
for (const d of data) {
|
||||
expect(d.fill).not.toMatch(/--(destructive|warning|brand)\)/);
|
||||
}
|
||||
});
|
||||
|
||||
it('maps the four donut severities to the --sev-* tokens', () => {
|
||||
const { container } = render(<SeverityDonutChart summaries={[SUMMARY]} />);
|
||||
const css = container.querySelector('style')?.textContent ?? '';
|
||||
expect(css).toContain('--color-critical: var(--sev-critical)');
|
||||
expect(css).toContain('--color-high: var(--sev-high)');
|
||||
expect(css).toContain('--color-medium: var(--sev-medium)');
|
||||
expect(css).toContain('--color-low: var(--sev-low)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('RiskTrendChart gradient vs flat', () => {
|
||||
beforeEach(() => configureChart());
|
||||
|
||||
it('keeps the gradient fill and stroke 1.5 in Signature (the no-op baseline)', () => {
|
||||
configureChart({ chartStyle: 'signature' });
|
||||
const { container } = render(<RiskTrendChart trend={TREND} />);
|
||||
const areas = [...container.querySelectorAll('[data-rc="Area"]')];
|
||||
expect(areas).toHaveLength(2);
|
||||
for (const a of areas) {
|
||||
expect(a.getAttribute('data-fill')).toMatch(/^url\(#risk/);
|
||||
expect(a.getAttribute('data-strokewidth')).toBe('1.5');
|
||||
expect(a.getAttribute('data-fillopacity')).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('drops the gradient for a solid low-opacity fill under Muted', () => {
|
||||
configureChart({ chartStyle: 'muted' });
|
||||
const { container } = render(<RiskTrendChart trend={TREND} />);
|
||||
const areas = [...container.querySelectorAll('[data-rc="Area"]')];
|
||||
for (const a of areas) {
|
||||
expect(a.getAttribute('data-fill')).toMatch(/^var\(--color-/);
|
||||
expect(a.getAttribute('data-strokewidth')).toBe('1.9');
|
||||
expect(a.getAttribute('data-fillopacity')).toBe('0.16');
|
||||
}
|
||||
});
|
||||
|
||||
it('uses the heat fill (0.15) with no gradient under Heat', () => {
|
||||
configureChart({ chartStyle: 'heat' });
|
||||
const { container } = render(<RiskTrendChart trend={TREND} />);
|
||||
const areas = [...container.querySelectorAll('[data-rc="Area"]')];
|
||||
expect(areas).toHaveLength(2);
|
||||
for (const a of areas) {
|
||||
expect(a.getAttribute('data-fill')).toMatch(/^var\(--color-/);
|
||||
expect(a.getAttribute('data-fillopacity')).toBe('0.15');
|
||||
expect(a.getAttribute('data-strokewidth')).toBe('1.9');
|
||||
}
|
||||
});
|
||||
|
||||
it('dims the fill further and flattens under reduced effects, even in Signature', () => {
|
||||
configureChart({ chartStyle: 'signature', reducedEffects: true });
|
||||
const { container } = render(<RiskTrendChart trend={TREND} />);
|
||||
const areas = [...container.querySelectorAll('[data-rc="Area"]')];
|
||||
for (const a of areas) {
|
||||
expect(a.getAttribute('data-fill')).toMatch(/^var\(--color-/);
|
||||
expect(a.getAttribute('data-strokewidth')).toBe('1.9');
|
||||
// 0.30 * 0.62
|
||||
expect(Number(a.getAttribute('data-fillopacity'))).toBeCloseTo(0.186, 5);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,28 @@
|
||||
import { useMemo } from 'react';
|
||||
import { PieChart, Pie, AreaChart, Area, BarChart, Bar, XAxis, YAxis, CartesianGrid, LabelList } from 'recharts';
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart';
|
||||
import { useChartStyle, type ChartStyle } from '@/hooks/use-theme';
|
||||
import type { ScanSummary, SecurityRiskTrendPoint } from '@/types/security';
|
||||
|
||||
// Severity palette stays within the design's semantic tokens: --destructive
|
||||
// (critical), --warning (high), a muted --warning (medium), --muted-foreground
|
||||
// (low). No new chart hue.
|
||||
// Severity colours resolve through the --sev-* tokens, which the appearance
|
||||
// chart-style switches (Signature keeps today's saturated semantics; Muted and
|
||||
// Heat are calmer ramps). ChartContainer injects them as --color-* for recharts.
|
||||
const SEVERITY_CONFIG = {
|
||||
critical: { label: 'Critical', color: 'var(--destructive)' },
|
||||
high: { label: 'High', color: 'var(--warning)' },
|
||||
medium: { label: 'Medium', color: 'color-mix(in oklch, var(--warning) 55%, var(--muted))' },
|
||||
low: { label: 'Low', color: 'var(--muted-foreground)' },
|
||||
critical: { label: 'Critical', color: 'var(--sev-critical)' },
|
||||
high: { label: 'High', color: 'var(--sev-high)' },
|
||||
medium: { label: 'Medium', color: 'var(--sev-medium)' },
|
||||
low: { label: 'Low', color: 'var(--sev-low)' },
|
||||
} satisfies ChartConfig;
|
||||
|
||||
// Area fill opacity, gradient on/off, and stroke per chart-style. Colours stay in
|
||||
// the --sev-* tokens; only these shape values vary. Reduced effects flattens
|
||||
// (no gradient) and dims the fill, matching the calm material direction.
|
||||
const TREND_SHAPE: Record<ChartStyle, { fill: number; gradient: boolean; stroke: number }> = {
|
||||
signature: { fill: 0.30, gradient: true, stroke: 1.5 },
|
||||
muted: { fill: 0.16, gradient: false, stroke: 1.9 },
|
||||
heat: { fill: 0.15, gradient: false, stroke: 1.9 },
|
||||
};
|
||||
|
||||
// The trend and top-exposed charts both plot only the Critical + High slots.
|
||||
const CRITICAL_HIGH_CONFIG = {
|
||||
critical: SEVERITY_CONFIG.critical,
|
||||
@@ -57,29 +67,53 @@ export function SeverityDonutChart({ summaries }: { summaries: ScanSummary[] })
|
||||
|
||||
/** Stacked area of Critical + High findings by scan-day (days with no scans are omitted). */
|
||||
export function RiskTrendChart({ trend }: { trend: SecurityRiskTrendPoint[] }) {
|
||||
const { chartStyle, reduced } = useChartStyle();
|
||||
if (trend.length === 0) return <EmptyChart label="No scan history yet" height={220} />;
|
||||
|
||||
const fmtDate = (d: string) => d.slice(5); // MM-DD
|
||||
|
||||
const shape = TREND_SHAPE[chartStyle];
|
||||
// Signature (gradient, stroke 1.5) is the no-op baseline. Flat styles and
|
||||
// reduced effects drop the gradient for a solid low-opacity fill + thicker line.
|
||||
const gradient = shape.gradient && !reduced;
|
||||
const fillOpacity = reduced ? shape.fill * 0.62 : shape.fill;
|
||||
const stroke = reduced ? 1.9 : shape.stroke;
|
||||
|
||||
return (
|
||||
<ChartContainer config={CRITICAL_HIGH_CONFIG} className="h-[220px] w-full">
|
||||
<AreaChart data={trend} margin={{ left: 4, right: 8, top: 8 }}>
|
||||
<defs>
|
||||
<linearGradient id="riskHigh" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="var(--color-high)" stopOpacity={0.35} />
|
||||
<stop offset="95%" stopColor="var(--color-high)" stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
<linearGradient id="riskCritical" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="var(--color-critical)" stopOpacity={0.4} />
|
||||
<stop offset="95%" stopColor="var(--color-critical)" stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{gradient && (
|
||||
<defs>
|
||||
<linearGradient id="riskHigh" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="var(--color-high)" stopOpacity={0.35} />
|
||||
<stop offset="95%" stopColor="var(--color-high)" stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
<linearGradient id="riskCritical" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="var(--color-critical)" stopOpacity={0.4} />
|
||||
<stop offset="95%" stopColor="var(--color-critical)" stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
)}
|
||||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" tickFormatter={fmtDate} tickLine={false} axisLine={false} fontSize={10} minTickGap={24} />
|
||||
<YAxis tickLine={false} axisLine={false} fontSize={10} width={28} allowDecimals={false} />
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Area dataKey="high" stackId="risk" stroke="var(--color-high)" fill="url(#riskHigh)" strokeWidth={1.5} />
|
||||
<Area dataKey="critical" stackId="risk" stroke="var(--color-critical)" fill="url(#riskCritical)" strokeWidth={1.5} />
|
||||
<Area
|
||||
dataKey="high"
|
||||
stackId="risk"
|
||||
stroke="var(--color-high)"
|
||||
fill={gradient ? 'url(#riskHigh)' : 'var(--color-high)'}
|
||||
fillOpacity={gradient ? undefined : fillOpacity}
|
||||
strokeWidth={stroke}
|
||||
/>
|
||||
<Area
|
||||
dataKey="critical"
|
||||
stackId="risk"
|
||||
stroke="var(--color-critical)"
|
||||
fill={gradient ? 'url(#riskCritical)' : 'var(--color-critical)'}
|
||||
fillOpacity={gradient ? undefined : fillOpacity}
|
||||
strokeWidth={stroke}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
);
|
||||
@@ -141,10 +175,14 @@ export function FindingsByTypeChart({ summaries }: { summaries: ScanSummary[] })
|
||||
secrets += s.secret_count;
|
||||
misconfigs += s.misconfig_count;
|
||||
}
|
||||
// Route every series through the severity ramp (or a neutral for misconfigs)
|
||||
// so no two complementary hues sit adjacent (the old cyan-next-to-rose clash).
|
||||
// --stat-icon is palette-invariant by design: misconfigs stay neutral across
|
||||
// Muted/Heat rather than picking up a severity hue.
|
||||
return [
|
||||
{ type: 'Vulnerabilities', value: vulnerabilities, fill: 'var(--brand)' },
|
||||
{ type: 'Secrets', value: secrets, fill: 'var(--destructive)' },
|
||||
{ type: 'Misconfigs', value: misconfigs, fill: 'var(--warning)' },
|
||||
{ type: 'Vulnerabilities', value: vulnerabilities, fill: 'var(--sev-vuln)' },
|
||||
{ type: 'Secrets', value: secrets, fill: 'var(--sev-critical)' },
|
||||
{ type: 'Misconfigs', value: misconfigs, fill: 'var(--stat-icon)' },
|
||||
];
|
||||
}, [summaries]);
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Check, Info } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { SegmentedControl } from '@/components/ui/segmented-control';
|
||||
@@ -6,7 +8,10 @@ import { useDensity } from '@/hooks/use-density';
|
||||
import type { Density } from '@/hooks/use-density';
|
||||
import { useTopNavLabels } from '@/hooks/use-top-nav-labels';
|
||||
import { useTopNavAlign, type TopNavAlign } from '@/hooks/use-top-nav-align';
|
||||
import { useTheme, THEME_MODE_OPTIONS, ACCENTS, CONTRAST, BORDER_BOOST, GLOW, TYPE_SCALE } from '@/hooks/use-theme';
|
||||
import {
|
||||
useTheme, activeVisualStyle, THEME_MODE_OPTIONS, ACCENTS, CONTRAST, BORDER_BOOST, GLOW, TYPE_SCALE,
|
||||
type VisualStyle, type HeadingStyle, type ChartStyle,
|
||||
} from '@/hooks/use-theme';
|
||||
import { AccentPicker } from '@/components/theme/AccentPicker';
|
||||
import { ThemePreview } from '@/components/theme/ThemePreview';
|
||||
import { TypeChips } from '@/components/theme/TypeChips';
|
||||
@@ -30,20 +35,246 @@ const TOP_NAV_ALIGN_OPTIONS: { value: TopNavAlign; label: string }[] = [
|
||||
{ value: 'center', label: 'Center' },
|
||||
];
|
||||
|
||||
const CHART_STYLE_OPTIONS: { value: ChartStyle; label: string }[] = [
|
||||
{ value: 'muted', label: 'Muted' },
|
||||
{ value: 'heat', label: 'Heat' },
|
||||
{ value: 'signature', label: 'Signature' },
|
||||
];
|
||||
|
||||
const HEADING_STYLE_OPTIONS: { value: HeadingStyle; label: string }[] = [
|
||||
{ value: 'clean', label: 'Clean' },
|
||||
{ value: 'signature', label: 'Signature' },
|
||||
];
|
||||
|
||||
const fmtSigned = (v: number) => `${v > 0 ? '+' : ''}${v.toFixed(2)}`;
|
||||
|
||||
// Preview swatches for the Visual style cards. Calm uses the muted ramp; Signature
|
||||
// deliberately puts the brand bar next to destructive to show the clash it fixes.
|
||||
interface VisualCardData {
|
||||
kind: VisualStyle;
|
||||
name: string;
|
||||
blurb: string;
|
||||
bars: string[];
|
||||
}
|
||||
|
||||
const VISUAL_CARDS: VisualCardData[] = [
|
||||
{
|
||||
kind: 'calm',
|
||||
name: 'Calm',
|
||||
blurb: 'Upright headings, muted flat charts. The readable default.',
|
||||
bars: ['oklch(0.605 0.105 28)', 'oklch(0.715 0.085 70)', 'oklch(0.675 0.045 88)', 'oklch(0.565 0.018 250)'],
|
||||
},
|
||||
{
|
||||
kind: 'signature',
|
||||
name: 'Signature',
|
||||
blurb: "Italic serif headings, saturated charts. Today's look.",
|
||||
bars: ['var(--destructive)', 'var(--warning)', 'var(--brand)', 'color-mix(in oklch, var(--warning) 50%, var(--stat-icon))'],
|
||||
},
|
||||
];
|
||||
const VISUAL_BAR_HEIGHTS = [16, 11, 13, 8];
|
||||
|
||||
function VisualCard({
|
||||
card, selected, disabled, onSelect,
|
||||
}: {
|
||||
card: VisualCardData;
|
||||
selected: boolean;
|
||||
disabled?: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const { kind, name, blurb, bars } = card;
|
||||
const calm = kind === 'calm';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
disabled={disabled}
|
||||
aria-pressed={selected}
|
||||
className={cn(
|
||||
'flex flex-col overflow-hidden rounded-lg border bg-well text-left transition-colors',
|
||||
selected
|
||||
? 'border-brand/55 ring-1 ring-brand/40'
|
||||
: 'border-card-border border-t-card-border-top hover:border-t-card-border-hover',
|
||||
disabled && 'opacity-50',
|
||||
)}
|
||||
>
|
||||
<div className="flex h-[74px] flex-col justify-center gap-1.5 border-b border-hairline px-3.5">
|
||||
<span
|
||||
className={cn(
|
||||
'text-[22px] leading-none text-stat-value',
|
||||
calm ? 'font-sans font-semibold not-italic' : 'font-display font-normal italic',
|
||||
)}
|
||||
>
|
||||
Critical
|
||||
</span>
|
||||
<span className="flex items-end gap-1" style={{ height: 16 }}>
|
||||
{bars.map((b, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="block w-[9px] rounded-sm"
|
||||
style={{ background: b, height: VISUAL_BAR_HEIGHTS[i] }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-3.5 pb-3 pt-2.5">
|
||||
<div className="flex items-center gap-2 text-[13px] font-semibold text-stat-value">
|
||||
{name}
|
||||
{selected ? <Check className="h-3.5 w-3.5 text-brand" strokeWidth={2.5} /> : null}
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] leading-snug text-stat-subtitle">{blurb}</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppearanceSection() {
|
||||
const [density, setDensity] = useDensity();
|
||||
const [topNavLabels, setTopNavLabels] = useTopNavLabels();
|
||||
const [topNavAlign, setTopNavAlign] = useTopNavAlign();
|
||||
const {
|
||||
theme, accent, borderBoost, glow, contrast, uiFont, monoFont, typeScale,
|
||||
headingStyle, chartStyle, reducedEffects, readability,
|
||||
setTheme, setAccent, setBorderBoost, setGlow, setContrast, setUiFont, setMonoFont, setTypeScale,
|
||||
setVisualStyle, setHeadingStyle, setChartStyle, setReducedEffects, setReadability,
|
||||
} = useTheme();
|
||||
const accentLabel = ACCENTS.find((a) => a.id === accent)?.label ?? 'Cyan';
|
||||
// Readability is a sticky master: it forces the calm resolution at apply time
|
||||
// without mutating the stored sub-axes, so the controls reflect the forced
|
||||
// value and lock while it is on. Effects are reduced under readability too.
|
||||
const effectiveReduced = readability || reducedEffects;
|
||||
const effectiveContrast = contrast + (readability ? 0.18 : 0);
|
||||
// A card is selected only while the stored sub-axes still match its preset, so
|
||||
// a custom combination de-selects both (and readability resolves to null).
|
||||
// Shared with the topbar quick-switch via activeVisualStyle so both surfaces
|
||||
// agree on which style is active.
|
||||
const activeVisual = activeVisualStyle({ headingStyle, chartStyle, reducedEffects, readability });
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
<SettingsSection title="Visual style" kicker="the master switch">
|
||||
<div className="grid grid-cols-2 gap-2.5 pt-3">
|
||||
{VISUAL_CARDS.map((c) => (
|
||||
<VisualCard
|
||||
key={c.kind}
|
||||
card={c}
|
||||
selected={activeVisual === c.kind}
|
||||
disabled={readability}
|
||||
onSelect={() => setVisualStyle(c.kind)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{readability ? (
|
||||
<div className="mt-3 flex items-start gap-2.5 rounded-md border border-brand/30 bg-brand/10 px-3 py-2.5">
|
||||
<Info className="mt-0.5 h-4 w-4 shrink-0 text-brand" strokeWidth={1.5} />
|
||||
<p className="text-sm leading-relaxed text-stat-title">
|
||||
<span className="font-medium text-brand">Readability mode is on.</span> It forces the calmest
|
||||
settings and a small contrast lift. Turn it off below to choose a style by hand.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Security visualization" kicker="the chart fix">
|
||||
<SettingsField
|
||||
label="Chart palette"
|
||||
helper="Muted desaturates the severity colors; Heat is one warm ramp with no red/blue clash; Signature is the saturated set."
|
||||
align="start"
|
||||
>
|
||||
<SegmentedControl
|
||||
value={readability ? 'muted' : chartStyle}
|
||||
options={CHART_STYLE_OPTIONS}
|
||||
onChange={setChartStyle}
|
||||
disabled={readability}
|
||||
ariaLabel="Chart palette"
|
||||
/>
|
||||
</SettingsField>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Readability" kicker="this browser">
|
||||
<SettingsField
|
||||
label="Readability mode"
|
||||
helper="One switch: upright headings, muted flat charts, reduced effects, and a contrast lift."
|
||||
>
|
||||
<TogglePill checked={readability} onChange={setReadability} aria-label="Readability mode" />
|
||||
</SettingsField>
|
||||
|
||||
<SettingsField
|
||||
label="Header style"
|
||||
helper="Clean uses the upright interface face; Signature keeps the italic display serif."
|
||||
align="start"
|
||||
>
|
||||
<SegmentedControl
|
||||
value={readability ? 'clean' : headingStyle}
|
||||
options={HEADING_STYLE_OPTIONS}
|
||||
onChange={setHeadingStyle}
|
||||
disabled={readability}
|
||||
ariaLabel="Header style"
|
||||
/>
|
||||
</SettingsField>
|
||||
|
||||
<SettingsField
|
||||
label="Contrast"
|
||||
helper="Master contrast: spreads the page, ink, and borders together. Pair high contrast with OLED for the crispest panel."
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Slider
|
||||
value={[contrast]}
|
||||
min={CONTRAST.min}
|
||||
max={CONTRAST.max}
|
||||
step={CONTRAST.step}
|
||||
onValueChange={([v]) => setContrast(v)}
|
||||
aria-label="Contrast"
|
||||
/>
|
||||
<span className="w-12 shrink-0 text-right font-mono text-xs tabular-nums text-stat-subtitle">
|
||||
{fmtSigned(effectiveContrast)}
|
||||
</span>
|
||||
</div>
|
||||
</SettingsField>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Motion & effects" kicker="this browser">
|
||||
<SettingsField
|
||||
label="Reduced effects"
|
||||
helper="Flattens card bevels, the accent glow, and chart gradients for a calmer surface."
|
||||
>
|
||||
<TogglePill
|
||||
checked={effectiveReduced}
|
||||
onChange={setReducedEffects}
|
||||
disabled={readability}
|
||||
aria-label="Reduced effects"
|
||||
/>
|
||||
</SettingsField>
|
||||
|
||||
<SettingsField
|
||||
label="Ambient glow"
|
||||
helper="Intensity of the accent-tinted glow behind the page."
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Slider
|
||||
value={[glow]}
|
||||
min={GLOW.min}
|
||||
max={GLOW.max}
|
||||
step={GLOW.step}
|
||||
onValueChange={([v]) => setGlow(v)}
|
||||
disabled={effectiveReduced}
|
||||
aria-label="Ambient glow"
|
||||
/>
|
||||
<span className="w-12 shrink-0 text-right font-mono text-xs tabular-nums text-stat-subtitle">
|
||||
{glow.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</SettingsField>
|
||||
|
||||
<SettingsActions>
|
||||
<SettingsSecondaryButton
|
||||
onClick={() => setVisualStyle('calm')}
|
||||
disabled={readability}
|
||||
>
|
||||
Reset to default
|
||||
</SettingsSecondaryButton>
|
||||
</SettingsActions>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Theme" kicker="this browser">
|
||||
<div className="pt-3">
|
||||
<ThemePreview />
|
||||
@@ -69,25 +300,6 @@ export function AppearanceSection() {
|
||||
<AccentPicker value={accent} onChange={setAccent} />
|
||||
</SettingsField>
|
||||
|
||||
<SettingsField
|
||||
label="Contrast"
|
||||
helper="Master contrast: spreads the page, ink, and borders together. Pair high contrast with OLED for the crispest panel."
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Slider
|
||||
value={[contrast]}
|
||||
min={CONTRAST.min}
|
||||
max={CONTRAST.max}
|
||||
step={CONTRAST.step}
|
||||
onValueChange={([v]) => setContrast(v)}
|
||||
aria-label="Contrast"
|
||||
/>
|
||||
<span className="w-12 shrink-0 text-right font-mono text-xs tabular-nums text-stat-subtitle">
|
||||
{fmtSigned(contrast)}
|
||||
</span>
|
||||
</div>
|
||||
</SettingsField>
|
||||
|
||||
<SettingsField
|
||||
label="Border brightness"
|
||||
helper="Lift or soften every hairline so separation reads exactly how you like it."
|
||||
@@ -99,29 +311,11 @@ export function AppearanceSection() {
|
||||
max={BORDER_BOOST.max}
|
||||
step={BORDER_BOOST.step}
|
||||
onValueChange={([v]) => setBorderBoost(v)}
|
||||
disabled={readability}
|
||||
aria-label="Border brightness"
|
||||
/>
|
||||
<span className="w-12 shrink-0 text-right font-mono text-xs tabular-nums text-stat-subtitle">
|
||||
{fmtSigned(borderBoost)}
|
||||
</span>
|
||||
</div>
|
||||
</SettingsField>
|
||||
|
||||
<SettingsField
|
||||
label="Ambient glow"
|
||||
helper="Intensity of the accent-tinted glow behind the page."
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Slider
|
||||
value={[glow]}
|
||||
min={GLOW.min}
|
||||
max={GLOW.max}
|
||||
step={GLOW.step}
|
||||
onValueChange={([v]) => setGlow(v)}
|
||||
aria-label="Ambient glow"
|
||||
/>
|
||||
<span className="w-12 shrink-0 text-right font-mono text-xs tabular-nums text-stat-subtitle">
|
||||
{glow.toFixed(2)}
|
||||
{fmtSigned(readability ? 0.03 : borderBoost)}
|
||||
</span>
|
||||
</div>
|
||||
</SettingsField>
|
||||
@@ -142,7 +336,7 @@ export function AppearanceSection() {
|
||||
<SettingsSection title="Typography" kicker="this browser">
|
||||
<SettingsField
|
||||
label="Interface font"
|
||||
helper="The sans face for body, labels, nav, and buttons. Display headings stay Instrument Serif."
|
||||
helper="The sans face for body, labels, navigation, and buttons. Heading style follows your Visual style choice."
|
||||
align="start"
|
||||
>
|
||||
<TypeChips value={uiFont} options={UI_FONT_OPTIONS} onChange={setUiFont} ariaLabel="Interface font" />
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, act, renderHook } from '@testing-library/react';
|
||||
import { AppearanceSection } from '../AppearanceSection';
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
|
||||
// AppearanceSection drives the shared theme store. Reset it to a known Signature
|
||||
// baseline (readability off, effects full) before each test so the disabled-state
|
||||
// assertions start from a clean, undimmed state.
|
||||
function resetTheme() {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
act(() => {
|
||||
result.current.setReadability(false);
|
||||
result.current.setVisualStyle('signature');
|
||||
result.current.setContrast(0);
|
||||
result.current.setGlow(0.16);
|
||||
});
|
||||
}
|
||||
|
||||
describe('AppearanceSection', () => {
|
||||
beforeEach(() => resetTheme());
|
||||
|
||||
it('renders the four refresh sections above Theme', () => {
|
||||
render(<AppearanceSection />);
|
||||
expect(screen.getByText('Visual style')).toBeTruthy();
|
||||
expect(screen.getByText('Security visualization')).toBeTruthy();
|
||||
expect(screen.getByText('Readability')).toBeTruthy();
|
||||
expect(screen.getByText('Motion & effects')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('selecting the Calm card applies the calm resolution to <html>', () => {
|
||||
render(<AppearanceSection />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /Calm/i }));
|
||||
expect(document.documentElement.dataset.headings).toBe('clean');
|
||||
expect(document.documentElement.dataset.chartStyle).toBe('muted');
|
||||
});
|
||||
|
||||
it('readability locks the header + chart controls and disables the glow slider', () => {
|
||||
const { container } = render(<AppearanceSection />);
|
||||
// Baseline: nothing reduced, so no slider is disabled.
|
||||
expect(container.querySelectorAll('[data-disabled]').length).toBe(0);
|
||||
expect(screen.getByRole('radiogroup', { name: 'Header style' }).getAttribute('aria-disabled')).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Readability mode' }));
|
||||
|
||||
expect(screen.getByRole('radiogroup', { name: 'Header style' }).getAttribute('aria-disabled')).toBe('true');
|
||||
expect(screen.getByRole('radiogroup', { name: 'Chart palette' }).getAttribute('aria-disabled')).toBe('true');
|
||||
expect((screen.getByRole('switch', { name: 'Reduced effects' }) as HTMLButtonElement).disabled).toBe(true);
|
||||
// Effective reduced (readability || reducedEffects) disables the glow slider
|
||||
// even though reducedEffects itself is still off.
|
||||
expect(container.querySelectorAll('[data-disabled]').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('readability also locks the Visual style cards and the Border brightness slider', () => {
|
||||
const { container } = render(<AppearanceSection />);
|
||||
const calmCard = () => screen.getByRole('button', { name: /readable default/i }) as HTMLButtonElement;
|
||||
const sigCard = () => screen.getByRole('button', { name: /Today's look/i }) as HTMLButtonElement;
|
||||
const borderLocked = () => !!container.querySelector('[aria-label="Border brightness"][data-disabled]');
|
||||
expect(calmCard().disabled).toBe(false);
|
||||
expect(borderLocked()).toBe(false);
|
||||
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Readability mode' }));
|
||||
|
||||
// Both cards lock (the topbar disables the same control), matching the
|
||||
// "turn readability off to choose a style" guidance.
|
||||
expect(calmCard().disabled).toBe(true);
|
||||
expect(sigCard().disabled).toBe(true);
|
||||
// Border brightness is forced to +0.03 under readability, so its slider locks.
|
||||
expect(borderLocked()).toBe(true);
|
||||
});
|
||||
|
||||
it('de-selects both visual-style cards when a custom sub-axis is chosen', () => {
|
||||
render(<AppearanceSection />);
|
||||
// Baseline is Signature, so the Signature card reads selected.
|
||||
expect(screen.getByRole('button', { name: /Today's look/i }).getAttribute('aria-pressed')).toBe('true');
|
||||
// A custom chart palette (Heat) makes the trio match no preset.
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Heat' }));
|
||||
expect(screen.getByRole('button', { name: /Today's look/i }).getAttribute('aria-pressed')).toBe('false');
|
||||
expect(screen.getByRole('button', { name: /readable default/i }).getAttribute('aria-pressed')).toBe('false');
|
||||
});
|
||||
|
||||
it('de-selects when only the header style diverges (not just the chart palette)', () => {
|
||||
render(<AppearanceSection />);
|
||||
// Baseline Signature; flipping only Header style to Clean breaks the match.
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Clean' }));
|
||||
expect(screen.getByRole('button', { name: /Today's look/i }).getAttribute('aria-pressed')).toBe('false');
|
||||
expect(screen.getByRole('button', { name: /readable default/i }).getAttribute('aria-pressed')).toBe('false');
|
||||
});
|
||||
|
||||
it('reset to default restores Calm and locks while readability is on', () => {
|
||||
render(<AppearanceSection />);
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Heat' }));
|
||||
expect(document.documentElement.dataset.chartStyle).toBe('heat');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reset to default' }));
|
||||
expect(document.documentElement.dataset.headings).toBe('clean');
|
||||
expect(document.documentElement.dataset.chartStyle).toBe('muted');
|
||||
expect(screen.getByRole('button', { name: /readable default/i }).getAttribute('aria-pressed')).toBe('true');
|
||||
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Readability mode' }));
|
||||
expect((screen.getByRole('button', { name: 'Reset to default' }) as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -99,6 +99,13 @@ describe('settings registry', () => {
|
||||
expect(SETTINGS_ITEMS.find(i => i.id === 'appearance')?.scope).toBe('browser');
|
||||
expect(SETTINGS_ITEMS.find(i => i.id === 'stacks')?.scope).toBe('browser');
|
||||
});
|
||||
|
||||
it('exposes the Calm/Signature appearance keywords for search', () => {
|
||||
const appearance = SETTINGS_ITEMS.find(i => i.id === 'appearance');
|
||||
for (const term of ['calm', 'signature', 'readability', 'heading', 'chart', 'motion', 'effects']) {
|
||||
expect(appearance?.keywords, `keyword ${term}`).toContain(term);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('scopeLabel', () => {
|
||||
|
||||
@@ -61,8 +61,8 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
|
||||
id: 'appearance',
|
||||
group: 'personal',
|
||||
label: 'Appearance',
|
||||
description: 'Theme, accent, density, and display preferences saved to this browser.',
|
||||
keywords: ['theme', 'dim', 'oled', 'light', 'dark', 'accent', 'color', 'glow', 'border', 'contrast', 'density', 'comfortable', 'compact', 'spacing', 'display'],
|
||||
description: 'Visual style, readability, theme, accent, charts, and display preferences saved to this browser.',
|
||||
keywords: ['theme', 'dim', 'oled', 'light', 'dark', 'accent', 'color', 'glow', 'border', 'contrast', 'density', 'comfortable', 'compact', 'spacing', 'display', 'calm', 'signature', 'readability', 'heading', 'chart', 'motion', 'effects'],
|
||||
tier: null,
|
||||
scope: 'browser',
|
||||
},
|
||||
|
||||
@@ -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 & glow in Settings
|
||||
Saved to this browser · fine-tune borders & 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>
|
||||
|
||||
@@ -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