Spillway CMSStructured content like Drupal. Page building like Puck. Setup like Ghost. AI that drafts, people who approve.
Menu

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_Table on 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: scheme light/dark/system with an Appearance menu; usePrefersDark wraps prefers-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

PraiseComplaint
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

FindingSeverityFix
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).CriticalButton 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.ModerateCtrl/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").ModeratePageHeader renders the title first, tabs under it.
Media empty state was a sentence with no call to action and no list view.ModerateIcon + 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.ModerateDataTable 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.ModerateCustom 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.MinorAccent-coloured AI trigger; button sizing.
No dark theme; no user preference.MinorLight/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

ElementIssueFix
TypeFour ad-hoc sizes (11, 12, 12.5, 13.5, 14, 16, 20 px)Eight-step scale, tokens only
HeadingsBreadcrumb-only editors had no h1; the theme tab had twoOne h1 per screen (visually hidden where the action bar is the header)
BadgesColour was the only cue for stateDot + label + colour
ButtonsGhost "Back" buttons, text-only icons (↗, ×, ✦, ▸)One icon set, consistent sizes and affordances
Empty statesPlain sentences or table rowsIcon, 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

  1. 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.
  2. 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.
  3. 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.
  4. 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).
  5. 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.
  6. 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-scheme wins until the person chooses.
  7. Nothing moves unless asked. All motion is 120–180 ms and switched off under prefers-reduced-motion.
  8. Preferences are local and cheap. Column visibility, density, sidebar state, media layout and theme live in localStorage per 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

UsePairRatioRequiredResult
Body text on cards--text #16191f on --surface #ffffff17.60:14.5:1pass
Body text on canvas--text #16191f on --bg #f5f6f816.28:14.5:1pass
Secondary text on cards--text-muted #525a66 on --surface #ffffff6.97:14.5:1pass
Secondary text on table header / tiles--text-muted #525a66 on --surface-2 #f1f3f66.27:14.5:1pass
Placeholder, timestamps, group labels--text-faint #646c79 on --surface #ffffff5.30:14.5:1pass
Faint text on surface-2--text-faint #646c79 on --surface-2 #f1f3f64.77:14.5:1pass
Links--accent-text #1c47b4 on --surface #ffffff8.05:14.5:1pass
Active nav item, info badge--accent-text #1c47b4 on --accent-soft #e6edfc6.85:14.5:1pass
Primary button label (white on accent)--white #ffffff on --accent #2456d46.26:14.5:1pass
Published badge--success #176b3e on --success-soft #dcf5e65.69:14.5:1pass
In review badge--warning #7d4b00 on --warning-soft #fff1d26.52:14.5:1pass
Error badge / form error--danger #b8232b on --danger-soft #fde8e95.40:14.5:1pass
Success text on card--success #176b3e on --surface #ffffff6.54:14.5:1pass
Warning text on card--warning #7d4b00 on --surface #ffffff7.30:14.5:1pass
Danger text on card--danger #b8232b on --surface #ffffff6.34:14.5:1pass
Toast and tooltip text--text-inverse #ffffff on --surface-inverse #1a1d2416.87:14.5:1pass
Focus ring vs card (non-text 3:1)--focus #2456d4 on --surface #ffffff6.26:13:1pass
Focus ring vs canvas--focus #2456d4 on --bg #f5f6f85.79:13:1pass
Input border (non-text 3:1)--border-input #8a919b on --surface #ffffff3.18:13:1pass
Primary button vs card (non-text)--accent #2456d4 on --surface #ffffff6.26:13:1pass
Muted text inside selected row--text-muted #525a66 on --accent-soft #e6edfc5.94:14.5:1pass

Dark

