Files
web-app-launcher/src/lib/components/app/IntegrationConfigFields.svelte
T
alexei.dolgolyov f087551454 feat(ui): cozy polish — primitives, motion, empty states
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.
2026-05-28 14:39:53 +03:00

58 lines
2.0 KiB
Svelte

<script lang="ts">
import type { IntegrationFieldDescriptor } from '$lib/server/integrations/types.js';
import Switch from '$lib/components/ui/Switch.svelte';
interface Props {
fields: IntegrationFieldDescriptor[];
values: Record<string, unknown>;
onchange: (name: string, value: unknown) => void;
idPrefix?: string;
}
let { fields, values, onchange, idPrefix = 'int' }: Props = $props();
const inputClass = 'w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30';
</script>
<div class="space-y-3">
{#each fields as field (field.name)}
<div>
<label for="{idPrefix}-{field.name}" class="mb-1 block text-sm font-medium text-card-foreground">
{field.label}
{#if field.required}
<span class="text-destructive">*</span>
{/if}
</label>
{#if field.type === 'boolean'}
<div class="flex items-center gap-3">
<Switch
id="{idPrefix}-{field.name}"
checked={!!values[field.name]}
onchange={(checked) => onchange(field.name, checked)}
ariaLabel={field.label}
/>
<span class="text-sm text-muted-foreground">{field.description ?? ''}</span>
</div>
{:else if field.type === 'number'}
<input
id="{idPrefix}-{field.name}"
type="number"
value={values[field.name] ?? ''}
oninput={(e) => onchange(field.name, parseInt(e.currentTarget.value) || 0)}
class={inputClass}
placeholder={field.description ?? ''}
/>
{:else}
<input
id="{idPrefix}-{field.name}"
type={field.name.toLowerCase().includes('password') || field.name.toLowerCase().includes('secret') ? 'password' : 'text'}
value={values[field.name] ?? ''}
oninput={(e) => onchange(field.name, e.currentTarget.value)}
class={inputClass}
placeholder={field.description ?? field.label}
/>
{/if}
</div>
{/each}
</div>