Files
sencho/frontend/src/components/settings/AppearanceSection.tsx
T
Anso c170c3f30c fix(ui): default Reduced Motion on Calm and quiet decorative rails (#1622)
* fix(ui): default Reduced Motion on Calm and quiet decorative rails

Calm (and Reset to default) enables Reduced Motion; Signature clears it. Manual Motion stays independent of the visual-style card. Reduced Effects also stops decorative masthead rail animations. Related to #1614; does not close that issue.

* test(e2e): fix Calm Motion specs racing empty-create compose edit

Stop appearance init-script reseeding after mid-test mutations, and wait for Save and Deploy / mobile compose editor after empty create instead of assuming Anatomy Edit compose.
2026-07-14 17:58:38 -04:00

455 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
import { TogglePill } from '@/components/ui/toggle-pill';
import { useDensity } from '@/hooks/use-density';
import type { Density } from '@/hooks/use-density';
import { useLogChipColorMode, type LogChipColorMode } from '@/hooks/use-log-chip-color-mode';
import { useTopNavLabels } from '@/hooks/use-top-nav-labels';
import { useTopNavAlign, type TopNavAlign } from '@/hooks/use-top-nav-align';
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';
import { UI_FONT_OPTIONS, MONO_FONT_OPTIONS } from '@/components/theme/typeOptions';
import { SettingsSection } from './SettingsSection';
import { SettingsField } from './SettingsField';
import { SettingsActions, SettingsSecondaryButton } from './SettingsActions';
import { SettingsCallout } from './SettingsCallout';
const DENSITY_OPTIONS: { value: Density; label: string }[] = [
{ value: 'comfortable', label: 'Comfortable' },
{ value: 'compact', label: 'Compact' },
];
const DENSITY_DESCRIPTIONS: Record<Density, string> = {
comfortable: 'Default spacing. Roomy rows for review and orientation.',
compact: 'Tighter rows and tiles. Fits more on screen for dense dashboards.',
};
const TOP_NAV_ALIGN_OPTIONS: { value: TopNavAlign; label: string }[] = [
{ value: 'left', label: 'Left' },
{ 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 CHIP_COLOR_OPTIONS: { value: LogChipColorMode; label: string }[] = [
{ value: 'unified', label: 'Unified' },
{ value: 'per-service', label: 'Per service' },
];
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 [chipColorMode, setChipColorMode] = useLogChipColorMode();
const [topNavLabels, setTopNavLabels] = useTopNavLabels();
const [topNavAlign, setTopNavAlign] = useTopNavAlign();
const {
theme, accent, borderBoost, glow, contrast, uiFont, monoFont, typeScale,
headingStyle, chartStyle, reducedEffects, reducedMotion, readability,
setTheme, setAccent, setBorderBoost, setGlow, setContrast, setUiFont, setMonoFont, setTypeScale,
setVisualStyle, setHeadingStyle, setChartStyle, setReducedEffects, setReducedMotion, 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">
{!reducedMotion ? (
<SettingsCallout
tone="warn"
icon={<Info className="h-4 w-4" strokeWidth={1.5} />}
title="Constrained graphics"
subtitle="If the active Sencho tab feels heavy on integrated graphics or similar devices, turn on Reduced motion. Reduced effects and Calm alone may not be enough for that idle cost."
/>
) : null}
<SettingsField
label="Reduced motion"
helper="Minimizes interface animations and transitions (dialogs, menus, expand and collapse). Calm turns this on by default; choosing Signature turns it off. You can still toggle it independently afterward. First control to try for high idle GPU use on constrained graphics. Toasts are unaffected."
>
<TogglePill
checked={reducedMotion}
onChange={setReducedMotion}
aria-label="Reduced motion"
/>
</SettingsField>
<SettingsField
label="Reduced effects"
helper="Flattens card bevels, the accent glow, and chart gradients, and turns off glass blur with solid chrome fills. Also stops the decorative masthead rail animations even if Reduced motion is off. Optional secondary material simplification; not a substitute for Reduced motion on reported idle GPU cost."
>
<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 />
</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="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)}
disabled={readability}
aria-label="Border brightness"
/>
<span className="w-12 shrink-0 text-right font-mono text-xs tabular-nums text-stat-subtitle">
{fmtSigned(readability ? 0.03 : borderBoost)}
</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, navigation, and buttons. Heading style follows your Visual style choice."
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"
helper={DENSITY_DESCRIPTIONS[density]}
>
<Combobox
options={DENSITY_OPTIONS}
value={density}
onValueChange={(v) => {
if (v === 'comfortable' || v === 'compact') setDensity(v);
}}
placeholder="Select density"
/>
</SettingsField>
<SettingsField
label="Top navigation labels"
helper="Show text labels beside top navigation icons. Turn off for a more compact navigation bar."
>
<TogglePill checked={topNavLabels} onChange={setTopNavLabels} />
</SettingsField>
{!topNavLabels && (
<SettingsField
label="Top navigation alignment"
helper="Place the icon-only navigation against the left edge or centered in the bar."
>
<SegmentedControl
value={topNavAlign}
options={TOP_NAV_ALIGN_OPTIONS}
onChange={setTopNavAlign}
ariaLabel="Top navigation alignment"
/>
</SettingsField>
)}
<SettingsField
label="Log chip color"
helper="Unified uses the accent color for all service chips. Per-service assigns each service a stable label color for faster visual scanning."
>
<SegmentedControl
value={chipColorMode}
options={CHIP_COLOR_OPTIONS}
onChange={setChipColorMode}
ariaLabel="Log chip color mode"
/>
</SettingsField>
</SettingsSection>
<p className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle/70">
saved to this browser only · every device remembers its own choice
</p>
</div>
);
}