Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .oxfmtignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,9 @@
# means the formatter and the generator fight, and the gate goes red on every
# release commit (which carries [skip ci], so it surfaces on the next PR instead).
**/CHANGELOG.md

# The golden palette fixture is a byte-for-byte snapshot of `buildPaletteCss()`
# output (tests/palette-css.test.ts). Formatting it rewrites the very bytes it
# exists to pin — the test would then fail against the emitter it just proved
# correct. Regenerated by tests/fixtures/regen.ts, never by hand or by oxfmt.
packages/basalt-ui/tests/fixtures/palette-default.css
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ bunx basalt-ui init
**Full docs, subpath export table, token system, and adapter batteries:** see the
[package README](./packages/basalt-ui/README.md).

Not a React app? The token system works without one — no React, no Mantine, no bundler:

```bash
bunx basalt-ui tokens:css --selector-attribute data-theme --only core --out src/tokens.css
```

See [Framework-free token consumption](./docs/FRAMEWORK-FREE.md).

## Repository Structure

```
Expand Down
162 changes: 162 additions & 0 deletions docs/FRAMEWORK-FREE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
# Framework-free token consumption

basalt-ui's token system does not need basalt-ui. No React, no Mantine, no
bundler — a static site can carry the same 197 `--vx-*` variables the framework's
own components read, and stay in sync with them.

This page is for that consumer: an Astro site, a Hugo theme, a plain
`index.html`, a design system in another stack that wants basalt's palette
underneath it.

## Three routes in

| Route | What it costs | Use when |
|-|-|-|
| `bunx basalt-ui tokens:css --out src/tokens.css` | nothing — no dependency at all | You want a file you own and commit. The default for a static site. |
| `import 'basalt-ui/tokens.css'` | one dependency, no peers | You already have a bundler and want the tokens to move with the version. |
| `import { buildPaletteCss } from 'basalt-ui/tokens'` | one dependency, no peers | You need consumer series, or you emit CSS at build time yourself. |

Every peer is optional, so `bun add basalt-ui` with no React installed brings the
package and its own dependencies (the nine `@visx/*`, `motion`, `remend`, three
font packages) — not the ~79 that come with the full framework.
`basalt-ui/tokens`, `basalt-ui/charts`, `basalt-ui/state` and
`basalt-ui/guard` resolve and run with no `@mantine/*` anywhere in the graph;
that is CI-enforced, not aspirational (`scripts/check-dist-layering.mjs` walks the
built graph, `scripts/pack-test.sh` installs the tarball into a scratch dir with
no Mantine and renders from it).

`basalt-ui/styles.css` is a different thing and you almost certainly do not want
it — it is the framework's base layer and assumes Mantine's own layered bundle
underneath.

## Retargeting the color-scheme selector

The default output keys per-scheme blocks off Mantine's toggle:

```css
:root { /* theme-independent scalars */ }
:root,
html[data-mantine-color-scheme='dark'] { /* dark primitives */ }
html[data-mantine-color-scheme='light'] { /* light primitives */ }
```

If your site toggles `data-theme` on `<html>`, or has no toggle at all and wants
the OS preference, pass options — from the CLI or the API, same emitter:

```bash
bunx basalt-ui tokens:css \
--selector-attribute data-theme \
--default-scheme light \
--media-fallback \
--only core \
--out src/styles/tokens.css
```

```ts
import { buildPaletteCss } from 'basalt-ui/tokens'

buildPaletteCss({
scheme: { attribute: 'data-theme' }, // also: darkValue, lightValue
defaultScheme: 'light', // which scheme rides the bare :root; or 'none'
mediaFallback: true, // @media (prefers-color-scheme) for the others
only: 'core', // see below
})
```

Passing any of the three switches the emitted selector from `html[…]` to
`:root[…]`. **This is the detail that quietly breaks dark mode if you write the
CSS yourself**, so it is worth the specificity arithmetic:

| Selector | Specificity | Against a light-default site's own `:root` block |
|-|-|-|
| `:root[data-theme='dark']` | 0-2-0 | wins |
| `html[data-theme='dark']` | 0-1-1 | wins |
| `[data-theme='dark']` | 0-1-0 | **tie — source order decides** |

The bare attribute selector is the natural thing to reach for and it is a trap.
It ties with `:root`, so whichever block your bundler emits last wins, and dark
mode does nothing on a site whose light `:root` happens to come after. `:root[…]`
sits above both forms and assumes nothing about which element carries the
attribute.

The `mediaFallback` block is a bare `:root` inside `@media`, so an explicit
attribute (0-2-0) outranks it on specificity rather than on order: the OS
preference is a fallback, never an override.

## `only: 'core'` — drop the component spacing

104 of the 197 variables are `--vx-space-*`, and 95 of those are named for a
basalt React component: `--vx-space-agent-transcript-inset`,
`--vx-space-toc-sub-indent`, `--vx-space-sidebar-child-row-indent`. Outside this
framework they are dead weight.

`--only core` keeps the 9 generic anchors — the `stack-xs`…`stack-xl` rhythm,
`control-height`, `input-height`, `row-inset-x`, `row-inset-y` — and takes the
emitted set from 197 variables to 102. It is a spacing filter only: color,
radius, shadow, type and status are identical in both modes.

## Opacity is `color-mix`, never `rgba()`

```css
/* wrong — freezes one scheme's hex */
border-color: rgba(228, 228, 231, 0.65);

