Admin UX review and design system
Date: 2026-11-05. Scope: packages/admin (the React admin at /admin). Goal set by the product owner: review the admin screens of enterprise Drupal, WordPress and the leading SaaS/headless platforms, and make Spillway's admin look and feel enterprise, stay accessible, and lead on user experience.
This document has four parts: (1) what the other platforms do, (2) a critique of the Spillway admin before this round, (3) Spillway's admin design principles and the concrete spec that was implemented, (4) the accessibility audit and verification results.
1. Platform review
Sources were read on 2026-11-05; vendor documentation and review-site snippets are marked as such. Where a claim could not be verified it is flagged.
Drupal: Claro, Gin, Navigation, Drupal CMS
- Navigation model. Drupal core committed the Navigation module as the default admin navigation in place of Toolbar (March 2026): a collapsible vertical sidebar with submenu drawers for deeper levels and a top bar built with the Experience Builder, Gin and core teams (The Drop Times, DrupalCon Atlanta session, Drupal CMS track). Gin offers Sidebar, Vertical toolbar (default), Horizontal and Legacy layouts (Gin settings).
- Density, typography, spacing. Claro uses a 16 px base, system fonts, a small modular scale, 14 px form labels / 16 px values, tall table rows and smaller buttons for tables, and a neutral palette with one vivid blue for primary actions (Claro design system). It is deliberately airy, which editors describe as comfortable but low-density.
- Tables and lists. Views-driven lists with exposed filters, bulk actions (VBO) and sticky table headers; column choice is a site-builder task, not an editor affordance.
- Content editor. Gin keeps Save, Preview and the published state in a sticky top bar; secondary actions fold into an overflow menu; the "meta" sidebar (author, URL alias, menu, revisions) sits on the right and can be collapsed; a toggle shows or hides field descriptions (Gin docs, Drupal.org Gin).
- Status and workflow. Content Moderation states appear as a select in the sidebar; the Moderation Dashboard contrib module gives per-user blocks for "needs review" and "my drafts" (moderation_dashboard).
- Dashboard. Drupal CMS ships a Dashboard module built on Layout Builder (Varbase documents the same pattern) with blocks for recent content, moderation and shortcuts (Varbase dashboards).
- Search / command palette. Coffee (contrib) is the de-facto command palette (Alt+D); it is not in core.
- Dark mode. Gin: light, dark and auto (follows the OS) (Gin settings).
- Accessibility posture. Claro states WCAG 2.1 AA conformance, consistent focus areas, contrast-checked text, high-contrast mode testing and 24×24 px icon targets (Claro accessibility). Gin carries an open accessibility meta issue from the accessibility team's audit, including a dark-mode layout defect and the note that automated tools cannot check contrast of semi-transparent colours (Gin a11y meta).
- Praise / complaints. Praised: Gin's focus on the editing form, sticky actions, accent and focus colour settings (ImageX). Complaints: regulated sites still default to Claro because contrib themes need extra review (Vardot); the admin remains page-reload heavy and visually dated next to SaaS tools.
WordPress 7.0 ("Armstrong", May 2026)
- First admin redesign since 2013: the "Modern" palette becomes the default, a ⌘K command palette sits in the top bar, screen changes use the View Transitions API, and DataViews replaces
WP_List_Tableon Posts, Pages and Media (AlternativeTo, Yoast, WPBrigade). - DataViews brings table/grid/list layouts, saved filters, column visibility and bulk actions to every list; this is the closest open-source benchmark for Spillway's DataTable.
- The block editor adds viewport visibility controls, per-block CSS, Breadcrumbs and Icons blocks and a visual revision diff with a slider (green additions, yellow changes, red deletions) (Yoast).
- Complaints: the admin still keeps the 2013 information architecture (left menu of everything), plugin screens do not follow the new palette, and real-time collaboration slipped to 7.1 (Raidboxes).
Contentful
- Navigation: top bar (Space, Content, Media, Content model, Apps) with a left sidebar inside Content holding private and shared saved views grouped in folders; admins can customise the sidebar per role (Saved views, Content tab).
- Entry editor: fields in the centre, a right sidebar with status/publish, versions (past published versions now listed in the sidebar for comparison), links, translations; the sidebar is configurable per content type (changelog, custom sidebar).
- Praise: fast editor onboarding, saved views. Complaints (G2/Capterra, review sites so treat as anecdotal): "confusing at first", hard to find a page or paragraph on large sites, rigid form-based editing with no front-end context, drafts that never get published, data model "optimised for Contentful's internal reasons" (G2, Capterra).
Sanity Studio
- Structure tool: configurable pane-based navigation (lists → document) built with the Structure Builder; document lists cap at 2000 items (Structure tool).
- Theme:
schemelight/dark/system with an Appearance menu;usePrefersDarkwrapsprefers-color-scheme(StudioProps, usePrefersDark). - Accessibility: Studio is built on Sanity UI, a component library whose stated aim is accessibility and consistency; no published conformance claim was found (Sanity Studio).
- Praise: speed, real-time, developer control. Complaints: everything is code-configured, marketers depend on developers for list views and workflows.
Storyblok
- Visual editor with live preview and reusable components is the headline feature; marketers praise that non-technical users edit without engineers (G2, Gartner).
- Complaints: pricing, setup still needs developers, limited pipeline visibility and migration pain, weak export (Gartner review Sept 2025).
Sitecore XM Cloud (Pages)
- Pages is a WYSIWYG, auto-saving editor; authors praise the visual editing; Forrester's vendor-commissioned TEI claims up to 60 % faster page creation (TrustRadius, Forrester TEI).
- Complaints: features from XP/XM missing in the new authoring experience, documentation gaps, limited mobile experience (G2). One university page says XM Cloud was renamed SitecoreAI on 10 Nov 2025 (single source).
Adobe Experience Manager Sites
- Universal Editor: in-context WYSIWYG for pages and content fragments; Content Fragments Console: a dense tabular view that maximises fragments per screen, with multi-select actions limited to what applies to all selected items (Universal Editor, CF console).
- Complaints: navigation entries missing between environments (KB 39866), editing fragments without leaving the page still needs setup (community thread).
Payload 4 (beta)
- Redesigned admin on a new design system with unified tokens for colour, typography, spacing, radius and elevation; collapsible sidebar open by default; sidebar tabs for folders and tags; better table cells and bulk actions; query presets to save and share filters, columns and sort; improved focus states and an enhanced-contrast mode; Sass removed (Payload 4 beta, List view docs, Admin overview). A June 2026 tracker still lists 4.0 as not stable.
Directus
- Collection pages have layouts (table, cards, calendar, map); presets store sort, filter and layout per user/role/global, and a named preset becomes a bookmark; defaults resolve user → role → global (Presets & bookmarks, Layouts).
Webflow
- Praise: visual building without code. Complaints: steep learning curve and an overwhelming interface for new adopters, CMS limits (60 fields, 10 reference fields), designer performance on large projects, tangled pricing (G2, Capterra, TrustRadius).
HubSpot Content Hub
- Praise: approachable drag-and-drop editor, CRM integration. Complaints: templates need HubL, weak blog editor, limited version control below Enterprise, generic-looking output, and "frequent updates can be overwhelming" (interface churn) (Capterra, TrustRadius, G2).
Linear and Notion (the density and polish benchmark)
- Linear: Cmd+K as the universal entry point with fuzzy search; mnemonic single-key shortcuts taught through tooltips; high density with low clutter through consistent spacing, muted colour and progressive disclosure; keyboard-first on desktop, with mobile explicitly secondary (925 Studios, buildmvpfast, DEV).
- Notion: a blank default state with power behind the keyboard, an understated type scale (40/30/24/20/16) and 16 px body at 1.5 line height, a restrained palette where each colour has a semantic role (Notion shortcuts, design study).
What marketers consistently praise and complain about
| Praise | Complaint |
|---|---|
| Visual editing with live preview (Storyblok, Sitecore, HubSpot, Webflow) | Needing a developer for lists, views and workflows (Sanity, Contentful, Storyblok) |
| Saved views / presets (Contentful, Directus, Payload, WordPress DataViews) | Finding things on large sites (Contentful) |
| One sticky action bar in the editor (Gin) | Interface churn (HubSpot) and dated, reload-heavy admin (Drupal, WordPress plugins) |
| Command palette and shortcuts (Linear, WordPress 7) | Overwhelming first run (Webflow, Contentful) |
| Dark mode and OS-following themes (Gin, Sanity) | Accessibility regressions in dark mode (Gin audit) |
2. Critique of the Spillway admin before this round
Screenshots of the previous build are in docs/screenshots/before/. The design:design-critique framework (first impression, usability, hierarchy, consistency, accessibility) was applied to the dashboard, content list, entity editor, page builder, settings, media and the mobile dashboard.
Overall impression
Clean and quiet, but generic: every sidebar entry rendered as a blue hyperlink, no icons, no grouping affordance beyond a label, no global search, and a dashboard that led with a counts table rather than what the signed-in person should do next. It read as a competent internal tool, not an enterprise product.
Usability
| Finding | Severity | Fix |
|---|---|---|
The primary LinkButton ("New page", "New form", …) rendered blue text on a blue button: the global #root a { color } rule beat .btn-primary, so the label was invisible (visible in before/content-list.png). | Critical | Button colours are set on the component with higher specificity; global link colour no longer leaks into .btn. |
| No global search or command palette; reaching a screen meant scanning a 20-item sidebar. | Moderate | Ctrl/Cmd+K palette with screens, actions and content search; / and ? shortcuts; top-bar search field. |
| Settings tabs sat above the page title, inverting the hierarchy ("Site / Theme / Brand…" then "Site settings"). | Moderate | PageHeader renders the title first, tabs under it. |
| Media empty state was a sentence with no call to action and no list view. | Moderate | Icon + title + description + Upload button; grid/list toggle; list view is a DataTable. |
| Lists had no sorting, no column choice, no density control; "Search" needed a click; selection existed only on the content list. | Moderate | DataTable with sortable headers, View menu (columns + density), pagination summary, selection prop. |
| The page builder header was Puck's default (plain "← Back", unstyled buttons), visually unrelated to the rest of the admin. | Moderate | Custom header: back, trail, status, unsaved indicator, undo/redo and panel toggles in the admin's tokens. |
| The AI triggers ("✦ AI") were low-contrast grey; "Generate from title" looked like body text. | Minor | Accent-coloured AI trigger; button sizing. |
| No dark theme; no user preference. | Minor | Light/dark tokens, OS-following by default, Auto/Light/Dark in the account menu, persisted. |
Visual hierarchy
What drew the eye first was the sidebar (a column of blue links), not the page title or the primary action. Reading flow on the dashboard went counts table → tasks → traffic zeros → review item; the thing a reviewer needs (the review queue) came fourth. Cards had equal weight regardless of importance.
Consistency
| Element | Issue | Fix |
|---|---|---|
| Type | Four ad-hoc sizes (11, 12, 12.5, 13.5, 14, 16, 20 px) | Eight-step scale, tokens only |
| Headings | Breadcrumb-only editors had no h1; the theme tab had two | One h1 per screen (visually hidden where the action bar is the header) |
| Badges | Colour was the only cue for state | Dot + label + colour |
| Buttons | Ghost "Back" buttons, text-only icons (↗, ×, ✦, ▸) | One icon set, consistent sizes and affordances |
| Empty states | Plain sentences or table rows | Icon, title, description and action |
Accessibility (before)
Contrast: --text-faint (#9a9aa3) on white was 2.9:1, used for timestamps and group labels (fail). Focus rings were present. Target sizes: several 20 px controls. Touch: the off-canvas sidebar existed but the topbar had a text "Menu" button only. Screen reader: tables had captions and labelled checkboxes (good); icon-only controls were few.
3. Spillway admin design principles
- Lead with the work, not the structure. The dashboard greets the person and shows what needs doing (review, scheduled, drafts). Lists lead with the primary action. The sidebar is grouped by job (Content, Marketing, Structure, System), not by module.
- Dense, not cramped. 14 px body, 13 px in tables, 34 px controls, 4 px spacing scale. Density is a user choice on every table (compact/normal/comfortable) and is remembered.
- One header, one primary action. Every screen has a trail, a title, a one-line description and actions aligned right with the primary action last. Editors keep actions in a sticky bar so Save is never off-screen.
- Keyboard first, pointer complete. Ctrl/Cmd+K reaches any screen or action; arrow keys move through rows and sidebar links; every dialog traps focus and returns it. Nothing requires dragging (the builder's drag and drop has a click path through the component list).
- State is never colour alone. Status badges carry a dot and a label; sort direction has an arrow and
aria-sort; unsaved state has a dot and text. - Two themes, one contract. Every colour is a semantic token with a light and dark value; both pass WCAG AA for the pairs they are used in (table in section 4).
prefers-color-schemewins until the person chooses. - Nothing moves unless asked. All motion is 120–180 ms and switched off under
prefers-reduced-motion. - Preferences are local and cheap. Column visibility, density, sidebar state, media layout and theme live in
localStorageper browser; nothing about them needs a server round trip.
4. The spec (as implemented)
Design tokens (src/styles.css)
Type scale (px): 11 caption · 12 meta · 13 table/body-small · 14 body · 16 lead/h3 · 18 h2 · 22 h1 · 28 display. Line height 1.5 (1.25 for headings). Font: Inter if installed, then the system stack; monospace for keys, paths and code. No web font is downloaded (privacy and offline installs).
Spacing (4 px base): 4, 8, 12, 16, 20, 24, 32, 40, 48 (--sp-1 … --sp-12). Page gutter 28 px (16 under 900 px, 12 under 420 px).
Radii: 4 (controls, chips), 6 (buttons, inputs, menu items), 10 (cards, tables), 14 (dialogs, palette), 999 (badges, avatars).
Elevation: --shadow-1 (cards: 1 px ambient + hairline), --shadow-2 (menus, popovers, sticky bars), --shadow-3 (dialogs, toasts, palette). Dark values use heavier black and a faint white edge.
Layout: sidebar 248 px (60 px collapsed), top bar 52 px, content max-width 1280 px (1360 for editors).
Semantic colours and their measured contrast ratios (WCAG 2.x relative luminance; computed from the tokens by scratchpad/contrast.mjs on 2026-11-05):
Light
| Use | Pair | Ratio | Required | Result |
|---|---|---|---|---|
| Body text on cards | --text #16191f on --surface #ffffff | 17.60:1 | 4.5:1 | pass |
| Body text on canvas | --text #16191f on --bg #f5f6f8 | 16.28:1 | 4.5:1 | pass |
| Secondary text on cards | --text-muted #525a66 on --surface #ffffff | 6.97:1 | 4.5:1 | pass |
| Secondary text on table header / tiles | --text-muted #525a66 on --surface-2 #f1f3f6 | 6.27:1 | 4.5:1 | pass |
| Placeholder, timestamps, group labels | --text-faint #646c79 on --surface #ffffff | 5.30:1 | 4.5:1 | pass |
| Faint text on surface-2 | --text-faint #646c79 on --surface-2 #f1f3f6 | 4.77:1 | 4.5:1 | pass |
| Links | --accent-text #1c47b4 on --surface #ffffff | 8.05:1 | 4.5:1 | pass |
| Active nav item, info badge | --accent-text #1c47b4 on --accent-soft #e6edfc | 6.85:1 | 4.5:1 | pass |
| Primary button label (white on accent) | --white #ffffff on --accent #2456d4 | 6.26:1 | 4.5:1 | pass |
| Published badge | --success #176b3e on --success-soft #dcf5e6 | 5.69:1 | 4.5:1 | pass |
| In review badge | --warning #7d4b00 on --warning-soft #fff1d2 | 6.52:1 | 4.5:1 | pass |
| Error badge / form error | --danger #b8232b on --danger-soft #fde8e9 | 5.40:1 | 4.5:1 | pass |
| Success text on card | --success #176b3e on --surface #ffffff | 6.54:1 | 4.5:1 | pass |
| Warning text on card | --warning #7d4b00 on --surface #ffffff | 7.30:1 | 4.5:1 | pass |
| Danger text on card | --danger #b8232b on --surface #ffffff | 6.34:1 | 4.5:1 | pass |
| Toast and tooltip text | --text-inverse #ffffff on --surface-inverse #1a1d24 | 16.87:1 | 4.5:1 | pass |
| Focus ring vs card (non-text 3:1) | --focus #2456d4 on --surface #ffffff | 6.26:1 | 3:1 | pass |
| Focus ring vs canvas | --focus #2456d4 on --bg #f5f6f8 | 5.79:1 | 3:1 | pass |
| Input border (non-text 3:1) | --border-input #8a919b on --surface #ffffff | 3.18:1 | 3:1 | pass |
| Primary button vs card (non-text) | --accent #2456d4 on --surface #ffffff | 6.26:1 | 3:1 | pass |
| Muted text inside selected row | --text-muted #525a66 on --accent-soft #e6edfc | 5.94:1 | 4.5:1 | pass |
Dark
| Use | Pair | Ratio | Required | Result |
|---|---|---|---|---|
| Body text on cards | --text #e8eaef on --surface #171a21 | 14.46:1 | 4.5:1 | pass |
| Body text on canvas | --text #e8eaef on --bg #0f1116 | 15.69:1 | 4.5:1 | pass |
| Secondary text on cards | --text-muted #a9b0bd on --surface #171a21 | 7.99:1 | 4.5:1 | pass |
| Secondary text on table header / tiles | --text-muted #a9b0bd on --surface-2 #1e222b | 7.30:1 | 4.5:1 | pass |
| Placeholder, timestamps, group labels | --text-faint #8d94a2 on --surface #171a21 | 5.71:1 | 4.5:1 | pass |
| Faint text on surface-2 | --text-faint #8d94a2 on --surface-2 #1e222b | 5.22:1 | 4.5:1 | pass |
| Links | --accent-text #8fb0ff on --surface #171a21 | 8.14:1 | 4.5:1 | pass |
| Active nav item, info badge | --accent-text #8fb0ff on --accent-soft #1a2746 | 6.90:1 | 4.5:1 | pass |
| Primary button label (white on accent) | --white #ffffff on --accent #3b6fe3 | 4.60:1 | 4.5:1 | pass |
| Primary button label on hover (Phase 4: was #5583ea at 3.61:1) | --white #ffffff on --accent-hover #3366d6 | 5.23:1 | 4.5:1 | pass |
| Published badge | --success #5ed79a on --success-soft #11301f | 7.95:1 | 4.5:1 | pass |
| In review badge | --warning #f3c06a on --warning-soft #3a2a0a | 8.29:1 | 4.5:1 | pass |
| Error badge / form error | --danger #ff8b8b on --danger-soft #3d1717 | 6.99:1 | 4.5:1 | pass |
| Success text on card | --success #5ed79a on --surface #171a21 | 9.67:1 | 4.5:1 | pass |
| Warning text on card | --warning #f3c06a on --surface #171a21 | 10.41:1 | 4.5:1 | pass |
| Danger text on card | --danger #ff8b8b on --surface #171a21 | 7.73:1 | 4.5:1 | pass |
| Toast and tooltip text | --text-inverse #16191f on --surface-inverse #f3f4f7 | 16.01:1 | 4.5:1 | pass |
| Focus ring vs card (non-text 3:1) | --focus #8fb0ff on --surface #171a21 | 8.14:1 | 3:1 | pass |
| Focus ring vs canvas | --focus #8fb0ff on --bg #0f1116 | 8.83:1 | 3:1 | pass |
| Input border (non-text 3:1) | --border-input #6b7383 on --surface #171a21 | 3.65:1 | 3:1 | pass |
| Primary button vs card (non-text) | --accent #3b6fe3 on --surface #171a21 | 3.79:1 | 3:1 | pass |
| Muted text inside selected row | --text-muted #a9b0bd on --accent-soft #1a2746 | 6.77:1 | 4.5:1 | pass |
--border and --border-strong are decorative dividers (cards, table rows) with no contrast requirement; inputs use --border-input. Theme selection: prefers-color-scheme by default; html[data-theme="light|dark"] overrides it; the choice is stored under spillway.admin.theme and applied before first paint (initTheme() in main.tsx). The page builder maps the tokens onto Puck's --puck-color-* variables so the builder follows the theme.
Layout
- Sidebar (
shell/Shell.tsx): grouped sections with icons (Dashboard / Review queue with a waiting count / Analytics; Content: one entry per content type, Media, Collections, Shared blocks, Vocabularies, Alerts; Marketing; Structure; System). Collapsible to a 60 px icon rail with CSS tooltips (hover and keyboard focus) andaria-labels; the state is remembered (spillway.admin.sidebar) and toggled by the top-bar button, the footer button or[. Arrow keys, Home and End move between links. Under 900 px it becomes an off-canvas drawer with a scrim and a Menu button; it works down to 360 px. - Top bar: sidebar toggle, site switcher (menu with the current site, View site and a Fleet placeholder), global search field (opens the palette, shows ⌘K), View site, Notifications (review-queue count; placeholder for a feed), account menu (theme radio group, API tokens, preferences, shortcuts, log out).
- Breadcrumbs: screens declare a trail through
PageHeader crumbsoruseBreadcrumbs(); otherwise the shell derives Dashboard › Group › Screen from the sidebar. The trail respects the unsaved-changes guard. - Page header (
components/PageHeader.tsx): eyebrow, title, meta badges, description, actions (primary last), optional tabs;level="sub"for section headers inside tabbed screens. - Right rail: editors keep a 320 px sticky rail (URL, Checks, Comments, Revisions, Details) that stacks under the form below 900 px.
- Skip links: "Skip to content" and "Skip to navigation".
Components
- DataTable (
components/DataTable.tsx;Tableis the compatibility export): sortable columns (aria-sort, client sort or controlled server sort, used server-side on the content list), a View menu with density (compact/normal/comfortable) and a column chooser (menuitemcheckbox), per-table persistence (spillway.table.<key>), sticky header, row selection with an indeterminate select-all, expandable rows (submissions), skeleton rows while loading, icon + title + description + action empty states, a pagination footer with "Showing x–y of n", ArrowUp/Down/Home/End between rows and Enter/Space to open, and a card layout under 720 px driven bydata-label. Adopted by: content lists, media (list view), form submissions, redirects, collections, users, review queue and scheduled, modules, experiments, audiences, vocabularies, menus, shared blocks, alerts, kits (list view), content types and the dashboard's recent list. - Form layout (
components/Form.tsx):FormSection(titled, described,aria-labelledby),FormGrid,FormActions(sticky bottom bar with a live "Unsaved changes / Saving… / All changes saved" status and the actions, primary last). Adopted by the site settings tab, the content-type editor, the form editor and the collection editor; the entity editor keeps its sticky top action bar (status, saved state, Preview, Schedule, Unpublish, Reject, Submit, Save draft, Publish). - Status badges (
components/Badge.tsx): draft/review/published/archived plus neutral/info/success/warning/danger tones, each with a dot. - Empty, loading and error states (
components/EmptyState.tsx):EmptyStatewith icon and heading level,Skeleton/SkeletonCard(shimmer, hidden from AT, off under reduced motion),LoadingState,ErrorStatewith retry. - Toasts (
components/Toast.tsx): polite live region; success/info arestatus, errors arealert; icon, left accent, dismiss button. - Dialogs (
components/Modal.tsx): rendered in a portal, the app root isinertwhile any dialog is open (nested dialogs share a counter), focus trapped and restored, Escape closes,aria-labelledby/aria-describedby. - Command palette (
shell/CommandPalette.tsx): Ctrl/Cmd+K or/; groups Content (FTS search over published and, withcontent.view_drafts, draft content), Go to (every permitted screen) and Actions (New <type> withcontent.create, Upload media withmedia.upload, Invite a user withusers.manage, theme, sidebar, View site, API tokens, preferences, shortcuts, log out); combobox + listbox semantics witharia-activedescendant; fuzzy ranking; the unsaved guard applies to navigation. - Icons (
components/icons.tsx): 68 hand-drawn 24 px outline icons, 1.75 px stroke, decorative by default,titlefor named use. - Keyboard shortcuts: Ctrl/Cmd+K palette,
/search,?help dialog,[sidebar, arrows in lists and the sidebar, Esc everywhere.
Interaction rules
- Unsaved guard: editors set a dirty flag; sidebar links, breadcrumbs, the palette and
beforeunloadask before leaving. (Stillwindow.confirm; a styled dialog needs an async guard, see "Not finished".) - Optimistic updates: list mutations (toggle, delete, rename) update the local list immediately and reconcile with the response; bulk actions reload.
- Loading: skeletons for tables and dashboard cards; spinners only on buttons that are busy (
aria-busy). - Density: a per-table preference, never global.
- Motion: 120–180 ms ease-out for menus, dialogs, toasts; none under reduced motion.
Accessibility requirements (WCAG 2.2 AA, ATAG 2.0 Part A)
| Requirement | How it is met |
|---|---|
| 1.4.3 / 1.4.11 contrast | Token pairs above; inputs use a 3:1 border; focus ring 6.26:1 (light) / 8.14:1 (dark) |
| 2.1.1 keyboard | All controls are native buttons/links/inputs; rows and sidebar links take arrows; palette and dialogs fully keyboard-driven |
| 2.4.3 focus order | DOM order matches visual order; dialogs and the palette trap focus; skip links first |
| 2.4.7 focus visible | 2 px accent outline with 2 px offset on :focus-visible everywhere |
| 2.4.11 Focus not obscured (min) | Sticky bars are translucent and the page keeps 96 px bottom padding so a focused control is never fully hidden; audit tabbed 20 controls per route with 0 obscured |
| 2.5.7 Dragging movements | Nothing in the admin requires drag; the builder offers click-to-insert through Puck's component list and the AI "Build from brief" |
| 2.5.8 Target size (min) | Buttons ≥ 28 px, icon buttons 34 px, sort headers and links ≥ 24 px; 18 px checkboxes rely on the spacing exception (≥ 24 px row pitch, no adjacent targets) |
| 3.2.6 consistent help | Help & shortcuts is in the same sidebar slot and the account menu on every screen |
| 3.3.7 redundant entry | Setup and login never re-ask for data already given |
| 3.3.8 Accessible authentication (min) | Password managers and paste are allowed (autocomplete="email", current-password, new-password); no CAPTCHA or puzzle; SSO is one click |
| 4.1.3 status messages | Toasts, save status and loading states are live regions |
| Reduced motion | prefers-reduced-motion zeroes every animation and transition |
| 200 % zoom / reflow | Verified at 680 px (200 % of 1360) and 360 px: no horizontal scroll; tables fall back to cards |
| Screen reader names | Every icon-only control has aria-label; tables have captions; sort buttons announce direction; palette options are role="option" |
| ATAG 2.0 Part A | The authoring UI is itself accessible (above). Part B (helping authors produce accessible content) is covered by the pre-publish checks (image alt, heading order, link targets) that block publishing |
5. Audit and verification
Run on 2026-11-05 against the built admin with Playwright (scratchpad/audit.mjs). axe-core is not available offline in this environment, so the audit is checklist-driven.
Route crawl: all 40 routes from App.tsx (lists, editors with real ids, every settings tab, the page builder, the 404) loaded with 0 page errors and 0 console errors (apart from expected 4xx responses the UI handles).
Per route (same result on every route unless noted): one h1; 0 unnamed buttons/links; 0 unlabelled inputs; tabbing through the first 20 focusable elements: 0 without a visible ring, 0 obscured; no positive tabindex; no horizontal scroll. The page builder is excluded from tabbing (Puck owns it) and reports one unlabelled control inside Puck's own UI (its viewport zoom select).
Scenarios:
| Check | Result |
|---|---|
| 200 % zoom (680 px) on dashboard, list, editor, settings, media | no horizontal scroll |
| 360 px on dashboard, list, editor, settings, users; off-canvas nav | no horizontal scroll; drawer visible |
Dark theme via prefers-color-scheme | body rgb(15, 17, 22), 0 errors |
prefers-reduced-motion | spinner animation-duration: 1e-06s |
Ctrl+K opens the palette; root is inert; typing "users" selects Users; Enter navigates | pass |
? opens the shortcuts dialog; focus trapped over 6 tabs; Escape closes | pass |
Sidebar collapse keeps accessible names; [ toggles | pass |
| Table: ArrowDown moves between rows; Enter opens | pass |
| Contrast pairs (light + dark) | 44/44 pass |
Build and tests: pnpm --filter @spillway-cms/admin typecheck and build pass; node e2e/smoke.mjs 14/15 (the known drag-and-drop limitation); node e2e/smoke-phase2.mjs 34/34. The smoke scripts were not changed: every label and button name they rely on was kept.
6. What changed (files)
src/styles.css: rewritten design system (tokens, light/dark, shell, components, screens, responsive, print).src/components/icons.tsx(new),DataTable.tsx(new;Table.tsxre-exports it),Form.tsx(new),PageHeader.tsx(trail, eyebrow, meta, tabs, sub level),Button.tsx(icons, icon-only),Badge.tsx(dots, tones),EmptyState.tsx(icons, skeletons, heading level),Modal.tsx(portal,inert, focus trap),Toast.tsx(icons, roles).src/shell/Shell.tsx(rewritten),shell/CommandPalette.tsx(new),shell/breadcrumbs.tsx(new),hooks/useTheme.ts(new),lib/inert.ts(new),main.tsx(theme init).- Screens: Dashboard (rewritten), ContentList (server-side sort, selection, saved columns), MediaScreen (grid/list, empty state), SettingsScreen (tabs under title, sections, sticky save), SubmissionsScreen (expandable DataTable), every list screen (DataTable features), TypeEditor/FormEditor/CollectionEditor (sections + sticky actions + trail), EntityEditor/EntityActions/EntityRail/LockBanner (trail, icons, unsaved dot,
h1), LayoutEditor (custom Puck header), Login/Setup (brand), NotFound. - Two pre-existing type errors from shared-contract additions (
workstream.merged,sites.manage) were fixed in the admin label maps. README.mdscreenshots table;docs/screenshots/replaced, previous set kept indocs/screenshots/before/.
7. Not finished / follow-ups
- Audit-log activity feed. The dashboard's "Recent activity" derives verbs from entity timestamps (created / updated / submitted / published) because core exposes no
GET /api/audit. When core adds one, replaceactivityVerb()inDashboard.tsxwith the feed. - Styled unsaved-changes dialog. The guard is still
window.confirm; replacing it needs an asyncconfirmLeaveand a router blocker. - Saved filters. Column and density preferences are saved per table; named saved views (Contentful/Directus style) need a server-side store so they can be shared per role.
- Settings tabs other than Site, Languages, Media and Forms keep their Save button in the tab's sub-header rather than a sticky bar (Theme, Brand, AI, Notifications, Integrations, Sign-in); the content area already uses cards. Converting them is mechanical (Media and Forms were converted in the Phase 4 pass below).
Notifications feed.Done in Phase 3: the bell polls the review queue, the user's returned drafts and the items scheduled for today (shell/useNotifications.ts); an audit-backed feed can replace the polling when core exposes one.Site switcher.Done in Phase 3:shell/SiteSwitcher.tsxlistsGET /api/me/sites, stores the choice and sendsX-Spillway-Siteon every call; the workstream switcher and banner sit beside it.- Axe. Run
@axe-core/playwrightin CI once the package is available; until thene2e/a11y-crawl.mjs(section 8) is the gate.
8. Phase 4 accessibility crawl (e2e/a11y-crawl.mjs)
Date: 2026-11-05, over the screens added in Phase 4. The Phase 3 audit lived in a scratch script; this one ships in the repo and runs against the built server: pnpm build && node e2e/a11y-crawl.mjs. It exits non-zero on any serious or critical finding and prints every finding as a table (route, element, WCAG criterion, severity, check, detail). A11Y_QUICK=1 runs light/1280 only, A11Y_ONLY=<regex> picks states, A11Y_COMBOS=light:390,dark:1280 picks theme and width, A11Y_OUT=<file> writes JSON, A11Y_DEBUG=1 adds computed styles and screenshots of obscured controls.
How it runs. The script starts the built app in its own process (packages/core/dist/app.js plus the ticker) with REGISTRY_HOST=1 and SPILLWAY_ALLOW_PRIVATE_URLS=1, and points the DAM connector at the in-process Cloudinary fake from packages/core/src/test/fake-dam.ts (Node 22.18+ strips the types; when that import fails the two DAM states are skipped and the log says so). It then seeds through the API: three languages and the manual XLIFF provider, a translation job over two pages, a Cloudinary DAM, a two-step contact form with a condition and a public page that renders it, a disabled Okta provider (so the SSO dialog shows the SCIM section), a saved view on the page list, four registry packages (the trusted one waits in the review queue) and an advisory, and an import of the fixture site (e2e/fixtures/import-site) crawled, classified, approved, converted, themed and landed.
States (36, each in light and dark theme at 1280 and 390 px): Import list, New import, the five wizard steps, the cancel dialog and the Compare drawer; Registry review queue, its drawer and approve dialog, Packages with the Deprecate and Add advisory dialogs, Publishers, Namespaces, Advisories, Publisher; Translations, the Send to translation dialog (with a search typed) and a job report; Settings: Media (DAM form), Forms (CAPTCHA with Turnstile selected), Languages (TMS section), the Sign-in provider dialog with the SCIM section; the media picker's From Cloudinary tab and the library's DAM dialog; the content list, the saved Views menu and the Save view dialog; the unsaved-changes dialog (title edited, then a guarded sidebar link, through the drawer at 390 px); the form builder with a field's condition editor open and the steps editor; Kits and the install plan modal; the public page with the Form block.
Checks (axe-core is not a dependency, so they are written in the script):
| Check | WCAG | Severity |
|---|---|---|
Every img has alt | 1.1.1 | serious |
Every input, select and textarea has a label (<label>, aria-label, aria-labelledby, title) | 1.3.1, 4.1.2 | critical |
| Every button, link, menu item and tab has an accessible name | 4.1.2 | critical |
No positive tabindex | 2.4.3 | serious |
Exactly one h1 per screen, no skipped heading levels (inside an open dialog the hierarchy starts at its h2 title) | 1.3.1, 2.4.6 | moderate |
aria-current="page" on exactly one sidebar link, aria-current="step" on exactly one wizard step, one current tab per tab strip | 1.3.1 | moderate |
role="menu" exposes only menu items, separators and presentational parts; its trigger has aria-haspopup and aria-expanded; ArrowDown moves to the next item; Escape closes and returns focus to the trigger | 4.1.2, 2.1.1 | moderate |
Dialogs: aria-modal, an accessible name, focus moves in, twelve Tabs and three Shift+Tabs stay inside, the app root is inert, Escape closes, focus returns to the opener (or to main when the opener is gone) | 2.4.3, 2.1.2, 4.1.2 | critical / moderate |
| Color contrast of every visible text run and form control against its composited background (ancestors' backgrounds and opacity folded in; 4.5:1, 3:1 for large text; disabled controls, placeholders and text over images skipped) in light and dark theme | 1.4.3 | serious |
| Focus ring (outline or box-shadow) on each of the first 24 tab stops, re-read after the 120 ms transition; the ring of a file input hidden in a button-styled label counts on the label | 2.4.7 | serious |
A focused control is not fully covered by a sticky bar or a sibling column (five sampled points through elementFromPoint) | 2.4.11 | serious |
| Targets at least 24×24 px, with the inline-text and spacing exceptions (a 24 px circle around a smaller target must not touch another target in the same layer); 16 px and larger checkboxes with row pitch pass | 2.5.8 | moderate |
| No horizontal scroll at 390 px, with the overflowing elements named | 1.4.10 | serious |
| A live region announces the result of each async action the states trigger (DAM search, translation search, the kit plan) and every screen has at least one live region | 4.1.3 | moderate / minor |
| Zero page errors and console errors (expected 4xx answers and the fixture's http logo, which the admin CSP blocks, are noted, not counted) | robustness | serious |
Findings and fixes (first run, before the fixes: 28 findings at light/1280, 52 over the four combos; all fixed in packages/admin and packages/blocks):
| Route | Element | WCAG | Severity | Fix |
|---|---|---|---|---|
| Any primary button on hover, dark theme | Approve and list (caught with the pointer resting on it at 390 px) | 1.4.3 | serious | White on the dark hover token #5583ea was 3.61:1; the dark --accent-hover is now #3366d6 (5.23:1 on white, 3.33:1 against the card), the light tokens were already fine |
| Every route at 390 px | the shell | 1.4.10 | serious | The top bar's content (site switcher, workstream switcher, icons) set the grid's minimum width at 407 px. .topbar gets min-width: 0, .topbar-left shrinks and clips, the workstream switcher caps at 96 px under 420 px |
| Forms › form editor with a field's details open, 1280 | Remove field N | 2.4.11 | serious | The fields table (an open details row with full-width selects) pushed the main column under the right rail because grid items keep min-width: auto; .split-layout > *, .stack > *, .form-section, .fields-table { min-width: 0 } so the table scrolls inside its card instead |
| Translations › Send to translation | Due date input | 2.4.7 | serious | While the date picker button inside the UA shadow tree has focus the host matches :focus-within only; date, time, month and week inputs now ring on :focus-within |
| Import › Convert › Compare | the two sandboxed iframes | 2.4.7, 2.1.1 | serious | A focused sandboxed frame has no ring and swallows Escape; both frames are tabindex="-1" and aria-hidden, the Text view (announced in the tab's title) is the keyboard and screen reader route |
| Settings › Site › Default social image | Choose media button | 2.5.3 | moderate | <label for> named the button "Default social image"; aria-label="Choose media for Default social image" keeps the visible text in the name |
| Content › Views menu | group label and separator | 4.1.2 | moderate | role="presentation" on the group label and empty row, role="separator" on the divider (the same roles on the notifications, account, site, workstream and language menus) |
| Translations | language tiles | 4.1.2 | moderate | <button role="listitem"> lost the button role; the tiles are plain toggle buttons in a role="group" named "Filter by language" |
| Translations › Send to translation | search results | 4.1.3 | moderate | A visually hidden role="status" reads "Searching…" and "N results for …" |
| Kits › Install | plan loading | 4.1.3 | moderate | "Resolving dependencies…" is a LoadingState (status region), the error line is an alert, "No dependencies" is a status |
| Media picker › From Cloudinary | results, Use this | 4.1.3, 2.4.6 | moderate | A hidden status reads the asset count after each search; every Use this button is named "Use this: <asset>"; the empty result is an EmptyState with a Clear search action |
Translations › job report (?job=), Content › Save view | dialog close | 2.4.3 | moderate | When the opener is gone or was body, Modal moves focus to main instead of leaving it on body; the Save view dialog hands focus back to the Views button |
| Dialogs with long descriptions | close button | 2.5.8 | moderate | The close icon button shrank to 17 px as a flex item; .btn-icon, .modal-close { flex: none } |
| Kits | Import structure | 2.4.7 | serious | The file input is sr-only inside a button-styled label, so the ring was invisible; label.btn:focus-within draws it |
| Import › wizard steps | step strip | 1.3.1 | moderate | Done steps were only drawn with a check; each step reads "Step n of 5: Name (done)" |
| Import › job | Cancel this import? | UX | minor | Both buttons said Cancel; the safe choice is "Keep running" (and "Keep import" on the delete dialog) |
| Settings › Media, Settings › Forms | Save | UX | minor | Save sat in the sub-header; both tabs use the sticky FormActions bar like Languages and the editors (with the saved state) |
| Media panel | Open ↗, ✦ Suggest | 4.1.2 | minor | The arrow and the sparkle are aria-hidden; the link says "in a new tab" to screen readers |
| Form builder › Steps | step cards | 1.3.1 | minor | Each step card is a region named "Step n: title" |
Public Form block (packages/blocks) | progress list, errors, status | 1.3.1, 3.3.1, 4.1.3 | moderate | The first step carries aria-current="step" from the server render (not only after the script runs); every control's description includes its error line (<id>-error, also added to checkboxes, which had none); the status region is in the tree from the start (empty, zero size) so its first message is announced |
Not changed: the 18 px row checkboxes (24 px row pitch, no adjacent target) and inline links in table cells pass under the 2.5.8 exceptions; Tabs keeps aria-current="page" on in-page tab buttons; the stepper's done state in the public form is visual only (the step title and count are read).
Result after the fixes: 0 findings over 36 states × 4 combos (node e2e/a11y-crawl.mjs exits 0), 0 page errors. Screenshots at 2× in docs/screenshots/: import-wizard.png, translations.png, dam-picker.png, saved-views.png, unsaved-dialog.png.
Known limits. The fake DAM's thumbnails point at res.cloudinary.com, so the picker shows alt text instead of images in the crawl. Focus scrolling: besides scroll-padding on html, FormActions listens for focusin and scrolls a control out from under the sticky bar, because browsers only scroll a focused control into the viewport, not clear of overlays.
9. Page builder accessibility, Puck's interface included
Date: 2026-11-07. Until then the crawl audited only the page builder's Style field (a region, at 1280 px), because the rest of the builder is Puck's own interface. The builder, builder-outline and builder-style-field states now audit the whole builder in light and dark at 1280 and 390 px, with 80 tab stops each, and the crawl gained a check for anything that shows a pointer cursor but is not a control the keyboard can reach (2.1.1, serious). A one-off axe-core 4.10.2 run over the same states (WCAG 2.2 A and AA plus best practices) found the last four rows. Every fix stays outside Puck: overrides first, CSS scoped under .builder-screen next, and attributes set after mount (screens/content/puckA11y.ts) only where no override or prop reaches. The issue text for Puck is upstream/puck-accessibility.md.
Findings and fixes (first run: 51 findings over the two states and four combos, 6 critical, 39 serious, 6 moderate):
| Where | Finding | WCAG | Severity | Fix |
|---|---|---|---|---|
| Canvas toolbar | Zoom select without a label | 1.3.1, 4.1.2 | critical | aria-label="Zoom" after mount (no prop or override reaches the toolbar) |
| Header | Publish was Puck's <span> with a click handler, out of the tab order | 2.1.1 | serious | The headerActions override renders its own Publish button |
| Plugin rail | Blocks, Outline (and Fields on phones) were clickable <div>s | 2.1.1, 4.1.2 | serious | role="button", tabindex="0", aria-pressed, Enter and Space, after mount (the rail has no override short of replacing the layout); a focus ring in CSS |
| Rail, fields panel breadcrumb, dark | "Blocks" 3.46:1, "Page" 3.79:1 | 1.4.3 | serious | Dark mode maps Puck's interactive color to the accent's text shade; fills keep the accent |
| Blocks panel | No ring on the focused block (Puck rings an inner box) | 2.4.7 | serious | The ring is drawn on the focused element |
| Canvas | No ring while focus is inside the canvas frame | 2.4.7 | serious | The frame is marked while it holds focus and ringed |
| Header | The only h1 was visually hidden | 1.3.1, 2.4.6 | moderate | The page title in the header is a visible h1 |
| 390 px | The header's minimum width (1143 px) widened Puck's one-column layout, pushing the canvas and toolbar off screen; the canvas was covered when focused | 1.4.10, 2.4.11 | serious | The header shrinks: title only and icon actions below 1180 px, two rows below 900 px, panel toggles hidden below 638 px (the rail opens the panels there) |
| 390 px, panel open | Puck collapses the header row to 0, leaving its buttons focusable but invisible | 2.4.7, 2.4.11 | serious | The header row keeps its height above an open panel |
| Outline | Duplicate and Delete hidden until hover, so Tab skipped them | 2.1.1 | serious | Shown while the row has focus |
| Canvas toolbar, about 900 px | Clipped on both sides when both panels are open | 1.4.10 | moderate | The toolbar wraps |
| Canvas frame | No title (axe frame-title) | 4.1.2 | serious | title="Page canvas" after mount |
| Loader | aria-label on a <span> without a role (axe aria-prohibited-attr) | 4.1.2 | serious | role="img" after mount |
| Whole builder | Content outside landmarks (axe region) | 1.3.1 | moderate | The builder is a main element (it renders outside the shell) |
Not fixed: adding and moving blocks needs a pointer, because Puck registers no keyboard sensor for dragging, and the Outline row is a draggable role="button" around the row's buttons (axe nested-interactive). Both are Puck's to fix; a keyboard path of our own is an open decision.
390 px. The builder works in Puck's phone layout instead of showing a "needs a wider screen" notice: once the header stopped setting the layout's width, Puck's own phone layout (canvas on top, the rail at the bottom opening Blocks, Outline and Fields as a panel) passes every check.
Result after the fixes: 0 findings in the three builder states over the four combos, and none in the rest of the crawl; the axe run reports only nested-interactive on Outline rows. e2e/smoke.mjs clicks the builder's Publish by its role now. Screenshot: docs/screenshots/page-builder-dark.png (dark mode, a Hero selected, 2×).
Spillway 0.1.0 · Edit this page on GitHub