mcpbeat Sign in

Empty State Must Keep Its Navigation Agent Skill

Replace a populated header with an empty-state message without deleting the controls that header owned — re-supply them only in the branch that owned them, order the loading branch above the empty one, and stop reserving the artwork's height for a line of text. Use when a user reaches an empty period, filter or search result and cannot get back out, when an empty message flashes on every reload, or when the same control renders twice.

2k tokens
context cost
the whole folder, loaded on every use
1
files
instructions only
0
copies elsewhere
how many repositories repackaged it
100
stars on the repo
on the repository, not the skill itself

Install

one command, takes just this skill from the repository
npx skills add https://github.com/maxrave-dev/kotlin-footguns --skill empty-state-must-keep-its-navigation

The instruction itself

3 sections, as written by the author

An empty state inherits the container's duties

A header composable is rarely just a header. It usually also carries the controls that change what

the screen is showing — a period navigator, a filter chip row, a sort control. Swapping the whole

thing for "nothing here" therefore removes the only way to reach a state that *does* have something,

and the user is parked.

when {
    topItems is Resource.Loading -> LoadingHeader(headerHeight)   // FIRST, always
    topItem == null              -> EmptyPeriodHeader(uiState, isPortrait, onStep)
    isPortrait                   -> PortraitHeader(topItem, headerHeight, …)
    else                         -> LandscapeHeader(uiState, topItem, onStep, …)
}
// inside EmptyPeriodHeader: re-supply the navigator ONLY where the header owned it
if (!isPortrait) {
    Spacer(Modifier.height(20.dp))
    PeriodNavigator(uiState, onStep, 0.dp)
}

Traps

Take an inventory of what the replaced container owned before replacing it. The wide arrangement

keeps the navigator inside its header; the tall one renders it as a separate item further down the

list. Substituting an empty-state header therefore deletes the navigator in one arrangement and not

in the other — the same edit, two different bugs, and only one of them reproducible on the device

you happen to be holding.

Re-supplying it unconditionally is the opposite bug. Add the control to the empty state without

the orientation test and the tall arrangement renders it twice: once inside the empty header and

once as the list item that was always there. The condition is not defensive noise; it mirrors the

ownership split exactly, so it has to be re-checked whenever either arrangement moves the control.

Order the loading branch above the empty branch. Both are usually expressed as "no data yet", and

data == null is true during a load. With the branches the other way round, every step to a new

period flashes "this period is empty" before the rows arrive — and users then report an emptiness

bug that does not exist. Loading first is a structural fix; an isLoading && bolted onto the empty

condition is the same thing said badly.

An empty state has two causes and two different recoveries. "Nothing here yet — go and do

something" is the right sentence only for a user who has never produced any data. Once they have stepped to

another window it is simply false — what they need to know is that *this* window is empty and the

arrows lead elsewhere. Branch the message on the offset (or on whether any data exists at all), not

on the emptiness of the current query.

Do not reserve the artwork's height for a line of text. The populated header takes a fraction of

the window (40% here) because an image fills it. The empty branch inherits that number by accident,

and the result is a dead band with a sentence floating in it and the only actionable control pushed

below. Give the empty branch its own bottom inset — a fixed 32dp here — chosen for text.

Floating siblings still occupy the top of an empty screen. Back buttons and pickers positioned

over the header are siblings of the list, not children of it, so they keep their place when the

header is swapped. Anything the empty branch draws above their bottom edge lands underneath them.

The populated branch never showed this, because an image is exactly what glass controls are designed

to float over; a line of text is not.

The reservation for that strip must be one number, not two spellings of it. One branch reserves

it as a named TOP_STRIP = 80.dp; the other spells it as vertical = 16.dp plus Spacer(48.dp)

plus Spacer(16.dp). Both are 16 + 48 + 16, and a change to the button's 48dp size updates neither

of them. A constant referenced from exactly one place is the tell.

Alignment is a property of the branch, not of the screen. The empty branch is reached from both

arrangements, so every inset it applies has to be conditional too — centred in the tall one, leading

in the wide one, and at different horizontal insets in each. A single unconditional padding value is

right for one arrangement and visibly wrong in the other.

Line the empty text up with the body, not with the header. In the wide arrangement the sections

below sit at the outer gutter *plus* their own content inset; text placed at only the gutter misses

by the inset and reads as broken beside the first section heading. Two additions, not one.

One period, two emptiness sources, is a screen that can disagree with itself. The header here

tests the top-items query while every section below tests the aggregate snapshot. They come from the

same events over the same window so they agree in practice — but they are separate asynchronous

calls, so during a step one can land before the other. Deriving both from one snapshot removes the

window entirely: one-snapshot-per-period-not-many-flows.

Which arrangement is in play must be decided by window size, not by platform —

responsive-gate-size-not-platform. And an empty state that hides *itself* is the failure in

partial-chart-must-say-so: the case that most needs an explanation is the one rendering nothing.

Verifying it

Run these from the repository root. They are read-only.

  • Every place the navigation control is instantiated. One per branch that owns it, plus the

definition — a count lower than the number of header branches means some branch has no way out:

   grep -rn --include='*.kt' "PeriodNavigator(" . | grep -v '/build/'

Expect three call sites (tall arrangement's list item, empty branch, wide arrangement's header)

and one definition.

  • Branch order inside the header's when. Within a file, the loading label must carry a lower line

number than the null label; unrelated == null -> branches in other files also match:

   grep -rn --include='*.kt' -E "^ +[A-Za-z]+ is [A-Za-z.]*Loading ->|^ +[A-Za-z]+ == null ->" . | grep -v '/build/'
  • The conditional re-supply, and the strip constant that only one branch uses:
   grep -rn --include='*.kt' -B2 "PeriodNavigator(" . | grep -v '/build/' | grep -E "if \(|isPortrait"
   grep -rn --include='*.kt' "TOP_STRIP" . | grep -v '/build/'

The first prints the single if (!isPortrait) guard; the second prints a definition and exactly

one use — the other branch is spelling the same reservation out by hand.

  • By hand: step back to a window with no data in both orientations. Confirm the arrows are

present in each, appear once, and still step; then step forward again and confirm no empty

message flashes while the rows load.

How to use it

Copy the folder

Take maxrave-dev/empty-state-must-keep-its-navigation from the repository into ~/.claude/skills for personal use, or into .claude/skills inside a project.

Check the name does not clash

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.