a template starter repo for sveltekit projects
0

Configure Feed

Select the types of activity you want to include in your feed.

suede / agent-notes / 2026-06-04-03-suede-button-scaffold.md
15 kB 61 lines
1# suede-button-scaffold 2 3## Task 4Scaffold a Suede-wrapped bits-ui Button (`SuedeButton`) that demonstrates the boundary "bits-ui provides functionality, suede (stylebase + scoped CSS) provides style/content." Demo lives in Storybook only (no route page); four story variants (Primary, Disabled, As link, With class override). Goal: a learning scaffold the user can read top-to-bottom to internalize the bits-ui + stylebase + Svelte 5 runes pattern. 5 6## Decisions 7- **Storybook only, Button only** (Q1) — user explicitly dropped the Accordion from scope; route-page demo deferred. 8- **Option C for the existing example stories** (Q2) — keep the auto-generated Storybook Button/Header/Page example content; name the new component `SuedeButton.svelte` (not `Button.svelte`) to avoid path collision. The user expects to delete the examples later. 9- **Approach 2 for the wrapper prop pattern** (Q3) — full HTML attribute spread (`{...rest}`) over bits-ui's `Button.Root`. Plus **Option A** for the type issue: `Props = ButtonRootProps` (the bits-ui-exported union), with `{...rest as Record<string, unknown>}` as the local escape hatch on the spread line. 10- **Utility classes + scoped `<style>` mix inside the component** (Q4, last user message) — `u:fs-1` font-size utility from stylebase in the `class` attribute; everything else (color, padding, radius, hover/active/disabled) in a scoped `<style>` block. Component is usable as-is with sensible defaults; user can override via `class`/`style` props or by adding their own scoped styles at the consumer. 11- **Skip the spec doc** — this is a single-file scaffold; the brainstorming skill's "design can be short for truly simple projects" applies. Plan was presented in chat and approved. 12- **Skip pnpm lint at the project level** — the project has 30+ files with pre-existing prettier drift (AGENTS.md, opencode.json, .opencode/*, .storybook/*, agent-notes/*, src/stories/{Button,Header,Page,Configure}.*, etc.). Reformatted only the two new files. Reformatting the rest is unrelated work and was deferred. 13 14## Actions 15- Read `package.json`, `src/app.css`, `src/routes/+page.svelte`, `src/lib/components/ui/` (empty), `src/stories/Button.stories.svelte` (already references non-existent `$lib/components/ui/Button.svelte`), `.storybook/{main,preview}.ts`, and bits-ui's `ButtonRootProps` type def at `node_modules/.pnpm/bits-ui@2.18.1_*/node_modules/bits-ui/dist/bits/button/types.d.ts` to confirm the `AnchorElement | ButtonElement` union shape that drives the rest-spread type issue. 16- Read stylebase's `dist/stylebase.min.css` to map the utility surface: `u:fs-0..10` (font size), `l:{repel,river,root,waterfall,ui-list}` (layout), and CSS custom properties for the rest (no Tailwind-style atomic utilities for color/spacing/border-radius). 17- Created deciduous goal node 67 with the user's verbatim prompt + planning refinements; branch `feat/install-bits-ui-stylebase`. 18- Wrote `src/lib/components/ui/SuedeButton.svelte` — 5-line script (bits-ui import, `Props = ButtonRootProps`, `$props()` destructure pulling out `href`, `children`, `class: klass`, plus `...rest`); one `Button.Root` element with `{href}`, `{...rest as Record<string, unknown>}`, a template-literal `class` attribute (`suede-button u:fs-1${klass ? ' ' + klass : ''}`), and `{@render children?.()}` (children is possibly-undefined per `WithChildren`); `<style>` block with the visual defaults and `:hover`/`:active`/`:disabled`/`:[aria-disabled='true']` state rules. Plus `<!-- svelte-ignore css_unused_selector -->` to suppress the Svelte CSS-pruner warnings (the class name is in a template literal, invisible to static extraction). 19- Wrote `src/stories/SuedeButton.stories.svelte``defineMeta` block with `title: 'UI/SuedeButton'`, autodocs tag, `onclick: fn()` default arg, and `argTypes` for `href` + `disabled`. Four `<Story>` blocks: Primary, Disabled, As link (`href="https://bits-ui.com"` exercises the polymorphic switch to `<a>`), With class override (`class="u:fs-3"` + inline `style` to show stylebase-utility + custom-style mixing). 20- **`pnpm check` (wrangler types + svelte-check) — 0 errors, 0 warnings on new code**. Two pre-existing errors remain in `src/stories/{Button,Accordion}.stories.svelte` (missing `$lib/components/ui/{Button,Accordion}.svelte`), pre-date this change, out of scope per option C. 21- `pnpm prettier --write` on the two new files — reformatting accepted. 22- `pnpm eslint` on the two new files — no errors. Whole-project `pnpm lint` flags 35 unrelated files with pre-existing prettier drift; not touched. 23- **Browser verification of the rendered DOM** — `pnpm storybook --port 6006` + Playwright script at `verify-button-nesting.mjs` (deleted after use) navigated to each story, queried the iframe body for `button button`, `a button`, and `button a` nested-selector counts. **Caught the button-in-button bug** the user reported: `component: SuedeButton` + children in `<Story>` causes `@storybook/addon-svelte-csf`'s `Story.svelte` (line 148) to render `<SuedeButton {...args}>{children}</SuedeButton>`, wrapping a `<SuedeButton>` inside a `<SuedeButton>`. Same bug would hit the existing `Button.stories.svelte` if its broken import were fixed. 24- **Fix applied** — added `asChild` to all four `<Story>` blocks. With `asChild={true}`, the addon routes children through `{@render children()}` (no component wrapping). Trade-off: args-driven controls don't update the canvas for `asChild` stories; acceptable for discrete-variant learning demos. 25- Re-verified after the fix via the same Playwright script: Primary / Disabled / With-class-override each render exactly one `<button data-button-root="true" class="suede-button ...">` at the root of the story canvas; As link renders as a single `<a data-button-root="true">` (polymorphic). All four `button button` / `a button` / `button a` nested counts: **0**. 26- Class-merge verification (the "With class override" story): the consumer's `class="u:fs-3"` was correctly appended to the default — final class string was `suede-button u:fs-1 u:fs-3` (consumer wins the cascade order for the same-specificity font-size utility). 27- Committed `c7c153b``feat(ui): scaffold SuedeButton (bits-ui + stylebase wrapper)`. Unstaged the pre-existing staged files (`.github/workflows/cleanup-decision-graphs.yml`, `.opencode/commands/serve-ui.md`) so the commit diff is exactly the two new files; the user can commit those separately. 28- Committed `6299973``fix(storybook): use asChild to prevent button-in-button nesting`. 29- **Follow-up unblock: removed `src/stories/Button.stories.svelte` and `src/stories/Accordion.stories.svelte`** (committed `bbdd827`). The user's working tree already had `src/lib/components/ui/{Button,Accordion,index}.{svelte,spec.ts}` deleted locally, which made the two stories' `$lib/components/ui/{Button,Accordion}.svelte` imports unresolvable. Vite's import-analysis plugin then failed at build time on those files, blocking Storybook entirely. The same `asChild` issue from the previous fix would have hit these stories the moment the broken imports were repaired (their `component: Button` + children-in-Story pattern had the same button-in-button structure). `src/stories/Page.stories.svelte` and `src/stories/Header.stories.svelte` were kept — their imports are local (`./Page.svelte`, `./Header.svelte`) and still resolve. **Verified via Playwright across all 6 surviving stories (4 SuedeButton + 2 examples): 0 console errors, 0 vite import-analysis errors, 0 nested buttons.** `pnpm check` is now **fully clean** (0 errors, 0 warnings project-wide — first time in the history of this branch). 30- Committed `b718ad0``fix(ui): import stylebase globally; wrap SuedeButton styles in :global()`. Two-part fix for the user's "global stylebase is not being imported" report. 31 - **Part 1: `.storybook/preview.ts`** — added `import '../src/app.css'`. The SvelteKit app loaded stylebase via `+layout.svelte``app.css`, but Storybook's preview iframe is a separate document; that import chain didn't reach it. Importing `app.css` in `preview.ts` brings stylebase (and the `:focus-visible` rule) into both surfaces. **Caught the bug by reading `--hue-blue-500` at `:root` in the iframe before the fix — empty string.** 32 - **Part 2: `src/lib/components/ui/SuedeButton.svelte`** — wrapped every `.suede-button` selector in `:global(...)`. The class is set as a prop on the child `<Button.Root>` (not as a class on SuedeButton's own template element). Svelte 5's per-component CSS scoping only applies to elements rendered by that component, so the rule was being **pruned from the bundle entirely** (0 selectors named `suede-button` in any stylesheet, even with the `<!-- svelte-ignore css_unused_selector -->` comment — svelte-ignore suppresses the warning but doesn't prevent the build-time strip). `:global()` escapes the scoping. Class name is unique enough that global leakage is safe. 33 - **Verified via Playwright** on all 4 SuedeButton stories with computed-style + stylesheet introspection: 34 - **Primary**: bg=oklch(.7188 .15 240) [stylebase blue], white text, 6.75/17.67px padding [stylebase scale], 6px radius, system-ui font [var(--ff-ui)], 600 weight, inline-flex, pointer cursor. 35 - **Disabled**: same blue, but `cursor: not-allowed` — the `:disabled`/`:[aria-disabled]` rule fired. 36 - **As link**: renders as `<a>` (polymorphic), all the same styles. 37 - **With class override**: bg=oklch(.7109 .15 140) [emerald — different hue, the inline `style=` override worked], font-size=20.1px (the `u:fs-3` override beat `u:fs-1`), class string = `suede-button u:fs-1 u:fs-3` (merge order correct). 38- Created deciduous outcome node 70 with `--commit HEAD`; linked to action nodes 68 (wrapper), 69 (story), 71 (asChild fix), 72 + outcome 73 (story-file cleanup), action 74 + outcome 75 (stylebase + :global fix), all linked to goal 67. 39 40## Files touched 41- `src/lib/components/ui/SuedeButton.svelte` — new (52 lines) 42- `src/stories/SuedeButton.stories.svelte` — new (36 lines) 43 44## Verification 45- `pnpm check`**0 errors / 0 warnings project-wide**. 46- `pnpm prettier --check` on new files — pass 47- `pnpm eslint` on new files — pass 48- `pnpm storybook --port 6006` + Playwright DOM inspection — all 4 SuedeButton stories render a single root-level button (or anchor, for "As link"); no nested-button-in-button or anchor-in-button anywhere in the story canvas. Both remaining example stories (`example-header`, `example-page`) compile cleanly. Zero console errors across all 6 URLs. 49- `pnpm storybook --port 6006` + Playwright computed-style + stylesheet introspection — `--hue-blue-500` resolves to `oklch(71.88% 0.15 240)` at `:root` in the preview iframe; the four `.suede-button` rules are present in the bundle; the Primary button computes to the expected blue/white/6px-radius/system-ui styling; Disabled correctly shows `cursor: not-allowed`; As link renders as `<a>`; With-class-override shows the consumer's emerald background and `u:fs-3` font-size override. 50- `git log -4``c7c153b feat(ui): scaffold SuedeButton (bits-ui + stylebase wrapper)` + `6299973 fix(storybook): use asChild to prevent button-in-button nesting` + `bbdd827 fix(storybook): remove broken Button/Accordion story files` + `b718ad0 fix(ui): import stylebase globally; wrap SuedeButton styles in :global()` on `feat/install-bits-ui-stylebase` 51 52## Follow-ups / stubs 53- **Pre-existing deleted files in working tree (not committed)**: `src/lib/components/ui/{Accordion,Button,index}.{svelte,spec.ts}` and the matching `spec.ts` files are deleted locally but uncommitted. The user can `git rm` them in a separate commit when ready; they were not staged in the current cleanup commit because the user had not asked for that scope. The story-file removal (`bbdd827`) is the only cleanup committed here. 54- **Pre-existing prettier drift** in 30+ files (AGENTS.md, opencode.json, .opencode/*, etc.). Not in scope for this change; consider a separate `chore(format): run prettier` commit. 55- **CSS scope caveat**: the `<!-- svelte-ignore css_unused_selector -->` comment is currently scoped to the whole `<style>` block. If a future user adds a new rule with a class name that IS used statically in the template, the ignore is over-broad. Consider moving to per-rule ignore once more selectors exist. 56- **The `{...rest as Record<string, unknown>}` cast** is a known escape hatch we discussed. If bits-ui ever exports a `WithoutButtonRest` helper, swap the cast for that. The cast preserves runtime behavior — every HTML attribute the consumer passes flows through to `Button.Root` correctly; the union is what blocks TypeScript, not what the runtime does. 57- **`ref` is not forwarded** in this version of the wrapper. If a future consumer needs the underlying DOM element, add `let { ref = $bindable(null), ... }: Props = $props()` and `bind:this={ref}` on `<Button.Root>`. Deferred — keeping the scaffold minimal. 58- **The `u:fs-1` utility is the only stylebase utility class used in the component** — the rest of the visual styling is in scoped CSS. If a future "Hybrid: global data-attribute CSS + per-component overrides" option (see deciduous node 64, the deferred revisit from the 341d364 commit) gets picked up, the mix here is the proof of concept. 59- **Story variants are intentionally minimal** — no `play` interaction tests, no `decorators` for surrounding layout. Add those when the user wants to exercise click handlers / hover states / responsive behavior. 60- **`asChild` is a real storybook pattern, not a workaround for a bug in our code** — it's the addon's intended mode for static stories. Worth knowing for future stories: any time the story's children IS the component instance (rather than the children being a separate prop-driven template), `asChild` is the right choice. If a future story needs args-driven controls to update the canvas live, switch to a `template` snippet instead. 61- **Two non-obvious traps for any future Suede wrapper component**: (1) Storybook's preview iframe doesn't share the SvelteKit app's `+layout.svelte` import chain, so any global stylesheet must be re-imported in `.storybook/preview.ts`. (2) Svelte 5's CSS scoping is strictly per-component — a `.foo` rule only applies if the class is on an element rendered by THIS component's template, not on a class prop passed to a child component. If a future wrapper passes its styling class to a child primitive (the common pattern with bits-ui), the rules must be wrapped in `:global(...)`. The `<!-- svelte-ignore css_unused_selector -->` comment does **not** prevent the build-time strip; it only suppresses the warning.