Skip to content

feat(ui): full-bleed library grid and a resizable docked asset panel - #22

Merged
davior merged 2 commits into
mainfrom
claude/nice-feynman-id0y2i
Sep 17, 2026
Merged

davior merged 2 commits into
mainfrom
claude/nice-feynman-id0y2i

Conversation

@davior

@davior davior commented Sep 17, 2026

Copy link
Copy Markdown
Owner

M1 built the library UI for one screen size and never revisited it while M3–M6 accreted panels onto AssetDetail. Two consequences, both reported by the user.

The grid wasted the screen

LibraryView sat in mx-auto max-w-7xl, so above 1280px the library was a centred column with dead margins either side. Its grid-cols-2 → sm:3 → lg:4 → xl:6 ramp then stopped at six, so past 1280px tiles stretched instead of multiplying.

It is full-bleed now, on auto-fill tracks that add columns as room appears rather than at four fixed viewport widths. Measured in a real browser:

Viewport Columns Tile width
390 (phone) 2 173px
768 (tablet) 3 237px
1280 4 303px
1920 6 305px
2560 8 306px
1920, panel open 4 342px

auto-fill rather than auto-fit, so a half-empty last row stays left-aligned instead of stretching two tiles across the screen. A max-w-[25rem] on the card is what actually caps a tile — auto-fill tracks reach roughly twice their minimum before a further column fits — and the grid's justify-items-center keeps a capped tile centred in its track rather than leaving the gutter all on one side.

Opening an asset took over badly

AssetDetail was an 896px centred modal with a fixed 320px sidebar and five hard-coded viewport caps (45vh video, 60vh image, 38vh transcript ×2, 90vh dialog) — small on a large screen, cramped on a small one, and covering the library either way.

It now docks against the right edge from 1024px up, drag-resizable from a handle, with the library visible and reflowing beside it. Below 1024px it is a full-screen sheet. The media pins to the top and the rest lives in tabs that fill the remaining height, so the transcript gets the whole panel instead of a 38vh window; past @4xl the media moves beside the tabs instead of above them.

The drag handle is a real role="separator" — arrow keys, Home/End, double-click to reset — and the width is remembered in localStorage.

Container queries, not viewport breakpoints

Once the panel is open and drag-resizable, the viewport no longer describes how much room the grid beside it has, so xl: is the wrong signal. Both the grid and the panel interior are driven by @container. That is the one new dependency (@tailwindcss/container-queries), build-time only, and it keeps everything in class strings rather than adding a semantic class to main.css, which is a vendored file exempt from Prettier.

Chrome split from content

AssetDetail baked fixed inset-0 … bg-black/50 into itself, which is why all three of its callers got modal chrome whether it suited them or not. Positioning moved to a new DetailDock; the content fills whatever box it is handed.

/a/{id} was the clearest victim — it drew a floating dialog over an empty shell. It is a full-width page now, which is what the route was for.

Changes

New — components/DetailDock.tsx, components/Tabs.tsx (the first UI primitive here), utils/useMediaQuery.ts, utils/useResizablePanel.ts, utils/useAutoGrow.ts, plus co-located tests for the two components.

Reworked — components/AssetDetail.tsx, views/LibraryView.tsx, views/SearchView.tsx, views/AssetView.tsx, components/AssetCard.tsx.

Touched — TranscriptPanel/DocumentTextPanel gain flex-1 on their roots so they fill a tab; tailwind.config.js and test-setup.ts (stubs for matchMedia and ResizeObserver, alongside the two jsdom already needed).

Description and summary auto-grow instead of scrolling inside 80px, and re-measure on a ResizeObserver because the panel's width changes under them without the text ever changing.

Tabs keeps hidden panels mounted: TranscriptPanel fetches on mount, so rendering only the active tab would refetch the transcript and throw away its scroll position every time you checked the description and came back.

Test impact

LibraryView.test.tsx needed no changes — DetailDock keeps role="dialog" and the accessible name — and gains one test for swapping assets without closing the panel.

AssetView.test.tsx's four role="dialog" assertions became heading assertions. /a/{id} is a page now, and asserting it is a dialog would be asserting the bug. The stale usageApi.forAsset mock beside them is fixed too — its field names (total_cost, events, estimated) are not on UsageTotals, and an as never cast was hiding it.

