When two looks of one screen each need a fit-exactly-one-screen measurement — measure the fixed blocks, split the remainder into equal gaps, floor it at a minimum — keep a copy per look instead of hoisting one; covers the effect keys the block needs, the invisible spacer that must mirror the ratio actually drawn, and which spacer may animate. Use when the gap above or below a hero element keeps last track's size, when content that should end at the fold overflows or leaves a band of dead space, or before extracting "the same" layout maths from two screens into one helper.
npx skills add https://github.com/maxrave-dev/kotlin-footguns --skill variant-layout-math-stays-in-the-variant
The block is small and looks shareable: measure the header, the hero frame and the info column, then
gap = (screenHeight − header − hero − info − minimum) / 2, floored at minimum, drawn above and
below the hero so it lands optically centred with the rest just reaching the fold.
// adapted
LaunchedEffect(headerDp, heroDp, infoDp, screenInfo, minimumDp) {
if (headerDp > 0 && heroDp > 0 && infoDp > 0 && screenInfo.hDP > 0) {
val result = (screenInfo.hDP - headerDp - heroDp - infoDp - minimumDp) / 2
gapDp = if (result > minimumDp) result else minimumDp
}
}
Do not hoist it. The two copies diverge, and one of them diverges silently. In this tree the hero
frame of one look is always square, while the other's changes shape while a video plays. Written
once, the shared version has to satisfy the union of both constraints — so the simple look pays for
the complex one with an extra effect run and relayout on every track change, and neither look's
author can tell which constraint belongs to whom. Two twenty-line copies with a comment each are the
cheaper artefact; see screen-shell-content-split for what genuinely does belong in the shell.
Key the effect on everything the body reads — including the measured hero height. The copy whose
hero is a fixed square reads heroDp in the body and does not list it as a key. That is a latent
stale read, invisible only because a constant-height hero never changes after the first measurement.
Copied into the look whose hero *does* change height, it became a real bug: the gap kept the previous
track's numbers and the whole column drifted. Add the key in both, so the next copy starts correct.
The measuring element is an invisible spacer, and its ratio must mirror what is actually drawn.
The hero is painted by a pager stacked behind the column, so the column reserves the space with a
spacer that reports its own height back. If the drawn frame is 16:9 while a video plays and the
spacer stays square, every number below it is wrong by the difference:
// adapted
Spacer(
Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp)
.onGloballyPositioned { heroDp = with(density) { it.size.height.toDp().value.toInt() } }
.aspectRatio(if (isVideo && watchVideo) 16f / 9 else 1f),
)
The condition on that last line has to be the *same expression* the drawn frame uses. Two independent
copies of one condition is the actual risk here — put it on the state holder if it starts drifting.
The gap spacers may animate; the measuring spacer may not. animateContentSize() on a gap makes
the layout settle smoothly when a track changes shape. On the *measured* element it feeds a stream of
intermediate heights into the effect that computes the gap, which changes the gap, which relayouts —
a measure/animate loop with no fixed point. In both copies here the two gap spacers animate and the
measuring spacer does not.
A spacer that reserves space must not take pointer input, or it eats the gestures aimed at the
element it is standing in for. A bare Spacer is inert; a Box with a background or a click is not.
The floor is a floor, not a guarantee. On a short window the arithmetic goes negative and the
branch returns minimum, so the column overflows and the page scrolls — which is right. What must
never happen is a negative gap being used: it does not clamp, it overlaps.
Measured heights held in saveable state come back before the next measurement. After a rotation
or a process restore the first frame computes the gap from the *previous* configuration's numbers and
corrects on the following pass. That is usually invisible and occasionally a one-frame jump; if it
matters, guard the whole block on screenInfo matching what the numbers were measured under.
they should differ only where the two looks genuinely differ:
grep -rn --include='*.kt' --exclude-dir=build -A8 "LaunchedEffect($" . \
| grep -B1 -A8 "HeightDp,\|heightDp," | head -60
Match the callback receiver-agnostically: its parameter is named by whoever wrote the block and is
as often left implicit, so a pattern anchored on one spelling returns an empty listing against a
tree — or against a snippet lifted from a note like this one — that happens to use the other.
grep -rn --include='*.kt' --exclude-dir=build -A12 "onGloballyPositioned {" . \
| grep "aspectRatio"
grep -rn --include='*.kt' --exclude-dir=build -B6 "onGloballyPositioned" . \
| grep "animateContentSize"
column sits, then skip to a track that plays as video and back. The column must return to the same
place both times — a permanent offset after the round trip is the missing effect key.
Take maxrave-dev/variant-layout-math-stays-in-the-variant from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.