Four CSS rules that fought our two-column refactor
Our practice tests were built mobile-first, and it showed. Every game put the stimulus (a matrix, a data table, a passage) in one narrow centred column, with the answer options underneath and the submit button under those. On a phone that is correct. On a laptop it wasted the entire right half of the screen and pushed the options, sometimes the button, below the fold. A candidate on a per-item clock had to scroll before they could answer.
The fix is a two-column layout above lg. The interesting part was not the grid. It was the four things in our own stylesheet that quietly defeated it.
The shape of the thing
One component, used by 56 game files:
<GameSplitLayout
stimulus={<MatrixGrid cells={item.cells} />}
answers={<OptionList options={item.options} onPick={pick} />}
action={<SubmitButton disabled={!picked} />}
mobileWidthClassName="max-w-md sm:max-w-lg"
desktopWidthClassName="lg:max-w-6xl"
ratio="wide-stimulus"
/>
The constraint I set before writing a line of it: mobile must render byte-identical markup. Not "looks about the same". Identical. 56 screens is far too many to re-verify by eye on a phone, so every class the split adds is lg: prefixed, and below 1024px the component collapses to w-full <your widths> space-y-4 wrapping two plain divs. That is exactly what a flat sibling list produced before.
Which brings us to the first thing that fought back.
1. space-y-* puts margins on grid items
Tailwind v4 compiles space-y-4 to margin-block-end on every child but the last. Fine in a stack. In a grid, those children are grid items sitting in their own tracks, and the margin is still there, pushing the answers column a rem down inside its cell.
So the rhythm class has to be cancelled at the same breakpoint the grid appears:
className={cn(
'w-full',
mobileWidthClassName,
spacingClassName, // "space-y-4 lg:space-y-6", from the game
'lg:grid lg:items-start lg:space-y-0',
...
)}
Note the order. lg:space-y-0 comes after spacingClassName because cn is tailwind-merge, and tailwind-merge resolves conflicts by last-wins, per variant. Put it first and the game's own lg:space-y-6 wins, and you get a mystery one-rem offset on exactly one column.
2. Grid items default to min-width: auto
This is the CSS spec behaviour that bites everyone once. A grid item will not shrink below its content's intrinsic minimum width. A wide data table or one long unbroken string will blow its track out past the container, and because our game shell is overflow-hidden, that overflow is clipped rather than scrollable. The content is just gone.
<div className={cn('min-w-0', spacingClassName, stimulusClassName)}>{stimulus}</div>
min-w-0 on both tracks, always, not only the one you think is wide.
3. position: sticky does nothing on a stretched grid item
Some games have a long stimulus and a short answer list, and pinning the answers while the stimulus scrolls past is the right call. The obvious implementation does not work:
stickyAnswers && 'lg:sticky lg:top-6' // no effect
Grid items default to align-self: stretch, so the item is already as tall as its row. It has nowhere to stick. You need:
stickyAnswers && 'lg:sticky lg:top-6 lg:self-start'
The prop is also opt-in rather than default, for a reason worth stating: a sticky element taller than its scrollport pins at the top, and its own bottom becomes permanently unreachable. If the answers plus the submit button are not clearly shorter than the viewport, sticky turns a scrolling annoyance into an unanswerable question.
4. Tailwind's scanner reads source text, not runtime values
Three column ratios, chosen per game:
const RATIO_CLASSES: Record<GameSplitRatio, string> = {
balanced: 'lg:grid-cols-2',
'wide-stimulus': 'lg:grid-cols-[minmax(0,1.35fr)_minmax(320px,1fr)]',
'wide-answers': 'lg:grid-cols-[minmax(0,1fr)_minmax(0,1.35fr)]',
};
Written out as complete literal strings. Tailwind generates CSS by scanning your source files for things that look like class names; it does not execute your code. lg:grid-cols-${cols} produces a class at runtime that has no CSS behind it, and you find out in production, on the one layout you did not open locally.
The stylesheet you forgot you wrote
The four above are general CSS. These two were ours, and they are the reason I now read globals.css before writing any layout in this codebase.
contain changes what fixed means. Our scroll container carries contain: layout style paint. Containment establishes a containing block for fixed-position descendants, so position: fixed inside a game resolves against that container rather than the viewport. A "fixed" bottom bar lands somewhere surprising. Use sticky.
A global !important that eats borders. There is an unlayered rule in our stylesheet:
[class*='bg-card'] {
border: none !important;
}
Attribute-substring, deliberately, so that bg-card/90 is caught too. It also means anything with bg-card anywhere in its class attribute cannot have a border, including every <Card> component. If a column needs a visible edge, bg-muted/40 with border-border border, or an explicit bg-border h-px divider.
And one more: we hide every scrollbar in the app. So a nested overflow-auto region inside a game would scroll with no visible affordance at all. GameSplitLayout therefore adds no overflow-* and no fixed heights. The columns grow, and the one real scroll container in the shell scrolls, exactly as it did before.
Was it worth 85 files?
The diff was 85 files, +4521 / -3003. The honest answer is that about a third of that churn was mechanical and could have been a codemod if the games were more uniform, which they are not, because each one imitates a different real assessment with different item shapes.
What made it tractable was the byte-identical-mobile rule. It turned "re-check 56 screens on two form factors" into "re-check 56 screens on one", and it meant every regression I did find was a desktop regression, which is the one you can see on the machine you are working on.
You can resize any of the practice tests at cogniprep.app/games across 1024px and watch the split appear. The candidate-facing reason this matters, incidentally, is that most of these tests are timed per item, so a scroll between reading and answering is not a cosmetic cost.

