From f0875514549dda12d77f5239fe76c3051a3a38aa Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Thu, 28 May 2026 14:39:53 +0300 Subject: [PATCH] =?UTF-8?q?feat(ui):=20cozy=20polish=20=E2=80=94=20primiti?= =?UTF-8?q?ves,=20motion,=20empty=20states?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 link-as-CTA cases - Checkbox: rounded square with animated check-draw + indeterminate - Select: native (boolean settings) - 5 multi-select checkboxes → (DiscoveryPanel, sys-stats metrics) - ~28 - 17 (ThemeCustomizer's hue picker kept custom) - ~25 hand-rolled buttons → + @@ -229,6 +286,7 @@ {$t('admin.backup_filename')} + {$t('admin.backup_format')} {$t('admin.backup_size')} {$t('admin.backup_date')} {$t('admin.backup_actions')} @@ -238,6 +296,20 @@ {#each backups as backup (backup.filename)} {backup.filename} + + {#if backup.format === 'tar.gz'} + + {$t('admin.backup_format_full')} + + {:else} + + {$t('admin.backup_format_legacy')} + + {/if} + {formatBytes(backup.size)} {formatDate(backup.createdAt)} @@ -281,6 +353,7 @@ {#if confirmRestore} + {@const target = backups.find((b) => b.filename === confirmRestore)}

@@ -289,6 +362,14 @@

{$t('admin.backup_restore_confirm')}

+ {#if target?.format === 'db'} +

+ {$t('admin.backup_restore_legacy_warning')} +

+ {/if} +

+ {$t('admin.backup_restore_logout_warning')} +

{confirmRestore}

@@ -310,6 +392,40 @@
{/if} + + {#if confirmSchemaMismatch} +
+
+

+ {$t('admin.backup_restore_schema_mismatch_title')} +

+

+ {$t('admin.backup_restore_schema_mismatch_intro')} +

+
{pendingSchemaMismatchMessage}
+
+ + +
+
+
+ {/if} + {#if confirmDelete}
@@ -350,11 +466,10 @@
- @@ -365,16 +480,12 @@ - - +
{#if cronPreset === 'custom'} @@ -402,16 +513,32 @@ class="w-24 rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground" />
+ + +
+
+ {$t('admin.backup_stats_success_count')} + {stats.successCount} + {$t('admin.backup_stats_failure_count')} + {stats.failureCount} + {#if stats.lastSuccessAt} + {$t('admin.backup_stats_last_success')} + {formatDate(stats.lastSuccessAt)} + {/if} + {#if stats.lastFailureAt} + {$t('admin.backup_stats_last_failure')} + {formatDate(stats.lastFailureAt)} + {/if} +
+ {#if stats.lastFailureReason} +

{stats.lastFailureReason}

+ {/if} +
{/if} - +

diff --git a/src/lib/components/admin/DiscoveryPanel.svelte b/src/lib/components/admin/DiscoveryPanel.svelte index e1556e5..e98430d 100644 --- a/src/lib/components/admin/DiscoveryPanel.svelte +++ b/src/lib/components/admin/DiscoveryPanel.svelte @@ -1,6 +1,8 @@ -
+

{$t('admin.discovery_title')}

{$t('admin.discovery_description')}

- +
@@ -169,12 +170,12 @@ - 0} + indeterminate={selected.size > 0 && selected.size < selectableCount} onchange={toggleSelectAll} disabled={selectableCount === 0} - class="h-4 w-4 rounded border-input" + ariaLabel={$t('admin.discovery_select_all') ?? 'Select all'} /> {$t('common.name')} @@ -187,12 +188,11 @@ {#each services as service, i (service.url)} - toggleSelect(i)} disabled={service.alreadyRegistered} - class="h-4 w-4 rounded border-input" + ariaLabel={`Select ${service.name}`} /> {service.name} @@ -227,14 +227,13 @@ {#if selectableCount > 0}
- +
{/if} {/if} diff --git a/src/lib/components/admin/GroupTable.svelte b/src/lib/components/admin/GroupTable.svelte index 8830940..84b7683 100644 --- a/src/lib/components/admin/GroupTable.svelte +++ b/src/lib/components/admin/GroupTable.svelte @@ -1,6 +1,7 @@ -
+

{channel ? 'Edit Channel' : 'Add Notification Channel'}

@@ -271,14 +273,9 @@ {/if} -
- - +
+ +
@@ -290,29 +287,17 @@
- + {#if channel?.id} - + {/if} - +
diff --git a/src/lib/components/onboarding/OnboardingWizard.svelte b/src/lib/components/onboarding/OnboardingWizard.svelte index 594257c..a07fbdd 100644 --- a/src/lib/components/onboarding/OnboardingWizard.svelte +++ b/src/lib/components/onboarding/OnboardingWizard.svelte @@ -1,4 +1,6 @@ -
+

Generate API Token

@@ -31,15 +33,11 @@ - - +
@@ -56,19 +54,8 @@
- - + +
diff --git a/src/lib/components/settings/CustomCssEditor.svelte b/src/lib/components/settings/CustomCssEditor.svelte index 681501d..4739f1c 100644 --- a/src/lib/components/settings/CustomCssEditor.svelte +++ b/src/lib/components/settings/CustomCssEditor.svelte @@ -1,5 +1,6 @@ + + + + diff --git a/src/lib/components/ui/Checkbox.svelte b/src/lib/components/ui/Checkbox.svelte new file mode 100644 index 0000000..7620985 --- /dev/null +++ b/src/lib/components/ui/Checkbox.svelte @@ -0,0 +1,95 @@ + + + + + diff --git a/src/lib/components/ui/Field.svelte b/src/lib/components/ui/Field.svelte new file mode 100644 index 0000000..4d354db --- /dev/null +++ b/src/lib/components/ui/Field.svelte @@ -0,0 +1,39 @@ + + +
+ {#if label} + + {/if} + {@render children()} + {#if error} + + {:else if hint} +

{hint}

+ {/if} +
diff --git a/src/lib/components/ui/Input.svelte b/src/lib/components/ui/Input.svelte new file mode 100644 index 0000000..bea655a --- /dev/null +++ b/src/lib/components/ui/Input.svelte @@ -0,0 +1,31 @@ + + + + + diff --git a/src/lib/components/ui/Select.svelte b/src/lib/components/ui/Select.svelte new file mode 100644 index 0000000..a40283c --- /dev/null +++ b/src/lib/components/ui/Select.svelte @@ -0,0 +1,37 @@ + + + + +
+ +
diff --git a/src/lib/components/ui/Slider.svelte b/src/lib/components/ui/Slider.svelte new file mode 100644 index 0000000..c0f1a02 --- /dev/null +++ b/src/lib/components/ui/Slider.svelte @@ -0,0 +1,155 @@ + + +
+ + {#if showValue} + {displayValue} + {/if} +
+ + diff --git a/src/lib/components/ui/Switch.svelte b/src/lib/components/ui/Switch.svelte new file mode 100644 index 0000000..5d85af2 --- /dev/null +++ b/src/lib/components/ui/Switch.svelte @@ -0,0 +1,86 @@ + + + diff --git a/src/lib/components/ui/__tests__/buttonClass.test.ts b/src/lib/components/ui/__tests__/buttonClass.test.ts new file mode 100644 index 0000000..8309af3 --- /dev/null +++ b/src/lib/components/ui/__tests__/buttonClass.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest'; +import { buttonClass } from '../Button.svelte'; + +describe('buttonClass', () => { + it('returns primary md by default', () => { + const cls = buttonClass(); + expect(cls).toContain('bg-primary'); + expect(cls).toContain('px-4'); + expect(cls).toContain('py-2'); + expect(cls).toContain('text-sm'); + }); + + it('applies secondary variant', () => { + const cls = buttonClass({ variant: 'secondary' }); + expect(cls).toContain('bg-secondary'); + expect(cls).not.toContain('bg-primary '); + }); + + it('applies destructive variant', () => { + const cls = buttonClass({ variant: 'destructive' }); + expect(cls).toContain('bg-destructive'); + }); + + it('applies sm size', () => { + const cls = buttonClass({ size: 'sm' }); + expect(cls).toContain('px-3'); + expect(cls).toContain('text-xs'); + }); + + it('applies lg size', () => { + const cls = buttonClass({ size: 'lg' }); + expect(cls).toContain('px-6'); + }); + + it('adds fullWidth', () => { + const cls = buttonClass({ fullWidth: true }); + expect(cls).toContain('w-full'); + }); + + it('merges extra class', () => { + const cls = buttonClass({ extra: 'custom-class' }); + expect(cls).toContain('custom-class'); + }); + + it('always includes focus-visible ring', () => { + const cls = buttonClass(); + expect(cls).toContain('focus-visible:ring-2'); + expect(cls).toContain('focus-visible:ring-primary/30'); + }); + + it('always includes disabled state', () => { + const cls = buttonClass(); + expect(cls).toContain('disabled:cursor-not-allowed'); + expect(cls).toContain('disabled:opacity-50'); + }); +}); diff --git a/src/lib/components/widget/WidgetConfigPanel.svelte b/src/lib/components/widget/WidgetConfigPanel.svelte index ec80b61..f2ae332 100644 --- a/src/lib/components/widget/WidgetConfigPanel.svelte +++ b/src/lib/components/widget/WidgetConfigPanel.svelte @@ -4,6 +4,9 @@ import { tick } from 'svelte'; import DynamicIcon from '$lib/components/ui/DynamicIcon.svelte'; import MultiEntityPicker from '$lib/components/ui/MultiEntityPicker.svelte'; + import Switch from '$lib/components/ui/Switch.svelte'; + import Slider from '$lib/components/ui/Slider.svelte'; + import Select from '$lib/components/ui/Select.svelte'; interface AppInfo { id: string; @@ -269,13 +272,12 @@
- +
{:else if widgetType === 'embed'} @@ -285,9 +287,8 @@
- +
{$t('widget.height') ?? 'Height'} ({embedHeight}px)
+
- +
-
- +
- +
Refresh ({sysStatsRefreshInterval}s)
+
{:else if widgetType === 'rss'} @@ -373,12 +371,11 @@
- +
Max Items ({rssMaxItems})
+
-
- +
{#if metricSource === 'static'}
@@ -461,9 +456,8 @@
- +
Refresh ({metricRefreshInterval}s)
+
@@ -482,8 +476,8 @@ {/each} -
- +
- +
Refresh ({cameraRefreshInterval}s)
+
- +
{:else if widgetType === 'integration'}
- +
- +
Refresh ({integrationRefreshInterval}s)
+
{/if} diff --git a/src/lib/components/widget/WidgetCreationForm.svelte b/src/lib/components/widget/WidgetCreationForm.svelte index a87c8e6..a0ae54e 100644 --- a/src/lib/components/widget/WidgetCreationForm.svelte +++ b/src/lib/components/widget/WidgetCreationForm.svelte @@ -9,6 +9,10 @@ import EntityPicker from '$lib/components/ui/EntityPicker.svelte'; import type { IconGridItem } from '$lib/components/ui/IconGrid.svelte'; import type { EntityPickerItem } from '$lib/components/ui/EntityPicker.svelte'; + import Switch from '$lib/components/ui/Switch.svelte'; + import Slider from '$lib/components/ui/Slider.svelte'; + import Select from '$lib/components/ui/Select.svelte'; + import Checkbox from '$lib/components/ui/Checkbox.svelte'; interface Props { sectionId: string; @@ -507,12 +511,11 @@ Select Apps
{#each apps as app (app.id)} - @@ -549,12 +552,8 @@

Leave empty for local time

-
@@ -599,26 +598,24 @@
- +
Metrics
{#each ['cpu', 'ram', 'disk', 'swap', 'network'] as metric (metric)} - @@ -629,14 +626,13 @@ -
@@ -658,23 +654,18 @@ -
-
@@ -733,14 +724,13 @@ - @@ -855,14 +845,13 @@ - @@ -916,12 +905,8 @@
-
@@ -942,43 +927,40 @@
- +
-
- +
@@ -990,44 +972,41 @@ {#if integrationApps.length === 0}

No apps with integrations configured. Add an integration to an app first.

{:else} - + {/if} {#if integrationAppId && integrationEndpoints.length > 0}
- +
-
{/if} diff --git a/src/routes/+error.svelte b/src/routes/+error.svelte index e337893..5d9b150 100644 --- a/src/routes/+error.svelte +++ b/src/routes/+error.svelte @@ -3,6 +3,7 @@ import { t } from 'svelte-i18n'; import AmbientBackground from '$lib/components/background/AmbientBackground.svelte'; import ErrorState from '$lib/components/ui/ErrorState.svelte'; + import { buttonClass } from '$lib/components/ui/Button.svelte'; const status = $derived($page.status); const message = $derived($page.error?.message ?? ''); @@ -46,17 +47,9 @@ {/if} {/snippet} {#snippet actions()} -
- {$t('error.back_to_dashboard')} - + {$t('error.back_to_dashboard')} {#if status === 401} - + {$t('auth.login_submit')} {/if} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index fac3508..9698a72 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,6 +1,7 @@ @@ -21,10 +22,7 @@ {#if !data.isGuest && data.user?.role === 'admin'} - + {$t('board.new')} {/if} @@ -32,22 +30,24 @@ {#if data.boards.length === 0}
-
- - - - - -
+

{$t('board.no_boards')}

{#if data.isGuest}

{$t('board.sign_in_more')}

@@ -55,11 +55,8 @@

Create your first board to organize apps into a custom dashboard.

- - + + @@ -69,8 +66,10 @@
{:else}
- {#each data.boards as board (board.id)} - + {#each data.boards as board, i (board.id)} +
+ +
{/each}
{/if} diff --git a/src/routes/boards/new/+page.svelte b/src/routes/boards/new/+page.svelte index 3ced70f..8d08339 100644 --- a/src/routes/boards/new/+page.svelte +++ b/src/routes/boards/new/+page.svelte @@ -3,6 +3,8 @@ import type { PageData } from './$types.js'; import { superForm } from 'sveltekit-superforms'; import TemplatePicker from '$lib/components/board/TemplatePicker.svelte'; + import Switch from '$lib/components/ui/Switch.svelte'; + import Button, { buttonClass } from '$lib/components/ui/Button.svelte'; let { data }: { data: PageData } = $props(); const { form, errors, enhance, submitting } = superForm(data.form); @@ -71,29 +73,25 @@ -
diff --git a/src/routes/forgot-password/+page.svelte b/src/routes/forgot-password/+page.svelte index 093024b..d4e1f3a 100644 --- a/src/routes/forgot-password/+page.svelte +++ b/src/routes/forgot-password/+page.svelte @@ -3,6 +3,7 @@ import { enhance } from '$app/forms'; import { KeyRound } from 'lucide-svelte'; import AmbientBackground from '$lib/components/background/AmbientBackground.svelte'; + import Button from '$lib/components/ui/Button.svelte'; interface ActionResult { error?: string; @@ -80,13 +81,9 @@

{form.error}

{/if} - +

diff --git a/src/routes/invite/+page.svelte b/src/routes/invite/+page.svelte index 28f9d4a..3ad8e3d 100644 --- a/src/routes/invite/+page.svelte +++ b/src/routes/invite/+page.svelte @@ -3,6 +3,7 @@ import { enhance } from '$app/forms'; import { TicketCheck } from 'lucide-svelte'; import AmbientBackground from '$lib/components/background/AmbientBackground.svelte'; + import Button from '$lib/components/ui/Button.svelte'; interface ActionResult { error?: string; @@ -62,13 +63,9 @@

{form.error}

{/if} - +

diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte index 49020dd..0951ffd 100644 --- a/src/routes/login/+page.svelte +++ b/src/routes/login/+page.svelte @@ -3,6 +3,8 @@ import { superForm } from 'sveltekit-superforms'; import type { PageData } from './$types.js'; import AmbientBackground from '$lib/components/background/AmbientBackground.svelte'; + import Switch from '$lib/components/ui/Switch.svelte'; + import Button from '$lib/components/ui/Button.svelte'; let { data }: { data: PageData } = $props(); @@ -106,12 +108,12 @@

- @@ -120,20 +122,9 @@
- + {/if} diff --git a/src/routes/settings/notifications/+page.svelte b/src/routes/settings/notifications/+page.svelte index 9ee75be..d630195 100644 --- a/src/routes/settings/notifications/+page.svelte +++ b/src/routes/settings/notifications/+page.svelte @@ -165,7 +165,7 @@ {:else}
{#each channels as channel (channel.id)} -
+
{channelTypeLabel(channel.type).charAt(0)} diff --git a/src/routes/status/+page.svelte b/src/routes/status/+page.svelte index 2f541da..a33f5fc 100644 --- a/src/routes/status/+page.svelte +++ b/src/routes/status/+page.svelte @@ -177,7 +177,7 @@

Recent Incidents

{#each data.incidents as incident (`${incident.appId}-${incident.startedAt}`)} -
+