+ control + `. |
+| Confirm-before-destructive | `ConfirmDialog.svelte` | Already exists. Use it. |
+| Entity / icon / tag picker | `EntityPicker`, `MultiEntityPicker`, `IconPickerButton`, `TagsInput` | Already exist. Reuse. |
+
+### Process
+
+1. Before writing any form control in a `.svelte` file, **scan `src/lib/components/ui/` first**. If a matching primitive exists, import and use it.
+2. If you find yourself copying a Tailwind class string verbatim from another file, **stop**: that's the trigger to extract a primitive (or expand an existing one).
+3. If you genuinely need a new primitive, add it to `src/lib/components/ui/`, give it a `class?: string` prop merged via `cn()`, document it in this table, and migrate at least two call sites in the same PR so it's not dead code.
+4. Tokens (`--primary`, `--card`, `--room-*`, `--shadow-soft`, etc.) are defined once in `src/app.css`. Never hardcode hex/HSL — read from the token.
+
+### Cozy spec quick reminders
+
+- Hero cards: `rounded-[1.4rem]` + `shadow-[var(--shadow-soft)]`. Dense panels: `rounded-xl`. **Never** `rounded-lg` on a section wrapper.
+- Headings (`h1`, `h2`, `h3`) automatically get Fraunces via base layer — no need to add `font-display` unless overriding non-heading text.
+- Focus uses `focus-visible:ring-2 focus-visible:ring-primary/30` — primitives already do this; mirror it on anything hand-rolled.
+- Motion is gentle and present: prefer `cozy-rise` / `cozy-expand` from `app.css` over generic Tailwind animations. All motion classes already respect `prefers-reduced-motion`.
+
+## Backend
+
+- Auth: session cookie + optional OAuth. Roles: `admin` / user / guest. Always check role at the route load function, not the component.
+- Validation: Zod schemas live in `src/lib/utils/validators.ts`. Reuse the same schema on client (superForms) and server.
+- DB: Prisma. Never query the DB directly from a route — go through `src/lib/server/services/*Service.ts`.
+
+## Testing
+
+- Vitest, Node environment, no DOM (existing pattern). Component tests use the module-scope helpers (e.g., `buttonClass` in `Button.svelte`) rather than rendering — keep that convention.
+- Run before committing: `npm run check && npm run lint && npm test && npm run build`.
+
+## Commands
+
+```bash
+npm run dev # vite dev on :5181
+npm run check # svelte-check (TS + Svelte)
+npm run lint # eslint
+npm test # vitest run
+npm run build # production build
+```
diff --git a/src/app.css b/src/app.css
index bc0b6d0..bd06af7 100644
--- a/src/app.css
+++ b/src/app.css
@@ -221,11 +221,46 @@
}
}
+@keyframes status-breathe {
+ 0%,
+ 100% {
+ opacity: 0.85;
+ }
+ 50% {
+ opacity: 1;
+ }
+}
+
+@keyframes status-flash {
+ 0% {
+ transform: scale(1);
+ opacity: 0.6;
+ }
+ 30% {
+ transform: scale(1.25);
+ opacity: 1;
+ }
+ 100% {
+ transform: scale(1);
+ opacity: 1;
+ }
+}
+
.status-online {
animation: status-pulse 2s ease-in-out infinite;
color: var(--status-online);
}
+.status-degraded {
+ animation: status-breathe 2.6s ease-in-out infinite;
+ color: var(--status-degraded);
+}
+
+.status-offline {
+ animation: status-flash 0.6s ease-out 1;
+ color: var(--status-offline);
+}
+
/* ===== Card Style Variants ===== */
.card-solid {
background: var(--card);
@@ -330,6 +365,47 @@
}
}
+/* ===== Cozy entrance reveal ===== */
+@keyframes cozy-rise {
+ 0% {
+ opacity: 0;
+ transform: translateY(12px);
+ }
+ 100% {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.cozy-rise {
+ animation: cozy-rise 0.5s cubic-bezier(0.2, 0.8, 0.2, 1) both;
+}
+
+/* For staggered grid reveals — set --i as 0,1,2,... per item */
+.cozy-rise-stagger {
+ animation: cozy-rise 0.5s cubic-bezier(0.2, 0.8, 0.2, 1) both;
+ animation-delay: calc(var(--i, 0) * 55ms);
+}
+
+/* ===== Cozy accordion (height slide for show/hide) ===== */
+@keyframes cozy-expand {
+ 0% {
+ opacity: 0;
+ transform: translateY(-4px);
+ max-height: 0;
+ }
+ 100% {
+ opacity: 1;
+ transform: translateY(0);
+ max-height: 1200px;
+ }
+}
+
+.cozy-expand {
+ overflow: hidden;
+ animation: cozy-expand 0.32s cubic-bezier(0.2, 0.8, 0.2, 1) both;
+}
+
/* ===== Cozy greeting wave ===== */
@keyframes cozy-wave {
0%,
@@ -359,7 +435,12 @@
@media (prefers-reduced-motion: reduce) {
.cozy-wave,
- .status-online {
+ .status-online,
+ .status-degraded,
+ .status-offline,
+ .cozy-rise,
+ .cozy-rise-stagger,
+ .cozy-expand {
animation: none;
}
.card-hover:hover {
diff --git a/src/lib/components/admin/BackupPanel.svelte b/src/lib/components/admin/BackupPanel.svelte
index 22eb7cc..7768ec4 100644
--- a/src/lib/components/admin/BackupPanel.svelte
+++ b/src/lib/components/admin/BackupPanel.svelte
@@ -1,11 +1,15 @@
-
+
{$t('admin.backup_title')}
{$t('admin.backup_description')}
-
+
{creating ? $t('admin.backup_creating') : $t('admin.backup_create')}
-
+
@@ -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}
confirmRestore && handleRestore(confirmRestore)}
- class="rounded-xl px-4 py-2 text-sm font-semibold text-white shadow-[var(--shadow-soft)] transition-all hover:-translate-y-0.5" style="background: var(--status-degraded);"
+ class="rounded-xl px-4 py-2 text-sm font-semibold text-white shadow-[var(--shadow-soft)] transition-all hover:-translate-y-0.5"
+ style="background: var(--status-degraded);"
>
{$t('admin.backup_restore')}
@@ -310,6 +392,40 @@
{/if}
+
+ {#if confirmSchemaMismatch}
+
+
+
+ {$t('admin.backup_restore_schema_mismatch_title')}
+
+
+ {$t('admin.backup_restore_schema_mismatch_intro')}
+
+
{pendingSchemaMismatchMessage}
+
+ {
+ confirmSchemaMismatch = false;
+ pendingSchemaMismatchFile = null;
+ }}
+ class="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted"
+ >
+ Cancel
+
+
+ {$t('admin.backup_restore_schema_mismatch_force')}
+
+
+
+
+ {/if}
+
{#if confirmDelete}
@@ -350,11 +466,10 @@
-
-
+
{$t('admin.backup_schedule_enabled')}
@@ -365,16 +480,12 @@
{$t('admin.backup_schedule_cron')}
-
+
{$t('admin.backup_schedule_preset_daily')}
{$t('admin.backup_schedule_preset_twice_daily')}
{$t('admin.backup_schedule_preset_weekly')}
{$t('admin.backup_schedule_preset_custom')}
-
+
{#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}
-
+
{savingSchedule ? $t('admin.backup_schedule_saving') : $t('admin.backup_schedule_save')}
-
+
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')}
-
{scanning ? $t('admin.discovery_scanning') : $t('admin.discovery_scan')}
-
+
@@ -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}
-
{approving ? $t('admin.discovery_approving') : $t('admin.discovery_approve')} ({selected.size})
-
+
{/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}
-
-
-
Enabled
+
+
+ Enabled
@@ -290,29 +287,17 @@
-
+
{channel ? 'Update' : 'Create'} Channel
-
+
{#if channel?.id}
-
+
{testing ? 'Sending...' : 'Send Test'}
-
+
{/if}
-
+
Cancel
-
+
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
@@ -56,19 +54,8 @@
-
- Generate Token
-
-
- Cancel
-
+ Generate Token
+ Cancel
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 @@
+
+
+
+
+ {#if loading}
+
+ {/if}
+ {@render children()}
+
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 @@
+
+
+
+ {#if indeterminate}
+
+
+
+ {:else if checked}
+
+
+
+ {/if}
+ {#if name !== undefined}
+
+ {/if}
+
+
+
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}
+
+ {label}
+ {#if required}* {/if}
+
+ {/if}
+ {@render children()}
+ {#if error}
+
{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 @@
+
+
+
+
+
+
+ {@render children()}
+
+
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 @@
+
+
+
+
+ {#if name !== undefined}
+
+ {/if}
+
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 @@
-
{$t('widget.format') ?? 'Format'}
-
+ {$t('widget.format') ?? 'Format'}
+
Markdown
Plain Text
HTML
-
-
+
{:else if widgetType === 'embed'}
@@ -285,9 +287,8 @@
-
{$t('widget.height') ?? 'Height'} ({embedHeight}px)
-
-
+
{$t('widget.height') ?? 'Height'} ({embedHeight}px)
+
Sandbox
@@ -318,16 +319,15 @@
-
{$t('widget.style') ?? 'Style'}
-
+ {$t('widget.style') ?? 'Style'}
+
Digital
Analog
24h
-
-
+
-
-
+
+
{$t('widget.show_weather') ?? 'Show Weather'}
{#if clockShowWeather}
@@ -352,18 +352,16 @@
-
Source Type
-
+ Source Type
+
Glances
Prometheus
Custom
-
-
+
-
Refresh ({sysStatsRefreshInterval}s)
-
-
+
Refresh ({sysStatsRefreshInterval}s)
+
{:else if widgetType === 'rss'}
@@ -373,12 +371,11 @@
-
Max Items ({rssMaxItems})
-
-
+
Max Items ({rssMaxItems})
+
-
-
+
+
Show Summary
@@ -398,9 +395,8 @@
+ Add URL
-
Days Ahead ({calendarDaysAhead})
-
-
+
Days Ahead ({calendarDaysAhead})
+
{:else if widgetType === 'markdown'}
@@ -417,13 +413,12 @@
-
Source
-
+ Source
+
Static
JSON Endpoint
Prometheus
-
-
+
{#if metricSource === 'static'}
@@ -461,9 +456,8 @@
-
Refresh ({metricRefreshInterval}s)
-
-
+
Refresh ({metricRefreshInterval}s)
+
@@ -482,8 +476,8 @@
{/each}
+ Add Link
-
-
+
+
Collapsible
@@ -494,39 +488,35 @@
-
Type
-
+ Type
+
Image
MJPEG
HLS
-
-
+
-
Refresh ({cameraRefreshInterval}s)
-
-
+
Refresh ({cameraRefreshInterval}s)
+
-
Aspect Ratio
-
+ Aspect Ratio
+
16:9
4:3
1:1
-
-
+
{:else if widgetType === 'integration'}
-
{$t('widget.app') ?? 'App'}
-
+ {$t('widget.app') ?? 'App'}
+
Select app...
{#each apps as app (app.id)}
{app.name}
{/each}
-
-
+
Endpoint ID
@@ -534,9 +524,8 @@
-
Refresh ({integrationRefreshInterval}s)
-
-
+
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
-
-
+
+
Show Weather
@@ -599,26 +598,24 @@
Source Type
-
Glances
Prometheus
Custom JSON
-
+
@@ -658,23 +654,18 @@
Max Items: {rssMaxItems}
-
-
-
+
+
Show Summaries
@@ -733,14 +724,13 @@
Days Ahead: {calendarDaysAhead}
-
@@ -855,14 +845,13 @@
Refresh: {metricRefreshInterval}s
-
@@ -916,12 +905,8 @@
-
-
+
+
Collapsible
@@ -942,43 +927,40 @@
Stream Type
-
Snapshot (Image)
MJPEG Stream
HLS Stream
-
+
@@ -990,44 +972,41 @@
{#if integrationApps.length === 0}
No apps with integrations configured. Add an integration to an app first.
{:else}
-
Select an app...
{#each integrationApps as app (app.id)}
{app.name} ({app.integrationType})
{/each}
-
+
{/if}
{#if integrationAppId && integrationEndpoints.length > 0}
Data Endpoint
-
Select endpoint...
{#each integrationEndpoints as ep (ep.id)}
{ep.name}
{/each}
-
+
Refresh: {integrationRefreshInterval}s
-
{/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}
-
+
{submitting ? '…' : ($t('auth.forgot_password_submit') ?? 'Request reset link')}
-
+
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}
-
+
{submitting ? '…' : ($t('auth.invite_continue') ?? 'Continue')}
-
+
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 @@
-
-
+
{$t('auth.remember_me')}
@@ -120,20 +122,9 @@
-
- {#if $submitting}
-
-
- {$t('auth.login_submitting')}
-
- {:else}
- {$t('auth.login_submit')}
- {/if}
-
+
+ {$submitting ? $t('auth.login_submitting') : $t('auth.login_submit')}
+
{/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}`)}
-