mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-17 22:17:50 +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:
@@ -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',
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user