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
18 kB

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 (not Button.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'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.
  • 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.
  • 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'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.
  • 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).
  • 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 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).
  • Wrote src/stories/SuedeButton.stories.sveltedefineMeta 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).
  • 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.
  • pnpm prettier --write on the two new files — reformatting accepted.
  • pnpm eslint on the two new files — no errors. Whole-project pnpm lint flags 35 unrelated files with pre-existing prettier drift; not touched.
  • Browser verification of the rendered DOMpnpm 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.
  • 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.
  • 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.
  • 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).
  • Committed c7c153bfeat(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 6299973fix(storybook): use asChild to prevent button-in-button nesting.
  • 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).
  • Committed b718ad0fix(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 — added import '../src/app.css'. The SvelteKit app loaded stylebase via +layout.svelteapp.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.
    • 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.
    • 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 (the u:fs-3 override beat u:fs-1), class string = suede-button u:fs-1 u:fs-3 (merge order correct).
  • Committed 27f3a6cfeat(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 with text-underline-offset: 0.2em, plus a matching :hover that darkens the color. The existing button-variant :hover/:active rules were re-scoped to :not([href]) so they don't repaint the link (their background-color: var(--hue-blue-600) had higher specificity and would have overridden the transparent background on hover without the :not([href]) qualifier). User also renamed klassclassName (React convention) in the destructure, and updated the "With class override" story to pass class="u:fs-1" (was u: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 in 03c6031; main's copy added a ## Before starting section + 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 check0 errors / 0 warnings project-wide.
  • pnpm prettier --check on new files — pass
  • pnpm eslint on new files — pass
  • 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.
  • 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.
  • git log -5c7c153b 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 button on feat/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 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.
  • 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.
  • 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 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.
  • 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.
  • 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.
  • 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.
  • 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.
  • 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.