kernel: the shared menu primitive (UI primitives, tier 1 of 4) - #432
Open
nyblnet wants to merge 2 commits into
Open
kernel: the shared menu primitive (UI primitives, tier 1 of 4)#432nyblnet wants to merge 2 commits into
nyblnet wants to merge 2 commits into
Conversation
…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.
Build size
Updated: |
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Tier 1 of the UI-primitives project, under the kernel serialization lock.
kernel/src/ui/menu.ts+menu.cssdefine the menu/dropdown once;scripts/test-ui-menu.tsguards 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-menuand.dv-menuregister aszero overlap. Compared by CONCEPT, the dropdown is one design copied four times,
and the tell is the offset from the trigger:
calc(100% + 4px)+5px+6px+6px.openclass.sp-openclass.openclasshiddenattribute--surface--surface--surface--field--radius--radius--radius-lg10pxNobody 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-indexis a CEILING that caps everydescendant, and
overflow-y: autoalso clips HORIZONTALLY — are each writtendown in slides', spaces' AND dash's own source, because each app hit the same
wall independently. Type records only one of the two.
menu.cssis now the oneplace either lesson lives.
What differed between the four was invisible
Which is exactly why it went unfixed in three of them:
helper, one hand-rolls the same listener inline, and five have none at all.
aria-expandedappears zero times in slides and dash, once intype, thirteen times in spaces.
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 aprefers-color-schememediaquery, dash via
light-dark(). All four have a dark theme; what differs is whodecides.
A shared sheet cannot express themed values with
light-dark(). It resolvesoff
color-scheme, and slides sets:root { color-scheme: only light }deliberately (
styles.css:96) so dark-mode phones don't render native controlsdark under dark ink. Under
only light, everylight-dark()value pins to itslight 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 themedtoken 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.
typehas no--surface— its chrome token is--field— so--bkm-bgfell through to#fffand a type menu in dark modewould have painted white under light ink.
menu.csshad already named that gapin 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).typedraws that line correctly —
--paperfor the page,--fieldcommented"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 singlestylesheet
scripts/postbuild-compress.mjsdeflates into#bento-rt-css, andthe rules inflate intact out of the built shell. No build change was needed.
menu.tsdeliberately does not import its own stylesheet — that separation iswhat lets the rig exercise the primitive in node with no build machinery.
Verification
test-ui-menu— 94 checks, mutation-tested. Separately disabling thedisabled-row skip, the shared listener, the focus return, the nested-menu
guard, and the
--fieldfallback each turns it red. A green rig I haven'tseen fail proves nothing.
test-sync45368 ·test-preview28 ·test-ci-registered290 ·test-theme38 ·test-storage·test-blobs·test-tokenize·test-return-gate·test-export-secrets— green.tsc -b, exit 0) and build;shell-gatepasses on the slides shell.
dashcould not be built — nonode_modulesin thischeckout.
test-autosaveandtest-validatecrash on missing deps, butidentically 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.tsfails 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.mdends its last entry with aClaude-Session:URL, and its header says "newest first" while the practice isto append at the tail. The attribution leak is already on the board and
origin/ops-commit-msg-attribution-hookexists.