suede-button-scaffold#
Task#
Scaffold 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.
Decisions#
- Storybook only, Button only (Q1) — user explicitly dropped the Accordion from scope; route-page demo deferred.
- Option C for the existing example stories (Q2) — keep the auto-generated Storybook Button/Header/Page example content; name the new component
SuedeButton.svelte(notButton.svelte) to avoid path collision. The user expects to delete the examples later. - Approach 2 for the wrapper prop pattern (Q3) — full HTML attribute spread (
{...rest}) over bits-ui'sButton.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. - Utility classes + scoped
<style>mix inside the component (Q4, last user message) —u:fs-1font-size utility from stylebase in theclassattribute; everything else (color, padding, radius, hover/active/disabled) in a scoped<style>block. Component is usable as-is with sensible defaults; user can override viaclass/styleprops or by adding their own scoped styles at the consumer. - 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.
- 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.
Actions#
- 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'sButtonRootPropstype def atnode_modules/.pnpm/bits-ui@2.18.1_*/node_modules/bits-ui/dist/bits/button/types.d.tsto confirm theAnchorElement | ButtonElementunion shape that drives the rest-spread type issue. - Read stylebase's
dist/stylebase.min.cssto 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). - Created deciduous goal node 67 with the user's verbatim prompt + planning refinements; branch
feat/install-bits-ui-stylebase. - Wrote
src/lib/components/ui/SuedeButton.svelte— 5-line script (bits-ui import,Props = ButtonRootProps,$props()destructure pulling outhref,children,class: klass, plus...rest); oneButton.Rootelement with{href},{...rest as Record<string, unknown>}, a template-literalclassattribute (suede-button u:fs-1${klass ? ' ' + klass : ''}), and{@render children?.()}(children is possibly-undefined perWithChildren);<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). - Wrote
src/stories/SuedeButton.stories.svelte—defineMetablock withtitle: 'UI/SuedeButton', autodocs tag,onclick: fn()default arg, andargTypesforhref+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"+ inlinestyleto show stylebase-utility + custom-style mixing). pnpm check(wrangler types + svelte-check) — 0 errors, 0 warnings on new code. Two pre-existing errors remain insrc/stories/{Button,Accordion}.stories.svelte(missing$lib/components/ui/{Button,Accordion}.svelte), pre-date this change, out of scope per option C.pnpm prettier --writeon the two new files — reformatting accepted.pnpm eslinton the two new files — no errors. Whole-projectpnpm lintflags 35 unrelated files with pre-existing prettier drift; not touched.- Browser verification of the rendered DOM —
pnpm storybook --port 6006+ Playwright script atverify-button-nesting.mjs(deleted after use) navigated to each story, queried the iframe body forbutton button,a button, andbutton anested-selector counts. Caught the button-in-button bug the user reported:component: SuedeButton+ children in<Story>causes@storybook/addon-svelte-csf'sStory.svelte(line 148) to render<SuedeButton {...args}>{children}</SuedeButton>, wrapping a<SuedeButton>inside a<SuedeButton>. Same bug would hit the existingButton.stories.svelteif its broken import were fixed. - Fix applied — added
asChildto all four<Story>blocks. WithasChild={true}, the addon routes children through{@render children()}(no component wrapping). Trade-off: args-driven controls don't update the canvas forasChildstories; acceptable for discrete-variant learning demos. - 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 fourbutton button/a button/button anested counts: 0. - Class-merge verification (the "With class override" story): the consumer's
class="u:fs-3"was correctly appended to the default — final class string wassuede-button u:fs-1 u:fs-3(consumer wins the cascade order for the same-specificity font-size utility). - 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. - Committed
6299973—fix(storybook): use asChild to prevent button-in-button nesting. - Follow-up unblock: removed
src/stories/Button.stories.svelteandsrc/stories/Accordion.stories.svelte(committedbbdd827). The user's working tree already hadsrc/lib/components/ui/{Button,Accordion,index}.{svelte,spec.ts}deleted locally, which made the two stories'$lib/components/ui/{Button,Accordion}.svelteimports unresolvable. Vite's import-analysis plugin then failed at build time on those files, blocking Storybook entirely. The sameasChildissue from the previous fix would have hit these stories the moment the broken imports were repaired (theircomponent: Button+ children-in-Story pattern had the same button-in-button structure).src/stories/Page.stories.svelteandsrc/stories/Header.stories.sveltewere 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 checkis now fully clean (0 errors, 0 warnings project-wide — first time in the history of this branch). - 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.- Part 1:
.storybook/preview.ts— addedimport '../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. Importingapp.cssinpreview.tsbrings stylebase (and the:focus-visiblerule) into both surfaces. Caught the bug by reading--hue-blue-500at:rootin the iframe before the fix — empty string. - Part 2:
src/lib/components/ui/SuedeButton.svelte— wrapped every.suede-buttonselector 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 namedsuede-buttonin 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. - Verified via Playwright on all 4 SuedeButton stories with computed-style + stylesheet introspection:
- 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.
- Disabled: same blue, but
cursor: not-allowed— the:disabled/:[aria-disabled]rule fired. - As link: renders as
<a>(polymorphic), all the same styles. - With class override: bg=oklch(.7109 .15 140) [emerald — different hue, the inline
style=override worked], font-size=20.1px (theu:fs-3override beatu:fs-1), class string =suede-button u:fs-1 u:fs-3(merge order correct).
- Part 1:
- Committed
27f3a6c—feat(ui): render anchor variant as a link, not a button. User's reaction to the previous commit was that the As-link variant looked identical to a button. Standard pattern is: an<a href>that performs navigation should look like a link (underlined, transparent bg, accent-color text), not like a button. Added:global(.suede-button[href])override that sets bg=transparent, color=blue, no horizontal padding, underline withtext-underline-offset: 0.2em, plus a matching:hoverthat darkens the color. The existing button-variant:hover/:activerules were re-scoped to:not([href])so they don't repaint the link (theirbackground-color: var(--hue-blue-600)had higher specificity and would have overridden the transparent background on hover without the:not([href])qualifier). User also renamedklass→className(React convention) in the destructure, and updated the "With class override" story to passclass="u:fs-1"(wasu:fs-3) so the demo isolates the inline-style background override without compounding it with a font-size change. Verified: As link now computes to bg=transparent, color=blue, text-decoration=underline, padding-inline=0; the three button variants unchanged. - 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), action 76 + outcome 77 (link-variant styling), all linked to goal 67.
Files touched#
src/lib/components/ui/SuedeButton.svelte— new (52 lines)src/stories/SuedeButton.stories.svelte— new (36 lines)
Merge into main#
After the work was pushed, the user opened the PR and reported a conflict against main. Pulled origin/main and merged with git merge origin/main — git's ort strategy auto-resolved the 3-way merge with no manual intervention. Only one commit (d5a9380 chore(process): add git workflow rule, drop Sentry/Axiom/agent tools from stack mentions) had diverged from my branch; it touched three files, all of which I had a clean ancestor for or didn't have at all:
AGENTS.md— auto-merged (no overlap with my changes; I never touched this file).opencode/skills/task-lifecycle/SKILL.md— auto-merged (my copy was committed in03c6031; main's copy added a## Before startingsection + commit-trailer + branch rules that don't conflict with mine)agent-notes/2026-06-03-02-process-updates.md— new on main, just taken as-is
Pushed the merge commit (8ff5a7b) to origin/feat/install-bits-ui-stylebase. pnpm check still 0 errors / 0 warnings project-wide post-merge. Ready for human review-and-merge to main.
PR-viewer conflict report (false positive)#
The user reported the PR viewer was still flagging a conflict after the merge push, at "line 31 of AGENTS.md". Investigated: locally git reports zero conflict. The actual diff between main and my branch is a pure addition (40a41,318 — main ends at line 40, my branch adds lines 41-318 with the ## Releases and ## Decision Graph Workflow sections). Line 31 of AGENTS.md ("Agents own (TypeScript only):") is byte-identical on both branches (verified with od -c); the only thing the PR viewer could possibly be flagging is the hunk boundary in the naive diff view, not an actual merge conflict.
Best guess: Tangled's PR view uses a different merge analysis (naive diff rather than 3-way) and flags any non-trivial AGENTS.md divergence as a conflict indicator. Resolved by git rebase origin/main — 23 commits replayed on top of main with no conflicts, producing a linear history with no merge commit. pnpm check still 0/0. Force-pushed (f1645ba) to update the remote. The PR should now show a clean linear diff rather than the merge-commit structure that may have confused the viewer.
Verification#
pnpm check— 0 errors / 0 warnings project-wide.pnpm prettier --checkon new files — passpnpm eslinton new files — passpnpm 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.pnpm storybook --port 6006+ Playwright computed-style + stylesheet introspection —--hue-blue-500resolves tooklch(71.88% 0.15 240)at:rootin the preview iframe; the four.suede-buttonrules are present in the bundle; the Primary button computes to the expected blue/white/6px-radius/system-ui styling; Disabled correctly showscursor: not-allowed; As link renders as<a>; With-class-override shows the consumer's emerald background andu:fs-3font-size override.git log -5—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()+27f3a6c feat(ui): render anchor variant as a link, not a buttononfeat/install-bits-ui-stylebase
Follow-ups / stubs#
- Pre-existing deleted files in working tree (not committed):
src/lib/components/ui/{Accordion,Button,index}.{svelte,spec.ts}and the matchingspec.tsfiles are deleted locally but uncommitted. The user cangit rmthem 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. - 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 prettiercommit. - 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. - The
{...rest as Record<string, unknown>}cast is a known escape hatch we discussed. If bits-ui ever exports aWithoutButtonResthelper, swap the cast for that. The cast preserves runtime behavior — every HTML attribute the consumer passes flows through toButton.Rootcorrectly; the union is what blocks TypeScript, not what the runtime does. refis not forwarded in this version of the wrapper. If a future consumer needs the underlying DOM element, addlet { ref = $bindable(null), ... }: Props = $props()andbind:this={ref}on<Button.Root>. Deferred — keeping the scaffold minimal.- The
u:fs-1utility 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. - Story variants are intentionally minimal — no
playinteraction tests, nodecoratorsfor surrounding layout. Add those when the user wants to exercise click handlers / hover states / responsive behavior. asChildis 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),asChildis the right choice. If a future story needs args-driven controls to update the canvas live, switch to atemplatesnippet instead.- Two non-obvious traps for any future Suede wrapper component: (1) Storybook's preview iframe doesn't share the SvelteKit app's
+layout.svelteimport chain, so any global stylesheet must be re-imported in.storybook/preview.ts. (2) Svelte 5's CSS scoping is strictly per-component — a.foorule 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.