Files
xarmian d5e8521f6d fix(web): give the tab fit real slack — short label at narrow widths + a visible overflow cue (TASK-2329)
CI was red on this branch's own core test, twice, deterministically. Root cause
confirmed with numbers from the runner rather than inferred, then fixed at the
level of the design instead of the assertion. Reverts the temporary diagnostic
added in the previous commit, replacing it with a permanent slack assertion.

── ROOT CAUSE: font metrics ──

                          dev box    CI      delta
    strip width             312      312       —
    tab content (scrollW)   297      337     +40
    scrollport              297      312
    OVERFLOW                  0       25     <-- the failure
    scrollbar in container    0        0     <-- ruled out
    devicePixelRatio          1        1     <-- ruled out
    padding per tab         8/8      8/8     <-- the <=330 rule DID fire

Per-tab glyph advance, local -> CI: Details 42.4->50.8, Relationships
84.4->98.5, Activity 46.4->55.5, Versions 53.9->62.1. Total 227.0->266.8,
+17.5%. The +40px content delta is ENTIRELY the +39.8px text delta.

`--font-ui` is `-apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui,
sans-serif` — every entry misses on Linux, so `system-ui` resolves through
fontconfig to different font FILES on the two machines. Same declaration,
wider glyphs. Ten candidate families forced locally spanned 273-297px, so CI
at 337px is outside the entire local envelope: this was not findable by
guessing at fonts, it needed the runner.

The deeper fault was mine: the <=330 padding rule had been tuned until 297 met
297. Zero slack. A pixel measured in one environment treated as universal —
the same mistake that produced the original mobile failure.

── FIX 1: shorten the longest label at narrow widths ──

`Relationships` -> `Links` below 560px of strip, VISIBLE TEXT ONLY. Worth 51px
measured, which is most of the row's margin in one move.

The accessible name stays `Relationships` at every width, pinned by
`aria-label` on the tab, because ~5 specs address these tabs by
`getByRole('tab', { name: 'Relationships' })`. Verified at four widths:
`name: 'Relationships'` resolves to exactly 1 everywhere, `name: 'Links'` to 0
everywhere, and `ariaSnapshot()` reads `tab "Relationships": Links` — name
unchanged, visible content swapped. A screen reader never hears "Links".

Slack now, measured, including a simulation of CI's wider glyphs:

    font scale   phone 360   phone 390   pane 360   pane 448
    1.000 (local)   +66         +72        +66        +130
    1.175 (== CI)   +35         +41        +35         +99
    1.300           +13         +19        +13         +77

CI's -25px becomes +35px. It still fits at 1.30, i.e. 30% wider than CI.

── FIX 2: overflow is no longer silent ──

`scrollbar-width: none` hid the only native signal that content was cut off —
which IS the defect this task exists to fix (two of four tabs invisible at
430px, unhinted). Slack makes overflow unlikely, not impossible:
translations, user zoom, a fifth tab.

`.pane-tabs` now takes a soft mask fade on an edge ONLY when there is more
content past that edge. Verified in all four states: fitting -> no mask class
at all; at scroll start -> right edge only; at end -> left only; mid-scroll ->
both. Paint-only, so the active tab's 1px divider overlap still measures 0.

Scripted, not a container query, and that is deliberate: CSS cannot ask "is
this element scrolling". That is the OPPOSITE of the tier rules, where CSS
answers natively and DR-2 rightly rejected JS — this reads
scrollWidth/clientWidth and writes two booleans, and touches no tier logic. It
writes without reading, so there is no effect-self-write loop (CONVE-1688),
and mask-image cannot resize the element so it cannot re-trigger its own
observer.

A pure-CSS attempt came first and was rejected ON MEASUREMENT: the two-layer
`background-attachment: local/scroll` scroll-shadow idiom is conditional by
construction, but backgrounds paint BEHIND the tab text and sampling the
rendered edge pixels showed no legible signal in either theme. Recorded in the
CSS so nobody re-tries it.

── FIX 3: the test can no longer pass on a coincidence ──

It asserted only `tabsFit` and passed with 0px to spare. Now it asserts
`slackPx >= 20` as well, where slackPx is `stripWidth - tabs.scrollWidth`.

The diagnostic's own `clientWidth - scrollWidth` was useless and is corrected:
it is ALWAYS 0 or negative, because `.pane-tabs` has no `flex-grow` so while
the content fits its clientWidth IS its content width. It could not
distinguish "just fits" from "loads of room".

Both assertions print the measured widths, the resolved font family/size and
the per-tab boxes, so the next failure on a machine nobody can attach to hands
over the numbers instead of `true !== false`. Roving-walk identity switched
from `textContent` (now "RelationshipsLinks", both spans) to accessible name.
New test covers the four overflow-cue states.

── MATRIX RE-VERIFIED ──

Desktop byte-identical: 1440/1200 one row header 148.8px; 1024/768 one row
174.8px; underline offset 0; full `Relationships` label; content 321 fits
716+. Phones 360/390/412/430 and panes 360/448/600 split and fit with 66-130px
slack. Pane 720 (672px strip) full tier, one row. Container proof intact: a
360px pane on a 1920px viewport splits while the full page at that viewport
does not. Drag sweep 563->688px still three clean states, tabs fit in all 27
samples. Star chip/menu-row symmetry holds at all eight widths.

── BUG-2334: answered with a control, not an assumption ──

Built `main` and ran the full local suite against it: 2 failures,
`pane-collab-teardown:60` and `pane-content-link-anchors:238` — both in the
set that fails on this branch, with none of this branch's specs or seeding
present. So the cross-spec toast interference is pre-existing locally and not
something this PR introduced or made materially likelier. Main's CI is green
at 10a5ae22, so CI is simply timing-luckier than local. All affected tests
pass in isolation.

Gates: `npm run check` 0 errors; `make check` exit 0 (Go tests, govulncheck
clean, 490 web unit tests); tier spec 6/6.

Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT
2026-07-26 12:22:41 +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