feat: framework-free token consumption - #24
Conversation
The fixture is a byte-for-byte snapshot of buildPaletteCss() output. Formatting it rewrites the exact bytes it exists to pin, so the formatter and the emitter disagree and the test goes red against code it just proved correct. Same class as the CHANGELOG entry above it: a generated file is owned by its generator.
buildPaletteCss() is the framework's widest contract — the Mantine theme, every CSS module, every chart and any non-React consumer read the same emitted --vx-* set. Per-group unit tests each see one slice, so a refactor that shifts a declaration, a selector or a byte of whitespace passes them all and still changes behaviour everywhere downstream. Pin the whole string against a committed fixture instead: 9718 bytes, 248 lines, 197 unique variables. The fixture lives under tests/ rather than src/ because copy-assets.mjs mirrors every src CSS file into dist, which would publish it as a real stylesheet. Regenerate deliberately via tests/fixtures/regen.ts and review the diff. This is the regression gate the optional emission modes land against — each one must leave the no-argument output identical.
The emitted per-scheme blocks were hardcoded to Mantine's own toggle (html[data-mantine-color-scheme='…']), so a non-Mantine consumer — a static site on data-theme, say — could use the palette data but not the emitted stylesheet. Add three options that retarget it: `scheme` (attribute + values), `defaultScheme` (which scheme rides the bare :root, or 'none'), and `mediaFallback` (an @media (prefers-color-scheme) block for every non-default scheme). Setting any of them moves the selector to `:root[attr='value']`, not `html[…]`. That is load-bearing: a bare `[attr='dark']` is 0-1-0, the same as a light-default site's own `:root` block, so source order decides and dark mode silently loses. `:root[…]` is 0-2-0 — above both a bare `:root` and `html[…]` — and carries no assumption about which element the attribute lands on. The OS-fallback block is a bare `:root`, so an explicit attribute outranks it on specificity rather than order: the preference is a fallback, never an override. Setting none of them keeps the legacy `html[…]` literal, byte for byte — a consumer may already override --vx-* under that exact selector, and raising basalt's own specificity would silently win over them. Pinned by the golden fixture.
./tokens, ./charts, ./state and ./guard resolve and render with no React and no Mantine in the graph — enforced by the token-layer boundary rule, by the dist graph walk, and by the no-Mantine step in the pack test. But npm expresses peer optionality per package, never per subpath, so `bun add basalt-ui` still pulled ~79 packages on a consumer that only ever imports basalt-ui/tokens. Mark all five remaining required peers optional: react, react-dom, @mantine/core, @mantine/hooks, @tanstack/react-query. They stay listed in peerDependencies — `optional` suppresses only the MISSING-peer install, so a consumer running against an incompatible Mantine or React major still warns, and that signal exists only while the entry is declared. The cost is real and worth naming: a root-entry consumer missing one now learns from their bundler rather than from their package manager. required-peers.test.ts flips from pinning "react-query must not be optional" to pinning the invariant that actually matters — every peer optional, none dropped from peerDependencies.
A consumer with no bundler and no React had no way to consume the token system short of hand-porting the hexes — ./styles.css is the framework's base layer and assumes Mantine underneath it. Two additions, one emitter. dist/tokens.css is buildPaletteCss() with no options, generated at build time and published as basalt-ui/tokens.css: link it and read var(--vx-*) from hand-written CSS. `basalt-ui tokens:css` re-emits the same thing with the scheme selector retargeted, for a consumer who shouldn't have to install the package just to change an attribute name — bunx it once, commit the output, carry no dependency at all. The subcommand parses flags and calls buildPaletteCss. It holds no emission logic and no post-processing, so a `bunx tokens:css` consumer and a `basalt-ui/tokens` consumer can never be looking at two different design systems; the artifact is byte-identical to the golden fixture by construction. check-tarball-parity now also asserts every file-valued export ships. publint validates the map rather than the artifact, so ./styles.css, ./tokens.css and ./llms.txt could each have resolved to nothing at install time with every other gate green.
104 of the 197 emitted variables are --vx-space-*, and 95 of those are named for a specific basalt React component — agent-transcript-inset, toc-sub-indent, sidebar-child-row-indent. Nothing outside this framework reads them, so a framework-free consumer was carrying half the stylesheet for nothing. `only: 'core'` keeps the 9 generic anchors (stack rhythm, the two control heights, the row insets) and drops the rest, taking the set from 197 to 102. Default stays 'all' and the output is unchanged. It is a SPACING filter only — color, radius, shadow, type and status are identical in both modes. The partition is derived from the SPACE key set, not maintained as a drop list, so a new SPACE_STEP one-off is excluded the day it is added and a renamed anchor follows automatically. --vx-space-row-line-height is in neither set by design: it is the NavLink row's line-height, not a general anchor. Exposed as `basalt-ui tokens:css --only core`, which is the flag that makes the install-free recipe worth running.
Two rules in the shipped global stylesheet matched elements basalt does not own. The print rule hid bare nav/header/footer, unlayered and with !important. A consumer page with a real landmark and no BasaltShell printed it blank, and the combination of no layer and !important left them nothing to override it with. Scope it to AppShell's class hooks and move it inside @layer basalt, where layer order already beats @layer mantine without !important — restoring the "one legitimate !important in this framework" claim the iOS input floor makes, now pinned by a test. The heading rule matches bare h1-h6 by design (basalt owns the page's type) but hardcoded 88% font-stretch, so a consumer layering its own base type had to out-specify the framework to keep its own heading width. Read the value from --basalt-font-head-stretch instead; setting it to `normal` opts out in one declaration. Named --basalt-* rather than --vx-*: it is declared by styles.css alongside the font stacks, not emitted by the token layer, and a --vx- name that tokens.css does not carry would be a lie. Reaching it from createBasaltTheme({ fonts }) is the obvious follow-up, deliberately not folded in here.
jkrumm.com evaluated 1.2.0 and hand-ported the hexes rather than installing the package. The capability was already there; nothing told a non-React consumer it was, or how to avoid the traps. FRAMEWORK-FREE.md covers the three routes in (bunx the CLI, link tokens.css, import buildPaletteCss), the selector specificity arithmetic that decides whether dark mode works at all, `only: 'core'`, the color-mix opacity law, and the two things a consumer most reliably gets wrong: mapping a lone hairline variable onto --vx-surface-hairline (the card ring, invisible on its own) instead of --vx-divider, and treating elevation as a border when the ring lives inside the shadow value and flips to inset on dark. STATUS records the five changes and, explicitly, the four follow-ups they did not fold in — chief among them that a framework-free consumer can retune radius and density but not the accent, and the accent drift between DESIGN-SPEC and the emitter.
The install section still told consumers Bun would auto-install the peers. It won't any more — every peer is optional now, so all five are the consumer's job and a missing one shows up as an unresolved import at build time rather than a warning at install time. Say that plainly; it is the one place the optionality trade is visible to someone who did nothing wrong. Both the README and llms.txt now name the no-React path, which is the whole point of the change: an agent reading llms.txt or a developer skimming the install block should learn that basalt-ui/tokens costs no framework, without having to infer it from a layering table.
📦 basalt-ui package modifiedTrigger the Make Release workflow after merging to publish to npm. |
WalkthroughThis PR adds framework-free palette token consumption through configurable CSS generation, a ChangesFramework-free token consumption
Sequence Diagram(s)sequenceDiagram
participant Consumer
participant TokensCssCLI
participant BuildPaletteCss
participant TokensCssAsset
Consumer->>TokensCssCLI: request token CSS
TokensCssCLI->>BuildPaletteCss: pass selector and output options
BuildPaletteCss-->>TokensCssCLI: return generated CSS
TokensCssCLI->>TokensCssAsset: write or print CSS
TokensCssAsset-->>Consumer: provide token stylesheet
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (1)
packages/basalt-ui/src/styles.css (1)
27-31: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winUse one compliant namespace for the new public styling knob.
The stylesheet and its test introduce
--basalt-font-head-stretch, while the package guideline requires new styling variables to use--vx-*.
packages/basalt-ui/src/styles.css#L27-L31: rename the custom-property declaration.packages/basalt-ui/src/styles.css#L83-L93: update thevar(...)reference.packages/basalt-ui/tests/styles-css.test.ts#L51-L55: update the expected property name.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/basalt-ui/src/styles.css` around lines 27 - 31, Rename the public custom property declaration in packages/basalt-ui/src/styles.css lines 27-31 from the basalt-prefixed name to the required --vx-* namespace, then update its var(...) usage in packages/basalt-ui/src/styles.css lines 83-93 and the expected property name in packages/basalt-ui/tests/styles-css.test.ts lines 51-55. Keep the existing font-stretch behavior unchanged.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/basalt-ui/CLAUDE.md`:
- Around line 351-356: Update the `tokens:css` flag documentation to include the
supported `--only <core|all>` option alongside the existing flags, without
changing the command’s behavior or other documented details.
In `@packages/basalt-ui/package.json`:
- Around line 199-207: Restore the required Mantine peer contract: in
packages/basalt-ui/package.json lines 199-207, remove optional metadata from
`@mantine/core` and `@mantine/hooks`. Update docs/FRAMEWORK-FREE.md lines 19-26 to
remove the every-peer-optional and no-peer-install claims, revise docs/STATUS.md
lines 282-284 to mention only genuinely optional peers, and update
packages/basalt-ui/CLAUDE.md lines 112-122 to document the required Mantine
peers and missing-peer behavior.
In `@packages/basalt-ui/src/styles.css`:
- Around line 164-175: Keep the scoped AppShell print selectors in the `@media`
print block effective against unlayered Mantine imports by retaining a scoped
!important override or adding an equivalent unlayered print rule. In
packages/basalt-ui/tests/styles-css.test.ts at lines 46-48, remove the assertion
that the print block contains no !important; at lines 58-63, replace the global
!important count check with an assertion scoped to the AppShell print selectors.
In `@packages/basalt-ui/tests/styles-css.test.ts`:
- Around line 40-44: Update the test around the layerStart assertion in the
`sits inside `@layer` basalt` case to verify that `@media print` is structurally
nested within the `@layer basalt` block, using brace-aware parsing or a scoped
pattern rather than only comparing source positions.
---
Nitpick comments:
In `@packages/basalt-ui/src/styles.css`:
- Around line 27-31: Rename the public custom property declaration in
packages/basalt-ui/src/styles.css lines 27-31 from the basalt-prefixed name to
the required --vx-* namespace, then update its var(...) usage in
packages/basalt-ui/src/styles.css lines 83-93 and the expected property name in
packages/basalt-ui/tests/styles-css.test.ts lines 51-55. Keep the existing
font-stretch behavior unchanged.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 53400df9-a8b1-4c4a-9521-e45e514e9175
📒 Files selected for processing (23)
.oxfmtignoreREADME.mddocs/FRAMEWORK-FREE.mddocs/STATUS.mdpackages/basalt-ui/CLAUDE.mdpackages/basalt-ui/README.mdpackages/basalt-ui/llms.txtpackages/basalt-ui/package.jsonpackages/basalt-ui/scripts/check-tarball-parity.mjspackages/basalt-ui/scripts/export-surface.mjspackages/basalt-ui/scripts/gen-llms.tspackages/basalt-ui/scripts/gen-tokens-css.mjspackages/basalt-ui/scripts/pack-test.shpackages/basalt-ui/src/cli/index.tspackages/basalt-ui/src/cli/tokens-css.test.tspackages/basalt-ui/src/styles.csspackages/basalt-ui/src/surfaces.tspackages/basalt-ui/src/tokens/index.tspackages/basalt-ui/tests/fixtures/palette-default.csspackages/basalt-ui/tests/fixtures/regen.tspackages/basalt-ui/tests/palette-css.test.tspackages/basalt-ui/tests/required-peers.test.tspackages/basalt-ui/tests/styles-css.test.ts
| - `tokens:css` — emit the `--vx-*` stylesheet (stdout, or `--out <path>`). Flags: | ||
| `--selector-attribute` / `--dark-value` / `--light-value` / `--default-scheme <dark|light|none>` / | ||
| `--media-fallback`. No flags → byte-identical to the shipped `basalt-ui/tokens.css`. It is a flag | ||
| parser over `buildPaletteCss` and holds NO emission logic of its own — the CLI and the API must | ||
| not be able to disagree about what basalt's tokens are (`src/cli/tokens-css.test.ts`). This is the | ||
| command that removes the install entirely for a static, non-React consumer. |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Document --only <core|all>.
The CLI supports --only, but this public flag list omits it.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/basalt-ui/CLAUDE.md` around lines 351 - 356, Update the `tokens:css`
flag documentation to include the supported `--only <core|all>` option alongside
the existing flags, without changing the command’s behavior or other documented
details.
| "@mantine/core": { | ||
| "optional": true | ||
| }, | ||
| "@mantine/form": { | ||
| "optional": true | ||
| }, | ||
| "@mantine/hooks": { | ||
| "optional": true | ||
| }, |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
Restore the required Mantine peer contract. Marking @mantine/core and @mantine/hooks optional violates the package policy and makes the related framework-free documentation inaccurate. As per coding guidelines, both must remain non-optional peer dependencies.
packages/basalt-ui/package.json#L199-L207: remove optional metadata for@mantine/coreand@mantine/hooks.docs/FRAMEWORK-FREE.md#L19-L26: revise the “every peer is optional” and no-peer-install claims.docs/STATUS.md#L282-L284: describe only the peers that are actually optional.packages/basalt-ui/CLAUDE.md#L112-L122: update the peer dependency contract and missing-peer behavior documentation.
📍 Affects 4 files
packages/basalt-ui/package.json#L199-L207(this comment)docs/FRAMEWORK-FREE.md#L19-L26docs/STATUS.md#L282-L284packages/basalt-ui/CLAUDE.md#L112-L122
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/basalt-ui/package.json` around lines 199 - 207, Restore the required
Mantine peer contract: in packages/basalt-ui/package.json lines 199-207, remove
optional metadata from `@mantine/core` and `@mantine/hooks`. Update
docs/FRAMEWORK-FREE.md lines 19-26 to remove the every-peer-optional and
no-peer-install claims, revise docs/STATUS.md lines 282-284 to mention only
genuinely optional peers, and update packages/basalt-ui/CLAUDE.md lines 112-122
to document the required Mantine peers and missing-peer behavior.
Source: Coding guidelines
| /* Print: hide the BasaltShell chrome so only page content prints. | ||
| Scoped to AppShell's own class hooks, NOT to bare nav/header/footer. The element form matched | ||
| any landmark on the page — a consumer with a real <nav> and no BasaltShell silently printed a | ||
| blank one, and being unlayered with `!important` there was nothing they could do about it | ||
| either. `@layer basalt` already outranks `@layer mantine`, so no `!important` is needed to win | ||
| against the AppShell's own display rules. */ | ||
| @media print { | ||
| .mantine-AppShell-navbar, | ||
| .mantine-AppShell-header, | ||
| .mantine-AppShell-footer { | ||
| display: none; | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Keep the scoped print rule effective for unlayered Mantine imports.
Removing !important from the layered rule makes AppShell chrome printable when Mantine CSS is imported unlayered; the tests currently enforce that broken behavior.
packages/basalt-ui/src/styles.css#L164-L175: retain a scoped important override or provide an equivalent unlayered print rule.packages/basalt-ui/tests/styles-css.test.ts#L46-L48: stop requiring the print block to contain no!important.packages/basalt-ui/tests/styles-css.test.ts#L58-L63: replace the global count assertion with a selector-scoped assertion.
📍 Affects 2 files
packages/basalt-ui/src/styles.css#L164-L175(this comment)packages/basalt-ui/tests/styles-css.test.ts#L46-L48packages/basalt-ui/tests/styles-css.test.ts#L58-L63
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/basalt-ui/src/styles.css` around lines 164 - 175, Keep the scoped
AppShell print selectors in the `@media` print block effective against unlayered
Mantine imports by retaining a scoped !important override or adding an
equivalent unlayered print rule. In packages/basalt-ui/tests/styles-css.test.ts
at lines 46-48, remove the assertion that the print block contains no
!important; at lines 58-63, replace the global !important count check with an
assertion scoped to the AppShell print selectors.
| it('sits inside @layer basalt, so a consumer can override it', () => { | ||
| const layerStart = css.indexOf('@layer basalt {') | ||
| expect(layerStart).toBeGreaterThanOrEqual(0) | ||
| expect(css.indexOf('@media print')).toBeGreaterThan(layerStart) | ||
| }) |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Assert actual layer nesting, not merely source order.
css.indexOf('@media print') > layerStart also passes if the media block appears after @layer basalt has already closed. Use a brace-aware check or a scoped pattern that proves the print rule is contained by the @layer basalt block.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/basalt-ui/tests/styles-css.test.ts` around lines 40 - 44, Update the
test around the layerStart assertion in the `sits inside `@layer` basalt` case to
verify that `@media print` is structurally nested within the `@layer basalt`
block, using brace-aware parsing or a scoped pattern rather than only comparing
source positions.
Closes the packaging and ergonomics gap that made jkrumm.com hand-port basalt's hexes into its own
global.cssinstead of installing the package. The capability was already there —buildPaletteCssran framework-free under Node and Bun — but four things stood in the way: required React peers, a hardcoded Mantine selector, 95 component-named spacing variables, and twostyles.cssrules that reach past basalt's own chrome.Five additive changes, each defaulting to today's exact behaviour. No majors.
What changed
test:buildPaletteCss()pinned byte-for-byte against a committed fixture — 9718 bytes, 248 lines, 197 variables. The gate everything else lands against.feat:scheme/defaultScheme/mediaFallbackonBuildPaletteOpts. Any of them moves the per-scheme selector to:root[…]; passing none keeps the legacyhtml[…]literal.feat:optional.feat:dist/tokens.csspublished asbasalt-ui/tokens.css, plus abasalt-ui tokens:csssubcommand.feat:only: 'core'drops the 95 component-named spacing variables — 197 → 102.fix:styles.cssprint rule scoped to AppShell class hooks and moved into@layer basalt; headingfont-stretchreads a knob.docs:docs/FRAMEWORK-FREE.md, STATUS, README, llms.txt.The specificity detail
The custom-selector path emits
:root[attr='value'](0-2-0), nothtml[…](0-1-1) and definitely not a bare[attr='value'](0-1-0 — the same as a light-default site's own:rootblock, so source order decides and dark mode silently does nothing). ThemediaFallbackblock is a bare:rootinside@media, so an explicit attribute outranks it on specificity rather than order.The no-options path keeps
html[data-mantine-color-scheme='…']verbatim: a consumer may already override--vx-*under that exact selector, and raising basalt's own specificity would silently win over them.The one real trade
@tanstack/react-querywas pinned as non-optional bytests/required-peers.test.tson the grounds thatBasaltProviderhard-requires it. npm expresses peer optionality per package, never per subpath, so that guarantee and "a tokens-only install costs no React" cannot both hold. This PR picks the latter: a root-entry consumer missing a peer now learns from their bundler instead of their package manager. The test flips to pinning what still matters — every peer optional, none dropped frompeerDependencies, so version-mismatch warnings survive. Both READMEs say so explicitly.Follow-ups deliberately not folded in
Recorded in
docs/STATUS.md:buildPaletteData/PaletteData— a framework-free consumer can retune radius and density but not the accent.dist/content.css.DESIGN-SPEC.md(#0077bd) and the emitter (#4374a6). The only item here that would move existing consumers' pixels.--basalt-font-head-stretchas acreateBasaltTheme({ fonts })option.Verification
bun test— 968 pass / 54 filesbun run pre— fmt, lint, typecheck, check-theme greenbun run pack-test— full gate green, includingbasalt-ui/tokens.cssresolving from a scratch install, the no-Mantine charts/tokens render, and tarball parity (now also asserting every file-valued export ships)node bin/basalt-ui.mjs tokens:css --selector-attribute data-theme --default-scheme light --media-fallback --only core→ 201 lines, correct cascade order, under plain Node with nothing installedSummary by CodeRabbit
tokens.cssexport for using design tokens without React, Mantine, or a bundler.tokens:cssCLI command with options for output location, color-scheme selectors, fallbacks, and core token sets.