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,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]);