feat(appearance): add Calm/Signature visual style, readability mode, and chart palette (#1407)

* feat(appearance): add Calm/Signature visual style, readability mode, and chart palette

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

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

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

- Migrate the two operational Blueprint headings (catalog tile name, drift-policy
  option title) from font-serif italic to the .font-heading utility; the first
  pass only covered font-display, so Calm still left these italic. font-serif and
  font-display both resolve to the same display face, so this is the same fix.
- Lock the Visual style cards under Readability (parity with the topbar switch and
  the on-screen guidance to turn Readability off to choose a style by hand).
- Lock the Border brightness slider under Readability and show its forced +0.03
  readout, since Readability overrides the stored value; dragging it previously
  appeared to do nothing.
- Correct the Appearance docs sentence for the topbar quick switch (it listed
  fonts; the quick switch now carries visual style, readability, and text size).
This commit is contained in:
Anso
2026-06-22 00:19:57 -04:00
committed by GitHub
parent 31aa1d2d37
commit 8d9e6574cc
39 changed files with 1191 additions and 166 deletions
+1 -1
View File
@@ -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>
+2 -2
View File
@@ -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;
+1 -1
View File
@@ -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">
+1 -1
View File
@@ -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 />
+1 -1
View File
@@ -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', () => {
+2 -2
View File
@@ -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 &amp; glow in Settings
Saved to this browser · fine-tune borders &amp; 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>
+1 -1
View File
@@ -100,7 +100,7 @@ export function PageMasthead({
</span>
<span
className={cn(
'font-display italic tracking-[-0.01em]',
'font-heading tracking-[-0.01em]',
size === 'hero' ? 'text-3xl leading-none' : 'text-[22px] leading-7',
config.stateTextClass,
)}
@@ -131,7 +131,7 @@ export function FleetActionCard(props: FleetActionCardProps) {
))}
<span className="text-stat-title">{lastCrumb}</span>
</div>
<h3 className="mt-2 font-display italic text-[22px] leading-[28px] text-stat-value">
<h3 className="mt-2 font-heading text-[22px] leading-[28px] text-stat-value">
{name}
</h3>
<div className={cn('mt-1.5 font-mono text-[11px] leading-none text-stat-subtitle tracking-[0.04em]')}>
+1 -1
View File
@@ -93,7 +93,7 @@ function HeaderShell({
<div className={cn(KICKER_CLASS, 'whitespace-nowrap', v.kicker)}>
{kicker}
</div>
<TitleComponent className="mt-1 font-display text-[1.75rem] italic leading-tight text-stat-value">
<TitleComponent className="mt-1 font-heading text-[1.75rem] leading-tight text-stat-value">
{title}
</TitleComponent>
<DescriptionComponent className="sr-only">
@@ -194,7 +194,7 @@ function ComfortableBody(props: ComfortableProps) {
{crumb.join(' ')}
</div>
<div className="mt-2 flex items-center gap-2">
<h3 className="font-display italic text-[22px] leading-[28px] text-stat-value">
<h3 className="font-heading text-[22px] leading-[28px] text-stat-value">
{name}
</h3>
{isLocal && (
@@ -256,7 +256,7 @@ function CompactBody(props: BodyChrome) {
return (
<>
<header className="flex items-center gap-2 px-4 py-2.5 pl-5">
<h3 className="font-display italic text-[18px] leading-[22px] text-stat-value mr-1">
<h3 className="font-heading text-[18px] leading-[22px] text-stat-value mr-1">
{name}
</h3>
{isLocal && (
@@ -517,7 +517,7 @@ function EmptyState({ nodeState, name, offlineReason, onAddStack, onRetry, onTog
return (
<div className="flex flex-col items-start gap-2 py-3">
<div className="font-display italic text-[18px] leading-[24px] text-stat-value">
<div className="font-heading text-[18px] leading-[24px] text-stat-value">
{headline}
</div>
<div className="font-mono text-[11px] leading-snug text-stat-subtitle">
@@ -0,0 +1,48 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { SegmentedControl } from './segmented-control';
const OPTS = [
{ value: 'a', label: 'A' },
{ value: 'b', label: 'B' },
];
describe('SegmentedControl disabled', () => {
it('marks the group aria-disabled and ignores clicks when disabled', () => {
const onChange = vi.fn();
render(<SegmentedControl value="a" options={OPTS} onChange={onChange} disabled ariaLabel="Mode" />);
expect(screen.getByRole('radiogroup', { name: 'Mode' }).getAttribute('aria-disabled')).toBe('true');
fireEvent.click(screen.getByRole('radio', { name: 'B' }));
expect(onChange).not.toHaveBeenCalled();
});
it('ignores arrow-key navigation when disabled', () => {
const onChange = vi.fn();
render(<SegmentedControl value="a" options={OPTS} onChange={onChange} disabled ariaLabel="Mode" />);
fireEvent.keyDown(screen.getByRole('radio', { name: 'A' }), { key: 'ArrowRight' });
expect(onChange).not.toHaveBeenCalled();
});
it('still fires onChange when enabled', () => {
const onChange = vi.fn();
render(<SegmentedControl value="a" options={OPTS} onChange={onChange} ariaLabel="Mode" />);
fireEvent.click(screen.getByRole('radio', { name: 'B' }));
expect(onChange).toHaveBeenCalledWith('b');
});
it('commits onChange on arrow-key navigation when enabled', () => {
const onChange = vi.fn();
render(<SegmentedControl value="a" options={OPTS} onChange={onChange} ariaLabel="Mode" />);
fireEvent.keyDown(screen.getByRole('radio', { name: 'A' }), { key: 'ArrowRight' });
expect(onChange).toHaveBeenCalledWith('b');
});
it('keeps a keyboard entry point when no option is active (value=null)', () => {
render(<SegmentedControl value={null} options={OPTS} onChange={() => {}} ariaLabel="Mode" />);
const radios = screen.getAllByRole('radio');
expect(radios.some((r) => r.getAttribute('aria-checked') === 'true')).toBe(false);
// The first option anchors the roving tabindex so Tab still reaches the group.
expect(radios[0].getAttribute('tabindex')).toBe('0');
expect(radios[1].getAttribute('tabindex')).toBe('-1');
});
});
@@ -11,12 +11,14 @@ export interface SegmentedControlOption<T extends string> {
}
interface SegmentedControlProps<T extends string> {
value: T;
/** Pass null to show no active segment (e.g. a custom, off-preset combination). */
value: T | null;
options: SegmentedControlOption<T>[];
onChange: (next: T) => void;
ariaLabel?: string;
iconOnly?: boolean;
fullWidth?: boolean;
disabled?: boolean;
className?: string;
}
@@ -27,6 +29,7 @@ export function SegmentedControl<T extends string>({
ariaLabel,
iconOnly,
fullWidth,
disabled,
className,
}: SegmentedControlProps<T>) {
const buttonsRef = useRef<(HTMLButtonElement | null)[]>([]);
@@ -42,6 +45,7 @@ export function SegmentedControl<T extends string>({
};
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>, current: number) => {
if (disabled) return;
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
event.preventDefault();
focusIndex(current + 1);
@@ -57,13 +61,21 @@ export function SegmentedControl<T extends string>({
}
};
// Roving tabindex: the active option is the keyboard entry point, but when no
// option is active (value is null for a custom, off-preset combination) anchor
// on the first option so the group stays Tab-reachable.
const activeIndex = options.findIndex((o) => o.value === value);
const rovingIndex = activeIndex === -1 ? 0 : activeIndex;
return (
<div
role="radiogroup"
aria-label={ariaLabel}
aria-disabled={disabled || undefined}
className={cn(
'inline-flex items-center rounded-md border border-card-border bg-card p-0.5',
fullWidth && 'flex w-full',
disabled && 'opacity-50',
className,
)}
>
@@ -87,8 +99,9 @@ export function SegmentedControl<T extends string>({
aria-checked={active}
aria-label={a11yLabel}
title={iconOnly ? opt.label : undefined}
tabIndex={active ? 0 : -1}
onClick={() => onChange(opt.value)}
disabled={disabled}
tabIndex={disabled ? -1 : index === rovingIndex ? 0 : -1}
onClick={() => { if (!disabled) onChange(opt.value); }}
onKeyDown={(e) => handleKeyDown(e, index)}
className={cn(
'flex items-center gap-1.5 rounded px-2.5 py-1 font-mono text-[10px] uppercase tracking-[0.14em] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/50',
+1 -1
View File
@@ -178,7 +178,7 @@ function SheetHeaderBand({ crumb, name, meta, onDismiss }: SheetHeaderBandProps)
})}
</nav>
<SheetTitle className="mt-2 font-display italic text-[1.5rem] leading-tight text-stat-value text-left">
<SheetTitle className="mt-2 font-heading text-[1.5rem] leading-tight text-stat-value text-left">
{name}
</SheetTitle>