UsePairRatioRequiredResult
Body text on cards--text #e8eaef on --surface #171a2114.46:14.5:1pass
Body text on canvas--text #e8eaef on --bg #0f111615.69:14.5:1pass
Secondary text on cards--text-muted #a9b0bd on --surface #171a217.99:14.5:1pass
Secondary text on table header / tiles--text-muted #a9b0bd on --surface-2 #1e222b7.30:14.5:1pass
Placeholder, timestamps, group labels--text-faint #8d94a2 on --surface #171a215.71:14.5:1pass
Faint text on surface-2--text-faint #8d94a2 on --surface-2 #1e222b5.22:14.5:1pass
Links--accent-text #8fb0ff on --surface #171a218.14:14.5:1pass
Active nav item, info badge--accent-text #8fb0ff on --accent-soft #1a27466.90:14.5:1pass
Primary button label (white on accent)--white #ffffff on --accent #3b6fe34.60:14.5:1pass
Primary button label on hover (Phase 4: was #5583ea at 3.61:1)--white #ffffff on --accent-hover #3366d65.23:14.5:1pass
Published badge--success #5ed79a on --success-soft #11301f7.95:14.5:1pass
In review badge--warning #f3c06a on --warning-soft #3a2a0a8.29:14.5:1pass
Error badge / form error--danger #ff8b8b on --danger-soft #3d17176.99:14.5:1pass
Success text on card--success #5ed79a on --surface #171a219.67:14.5:1pass
Warning text on card--warning #f3c06a on --surface #171a2110.41:14.5:1pass
Danger text on card--danger #ff8b8b on --surface #171a217.73:14.5:1pass
Toast and tooltip text--text-inverse #16191f on --surface-inverse #f3f4f716.01:14.5:1pass
Focus ring vs card (non-text 3:1)--focus #8fb0ff on --surface #171a218.14:13:1pass
Focus ring vs canvas--focus #8fb0ff on --bg #0f11168.83:13:1pass
Input border (non-text 3:1)--border-input #6b7383 on --surface #171a213.65:13:1pass
Primary button vs card (non-text)--accent #3b6fe3 on --surface #171a213.79:13:1pass
Muted text inside selected row--text-muted #a9b0bd on --accent-soft #1a27466.77:14.5:1pass

--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) and aria-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 crumbs or useBreadcrumbs(); 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; Table is 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 by data-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): EmptyState with icon and heading level, Skeleton/SkeletonCard (shimmer, hidden from AT, off under reduced motion), LoadingState, ErrorState with retry.
  • Toasts (components/Toast.tsx): polite live region; success/info are status, errors are alert; icon, left accent, dismiss button.
  • Dialogs (components/Modal.tsx): rendered in a portal, the app root is inert while 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, with content.view_drafts, draft content), Go to (every permitted screen) and Actions (New <type> with content.create, Upload media with media.upload, Invite a user with users.manage, theme, sidebar, View site, API tokens, preferences, shortcuts, log out); combobox + listbox semantics with aria-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, title for 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 beforeunload ask before leaving. (Still window.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)

RequirementHow it is met
1.4.3 / 1.4.11 contrastToken pairs above; inputs use a 3:1 border; focus ring 6.26:1 (light) / 8.14:1 (dark)
2.1.1 keyboardAll controls are native buttons/links/inputs; rows and sidebar links take arrows; palette and dialogs fully keyboard-driven
2.4.3 focus orderDOM order matches visual order; dialogs and the palette trap focus; skip links first
2.4.7 focus visible2 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 movementsNothing 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 helpHelp & shortcuts is in the same sidebar slot and the account menu on every screen
3.3.7 redundant entrySetup 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 messagesToasts, save status and loading states are live regions
Reduced motionprefers-reduced-motion zeroes every animation and transition
200 % zoom / reflowVerified at 680 px (200 % of 1360) and 360 px: no horizontal scroll; tables fall back to cards
Screen reader namesEvery icon-only control has aria-label; tables have captions; sort buttons announce direction; palette options are role="option"
ATAG 2.0 Part AThe 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:

CheckResult
200 % zoom (680 px) on dashboard, list, editor, settings, mediano horizontal scroll
360 px on dashboard, list, editor, settings, users; off-canvas navno horizontal scroll; drawer visible
Dark theme via prefers-color-schemebody rgb(15, 17, 22), 0 errors
prefers-reduced-motionspinner animation-duration: 1e-06s
Ctrl+K opens the palette; root is inert; typing "users" selects Users; Enter navigatespass
? opens the shortcuts dialog; focus trapped over 6 tabs; Escape closespass
Sidebar collapse keeps accessible names; [ togglespass
Table: ArrowDown moves between rows; Enter openspass
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.tsx re-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.md screenshots table; docs/screenshots/ replaced, previous set kept in docs/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, replace activityVerb() in Dashboard.tsx with the feed.
  • Styled unsaved-changes dialog. The guard is still window.confirm; replacing it needs an async confirmLeave and 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.tsx lists GET /api/me/sites, stores the choice and sends X-Spillway-Site on every call; the workstream switcher and banner sit beside it.
  • Axe. Run @axe-core/playwright in CI once the package is available; until then e2e/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):

