mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 20:27:22 +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:
@@ -24,7 +24,7 @@ export function ThemePreview() {
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-brand">
|
||||
fleet · preview
|
||||
</div>
|
||||
<div className="font-display text-2xl italic leading-none text-stat-value">
|
||||
<div className="font-heading text-2xl leading-none text-stat-value">
|
||||
Healthy
|
||||
</div>
|
||||
<div className="mt-1.5 font-mono text-[11px] text-stat-subtitle">
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, act, renderHook } from '@testing-library/react';
|
||||
import { ThemeQuickSwitch } from './ThemeQuickSwitch';
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
|
||||
function resetTheme() {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
act(() => {
|
||||
result.current.setReadability(false);
|
||||
result.current.setVisualStyle('calm');
|
||||
});
|
||||
}
|
||||
|
||||
describe('ThemeQuickSwitch', () => {
|
||||
beforeEach(() => resetTheme());
|
||||
|
||||
it('replaces the font pickers with a Visual style switch and a Readability toggle', () => {
|
||||
render(<ThemeQuickSwitch />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Theme' }));
|
||||
expect(screen.getByRole('radiogroup', { name: 'Visual style' })).toBeTruthy();
|
||||
expect(screen.getByRole('switch', { name: 'Readability mode' })).toBeTruthy();
|
||||
// Interface / Data font pickers are gone; Text size stays.
|
||||
expect(screen.queryByText('Interface')).toBeNull();
|
||||
expect(screen.getByText('Text size')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('switching the visual style from the panel applies it', () => {
|
||||
render(<ThemeQuickSwitch />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Theme' }));
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Signature' }));
|
||||
expect(document.documentElement.dataset.headings).toBe('signature');
|
||||
});
|
||||
|
||||
it('locks the Visual style switch while readability is on', () => {
|
||||
render(<ThemeQuickSwitch />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Theme' }));
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Readability mode' }));
|
||||
expect(screen.getByRole('radiogroup', { name: 'Visual style' }).getAttribute('aria-disabled')).toBe('true');
|
||||
});
|
||||
|
||||
it('the Settings link calls onOpenAppearance', () => {
|
||||
const onOpenAppearance = vi.fn();
|
||||
render(<ThemeQuickSwitch onOpenAppearance={onOpenAppearance} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Theme' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Settings' }));
|
||||
expect(onOpenAppearance).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,32 +1,53 @@
|
||||
import { useState } from 'react';
|
||||
import { Palette } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { SegmentedControl } from '@/components/ui/segmented-control';
|
||||
import { TogglePill } from '@/components/ui/toggle-pill';
|
||||
import { AccentPicker } from './AccentPicker';
|
||||
import { TypeChips } from './TypeChips';
|
||||
import { UI_FONT_OPTIONS, MONO_FONT_OPTIONS, SIZE_OPTIONS, sizeIdForScale } from './typeOptions';
|
||||
import { useTheme, THEME_MODES, THEME_MODE_OPTIONS, ACCENTS, TYPE_SIZES } from '@/hooks/use-theme';
|
||||
import { SIZE_OPTIONS, sizeIdForScale } from './typeOptions';
|
||||
import { useTheme, activeVisualStyle, THEME_MODES, THEME_MODE_OPTIONS, ACCENTS, TYPE_SIZES, type VisualStyle } from '@/hooks/use-theme';
|
||||
|
||||
const VISUAL_STYLE_OPTIONS: { value: VisualStyle; label: string }[] = [
|
||||
{ value: 'calm', label: 'Calm' },
|
||||
{ value: 'signature', label: 'Signature' },
|
||||
];
|
||||
|
||||
interface ThemeQuickSwitchProps {
|
||||
/** Jump straight to Settings → Appearance (closes the popover first). */
|
||||
onOpenAppearance?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Topbar quick theme switch (between search and notifications). Opens a small
|
||||
* popover to flip the mode and accent; the fine-tune sliders live in
|
||||
* Settings → Appearance. Reads/writes the shared theme store directly. Mirrors
|
||||
* the masthead + bordered-section chrome of the notification and profile panels.
|
||||
* popover to flip the mode, accent, visual style, readability, and text size; the
|
||||
* fine-tune sliders live in Settings → Appearance. Reads/writes the shared theme
|
||||
* store directly. Mirrors the masthead + bordered-section chrome of the
|
||||
* notification and profile panels.
|
||||
*/
|
||||
export function ThemeQuickSwitch() {
|
||||
export function ThemeQuickSwitch({ onOpenAppearance }: ThemeQuickSwitchProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const {
|
||||
theme, accent, uiFont, monoFont, typeScale,
|
||||
setTheme, setAccent, setUiFont, setMonoFont, setTypeScale,
|
||||
theme, accent, typeScale, headingStyle, chartStyle, reducedEffects, readability,
|
||||
setTheme, setAccent, setTypeScale, setVisualStyle, setReadability,
|
||||
} = useTheme();
|
||||
const themeLabel = THEME_MODES.find((m) => m.id === theme)?.label ?? 'Dim';
|
||||
const accentLabel = ACCENTS.find((a) => a.id === accent)?.label ?? 'Cyan';
|
||||
// Highlight the matched preset, or none for a custom combination, so the
|
||||
// quick-switch agrees with the Appearance cards.
|
||||
const activeVisual = activeVisualStyle({ headingStyle, chartStyle, reducedEffects, readability });
|
||||
const onSize = (id: string) => {
|
||||
const match = TYPE_SIZES.find((s) => s.id === id);
|
||||
if (match) setTypeScale(match.scale);
|
||||
};
|
||||
const openAppearance = () => {
|
||||
setOpen(false);
|
||||
onOpenAppearance?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -44,7 +65,7 @@ export function ThemeQuickSwitch() {
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-r from-brand/[0.05] via-transparent to-transparent" />
|
||||
<div className="absolute inset-y-0 left-0 w-[2px] bg-brand/60" />
|
||||
<div className="relative flex items-center justify-between px-[var(--density-row-x)] py-[var(--density-tile-y)]">
|
||||
<span className="font-display text-xl italic leading-none text-stat-value">
|
||||
<span className="font-heading text-xl leading-none text-stat-value">
|
||||
Theme
|
||||
</span>
|
||||
<span className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle">
|
||||
@@ -81,41 +102,52 @@ export function ThemeQuickSwitch() {
|
||||
<AccentPicker value={accent} onChange={setAccent} />
|
||||
</div>
|
||||
|
||||
{/* Type */}
|
||||
<div className="space-y-3 border-t border-card-border/60 px-[var(--density-row-x)] py-[var(--density-row-y)]">
|
||||
{/* Visual style + Readability */}
|
||||
<div className="space-y-2.5 border-t border-card-border/60 px-[var(--density-row-x)] py-[var(--density-row-y)]">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
|
||||
Type
|
||||
Visual style
|
||||
</span>
|
||||
<div className="space-y-1.5">
|
||||
<span className="block font-mono text-[9px] uppercase tracking-[0.16em] text-stat-icon">
|
||||
Interface
|
||||
<SegmentedControl
|
||||
value={activeVisual}
|
||||
options={VISUAL_STYLE_OPTIONS}
|
||||
onChange={setVisualStyle}
|
||||
disabled={readability}
|
||||
ariaLabel="Visual style"
|
||||
fullWidth
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
|
||||
Readability
|
||||
</span>
|
||||
<TypeChips value={uiFont} options={UI_FONT_OPTIONS} onChange={setUiFont} ariaLabel="Interface font" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<span className="block font-mono text-[9px] uppercase tracking-[0.16em] text-stat-icon">
|
||||
Data
|
||||
</span>
|
||||
<TypeChips value={monoFont} options={MONO_FONT_OPTIONS} onChange={setMonoFont} ariaLabel="Data font" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<span className="block font-mono text-[9px] uppercase tracking-[0.16em] text-stat-icon">
|
||||
Size
|
||||
</span>
|
||||
<TypeChips
|
||||
value={sizeIdForScale(typeScale)}
|
||||
options={SIZE_OPTIONS}
|
||||
onChange={onSize}
|
||||
columns={4}
|
||||
ariaLabel="Text size"
|
||||
/>
|
||||
<TogglePill checked={readability} onChange={setReadability} aria-label="Readability mode" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer hint */}
|
||||
{/* Text size */}
|
||||
<div className="space-y-2 border-t border-card-border/60 px-[var(--density-row-x)] py-[var(--density-row-y)]">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
|
||||
Text size
|
||||
</span>
|
||||
<TypeChips
|
||||
value={sizeIdForScale(typeScale)}
|
||||
options={SIZE_OPTIONS}
|
||||
onChange={onSize}
|
||||
columns={4}
|
||||
ariaLabel="Text size"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-card-border/60 px-[var(--density-row-x)] py-[var(--density-row-y)]">
|
||||
<p className="font-mono text-[10px] leading-4 uppercase tracking-[0.14em] text-stat-subtitle/70">
|
||||
Saved to this browser · fine-tune borders & glow in Settings
|
||||
Saved to this browser · fine-tune borders & glow in{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={openAppearance}
|
||||
className="uppercase tracking-[0.14em] text-brand transition-colors hover:text-brand/80 focus-visible:underline focus-visible:outline-none"
|
||||
>
|
||||
Settings
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
|
||||
Reference in New Issue
Block a user