Skip to content

fix(layout): drive split-pane orientation from CSS media queries so first paint is correct without JS - #203

Merged
obvious-autobuild-staging[bot] merged 3 commits into
masterfrom
feat/css-first-split
Sep 7, 2026
Merged

fix(layout): drive split-pane orientation from CSS media queries so first paint is correct without JS#203
obvious-autobuild-staging[bot] merged 3 commits into
masterfrom
feat/css-first-split

Conversation

@obvious-autobuild-staging

@obvious-autobuild-staging obvious-autobuild-staging Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Why

On desktop, csvjson.com painted a stacked converter on load and then snapped into columns once React mounted — the first-paint flash David reported. The prerendered HTML baked in the stacked layout because useMediaQuery("(min-width: 768px)") answers false wherever window.matchMedia is unavailable, which is exactly the prerender environment: dist/index.html shipped 13× flex-col, zero flex-row, and stacked inline geometry (height: 50% left pane, top: 50% seam, cursor-row-resize, aria-orientation="horizontal"). A real desktop browser then mounts with createRoot, matchMedia answers correctly, and the whole layout snaps — that snap is the flash. Mobile never saw it, so any JS-side fix that just corrects the prerender guess would move the flash to another viewport, not remove it.

What

Orientation is now a CSS decision, not a JS decision. SplitPane renders the same orientation-neutral markup for both layout values, and media queries pick the geometry at paint time — correct in both orientations, with zero JS dependency, so the prerendered HTML matches React's first paint in every viewport.

  • Container: always flex-col md:flex-row (the stacked ? "flex-col" : "flex-row" ternary is gone).
  • Pane size: the orientation-ternary inline height/width style is replaced by an inline --split custom property plus h-[var(--split)] md:h-auto md:w-[var(--split)]. Drag still updates --split via React; CSS consumes it.
  • Border: border-b md:border-b-0 md:border-r.
  • Seam: --split + top-[var(--split)] … md:left-[var(--split)] replaces the top:/left: ternary; zero-dimension axis and cursor (cursor-row-resize md:cursor-col-resize) are classes. touch-action: none stays inline — it is orientation-neutral.
  • JS survives for what JS is right for: the layout prop still feeds pointer drag math (clientY vs clientX) and aria-orientation, which are not first-paint visual concerns. The 768px coupling between the md: variants and App.tsx's useMediaQuery is documented in both files.

Rejected alternative: stubbing matchMedia in the prerender script. That fixes desktop but flips the flash to mobile (columns → stacked on mount). CSS-first fixes every viewport and matches the rebuild spec's constraint that prerendered HTML matches first paint.

How to Review

  • app/src/components/SplitPane.tsx — the whole fix. Geometry is expressed as longhand position/size classes (no inset-* shorthands mixed with longhands at the same variant), so every base property is either overridden or explicitly reset under md:.
  • app/src/components/SplitPane.test.tsx — six new tests pin the contract: identical direction classes for both layouts, --split on panes and seam, no orientation-ternary inline geometry, drag updating the CSS var, cursor/aria still following the prop.
  • DividerSwitch needed no change — it is absolutely centered on the seam strip and reads no orientation props.
  • Intentionally unchanged: pointer/keyboard seam logic, pane content, paneStates behavior — no behavior change beyond first paint.