CheckWCAGSeverity
Every img has alt1.1.1serious
Every input, select and textarea has a label (<label>, aria-label, aria-labelledby, title)1.3.1, 4.1.2critical
Every button, link, menu item and tab has an accessible name4.1.2critical
No positive tabindex2.4.3serious
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.6moderate
aria-current="page" on exactly one sidebar link, aria-current="step" on exactly one wizard step, one current tab per tab strip1.3.1moderate
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 trigger4.1.2, 2.1.1moderate
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.2critical / 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 theme1.4.3serious
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 label2.4.7serious
A focused control is not fully covered by a sticky bar or a sibling column (five sampled points through elementFromPoint)2.4.11serious
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 pass2.5.8moderate
No horizontal scroll at 390 px, with the overflowing elements named1.4.10serious
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 region4.1.3moderate / 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)robustnessserious

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):

RouteElementWCAGSeverityFix
Any primary button on hover, dark themeApprove and list (caught with the pointer resting on it at 390 px)1.4.3seriousWhite 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 pxthe shell1.4.10seriousThe 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, 1280Remove field N2.4.11seriousThe 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 translationDue date input2.4.7seriousWhile 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 › Comparethe two sandboxed iframes2.4.7, 2.1.1seriousA 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 imageChoose media button2.5.3moderate<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 menugroup label and separator4.1.2moderaterole="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)
Translationslanguage tiles4.1.2moderate<button role="listitem"> lost the button role; the tiles are plain toggle buttons in a role="group" named "Filter by language"
Translations › Send to translationsearch results4.1.3moderateA visually hidden role="status" reads "Searching…" and "N results for …"
Kits › Installplan loading4.1.3moderate"Resolving dependencies…" is a LoadingState (status region), the error line is an alert, "No dependencies" is a status
Media picker › From Cloudinaryresults, Use this4.1.3, 2.4.6moderateA 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 viewdialog close2.4.3moderateWhen 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 descriptionsclose button2.5.8moderateThe close icon button shrank to 17 px as a flex item; .btn-icon, .modal-close { flex: none }
KitsImport structure2.4.7seriousThe file input is sr-only inside a button-styled label, so the ring was invisible; label.btn:focus-within draws it
Import › wizard stepsstep strip1.3.1moderateDone steps were only drawn with a check; each step reads "Step n of 5: Name (done)"
Import › jobCancel this import?UXminorBoth buttons said Cancel; the safe choice is "Keep running" (and "Keep import" on the delete dialog)
Settings › Media, Settings › FormsSaveUXminorSave sat in the sub-header; both tabs use the sticky FormActions bar like Languages and the editors (with the saved state)
Media panelOpen ↗, ✦ Suggest4.1.2minorThe arrow and the sparkle are aria-hidden; the link says "in a new tab" to screen readers
Form builder › Stepsstep cards1.3.1minorEach step card is a region named "Step n: title"
Public Form block (packages/blocks)progress list, errors, status1.3.1, 3.3.1, 4.1.3moderateThe 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):

WhereFindingWCAGSeverityFix
Canvas toolbarZoom select without a label1.3.1, 4.1.2criticalaria-label="Zoom" after mount (no prop or override reaches the toolbar)
HeaderPublish was Puck's <span> with a click handler, out of the tab order2.1.1seriousThe headerActions override renders its own Publish button
Plugin railBlocks, Outline (and Fields on phones) were clickable <div>s2.1.1, 4.1.2seriousrole="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:11.4.3seriousDark mode maps Puck's interactive color to the accent's text shade; fills keep the accent
Blocks panelNo ring on the focused block (Puck rings an inner box)2.4.7seriousThe ring is drawn on the focused element
CanvasNo ring while focus is inside the canvas frame2.4.7seriousThe frame is marked while it holds focus and ringed
HeaderThe only h1 was visually hidden1.3.1, 2.4.6moderateThe page title in the header is a visible h1
390 pxThe header's minimum width (1143 px) widened Puck's one-column layout, pushing the canvas and toolbar off screen; the canvas was covered when focused1.4.10, 2.4.11seriousThe 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 openPuck collapses the header row to 0, leaving its buttons focusable but invisible2.4.7, 2.4.11seriousThe header row keeps its height above an open panel
OutlineDuplicate and Delete hidden until hover, so Tab skipped them2.1.1seriousShown while the row has focus
Canvas toolbar, about 900 pxClipped on both sides when both panels are open1.4.10moderateThe toolbar wraps
Canvas frameNo title (axe frame-title)4.1.2serioustitle="Page canvas" after mount
Loaderaria-label on a <span> without a role (axe aria-prohibited-attr)4.1.2seriousrole="img" after mount
Whole builderContent outside landmarks (axe region)1.3.1moderateThe 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

Was this page helpful?