ADR-0015: SnatchArr's own UI: palette A, the M3 mark, a task-flow navigation and a UI that never blocks¶
- Status: accepted
- Date: 2026-10-19
- Milestone: UI and design, issues #417 to #447
Context¶
The web UI started from the newtarr template: twelve flat sidebar links, an inline create
form under every list, one long scrolling page per instance, neutral greys, and a root
layout that renders nothing but "Loading" until the session request returns
(web/src/routes/+layout.svelte, the ready flag). An audit on 2026-10-19 filed 31
issues against it (milestone 11): contrast failures (#417 to #419), phone overflow (#420
to #427, #441), missing empty states and confirmations (#428 to #433), error handling
(#436, #437), accessibility (#438, #439) and structure (#442 to #447).
Per operator direction (paraphrased), newtarr was only the starting template. SnatchArr
gets its own information architecture, navigation and page layouts, derived from what its
users do. Two design rounds were compared on screenshots. The operator picked palette A
("Night Prowler", violet, hue 285) and the M3 mark (a vesica, the almond shape where two
circles overlap). Page pattern a was also picked: instance detail as subtabs, a "+ Add"
toolbar that opens a dialog, the brand mark on /login, and the sveltesentio DataTable
on every major list. Two further directions bind every UI round. The UI never blocks
input while it waits for the API. It also follows current Svelte 5 and SvelteKit idioms,
checked against the docs of the installed versions.
The design evidence (task flows, route map, before and after screenshots at 1280x800 and
375x812, light and dark, axe results) is a local record of the design lane run
snatcharr-ui-ia2; this ADR carries its conclusions.
Decision¶
1. Rules every UI change follows¶
| Id | Rule | Checked by |
|---|---|---|
| A11Y1 | Target EN 301 549 V4.1.1; the legal floor is V3.2.1, which references WCAG 2.1 AA, and SnatchArr meets WCAG 2.2 AA. axe-core runs on every route in CI with zero violations allowed. Each new or restructured page gets a keyboard pass (tab order, visible focus, Escape closes overlays, focus returns to the opener) and a screen-reader pass (landmarks, names, live regions), recorded in its pull request. | web/e2e/a11y.test.ts in make web-e2e; pull request checklist |
| UIF1 | Never freeze. No computation on the UI thread beyond one frame's work; every API call is async with a deadline and a visible state (skeleton, pending, error with retry). A test delays the API by 10 s and then answers 503: navigation and form inputs stay usable throughout. A planted blocking await in the root layout must turn that test red. The input-latency budget (section 5) is measured and recorded. |
web/e2e/nonblocking.test.ts (added with the first implementation round) |
| TOK1 | Colours come only from the palette preset in @sveltesentio/ui/presets (presetSnatchArr), generated into web/src/lib/theme.css by web/scripts/gen-theme.ts. No hex and no literal oklch values in components or route files; Tailwind palette classes such as text-red-600 are replaced by token classes (#440). The preset moves into the shared W3C design tokens file when sveltesentio cuts it. |
web/src/lib/theme-contrast.test.ts; sveltesentio packages/ui/test/palette.test.ts |
| RS-svelte-ai-tools | The 2026-10-16 Svelte tooling evaluation is read before choosing a library. Component work is checked with the official Svelte MCP server (@sveltejs/mcp, autofixer and live docs). The two picks it named for SnatchArr are not adopted: @workflow/sveltekit and @sentry/sveltekit both need a SvelteKit server or hooks.server.ts, which this static SPA does not have (ADR-0005). Background work stays in the Go API and the worker; errors reach the user as RFC 9457 problems. |
review |
2. Palette and brand¶
- Palette A in light and dark, as
presetSnatchArrin golusoris/sveltesentio (packages/ui/src/presets, beside the sizing presets of sveltesentio ADR-0047). The sveltesentio test checks every text pair at 4.5:1 and every non-text pair at 3:1 (WCAG 2.2 1.4.3 and 1.4.11), and a planted low-contrast pair fails it. - The M3 mark is one SVG that uses
currentColor, so it follows the theme. That fixes the invisible mark on the dark navigation (#443). It appears in the sidebar header, on/loginand/setup(#446), in empty states, and as the favicon (SVG plus 32 px PNG and 180 px Apple touch icon). - Wordmark: system sans, weight 600, letter spacing -0.01em.
3. Information architecture and navigation¶
The navigation is grouped by what the user is doing, most frequent first:
| Group | Entries | Task flow |
|---|---|---|
| Fleet | Dashboard /, Instances /instances |
see fleet health and stamina; add, tune and inspect *arr instances |
| Activity | History /history, Live /live |
audit runs; watch dispatches as they happen |
| Automation | Schedules /schedules |
pause windows and cap changes |
| Integrations | Download clients, Seerr, Prowlarr, Configarr | connect the systems around the *arr apps |
| System | Settings /settings, Logs /logs (admins only) |
notifications, retention, diagnostics |
| Footer | Account /account, language, log out |
personal settings, out of the task list |
- Every entry carries an icon through
@sveltesentio/ui/icons(Lucide set via the@lucide/sveltepeer), never inline SVG copies. - Desktop (1024 px and wider): fixed 240 px sidebar with group labels; the active entry gets a tinted background and a 3 px brand-colour left border.
- Below 1024 px: a top bar with the mark, the name and a menu button; the navigation opens as a modal drawer (focus trapped, Escape and backdrop close it, focus returns to the menu button). The page content starts on the first screen (#442).
- Every route keeps its URL.
/accountmoves from the main list to the footer.
4. Page layouts¶
- Content column: max width 80rem, 1.5 rem gutter (1 rem on phones), 8 px spacing grid.
- Header band on every page: title, one-line description, and the page's primary action on the right ("+ Add instance", "+ Add schedule", ...).
- Create flows open a dialog from that action (#445). The dialog is a native modal
<dialog>opened withshowModal(), or@sveltesentio/ui/dialog, which wraps the same behaviour: backdrop, inert page, focus moved into the dialog, focus back to the opener on close. An inline panel that overlaps the page is not a dialog and fails review. - Major lists use
@sveltesentio/ui/data/table(#447): instances, history, schedules, download clients, Seerr requests, notification channels and rules, API keys, logs. Wide tables scroll inside their card; the page never scrolls sideways. - Instance detail is split into subtabs that are child routes (#444):
/instances/[id](overview and edit),/instances/[id]/policy,/instances/[id]/queue,/instances/[id]/activity,/instances/[id]/memory. Child routes give each tab a deep link, working back and forward buttons and its own code chunk; the static adapter's fallback page serves them like any other route. An unknown id shows a not-found state (#433). - Dashboard: cards in a grid, one column on phones, two from 768 px, three from 1280 px.
- Every list has an empty state with the mark artwork, one sentence and the primary action (#431, #432). Every delete asks for confirmation (#428 to #430).
- Errors render as
ProblemNotewith a retry action (#437), in the region that failed, never as a full-page replacement (#436).
5. A UI that never blocks¶
Installed versions (from web/pnpm-lock.yaml on 2026-10-19), against whose docs these
idioms were checked: svelte 5.57.2, @sveltejs/kit 3.0.1, @sveltejs/adapter-static
4.0.0, @tanstack/svelte-query 6.3.2, @sveltesentio/ui 0.7.0, @sveltesentio/query 0.5.0,
@playwright/test 1.64.0, @axe-core/playwright 4.13.0.
- The shell (sidebar or top bar, header band, content frame) renders on the first frame.
The root layout's
readygate goes. The session request runs in the background; until it answers, data regions show skeletons and no protected data is fetched. A 401 sends the user to/login. No authentication hint is stored inlocalStorage: the session cookie stays the only source of truth. - Server state goes through TanStack Query (
@sveltesentio/query): cached data renders at once and refreshes in the background; first loads show a skeleton of the region's shape. Mutations update the cache optimistically, roll back on error, and report the outcome as a toast (@sveltesentio/ui/toast). - Long work (instance probe, Configarr import, run dispatch) is a background request with a pending state on the button that started it and a toast when it ends; SSE events update the views that show it.
- Each data region sits in a
<svelte:boundary>with afailedsnippet, so a render error replaces that region only (Svelte docs,svelte-boundary). - Not adopted: Svelte's
awaitin markup and$derived. It still needs theexperimental.asynccompiler flag, and its details may change outside a semver major release (Svelte docs,await-expressions). Revisit when the flag is removed. - Not available: SvelteKit form actions and remote functions. Both run on a SvelteKit
server, which an
adapter-staticSPA does not deploy (SvelteKit docs,single-page-apps). Forms submit through query mutations to the Go API. - Budgets: Lighthouse CI on the built SPA, Total Blocking Time at most 150 ms, Cumulative Layout Shift at most 0.05 and Largest Contentful Paint at most 1.2 s. A Lighthouse navigation run has no interactions and so reports no INP. web.dev names TBT as the lab stand-in. INP is measured directly in Playwright during scripted flows (nav click, dialog open, form typing) through the Event Timing API: the worst interaction must stay at or under 200 ms, web.dev's "good" threshold, and the measured values are recorded in the pull request.
6. What stays and what goes from the newtarr template¶
| Part | Decision | Reason |
|---|---|---|
Flat sidebar in +layout.svelte |
replace | grouped navigation with icons and a phone drawer (section 3) |
ready loading gate in +layout.svelte |
replace | blocks the whole UI on one request (section 5) |
| Language select and log out in the middle of the nav | replace | moved to the sidebar footer |
| Inline create forms under lists (instances, schedules, download clients, Seerr links, notification channels and rules) | replace | "+ Add" dialog (section 4) |
Hand-written tables in route files and RunList, ScheduleTable, PreviewPanel |
replace | DataTable with a scroll container (section 4) |
InstanceDetail.svelte as one long page |
replace | child-route subtabs; its sections become one component per tab |
Neutral grey theme in gen-theme.ts |
replace | palette A preset (TOK1) |
Duplicate change-password form on /settings (#434) |
replace | /account keeps the only copy |
Raw Tailwind colours on /live and /logs (#440) |
replace | token classes (TOK1) |
/login, /setup plain forms |
keep the forms, replace the frame | centred card with the mark (#446) |
FormCard, Field, TextField, SecretField, KindSelect, InstanceSelect |
keep | already token-based and labelled; reused inside dialogs; hand-rolled forms move onto them (#435) |
ConfirmDelete, ConfirmAction |
keep | in-place confirm step beside the action; added where deletes lack it (#428 to #430) |
ProblemNote |
keep, extend | RFC 9457 display; gains a retry action (#437) |
CheckState, StaminaGauge |
keep, fix | fit the new cards; gauge colours move to tokens (#418) |
PolicyFilters, QueuePolicy, QueueStrikes, ResetMemory, LinkForm, SeerrLinkForm, DownloadClientForm, InstanceEditForm, ScheduleForm |
keep | domain forms; they move into the policy, queue and memory subtabs or into create dialogs unchanged |
ApiKeyDialog on /account |
keep | already a native modal dialog; the model for section 4 |
session.svelte.ts |
keep, change | stays the session store; loses the blocking first refresh |
| Paraglide messages (en, de) | keep | every new string goes through them |
7. How implementation rounds are checked¶
Each round closes milestone issues and shows, in its pull request:
- Playwright screenshots before and after at 1280x800 and at phone width (390x844), light and dark, wherever navigation or layout visibly changes.
- axe at zero violations on every touched route, and the keyboard and screen-reader passes of A11Y1.
- For the round that removes the
readygate:nonblocking.test.tsred on the old layout and green on the new one; later rounds keep it green.
Consequences¶
- SnatchArr pins a released
@sveltesentio/uithat containspresetSnatchArr. Until that release exists,gen-theme.tskeeps its current tokens, and no round adds local colour values in the meantime. - Lighthouse CI and the INP measurement add one CI job; its runtime is part of the first implementation round's pull request.
- Child-route subtabs change the instance detail URLs; the old
/instances/[id]URL still opens the overview tab, so existing links keep working. - The not-adopted items (
@workflow/sveltekit,@sentry/sveltekit, Svelte asyncawait) are revisited if SnatchArr ever gains a SvelteKit server or Svelte removes the experimental flag.