Skip to content

kernel: the shared menu primitive (UI primitives, tier 1 of 4) - #432

Open
nyblnet wants to merge 2 commits into
mainfrom
bento-ui-primitives
Open

kernel: the shared menu primitive (UI primitives, tier 1 of 4)#432
nyblnet wants to merge 2 commits into
mainfrom
bento-ui-primitives

Conversation

@nyblnet

@nyblnet nyblnet commented Sep 9, 2026

Copy link
Copy Markdown
Owner

Tier 1 of the UI-primitives project, under the kernel serialization lock.
kernel/src/ui/menu.ts + menu.css define the menu/dropdown once;
scripts/test-ui-menu.ts guards it.

Kernel half only. No app is migrated here — that is each app zone's own
change, per the kernel brief's rule that a kernel PR never carries the app half.
Nothing imports the primitive yet, so this PR changes no shipped behaviour.

What the measurement found

Compared by selector NAME the four apps share almost nothing — two apps
implementing the identical dropdown as .ed-menu and .dv-menu register as
zero overlap. Compared by CONCEPT, the dropdown is one design copied four times,
and the tell is the offset from the trigger:

slides spaces dash type
offset calc(100% + 4px) +5px +6px +6px
z-index 50 60 60 60
open state .open class .sp-open class .open class hidden attribute
chrome surface --surface --surface --surface --field
radius --radius --radius --radius-lg hardcoded 10px

Nobody designed 4, 5, 6, 6.

The cost is not bytes. Each app is one self-contained file, so shared code is
bundled into each anyway. Measured: 604 compressed bytes added to the slides
shell, with slides still carrying its own menu code as well. Once an app deletes
its own copy this should be roughly neutral.

The cost is that hard-won knowledge has nowhere to live. CLAUDE.md's
hard-won details 9 and 10 — a flex item's z-index is a CEILING that caps every
descendant, and overflow-y: auto also clips HORIZONTALLY — are each written
down in slides', spaces' AND dash's own source, because each app hit the same
wall independently. Type records only one of the two. menu.css is now the one
place either lesson lives.

What differed between the four was invisible

Which is exactly why it went unfixed in three of them:

  • Outside-press dismissal — slides builds eight dropdowns; two call its
    helper, one hand-rolls the same listener inline, and five have none at all.
  • Escape — no slides dropdown closes on it.
  • ARIAaria-expanded appears zero times in slides and dash, once in
    type, thirteen times in spaces.
  • Arrow-key navigation — no app has it, in any menu.
  • Listener leak — slides and spaces add document listeners per dropdown and
    remove none; spaces adds two. The rig asserts eight menus install exactly one
    pointerdown and one keydown listener between them, and that destroying them
    removes both.

So the primitive takes dash's delegation and mutual exclusion, spaces'
ARIA and disabled/selected row semantics, slides' split-button and phone-fold
structure — and adds the arrow navigation and focus-return no app had.

"Slides is the basis" holds for the CSS and not for the behaviour, which the
brief invited me to say if I found it. Slides has the richest structure and the
weakest behaviour of the four.

Theming: the sheet imposes no mechanism, and must not

The four apps theme by three different mechanisms — slides and type via
:root[data-theme="dark"], spaces via that and a prefers-color-scheme media
query, dash via light-dark(). All four have a dark theme; what differs is who
decides.

A shared sheet cannot express themed values with light-dark(). It resolves
off color-scheme, and slides sets :root { color-scheme: only light }
deliberately (styles.css:96) so dark-mode phones don't render native controls
dark under dark ink. Under only light, every light-dark() value pins to its
light half forever — a shared sheet built on it would silently cost slides the
dark theme it already has.

So every value reads through a --bkm-* chain that consumes whatever themed
token the host already defines, by whichever mechanism that app uses
. One
sheet, four mechanisms, no ruling required — which is why tier 1 did not have to
wait for the tier 4 token decision. Adoption is appearance-neutral. Only the
offset (6px) and z-index (60) had to be picked; both are the majority of four
and both override.

