mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 19:57:37 +00:00
feat(appearance): add theme, accent, contrast, and typography personalization (#1307)
* feat(appearance): add theme, accent, contrast, and typography personalization Expand Settings to Appearance into a full personalization surface and add a quick switcher to the top bar (the palette button between search and notifications). Choices are saved to the browser, sync across tabs, and apply before first paint so there is no flash on reload. - Themes: Dim (the default raised charcoal), OLED true black, Light, and Auto (follows the OS and re-resolves live when it flips). - Accent: an eight-hue wheel (cyan default) that drives the one data color across charts, rails, focus rings, active states, and the ambient glow. - Fine-tune sliders: a master Contrast that spreads page, ink, and borders together, plus Border brightness and Ambient glow. - Typography: swappable interface (Geist / IBM Plex Sans / Hanken Grotesk) and data (Geist Mono / IBM Plex Mono / Fira Code) faces, and a text-size control (continuous slider in Settings, S/M/L/XL presets in the popover, kept in sync). The display serif stays locked as the signature face. Surfaces, borders, and ink derive from per-theme lightness values so the live knobs scale the whole UI through CSS, and the opaque directional borders read on every panel including true black. A live preview reflects changes in real time. Adds a documentation page under Features. * fix(appearance): keep contrast-driven tokens in gamut and add picker keyboard nav Clamp the contrast and knob driven surface, border, and ink lightness so the full slider range stays a valid color. The page now always sits below the card tone, so page/card separation no longer collapses at high contrast in Light; the lit border edge never reaches white in Light at the low end of the knobs; and the OLED and Dim extremes resolve to valid black/white instead of out-of-range values. Add the radiogroup keyboard model (roving tabindex plus Arrow / Home / End) to the accent and type pickers through a shared hook, matching the segmented control. One item is tabbable and arrow keys move focus and selection together.
This commit is contained in:
@@ -13,7 +13,8 @@ import { useStackListState } from './EditorLayout/hooks/useStackListState';
|
||||
import { useViewNavigationState } from './EditorLayout/hooks/useViewNavigationState';
|
||||
import { useOverlayState } from './EditorLayout/hooks/useOverlayState';
|
||||
import { useStackActions, NODE_SWITCH_PENDING_TOKEN } from './EditorLayout/hooks/useStackActions';
|
||||
import { useTheme } from './EditorLayout/hooks/useTheme';
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
import { ThemeQuickSwitch } from './theme/ThemeQuickSwitch';
|
||||
import { useNotifications } from './EditorLayout/hooks/useNotifications';
|
||||
import { useContainerStats } from './EditorLayout/hooks/useContainerStats';
|
||||
import { useSidebarContextMenu } from './EditorLayout/hooks/useSidebarContextMenu';
|
||||
@@ -223,7 +224,7 @@ export default function EditorLayout() {
|
||||
const loadingAction = selectedFile ? (stackActionMap[selectedFile] ?? null) : null;
|
||||
const stackName = selectedFile || '';
|
||||
|
||||
const { theme, setTheme, isDarkMode } = useTheme();
|
||||
const { isDarkMode } = useTheme();
|
||||
|
||||
// Track the last "committed" node id so the node-switch dirty guard can
|
||||
// detect an actual switch (vs the initial mount or an internal revert).
|
||||
@@ -347,7 +348,7 @@ export default function EditorLayout() {
|
||||
|
||||
return (
|
||||
<GlobalCommandPaletteProvider>
|
||||
<div className="flex h-screen w-screen overflow-hidden bg-background text-foreground">
|
||||
<div className="flex h-screen w-screen overflow-hidden app-canvas text-foreground">
|
||||
<GlobalCommandPalette
|
||||
navItems={navItems}
|
||||
onNavigate={handleNavigate}
|
||||
@@ -417,6 +418,7 @@ export default function EditorLayout() {
|
||||
mobileNavOpen={mobileNavOpen}
|
||||
onMobileNavOpenChange={setMobileNavOpen}
|
||||
search={<GlobalCommandPaletteTrigger />}
|
||||
themeSwitch={<ThemeQuickSwitch />}
|
||||
notifications={
|
||||
<NotificationPanel
|
||||
notifications={notifications}
|
||||
@@ -429,8 +431,6 @@ export default function EditorLayout() {
|
||||
}
|
||||
userMenu={
|
||||
<UserProfileDropdown
|
||||
theme={theme}
|
||||
setTheme={setTheme}
|
||||
onOpenSettings={() => handleOpenSettings('account')}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { useTheme } from './useTheme';
|
||||
|
||||
const mockMatchMedia = (matches: boolean) => {
|
||||
const listeners: Array<(e: MediaQueryListEvent) => void> = [];
|
||||
return {
|
||||
matches,
|
||||
addEventListener: vi.fn((_: string, cb: (e: MediaQueryListEvent) => void) => { listeners.push(cb); }),
|
||||
removeEventListener: vi.fn(),
|
||||
_trigger: (m: boolean) => listeners.forEach(cb => cb({ matches: m } as MediaQueryListEvent)),
|
||||
};
|
||||
};
|
||||
|
||||
describe('useTheme', () => {
|
||||
let mq: ReturnType<typeof mockMatchMedia>;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
mq = mockMatchMedia(false);
|
||||
vi.stubGlobal('matchMedia', vi.fn(() => mq));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
document.documentElement.classList.remove('dark');
|
||||
});
|
||||
|
||||
it('defaults to dark theme when no localStorage entry', () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
expect(result.current.theme).toBe('dark');
|
||||
expect(result.current.isDarkMode).toBe(true);
|
||||
});
|
||||
|
||||
it('reads persisted theme from localStorage', () => {
|
||||
localStorage.setItem('sencho-theme', 'light');
|
||||
const { result } = renderHook(() => useTheme());
|
||||
expect(result.current.theme).toBe('light');
|
||||
expect(result.current.isDarkMode).toBe(false);
|
||||
});
|
||||
|
||||
it('setTheme persists to localStorage and updates isDarkMode', () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
act(() => result.current.setTheme('light'));
|
||||
expect(result.current.theme).toBe('light');
|
||||
expect(result.current.isDarkMode).toBe(false);
|
||||
expect(localStorage.getItem('sencho-theme')).toBe('light');
|
||||
});
|
||||
|
||||
it('auto theme tracks system preference', () => {
|
||||
mq = mockMatchMedia(true);
|
||||
vi.stubGlobal('matchMedia', vi.fn(() => mq));
|
||||
localStorage.setItem('sencho-theme', 'auto');
|
||||
const { result } = renderHook(() => useTheme());
|
||||
expect(result.current.isDarkMode).toBe(true);
|
||||
});
|
||||
|
||||
it('applies dark class to documentElement when isDarkMode', () => {
|
||||
renderHook(() => useTheme());
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true);
|
||||
});
|
||||
|
||||
it('removes dark class when switching to light', () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
act(() => result.current.setTheme('light'));
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,37 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
type Theme = 'light' | 'dark' | 'auto';
|
||||
|
||||
function readTheme(): Theme {
|
||||
if (typeof window === 'undefined') return 'dark';
|
||||
const saved = localStorage.getItem('sencho-theme') as Theme | null;
|
||||
if (saved === 'light' || saved === 'dark' || saved === 'auto') return saved;
|
||||
return 'dark';
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const [theme, setThemeState] = useState<Theme>(readTheme);
|
||||
const [systemDark, setSystemDark] = useState(() =>
|
||||
typeof window !== 'undefined'
|
||||
? window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
: false,
|
||||
);
|
||||
|
||||
const isDarkMode = theme === 'dark' || (theme === 'auto' && systemDark);
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handler = (e: MediaQueryListEvent) => setSystemDark(e.matches);
|
||||
mq.addEventListener('change', handler);
|
||||
return () => mq.removeEventListener('change', handler);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle('dark', isDarkMode);
|
||||
try { localStorage.setItem('sencho-theme', theme); } catch { /* ignore */ }
|
||||
}, [isDarkMode, theme]);
|
||||
|
||||
const setTheme = (next: Theme) => setThemeState(next);
|
||||
|
||||
return { theme, setTheme, isDarkMode } as const;
|
||||
}
|
||||
@@ -18,6 +18,7 @@ interface TopBarProps {
|
||||
mobileNavOpen: boolean;
|
||||
onMobileNavOpenChange: (open: boolean) => void;
|
||||
search?: ReactNode;
|
||||
themeSwitch?: ReactNode;
|
||||
notifications: ReactNode;
|
||||
userMenu: ReactNode;
|
||||
}
|
||||
@@ -29,6 +30,7 @@ export function TopBar({
|
||||
mobileNavOpen,
|
||||
onMobileNavOpenChange,
|
||||
search,
|
||||
themeSwitch,
|
||||
notifications,
|
||||
userMenu,
|
||||
}: TopBarProps) {
|
||||
@@ -76,6 +78,7 @@ export function TopBar({
|
||||
{/* RIGHT ZONE: Utilities + identity pin */}
|
||||
<div className="flex flex-1 min-w-0 items-center justify-end gap-2">
|
||||
{search}
|
||||
{themeSwitch}
|
||||
{notifications}
|
||||
{userMenu}
|
||||
|
||||
|
||||
@@ -3,9 +3,6 @@ import {
|
||||
Settings,
|
||||
LogOut,
|
||||
ExternalLink,
|
||||
Monitor,
|
||||
Sun,
|
||||
Moon,
|
||||
User,
|
||||
Loader2,
|
||||
BookOpen,
|
||||
@@ -15,7 +12,6 @@ import {
|
||||
import type { LucideIcon } 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 { useAuth } from '@/context/AuthContext';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
@@ -23,20 +19,10 @@ import { toast } from '@/components/ui/toast-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { TierBadge } from './TierBadge';
|
||||
|
||||
type Theme = 'light' | 'dark' | 'auto';
|
||||
|
||||
interface UserProfileDropdownProps {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
onOpenSettings: () => void;
|
||||
}
|
||||
|
||||
const THEME_OPTIONS = [
|
||||
{ value: 'auto' as const, label: 'Auto', icon: Monitor },
|
||||
{ value: 'light' as const, label: 'Light', icon: Sun },
|
||||
{ value: 'dark' as const, label: 'Dark', icon: Moon },
|
||||
];
|
||||
|
||||
function getInitials(username: string | undefined): string {
|
||||
if (!username) return '';
|
||||
const trimmed = username.trim();
|
||||
@@ -48,7 +34,7 @@ function getInitials(username: string | undefined): string {
|
||||
return trimmed.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
export function UserProfileDropdown({ theme, setTheme, onOpenSettings }: UserProfileDropdownProps) {
|
||||
export function UserProfileDropdown({ onOpenSettings }: UserProfileDropdownProps) {
|
||||
const { logout, user, isAdmin } = useAuth();
|
||||
const { license } = useLicense();
|
||||
const [billingLoading, setBillingLoading] = useState(false);
|
||||
@@ -172,20 +158,6 @@ export function UserProfileDropdown({ theme, setTheme, onOpenSettings }: UserPro
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Appearance */}
|
||||
<div className="flex items-center justify-between gap-3 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">
|
||||
Appearance
|
||||
</span>
|
||||
<SegmentedControl
|
||||
value={theme}
|
||||
options={THEME_OPTIONS}
|
||||
onChange={setTheme}
|
||||
iconOnly
|
||||
ariaLabel="Theme"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Logout */}
|
||||
<div className="border-t border-card-border/60">
|
||||
<button
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { SegmentedControl } from '@/components/ui/segmented-control';
|
||||
import { useDensity } from '@/hooks/use-density';
|
||||
import type { Density } from '@/hooks/use-density';
|
||||
import { useDeployFeedbackEnabled } from '@/hooks/use-deploy-feedback-enabled';
|
||||
import { useComposeDiffPreviewEnabled } from '@/hooks/use-compose-diff-preview-enabled';
|
||||
import { useTheme, THEME_MODE_OPTIONS, ACCENTS, CONTRAST, BORDER_BOOST, GLOW, TYPE_SCALE } from '@/hooks/use-theme';
|
||||
import { AccentPicker } from '@/components/theme/AccentPicker';
|
||||
import { ThemePreview } from '@/components/theme/ThemePreview';
|
||||
import { TypeChips } from '@/components/theme/TypeChips';
|
||||
import { UI_FONT_OPTIONS, MONO_FONT_OPTIONS } from '@/components/theme/typeOptions';
|
||||
import { SettingsSection } from './SettingsSection';
|
||||
import { SettingsField } from './SettingsField';
|
||||
import { SettingsActions, SettingsSecondaryButton } from './SettingsActions';
|
||||
|
||||
const DENSITY_OPTIONS: { value: Density; label: string }[] = [
|
||||
{ value: 'comfortable', label: 'Comfortable' },
|
||||
@@ -17,13 +25,152 @@ const DENSITY_DESCRIPTIONS: Record<Density, string> = {
|
||||
compact: 'Tighter rows and tiles. Fits more on screen for dense dashboards.',
|
||||
};
|
||||
|
||||
const fmtSigned = (v: number) => `${v > 0 ? '+' : ''}${v.toFixed(2)}`;
|
||||
|
||||
export function AppearanceSection() {
|
||||
const [density, setDensity] = useDensity();
|
||||
const [isEnabled, setEnabled] = useDeployFeedbackEnabled();
|
||||
const [diffPreviewEnabled, setDiffPreviewEnabled] = useComposeDiffPreviewEnabled();
|
||||
const {
|
||||
theme, accent, borderBoost, glow, contrast, uiFont, monoFont, typeScale,
|
||||
setTheme, setAccent, setBorderBoost, setGlow, setContrast, setUiFont, setMonoFont, setTypeScale,
|
||||
} = useTheme();
|
||||
const accentLabel = ACCENTS.find((a) => a.id === accent)?.label ?? 'Cyan';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
<SettingsSection title="Theme" kicker="this browser">
|
||||
<div className="pt-3">
|
||||
<ThemePreview />
|
||||
</div>
|
||||
|
||||
<SettingsField
|
||||
label="Mode"
|
||||
helper="Dim lifts surfaces off black; OLED is true black; Light inverts; Auto follows your OS."
|
||||
>
|
||||
<SegmentedControl
|
||||
value={theme}
|
||||
options={THEME_MODE_OPTIONS}
|
||||
onChange={setTheme}
|
||||
ariaLabel="Theme mode"
|
||||
/>
|
||||
</SettingsField>
|
||||
|
||||
<SettingsField
|
||||
label="Accent"
|
||||
helper={`The one data color, used for charts, rails, and focus. Currently ${accentLabel}.`}
|
||||
align="start"
|
||||
>
|
||||
<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."
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Slider
|
||||
value={[borderBoost]}
|
||||
min={BORDER_BOOST.min}
|
||||
max={BORDER_BOOST.max}
|
||||
step={BORDER_BOOST.step}
|
||||
onValueChange={([v]) => setBorderBoost(v)}
|
||||
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)}
|
||||
</span>
|
||||
</div>
|
||||
</SettingsField>
|
||||
|
||||
<SettingsActions>
|
||||
<SettingsSecondaryButton
|
||||
onClick={() => {
|
||||
setContrast(CONTRAST.default);
|
||||
setBorderBoost(BORDER_BOOST.default);
|
||||
setGlow(GLOW.default);
|
||||
}}
|
||||
>
|
||||
Reset fine-tune
|
||||
</SettingsSecondaryButton>
|
||||
</SettingsActions>
|
||||
</SettingsSection>
|
||||
|
||||
<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."
|
||||
align="start"
|
||||
>
|
||||
<TypeChips value={uiFont} options={UI_FONT_OPTIONS} onChange={setUiFont} ariaLabel="Interface font" />
|
||||
</SettingsField>
|
||||
|
||||
<SettingsField
|
||||
label="Data font"
|
||||
helper="The mono face for terminal, stats, codes, and timestamps."
|
||||
align="start"
|
||||
>
|
||||
<TypeChips value={monoFont} options={MONO_FONT_OPTIONS} onChange={setMonoFont} ariaLabel="Data font" />
|
||||
</SettingsField>
|
||||
|
||||
<SettingsField
|
||||
label="Text size"
|
||||
helper="Scales the whole interface from a single root multiplier. Default 1.00×."
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Slider
|
||||
value={[typeScale]}
|
||||
min={TYPE_SCALE.min}
|
||||
max={TYPE_SCALE.max}
|
||||
step={TYPE_SCALE.step}
|
||||
onValueChange={([v]) => setTypeScale(v)}
|
||||
aria-label="Text size"
|
||||
/>
|
||||
<span className="w-12 shrink-0 text-right font-mono text-xs tabular-nums text-stat-subtitle">
|
||||
{typeScale.toFixed(2)}×
|
||||
</span>
|
||||
</div>
|
||||
</SettingsField>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Display" kicker="this browser">
|
||||
<SettingsField
|
||||
label="Density"
|
||||
|
||||
@@ -46,8 +46,8 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
|
||||
id: 'appearance',
|
||||
group: 'identity',
|
||||
label: 'Appearance',
|
||||
description: 'Density and display preferences saved to this browser.',
|
||||
keywords: ['density', 'comfortable', 'compact', 'spacing', 'display', 'theme'],
|
||||
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'],
|
||||
tier: null,
|
||||
scope: 'global',
|
||||
},
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { AccentPicker } from './AccentPicker';
|
||||
|
||||
// Covers the roving-radio keyboard model added via useRovingRadio (shared with
|
||||
// TypeChips). ACCENTS order is orange, amber, lime, cyan, blue, violet, magenta, steel.
|
||||
|
||||
describe('AccentPicker keyboard model', () => {
|
||||
it('exposes exactly one tabbable radio (the selected one)', () => {
|
||||
render(<AccentPicker value="cyan" onChange={() => {}} />);
|
||||
const radios = screen.getAllByRole('radio');
|
||||
const tabbable = radios.filter((r) => r.getAttribute('tabindex') === '0');
|
||||
expect(tabbable).toHaveLength(1);
|
||||
expect(tabbable[0].getAttribute('aria-label')).toBe('Cyan');
|
||||
});
|
||||
|
||||
it('ArrowRight moves selection to the next accent, ArrowLeft to the previous', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<AccentPicker value="cyan" onChange={onChange} />);
|
||||
const cyan = screen.getByRole('radio', { name: 'Cyan' });
|
||||
fireEvent.keyDown(cyan, { key: 'ArrowRight' });
|
||||
expect(onChange).toHaveBeenLastCalledWith('blue');
|
||||
fireEvent.keyDown(cyan, { key: 'ArrowLeft' });
|
||||
expect(onChange).toHaveBeenLastCalledWith('lime');
|
||||
});
|
||||
|
||||
it('Home selects the first accent, End selects the last', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<AccentPicker value="cyan" onChange={onChange} />);
|
||||
const cyan = screen.getByRole('radio', { name: 'Cyan' });
|
||||
fireEvent.keyDown(cyan, { key: 'Home' });
|
||||
expect(onChange).toHaveBeenLastCalledWith('orange');
|
||||
fireEvent.keyDown(cyan, { key: 'End' });
|
||||
expect(onChange).toHaveBeenLastCalledWith('steel');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ACCENTS, type AccentId } from '@/hooks/use-theme';
|
||||
import { useRovingRadio } from './useRovingRadio';
|
||||
|
||||
interface AccentPickerProps {
|
||||
value: AccentId;
|
||||
onChange: (accent: AccentId) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ACCENT_IDS = ACCENTS.map((a) => a.id);
|
||||
|
||||
/**
|
||||
* The eight swappable accent hues as a swatch grid. Each dot renders the accent
|
||||
* at a representative lightness so it reads on both dark and light panels; the
|
||||
* selected swatch gets a ring in its own hue. Shared by the topbar quick switch
|
||||
* and Settings → Appearance.
|
||||
*/
|
||||
export function AccentPicker({ value, onChange, className }: AccentPickerProps) {
|
||||
const itemProps = useRovingRadio(ACCENT_IDS, value, onChange);
|
||||
return (
|
||||
<div role="radiogroup" aria-label="Accent" className={cn('grid grid-cols-4 gap-2', className)}>
|
||||
{ACCENTS.map((a, i) => {
|
||||
const color = `oklch(0.745 ${a.c} ${a.h})`;
|
||||
const active = a.id === value;
|
||||
return (
|
||||
<button
|
||||
key={a.id}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
aria-label={a.label}
|
||||
title={a.label}
|
||||
onClick={() => onChange(a.id)}
|
||||
{...itemProps(i)}
|
||||
className={cn(
|
||||
'flex h-8 items-center justify-center rounded-md border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/50',
|
||||
active ? 'border-card-border-hover bg-card' : 'border-card-border hover:bg-accent',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="h-4 w-4 rounded-full"
|
||||
style={{
|
||||
background: color,
|
||||
boxShadow: active ? `0 0 0 2px var(--card), 0 0 0 4px ${color}` : undefined,
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const LADDER: { bg: string; label: string }[] = [
|
||||
{ bg: 'bg-background', label: 'page' },
|
||||
{ bg: 'bg-card', label: 'card' },
|
||||
{ bg: 'bg-band', label: 'band' },
|
||||
{ bg: 'bg-well', label: 'well' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Compact, self-contained preview of the current theme. It consumes only global
|
||||
* tokens, so it (and the rest of the app) re-render live as the mode, accent, or
|
||||
* fine-tune knobs change. A concentrated view of surfaces, borders, ink tiers,
|
||||
* status, and the accent without scanning the whole page.
|
||||
*/
|
||||
export function ThemePreview() {
|
||||
return (
|
||||
<div className="group relative overflow-hidden rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel transition-colors hover:border-t-card-border-hover">
|
||||
<div className="absolute inset-y-0 left-0 w-[3px] bg-brand" />
|
||||
<div className="flex flex-col gap-4 p-5 pl-6">
|
||||
{/* Mini masthead: rail + display state word + mono kicker + status dots */}
|
||||
<div className="flex items-end justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<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">
|
||||
Healthy
|
||||
</div>
|
||||
<div className="mt-1.5 font-mono text-[11px] text-stat-subtitle">
|
||||
6 nodes · last sync 9s
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="h-2 w-2 rounded-full bg-success" />
|
||||
<span className="h-2 w-2 rounded-full bg-warning" />
|
||||
<span className="h-2 w-2 rounded-full bg-destructive" />
|
||||
<span className="h-2 w-2 rounded-full bg-brand" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ink tiers */}
|
||||
<div className="flex flex-wrap items-baseline gap-x-4 gap-y-1 text-sm">
|
||||
<span className="text-stat-value">Value</span>
|
||||
<span className="text-stat-title">Title</span>
|
||||
<span className="text-stat-subtitle">Subtitle</span>
|
||||
<span className="text-stat-icon">Icon</span>
|
||||
</div>
|
||||
|
||||
{/* Surface ladder */}
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{LADDER.map(({ bg, label }) => (
|
||||
<div key={label} className="flex flex-col items-center gap-1.5">
|
||||
<div className={cn('h-9 w-full rounded-md border border-card-border', bg)} />
|
||||
<span className="font-mono text-[9px] uppercase tracking-[0.16em] text-stat-icon">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Brand progress + key affordances */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-well">
|
||||
<div className="h-full w-[62%] rounded-full bg-brand" />
|
||||
</div>
|
||||
<span className="pointer-events-none select-none rounded-md border border-card-border bg-card px-2.5 py-1 font-mono text-[10px] uppercase tracking-[0.14em] text-stat-subtitle shadow-btn-glow">
|
||||
Outline
|
||||
</span>
|
||||
<span className="pointer-events-none select-none rounded-md bg-brand px-2.5 py-1 font-mono text-[10px] uppercase tracking-[0.14em] text-brand-foreground">
|
||||
Primary
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
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 { 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';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function ThemeQuickSwitch() {
|
||||
const {
|
||||
theme, accent, uiFont, monoFont, typeScale,
|
||||
setTheme, setAccent, setUiFont, setMonoFont, setTypeScale,
|
||||
} = useTheme();
|
||||
const themeLabel = THEME_MODES.find((m) => m.id === theme)?.label ?? 'Dim';
|
||||
const accentLabel = ACCENTS.find((a) => a.id === accent)?.label ?? 'Cyan';
|
||||
const onSize = (id: string) => {
|
||||
const match = TYPE_SIZES.find((s) => s.id === id);
|
||||
if (match) setTypeScale(match.scale);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-lg"
|
||||
title="Theme"
|
||||
aria-label="Theme"
|
||||
>
|
||||
<Palette className="h-4 w-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72 overflow-hidden rounded-md p-0" align="end" sideOffset={8}>
|
||||
{/* Masthead */}
|
||||
<div className="relative overflow-hidden">
|
||||
<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">
|
||||
Theme
|
||||
</span>
|
||||
<span className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle">
|
||||
{themeLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mode */}
|
||||
<div className="flex flex-col gap-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">
|
||||
Mode
|
||||
</span>
|
||||
<SegmentedControl
|
||||
value={theme}
|
||||
options={THEME_MODE_OPTIONS}
|
||||
onChange={setTheme}
|
||||
ariaLabel="Theme mode"
|
||||
iconOnly
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Accent */}
|
||||
<div className="space-y-2 border-t border-card-border/60 px-[var(--density-row-x)] py-[var(--density-row-y)]">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
|
||||
Accent
|
||||
</span>
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle/70">
|
||||
{accentLabel}
|
||||
</span>
|
||||
</div>
|
||||
<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)]">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
|
||||
Type
|
||||
</span>
|
||||
<div className="space-y-1.5">
|
||||
<span className="block font-mono text-[9px] uppercase tracking-[0.16em] text-stat-icon">
|
||||
Interface
|
||||
</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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer hint */}
|
||||
<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
|
||||
</p>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { TypeChipOption } from './typeOptions';
|
||||
import { useRovingRadio } from './useRovingRadio';
|
||||
|
||||
interface TypeChipsProps<T extends string> {
|
||||
value: T;
|
||||
options: TypeChipOption<T>[];
|
||||
onChange: (id: T) => void;
|
||||
columns?: 3 | 4;
|
||||
ariaLabel?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A row of preview chips for type personalization: each chip renders a sample
|
||||
* glyph in the option's own face (or size) above its name, with the selected
|
||||
* chip highlighted in the accent. Shared by the topbar Type section and
|
||||
* Settings → Appearance → Typography. Mirrors AccentPicker's selected treatment.
|
||||
*/
|
||||
export function TypeChips<T extends string>({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
columns = 3,
|
||||
ariaLabel,
|
||||
className,
|
||||
}: TypeChipsProps<T>) {
|
||||
const itemProps = useRovingRadio(options.map((o) => o.id), value, onChange);
|
||||
return (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={ariaLabel}
|
||||
className={cn('grid gap-2', columns === 4 ? 'grid-cols-4' : 'grid-cols-3', className)}
|
||||
>
|
||||
{options.map((o, i) => {
|
||||
const active = o.id === value;
|
||||
return (
|
||||
<button
|
||||
key={o.id}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
title={o.name}
|
||||
onClick={() => onChange(o.id)}
|
||||
{...itemProps(i)}
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center gap-1.5 rounded-md border px-2 py-2.5 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/50',
|
||||
active
|
||||
? 'border-brand/40 bg-brand/10'
|
||||
: 'border-card-border bg-card hover:bg-accent',
|
||||
)}
|
||||
>
|
||||
<span className="leading-none text-stat-value" style={o.previewStyle}>
|
||||
{o.preview}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'font-mono text-[9px] uppercase tracking-[0.12em]',
|
||||
active ? 'text-brand' : 'text-stat-subtitle',
|
||||
)}
|
||||
>
|
||||
{o.name}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { sizeIdForScale, SIZE_OPTIONS, UI_FONT_OPTIONS, MONO_FONT_OPTIONS } from './typeOptions';
|
||||
|
||||
describe('sizeIdForScale', () => {
|
||||
it('maps each exact preset scale to its id', () => {
|
||||
expect(sizeIdForScale(0.92)).toBe('S');
|
||||
expect(sizeIdForScale(1.0)).toBe('M');
|
||||
expect(sizeIdForScale(1.08)).toBe('L');
|
||||
expect(sizeIdForScale(1.16)).toBe('XL');
|
||||
});
|
||||
|
||||
it('returns "" for off-preset scales so the popover shows no false selection', () => {
|
||||
expect(sizeIdForScale(0.96)).toBe('');
|
||||
expect(sizeIdForScale(1.04)).toBe('');
|
||||
expect(sizeIdForScale(1.2)).toBe('');
|
||||
});
|
||||
|
||||
it('tolerates tiny floating-point drift within epsilon', () => {
|
||||
expect(sizeIdForScale(1.0 + 0.0005)).toBe('M');
|
||||
expect(sizeIdForScale(1.08 - 0.0005)).toBe('L');
|
||||
});
|
||||
});
|
||||
|
||||
describe('type option sets', () => {
|
||||
it('exposes 3 interface faces, 3 data faces, and the four size presets', () => {
|
||||
expect(UI_FONT_OPTIONS).toHaveLength(3);
|
||||
expect(MONO_FONT_OPTIONS).toHaveLength(3);
|
||||
expect(SIZE_OPTIONS.map((o) => o.id)).toEqual(['S', 'M', 'L', 'XL']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import { UI_FONTS, MONO_FONTS, TYPE_SIZES, type UiFont, type MonoFont } from '@/hooks/use-theme';
|
||||
|
||||
export interface TypeChipOption<T extends string> {
|
||||
id: T;
|
||||
name: string;
|
||||
preview: string;
|
||||
previewStyle?: CSSProperties;
|
||||
}
|
||||
|
||||
// Shared option sets so the popover and the settings section stay identical.
|
||||
export const UI_FONT_OPTIONS: TypeChipOption<UiFont>[] = UI_FONTS.map((f) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
preview: 'Ag',
|
||||
previewStyle: { fontFamily: `'${f.id}', sans-serif`, fontSize: '17px' },
|
||||
}));
|
||||
|
||||
export const MONO_FONT_OPTIONS: TypeChipOption<MonoFont>[] = MONO_FONTS.map((f) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
preview: '01',
|
||||
previewStyle: { fontFamily: `'${f.id}', monospace`, fontSize: '16px' },
|
||||
}));
|
||||
|
||||
export const SIZE_OPTIONS: TypeChipOption<string>[] = TYPE_SIZES.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.id,
|
||||
preview: 'A',
|
||||
previewStyle: { fontSize: `${s.px}px` },
|
||||
}));
|
||||
|
||||
/** Map the stored numeric --type-scale to its preset id, or '' when it sits
|
||||
* between presets (the Settings slider can land on off-preset values) so the
|
||||
* popover chips show no false selection. */
|
||||
export function sizeIdForScale(scale: number): string {
|
||||
return TYPE_SIZES.find((s) => Math.abs(s.scale - scale) < 0.001)?.id ?? '';
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useRef, type KeyboardEvent } from 'react';
|
||||
|
||||
/**
|
||||
* Roving-tabindex keyboard model for a single-select radio group rendered as a
|
||||
* grid of buttons (AccentPicker, TypeChips). Exactly one item is tabbable (the
|
||||
* selected one, or the first when nothing matches), and Arrow / Home / End move
|
||||
* focus and selection together, matching the WAI-ARIA radiogroup pattern (and
|
||||
* the existing SegmentedControl). Returns a props factory for each item.
|
||||
*/
|
||||
export function useRovingRadio<T extends string>(values: T[], current: T, onChange: (value: T) => void) {
|
||||
const refs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const selectedIndex = values.indexOf(current);
|
||||
const tabbableIndex = selectedIndex >= 0 ? selectedIndex : 0;
|
||||
|
||||
const move = (index: number) => {
|
||||
const n = values.length;
|
||||
if (n === 0) return;
|
||||
const i = ((index % n) + n) % n;
|
||||
refs.current[i]?.focus();
|
||||
onChange(values[i]);
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLButtonElement>, index: number) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowRight':
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
move(index + 1);
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
move(index - 1);
|
||||
break;
|
||||
case 'Home':
|
||||
e.preventDefault();
|
||||
move(0);
|
||||
break;
|
||||
case 'End':
|
||||
e.preventDefault();
|
||||
move(values.length - 1);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (index: number) => ({
|
||||
ref: (el: HTMLButtonElement | null) => {
|
||||
refs.current[index] = el;
|
||||
},
|
||||
tabIndex: index === tabbableIndex ? 0 : -1,
|
||||
onKeyDown: (e: KeyboardEvent<HTMLButtonElement>) => onKeyDown(e, index),
|
||||
});
|
||||
}
|
||||
@@ -16,6 +16,7 @@ interface SegmentedControlProps<T extends string> {
|
||||
onChange: (next: T) => void;
|
||||
ariaLabel?: string;
|
||||
iconOnly?: boolean;
|
||||
fullWidth?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -25,6 +26,7 @@ export function SegmentedControl<T extends string>({
|
||||
onChange,
|
||||
ariaLabel,
|
||||
iconOnly,
|
||||
fullWidth,
|
||||
className,
|
||||
}: SegmentedControlProps<T>) {
|
||||
const buttonsRef = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
@@ -61,6 +63,7 @@ export function SegmentedControl<T extends string>({
|
||||
aria-label={ariaLabel}
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-md border border-card-border bg-card p-0.5',
|
||||
fullWidth && 'flex w-full',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -89,6 +92,7 @@ export function SegmentedControl<T extends string>({
|
||||
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',
|
||||
fullWidth && 'flex-1 justify-center',
|
||||
active
|
||||
? 'bg-brand/10 text-brand'
|
||||
: 'text-stat-subtitle hover:text-stat-value',
|
||||
|
||||
Reference in New Issue
Block a user