257 tests pass; format:check, lint, vitest and build are all green. No backend files are touched.

Verification

Driven in a real Chromium at 390/768/1280/1920/2560, light and dark, with the API stubbed at the network layer: column counts and tile widths come out as tabled above, the grid reflows live as the panel is dragged, the transcript fills the panel, the sheet covers the screen on a phone, and nothing throws.

Known gaps, deliberately not closed here

Both are recorded under Outstanding, unscheduled in docs/plan-of-attack.md rather than widening this change:

  • Escape does too much. TagInput and the transcript segment editor handle Escape without stopPropagation, so dismissing a suggestion menu or abandoning a half-typed correction also closes the panel. Predates this work — the old modal had the same listener.
  • The full-screen sheet is not a trapped modal. No focus trap, no focus restore, the library behind is not inert. Docked, none of it applies.

While in that section, two entries it still listed as gaps were struck: the /a/{id} deep link and the 401 interceptor were both built in cdcabbf, and a handoff doc that reports closed gaps as open defeats its own purpose.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YTm2BUsWF7ufB3fYs9kNgB


Generated by Claude Code

… sit

The library was built in M1 for one screen size and never revisited while
M3-M6 accreted panels onto it. Two consequences, both reported by the user.

The grid sat in `mx-auto max-w-7xl`, so above 1280px the library was a
centred column with dead margins either side, and its `grid-cols-2 →
sm:3 → lg:4 → xl:6` ramp stopped at six — past that, tiles stretched
instead of multiplying. It is full-bleed now, on `auto-fill` tracks that
add columns as room appears: 2 on a 360px phone, 4 at 1280, 6 at 1920, 8
at 2560. `auto-fill` rather than `auto-fit` so a half-empty last row stays
left-aligned instead of stretching two tiles across the screen, and a
`max-w-[25rem]` on the card is what actually caps a tile, since auto-fill
tracks reach roughly twice their minimum before a further column fits.

Opening an asset was a 896px centred modal with a fixed 320px sidebar and
five hard-coded viewport caps — 45vh video, 60vh image, 38vh transcript
twice, 90vh dialog — small on a large screen, cramped on a small one, and
covering the library either way. It now docks against the right edge from
1024px up, drag-resizable from a `role="separator"` handle (arrow keys and
Home/End too, double-click to reset) with the width remembered, and the
grid reflows beside it. Narrower than that, it is a full-screen sheet.
The media pins to the top and the rest lives in tabs that fill the
remaining height, so the transcript gets the panel instead of 38vh; past
`@4xl` the media moves beside the tabs instead.

Container queries, not viewport breakpoints. Once the panel is open and
drag-resizable the viewport no longer describes how much room the grid
beside it has, so `xl:` is simply the wrong signal. That is the one new
dependency, build-time only.

`AssetDetail` baked `fixed inset-0 … bg-black/50` into itself, which is
why all three of its callers got modal chrome whether it suited them or
not. Positioning moved to `DetailDock`; the content fills whatever box it
is handed. `/a/{id}` was the clearest victim — it drew a floating dialog
over an empty shell, and is a full-width page now. Its four tests asserted
`role="dialog"`, which was asserting the bug, so they assert the heading
instead; the stale `usageApi.forAsset` mock beside them, whose field names
are not on `UsageTotals` and were hidden by an `as never`, is fixed too.

Description and summary auto-grow rather than scrolling inside 80px, and
re-measure on a `ResizeObserver` because the panel's width changes under
them without the text ever changing.

`Tabs` is the first UI primitive here. It keeps hidden panels mounted:
`TranscriptPanel` fetches on mount, so rendering only the active tab would
refetch the transcript and lose its scroll position every time you checked
the description and came back.

Verified in a real browser at 390/768/1280/1920/2560, light and dark, with
the API stubbed at the network layer — column counts and tile widths come
out as designed and nothing throws.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTm2BUsWF7ufB3fYs9kNgB
Written before the pull request existed, so the entry had nowhere to send
a later session looking for the reasoning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTm2BUsWF7ufB3fYs9kNgB
@davior
davior marked this pull request as ready for review September 17, 2026 07:02
@davior
davior merged commit 4011842 into main Sep 17, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants