From 5ea843684d61d7c73e910ff5f0f3ea53b6182302 Mon Sep 17 00:00:00 2001 From: nyblnet <306268465+nyblnet@users.noreply.github.com> Date: Wed, 9 Sep 2026 02:21:26 +0100 Subject: [PATCH 1/2] kernel: the shared menu primitive, and the knowledge four apps each paid for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/workflows/ci.yml | 10 ++ docs/DECISIONS.md | 66 ++++++++ kernel/src/ui/menu.css | 264 ++++++++++++++++++++++++++++++ kernel/src/ui/menu.ts | 342 +++++++++++++++++++++++++++++++++++++++ scripts/test-ui-menu.ts | 263 ++++++++++++++++++++++++++++++ 5 files changed, 945 insertions(+) create mode 100644 kernel/src/ui/menu.css create mode 100644 kernel/src/ui/menu.ts create mode 100644 scripts/test-ui-menu.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e977494d..e8cf2dd4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -329,6 +329,16 @@ jobs: # plus the theming guard (every colour chain reaches a token each of the # four apps defines and themes). Each is mutation-caught. run: node scripts/test-ui-panel.ts + - name: Shared menu primitive rig + # kernel/src/ui/menu.ts replaces four hand-rolled dropdowns, and what + # differed between them was all INVISIBLE: three of the four apps + # publish no aria-expanded at all, five of slides' eight dropdowns never + # dismissed on an outside press, no app closed a menu on Escape or let + # an arrow key move through one, and two apps added a document listener + # per dropdown and removed none. Each of those is a check here, because + # every one of them is a regression a sighted developer with a mouse + # would never notice. + run: node scripts/test-ui-menu.ts - name: First-page preview rig # Every save writes a static render of page one for file-manager diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 2a0a374f..6170d7a4 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -6578,3 +6578,69 @@ Pointers: `kernel/src/ui/panel.ts`, `kernel/src/ui/panel.css`, `scripts/test-ui-panel.ts` (62 checks; the drag/RTL/collapse/persistence/drawer behaviours are each mutation-caught, and the theming guard is reproduced from tier 1 — to be factored into one shared helper when both primitives have landed). +## 2026-09-09 — Shared UI primitives live in the kernel; the menu is the first, and values stay the app's + +**Decision.** The seven UI primitives every Bento app implements independently +move into `kernel/src/ui/`, one at a time, each as its own serialized kernel PR. +The menu/dropdown is first: `kernel/src/ui/menu.ts` + `menu.css`, guarded by +`scripts/test-ui-menu.ts`. **This lands the shared definition only — each app's +migration is that app zone's own change**, per the kernel zone's rule that a +kernel PR never carries the app half. + +**Why, and it is not bytes.** Each Bento app is one self-contained file, so +shared code is bundled into each anyway; measured, the primitive adds 604 +compressed bytes to the slides shell while slides still carries its own menu +code as well. A general primitive can easily cost more than the specific thing +one app needed. The return is elsewhere. + +Comparing CSS selector NAMES across the apps finds nothing — two apps +implementing the identical dropdown as `.ed-menu` and `.dv-menu` register as +zero overlap, which is why an earlier count of 11 shared selectors out of 2,156 +concluded, wrongly, that there was nothing to share. Compared by CONCEPT, seven +primitives are implemented four times over. 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 real 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 — cost slides real +debugging. Slides, spaces AND dash each record both traps in their own source, +because each hit the same wall independently. Type records only one. Three teams +paid for the same two lessons, and the only home either lesson had was a comment +in one app's CSS. `menu.css` is now that home. + +**What differed between the four was invisible, and that is what the primitive +fixes.** Of slides' eight dropdowns, five had no outside-press dismissal at all, +one hand-rolled it inline and two called a helper; none closed on Escape. Three +of the four apps publish no `aria-expanded` anywhere. No app had arrow-key +navigation. slides and spaces each add document listeners per dropdown and +remove none. So the primitive takes dash's single delegated listener pair and +mutual exclusion, spaces' ARIA and disabled/selected row semantics, slides' +split-button and phone-fold structure — and adds the arrow-key navigation and +focus-return that no app had. + +**"Slides is the basis" is true of the CSS and false of the behaviour.** It was +the maintainer's default and it holds for structure; where slides has the least, +the fuller implementation wins on its merits and this entry says which. + +**Token VALUES are deliberately NOT settled here.** slides, spaces and type +agree; dash has diverged forward with `--line-strong`, `--shadow-pop`, +`--radius-lg` and a `light-dark()` dark mode. Which way that resolves is a +maintainer's ruling and a later tier of this work. Until then every value in +`menu.css` reads through a `--bkm-*` property whose fallback chain lands on +whatever the host app already defines, so an app that adopts the primitive keeps +the appearance it has today. Only two values had to be picked, because a shared +rule cannot hold four: the trigger offset (6px) and the stacking level (60), +each the majority of the four and each overridable per app. + +**The kernel ships CSS now, which it never did before.** Verified end to end +rather than assumed: an app's `import '../../kernel/src/ui/menu.css'` is folded +by Vite into the single stylesheet that `scripts/postbuild-compress.mjs` +deflates into the `#bento-rt-css` payload, and the rules inflate intact out of +the built shell. No build change was needed. `menu.ts` deliberately does NOT +import its own stylesheet: keeping them separate is what lets the rig exercise +the whole primitive in node with no build machinery and no CSS-import stub. + +Pointers: `kernel/src/ui/menu.ts` (the four-app comparison, function by +function), `kernel/src/ui/menu.css` (details 9 and 10, written down once), +`scripts/test-ui-menu.ts` (what each check guards, and which app's gap it is). diff --git a/kernel/src/ui/menu.css b/kernel/src/ui/menu.css new file mode 100644 index 00000000..614029cf --- /dev/null +++ b/kernel/src/ui/menu.css @@ -0,0 +1,264 @@ +/* SPDX-License-Identifier: MIT + Copyright (c) 2026 The Bento authors + + THE SHARED MENU PRIMITIVE'S STYLES. See menu.ts for why this exists and what + each of the four apps contributed. + + THIS IS THE FIRST CSS THE KERNEL HAS EVER SHIPPED. It reaches a build the way + every other kernel module does — an app imports it, and Vite folds it into the + one stylesheet that `scripts/postbuild-compress.mjs` then deflates into the + `#bento-rt-css` payload. Nothing in the build needed changing for that, but it + is worth stating plainly, because that script matches exactly ONE + `