From bce44769ddb514de92021126e569dd13a515ab6d Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Tue, 9 Jun 2026 20:55:22 +0200 Subject: [PATCH] feat(dashboard): unified design system + persistent per-app tab bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses two interrelated UX complaints: 1. Browser-default controls leaking through the dark theme (native , , , , , and
/ ensure native controls track the dark palette out of the box. No per-page edits needed. Tab consistency: - New dashboard/src/lib/AppTabBar.svelte renders all 11 per-app tabs (Scripts, Domains, Members, Triggers, Topics, Secrets, Settings, Users, Files, Queues, Dead letters) as links with an active highlight derived from the URL. Tabs that switch in-page panels go to ?tab=; tabs that switch routes go to the subroute. Admin-only tabs are hidden when canAdmin is false. - New dashboard/src/routes/apps/[slug]/+layout.svelte loads the app once, handles the historical-slug redirect, exposes the shared app + canAdmin + canWrite + dead-letter-count state via Svelte context, and renders the breadcrumb + AppTabBar above every per-app page. The 5 subroute pages drop their own "← back" headers since the layout owns them now. - apps/[slug]/+page.svelte's local-state activeTab becomes URL- driven via $page.url.searchParams.get('tab'). Defense-in-depth redirect for non-admin viewers landing on admin-only tabs uses goto({replaceState:true}) instead of mutating state. Light-theme leftovers swept on 5 subroute pages: - dead-letters: error banner, badge, pre/code blocks all swap to --color-danger-*, --bg-elevated, --text-primary - files: button.danger, var(--muted,#666) → token-only - queues + queues/[name]: bare hex fallbacks removed; .toolbar and .auto-refresh styled with tokens; data-testid for the queues empty state (already added by previous commit, reaffirmed here) - users + users/invitations: badge-ok/badge-pending now use --color-success-bg/fg and --color-warning-bg/fg; chips use --bg-elevated + --text-strong; .create-form gets a token-styled surface; row-action buttons gain explicit dark-theme styling E2E selector updates: - members.spec.ts, integration.spec.ts, apps.spec.ts — the tab bar is now elements (role=link) without a count suffix. Test selectors swap from getByRole('button', name: /^Scripts \(\d+\)$/) to getByRole('link', { name: 'Scripts' }), etc. - New navigation/tabs.spec.ts still passes; existing 60+ tests unchanged except for the selector swap. Two pre-existing failures (routing.spec.ts:79, integration.spec.ts:89) untouched. Co-Authored-By: Claude Opus 4.7 (1M context) --- dashboard/src/lib/AppTabBar.svelte | 109 ++++++++++ dashboard/src/routes/+layout.svelte | 148 +++++++++++++ .../src/routes/apps/[slug]/+layout.svelte | 178 ++++++++++++++++ dashboard/src/routes/apps/[slug]/+page.svelte | 201 ++---------------- .../apps/[slug]/dead-letters/+page.svelte | 91 ++++---- .../src/routes/apps/[slug]/files/+page.svelte | 27 ++- .../routes/apps/[slug]/queues/+page.svelte | 54 +++-- .../apps/[slug]/queues/[name]/+page.svelte | 19 +- .../src/routes/apps/[slug]/users/+page.svelte | 119 ++++++++--- .../[slug]/users/invitations/+page.svelte | 88 ++++++-- dashboard/tests/e2e/apps/apps.spec.ts | 18 +- .../tests/e2e/integration/integration.spec.ts | 4 +- dashboard/tests/e2e/members/members.spec.ts | 10 +- 13 files changed, 744 insertions(+), 322 deletions(-) create mode 100644 dashboard/src/lib/AppTabBar.svelte create mode 100644 dashboard/src/routes/apps/[slug]/+layout.svelte diff --git a/dashboard/src/lib/AppTabBar.svelte b/dashboard/src/lib/AppTabBar.svelte new file mode 100644 index 0000000..dedda28 --- /dev/null +++ b/dashboard/src/lib/AppTabBar.svelte @@ -0,0 +1,109 @@ + + + + + diff --git a/dashboard/src/routes/+layout.svelte b/dashboard/src/routes/+layout.svelte index e5c9288..fb4f028 100644 --- a/dashboard/src/routes/+layout.svelte +++ b/dashboard/src/routes/+layout.svelte @@ -81,7 +81,9 @@ --bg-primary: #0f172a; --bg-secondary: #0b1220; --bg-elevated: #1e293b; + --bg-elevated-hover: #283549; --text-primary: #e2e8f0; + --text-strong: #cbd5e1; --text-muted: #94a3b8; --text-subtle: #64748b; --border: #334155; @@ -90,8 +92,154 @@ --color-muted: #94a3b8; --color-danger: #f87171; --color-danger-bg: #7f1d1d; + --color-danger-fg: #fecaca; + --color-danger-border: #b91c1c; --color-success: #34d399; + --color-success-bg: #14532d; + --color-success-fg: #6ee7b7; --color-warning: #fbbf24; + --color-warning-bg: #3f2e07; + --color-warning-fg: #fde68a; + --color-warning-border: #ca8a04; + --accent: #38bdf8; + --accent-fg: #0b1220; + + /* Aliases for orphan references that several pages adopted before + the token block was canonicalised (F-U-004). Removing these + requires sweeping ~30 call sites; until then the alias keeps + the visuals consistent. */ + --muted: var(--text-muted); + --link: var(--color-link); + --text: var(--text-primary); + --color-error: var(--color-danger); + --color-border: var(--border); + --chip-bg: var(--bg-elevated); + --code-bg: var(--bg-secondary); + + /* Radii, shadows, z scale. */ + --radius-sm: 0.25rem; + --radius-md: 0.375rem; + --radius-lg: 0.5rem; + --radius-pill: 999px; + --shadow-elev-2: 0 25px 50px -12px rgba(0, 0, 0, 0.7); + --z-popover: 50; + --z-modal: 100; + --z-toast: 200; + } + + /* Global form-control resets — these stop macOS / Linux native + controls (selects with OS chevrons, blue-tick checkboxes, raised + default buttons, light-rendered date pickers) from breaking the + dark theme. Per-page styles still win because of specificity. */ + :global(button) { + font-family: inherit; + } + :global(select) { + appearance: none; + -webkit-appearance: none; + background-color: var(--bg-secondary); + color: var(--text-primary); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 0.45rem 2rem 0.45rem 0.75rem; + font: inherit; + /* Inline-SVG chevron so the dropdown indicator matches the + slate-400 muted text colour regardless of OS. */ + background-image: url("data:image/svg+xml;utf8,"); + background-repeat: no-repeat; + background-position: right 0.6rem center; + cursor: pointer; + } + :global(select:focus) { + outline: none; + border-color: var(--color-link); + } + :global(input[type='checkbox']), + :global(input[type='radio']) { + appearance: none; + -webkit-appearance: none; + width: 1rem; + height: 1rem; + margin: 0; + background: var(--bg-secondary); + border: 1px solid var(--border); + flex-shrink: 0; + cursor: pointer; + display: inline-block; + vertical-align: middle; + position: relative; + } + :global(input[type='checkbox']) { + border-radius: var(--radius-sm); + } + :global(input[type='radio']) { + border-radius: var(--radius-pill); + } + :global(input[type='checkbox']:checked), + :global(input[type='radio']:checked) { + background: var(--color-link); + border-color: var(--color-link); + } + :global(input[type='checkbox']:checked::after) { + content: ''; + position: absolute; + left: 3px; + top: 0; + width: 4px; + height: 8px; + border: solid var(--accent-fg); + border-width: 0 2px 2px 0; + transform: rotate(45deg); + } + :global(input[type='radio']:checked::after) { + content: ''; + position: absolute; + left: 3px; + top: 3px; + width: 8px; + height: 8px; + border-radius: var(--radius-pill); + background: var(--accent-fg); + } + :global(input[type='checkbox']:focus-visible), + :global(input[type='radio']:focus-visible) { + outline: 2px solid var(--color-link); + outline-offset: 2px; + } + /* Number input spinners — hidden so the dark theme doesn't ship + OS-blue stepper arrows on inputs like timeout/visibility knobs. */ + :global(input[type='number']) { + -moz-appearance: textfield; + appearance: textfield; + } + :global(input[type='number']::-webkit-outer-spin-button), + :global(input[type='number']::-webkit-inner-spin-button) { + -webkit-appearance: none; + margin: 0; + } + /* Date picker icon — recoloured to match muted text. */ + :global(input[type='date']::-webkit-calendar-picker-indicator) { + filter: invert(0.7); + cursor: pointer; + } + /*
default triangle replaced with a slate chevron so the + disclosure widget tracks the rest of the dark theme. */ + :global(details > summary) { + list-style: none; + cursor: pointer; + } + :global(details > summary::-webkit-details-marker) { + display: none; + } + :global(details > summary::before) { + content: '▸'; + display: inline-block; + margin-right: 0.4rem; + color: var(--text-muted); + transition: transform 120ms ease; + } + :global(details[open] > summary::before) { + transform: rotate(90deg); } :global(html, body) { diff --git a/dashboard/src/routes/apps/[slug]/+layout.svelte b/dashboard/src/routes/apps/[slug]/+layout.svelte new file mode 100644 index 0000000..a4ceb4f --- /dev/null +++ b/dashboard/src/routes/apps/[slug]/+layout.svelte @@ -0,0 +1,178 @@ + + + + +
+ + {#if $app} +

{$app.name}

+ {#if $app.description}

{$app.description}

{/if} + {/if} +
+ + + +{#if loading && !$app} +

Loading…

+{:else if error && !$app} +

{error}

+{:else} + {@render children?.()} +{/if} + + diff --git a/dashboard/src/routes/apps/[slug]/+page.svelte b/dashboard/src/routes/apps/[slug]/+page.svelte index 7eb84d4..31ca398 100644 --- a/dashboard/src/routes/apps/[slug]/+page.svelte +++ b/dashboard/src/routes/apps/[slug]/+page.svelte @@ -58,7 +58,23 @@ /// build webhook URLs the operator shares with external systems so /// they don't accidentally inherit the admin's LAN address. let publicBaseUrl = $state(null); - let activeTab = $state('scripts'); + // Tab is URL-driven so the layout's can link directly to + // `?tab=` and the in-page panel switches accordingly. The legacy + // state-based switching was incompatible with cross-route tab + // continuity (the user complained that subroute pages dropped the + // tab bar — now the layout owns it everywhere). + const isValidTab = (s: string | null): s is Tab => + s === 'scripts' || + s === 'domains' || + s === 'members' || + s === 'settings' || + s === 'triggers' || + s === 'topics' || + s === 'secrets'; + let activeTab = $derived.by(() => { + const qp = page.url.searchParams.get('tab'); + return isValidTab(qp) ? qp : 'scripts'; + }); let scripts = $state([]); let domains = $state([]); @@ -907,10 +923,10 @@ void loadApp(); }); - // Defense-in-depth: a viewer / editor following a stale link to - // the Settings or Members tab gets bounced back to Scripts. The - // backend still 403s the underlying calls, but no point showing an - // empty tab. + // Defense-in-depth: a viewer / editor following a stale link to an + // admin-only tab (?tab=settings, etc.) gets bounced back to Scripts. + // The backend still 403s the underlying calls, but no point showing + // an empty tab. activeTab is URL-driven now so we update the URL. $effect(() => { if ( !canAdmin && @@ -920,101 +936,12 @@ activeTab === 'topics' || activeTab === 'secrets') ) { - activeTab = 'scripts'; + void goto(`${base}/apps/${slug}?tab=scripts`, { replaceState: true }); } }); -{#if loading && !app} -

Loading…

-{:else if loadError && !app} -
- Could not load app. -

{loadError}

- Back to apps -
-{:else if app} - - - - +{#if app} {#if activeTab === 'scripts'}
@@ -2104,36 +2031,6 @@ {/if} diff --git a/dashboard/src/routes/apps/[slug]/files/+page.svelte b/dashboard/src/routes/apps/[slug]/files/+page.svelte index afcc62e..e1d4e9e 100644 --- a/dashboard/src/routes/apps/[slug]/files/+page.svelte +++ b/dashboard/src/routes/apps/[slug]/files/+page.svelte @@ -114,11 +114,10 @@ Files · {slug} · PiCloud -
-
+
+
- ← back to {app?.name ?? slug} -

Files

+

Files

Browse and delete stored blobs by collection. Uploads happen from scripts via files::collection(c).create(…). @@ -246,19 +245,15 @@ {/if} diff --git a/dashboard/src/routes/apps/[slug]/queues/+page.svelte b/dashboard/src/routes/apps/[slug]/queues/+page.svelte index f462d1f..cf460a8 100644 --- a/dashboard/src/routes/apps/[slug]/queues/+page.svelte +++ b/dashboard/src/routes/apps/[slug]/queues/+page.svelte @@ -72,16 +72,15 @@ Queues — {app?.name ?? slug} — PiCloud -

- ← Back to {app?.name ?? slug} -

Queues

+
+

Queues

Per-app durable queues. Producers call queue::enqueue(name, msg); consumers register a queue:receive trigger. v1.1.9.

-