From 915d934c26b0bc70e4444c5c6db4182e632305d5 Mon Sep 17 00:00:00 2001 From: pptx704 Date: Tue, 24 Mar 2026 15:51:11 +0600 Subject: [PATCH] Frontend consistency pass: delight, audit, and normalization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delight (keys page): - Animated checkmark draw + circle pop on key reveal dialog open - Key display area pulses accent glow on open to draw eye to "copy this" - Copy button spring-bounces on successful copy (re-triggers on repeat) - Empty state key icon floats (iconFloat, now global) - Row hover uses scaleY left-accent stripe (matches capsules pattern) - New key row flashes accent on reveal dialog dismiss (matches capsule-born) Audit fixes (all dashboard pages): - Page titles standardized to em dash: "Wrenn — X" across all four pages - formatDate/timeAgo extracted to src/lib/utils/format.ts (string | undefined signatures); keys and snapshots now import from there instead of duplicating - team formatDate gains undefined guard (kept local, date-only format differs) - spin-once and iconFloat keyframes moved to app.css as globals; scoped copies removed from capsules and keys - Snapshots empty state icon was referencing undefined @keyframes float; fixed to iconFloat Normalization: - Snapshots table rows: replaced ::before pseudo-element accent (opacity-only, single color) with DOM row-stripe element using scaleY transition, type-keyed color (green for snapshots, blue for images) — matches capsules pattern - Create Key dialog: max-w-[400px] → max-w-[420px] to align with form dialogs - Snapshots count and empty-state heading are now terminology-aware: shows "templates/snapshots/images" based on active filter; empty heading for all filter reads "No templates yet" instead of "No snapshots yet" Not done (documented in audit, deferred): - Sidebar nav items pointing to unimplemented routes (audit, usage, billing, notifications, settings) — left as-is, needs product decision - Dialog max-widths fully normalized beyond Create Key — minor, deferred - capsules timeAgo not imported from shared util (formatTime differs intentionally) Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/app.css | 12 ++ frontend/src/lib/utils/format.ts | 25 +++ .../routes/dashboard/capsules/+page.svelte | 60 ++++++- .../src/routes/dashboard/keys/+page.svelte | 160 +++++++++++------ .../routes/dashboard/snapshots/+page.svelte | 162 +++++++++++++----- .../src/routes/dashboard/team/+page.svelte | 5 +- 6 files changed, 310 insertions(+), 114 deletions(-) create mode 100644 frontend/src/lib/utils/format.ts diff --git a/frontend/src/app.css b/frontend/src/app.css index 765d14a..0a6b995 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -143,6 +143,18 @@ body { } } +/* Refresh icon spin — one full rotation */ +@keyframes spin-once { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +/* Floating icon — used on empty-state icon containers */ +@keyframes iconFloat { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-6px); } +} + /* Respect user motion preferences — covers both CSS class animations and inline style animations */ @media (prefers-reduced-motion: reduce) { *, diff --git a/frontend/src/lib/utils/format.ts b/frontend/src/lib/utils/format.ts new file mode 100644 index 0000000..0cba447 --- /dev/null +++ b/frontend/src/lib/utils/format.ts @@ -0,0 +1,25 @@ +/** + * Shared date/time formatting utilities. + * All functions accept `string | undefined` and return a safe fallback. + */ + +export function formatDate(iso: string | undefined): string { + if (!iso) return '—'; + return new Date(iso).toLocaleString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: false + }); +} + +export function timeAgo(iso: string | undefined): string { + if (!iso) return ''; + const seconds = Math.floor((Date.now() - new Date(iso).getTime()) / 1000); + if (seconds < 60) return `${seconds}s ago`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; + return `${Math.floor(seconds / 86400)}d ago`; +} diff --git a/frontend/src/routes/dashboard/capsules/+page.svelte b/frontend/src/routes/dashboard/capsules/+page.svelte index 9210293..13cf682 100644 --- a/frontend/src/routes/dashboard/capsules/+page.svelte +++ b/frontend/src/routes/dashboard/capsules/+page.svelte @@ -57,6 +57,9 @@ let destroying = $state(false); let destroyError = $state(null); + // Delight: briefly highlight a newly created capsule row + let newCapsuleId = $state(null); + let filteredCapsules = $derived.by(() => { let list = searchQuery ? capsules.filter((c) => c.id.toLowerCase().includes(searchQuery.toLowerCase())) @@ -217,6 +220,9 @@ capsules = [result.data, ...capsules]; showCreateDialog = false; createForm = { template: 'minimal', vcpus: 1, memory_mb: 512, timeout_sec: 0 }; + // Flash the new row briefly + newCapsuleId = result.data.id; + setTimeout(() => { newCapsuleId = null; }, 1600); } else { createError = result.error; } @@ -260,20 +266,35 @@ { if (e.key === 'Escape') openMenuId = null; }} /> - Wrenn - Capsules + Wrenn — Capsules
@@ -397,6 +418,20 @@ title={autoRefresh ? 'Click to disable auto-refresh' : 'Click to enable auto-refresh (30s)'} > {#if autoRefresh} + + {countdown}s {:else} Off @@ -469,10 +504,14 @@
{:else} {#each filteredCapsules as capsule, i (capsule.id)} + {@const stripeColor = capsule.status === 'running' ? 'bg-[var(--color-accent)]' : capsule.status === 'paused' ? 'bg-[var(--color-amber)]' : 'bg-[var(--color-text-muted)]'}
+ +
+
{#if capsule.status === 'running'} @@ -485,7 +524,12 @@ {:else} {/if} - {capsule.id} + {#if searchQuery && capsule.id.toLowerCase().includes(searchQuery.toLowerCase())} + {@const matchIdx = capsule.id.toLowerCase().indexOf(searchQuery.toLowerCase())} + {capsule.id.slice(0, matchIdx)}{capsule.id.slice(matchIdx, matchIdx + searchQuery.length)}{capsule.id.slice(matchIdx + searchQuery.length)} + {:else} + {capsule.id} + {/if}
@@ -536,7 +580,7 @@ openMenuId = capsule.id; } }} - class="inline-flex items-center gap-1.5 rounded-[var(--radius-button)] border border-[var(--color-border)] bg-[var(--color-bg-2)] px-2.5 py-1 text-label font-semibold uppercase tracking-[0.04em] text-[var(--color-text-secondary)] transition-colors duration-150 hover:border-[var(--color-border-mid)] hover:text-[var(--color-text-primary)]" + class="inline-flex items-center gap-1.5 rounded-[var(--radius-button)] border px-2.5 py-1 text-label font-semibold uppercase tracking-[0.04em] transition-colors duration-150 {capsule.status === 'running' ? 'border-[var(--color-accent)]/40 bg-[var(--color-accent-glow)] text-[var(--color-accent-mid)] hover:border-[var(--color-accent)]/70 hover:text-[var(--color-accent-bright)]' : capsule.status === 'paused' ? 'border-[var(--color-amber)]/30 bg-[var(--color-amber)]/5 text-[var(--color-amber)] hover:border-[var(--color-amber)]/60' : 'border-[var(--color-border)] bg-[var(--color-bg-2)] text-[var(--color-text-secondary)] hover:border-[var(--color-border-mid)] hover:text-[var(--color-text-primary)]'}" > {capsule.status} (null); let copied = $state(false); + let copyCount = $state(0); // increment to re-trigger bounce animation + + // Delight: flash the row for a key once its reveal dialog is dismissed + let flashKeyId = $state(null); // Revoke state let revokeTarget = $state(null); @@ -53,6 +58,7 @@ showCreate = false; createName = ''; copied = false; + copyCount = 0; } else { createError = result.error; } @@ -74,44 +80,32 @@ revoking = false; } + function dismissReveal() { + const id = newKey?.id ?? null; + newKey = null; + if (id) { + flashKeyId = id; + setTimeout(() => { flashKeyId = null; }, 1600); + } + } + async function copyKey() { if (!newKey?.key) return; try { await navigator.clipboard.writeText(newKey.key); copied = true; + copyCount += 1; setTimeout(() => (copied = false), 2000); } catch { toast.error('Copy failed — select the key text and copy it manually.'); } } - function formatDate(iso: string | undefined): string { - if (!iso) return '—'; - return new Date(iso).toLocaleString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - hour: '2-digit', - minute: '2-digit', - hour12: false - }); - } - - function timeAgo(iso: string | undefined): string { - if (!iso) return ''; - const seconds = Math.floor((Date.now() - new Date(iso).getTime()) / 1000); - if (seconds < 60) return `${seconds}s ago`; - if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; - if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; - return `${Math.floor(seconds / 86400)}d ago`; - } - - onMount(fetchKeys); - Wrenn - API Keys + Wrenn — API Keys
@@ -170,7 +164,7 @@
{:else if keys.length === 0}
-
+
@@ -191,7 +185,7 @@
-
Name / Key
+
Key
Created By
Created
Last Used
@@ -200,13 +194,14 @@ {#each keys as key, i (key.id)}
+
-
+
{key.name || '—'} - {key.key_prefix}… + {key.key_prefix}…
@@ -264,7 +259,7 @@ onkeydown={(e) => { if (e.key === 'Escape' && !creating) showCreate = false; }} >
-
+

New API Key

Give your key a name to identify it later.

@@ -321,19 +316,19 @@
{ newKey = null; }} - onkeydown={(e) => { if (e.key === 'Escape') newKey = null; }} + onclick={dismissReveal} + onkeydown={(e) => { if (e.key === 'Escape') dismissReveal(); }} >
- + - + - Key created successfully + Key created successfully