One defect this shipped and then caught. A chain ends in a light-only literal
so the rule is never invalid. type has no --surface — its chrome token is
--field — so --bkm-bg fell through to #fff and a type menu in dark mode
would have painted white under light ink. menu.css had already named that gap
in a comment
and the fallback was still wrong, which is the whole argument for a
check over a note. Fixed, and the rig now asserts statically that every colour
chain reaches a token each of the four apps both defines and themes,
accepting all three mechanisms.

Recorded for tier 4, not settled here: menus are chrome and chrome should
follow the theme; the document must not (kernel/src/theme.ts:17-18). type
draws that line correctly — --paper for the page, --field commented
"form-control surface — CHROME, not paper". The shared token set should make it
explicit rather than inherit whichever app's habit arrives first.

The kernel ships CSS for the first time

Verified end to end rather than assumed: an app's
import '../../kernel/src/ui/menu.css' is folded by Vite into the single
stylesheet scripts/postbuild-compress.mjs deflates into #bento-rt-css, and
the rules inflate intact out of the built shell. No build change was needed.
menu.ts deliberately does not import its own stylesheet — that separation is
what lets the rig exercise the primitive in node with no build machinery.

Verification

  • test-ui-menu — 94 checks, mutation-tested. Separately disabling the
    disabled-row skip, the shared listener, the focus return, the nested-menu
    guard, and the --field fallback each turns it red. A green rig I haven't
    seen fail proves nothing.
  • test-sync 45368 · test-preview 28 · test-ci-registered 290 ·
    test-theme 38 · test-storage · test-blobs · test-tokenize ·
    test-return-gate · test-export-secrets — green.
  • slides, spaces, type typecheck (tsc -b, exit 0) and build; shell-gate
    passes on the slides shell.
  • Not done, honestly: dash could not be built — no node_modules in this
    checkout. test-autosave and test-validate crash on missing deps, but
    identically on main — pre-existing, and nothing here touches them.

One contract tension, flagged not resolved

START-HERE says "do not add a CI step while the queue is contended — land the
feature, register the rig in a small follow-up."
But scripts/test-ci-registered.ts
fails on an unregistered rig (288/289 before I added the step). Following the
rule literally lands this PR red. I registered it — one step, at the natural
place beside the preview rig — and am flagging the collision rather than
deciding it. The two rules need reconciling in one direction or the other.

Also noticed, not fixed here because out-of-scope work goes on the board
rather than into someone's PR: docs/DECISIONS.md ends its last entry with a
Claude-Session: URL, and its header says "newest first" while the practice is
to append at the tail. The attribution leak is already on the board and
origin/ops-commit-msg-attribution-hook exists.

…aid for

Tier 1 of the UI-primitives project. kernel/src/ui/menu.ts + menu.css define
the menu/dropdown once; scripts/test-ui-menu.ts guards it. Kernel half only —
each app's migration is that app zone's own change.

Measured by CONCEPT rather than by selector name, seven UI primitives are
implemented independently in all four apps. The dropdown is one design copied
four times, and the tell is the offset from the trigger: slides calc(100% +
4px), spaces +5px, dash +6px, type +6px. Nobody designed 4, 5, 6, 6.

The cost was never bytes — each app is one self-contained file, so shared code
is bundled into each anyway. Measured, this adds 604 compressed bytes to the
slides shell while slides still carries its own menu code as well. What four
copies actually cost is that no piece of hard-won knowledge about menus has
anywhere to live: CLAUDE.md's hard-won details 9 and 10 (a flex item's z-index
is a ceiling that caps every descendant; overflow-y:auto also clips
horizontally) are recorded in slides', spaces' AND dash's own source, because
each hit the same wall independently. Type records only one. menu.css is now
the one place either lesson lives.

