Files
web-app-launcher/src/lib/components/app/AppForm.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

447 lines
14 KiB
Svelte

<script lang="ts">
import { t } from 'svelte-i18n';
import { superForm, type SuperValidated } from 'sveltekit-superforms';
import type { z } from 'zod';
import type { createAppSchema } from '$lib/utils/validators.js';
import AppIconPicker from './AppIconPicker.svelte';
import IntegrationConfigFields from './IntegrationConfigFields.svelte';
import AppUrlPreview from './AppUrlPreview.svelte';
import AutocompleteInput from '$lib/components/ui/AutocompleteInput.svelte';
import TagsInput from '$lib/components/ui/TagsInput.svelte';
import IconGrid from '$lib/components/ui/IconGrid.svelte';
import type { IconGridItem } from '$lib/components/ui/IconGrid.svelte';
import Switch from '$lib/components/ui/Switch.svelte';
import Button from '$lib/components/ui/Button.svelte';
type AppSchema = z.infer<typeof createAppSchema>;
interface Props {
form: SuperValidated<AppSchema>;
action?: string;
mode?: 'create' | 'edit';
}
let { form: formData, action = '?/create', mode = 'create' }: Props = $props();
const { form, errors, enhance, submitting } = superForm(formData, {
resetForm: mode === 'create'
});
let showAdvanced = $state(false);
let showIntegration = $state(false);
let categorySuggestions = $state<string[]>([]);
let tagSuggestions = $state<string[]>([]);
// Fetch autocomplete suggestions
$effect(() => {
fetch('/api/apps/suggestions')
.then((r) => r.json())
.then((json) => {
if (json.success) {
categorySuggestions = json.data?.categories ?? [];
tagSuggestions = json.data?.tags ?? [];
}
})
.catch(() => {});
});
interface IntegrationField {
name: string;
type: 'string' | 'number' | 'boolean';
required: boolean;
label: string;
description?: string;
}
let availableIntegrations = $state<
Array<{
id: string;
name: string;
icon: string;
authConfigFields: IntegrationField[];
extraConfigFields: IntegrationField[];
}>
>([]);
let integrationConfig = $state<Record<string, unknown>>({});
let testingConnection = $state(false);
let testResult = $state<{ success: boolean; message: string } | null>(null);
$effect(() => {
fetch('/api/integrations')
.then((r) => r.json())
.then((json) => {
if (json.success) availableIntegrations = json.data ?? [];
})
.catch(() => {});
});
const selectedIntegration = $derived(
availableIntegrations.find((i) => i.id === ($form.integrationType ?? ''))
);
async function handleTestConnection() {
if (!$form.integrationType || !$form.url) return;
testingConnection = true;
testResult = null;
try {
const res = await fetch('/api/integrations/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
integrationType: $form.integrationType,
appUrl: $form.url,
config: integrationConfig
})
});
const json = await res.json();
testResult = json.data ?? { success: false, message: json.error ?? 'Unknown error' };
} catch {
testResult = { success: false, message: 'Network error' };
} finally {
testingConnection = false;
}
}
$effect(() => {
if ($form.integrationType && Object.keys(integrationConfig).length > 0) {
$form.integrationConfig = JSON.stringify(integrationConfig);
}
});
const healthcheckMethodItems: IconGridItem[] = [
{ value: 'GET', icon: '🔍', label: 'GET', desc: 'Full response' },
{ value: 'HEAD', icon: '📋', label: 'HEAD', desc: 'Headers only' }
];
</script>
<form method="POST" {action} use:enhance class="space-y-4">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label for="name" class="mb-1 block text-sm font-medium text-card-foreground">
{$t('app.name')} <span class="text-destructive">{$t('common.required')}</span>
</label>
<input
id="name"
name="name"
type="text"
bind:value={$form.name}
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
placeholder={$t('app.name_placeholder')}
/>
{#if $errors.name}
<p class="mt-1 text-sm text-destructive">{$errors.name[0]}</p>
{/if}
</div>
<div>
<label for="url" class="mb-1 block text-sm font-medium text-card-foreground">
{$t('app.url')} <span class="text-destructive">{$t('common.required')}</span>
</label>
<input
id="url"
name="url"
type="url"
bind:value={$form.url}
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
placeholder={$t('app.url_placeholder')}
/>
{#if $errors.url}
<p class="mt-1 text-sm text-destructive">{$errors.url[0]}</p>
{/if}
</div>
</div>
<!-- URL Preview / Test Connection -->
<AppUrlPreview
url={$form.url ?? ''}
currentIcon={$form.icon ?? ''}
currentName={$form.name ?? ''}
onApplyFavicon={(favicon) => {
$form.icon = favicon;
$form.iconType = 'url';
}}
onApplyTitle={(title) => {
$form.name = title;
}}
/>
<div>
<label for="description" class="mb-1 block text-sm font-medium text-card-foreground">
{$t('app.description')}
</label>
<input
id="description"
name="description"
type="text"
bind:value={$form.description}
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
placeholder={$t('app.description_placeholder')}
/>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label for="category" class="mb-1 block text-sm font-medium text-card-foreground">
{$t('app.category')}
</label>
<AutocompleteInput
id="category"
name="category"
bind:value={$form.category}
suggestions={categorySuggestions}
placeholder={$t('app.category_placeholder')}
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
/>
</div>
<div>
<label for="tags" class="mb-1 block text-sm font-medium text-card-foreground">
{$t('app.tags')}
</label>
<TagsInput
id="tags"
name="tags"
bind:value={$form.tags}
suggestions={tagSuggestions}
placeholder={$t('app.tags_placeholder')}
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
/>
</div>
</div>
<AppIconPicker
iconType={$form.iconType ?? 'lucide'}
iconValue={$form.icon ?? ''}
onchange={(type, value) => {
$form.iconType = type as typeof $form.iconType;
$form.icon = value;
}}
/>
<input type="hidden" name="icon" value={$form.icon ?? ''} />
<input type="hidden" name="iconType" value={$form.iconType ?? 'lucide'} />
<button
type="button"
onclick={() => (showAdvanced = !showAdvanced)}
aria-expanded={showAdvanced}
class="group inline-flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
>
<svg
class="h-3.5 w-3.5 transition-transform duration-200 {showAdvanced ? 'rotate-90' : ''}"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<polyline points="6 4 10 8 6 12" />
</svg>
{showAdvanced ? $t('app.healthcheck_hide') : $t('app.healthcheck_show')} {$t('app.healthcheck_toggle')}
</button>
{#if showAdvanced}
<div class="cozy-expand space-y-4 rounded-xl border border-border p-4">
<div class="flex items-center gap-3">
<Switch
id="healthcheckEnabled"
name="healthcheckEnabled"
bind:checked={$form.healthcheckEnabled}
ariaLabelledby="healthcheckEnabledLabel"
/>
<label id="healthcheckEnabledLabel" for="healthcheckEnabled" class="cursor-pointer text-sm text-card-foreground">
{$t('app.healthcheck_enabled')}
</label>
</div>
{#if $form.healthcheckEnabled}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div>
<!-- svelte-ignore a11y_label_has_associated_control -->
<label
class="mb-1 block text-sm font-medium text-card-foreground"
>
{$t('app.healthcheck_method')}
</label>
<IconGrid
items={healthcheckMethodItems}
value={$form.healthcheckMethod ?? 'GET'}
onchange={(v) => ($form.healthcheckMethod = v as 'GET' | 'HEAD')}
name="healthcheckMethod"
columns={2}
/>
</div>
<div>
<label
for="healthcheckExpectedStatus"
class="mb-1 block text-sm font-medium text-card-foreground"
>
{$t('app.healthcheck_expected_status')}
</label>
<input
id="healthcheckExpectedStatus"
name="healthcheckExpectedStatus"
type="number"
bind:value={$form.healthcheckExpectedStatus}
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
min="100"
max="599"
/>
</div>
<div>
<label
for="healthcheckTimeout"
class="mb-1 block text-sm font-medium text-card-foreground"
>
{$t('app.healthcheck_timeout')}
</label>
<input
id="healthcheckTimeout"
name="healthcheckTimeout"
type="number"
bind:value={$form.healthcheckTimeout}
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
min="1000"
max="30000"
step="1000"
/>
</div>
</div>
<div>
<label
for="healthcheckInterval"
class="mb-1 block text-sm font-medium text-card-foreground"
>
{$t('app.healthcheck_interval')}
</label>
<input
id="healthcheckInterval"
name="healthcheckInterval"
type="number"
bind:value={$form.healthcheckInterval}
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
min="30"
max="86400"
/>
</div>
{/if}
</div>
{/if}
<!-- Integration Section -->
<button
type="button"
onclick={() => (showIntegration = !showIntegration)}
aria-expanded={showIntegration}
class="group inline-flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
>
<svg
class="h-3.5 w-3.5 transition-transform duration-200 {showIntegration ? 'rotate-90' : ''}"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<polyline points="6 4 10 8 6 12" />
</svg>
{showIntegration ? 'Hide' : 'Show'} Integration Settings
</button>
{#if showIntegration}
<div class="cozy-expand space-y-4 rounded-xl border border-border p-4">
<div class="flex items-center gap-3">
<Switch
id="integrationEnabled"
name="integrationEnabled"
bind:checked={$form.integrationEnabled}
ariaLabelledby="integrationEnabledLabel"
/>
<label id="integrationEnabledLabel" for="integrationEnabled" class="cursor-pointer text-sm text-card-foreground">
Enable Integration
</label>
</div>
{#if $form.integrationEnabled}
<div>
<label for="integrationType" class="mb-1 block text-sm font-medium text-card-foreground">
Integration Type
</label>
<select
id="integrationType"
name="integrationType"
bind:value={$form.integrationType}
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
>
<option value="">None</option>
{#each availableIntegrations as integration (integration.id)}
<option value={integration.id}>{integration.name}</option>
{/each}
</select>
</div>
{#if selectedIntegration}
<div>
<h4 class="mb-2 text-sm font-medium text-card-foreground">Authentication</h4>
<IntegrationConfigFields
fields={selectedIntegration.authConfigFields}
values={integrationConfig}
onchange={(name, value) => {
integrationConfig = { ...integrationConfig, [name]: value };
}}
idPrefix="int-auth"
/>
</div>
{#if selectedIntegration.extraConfigFields.length > 0}
<div>
<h4 class="mb-2 text-sm font-medium text-card-foreground">Extra Configuration</h4>
<IntegrationConfigFields
fields={selectedIntegration.extraConfigFields}
values={integrationConfig}
onchange={(name, value) => {
integrationConfig = { ...integrationConfig, [name]: value };
}}
idPrefix="int-extra"
/>
</div>
{/if}
<div class="flex items-center gap-3">
<button
type="button"
onclick={handleTestConnection}
disabled={testingConnection}
class="rounded-md border border-border px-3 py-1.5 text-sm text-foreground hover:bg-accent disabled:opacity-50"
>
{testingConnection ? 'Testing...' : 'Test Connection'}
</button>
{#if testResult}
<span class="text-sm {testResult.success ? 'text-status-online-ink' : 'text-destructive'}">
{testResult.message}
</span>
{/if}
</div>
{/if}
{/if}
<input type="hidden" name="integrationConfig" value={$form.integrationConfig ?? ''} />
<input type="hidden" name="integrationType" value={$form.integrationType ?? ''} />
</div>
{/if}
<div class="flex justify-end">
<Button type="submit" size="lg" disabled={$submitting} loading={$submitting}>
{#if $submitting}
{$t('app.saving')}
{:else}
{mode === 'edit' ? $t('app.update') : $t('app.save')}
{/if}
</Button>
</div>
</form>