Acceptance mapping

  • Prerendered dist/index.html contains md:flex-row and --split (verified after build); zero height: 50%/top: inline pane or seam styles remain.
  • All new Tailwind classes compile into the CSS asset (verified against dist/assets/*.css, not purged).
  • Full Vitest suite green: 357 tests (351 baseline + 6 new); lint, tsc -b --noEmit, npm run build with app/dist rebuilt and committed, verify-seo.sh green.
  • Browser dogfood evidence (desktop first paint, fresh-load WebM, 375px mobile) recorded against the tested head SHA via qa-evidence-upload before merge.

🔗 Obvious Project · 🧵 Obvious Thread

Test Evidence

Fresh page load: master shows the stacked-to-columns flash, fix build paints side-by-side from the first frame
Fresh page load: master shows the stacked-to-columns flash, fix build paints side-by-side from the first frame — before
Fresh page load: master shows the stacked-to-columns flash, fix build paints side-by-side from the first frame — after
Fresh page load: master shows the stacked-to-columns flash, fix build paints side-by-side from the first frame — result

…irst paint is correct without JS

The prerendered HTML shipped the stacked layout (useMediaQuery answers
false without matchMedia), so desktop users saw the converter paint
stacked and snap to columns when React mounted. Orientation now renders
through CSS media queries — flex-col md:flex-row, an orientation-neutral
--split custom property consumed by responsive h-/w-/top-/left- classes —
so the prerendered HTML is correct at first paint in both orientations.
The layout prop survives only for drag math and aria-orientation.
@obvious-autobuild-staging
obvious-autobuild-staging Bot marked this pull request as ready for review September 7, 2026 20:49
The EditorView mounts from a passive effect after the output-view host
div commits, so a test that resolves on the host and queries .cm-editor
immediately can lose that race on slow CI schedulers (2x CI failures on
outputEditing while the suite passes locally). populate() now waits for
the editor before any editor lookup.

@obvious-autobuild-staging obvious-autobuild-staging Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Obvious Code Review

Verdict: COMMENT

  • Blocker: 0
  • High: 1
  • Medium: 0
  • Suggestion: 0

Verified at head fd94253 in an isolated worktree: lint, typecheck, 357/357 tests, and a byte-identical app/dist rebuild all pass. The changelog entry (id 7) follows the repo's bundling convention, and the prerendered HTML carries the new orientation-neutral geometry.

Summary of the open finding: the JS drag-math breakpoint (matchMedia("(min-width: 768px)")) and Tailwind's md: (@media (min-width: 48rem)) only agree while the browser's default font size is 16px — media-query rem resolves against the initial font size, so a user font preference shifts the CSS breakpoint but not the JS one. For other font preferences there is a viewport band where the painted layout and the drag-math/aria-orientation axis disagree. Details and the one-line fix in the inline comment.


View full review in Obvious

Comment thread app/src/App.tsx Outdated

// Responsive orientation is CSS-first in SplitPane (flex-col md:flex-row);
// this query feeds only drag math and aria-orientation. Keep the 768px
// breakpoint in sync with the md: variants in SplitPane.tsx.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High (reliability): px-based JS breakpoint vs rem-based Tailwind md: — these only agree while the browser's default font size is 16px.

Tailwind compiles md: to @media (min-width: 48rem) (confirmed in the built CSS; no screen overrides in this repo). In media queries, rem resolves against the initial font size — the user's default-font preference — while this query is fixed at 768px. With a 20px default, md: fires at 960px but this fires at 768px: across the 768–960px band the CSS paints the stacked layout (horizontal seam, cursor-row-resize) while layout="side-by-side" drives pointermove with (clientX − rect.left) / rect.width — the drag stops tracking vertical cursor motion, and aria-orientation reports "vertical" against a horizontal seam. Smaller defaults (e.g. 14px → md: at 672px) produce the mirror band (672–768px): side-by-side paint, stacked drag math.

Before this PR, visible geometry and drag math both derived from the single layout value and could never disagree; the split ownership introduced the gap. This comment documents "keep the 768px breakpoint in sync with the md: variants", but px-sync is not the actual invariant — unit-sync is.

Suggestion:
Query Tailwind's breakpoint in the same units:

// 48rem — Tailwind's `md:`. As a media query this resolves against the same
// initial font size as the compiled CSS, so the two can never diverge.
const desktop = useMediaQuery("(min-width: 48rem)");

Reword the comment accordingly ("48rem — Tailwind's md:"), and optionally hoist the query string into a shared constant next to the SplitPane breakpoint note so future breakpoint changes move both sides together.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2f92a79: the query is now useMediaQuery("(min-width: 48rem)"), matching Tailwind's compiled @media (min-width: 48rem) unit-for-unit, so paint and drag math can no longer diverge under a non-16px default font. The comments in App.tsx and SplitPane.tsx now state the real invariant (unit-sync) instead of px-sync, and PaneShell's stale '768px JS breakpoint' wording — its reflow is pure CSS — was corrected too. Verified: lint, typecheck, 357/357 tests, dist rebuild, verify-seo.

…s md:

Tailwind compiles md: to @media (min-width: 48rem), and in media queries
rem resolves against the browser's initial font size — a fixed 768px JS
query drifts from the CSS breakpoint whenever the default font differs
from 16px, leaving a viewport band where painted layout and drag
math/aria-orientation disagree. Querying 48rem keeps both on the same
unit. Comments updated to state the real invariant (unit-sync, not
px-sync).

@obvious-autobuild-staging obvious-autobuild-staging Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Obvious Code Review

Verdict: COMMENT

No findings — clean pass.

The High from the previous review (px-based 768px drag-math breakpoint vs rem-based Tailwind md:) is verified resolved at head 2f92a79: App.tsx now queries useMediaQuery("(min-width: 48rem)"), which as a media query resolves against the same initial font size as Tailwind's compiled CSS, so the paint-time orientation and the drag-math/aria-orientation axis can no longer diverge for any browser default font size. Comments in App.tsx, SplitPane.tsx, and PaneShell.tsx now document the unit invariant correctly.

Verified at this head in an isolated worktree: lint, typecheck, 357/357 tests, and a byte-identical app/dist rebuild all pass.


View full review in Obvious

@obvious-autobuild-staging
obvious-autobuild-staging Bot merged commit dcaa2c8 into master Sep 7, 2026
3 checks passed
@obvious-autobuild-staging
obvious-autobuild-staging Bot deleted the feat/css-first-split branch September 7, 2026 21:18
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.

1 participant