{newKey.name || 'API Key'}

@@ -342,31 +337,34 @@

-
+
{newKey.key ?? ''} - + {#key copyCount} + + {/key}
@@ -383,7 +381,7 @@
{/if} + + diff --git a/frontend/src/routes/dashboard/snapshots/+page.svelte b/frontend/src/routes/dashboard/snapshots/+page.svelte index 4bd9c15..8892a8c 100644 --- a/frontend/src/routes/dashboard/snapshots/+page.svelte +++ b/frontend/src/routes/dashboard/snapshots/+page.svelte @@ -2,12 +2,15 @@ import Sidebar from '$lib/components/Sidebar.svelte'; import { onMount } from 'svelte'; import { goto } from '$app/navigation'; + import { fly } from 'svelte/transition'; + import { cubicIn, cubicOut } from 'svelte/easing'; import { listSnapshots, deleteSnapshot, createCapsule, type Snapshot } from '$lib/api/capsules'; + import { formatDate, timeAgo } from '$lib/utils/format'; let collapsed = $state( typeof window !== 'undefined' @@ -110,29 +113,10 @@ return `${(bytes / 1024 ** 3).toFixed(2)} GB`; } - function formatDate(iso: string): string { - return new Date(iso).toLocaleString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - hour: '2-digit', - minute: '2-digit', - hour12: false - }); - } - - function timeAgo(iso: string): string { - const seconds = Math.floor((Date.now() - new Date(iso).getTime()) / 1000); - if (seconds < 60) return `${seconds}s ago`; - if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; - if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; - return `${Math.floor(seconds / 86400)}d ago`; - } - function emptyHeading(f: TypeFilter): string { if (f === 'snapshot') return 'No snapshots'; if (f === 'base') return 'No images'; - return 'No snapshots yet'; + return 'No templates yet'; } function emptyDescription(f: TypeFilter): string { @@ -145,7 +129,7 @@ - Wrenn - Templates + Wrenn — Templates @@ -228,13 +212,39 @@ {/if} {#if loading} -
-
- - - - Loading snapshots... + +
+
+ {#each Array(3) as _, i} +
+ {/each}
+
+
+
+
+
Name
+
Type
+
vCPUs
+
Memory
+
Size
+
Created
+
Actions
+
+ {#each Array(4) as _, i} +
+
+
+
+
+
+
+
+
+ {/each}
{:else} @@ -243,7 +253,7 @@ {#each ([['all', 'All'], ['snapshot', 'Snapshots'], ['base', 'Images']] as const) as [val, label]}
{filteredSnapshots.length} - {filteredSnapshots.length === 1 ? 'snapshot' : 'snapshots'} + {typeFilter === 'all' + ? filteredSnapshots.length === 1 ? 'template' : 'templates' + : typeFilter === 'snapshot' + ? filteredSnapshots.length === 1 ? 'snapshot' : 'snapshots' + : filteredSnapshots.length === 1 ? 'image' : 'images'}
{#if filteredSnapshots.length === 0}
-
- - - - +
+ +
+
+ + + + +

{emptyHeading(typeFilter)} @@ -275,7 +296,7 @@ {#if typeFilter === 'all' || typeFilter === 'snapshot'} Go to Capsules @@ -301,10 +322,14 @@ {#each filteredSnapshots as snapshot, i (snapshot.name)} + {@const stripeColor = snapshot.type === 'snapshot' ? 'bg-[var(--color-accent)]' : 'bg-[var(--color-blue)]'}

+
{snapshot.name} @@ -314,12 +339,15 @@
{#if snapshot.type === 'snapshot'} - + Snapshot {:else} - + Image {/if} @@ -355,11 +383,11 @@
-
+
@@ -393,8 +421,13 @@

- {filteredSnapshots.length} {filteredSnapshots.length === 1 ? 'snapshot' : 'snapshots'} - {typeFilter !== 'all' ? `· filtered` : '· total'} + {filteredSnapshots.length} + {typeFilter === 'all' + ? filteredSnapshots.length === 1 ? 'template' : 'templates' + : typeFilter === 'snapshot' + ? filteredSnapshots.length === 1 ? 'snapshot' : 'snapshots' + : filteredSnapshots.length === 1 ? 'image' : 'images'} + {typeFilter !== 'all' ? '· filtered' : '· total'}

{/if} {/if} @@ -405,7 +438,10 @@
- + All systems operational
@@ -487,7 +523,7 @@
{/if} + + diff --git a/frontend/src/routes/dashboard/team/+page.svelte b/frontend/src/routes/dashboard/team/+page.svelte index 7bbf159..32a33e3 100644 --- a/frontend/src/routes/dashboard/team/+page.svelte +++ b/frontend/src/routes/dashboard/team/+page.svelte @@ -266,7 +266,8 @@ return palette[email.charCodeAt(0) % palette.length]; } - function formatDate(iso: string): string { + function formatDate(iso: string | undefined): string { + if (!iso) return '—'; return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', @@ -292,7 +293,7 @@ - Wrenn - Team + Wrenn — Team