Files
pad/web
xarmian 13852439e1 feat(a11y): give the attachment viewer a real modal contract (TASK-2429)
`Lightbox` was a `role="presentation"` fixed div with a local `<svelte:window>`
keydown handler and no focus management at all. 3a's later tasks delete the
editor's hand-rolled `showModal()` dialog and route inline body images here, so
everything that dialog was getting from the platform for free has to exist here
first (DR-4b).

The contract:

  - `role="dialog"` + `aria-modal="true"` + an accessible name (the image alt,
    else "Attachment viewer"), tracking the image CURRENTLY shown. The controls
    get real `aria-label`s too — their text is "✕" / "‹" / "›", and `title` does
    not win over element content for the accessible name.
  - Portaled to `<body>` DIRECTLY — deliberately not `portalAction.ts`, which
    targets the nearest ancestor `<dialog>` when one exists: the opposite of
    what a top-most surface needs. `<body>` is also the parent the backdrop
    manager's inert bookkeeping requires, and the only one with no ancestor that
    could establish a containing block and silently trap a `position: fixed`
    overlay.
  - `wsSlug` CAPTURED at open, not read live: the pane switches workspace
    without remounting what is above it, so a live read could rebuild
    already-captured attachment ids against a different workspace.
  - Focus entry to the first TABBABLE DESCENDANT via `paneFocusables` (the root
    is `tabindex="-1"` only as the no-controls-yet fallback); restore on close to
    an OPTIONAL `invoker` prop, verified still connected AND focusable — else
    focus is parked on `<body>` deliberately. With no invoker threaded it falls
    back to whatever held focus at open, so the producers that thread one only in
    TASK-2431 don't come out of this commit worse than they went in. The restore
    declines when something else already owns focus.
  - Background inertness via TASK-2427's manager (`acquire(exemptRoot)`), never
    a hand-rolled `inert`. Released BEFORE the focus restore, and the returned
    `stackEmpty` decides: with a viewer still open the manager has already handed
    focus into it and this one stands down.
  - Tab trap through `paneFocusables` / `nextTrapTarget` — the pane's tested trap
    math, not a second implementation.
  - ONLY the frontmost viewer (`isViewerFrontmost`) traps Tab, handles ←/→ and
    consumes Escape. Handlers are global and `nextTrapTarget` deliberately
    redirects out-of-container focus INWARD, so a background viewer would
    otherwise drag focus out of the viewer in front of it. And the viewer stands
    down entirely (`isBlockedByModal`) while a `showModal()` dialog is open over
    it: the top layer is above any body-portaled surface, so the frontmost LEASE
    is not always the frontmost SURFACE, and the manager keeps such a dialog
    operable on purpose.
  - Escape: the local branch is DELETED, not gated — it ignored
    `defaultPrevented`, so alongside the stack it gave Escape two owners and let
    one press collapse two layers. `escapeStack` is now the sole owner, at a new
    `viewer` priority (50) above `menu` (40).

`AttachmentViewerHost` stops restoring focus itself and threads `invoker` down
instead. Its own restore ran while the viewer still held the backdrop lease —
i.e. while the invoker sat inside an `inert` body child, where it is not
focusable at all — so it would have silently become a no-op the moment this
commit landed. The only correct moment is after the lease is released, which is
inside the viewer's teardown.

Atomic with the route guards, because the stack is unreachable without them:
both `[collection]` and `[collection]/[slug]` bailed out of the ESC chain on
`document.querySelector('dialog[open], [role="dialog"]:not(.item-pane)')`, which
the viewer now matches — Escape would have been dead. Both call the shared
`hasForeignEscapeOwner()` instead. It KEEPS the ARIA branch (`BottomSheet` /
`DockedSheet` are shipped `role="dialog"` Escape owners with no stack
registration; dropping it would regress them) and narrows only the NATIVE branch
to a feature-detected `dialog:modal`, falling back to today's `dialog[open]`
where the pseudo-class is unsupported.

The backdrop's `z-index` goes to 100000. At 1000 it was under the desktop emoji
picker's body-portaled dropdown (99999) — and the app shell wrapper is
`display: contents`, so every fixed overlay in the tree competes in the ROOT
stacking context; being a body child is not protection. A surface that paints
over a viewer that has inerted it is visible-but-untouchable, the worst of both.
The full sweep, and the rule that a new overlay above this value is a bug, are
recorded at the declaration. The one thing legitimately above it is a native
`showModal()` dialog, which gets there via the top layer and no z-index at all.

Collision audit for `[role="dialog"]`-as-foreign-modal consumers:

  - `app.css` `@media print` — the viewer matches, and should: a transient
    overlay must not print. Documented in place (a JS-only grep misses this).
  - `paneFocus.ts` `PANE_EXEMPT_SURFACE_SELECTOR` and `PaneHost`'s mobile trap +
    focus-follows classifier — the viewer matches, and must: it runs its own
    trap and key handling.
  - `web/e2e/**` incl. `e2e/lib/*.ts` — no shared dialog selector constants; the
    only bare `getByRole('dialog')` is
    `workspace-bundle-roundtrip.spec.ts:204`, claimed by TASK-2430 by name.

Tests: 36 cases for the modal contract, 10 for `hasForeignEscapeOwner`, plus the
fallout of portaling (host tests can no longer scope by container — ownership is
proven by distinguishable payloads and destroying a known host; the strip's
Escape case now drives the stack). Comments state what jsdom cannot prove — real
inertness, layout/stacking, real Tab traversal — which is TASK-2436's browser
suite, and mark the one assertion whose guard is genuinely indistinguishable from
its neighbour in jsdom. Verified by mutation: 26 mutations run, all caught except
that documented one.

`escapeGuardWiring.svelte.test.ts` is a deliberately narrow addition: the
Lightbox tests drive a route-SHAPED driver they define themselves, so they prove
the shape and not that either route still calls it. This asserts against the real
source of both route files that `hasForeignEscapeOwner()` is imported, called as
an early return, and called BEFORE `runTopEscape()` — catching deletion,
re-inlining of the old selector, and reordering, which is the actual regression
risk. Mounting a route under vitest to prove it behaviourally is not worth its
cost; the behavioural proof is TASK-2436's browser suite. Two things stop it
being a grep that lies: comments are stripped first (every one of these strings
now appears in prose in those files, so a whole-file search could be satisfied by
a comment), and the assertions are scoped to the handler that actually calls
`runTopEscape` rather than to the file. Verified against a commented-out guard, a
guard moved to an unrelated helper, and a reverted selector under different
quoting — while a prettier-style reflow of the guard still passes.

TASK-2429
2026-08-06 16:50:10 +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
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