/* right — the underlying token still resolves per scheme */
border-color: color-mix(in srgb, var(--vx-neutral) 65%, transparent);
```

Every `--vx-*` color is a variable that changes value across schemes. Writing
`rgba()` means reading one scheme's hex, baking it in, and losing the other. From
JS the `alpha(token, a)` helper does exactly the `color-mix` above:

```ts
import { alpha, VX } from 'basalt-ui/tokens'

alpha(VX.neutral, 0.65) // 'color-mix(in srgb, var(--vx-neutral) 65%, transparent)'
```

## Three line tokens, three roles

The most reliable way to get basalt's surfaces wrong is to map a single
`--hairline` variable onto the token whose name matches:

| Token | Role |
|-|-|
| `--vx-surface-border` | Structural border between regions — sidebar edge, header rule, a floating surface's real 1px edge. |
| `--vx-divider` | Soft rule *inside* a surface — rows in a list, sections in a card. |
| `--vx-surface-hairline` | The card ring baked into `--vx-shadow-card`. Never reference it directly. |

A single-hairline consumer maps it to **`--vx-divider`**. Reaching for
`--vx-surface-hairline` on the strength of the name gives you a line tuned to sit
*inside* a shadow: `#eaeaee` on the light page, which all but vanishes against
`#f2f2f5`. If you also apply `--vx-shadow-card`, you get that ring twice.

## Elevation is a shadow with the ring inside it

Depth in basalt is one whisper shadow that carries its own 1px ring — never a
`border` property alongside a `box-shadow`:

```css
--vx-shadow-card:
/* light */ 0 1px 2px rgba(28, 25, 23, 0.05), 0 0 0 1px var(--vx-surface-hairline);
/* dark */ 0 1px 3px rgba(0, 0, 0, 0.4), inset 0 0 0 1px color-mix(in srgb, #ffffff 4%, transparent);
```

Light draws the ring outset in a hairline gray; dark flips it to `inset` and
draws it in 4% white. That is why the two schemes cannot share one expression —
on a dark page an outset gray ring reads as a seam, and the lift has to come from
a hint of light along the top edge instead. Use `var(--vx-shadow-card)` and let
the variable resolve; the floating tier (`--vx-shadow-overlay`) deliberately
carries no ring, because a popover needs a real `--vx-surface-border` for its
arrow to inherit an edge.

## What you don't get

- **Accent retuning.** `deriveRadius` and `deriveSpacing` are public, but the
color derivation runs through `createBasaltTheme`, which is React and Mantine.
A framework-free consumer takes the shipped palette as given. Tracked in
`docs/STATUS.md`.
- **Prose styling.** `basalt-ui/content` is CSS-modules-scoped and reachable only
through its React components. There is no plain-class `content.css` yet.
- **Component behavior.** Tokens are values. The shell, charts, forms and the
agent layer are React.
69 changes: 50 additions & 19 deletions docs/STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,18 @@
> **Single source of truth for current state.** As of **2026-07-27**. The other docs in `docs/`
> are historical process artifacts or superseded scope ledgers — this file is what's true now.

