mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-16 13:38:33 +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:
@@ -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]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user