From f15a6ec42696dd9ed5241df28ce87c259e5d9e80 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 5 Jul 2026 14:03:15 +0800 Subject: [PATCH 01/10] feat(ui): establish Base UI style-hook convention (data-slot + native state attrs) (#520 PR5 item 23) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation commit for PR5 — the convention the disclosure / tooltip / number-field / input migrations in the following commits follow. data-slot: add data-slot="" to all 20 Base UI wrappers in ui.tsx (Button / Separator / Checkbox / DialogBackdrop / DialogPopup / Select Trigger/List/Popup/Group/GroupLabel/Separator/Item / FieldDescription / Label / Switch / Toggle / ToggleGroup / RadioGroup / Radio / Progress). The ./primitives/ wrappers (accordion / alert / badge / …) already do this; ui.tsx wrappers did not. data-slot is a stable CSS targeting hook that survives className drift. Base UI does not emit data-slot itself, so the wrapper owns it. No CSS targets these yet — this is forward-looking convention, a visual no-op. State-attribute form: adopt Base UI's native attribute-presence form ([data-active] / [data-open] / [data-checked] / [data-selected] / [data-pressed] / [data-highlighted] / [data-disabled]), NOT the attribute-value form ([data-active="true"]). Maka's renderer CSS has zero state-attribute selectors today, so there is nothing to break and no override layer to maintain. A per-component hook map (Tabs data-active / Select data-[highlighted]/data-[selected] / Checkbox/Switch/Radio data-[checked] / Toggle data-[pressed] / Dialog/Tooltip/Popover data-[open]) is documented in a doc comment at the top of ui.tsx, along with the whitelisted CSS var hooks (--anchor-* / --available-* / --active-tab-*). className(state) function form is deferred — add only when a migration in this PR actually needs state-based classes. style-hook-convention-contract.test.ts locks the data-slot rule: every ui.tsx wrapper that forwards to a component must carry data-slot, and the convention doc comment must stay present. Hand-written native elements (legacy Input / Textarea / Badge) are out of scope until they retire onto a Base UI primitive (input canonical, commit 5). Verification: 1972/1972 desktop tests pass (+4 contract). typecheck clean. renderer build clean. Visual no-op (data-slot is an attribute; no CSS targets it yet). --- .../style-hook-convention-contract.test.ts | 107 ++++++++++++++++++ packages/ui/src/ui.tsx | 78 +++++++++++-- 2 files changed, 173 insertions(+), 12 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/style-hook-convention-contract.test.ts diff --git a/apps/desktop/src/main/__tests__/style-hook-convention-contract.test.ts b/apps/desktop/src/main/__tests__/style-hook-convention-contract.test.ts new file mode 100644 index 0000000000..b976bbc6a1 --- /dev/null +++ b/apps/desktop/src/main/__tests__/style-hook-convention-contract.test.ts @@ -0,0 +1,107 @@ +/** + * PR-STYLE-HOOK-CONVENTION-0 (issue #520 PR5 item 23, 2026-07-05): + * every Base UI wrapper in `packages/ui/src/ui.tsx` exposes a `data-slot` + * attribute so CSS can target `[data-slot="..."]` (a stable hook that + * survives className drift), matching the `./primitives/` wrappers that + * already do this (accordion / alert / badge / …). New wrappers + * (Collapsible / Tooltip / NumberField / …) follow the same rule. + * + * Boolean state hooks adopt Base UI's native attribute-presence form + * (`[data-active]` / `[data-open]` / `[data-checked]` / `[data-selected]` / + * `[data-pressed]` / `[data-highlighted]` / `[data-disabled]`), NOT the + * attribute-value form (`[data-active="true"]`). Maka's renderer CSS has + * zero state-attribute selectors today, so adopting Base UI's form breaks + * nothing and avoids an override layer. The per-component hook map lives in + * the doc comment at the top of ui.tsx. + * + * This contract locks the data-slot rule: a wrapper that forwards props to a + * `Base*` Base UI component must carry `data-slot`. It does NOT lock the + * state-attribute decision (that is a "don't override" rule, enforced by the + * absence of `[data-active="true"]`-style overrides, which the existing + * CSS-scan contracts already cover indirectly). + */ + +import { strict as assert } from 'node:assert'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { describe, it } from 'node:test'; +import { REPO_ROOT } from './css-test-helpers.js'; + +const UI_FILE = resolve(REPO_ROOT, 'packages/ui/src/ui.tsx'); + +/** A top-level wrapper declaration: `export const X = forwardRef...` / + * `const X = forwardRef...` / `export function X(...)` at column 0. */ +const WRAPPER_DECL_RE = /^(?:export (?:const|function)|const) [A-Z][A-Za-z0-9]*\b/gm; + +/** A Base UI component JSX tag: ` { + const matches = [...source.matchAll(WRAPPER_DECL_RE)]; + const blocks: Array<{ name: string; body: string }> = []; + for (let i = 0; i < matches.length; i++) { + const start = matches[i].index; + const end = i + 1 < matches.length ? matches[i + 1].index : source.length; + const body = source.slice(start, end); + const name = matches[i][0].replace(/^(?:export )?(?:const|function) /, ''); + blocks.push({ name, body }); + } + return blocks; +} + +describe('PR-STYLE-HOOK-CONVENTION-0 contract', () => { + it('every ui.tsx wrapper that forwards to a Base* component carries data-slot', async () => { + const source = await readFile(UI_FILE, 'utf8'); + const blocks = wrapperBlocks(source); + const offenders: string[] = []; + for (const { name, body } of blocks) { + // Only wrappers that render a `` Base UI component are in scope. + // Hand-written native elements (the legacy Input / Textarea / + //