**Branch:** `master` is the released 1.x line; `feat/density-tokens` (PR #23) carries the density
dimension and the guard wave below it.
**Version:** `1.1.1` on `master`, **published** to npm (tags through `v1.1.1`, Trusted Publisher
**Branch:** `master` is the released 1.x line; `feat/framework-free-tokens` carries the
framework-free token work below.
**Version:** `1.2.0` on `master`, **published** to npm (tags through `v1.2.0`, Trusted Publisher
OIDC).

## TL;DR

The 1.0 Mantine pivot shipped and the 1.x line is live on npm. Current work is the theme-config
surface: `createBasaltTheme`'s four dimensions (`derive`, `fonts`, `radius`, `density`) — the first
three released, `density` in review on PR #23. The June-era roadmap/handover docs still phrase built
work as "remaining"; that language is historical, see the banner on each.
The 1.0 Mantine pivot shipped and the 1.x line is live on npm. The theme-config surface is closed:
all four of `createBasaltTheme`'s dimensions (`derive`, `fonts`, `radius`, `density`) are released as
of 1.2.0. Current work is framework-free token consumption — making the `--vx-*` system usable from a
static site with no React, no Mantine and no bundler. The June-era roadmap/handover docs still phrase
built work as "remaining"; that language is historical, see the banner on each.

## Built (verified as-built, 2026-07-07)

Expand Down Expand Up @@ -266,22 +267,51 @@ preview`) — run the playground through its dev server. Two things previously d
density })` one, not merely the dev slider (see `deriveSpacing`'s JSDoc, `tokens/palette.ts`, for
the full accounting of what tracks density end to end and what doesn't).

## Open — PR #23 (`feat/density-tokens`)
## Open — framework-free token consumption (`feat/framework-free-tokens`)

The 1.0 ship sequence is closed: the pivot merged, npm Trusted Publisher (OIDC) is configured, and
`v1.0.2`/`v1.1.0`/`v1.1.1` published. What's open is one PR:
Driven by jkrumm.com, which evaluated 1.2.0 and hand-ported the hexes rather than installing the
package. The capability was there — `buildPaletteCss` already ran framework-free under Node and Bun;
the blockers were packaging and ergonomics. Five additive changes, every one defaulting to today's
exact output:

1. **PR #23** — the density dimension, the theme-lab prune, and the guard wave above. Mergeable, no
conflicts.
2. **`/code-review ultra`** before merge (billed).
3. **Merge**, then trigger the release workflow (semantic-release-monorepo, npm provenance).
`release.yml` is `workflow_dispatch`-only — merging to `master` does NOT auto-release.
1. **Golden fixture** — `buildPaletteCss()` pinned byte-for-byte (9718 bytes, 248 lines, 197
variables). The regression gate the rest land against.
2. **Selector options** — `scheme` / `defaultScheme` / `mediaFallback` on `BuildPaletteOpts`. Any of
them moves the emitted per-scheme selector from `html[…]` (0-1-1) to `:root[…]` (0-2-0); the
no-options path stays on the legacy literal.
3. **Optional peers** — the five remaining required peers (`react`, `react-dom`, `@mantine/core`,
`@mantine/hooks`, `@tanstack/react-query`) are now `optional`, so a tokens-only install carries
no React. They stay in `peerDependencies`, which is what preserves the version-mismatch warning.
4. **`dist/tokens.css` + `basalt-ui tokens:css`** — the prebuilt stylesheet as a published subpath,
plus a CLI that re-emits it with the options above. `bunx` it once and carry no dependency at all.
5. **`only: 'core'`** — drops the 95 component-named `--vx-space-*` one-offs, 197 variables → 102.
Partition derived from the `SPACE` key set, not a maintained list.

Plus two `styles.css` reach fixes (the unlayered `!important` print rule matched a consumer's own
landmarks; the heading `font-stretch` is now a `--basalt-font-head-stretch` knob) and
`docs/FRAMEWORK-FREE.md`.

**Follow-ups this work deliberately did NOT fold in:**

- **Expose `buildPaletteData` / `PaletteData`.** `deriveRadius` and `deriveSpacing` are public, but
the color derivation runs through `createBasaltTheme` (React + Mantine), so a framework-free
consumer can retune radius and density and **cannot retune the accent**. Real gap.
- **A plain-class `dist/content.css`.** The prose language is CSS-modules-scoped and reachable only
through React components. Larger design question.
- **Reconcile the accent drift.** `docs/DESIGN-SPEC.md` states `#0077bd` / `#8ec5ff`; the emitter
produces `#4374a6` / `#a2c3f0`, because chroma is scaled by `max(seedChroma, 40) × 0.72` at
vibrancy 0. `theme/contrast.test.ts` pins the drifted values, so this is known rather than
accidental — but a consumer reading the spec gets a different palette than one calling the
emitter. Decide which is authoritative. This is the one item here that would visibly move existing
consumers' pixels, which is why it stays separate.
- **`--basalt-font-head-stretch` as a `createBasaltTheme({ fonts })` option.** The knob exists in
CSS; reaching it from the theme config would make it a real dimension like the rest.

## Validation

Last verified green **2026-07-27** on `feat/density-tokens` — `bun test`: 905 pass / 49 files, and
`bun run pre` (fmt/lint/typecheck/check-theme). The pack-test's export-surface snapshot was updated
in the same pass for `./tokens`'s three new exports (`deriveSpacing`, `buildDensityCss`, `pxRem`).
Last verified green **2026-07-27** on `feat/framework-free-tokens` — `bun test`: 968 pass / 54
files, `bun run pre` (fmt/lint/typecheck/check-theme), and the full pack-test (`./tokens.css`
resolves from a scratch install; tarball parity now asserts every file-valued export ships).
**A final re-verification (`bun run pre` + `bun test` + pack-test) runs before ship** if further
commits land.

Expand Down Expand Up @@ -319,7 +349,8 @@ packaging, the charts/tokens API, the shell, or the batteries above.

- **Living reference** (current, maintained alongside the code) — **`STATUS.md`** (this file,
single source of truth), `DESIGN-SPEC.md` (2026-07 visual identity, supersedes older doctrine —
see its "Doctrine inversions" section), `DESIGN-CORE.md`, `MANTINE-THEMING.md`.
see its "Doctrine inversions" section), `DESIGN-CORE.md`, `MANTINE-THEMING.md`,
`FRAMEWORK-FREE.md` (consuming the token system with no React/Mantine/bundler).
- **`docs/archive/`** — superseded scope ledgers and historical process artifacts, kept for
provenance only:
- Executed ledger — `MATURATION-REVIEW.md` (the maturation quality ledger; its phases are
Expand Down
Loading
Loading