Status: ✅ complete on feature/responsive-foundation (2.40.1 → 2.40.8), pending release to
main. Note: a real-device visual review after 2.40.7 found the mobile layout cluttered/cut-off
(functional tests had passed but never checked appearance). Reworked in 2.40.8 — retired the generic
≤860 stack, Home is two-pane→drawer at 768 with an inline trigger (no floating FAB), the header collapses
to a vertical ⋯ menu only ≤768, tabs tightened to fit, breakpoints unified at 768. Now verified by
headless screenshots at 360/390/768/1280 as well as the full test suite. Lesson recorded: for UI work,
look at screenshots before declaring done. Phase 1 (fluid tokens) ✅, Phase 2 (layout layer) ✅, Phase 3 (responsive top bar) ✅,
Phase 4a (Home drawer) ✅, Phase 4b (Single view stack) ✅, Phase 4c (Manage master/detail) ✅,
Phase 5 (touch ergonomics) ✅, Phase 6 (verification) ✅ — every view is responsive and touch-ready,
verified by the full gate (npm test) + the whole Playwright suite (18 specs: E2E, visual regression,
a11y, and the responsive viewport matrix), all green. Desktop rendering is byte-identical throughout
(visual baselines unchanged). Optional future polish: a committed phone-width visual baseline, and the
Single view's sticky bottom action bar.
Scope: the whole targets/web/ SPA — every top-level view (Home / Gallery / Single / Manage), the top bar,
and the shared shell. No feature is removed at any width. Features relocate (drawer, overflow
menu, stacked pane, sticky action bar); they never disappear. Applies to both editions — the local build
and the online build — so anyone on any device gets the full, flexible interface.
1. Goal & the guiding principle
Make the interface fluid (it breathes continuously as the window changes size) and adaptive (it switches to distinct tablet / phone layouts where continuous reflow isn't enough), from a phone up to a wide desktop, with zero loss of capability when the screen shrinks.
The principle that guarantees "no lost features": on a smaller screen a feature changes its container, not its existence. The Home sidebar palette becomes a drawer/bottom-sheet. The top-bar control pile (Providers, provider gear, NSFW, theme, links) collapses behind one top overflow menu (the chosen nav pattern — see §5). A two-pane view (Single, Manage) becomes one pane with a toggle. Same components, same state, same abilities — re-homed.
2. Where the app was before this work
Effectively desktop-only. Three real breakpoints existed in the entire stylesheet:
styles/components/responsive.css— stacks Home's.workspaceto one column at≤860px.styles/components/gallery-single-image-derived.css—≤720px.styles/components/wrapper.css—≤620px.
Everything else was fixed. The concrete breakages on a phone/tablet:
- Top bar (
title-bar.css): one flex row = brand + wordmark + a 4-tab switch + Providers + gear + NSFW + theme + links. Overflows below ~700px. .app { height: 100vh }(app-frame.css): the mobile URL-bar bug (content hidden under the bar).- Two-pane views: Home's
.workspacegrid (minmax(240px,300px) 1fr), plus Single and Manage, assume desktop width. - No fluid scale: spacing/type were hardcoded rem literals (
0.6rem 1.1rem,font-size: 0.82rem). Layouts snapped at breakpoints instead of scaling between them. - Touch: tap targets (e.g.
.vs-tab, ~0.3rem padding) below the 44px thumb minimum; many:hover-only affordances with no touch equivalent.
Two existing assets make this clean:
- A primitive → semantic token system (
foundation/tokens.css) — the right place to add scales. - An empty
layoutcascade layer already declared inindex.css(@layer reset, tokens, base, layout, components, utilities, theme, overrides) — the purpose-built, correct-precedence home for responsive layout, needing no refactor to adopt.
3. The two techniques (and why both)
- Fluid foundation —
clamp()type + spacing scales and container queries, so regions scale continuously with no dead zones. - Deliberate layout modes — a small set of breakpoints (tablet / phone) only where a region must change shape (top bar, sidebar, two-pane views).
Container queries over viewport media queries for component internals. All three main views stay
mounted at once (switched by CSS display — see App.jsx / workspace.css), and the tablet story
often wants a pane to adapt to its own width, not the window's. @container lets each region (palette,
results, single-image detail, manage tree) respond to the space it actually has. It's pure CSS, so it's
SSR-safe. Media queries remain for true window-level mode switches (top bar, global nav).
Where container contexts may live (safety constraint, found in Phase 2). container-type implies
contain: layout, which makes the element the containing block for position: fixed descendants.
Several fixed-positioned popovers render inside Home's pane (.hover-tip, the DPL insert toolbar, the
prompt-settings gear popover — all positioned viewport-relative by JS). So we must not put
container-type on .view-pane / .main-col / any wrapper that contains those popovers — it would
break their positioning. Container contexts therefore go on leaf wrappers (a card, a results list, a
detail table) that don't enclose a fixed popover, established per-component in Phases 3–4.
4. The hard constraint: prerender + hydration
The online build prerenders first paint and hydrateRoots it (targets/web/scripts/build.mjs,
entry-server.jsx), and CLAUDE.md is emphatic: the initial render must not touch
window / matchMedia / document / localStorage. A useIsMobile() hook that chooses a layout in
JS would render the desktop tree on the server and a phone tree on the client → hydration mismatch.
Therefore the backbone is CSS-first: the layout is correct at any width from the first painted byte,
because media/container queries resolve during layout, not in a React render pass. JS breakpoint state is
allowed only post-hydration and only for genuinely stateful behavior (is the overflow menu open?),
driven by an effect — never by the initial render. tests/prerender.test.js and warning-free hydration
stay green.
5. Navigation pattern (decided)
Top overflow menu only. On phones the secondary controls (Providers, provider gear, NSFW, theme, links) collapse behind a single top "⋯"/menu affordance; the primary view switch stays at the top, condensed. This keeps today's top-anchored structure (least structural churn, no new bottom bar) while fitting a narrow screen. Tablet keeps controls inline where they fit; desktop is unchanged.
6. Phased build
Each phase is independently shippable and desktop-neutral (existing desktop rendering unchanged —
clamp() maxes equal today's fixed values, so wide viewports clamp to the current look).
Phase 1 — Fluid foundation (tokens). (this branch)
Add a fluid type scale and spacing + gutter tokens to foundation/tokens.css; document
breakpoint conventions. Swap 100vh → 100dvh (with a vh fallback) in app-frame.css. Wire the
fluid gutter into the top bar and .main-col padding (desktop-neutral clamp; tightens on narrow).
Nothing else keys off them yet — pure, safe foundation.
Phase 2 — Adopt the layout layer. ✅ Moved the app frame + top-level view/grid skeleton (.app,
main, .view-pane, .workspace, .sidebar, .main-col) into styles/layout/ at layer(layout);
rewired index.css. The responsive overrides stay in components (they target component-internal
selectors and must win). Container contexts were not placed on the panes — see the safety constraint
above; they'll be established on leaf wrappers in Phases 3–4. Desktop rendering unchanged (no selector
collisions; precedence preserved).
Phase 3 — Top bar → responsive nav. ✅ The secondary control pile (Providers, gear, NSFW, theme,
links) is wrapped in .topbar-overflow: display:contents on wide screens (inline, byte-identical to
before — verified by the visual-regression baselines), and at ≤820px it collapses behind a ⋯ toggle
(MoreIcon) into an anchored dropdown panel. Panel visibility is width-driven CSS; the open state is the
toggle's [aria-expanded], flipped on click post-hydration, so first paint stays SSR-safe (the
prerender.test.js guard passes). Controls render once (no duplicated state / fixed popovers).
Dismisses on Escape + outside pointerdown. At ≤640px the wordmark drops (logo stays) and the view
switch scrolls horizontally rather than overflow the bar. a11y: aria-haspopup/-expanded/-controls
- label; axe finds no serious/critical violations. Verified by the new
tests/e2e/responsive.spec.js(desktop / phone / tablet).
Phase 4 — The heavy views. Split into three independently-shipped sub-phases:
- 4a — Home sidebar → phone drawer. ✅ At
≤640pxthe building-block palette becomes an off-canvas left drawer (.workspace.palette-open); the composer takes the full width, and a floating "Building blocks" trigger (BlocksIcon) opens it. Dismiss via the in-drawer ✕, the scrim, or Escape. Panel state is React (flipped on tap post-hydration); the drawer's existence is width-driven CSS, so first paint is SSR-safe. The full-height drawer restores the vertical palette layout (undoing the 641–860 stack's row-wrapped tabs / capped chip area).prefers-reduced-motiondisables the slide. a11y:aria-controls/-expandedon the trigger + a labelled close. Verified byresponsive.spec.js(desktop inline / phone off-canvas → open → Escape → ✕) and the visual baselines (desktop unchanged). - 4b — Single view. ✅ At
≤860pxthe image+metadata two-column grid (.g-single-body) collapses to one column; the image un-sticks (position: static) and caps at60vhso the details below stay reachable. CSS-only, width-driven — first paint unaffected, no feature change. Verified byresponsive.spec.js(route-mocks/api/feedto populate the view: desktop 2-col vs phone 1-col + un-stuck). A sticky bottom action bar was considered and deferred as optional polish (the action row already wraps; not needed for mobile usability). - 4c — Manage. ✅ At
≤640pxthe tree + editor become a master/detail: with no selection the tree fills the screen; selecting an entry/folder (Reactselected→.workspace.manage.detail-open) shows the editor with a phone-only "‹ Back to list" control. Desktop/tablet unchanged (both panes show; Back hidden). This phase also scoped the Phase-4a Home drawer to.workspace.home— the drawer's base.sidebarrule was matching Manage's.mg-sidebartoo, which would have parked the Manage tree off-canvas with no opener; now Home-only. Verified byresponsive.spec.js(mocks the/api/manage/*backend: phone master→detail→back, tree not a stray drawer; desktop both-panes).
Every capability preserved, only re-homed.
Phase 5 — Touch & input ergonomics. ✅ New touch.css, guarded by input-capability queries (not
width) so it never touches the mouse/desktop rendering. @media (hover: none) keeps the hover-revealed
action buttons visible (Manage entry delete/restore, per-image actions, list-row delete, theme delete) —
otherwise they're untappable on a touchscreen. @media (pointer: coarse) gives the primary controls a
≥44px tap target (WCAG 2.5.5). The palette FAB already uses env(safe-area-inset-bottom). Verified by a
touch-emulated (isMobile) responsive.spec.js case (hover-only action opacity:1; .vs-tab ≥44px).
Phase 6 — Verification. ✅ tests/e2e/responsive.spec.js grew into a full functional viewport matrix
(390 / 768 / 1280) across all views: top bar overflow, Home palette drawer, Single view stack, Manage
master/detail, and touch ergonomics (touch-emulated). The whole gate is green — npm test (check:docs +
lint + smoke + 260 unit + 313 web, incl. the prerender.test.js hydration guard) and the full Playwright
suite (18 specs: E2E + visual regression + @axe-core a11y). Desktop stays pixel-identical. Deferred as
optional: a committed phone-width visual baseline (functional coverage already locks the behavior).
7. Verification per phase
Standard gate (npm test = check:docs + lint + smoke + unit + web) plus, from Phase 6, the Playwright
viewport matrix (npm run test:e2e). Because views stay mounted, always confirm one view's responsive
CSS doesn't leak into another (scope every rule under its view/container). Legal/credits/data-practice
docs are unaffected (no data-flow change).