diff --git a/CHANGELOG.md b/CHANGELOG.md index 9af207e..4340eae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,36 @@ Earlier releases (v0.1.x) are documented in the ## [Unreleased] +### Changed — the generation skill now consumes the house style + +The `/tinkerdown` generation skill reads a project's house style and authors +against it, closing a declared-but-unconsumed gap: `generation.style_guide` (a +markdown file of house tone/layout/do-don't) was a config field nothing ever +read. The skill now reads it — plus the `styling` block (`theme` + +`styling.tokens`) — and carries the on-brand rule into generation: **write +semantic HTML, never hardcode colours** and let the design tokens skin it, follow +the project's `style-guide.md`. The token vocabulary is documented in the skill +`reference.md` (guarded against `KnownStyleTokens` so a new token can't go +untaught). The whole style block stays optional — with none, generation falls +back to on-brand defaults. The PII reference app now ships a `style-guide.md` + +`styling.tokens` demonstrating a governed, on-brand console. + +### Added — declarative design tokens (`styling.tokens`) + +A project can now set its on-brand palette declaratively, without writing raw +CSS. `styling.tokens` in `tinkerdown.yaml` maps snake_case design-token names to +values — e.g. `accent: "#5a67d8"`, `card_bg: "#ffffff"` — and each drives the +matching CSS custom property (`--accent`, `--card-bg`, …) in every page's +`:root`, overriding the built-in default via the same mechanism `primary_color` +already uses for `--accent`. Because the override skins the *semantic* HTML the +generator emits, a generated UI is on-brand by construction rather than by +careful prompting. An unknown token key fails loudly at config load (naming the +key and listing the known tokens) so a typo can't silently skin nothing; token +values are sanitized against CSS-injection at render. Absent, built-in defaults +apply — existing projects are unaffected. A token override is a single value that +applies to **both** light and dark themes (same as `primary_color`); per-theme +palettes are not yet expressible. + ### Added — runtime approved-surface enforcement When a project declares a `generation:` block, Tinkerdown now enforces the diff --git a/docs/plans/2026-07-09-ephemeral-ui-reframe.md b/docs/plans/2026-07-09-ephemeral-ui-reframe.md index 7f621c6..1ec091c 100644 --- a/docs/plans/2026-07-09-ephemeral-ui-reframe.md +++ b/docs/plans/2026-07-09-ephemeral-ui-reframe.md @@ -21,7 +21,7 @@ What this reframe ships, most-important first. **M1 is the headline** (makes the **M0 — foundation:** repositioned narrative (README/SKILL/llms.txt) + upstream version bump to latest (`livetemplate` v0.10→latest, client, components). -**M2–M5 — hardening:** `livetemplate.Validate()` API + policy lint *(M2, upstream + tinkerdown)* · runtime approved-surface enforcement *(M3, tinkerdown; field-name validation deferred — needs a schema source)* · richer `lvt/components` + enforceable design-token style guide *(M4)* · save/share gallery (stores captured skills) + external-embed handshake *(M5)*. +**M2–M5 — hardening:** `livetemplate.Validate()` API + policy lint *(M2, upstream + tinkerdown)* · runtime approved-surface enforcement *(M3, tinkerdown; field-name validation deferred — needs a schema source)* · house style on-brand by construction — formalized design tokens + wired `style_guide` *(M4, tinkerdown; no lint)* · save/share gallery (stores captured skills) + external-embed handshake *(M5)*. --- @@ -92,7 +92,7 @@ The brief names four things a team defines once so LLMs can safely generate agai **How the plan threads the UX style guide:** - **M1 (this milestone) — make house style LLM-consumable, coarse enforcement.** The manifest's style block = `theme` + `site_css` **plus a short `style-guide.md`** (house tone, layout conventions, which `lvt/components` to prefer, do/don't) that the `/tinkerdown` skill injects into its generation context so output conforms by construction. **The whole style block is optional** — with none, generation falls back to tinkerdown's sane defaults (PicoCSS semantic styling + default theme), so a team can adopt generation with zero style setup and add house style later. Enforcement in M1 is coarse (the shared `site_css` skins every generated page; the visual-conformance check in Phase 5). *This is a conscious M1 scope: consume the style guide, don't yet mechanically enforce tokens.* -- **M4 — elevate to an enforceable spec.** Alongside the `lvt/components` enrichment, promote house style to **design tokens + an enforced component set** (the real "style guide object" the exploration found missing), so generated UIs can't drift off-brand even without a careful prompt. +- **M4 — make house style on-brand by construction** *(re-scoped at kickoff 2026-07-24; see § M4 phases).* Formalize the design-token layer that already exists implicitly (single-source it, define the four undefined tokens, drive it from a `tokens` config) and **wire the declared-but-unconsumed `style_guide`** into generation, so generated UIs can't drift off-brand even without a careful prompt — delivered by **tokens, not an enforced-component lint** (that fights tinkerdown's semantic-HTML-first model; component enrichment deferred). --- @@ -108,7 +108,7 @@ The brief names four things a team defines once so LLMs can safely generate agai | 1 | Generated docs get only a *plain* parse error; the structured, line-numbered parse/reactive-AST diagnostics live in `livetemplate/internal/parse` (unreachable downstream) and the public render path *swallows* them. Allowed-tags, bound-refs and action-param are tinkerdown-owned semantics livetemplate can't see. | **Split** (kickoff 2026-07-23): `livetemplate.Validate(templateText)` exposes the structured parse/AST diagnostics + optional render-determinism [server]; allowlist / bound-refs / action-param stay in tinkerdown `validate` | Deterministic first-pass correctness | **M2** | | 2 | No **field-name validation**: a misspelled `{{.Field}}` renders empty. (Action-name/`describes:` metadata already exists in `config.Summarize`.) | **Deferred** (M3 kickoff 2026-07-24): needs a *validate-time schema source* tinkerdown lacks — schema lives only in the runtime DB (gitignored, seed-built). Real fix = parse `CREATE TABLE` from a seed, or a declared `columns:` schema — a new introspection capability, not a lint. | `validate` catches a bad field before serve | **Deferred** (§ Risks) | | 3 | Runtime enforcement is generation-time only; a webhook / bypassing caller can invoke outside the approved surface (three action paths, no shared gate) | **Tinkerdown** policy gate at all three paths (kickoff 2026-07-24: an upstream `WithActionPolicy` is dead code — tinkerdown never uses livetemplate dispatch) | Runtime safety (defense in depth) | **M3** | -| 4 | Primitive library too thin (no charts, badges, cards, stat tiles, alerts, empty-states) | Enrich `lvt/components` [components] | "LLMs rarely need custom HTML" | **M4** | +| 4 | House style can't be guaranteed on-brand: the design-token layer is duplicated/partial + `style_guide` is a declared-but-unconsumed config field. (The `lvt/components` library is deep — ~29 components — but tinkerdown uses 1; enrichment is optional, not the gap.) | **Tinkerdown** (kickoff 2026-07-24): formalize the tokens + wire `style_guide` into generation — on-brand by construction, no lint | Guaranteed on-brand, no generic LLM aesthetic | **M4** | | 5 | External-app embedding is iframe-bridge only | Embed handshake [client] | Sandboxed external embeds | **M5** | | 6 | Generation→validate→serve **orchestration** | **Legitimately tinkerdown** (all 3 exploration agents concur) | The skill itself | **M1** | @@ -187,13 +187,13 @@ All follow the Anthropic skill shape already used by `skills/tinkerdown/` (conci ## Roadmap -Ordered milestones. **M0, M1, M2 and M3 are fully detailed** (full phase blocks); M4–M5 are outline-only and get expanded at their kickoff (see LLM session guide convention 9). +Ordered milestones. **M0, M1, M2, M3 and M4 are fully detailed** (full phase blocks); M5 is outline-only and gets expanded at its kickoff (see LLM session guide convention 9). - **M0 — Reframe + upstream bump + a trustworthy vocabulary.** Reposition the narrative (README/SKILL/llms.txt/ai-generation) around ephemeral generated UIs; **bump `livetemplate` + client + components to their latest tags** and absorb behavior changes; **reconcile the `lvt-*` attribute reference with what the client actually implements** (Phase 2 — added after Phase 1's Audit found 8 of 11 sampled attributes stale). *Lights up:* the fast, correct runtime substrate, the honest story, and a vocabulary reference that is safe to hand an LLM — the last being a hard prerequisite for M1, not polish. *(no demo yet)* - **M1 — THE DEMO (thin vertical slice).** The five M1 deliverables (see § Deliverables at a glance) on existing primitives. *Lights up:* the target acceptance test — generate a real, friction-removing UI in ~30s — *and* re-run it from a skill in seconds. - **M2 — Deterministic validation (upstream `Validate()` + tinkerdown lint).** `livetemplate.Validate(templateText)` exposes the **structured, line-numbered parse / reactive-AST diagnostics** that today live in livetemplate's `internal/parse` (unreachable downstream) and that its public render path silently *swallows* — plus an optional render-determinism check. tinkerdown's `validate` consumes it per lvt block and adds the **tinkerdown-owned** semantic checks livetemplate cannot see: **bound-refs** (a used `lvt-source` must resolve to a declared source), **action-param completeness**, a **state-ref diagnostic that teaches the real `lvt-source` fix**, and the **`lvt-persist`→removed-with-migration** cleanup. *Lights up:* first-pass generation reliability — richer diagnostics for the skill's self-correct loop, and the "validates clean but breaks at serve" gaps M1 kept hitting closed at the gate. *(The attribute allowlist stays in tinkerdown, guarded against the vendored client bundle — livetemplate-Go can't own it, as it never references the client-only `lvt-mod:`/`lvt-nav:`/`lvt-ignore` namespaces. Design expanded at kickoff 2026-07-23 — see § M2 design.)* - **M3 — Runtime approved-surface enforcement (tinkerdown).** A server-side policy gate at all three action paths (WS-custom, webhook, builtins) so a running app — or a webhook / crafted-message caller — may only invoke **approved** actions against **approved** sources, respecting readonly: defense-in-depth beyond the *generation-time* lint. *Lights up:* the running app can't exceed the approved surface — the server-side gate `confirm:` never was. *(Kickoff 2026-07-24 re-scoped this **tinkerdown-only** — the planned upstream `WithActionPolicy` would be dead code for tinkerdown, which never routes actions through livetemplate. **Field-name validation was de-scoped** — it needs a validate-time schema source tinkerdown lacks; see § M3 phases and § Risks.)* -- **M4 — Component vocabulary + enforceable house style (upstream `lvt/components` + tinkerdown).** Charts, badges, cards, stat tiles, alerts, empty-states; **plus** promoting the UX style guide from coarse (`site_css` + `style-guide.md`) to **design tokens + an enforced component set** (the "style guide object" the exploration found missing). *Lights up:* richer generated dashboards with no free-form HTML, guaranteed on-brand. +- **M4 — House style on-brand by construction (tinkerdown).** Formalize the design-token layer that already exists implicitly (single-source it, define the four undefined tokens, drive it from a `tokens` config) so semantic HTML + PicoCSS renders on-brand **by construction**; and wire the **declared-but-unconsumed** `style_guide` into the generation context. *Lights up:* guaranteed on-brand generated UIs — no generic LLM aesthetic — via tokens, not a rejecting lint. *(Kickoff 2026-07-24 re-scoped this **tinkerdown-only** [tinkerdown consumes 1 of `lvt/components`' ~29 components], **spine-only** [component enrichment deferred, operator decision], and **no enforcement lint** [it fights semantic-HTML-first]; see § M4 phases.)* - **M5 — Persist to the malleable substrate (save & share).** This is where **malleability lives**: persist a generated UI to the repo + a gallery + share link by storing the captured **skill** (per convention 13 / M1 Phase 6), not just the `app.md` — so "save" means a re-runnable workflow you evolve, while individual UIs stay ephemeral; plus **substrate extensibility** (teams can add their own approved source types) and the external-app embed handshake. *Lights up:* "throw away the UI, keep + reshape the substrate" (folds issues #223 host read-only apps, #282 review mode, #249 external embed, #216 writable WASM sources, #222 custom Go+WASM sources). **Open-issue walkthrough.** Triaged into three buckets against this reframe. *(Counts drift: **48 open** at plan authoring, **82** by 2026-07-19 — the repo files `from-review` issues continuously. The named issue→milestone mappings below stay valid; **re-run the triage at M0 kickoff** rather than trusting the totals, same as the version pins.)* @@ -1002,12 +1002,90 @@ type Diagnostic struct { --- -### M4–M5 phases — outline only (expanded at milestone kickoff per convention 9) +### M4 phases — house style on-brand by construction (expanded at kickoff 2026-07-24, per convention 9) -The *what* + *why* of each is in § Roadmap; here is only each milestone's **kickoff design checklist** — the sections to write into full phase blocks when that milestone starts (per convention 9): +> **Milestone shape (settled at kickoff):** **tinkerdown-only, spine-scoped, no enforcement lint** (operator decision 2026-07-24). The plan filed M4 as "component vocabulary + enforceable house style (upstream `lvt/components` + tinkerdown)"; a direct read (Explore 2026-07-24) reshaped all three parts: +> - **Tinkerdown-only** (like M3). tinkerdown consumes **exactly one** of `lvt/components`' ~29 components (datatable) and reimplements the rest natively (charts/tabs/status-banners as parser transforms; accordion as raw `
`). New components would be tinkerdown-local by default; upstream is opt-in for *reuse*, at a cross-module release cost — not a dependency. +> - **Spine-scoped.** The verified, in-grain gap is *not* "no components" — it is that `style_guide` is a **declared-but-unconsumed config field** (`GenerationConfig.StyleGuide`, no runtime reader) and the design-token layer already exists but is **duplicated + partial**. M4 builds outward from that; component enrichment is deferred (operator chose spine-only). +> - **No enforcement lint.** M4 as filed promised "no free-form HTML." A validate lint that rejects hand-written HTML where a component exists fights tinkerdown's core model (semantic-HTML-first, PicoCSS-classless — the "no CSS classes needed" selling point; the FAQ example uses raw `
` deliberately). **The vision (guaranteed on-brand, no generic LLM aesthetic) is delivered by tokens-by-construction, not by rejection** — the mechanism was mis-specified, not the intent. -*(M2 and M3 were expanded to full phase blocks at kickoff — see § M2 phases and § M3 phases above. The M3 upstream `WithActionPolicy` hook was dropped as dead code for tinkerdown; a livetemplate-side hook for its own consumers is separable framework work, not tinkerdown's M3.)* -- **M4** (upstream `lvt/components` + tinkerdown): the component list + options; how the skill's reference advertises them; the design-token/enforced-component style-guide schema. +**Kickoff Audit findings (Explore of tinkerdown + `lvt/components`, 2026-07-24):** + +- **A real token set already exists — implicit, duplicated, partial.** `server.go:1196-1234` defines `:root` + `[data-theme="dark"]` with ~12 custom properties (`--bg-*`, `--text-*`, `--border-color`, `--card-*`, `--accent`) + `--pico-*` overrides. The client CSS consumes them **with the fallback literal re-hardcoded at every use site** (`var(--accent, #0066cc)` in dozens of places), and four consumed tokens (`--bg-hover`, `--badge-bg`, `--highlight-bg`, `--hover-bg`) are **never defined**. M4-Phase-1 is **extract/dedupe/wire this existing set**, not greenfield. +- **The only author knobs into the token layer are `primary_color` (→ `--accent`) and `font`.** `StylingConfig` (`config.go:676-693`) = `theme`/`primary_color`/`font`/`site_css`/`custom_css`; `theme` maps name→light/dark/auto (`styling.go`, "clean"=light, "minimal" unrecognized→auto). No colors/spacing/typography schema. +- **`style_guide` is wired to nothing.** `GenerationConfig.StyleGuide` (`config.go:181`) is documented "injected into the generation context" but has **no runtime consumer** — `SKILL.md`/`reference.md` never mention style/tokens; no command marshals it. So "`site_css` + a `style-guide.md`" overstates reality: `site_css` is wired, the style-guide half is a dead field. M4-Phase-2 builds the first style-guide pipeline. +- **PicoCSS is the classless substrate.** Embedded (`assets.go:22`), applied to semantic HTML; the one upstream component (datatable) renders class-less because tinkerdown imports no style adapter, so `.Styles.X` is empty → `` → PicoCSS skins it. The token layer sits on top of this — that is why tokens (not classes) are the right on-brand lever. + +#### Phase 1 (M4, tinkerdown) — Design-token system: on-brand by construction (~1 session) + +> **Goal at end:** a team can set its on-brand **page-shell palette** once, declaratively, via a `styling.tokens` config block — each token driving the matching CSS custom property in every page's `:root`, overriding the built-in default through the **same mechanism `primary_color` already uses for `--accent`**. Semantic HTML + PicoCSS then renders on-brand **by construction**, no prompt and no lint required. + +**Design refs:** +- § house-style gap · § M4 design (the token-inventory finding) +- tinkerdown `internal/server/server.go:1196-1234` (the `:root`/`[data-theme="dark"]` block — the 14 author-facing page-shell tokens — + its `` injection at `:3121`), `internal/server/styling.go` (`buildStylingOverrideCSS` — `theme`→light/dark, `primary_color`→`--accent`, `font`; and now `tokens`), `internal/config/config.go` (`StylingConfig`) + +**Audit (first task):** +- [x] **Design-ref completeness check** — corrected the styling path (`internal/server/styling.go`, not `internal/styling/`). +- [x] **Scope split settled (advisor-confirmed): page-shell palette IN, client-chrome CSS OUT.** The inventory found two distinct token layers. (1) The **page-shell** palette — `server.go:1196-1234`'s `:root` (`--bg-*`, `--text-*`, `--border-color`, `--card-*`, `--code-bg`/`--pre-bg`, `--accent`) — is author-facing house style; **this is Phase 1's scope.** (2) The re-hardcoded `var(--x, literal)` fallbacks and the four undefined tokens (`--bg-hover`/`--badge-bg`/`--highlight-bg`/`--hover-bg`) live **only in `internal/assets/client/tinkerdown-client.browser.css`** — a *generated, minified* artifact (built from `client/src` via esbuild) skinning internal client **chrome** (toc/tabs/search), never author-facing. Hand-editing it is the #295 provenance trap, and a "consumed-but-undefined token" lint over generated CSS is a self-certifying guard. So that half is **EXCLUDED from M4** (not deferred — it was never house style), matching the milestone's **no-lint** shape. +- [x] Config surface: extend `StylingConfig` with a `tokens` map (snake_case key → value); `primary_color`/`font` stay as shortcuts. An unknown key is a config **error** (fail-loud at load), not a silent skip. + +**Implementation:** +- [x] `StylingConfig.Tokens map[string]string` + `KnownStyleTokens` (the 14 page-shell keys → CSS custom properties) + `ValidateStyleTokens()`, wired into `config.Load` so a typo fails loudly (names the key, lists the known set). Absent → empty map, no behavior change (backward-compatible). +- [x] `buildStylingOverrideCSS` emits a `:root { --token: value; … }` block from `styling.tokens`, in deterministic key order, values run through `sanitizeCSSValue` (CSS-injection defense). Emitted **after** the `primary_color` block, so an explicit `tokens.accent` wins over the defaulted `primary_color` (source-order precedence — load-bearing; the two `:root` blocks must stay separate). +- [x] `CHANGELOG.md`. + +**Acceptance criteria:** +- [x] **Simplify:** `/simplify` the diff — code-simplifier found it already minimal (no edits); confirmed the two-`:root`-block ordering is load-bearing, not churn. +- [x] **Unit:** `TestBuildStylingOverrideCSS_Tokens`/`_TokensSanitized` (styling); `TestLoad_StylingTokens`, `_UnknownKeyRejected`, `_DefaultsEmpty`, `TestValidateStyleTokens_AllKnownKeysAccepted` (config-load: round-trip, fail-loud typo, backward-compat, map↔validator drift guard). +- [x] **Integration:** the `examples/` corpus renders unchanged under defaults **by construction** — the token block is emitted only when `styling.tokens` is non-empty, so a no-tokens config produces byte-identical output; full suite green confirms no corpus regression. +- [x] **E2E (chromedp, four-channel):** `TestStylingTokensComputedStyle` — a `tokens.accent: #ff00ff` + `card_bg: #00ff00` config produces exactly those **computed** `getComputedStyle(...).getPropertyValue()` values (through the real cascade, not just emitted `", diff --git a/skills/tinkerdown/SKILL.md b/skills/tinkerdown/SKILL.md index 44aa5e8..fa0bb27 100644 --- a/skills/tinkerdown/SKILL.md +++ b/skills/tinkerdown/SKILL.md @@ -45,7 +45,7 @@ reaching for custom JavaScript. Follow these steps in order. They exist because each one catches something the next cannot. -### 1. Read the approved surface, if there is one +### 1. Read the approved surface and house style, if there is one If the project has a `tinkerdown.yaml` with a `generation:` block, it declares the sources and actions you may use — and those are the **only** ones you may use: @@ -60,6 +60,21 @@ document's frontmatter** — a name outside the approved set fails validation, a redefining an approved name silently has no effect, because approved definitions are pinned and yours will be ignored. +**Read the house style, too.** The same `tinkerdown.yaml` carries the project's +style: a `styling` block (`theme`, and any `styling.tokens` design-token overrides) +and an optional `generation.style_guide` pointing at a markdown file. If that field +is set, read the file it names and follow it: + +```bash +cat style-guide.md # the path given by generation.style_guide +``` + +The style guide describes house tone, layout conventions, which components to prefer, +and what to avoid. The design tokens (see `reference.md` § House style) skin semantic +HTML on-brand automatically — so **write semantic HTML and never hardcode colours**; +let the tokens apply. All of this is optional: with no style block, generation falls +back to Tinkerdown's on-brand defaults. + No `generation:` block means no approved surface: declare sources in frontmatter as normal. @@ -81,6 +96,14 @@ Use only attributes from `reference.md`. If you need a capability you cannot fin there, check whether it exists before inventing an attribute for it — a plausible invention is the most common way generated pages fail. +**Stay on-brand by construction — write semantic HTML, never hardcode colours.** +Use semantic elements (`
`, `
`, ``, ``, headings); the +project's design tokens skin them on-brand automatically. Do **not** write a raw +colour (`#3949ab`, `rgb(...)`, a named colour) or reach for Tailwind colour classes — +that bypasses the house palette and drifts off-brand. Follow the project's +`style-guide.md` (from step 1) for layout, tone, and preferred components. With no +style block the defaults are already on-brand, so this costs nothing. + **Binding data and actions — three rules that cause most first-try failures:** - **State comes from `lvt-source` on the block's container.** `{{range .Data}}` and @@ -236,6 +259,7 @@ See [reference.md](./reference.md) for complete API documentation: - Source configuration (pg, rest, csv, json, exec) - Template syntax (Go templates) - Components (datatable, dropdown) +- House style (design tokens, style guide) ## Examples diff --git a/skills/tinkerdown/reference.md b/skills/tinkerdown/reference.md index 0a2e9de..d06bb3f 100644 --- a/skills/tinkerdown/reference.md +++ b/skills/tinkerdown/reference.md @@ -344,6 +344,58 @@ Tinkerdown uses Go templates. ## Styling +### House style — design tokens + +**The on-brand default, and the way to style generated UIs.** A project sets its +palette once in `tinkerdown.yaml`; every page renders against it automatically. +Write **semantic HTML** and let the tokens skin it — do **not** hardcode colours. + +- **Never write a raw colour** (`#3949ab`, `rgb(...)`, a named colour) in generated + markup. Hardcoding bypasses the house palette and drifts off-brand. +- Use semantic elements — `
`, `
`, ``, ``, headings — + which inherit the tokens (`--accent`, `--card-bg`, `--text-heading`, …) with no + classes and no inline styles. +- If the project's `generation.style_guide` points at a markdown file, **read it and + follow it** (tone, layout conventions, which components to prefer, what to avoid). + +A project sets tokens under `styling.tokens`, each key mapped to the CSS custom +property it drives in the page's `:root`: + +```yaml +# tinkerdown.yaml +generation: + style_guide: style-guide.md # optional house-style prose the generator follows +styling: + theme: clean # light / dark / auto — or a preset + tokens: # optional; absent → built-in on-brand defaults + accent: "#3949ab" + card_bg: "#ffffff" + text_heading: "#1a237e" +``` + +The overridable tokens (snake_case key → CSS custom property): + +| Key | CSS property | Skins | +|-----|--------------|-------| +| `accent` | `--accent` | Links, primary actions, active accents | +| `bg_primary` | `--bg-primary` | Page background | +| `bg_secondary` | `--bg-secondary` | Secondary / gradient background | +| `text_primary` | `--text-primary` | Body text | +| `text_secondary` | `--text-secondary` | Muted / secondary text | +| `text_heading` | `--text-heading` | Headings | +| `border_color` | `--border-color` | Rules and borders | +| `card_bg` | `--card-bg` | Card / panel background | +| `card_border` | `--card-border` | Card border | +| `card_shadow` | `--card-shadow` | Card shadow | +| `code_bg` | `--code-bg` | Inline code background | +| `code_border` | `--code-border` | Inline code border | +| `pre_bg` | `--pre-bg` | Code-block background | +| `pre_text` | `--pre-text` | Code-block text | + +An unknown token key fails loudly at config load. A token value applies to both +light and dark themes. Everything below (inline styles, Tailwind) is an escape +hatch for the rare case tokens cannot express — reach for tokens first. + ### Inline Styles Include `