The flat twin shares the geometry, not the material
SkillDev toolsWhen a user setting picks between two renderings of the same control — an expensive decorative one and a plain twin — every geometry decision has to be mirrored between them (item width, bar height, indicator size, inset, and the rule that computes the width budget) so toggling changes the material and never the layout; the compiler cannot pair two constants declared in two files. Also covers recolouring an icon that arrives as a slot lambda, which only CompositionLocalProvider(LocalContentColor) can reach. Use when switching a visual setting also moves things, when the plain variant's indicator sits off its item, or when a slot-lambda icon ignores every tint you pass.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the The flat twin shares the geometry, not the material skill
What this skill tells your AI
The instructions your AI receives, as published by maxrave-dev/kotlin-footguns in skills/flat-twin-shares-geometry-not-material/SKILL.md and read by ahel’s review.
Two renderings of one control — one drawing an expensive decorative surface, one drawing flat theme surfaces — are a single form in two materials. The setting is allowed to change what the pixels are made of. It is not allowed to change where anything is.
// adapted — the flat twin's constants, named to say what they mirror
// Mirrors the decorative bar's geometry (TabWidth / BarHeight / BlobHeight / BarInset) so the two
// bars are one form in two materials.
private val FlatTabWidth = 96.dp
private val FlatBarHeight = 64.dp
private val FlatIndicatorHeight = 56.dp
private val CapsuleInset = 6.dp
Five things are mirrored here, and the fifth is the one people forget — not just the four numbers
but the rule that consumes them: both bars size a item as
((available - inset * 2) / count).coerceAtMost(cap). Copy the constants without the rule and the
twins agree until the tab count changes, then diverge exactly where it is hardest to notice.
Traps
Two constants in two files cannot drift loudly. FlatTabWidth = 96.dp and TabWidth = 96.dp
have no relationship the compiler can check; renaming one, or nudging it by 2 dp during a visual
tweak, produces two bars that are subtly different heights and nothing at all to read. Keep the
comment that names the twin on both declaration blocks — it is the only link that exists — and
when the numbers genuinely want to be shared, hoist them rather than pairing them by eye.
Splits are geometry too. If one item renders outside the container in one twin (a search button
beside the capsule rather than inside it), it must render outside in the other, and both must filter
the list the same way. Get this wrong and the twins have different item counts, so the width budget
divides differently and every position shifts — see nav-tab-registration-drift for what a list
maintained in more than one place does next.
An icon handed in as @Composable () -> Unit cannot be tinted from the outside. The slot is
opaque: there is no colour parameter to reach, and wrapping it in something that draws does not
recolour it. The only handle is the ambient one:
// adapted — the twin's per-item colour, in both bars, applied the same way
CompositionLocalProvider(LocalContentColor provides contentColor) {
screen.icon() // an Icon() inside reads LocalContentColor and tints
}
This works only because the slot's body uses Icon. Image does not read LocalContentColor and
will draw the vector in its authored colour — invisible on the wrong surface
(material-symbols-icon-system).
Colour roles are material and may differ — but check that they differ on purpose. In this tree
the two bars use the same primary for a selected item and different tokens for an unselected one
(onSurfaceVariant flat, onSurface decorative). That is legal under this rule and is also exactly
how an unintended divergence looks. Diff the two colour blocks deliberately whenever you touch
either, because nothing else will.
Guard the position lookup before you offset anything. The indicator's position comes from
indexOfFirst { it.ordinal == selected }, which returns -1 whenever the selection is an item
that lives outside the container. Both twins must hide the indicator on -1
(if (selectedPosition >= 0)); an unguarded width * position slams it a full item to the left of
the container and reads as a rendering glitch rather than a selection state. Coercing the -1 away is
not hiding — it parks the indicator on the previously selected item, which looks like a correct
selection state for the wrong item. In this tree only the flat twin does both, and step 3 below is
what catches that.
Centring is a mirrored decision as well. Both twins hold the container in a weighted slot beside
a sibling button, and both need weight(1f, fill = false) plus Arrangement.Center so the leftover
width goes around the cluster instead of between its halves
(weight-fill-false-to-center-a-cluster). Give one twin fill = true and it is centred while the
other is edge-pinned — a difference the setting appears to cause.
Translucency tints the control, never a strip behind it. The flat twin's translucent mode applies alpha to the container's own colour; painting a translucent band across the full width instead would make the flat twin occupy a different area than the decorative one, which floats.
The twins may not live in the same source set — check before you tune. Here the flat twin is common code while the decorative bar is Android-only, so every other target renders the flat geometry unconditionally. Tune only the platform-specific constants and the shared twin silently keeps the old numbers on every other platform, where nobody is looking; and a preview of the decorative twin simply does not exist there to compare against.
Do not "temporarily" build the twin from the original's leftovers. The plain variant is not the old pre-effect widget kept around — it was rebuilt into the new form deliberately. A twin that is actually the previous design is how a setting ends up switching between two eras of the UI.
Verifying it
# 1. The mirrored constants, side by side. Names differ by prefix, values must not differ at all.
grep -rn --include='*.kt' -E '^private val [A-Za-z]*(Tab|Bar|Blob|Indicator|Inset)[A-Za-z]* = [0-9]+\.dp' . | grep -v '/build/'
→ observed: eight declarations in two files, pairing 96 / 64 / 56 / 6 exactly. A pair that does not line up, or a constant with no partner, is the drift.
# 2. Slot-lambda icons being recoloured. Every twin that renders the slot needs one of these;
# a twin rendering `screen.icon()` with no provider around it draws in the default content colour.
grep -rn --include='*.kt' 'LocalContentColor provides' . | grep -v '/build/'
→ observed: five hits, and they are not symmetrical. Count the item bodies, not the hits: two hits are theme-level providers; of the five slot renderings in the two bars, the flat twin wraps both of its own (its outside-the-container button is a second body) and the decorative twin wraps one of its three, leaving its outside-the-container button and its collapsed-toolbar icon bare. Four wrapped out of five, and the gap is visible: the flat button tints for selected-vs-unselected while the decorative one inherits whatever the theme left in scope and so never shows a selected state.
# 3. The position lookup, which must be guarded in both twins.
grep -rn -A2 --include='*.kt' 'indexOfFirst { it.ordinal ==' . | grep -v '/build/'
→ observed: one per twin, and they disagree — which is the divergence this step exists to find. The
flat twin coerces the -1 away for the offset (selectedPosition.coerceAtLeast(0)) and hides the
indicator a few lines later with if (selectedPosition >= 0). The decorative one passes the raw -1
into its widget, which only coerces: the widget seeds an internal index from the same
coerceAtLeast(0) and syncs it under a if (selectedTab >= 0) guard, so a -1 changes nothing and
the indicator draws unconditionally, parked on the tab you were on. There is no visibility branch
anywhere in that file. Select the outside-the-container item and one bar's indicator disappears while
the other's stays lit.
- By eye: put the two renderings on screen back to back at the same window size and flip the setting with a screenshot before and after. Overlay them — the container outline, the item centres and the sibling button must land on the same pixels. Then drop to two items and grow to five: the twins must stay aligned at every count, which is what tests the shared width rule rather than the shared constants.
Signals
- GitHub stars
- 315
- Forks
- 6
- Last commit
- Aug 2026
Advanced
- Catalog kind
- skill
- Gateway key
flat-twin-shares-geometry-not-material- Source
- github.com/maxrave-dev/kotlin-footguns