Files
pad/web
xarmian b76abdd66b fix(web): full ARIA-modal isolation for the mobile detail pane (TASK-2131) (#1007)
* feat(web): full ARIA-modal isolation for the mobile detail pane (TASK-2131)

Follow-up to TASK-2122. The mobile full-screen detail-pane overlay had a
JS focus trap + an inert list column, but the app-shell chrome behind it
stayed in the a11y tree and the pane was still just an <aside>. Complete
the modal:

- The mobile `.item-pane` becomes role="dialog" aria-modal="true"; the
  desktop split stays a bare <aside> (complementary landmark, non-modal).
- MobileContextBar + BottomNav (rendered in the workspace +layout, ABOVE
  the pane host) are marked `inert` while a mobile overlay is up, so they
  leave the focus order AND the screen-reader tree. A JS trap can't
  constrain an SR virtual cursor and aria-modal is unevenly honored, so
  the background chrome must physically drop out.

The chrome is a layout sibling the host can't reach by prop, so PaneHost
hoists "a mobile overlay is active" into a small ref-counted store
(paneOverlay.svelte.ts) the layout reads — one-way writer/reader split
per CONVE-1688. Ref-counted so an overlapping route-change remount can't
clear the signal early. The layout carries `inert` on display:contents
wrappers (cascades to the fixed chrome, adds no box).

Verified in a real browser (Playwright): mobile → dialog role + aria-modal
+ inert descendants unfocusable; desktop → bare aside + chrome interactive.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra

* fix(web): exclude the pane's dialog role from foreign-modal guards (TASK-2131)

The new mobile role="dialog" on the pane collided with three places that
treat ANY [role="dialog"] as a *foreign* modal that owns its own ESC /
focus — regressions the pane's <aside>-not-a-dialog invariant had been
silently relying on:

- paneFocus.ts PANE_EXEMPT_SURFACE_SELECTOR: any in-pane element matched
  closest('[role="dialog"]') → the whole pane read as an "exempt surface",
  killing the mobile Tab trap and confusing the focus-follows classifier.
- The collection + item-page ESC guards querySelector('[role="dialog"]')
  → the pane matched itself → ESC was swallowed instead of closing/popping
  the pane on mobile.

Fix: exclude the pane via [role="dialog"]:not(.item-pane) at all three
sites (a genuinely nested dialog/menu opened FROM the pane still matches).
Adds inExemptSurface unit coverage for the pane-not-exempt case.

Caught by the independent Codex review pass.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra

* fix(web): complete mobile modal isolation — banners + print (TASK-2131)

Two more members of the same [role="dialog"] collision class, from the
independent Codex pass:

- Print: app.css @media print hides [role="dialog"] to strip overlays.
  The mobile pane now matches, so printing at <=768px with the pane open
  dropped the whole item from the printout. Exclude via :not(.item-pane) —
  the pane is the content being printed, not a transient overlay.
- Banners: VerifyEmailBanner + ConnectBanner rendered OUTSIDE the inert
  wrappers, so their controls (Resend / Connect) stayed reachable by an SR
  virtual cursor behind the aria-modal pane — the same gap the inert of
  MobileContextBar/BottomNav closes. Fold them into the top inert wrapper
  so ALL app-shell siblings behind the overlay leave the a11y tree; only
  the pane (in children()) stays interactive.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-22 18:13:37 -04:00
..
2026-03-26 01:52:36 +00:00
2026-03-26 01:52:36 +00:00
2026-03-26 01:52:36 +00:00
2026-03-26 01:52:36 +00:00
2026-03-26 01:52:36 +00:00

Pad Web UI

SvelteKit 2 + Svelte 5 frontend for Pad, compiled to static files and embedded into the Go binary.

Development

npm install
npm run dev          # Dev server at localhost:5173 (proxies API to localhost:7777)
npm run build        # Production build to build/
npm run check        # Type checking with svelte-check

When developing, run the Go backend separately with make dev from the project root.

Building for Production

Do not build in isolation. Always use make build from the project root — this builds the web frontend, then compiles the Go binary with the build output embedded via //go:embed.

Stack

  • Svelte 5 with runes ($state, $derived, $effect)
  • SvelteKit 2 with adapter-static (SPA mode)
  • Tiptap block editor with markdown round-trip
  • svelte-dnd-action for drag-and-drop in board/list views
  • SSE for real-time updates
  • TypeScript throughout

Structure

src/
  routes/                    SvelteKit pages
    +layout.svelte           App shell (sidebar + main)
    +page.svelte             Landing/redirect
    [workspace]/
      +page.svelte           Dashboard (collections, phases, activity)
      +layout.svelte         SSE connection per workspace
      [collection]/
        +page.svelte         Collection view (board/list)
      [collection]/[item]/
        +page.svelte         Item detail + editor
      conventions/            Purpose-built conventions page
      playbooks/              Purpose-built playbooks page
      settings/               Workspace settings
  lib/
    api/client.ts            HTTP API client
    components/
      layout/                Sidebar, navigation
      editor/                Tiptap editor, raw markdown editor
      fields/                FieldEditor, relation picker
      items/                 ItemCard, ItemDetail
      collections/           BoardView, ListView
      common/                StatusBadge, badges, modals
      search/                CommandPalette
      activity/              ActivityFeed
    stores/                  Svelte 5 reactive stores
      workspace.svelte.ts    Workspace state
      collections.svelte.ts  Collection + item state
      ui.svelte.ts           Sidebar, mobile state
    types/index.ts           TypeScript types and constants
  app.css                    Global styles and design tokens