f087551454
Adds 7 reusable primitives in src/lib/components/ui/ and migrates ~70 hand-rolled call sites across forms, admin panels, and routes. Tokens unchanged — same Cozy Home palette, just consistently applied. Primitives - Switch: pill toggle, role=switch, terracotta track, cubic-bezier knob - Button: 5 variants × 4 sizes, press-squash, loading spinner, buttonClass() helper for <a> link-as-CTA cases - Checkbox: rounded square with animated check-draw + indeterminate - Select: native <select> with Cozy chevron + matched radius - Slider: gradient track, terracotta-bordered knob, aria-valuetext - Input + Field: documented in CLAUDE.md for future use - 9 buttonClass unit tests Migrations - 23 <input type=checkbox> → <Switch> (boolean settings) - 5 multi-select checkboxes → <Checkbox> (DiscoveryPanel, sys-stats metrics) - ~28 <select> → <Select> - 17 <input type=range> → <Slider> (ThemeCustomizer's hue picker kept custom) - ~25 hand-rolled buttons → <Button> / buttonClass() Surface polish - Admin section wrappers: rounded-lg → rounded-[1.4rem] + shadow-soft (resolves the Phase-5 tradeoff from the Cozy migration memo) - BoardPropertiesPanel: live theme preview swatch showing computed hsl() on a sample button; hue/sat use Slider; bg/cardSize use Select - AppHealthBadge: role=status + aria-live=polite; .status-degraded (slow amber breathing) and .status-offline (single attention flash) now applied - AppForm collapse triggers: rotating chevron + aria-expanded - Empty states for /boards and /apps: inline SVGs using --room-* tokens (peach/sky/sage/butter) instead of generic Lucide icons - Login Remember Me: showcase Switch (first-impression surface) Motion (src/app.css) - New cozy-rise / cozy-rise-stagger for staggered grid reveals (/boards, /apps) - New cozy-expand for accordion sections (healthcheck, integration, wallpaper) - All motion respects prefers-reduced-motion CLAUDE.md - New project guide with a mandatory Frontend reuse table — every primitive documented with "never use raw <input type=checkbox>/<select>/<range>" and "do not repeat rounded-xl bg-primary px-4 py-2 ..." rules Verification - npm run check: 0 errors, 0 warnings, 5831 files - npm test: 301 passing - npm run lint: 0 errors (19 pre-existing warnings unchanged) - npm run build: ✔ done Branch is feat/cozy-polish, ready to PR against master.
87 lines
2.1 KiB
Svelte
87 lines
2.1 KiB
Svelte
<script lang="ts">
|
|
import { cn } from '$lib/utils/cn.js';
|
|
|
|
interface Props {
|
|
checked?: boolean | undefined;
|
|
onchange?: (checked: boolean) => void;
|
|
disabled?: boolean;
|
|
id?: string;
|
|
name?: string;
|
|
label?: string;
|
|
ariaLabel?: string;
|
|
ariaLabelledby?: string;
|
|
size?: 'sm' | 'md';
|
|
class?: string;
|
|
}
|
|
|
|
let {
|
|
checked = $bindable(false),
|
|
onchange,
|
|
disabled = false,
|
|
id,
|
|
name,
|
|
label,
|
|
ariaLabel,
|
|
ariaLabelledby,
|
|
size = 'md',
|
|
class: className = ''
|
|
}: Props = $props();
|
|
|
|
function toggle() {
|
|
if (disabled) return;
|
|
checked = !checked;
|
|
onchange?.(checked);
|
|
}
|
|
|
|
function onKeydown(e: KeyboardEvent) {
|
|
if (e.key === ' ' || e.key === 'Enter') {
|
|
e.preventDefault();
|
|
toggle();
|
|
}
|
|
}
|
|
|
|
const trackBase =
|
|
'relative inline-flex shrink-0 cursor-pointer items-center rounded-full transition-colors duration-200 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50';
|
|
const trackSize = $derived(size === 'sm' ? 'h-5 w-9' : 'h-6 w-11');
|
|
const knobSize = $derived(size === 'sm' ? 'h-4 w-4' : 'h-5 w-5');
|
|
const knobTranslate = $derived(
|
|
size === 'sm'
|
|
? checked
|
|
? 'translate-x-4'
|
|
: 'translate-x-0.5'
|
|
: checked
|
|
? 'translate-x-[1.375rem]'
|
|
: 'translate-x-0.5'
|
|
);
|
|
</script>
|
|
|
|
<button
|
|
type="button"
|
|
role="switch"
|
|
aria-checked={checked}
|
|
aria-label={ariaLabel ?? label}
|
|
aria-labelledby={ariaLabelledby}
|
|
{id}
|
|
{disabled}
|
|
onclick={toggle}
|
|
onkeydown={onKeydown}
|
|
class={cn(
|
|
trackBase,
|
|
trackSize,
|
|
checked ? 'bg-primary shadow-[inset_0_1px_2px_rgba(0,0,0,0.18)]' : 'bg-muted-foreground/35',
|
|
className
|
|
)}
|
|
>
|
|
<span
|
|
aria-hidden="true"
|
|
class={cn(
|
|
'pointer-events-none inline-block transform rounded-full bg-white shadow-[0_2px_4px_rgba(80,50,20,0.35),0_1px_2px_rgba(80,50,20,0.18)] ring-0 transition-transform duration-200 ease-[cubic-bezier(0.34,1.56,0.64,1)]',
|
|
knobSize,
|
|
knobTranslate
|
|
)}
|
|
></span>
|
|
{#if name !== undefined}
|
|
<input type="hidden" {name} value={checked ? 'on' : ''} />
|
|
{/if}
|
|
</button>
|