src/components/appearance-control.tsx
Linked repository file from this build.
"use client";
import { useReviewNavigation } from "@/features/reviews/provider";
import { fieldClass } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { useId, useLayoutEffect, useRef, useState } from "react";
import { Eye, Monitor, Moon, Palette, Sun, type LucideIcon } from "lucide-react";
import {
animationDurations,
animationDurationMs,
FONT_SIZE_MAX,
FONT_SIZE_MIN,
FONT_SIZE_STEP,
snapFontSize,
layoutMeta,
paletteGroups,
paletteMeta,
themes,
visionMeta,
visions,
type LayoutId,
type Theme,
} from "@/lib/appearance";
import { useAppearance } from "./appearance-provider";
import { HeaderPopover } from "./header-popover";
export type { Appearance } from "@/lib/appearance";
const themeMeta: Record<Theme, { name: string; icon: LucideIcon }> = {
light: { name: "Light", icon: Sun },
dark: { name: "Dark", icon: Moon },
system: { name: "System", icon: Monitor },
};
/** Wireframe glyphs for each layout, drawn in the current text color. */
function LayoutGlyph({ layout }: { layout: LayoutId }) {
const frame = "fill-none stroke-current";
const solid = "fill-current";
return (
<svg viewBox="0 0 40 28" aria-hidden className="h-7 w-10 shrink-0">
<rect x="0.5" y="0.5" width="39" height="27" rx="2.5" className={frame} strokeWidth="1" />
{layout === "classic" && (
<>
<rect x="3" y="3" width="34" height="4" rx="1" className={solid} opacity="0.85" />
<rect x="3" y="10" width="34" height="15" rx="1" className={solid} opacity="0.18" />
</>
)}
{layout === "workbench" && (
<>
<rect x="3" y="3" width="10" height="22" rx="1" className={solid} opacity="0.85" />
<rect x="16" y="3" width="21" height="17" rx="1" className={solid} opacity="0.18" />
<rect x="16" y="22" width="21" height="3" rx="1" className={solid} opacity="0.5" />
</>
)}
{layout === "desk" && (
<>
<rect x="3" y="3" width="34" height="3" rx="1" className={solid} opacity="0.85" />
<rect x="3" y="8" width="11" height="17" rx="1" className={solid} opacity="0.5" />
<rect x="17" y="8" width="20" height="17" rx="1" className={solid} opacity="0.18" />
</>
)}
</svg>
);
}
/** Split preview: light tones on the left, dark tones on the right. */
function PaletteSwatch({ light, dark }: { light: string[]; dark: string[] }) {
return (
<span
aria-hidden
className="flex h-9 w-14 shrink-0 overflow-hidden rounded-md border shadow-xs"
>
{[light, dark].map(([background, foreground, primary, accent], index) => (
<span
key={index}
className="flex flex-1 flex-col justify-between p-1"
style={{ background }}
>
<span className="h-1 w-3/4 rounded-full" style={{ background: foreground }} />
<span className="flex gap-0.5">
<span className="size-2 rounded-full" style={{ background: primary }} />
<span className="size-2 rounded-full" style={{ background: accent }} />
</span>
</span>
))}
</span>
);
}
// Anchor the visually hidden radio inside its label. Otherwise focusing it can scroll
// Clerk's outer overflow-hidden container and move the entire settings page out of view.
const optionClass =
"relative flex cursor-pointer items-center gap-3 rounded-lg border p-2 text-left transition-colors hover:bg-accent/60 has-[:checked]:border-primary has-[:checked]:bg-primary/8 has-[:checked]:ring-1 has-[:checked]:ring-primary has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-ring";
/** Mode, palette and layout choices. Used in the header popover and in Clerk user settings. */
export function AppearanceSettings({ page = false }: { page?: boolean }) {
const { appearance, change, layoutChanging } = useAppearance();
const { navigationTarget } = useReviewNavigation();
const layoutPending = layoutChanging || !!navigationTarget;
const id = useId();
const root = useRef<HTMLDivElement>(null);
const [draftFontSize, setDraftFontSize] = useState<number | null>(null);
const fontSize = draftFontSize ?? appearance.fontSize;
const commitFontSize = (value: string) => {
const next = snapFontSize(Number(value));
if (next !== appearance.fontSize) change({ fontSize: next });
setDraftFontSize(null);
};
useLayoutEffect(() => {
// Layouts and palettes can change font size and the number of option columns.
// Keep the focused choice visible after that reflow, including in Clerk's modal.
const focused = document.activeElement;
if (focused instanceof HTMLElement && root.current?.contains(focused)) {
(focused.closest("label") ?? focused).scrollIntoView({
block: "nearest",
inline: "nearest",
});
}
}, [appearance.layout, appearance.palette, appearance.fontSize]);
return (
<div
ref={root}
className={`appearance-settings space-y-5 ${page ? "appearance-settings-page" : ""}`}
>
<div>
<h2 className="font-semibold">Appearance</h2>
<p className="mt-0.5 text-xs text-muted-foreground">
Choices apply everywhere and save to this device and your account.
</p>
</div>
<fieldset>
<legend className="mb-2 text-sm font-medium">Font size</legend>
<div className="flex items-center gap-3">
<span className="w-6 text-xs tabular-nums text-muted-foreground" aria-hidden>
{FONT_SIZE_MIN}
</span>
<input
id={`${id}-font-size`}
type="range"
min={FONT_SIZE_MIN}
max={FONT_SIZE_MAX}
step={FONT_SIZE_STEP}
value={fontSize}
aria-label="Font size"
aria-valuetext={`${fontSize} pixels`}
className="font-size-slider min-w-0 flex-1"
onChange={(event) => setDraftFontSize(snapFontSize(Number(event.target.value)))}
onPointerDown={(event) => event.currentTarget.setPointerCapture(event.pointerId)}
onPointerUp={(event) => commitFontSize(event.currentTarget.value)}
onPointerCancel={() => setDraftFontSize(null)}
onKeyUp={(event) => commitFontSize(event.currentTarget.value)}
onBlur={(event) => commitFontSize(event.currentTarget.value)}
/>
<span className="w-6 text-xs tabular-nums text-muted-foreground" aria-hidden>
{FONT_SIZE_MAX}
</span>
<output className="w-8 text-right text-sm tabular-nums" htmlFor={`${id}-font-size`}>
{fontSize}
</output>
</div>
</fieldset>
<fieldset disabled={layoutPending} aria-busy={layoutPending} className="disabled:opacity-60">
<legend className="mb-2 text-xs font-semibold tracking-wide text-muted-foreground uppercase">
{layoutPending ? "Changing layout…" : "Layout"}
</legend>
<div role="radiogroup" aria-label="Layout" className="grid gap-1.5">
{(["classic", "workbench", "desk"] as const).map((layout) => {
const meta = layoutMeta[layout];
return (
<label key={layout} className={optionClass}>
<input
type="radio"
name={`${id}-layout`}
value={layout}
checked={appearance.layout === layout}
onChange={() => change({ layout })}
className="sr-only"
/>
<LayoutGlyph layout={layout} />
<span className="min-w-0">
<span className="block text-sm font-medium">{meta.name}</span>
<span className="block text-xs leading-snug text-muted-foreground">
{meta.description}
</span>
</span>
</label>
);
})}
</div>
</fieldset>
{appearance.layout !== "classic" && (
<fieldset>
<legend className="mb-2 text-sm font-medium">Sidebar when closed</legend>
<label className="flex cursor-pointer items-center justify-between gap-3 rounded-md border p-3 text-sm">
<span>
<span className="block">Show icons when closed</span>
<span className="block text-xs text-muted-foreground">
Off hides the sidebar and queue rails.
</span>
</span>
<Switch
aria-label="Show icons when closed"
checked={appearance.sidebar === "icons"}
onCheckedChange={(checked) => change({ sidebar: checked ? "icons" : "hidden" })}
/>
</label>
</fieldset>
)}
<fieldset>
<legend className="mb-2 text-sm font-medium">Animation duration</legend>
<div
role="radiogroup"
aria-label="Animation duration"
className="segmented grid grid-cols-4 gap-1 p-1"
>
{animationDurations.map((duration) => (
<label
key={duration}
className="relative cursor-pointer rounded-md px-1 py-2 text-center text-xs has-[:checked]:bg-card has-[:checked]:font-medium has-[:checked]:shadow-xs has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-ring"
>
<input
type="radio"
name={`${id}-animation-duration`}
value={duration}
checked={appearance.animationDuration === duration}
onChange={() => change({ animationDuration: duration })}
className="sr-only"
/>
<span className="block capitalize">{duration}</span>
<span className="mt-0.5 block text-[10px] text-muted-foreground">
{duration === "off" ? "No motion" : `${animationDurationMs[duration]}ms`}
</span>
</label>
))}
</div>
<p className="mt-2 text-xs text-muted-foreground">
Applies to pages and controls. Your device’s reduced-motion setting takes priority.
</p>
</fieldset>
<fieldset>
<legend className="mb-2 text-xs font-semibold tracking-wide text-muted-foreground uppercase">
Mode
</legend>
<div
role="radiogroup"
aria-label="Mode"
className="grid grid-cols-3 gap-1 rounded-lg border bg-muted/60 p-1"
>
{themes.map((theme) => {
const meta = themeMeta[theme];
return (
<label
key={theme}
className="relative flex cursor-pointer items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-sm text-muted-foreground transition-colors has-[:checked]:bg-card has-[:checked]:font-medium has-[:checked]:text-foreground has-[:checked]:shadow-xs has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-ring"
>
<input
type="radio"
name={`${id}-theme`}
value={theme}
checked={appearance.theme === theme}
onChange={() => change({ theme })}
className="sr-only"
/>
<meta.icon className="size-4" aria-hidden />
{meta.name}
</label>
);
})}
</div>
</fieldset>
<fieldset>
<legend className="mb-2 text-xs font-semibold tracking-wide text-muted-foreground uppercase">
Theme
</legend>
<div role="radiogroup" aria-label="Theme" className="grid gap-3">
{paletteGroups.map((group) => (
<div key={group.name} role="group" aria-label={group.name} className="grid gap-1.5">
<p className="px-1 text-[0.7rem] font-medium text-muted-foreground">{group.name}</p>
{group.palettes.map((palette) => {
const meta = paletteMeta[palette];
return (
<label key={palette} className={optionClass}>
<input
type="radio"
name={`${id}-palette`}
value={palette}
checked={appearance.palette === palette}
onChange={() => change({ palette })}
className="sr-only"
/>
<PaletteSwatch light={meta.light} dark={meta.dark} />
<span className="min-w-0">
<span className="block text-sm font-medium">{meta.name}</span>
<span className="block text-xs leading-snug text-muted-foreground">
{meta.description}
</span>
</span>
</label>
);
})}
</div>
))}
</div>
</fieldset>
<fieldset>
<legend className="mb-2 flex items-center gap-1.5 text-xs font-semibold tracking-wide text-muted-foreground uppercase">
<Eye className="size-3.5" aria-hidden />
Accessibility
</legend>
<div role="radiogroup" aria-label="Accessibility" className="grid gap-1.5">
{visions.map((vision) => {
const meta = visionMeta[vision];
return (
<label key={vision} className={optionClass}>
<input
type="radio"
name={`${id}-vision`}
value={vision}
checked={appearance.vision === vision}
onChange={() => change({ vision })}
className="sr-only"
/>
<span aria-hidden className="flex shrink-0 gap-1">
<span
className={`size-3 rounded-full ${vision === "rg-safe" ? "bg-info" : "bg-success"}`}
/>
<span className="size-3 rounded-full bg-warning" />
<span className="size-3 rounded-full bg-destructive" />
</span>
<span className="min-w-0">
<span className="block text-sm font-medium">{meta.name}</span>
<span className="block text-xs leading-snug text-muted-foreground">
{meta.description}
</span>
</span>
</label>
);
})}
</div>
</fieldset>
<fieldset>
<legend className="mb-2 text-sm font-medium">Font</legend>
<select
aria-label="Font"
className={fieldClass}
value={appearance.font}
onChange={(event) => change({ font: event.target.value as typeof appearance.font })}
>
{(["system", "sans", "serif", "mono"] as const).map((font) => (
<option key={font} value={font}>
{font === "system"
? "System"
: font === "sans"
? "Sans serif"
: font === "serif"
? "Serif"
: "Monospace"}
</option>
))}
</select>
</fieldset>
</div>
);
}
/** Header popover entry point. */
export function AppearanceControl() {
return (
<HeaderPopover label="Appearance" trigger={<Palette className="size-4" />}>
<AppearanceSettings />
</HeaderPopover>
);
}