What differed between the four was invisible, which is why it went unfixed:
five of slides' eight dropdowns had no outside-press dismissal, none closed on
Escape, three of the four apps publish no aria-expanded anywhere, no app had
arrow-key navigation, and slides and spaces each add document listeners per
dropdown and remove none. The primitive takes dash's single delegated listener
pair and mutual exclusion, spaces' ARIA and row semantics, slides' split-button
and phone-fold structure, and adds the arrow navigation and focus-return that
no app had. Slides is the basis for the CSS; where it had the least, the fuller
implementation won and the source says which.

Token VALUES are deliberately not settled: dash has diverged forward
(--line-strong, --shadow-pop, --radius-lg, light-dark()) and which way that
resolves is the maintainer's ruling, a later tier. Every value reads through a
--bkm-* property whose fallback lands on whatever the host app already defines,
so adoption changes no app's appearance. Only the offset (6px) and the stacking
level (60) had to be picked; both are the majority of four and both override.

The kernel ships CSS for the first time. Verified end to end, not assumed: the
rules inflate intact out of the built, compressed shell, and no build change
was needed. menu.ts does not import its own stylesheet, which is what lets the
rig exercise the primitive in node with no build machinery.

Verification: test-ui-menu 41 checks (mutation-tested — disabling the
disabled-row skip, the shared listener, the focus return and the nested-menu
guard each turn it red). test-sync 45368, test-preview 28, test-storage,
test-blobs, test-theme, test-tokenize, test-return-gate, test-export-secrets,
test-ci-registered 290 all green. slides, spaces and type typecheck and build;
shell-gate passes on the slides shell. dash could not be built (no node_modules
in this checkout) and test-autosave/test-validate fail identically on main —
both pre-existing, neither touched by this change.
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown

Build size

main (0100083) → bento-ui-primitives (f7946bb)

app base PR change
bento/slides 677.9 KiB 677.9 KiB 0.0 KiB (0.00%)
bento/spaces 271.3 KiB 271.3 KiB 0.0 KiB (0.00%)
bento/dash 424.1 KiB 424.1 KiB 0.0 KiB (0.00%)

Updated: 2026-09-09T01:30:47Z

type has no --surface — its chrome surface token is --field, themed #ffffff →
#12151a and commented "form-control surface — CHROME, not paper". So
--bkm-bg's chain fell through to its light-only literal #fff, and a type menu
in dark mode would have painted white under light ink. The comment in menu.css
had already NAMED that gap and the fallback was still wrong, which is the
argument for a check rather than a note.

scripts/test-ui-menu.ts now asserts statically that every colour chain in
menu.css reaches a token each of the four apps both DEFINES and THEMES. It
accepts all three theming mechanisms in the tree — [data-theme="dark"], a
prefers-color-scheme media query, and dash's light-dark() — because "is this
themed" has four apps and three answers. Mutation-tested: restoring the old
chain turns it red on type alone. shadow is exempt and says why: only dash has
a shadow token, and a translucent shadow that ignores the theme is not a
legibility failure the way a background is.

Also corrects a premise this branch had inherited and repeated. dash was
described as having "diverged forward" with a light-dark() dark mode the others
lack; that is false — all four have a dark theme, and --ink: #e7eaf0 sits in
slides:39, spaces:165 and type:75. What actually differs is the MECHANISM, and
that matters more here: a shared sheet must not express themed values with
light-dark(), because it resolves off color-scheme and slides sets
`color-scheme: only light` deliberately (styles.css:96) to stop dark-mode
phones rendering native controls dark under dark ink. Under `only light` every
light-dark() value pins to its light half, so a shared sheet built on it would
silently cost slides the dark theme it has. The fallback chains sidestep this
by consuming whatever the host already themes, by whichever mechanism it uses —
which is why one sheet survives four mechanisms without picking one.

Recorded for tier 4, not settled here: menus are chrome and chrome should
follow the theme; the document must not, per kernel/src/theme.ts:17-18. type
draws that line correctly with --paper for the page and --field for chrome, and
the shared token set should make it explicit rather than inherit whichever
app's habit arrives first.

test-ui-menu 94 checks (was 41). test-ci-registered 290, test-preview 28,
test-theme 38 green; slides typechecks, builds, and passes shell-gate.
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