Fuaranfuaran

The source for generative UI

Host-styling checklist

This file enumerates the CSS variables and class hooks the Fuaran renderer emits, plus the contract a host stylesheet must honour to render Fuaran UI correctly. Companion of HOST-INTEGRATION-CHECKLIST.md (which covers host-integration-tier wiring). Forge consumers can drop the packaged content/fuaran-reference.css straight in; non-forge hosts and consumers with their own design tokens read this file end-to-end and bridge per docs/THEME-BRIDGE-GUIDE.md.

Phase 12 session 3b shipped the reference stylesheet at samples/demo/index.css. Phase 12.H (this checklist's authoring) packs it as content/fuaran-reference.css in the Fuaran.UI.Renderer NuGet under Apache 2.0, completes the 7×N tone matrix for callout / progress / pill / metric, and turns the previously-implicit class contract into the document below. Phase 12.K (next) will migrate the variable surface into a typed Theme record – this checklist remains the canonical source for "what classes does the renderer emit".

The renderer NEVER emits Tailwind utility classes or inline hex colours. Every visual decision is reachable from a CSS variable (Section 1) and styled against a class hook (Sections 2–4). Two override paths are supported: re-binding variables at the :root layer (preferred) or replacing the reference stylesheet wholesale (kept open for §4l down-shift portability).

1. CSS variables

The renderer's reference stylesheet (src/Fuaran.UI.Renderer/content/fuaran-reference.css) binds these variables at the :root layer. Consumers override by re-binding at their app shell. Every variable reference in the reference CSS uses the var(--X, fallback) form so an unstyled-mode renderer (no consumer stylesheet, no reference stylesheet) still shows recognisable defaults.

1.1 Tone palette (7 × 3 = 21 variables)

ToneBackgroundForegroundBorder
default--fuaran-tone-default-bg--fuaran-tone-default-fg--fuaran-tone-default-border
subdued--fuaran-tone-subdued-bg--fuaran-tone-subdued-fg--fuaran-tone-subdued-border
brand--fuaran-tone-brand-bg--fuaran-tone-brand-fg--fuaran-tone-brand-border
success--fuaran-tone-success-bg--fuaran-tone-success-fg--fuaran-tone-success-border
warning--fuaran-tone-warning-bg--fuaran-tone-warning-fg--fuaran-tone-warning-border
critical--fuaran-tone-critical-bg--fuaran-tone-critical-fg--fuaran-tone-critical-border
info--fuaran-tone-info-bg--fuaran-tone-info-fg--fuaran-tone-info-border

ToneVariant cases at Fuaran.UI/Types.fs:607. The naming root is --fuaran-tone-{toneVar}-{bg|fg|border} where toneVar matches the lower-cased DU case (Theme.toneVar).

1.2 Spacing scale (5 variables)

Lifted out of hardcoded pixels in Phase 12.H so consumers can override compact / spacious densities without forking the reference CSS.

VariableReference valueUsed by
--fuaran-space-xs4pxspacer-small, fine gaps inside cells
--fuaran-space-sm8pxrow spacing, button vertical padding, default node margin
--fuaran-space-md12pxstack default gap, card body padding, button horizontal padding
--fuaran-space-lg16pxcard padding, dashboard gap, panel padding
--fuaran-space-xl24pxspacer-large, top-level container padding

1.3 Typography scale (7 size + 4 weight + 3 line-height = 14 variables)

VariableReference valueUsed by
--fuaran-text-xs12pxhelp text, table headers, grid pill, caveat, badge
--fuaran-text-sm13pxMetric label, form label, tab body, callout, progress label
--fuaran-text-base14pxbody default, buttons
--fuaran-text-lg16pxcard heading
--fuaran-text-xl20pxmid-sized headings
--fuaran-text-2xl24pxlarge headings
--fuaran-text-3xl28pxMetric value
--fuaran-font-weight-regular400body
--fuaran-font-weight-medium500buttons, table headers, Metric label
--fuaran-font-weight-semibold600headings, card heading
--fuaran-font-weight-bold700Metric value
--fuaran-line-height-tight1.25dense headings
--fuaran-line-height-normal1.5body default
--fuaran-line-height-relaxed1.75spacious mode (Phase 12.K)

1.4 Component dimensions (7 variables)

VariableReference valueUsed by
--fuaran-button-pad-yvar(--fuaran-space-sm, 8px)button vertical padding
--fuaran-button-pad-xvar(--fuaran-space-md, 12px)button horizontal padding
--fuaran-radius-sm4pxinputs, callouts, small surfaces
--fuaran-radius-md6pxbuttons, table
--fuaran-radius-lg8pxcards, panels, Metric
--fuaran-radius-full9999pxpills, badges
--fuaran-border-width1pxevery bordered surface

1.4b Component extension points (14 variables) — fallback-only by design

These are per-component knobs a host may re-bind, and they are the one group the reference stylesheet does not declare at :root: each exists only as the fallback in its own var(--X, fallback) site. That made them effectively undiscoverable — a host reading :root could not tell that the code pane's colours or the modal's backdrop were overridable at all — which is why they are enumerated here.

Why they are not simply declared at :root (checked 2026-08-21, having first tried it): the reference sheet's :root block is bijective with the typed Theme record, and ThemeTests' "covers every --fuaran-* variable" test asserts the correspondence in both directions — a variable in :root that Theme.toCss does not emit fails the suite, as does the reverse. So declaring these fourteen is not a stylesheet edit at all; it is a decision to take them into the typed theme surface, which reaches the Theme record and its parser, the four byte-identical CSS copies, the theme-manifest bridge, and the brand re-bind. Worth doing deliberately; not something to slip in as tidying.

Re-binding one works exactly as for any other token — set it at your app shell and the var() site picks it up, the fallback being what applies when you do not.

VariableFallback value (the effective default)Used by
--fuaran-font-monoui-monospace, "SF Mono", Menlo, Consolas, monospacecode pane, inline code
--fuaran-code-bg#1e1e2ecode-pane surface
--fuaran-code-fg#cdd6f4code-pane text
--fuaran-code-copy-bg#313244code-pane copy button
--fuaran-code-copy-border#45475acode-pane copy button border
--fuaran-avatar-size40pxavatar width + height
--fuaran-disclosure-summary-paddingvar(--fuaran-space-md, 12px) var(--fuaran-space-lg, 16px)disclosure <summary> padding
--fuaran-modal-backdroprgba(0, 0, 0, 0.5)modal scrim
--fuaran-modal-max-width560pxmodal dialog width cap
--fuaran-toast-max-width360pxtoast width cap
--fuaran-shadow-lg0 10px 15px -3px rgba(0, 0, 0, 0.1)raised surfaces (popover, toast)
--fuaran-shadow-xl0 20px 25px -5px rgba(0, 0, 0, 0.1)modal dialog
--fuaran-z-modal1000modal stacking order
--fuaran-z-toast1100toast stacking order

1.5 Weight and emphasis: consumer hooks, not a pending gap

StyleWeight and Emphasis on SemanticStyle emit fuaran-weight-{compact|standard|spacious} and fuaran-emphasis-{quiet|normal|loud}, and the reference CSS carries no rules for them, deliberately. This entry previously read as a Phase-12.K to-do (compact / spacious against the spacing scale; quiet / loud against border-width + shadow); Phase 431, which made emitted-class ↔ rule coverage executable, settled it the other way and recorded the absence as a declared, tested one rather than an open promise.

The reason is the shape of these two axes rather than a shortage of design opinion: every node wrapper carries one class from each, so any rule here lands on every node in the tree at single-class specificity — it would race the per-component rules by file order — and a standard / normal rule restating a token's default value would defeat a consumer's own :root override of that token. Consumers safelist and bind these hooks at their app shell, which is what they are for. Giving them reference rules is a design decision with estate-wide visual blast radius; it is open to a deliberate later phase, but it is not a conformance defect.

The class hooks are documented in Section 2.0 below so Tailwind-JIT-shaped consumers can pre-safelist them.

1.5b Coverage is enforced, not documented (Phase 431)

The class contract in Sections 2–4 is executable. src/Fuaran.UI.Tests/CssCoverageTests.fs enumerates every class the renderer can emit — the Theme projections by running them over every DU case, the structural per-spec vocabulary by scanning the renderer sources — and fails the build when one has no rule in content/fuaran-reference.css and no declared absence. A declared absence names the class that carries the chrome instead, and the suite checks that class really is styled, so an exemption cannot be a mute-list entry. The same suite asserts the TypeScript tier's byte-copy of the stylesheet is identical, which is what carries that coverage proof across to the copy.

So a new class hook is not shipped until it has a rule or an entry. Adding one to a renderer without either turns the build red here, rather than surfacing as an unstyled node in a downstream host's page.

1.5c The tier copies are generated (Phase 432)

content/fuaran-reference.css is the canonical stylesheet; every other host tier ships a byte-copy of it. Those copies are generated, not hand-copied:

dotnet run --project Build.fsproj -- Css          # rewrite every tier copy present in this checkout
dotnet run --project Build.fsproj -- CssCheck     # fail, naming every copy that is not byte-identical

CssCheck is wired into the Check gate, so an edit to the canonical sheet that has not been propagated fails for the author who made it. That is the point of running it from this side: each consuming tier already locks its own copy, but only that tier's suite sees the drift, in a repo the author was not in — which is how a preceding change left two copies serving a stylesheet two rule families behind. A tier whose repo is not in the checkout is reported as not checked rather than passing quietly.

1.5e Writing direction: the sheet is LOGICAL; the shell is not (Phase 1114)

Every inline-axis rule in the canonical sheet is expressed in CSS logical properties — margin-inline-start / padding-inline-end / border-inline-start / inset-inline-start / inset-inline-end / inset-inline / text-align: start|end / float: inline-end. The consequence for a host is short: set dir and the whole component vocabulary mirrors. There is no RTL stylesheet, no override block to import, and no per-component opt-in. A nested subtree that sets its own dir mirrors only itself, which is what a mixed-direction page is made of — an Arabic page with a left-to-right table of identifiers in it, say. The sheet's own header enumerates the three physical usages that survive deliberately and why, and the only two [dir="rtl"] rules in it flip the disclosure chevron's GLYPH, never any geometry.

Two things this does not do for you, and they are the whole of the host-side work:

  1. The dir attribute itself. Nothing in the stylesheet emits it. A host derives it from the document's locale — in this repo Formatting.textDirection : string -> string is that derivation, and the Giraffe DocumentShell is its first consumer. A tier that ships the byte-copy inherits the mirroring and inherits none of this.
  2. A host that re-implemented the class hooks rather than serving the sheet (the §1 option (b) path) must make the same physical→logical substitution in its own rules. Serving a mirrored vocabulary from a sheet whose own rules are physical produces a page that is half-mirrored, which is worse than one that is not mirrored at all.

Per-tier handoff — enumerated here for Phase 1128. The CSS moves by byte-copy; the shells do not, and each tier's shell is a different seam:

TierInherits the mirrored sheetStill owes a lang / dir declaration
fuaran-tsyes — packages/renderer/css/fuaran.cssits standalone-bundle mount and any host page template it ships
fuaran-goyes — renderer/content/fuaran-reference.cssthe static-HTML + islands document emitter (the <html> open tag it writes)
fuaran-rsyes — css/fuaran.cssthe server-side emitter and the wasm32 client's mount root
fuaran-pyyes — src/fuaran_ui/renderer/content/fuaran-reference.cssthe server-HTML renderer's document wrapper
fuaran-swift / fuaran-ktn/a — native surfaces, no stylesheetthe platform's own layout-direction setting, from the same locale

Each of those shells needs the same two facts the F# shell now derives: the BCP-47 tag, and the direction that follows from it. The derivation is small and deterministic (an RTL script set, an RTL language-default set, script-subtag-wins) — port it, do not re-derive it, and do not reach for a runtime locale database, because the answer must be the same string on the server that emits the markup and the client that hydrates it.

1.5f The sheet has a PRINT medium, and a host re-implementing it owes one too (Phase 1124)

content/fuaran-reference.css carries two @media print blocks. A host that replaces the sheet wholesale (route (b) in this document's header) inherits neither, and the failure is silent in the way this document exists to make loud: nothing renders wrong, nothing logs, and the defect appears on paper in somebody else's office.

The two blocks are different kinds of obligation and only one of them is optional.

  • The AUTHORED block is a wire contract. .fuaran-break-inside-avoid, .fuaran-break-before-page, .fuaran-grid-rows-together and .fuaran-grid-repeat-header are the projections of four declared wire members (BoxSpec.keepTogether / .breakBefore, DataGridSpec.keepRowsTogether / .repeatHeader). A document that declares one has STATED a fact about itself, and a sheet with no rule for it silently drops that statement. This block is not a default and not a preference — a host reproducing the class vocabulary owes these four rules, and the vocabulary fingerprint (§1.5d) is what makes their absence detectable.
  • The DEFAULTS block is an opinion, and a good one. What a page does on paper when the author declared nothing: transient and pointer-only chrome hidden, collapsed detail expanded, atomic tiles kept whole, headings not stranded, tone legible without a background fill, absolute link destinations printed. A host is free to disagree with any of it. What a host should not do is have no answer, which is what "no @media print at all" is.

The rule the defaults follow, restated here so a re-implementation can apply it rather than copy the selector list. Hide what is transient or pointer-only; hide a control whose whole content is an affordance glyph, and KEEP one carrying authored text; keep anything that records WHICH DATA this is (a filter bar, a pager status) — a table printed without its filters makes a claim it cannot support; expand collapsed DETAIL of what is on the page (disclosures, transcripts, scroll areas) but leave ALTERNATIVES alone (tab panels, switch stages — the reader chose between them, and printing the unchosen ones is a different document); and give break-inside: avoid only to shapes that are always atomic, never to anything a wire member already governs.

ORDER THE DEFAULTS FIRST. Both blocks reach the same fragmentation properties at the same specificity, so at equal specificity the later rule wins — and it must be the author's. The reference repo pins this with a test (PrintCascadeTests) rather than a comment, because the failure has no symptom until someone prints.

Three things the reference sheet deliberately does NOT do, each of which a re-implementation should decline for the same reasons rather than rediscover: no @page rule (page size, margins and running furniture are the reader's, chosen in their own print dialogue — the wire format's charter puts the paged medium outside the language, and that applies to the reference host's stylesheet too); no print-color-adjust: exact (forcing fills to print spends the reader's ink to rescue a tone channel that should not have depended on colour, and it would conceal the fact that it had); and no blanket row cohesion (keepRowsTogether is the wire member that says exactly that, and a default would make every declaration of it change nothing).

Two audited limits, recorded because CSS cannot close them and a host should not spend time trying.

  1. A closed <details> prints closed on any engine implementing neither ::details-content nor the legacy display leg. The content is in the markup either way, so a host with a stricter requirement opens the elements before printing — from script, not from the sheet.
  2. A Media transport with no poster prints as an empty rectangle, and its mandatory label reaches the DOM only as aria-label, which is not rendered text. The sheet gives the transport a border so the rectangle reads as a deliberate frame rather than a smudge, and that is the whole of what a stylesheet can do: <video> and <audio> are replaced elements, so ::after on them generates nothing and the label cannot be surfaced from CSS. Making it visible would be a renderer EMISSION change — a decision about what the reference host puts on the page — and is deliberately not taken here.

What audits clean today, for the avoidance of a second audit. Charts, sparklines and drawings print losslessly: they are real SVG geometry from the shared builder, so they are vector on paper at any resolution, and they are kept whole by the defaults. Grids print their resolved rows with header repetition available on declaration. Forms print their controls with resolved values and inert-looking chrome, so a filled form reads as a record rather than as an empty one.

1.5d The stylesheet carries a class-vocabulary fingerprint (Phase 433)

A host serves its stylesheet from Fuaran.UI.Renderer and — if it server-renders — emits its classes from Fuaran.UI.Renderer.Server. Nothing couples those two package versions. A host that pins one and serves the other's sheet gets no error at all: nodes render unstyled or mis-styled, and a shipped control appearing as a bare browser input reads as a design choice rather than as version skew. That is the same failure the Range control had before §1.5b, arriving across a package boundary instead of an authoring one.

content/fuaran-reference.css therefore carries a stamp in its header naming the class vocabulary it was written against:

/* fuaran-vocabulary-fingerprint: fv1:db6e4135e0aa5b83 */

and the renderer exposes the matching value, so the host can assert the two agree.

What the fingerprint covers. The class vocabulary — exactly the enumeration §1.5b's coverage suite builds, which is the Theme projections run over every DU case unioned with the structural class literals scanned out of the renderer sources. It moves when a class enters or leaves the vocabulary.

What it deliberately does not cover. The rules. Re-colouring .fuaran-callout, retuning the token defaults, or adding a media query leaves it unchanged. It answers does this sheet know the classes this renderer emits — the skew that silently breaks a control — and nothing else. Whole-sheet identity is a different question with a different answer already: the sha256 printed by Build.fsproj -- CssCheck, and the byte-copy assertion in §1.5c. A host that needs byte identity should hash against the packaged copy rather than read the fingerprint as if it meant that.

The recommended host assertion. Do it once at startup, and fail hard — the whole value is that a mismatch stops a deploy instead of producing a bug report about a page looking wrong:

open Fuaran.UI.Renderer.Server

match Render.checkStylesheet (File.ReadAllText servedStylesheetPath) with
| Ok () -> ()
| Error message -> failwith message   // do not degrade to a warning

Render.vocabularyFingerprint is the constant if you would rather compare it yourself, and Render.stylesheetFingerprint reads the stamp out of a stylesheet's text. All three take or return plain values and read no files: where a host's stylesheet comes from — a static file, an embedded resource, something fetched at boot — is the host's business, and a check that guessed would be checking the wrong bytes.

Two boundaries worth stating, because both are decisions rather than omissions. An unstamped sheet is an Error, not a pass: a host that calls this has asserted the served sheet is the packaged one, and staying silent about a sheet that cannot be identified is precisely the outcome the check removes. And a host serving its own replacement sheet (the §4l down-shift path) should not call this at all — it implements the class hooks in Sections 2–4 directly, and the fingerprint of the reference sheet says nothing about whether it did.

The value is machine-maintained, in three links. Theme.vocabularyFingerprint is pinned rather than computed, because half the vocabulary is read out of the renderer sources, which a shipping package cannot see at runtime. So: the coverage suite recomputes the truth and fails naming the value to pin when the vocabulary moves; -- CssCheck (wired into Check) fails when the stylesheet's stamp and the constant disagree; and the byte-copy check carries the stamp to every tier. Changing the vocabulary is therefore: run the suite, paste the value it names into Theme.fs, run -- Css, commit the sheet and its three tier copies in the same change-set. No step of that is remembered rather than enforced.

1.5g The cross-repo stylesheet defects, and where each stands (Phases 1648, 1674, 1701, 1702)

Every item below is REAL and MEASURED against the current sheet and renderers. None is repairable in this repo alone, for one shared reason: each changes the canonical stylesheet, and the canonical stylesheet is byte-copied into four sibling reference implementations (§1.5c). A change here with the copies unlanded turns CssCheck — and the byte-copy assertion §1.5b relies on — red for everyone until all five land, which is a repo-wide stop for work that has nothing to do with the change. So each is recorded with the exact bytes, so the change-set that does land it is an edit rather than a re-derivation. The landing order is fixed and is not a preference: this repo first (its gate is what pins the copies), then fuaran-ts, fuaran-go, fuaran-rs, fuaran-py.

Phase 1674 landed (a) and (c) TOGETHER, and the reason is worth keeping. (a) moves the sheet's bytes and (c) moves the vocabulary fingerprint, which restamps the sheet — so each on its own costs the same five-repo sync, and doing them separately would have cost it twice for no separable review. (b) was the one of the three that also needed a renderer EMISSION change, in five renderers, which is a different size of act; Phase 1701 landed it. (d) arrived later and from a different direction — it was filed by a session that could SEE it and fixed by the first session that could MEASURE it. All four are closed, and each is kept below with what it cost, because the class recurs.

(a) — LANDED, Phase 1674. Three fallback/declaration mismatches the 2026-07-30 design-system audit missed. A var(--X, fallback) whose fallback disagrees with :root's declaration means unstyled mode and styled mode disagree about a metric or a colour — a host that drops the sheet gets a subtly different rendering rather than a plainly unstyled one, which is harder to notice and harder to attribute. Phase 914dcf7 reconciled six of these; three were missed, and are:

Token:root declaresDisagreeing fallbacks
--fuaran-text-sm13px14px, at four sites
--fuaran-tone-default-disabled-fg#6b7280#9ca3af, at two sites
--fuaran-tone-success-border#6ee7b7#86efac, at one site

The fix was the declaration's value at every site — the same direction 914dcf7 took, for its reason: the :root value is the one a host overriding the token sees, so the fallback is what should move. Applied at all seven sites and synced to the four copies. Nothing a styled host renders moved, which is the whole character of this class of defect: a var() fallback applies only where the token is undeclared, so the change is invisible in styled mode by construction and visible only in the unstyled mode it was wrong in.

(b) — LANDED, Phase 1701. .fuaran-table-row:hover claimed cursor: pointer unconditionally. The rule (shared with .fuaran-grid-row:hover) told every reader that every row was clickable, when only a row whose grid declares onRowClick is. A pointer cursor over inert content is a promise the markup does not keep, and it is the kind of thing a reader learns to distrust rather than reports; at least one host was scoping its own neutralisation to undo it.

The fix is a renderer-EMITTED class the hover rule consumes. fuaran-grid-row-interactive is emitted beside fuaran-grid-row on exactly the rows of a grid declaring onRowClick, and cursor: pointer moved onto .fuaran-grid-row-interactive:hover. The hover BACKGROUND stayed where it was, on every row: it says "this is the row under your pointer", which is true of a row you cannot click. Only the promise of a click moved. Being a new class it moved the fingerprint (fv1:e56df5af70231f8e → fv1:253483dae447ee83) and therefore the stamp and all four copies, per §1.5d — the cost 1674 predicted, paid once.

The decision the item left open — is a row interactive because the GRID declares a row action, or because the row itself carries one? The wire answers it: there is no per-row action to carry. onRowClick is a grid-level closure-bearing slot, so the grid's declaration is the only fact any host can read, and every host now reads the same one. Two consequences are worth stating because they are the parts a reader is most likely to think are bugs:

  • A staticRows grid is marked on no row, whatever it declares. That mode honours no row action in any tier — its rows are TextSource cells, not the row values a declared action is applied to — so Fuaran.table pins OnRowClick to None and the client leg drops a declared one. A marked row there would promise a click nothing can deliver. fuaran-table-row therefore carries no pointer at all now, which is exactly what the item was about.
  • The client tier's SELECTION fallback is deliberately not marked. A bound row with no declared action still writes the clicked row to the selection store, and where a row key is declared that is visible. But selection is the HOST's fallback rather than the document's declaration: a host with no selection at all (every SSR tier here) would emit a different class set for the same document, and the class has to survive hydration byte-identically. So the marker states what was DECLARED, and a selection-only row renders with the ordinary arrow.

What the reference SERVER host can and cannot answer for. It draws a bound grid as a hydration placeholder, so it emits no bound row and the positive half of the claim is answered by the hosts that render bound rows (fuaran-ts's server tier, fuaran-go, fuaran-py, fuaran-rs). What it does answer, and not vacuously, is every negative half — including the static leg, where the declaration is in scope at exactly the point the rows are built and must still reach no row. That asymmetry is stated in the obligation itself (WIRE_FORMAT.md §3.6.24 rule 3) rather than left as a gap in one suite.

Pinned, not merely fixed. The corpus roster declares DataGrid/interactive-row-only-with-action, so all five hosts enumerate the claim from the artefact and report it unchecked until each asserts it — the §1.5g(c) lesson applied in advance: CssCheck compares stylesheet BYTES and the coverage scan sees NAMES, so an emission condition is invisible from either instrument and needed a third.

(c) — LANDED, Phase 1674. The two SSR hosts disagreed on the file-input class. Fuaran.UI.Renderer emits fuaran-file-upload-input on a FileUpload's <input type="file">; Fuaran.UI.Renderer.Server emits fuaran-file-upload-control for the same element. Both are declared bare hooks — the reference sheet holds no opinion on native file-input chrome — so nothing about the SHEET is wrong, and that is exactly why it survived: CssCheck compares stylesheet BYTES while the coverage scan sees NAMES, so a name that is in the vocabulary and unstyled is invisible from either side. A host selecting on the class the client gave it gets neither styling nor a DOM match against the static render.

The decided direction is that the SERVER adopts fuaran-file-upload-input, and the reasoning is worth keeping because it is not symmetric. The client's name is the older one; it is the one the reference sheet's declared-absence note was written about first; and -control reads as the wrapper in a vocabulary where fuaran-file-upload already is the wrapper, so it is the more confusing of the two names for the element it names. What made it a cross-repo change rather than a two-line edit is §1.5d: retiring fuaran-file-upload-control REMOVED a class from the vocabulary, which moved Theme.vocabularyFingerprint (fv1:533d4239b16f57b7 → fv1:e56df5af70231f8e), which restamped the sheet, which moved all four byte copies. And it is pinned now rather than merely fixed, which is what closes the pair of blind spots: CssCoverageTests asserts that both F# renderers name the file input the same way, and can go red (proved by reverting the server to -control). That assertion is deliberately NOT in the declared-tier-divergence list beside it — that list is for substitutions the spec SANCTIONS, and this was never sanctioned.

The divergence was FIVE-way, not two. The shard recorded two hosts because two were looked at. fuaran-go's and fuaran-py's renderers had both copied the F# SERVER's spelling, so three of the five emitted -control and two (fuaran-ts's pair, fuaran-rs's server) emitted -input. All three moved in this change-set — one line each, and no golden anywhere in the estate carried the class, which is exactly why nothing was red while three hosts disagreed with two.

What is NOT done, and is the durable form of this pin: a render-fidelity.json obligation naming the class, which would make every host's obligation suite assert it rather than leaving four hosts pinned by nothing. It was drafted and withdrawn deliberately: a newly declared obligation turns every host's obligation suite RED until that host writes a checker (their suites say so in their headers — "a newly declared obligation on a kind this host renders arrives here as a claim with no checker"), so it is a five-host change-set of its own and arming it mid-campaign would have stopped four repos for a divergence that is now closed.

What is already safe. The server-driven ReadFileBody shim does not depend on either name — it resolves the control by input[type="file"], deliberately, because a shim that keyed off a class would work against one host and fail silently against the other (Phase 1648). So the divergence is a styling and DOM-query trap, and no longer a functional one.

(d) — LANDED, Phase 1702. A HIDDEN .fuaran-tooltip widened the document at 375px. Filed by Phase 1129 as "7px of scroll width", diagnosed by Phase 1674 to the arithmetic, and fixed here — the first of these four that needed a LAYOUT ENGINE rather than a reading, which is why it outlived three phases on a machine with no browser binaries. Two independent defects, both measured in Edge 152 at a 375px viewport, and it is worth separating them because only one is the phantom:

BeforeAfter
documentElement.scrollWidth, nothing hovered482375
clientWidth375375
hidden hint's border box346px wide0 (collapsed)
revealed hint's border box346px320px
  1. box-sizing was never declared, and this sheet imposes no global reset — so max-inline-size: min(20rem, calc(100vw - 2rem)) was a CONTENT-box cap and the hint's real box was 26px wider than the comment beside it claimed. One declaration; visible in the revealed row above.
  2. visibility: hidden does not take a box out of the scrollable overflow region. The hint is absolutely positioned, so its bounds still widened the viewport's scroll area while nobody was hovering anything. That is the phantom: a horizontal scrollbar with nothing to scroll to, on a page whose only wide thing is invisible — the worst class of defect to report, because there is nothing to point at. transform: scale(0) while hidden collapses it (scrollable overflow is computed from TRANSFORMED boxes), riding the same 120ms delay visibility already uses so the fade-OUT still plays.

Three other candidates were measured and rejected, and the measurements are the reason. display: none collapses it and discards the reason the rule says visibility in the first place (the element stays in the a11y tree's reach for an eagerly-resolved aria-describedby). Clipping the WRAPPER while it rests collapses it and clips every descendant's focus ring and shadow in the state a page spends all its time in. Clamping the hint to its wrapper (max-inline-size: 100%) — the candidate that reads best on paper — collapses it and ruins it: measured at 78px wide by 303px tall, a column of single words. A hint on a two-word node is not allowed to be two words wide.

What is deliberately NOT fixed. A hint that is actually ON SCREEN near the right edge still overflows, by 81px in the same probe. That is not a regression and not an oversight: collision-aware flipping needs measurement and is the client-tier enhancement WIRE_FORMAT.md §3.1 declines to claim for the reference renderer. This phase closed the phantom — overflow caused by something no reader can see — and left the visible case exactly where the spec puts it.

The change is declarations only, so the vocabulary fingerprint did not move (fv1:253483dae447ee83 before and after) and the sheet was not restamped. It still cost the four-copy sync of §1.5c, because the BYTES moved: CssCheck compares bytes, and §1.5d's restamp is a second, independent trigger rather than the only one.

1.6 Interaction state matrix (Phase 12.N) – 84 + 4 = 88 variables

The static palette in §1.1 describes the idle appearance of every tone. The interaction matrix adds per-state × per-tone × per-slot tokens so consumers can theme :hover / :focus-visible / :active / :disabled independently of the base palette, without monkey-patching .fuaran-button / .fuaran-tab / .fuaran-callout-dismiss etc. Pre-12.N every interactive surface inherited an opinionated filter: brightness(0.92) hover with no theme escape hatch; post-12.N the brightness opinion is gone and tokens are the only knob.

Variable name shape: --fuaran-tone-{tone}-{state}-{slot} where the axes are:

  • tone ∈ {default, subdued, brand, success, warning, critical, info} (7) – matches §1.1.
  • state ∈ {hover, focus, active, disabled} (4) – :focus-visible-bound, not :focus.
  • slot ∈ {bg, fg, border} (3) – matches the §1.1 slot vocabulary.

Total: 7 × 4 × 3 = 84 tone-state-slot variables, listed in stable order in the reference CSS :root block (fuaran-reference.css post-12.N) grouped by state then by tone.

Plus 4 focus-ring globals controlling the :focus-visible outline shape on every interactive surface:

VariableReference valueNotes
--fuaran-focus-ring-color#93c5fd (brand-border)drives outline-color on every :focus-visible rule
--fuaran-focus-ring-width2pxdrives outline-width
--fuaran-focus-ring-offset2pxdrives outline-offset
--fuaran-focus-ring-stylesoliddrives outline-style

1.6.1 Static fallback rules

The reference CSS picks tokens such that the default values map semantically to a small set of rules (consumers can rely on this):

  • Hover – each slot defaults to a darker variant of the base tone's same slot (one Tailwind stop down on the palette ladder).
  • Focus – bg / fg default to the base value (no surface shift on focus – the outline ring is the primary affordance); border defaults to brand-border so a focused-edge tint reads as a brand accent.
  • Active – each slot defaults to a darker-than-hover variant.
  • Disabled – every tone's slot collapses to the matching subdued slot (bg: #f3f4f6, fg: #6b7280, border: #d1d5db). This is the "drained colour" convention.

1.6.2 Per-component state surface

Which states the reference CSS actually applies to each interactive class:

Surfacehoverfocus-visibleactivedisabled
.fuaran-button-primary✅ tone-brand-hover-fg✅ outline ring✅ tone-brand-active-fg✅ tone-brand-disabled-fg + opacity
.fuaran-button-secondary✅ tone-default-hover-bg + border✅ outline ring✅ tone-default-active-bg + border✅ tone-default-disabled-{bg,fg,border}
.fuaran-button-tertiary✅ tone-brand-hover-bg✅ outline ring✅ tone-brand-active-bg✅ tone-brand-disabled-fg (text only)
.fuaran-button-destructive✅ tone-critical-hover-fg✅ outline ring✅ tone-critical-active-fg✅ tone-critical-disabled-fg + opacity
.fuaran-tab✅ tone-brand-hover-{fg,border}✅ outline ring––
.fuaran-stepper-step✅ tone-subdued-hover-{bg,fg}–––
.fuaran-callout-dismiss✅ opacity shift✅ outline ring––
.fuaran-grid-row / .fuaran-table-row✅ tone-subdued-hover-bg–––
.fuaran-form-input / .fuaran-form-select / .fuaran-form-textarea–✅ outline ring + tone-default-focus-border––
.fuaran-filter-input / .fuaran-filter-select–✅ outline ring + tone-default-focus-border––
.fuaran-select-control–✅ outline ring + tone-default-focus-border––
.fuaran-grid-cell-editable–✅ outline ring + tone-default-focus-border––
.fuaran-grid-cell-button✅ tone-default-hover-{bg,border}✅ outline ring––
.fuaran-form-submit✅ tone-brand-hover-fg✅ outline ring✅ tone-brand-active-fg–

The matrix records which states the reference CSS exercises; the variable surface is fully populated regardless (all 84 + 4 are declared at :root), so consumers who add their own rules – e.g. :hover on .fuaran-metric or :active on .fuaran-callout – can consume the tokens without needing to author the variables.

1.6.3 Anti-patterns (extending §6)

  • Don't drop the :focus-visible distinction in favour of bare :focus. The reference CSS deliberately uses :focus-visible so keyboard navigation gets the outline ring but mouse clicks don't. Consumers who want both can layer their own :focus { ... } rule on top; consumers who want only-keyboard behaviour get it by default.
  • Don't outline: none interactive elements at the base layer. The reference declares the outline ring through :focus-visible so removing it at the base layer would create an unstyled-focus moment. If a host design demands no outlines, override --fuaran-focus-ring-width: 0; instead – the outline declaration still emits, but its width collapses to zero.
  • Don't bind the per-state tokens as color-mix() of the base tones. The reference uses static hex values for cross-browser consistency (the phase body flagged sRGB-vs-OKLCH drift). If a consumer wants color-mix()-derived state colours, they can re-bind individual variables; the contract is that the variable holds a CSS colour expression, not that it's a static hex.
  • Don't apply per-state tokens to non-interactive surfaces (.fuaran-metric, .fuaran-callout, .fuaran-badge). The variable surface includes the passive tones for completeness (a consumer might author hover-able variants), but the reference CSS does not apply them – pre-12.N convention preserved.

2. Class hooks the renderer emits

Every Fuaran node renders inside a wrapper element whose className is computed by Theme.nodeClassName. That string is the concatenation of:

  1. fuaran-kind-{...} – per-NodeKind tag (Section 2.1).
  2. fuaran-node fuaran-tone-{...} fuaran-weight-{...} fuaran-emphasis-{...} – semantic-style tokens (Section 2.0).

Inner kind-specific class hooks are emitted by the per-kind renderers (Section 3+).

2.0 SemanticStyle hooks (always present on outer wrapper)

ClassSourceNotes
fuaran-nodeevery nodebase hook; consumers MUST NOT override (see anti-patterns)
fuaran-tone-{default,subdued,brand,success,warning,critical,info}style.Tone7 variants
fuaran-weight-{compact,standard,spacious}style.Weight3 variants – consumer hook, deliberately unstyled by the reference sheet (§1.5)
fuaran-emphasis-{quiet,normal,loud}style.Emphasis3 variants – consumer hook, deliberately unstyled by the reference sheet (§1.5)

2.1 Per-NodeKind base hooks (25 variants)

One class per NodeKind<'Msg> case (Theme.kindClass):

NodeKind caseEmitted classNotes
Layout.Dashboardfuaran-kind-dashboard
Layout.Stackfuaran-kind-stack
Layout.GridLayoutfuaran-kind-grid-layoutdistinct from fuaran-kind-grid (Visualisation)
Layout.SplitPanelfuaran-kind-split-panel
Layout.Tabsfuaran-kind-tabs
Layout.Cardfuaran-kind-card
Layout.Stepperfuaran-kind-stepper
Layout.SummaryListfuaran-kind-summary-listPhase 12.P – single-card-of-rows shape
Display.Headingfuaran-kind-heading
Display.LabelValueRowfuaran-kind-label-value-rowPhase 12.P – single label-left / value-right row
Display.Markdownfuaran-kind-markdown
Display.Metricfuaran-kind-metric
Display.Badgefuaran-kind-badge
Display.Sparklinefuaran-kind-sparkline
Display.Spacerfuaran-kind-spacer
Display.Calloutfuaran-kind-callout
Display.Progressfuaran-kind-progress
Display.Skeletonfuaran-kind-skeleton
Input.Formfuaran-kind-form
Input.Filtersfuaran-kind-filters
Input.Buttonfuaran-kind-button
Input.FileUploadfuaran-kind-file-upload
Input.Selectfuaran-kind-select
Visualisation.DataGridfuaran-kind-griddistinct from fuaran-kind-grid-layout (Layout)
Visualisation.Chartfuaran-kind-chart
Visualisation.Tablefuaran-kind-table
Visualisation.Mapfuaran-kind-map
Custom(moduleId, componentId, _)fuaran-kind-custom fuaran-custom-{moduleId}-{componentId}both segments sanitised – see Section 5

3. Dynamic class suffixes (the Tailwind-safelist list)

Classes whose suffix is computed at render-time from spec / state. Tailwind JIT can't see these without explicit safelisting; the list below is the canonical safelist source.

3.1 Layout-side dynamic suffixes

ClassSource
fuaran-stack-vertical / fuaran-stack-horizontalStackSpec.Orientation
fuaran-stack-wrapadded when StackSpec.Wrap = true (Phase 12.P)
fuaran-tabs-horizontal / fuaran-tabs-verticalTabsSpec.Orientation
fuaran-tab-activeadded to selected tab in Tabs
fuaran-stepper-step-activeadded to current step in Stepper
fuaran-split-pane-left / fuaran-split-pane-rightfirst / second child in SplitPanel

3.2 Display-side dynamic suffixes

ClassSource
fuaran-spacer-small / fuaran-spacer-medium / fuaran-spacer-largeSpacerSpec.Size
fuaran-progress-indeterminateadded when ProgressSpec.Indeterminate = true
fuaran-sparkline-emptyadded to sparkline when source resolves to empty
fuaran-heading-eyebrow / fuaran-heading-caption / fuaran-heading-leadHeadingSpec.Variant (Phase 12.P – Standard emits no suffix)
fuaran-label-value-row-emphasisadded when LabelValueRowSpec.Emphasis = true (Phase 12.P)

3.2a Motion suffixes (Phase 12.F – outer-wrapper)

The renderer emits one fuaran-motion-{token} class on the outer wrapper when Node.Motion = Some token. Eight tokens; four ship with @keyframes in the reference CSS, four are no-op class hooks for consumer extension.

ClassSourceReference CSS rule
fuaran-motion-noneMotion.Noneno-op
fuaran-motion-pulse-during-loadMotion.PulseDuringLoad@keyframes fuaran-motion-pulse – opacity 1 → 0.5 → 1
fuaran-motion-fade-in-on-mountMotion.FadeInOnMount@keyframes fuaran-motion-fade-in – opacity 0 → 1
fuaran-motion-slide-in-from-belowMotion.SlideInFromBelowno-op
fuaran-motion-shake-on-errorMotion.ShakeOnError@keyframes fuaran-motion-shake – ±4px translateX
fuaran-motion-rotate-on-refreshMotion.RotateOnRefreshno-op
fuaran-motion-slide-in-from-rightMotion.SlideInFromRight@keyframes fuaran-motion-slide-in-right – translateX(16px) + fade
fuaran-motion-expand-collapseMotion.ExpandCollapseno-op

@media (prefers-reduced-motion: reduce) disables every shipped keyframe rule. Consumers authoring overrides for the no-op hooks should respect the same media query.

3.3 Input-side dynamic suffixes

ClassSource
fuaran-button-unwiredadded to buttons whose OnClick action transitively contains a non-Dispatch / non-Chain branch (Call / Notify / Navigate / SetState / AiTool) – Phase 12 session 3b convention to mark substrate-routed actions visually

3.5 The uniform icon hook

Every icon-bearing spec (tab header / Fact / Metric / Callout / Button) renders its IconSource as one EMPTY placement element – <span class="fuaran-icon fuaran-{kind}-icon" data-icon="{name}" aria-hidden="true"></span>. The icon name rides data-icon, never the text content; the reference CSS ships no glyphs, so with no host icon system the hook renders as nothing. Map data-icon to glyphs with your own mechanism, e.g. .fuaran-icon[data-icon="user"]::before { content: ...; }, an icon-font class added by hydration, or SVG injection.

ClassEmitted on
fuaran-iconevery icon hook (shared base)
fuaran-tab-icona tab header with Icon set
fuaran-fact-icona Fact with Icon set (inside .fuaran-fact-value)
fuaran-metric-icona Metric with Icon set (leads the tile)
fuaran-callout-icona Callout with Icon set (leads the column)
fuaran-button-icona Button with Icon set (leads the label)

3.4 Visualisation-side dynamic suffixes

ClassSource
fuaran-skeleton-rowone per row in SkeletonSpec.Rows
fuaran-grid-row / fuaran-table-rowevery data row (clickable when OnRowClick is wired)

4. Per-spec tone / variant suffixes

The per-spec variant DUs project to class suffixes. The renderer's Tone / Variant propagation is the only way a host stylesheet can colour a spec independently from the outer fuaran-tone-* wrapper.

4.1 Tone-bearing components (4 × 7 = 28 classes)

The full 7×N matrix landed in Phase 12.H. Pre-12.H several tones rendered the default styling because their CSS rules were missing – Phase 12.H closed this gap by completing the matrix in fuaran-reference.css AND propagating MetricSpec.Tone through renderMetric (which previously dropped the tone on the floor).

ComponentDefaultSubduedBrandSuccessWarningCriticalInfo
Calloutfuaran-callout-defaultfuaran-callout-subduedfuaran-callout-brandfuaran-callout-successfuaran-callout-warningfuaran-callout-criticalfuaran-callout-info
Progressfuaran-progress-defaultfuaran-progress-subduedfuaran-progress-brandfuaran-progress-successfuaran-progress-warningfuaran-progress-criticalfuaran-progress-info
Pill (grid cell)fuaran-pill-defaultfuaran-pill-subduedfuaran-pill-brandfuaran-pill-successfuaran-pill-warningfuaran-pill-criticalfuaran-pill-info
Metricfuaran-metric-defaultfuaran-metric-subduedfuaran-metric-brandfuaran-metric-successfuaran-metric-warningfuaran-metric-criticalfuaran-metric-info

The tone suffix uses Theme.toneVar – the same function the outer wrapper uses for fuaran-tone-* – so the two classes always match for the same ToneVariant value.

4.2 Badge variants (6 classes)

BadgeVariant is its own DU, distinct from ToneVariant. See Section 4.4 for the vocabulary fork.

VariantClass
Neutralfuaran-badge-neutral
Brandfuaran-badge-brand
Successfuaran-badge-success
Warningfuaran-badge-warning
Criticalfuaran-badge-critical
Infofuaran-badge-info

4.3 Button variants (4 classes + 1 modifier)

VariantClass
Primaryfuaran-button-primary
Secondaryfuaran-button-secondary
Tertiaryfuaran-button-tertiary
Destructivefuaran-button-destructive
(modifier)fuaran-button-unwired (see Section 3.3)

4.4 BadgeVariant ↔ ToneVariant vocabulary fork

The two DUs are intentionally separate. BadgeVariant is the in-band "what semantic flavour is this badge" surface; ToneVariant is the styling-token surface used by every other tone-bearing component. The fork is documented in the Fuaran design specification §4k Q3.4 – the merge would ripple through the AI authoring guide.

Side-by-side mapping (so consumers stop expecting fuaran-tone-neutral to exist):

BadgeVariantClosest ToneVariantNotes
NeutralSubduedNOT Default. Badges historically used a flat grey-on-grey; Subdued matches it.
BrandBranddirect
SuccessSuccessdirect
WarningWarningdirect
CriticalCriticaldirect
InfoInfodirect

Consumers who want the Neutral badge to share styling with fuaran-tone-subdued-bg should style .fuaran-badge-neutral against var(--fuaran-tone-subdued-bg) – not invent a --fuaran-tone-neutral-* variable. The reference CSS does exactly this.

5. Reserved class fragments (Custom nodes)

NodeKind.Custom(moduleId, componentId, props) carries unconstrained-string moduleId and componentId. The renderer projects each through Theme.sanitiseClassFragment (Theme.fs:32) which replaces every character outside [a-zA-Z0-9_-] with - before interpolation into the emitted fuaran-custom-{moduleId}-{componentId} class.

Implications for hosts:

  • A Custom("My Module", "Pill Chart", _) node emits fuaran-custom-My-Module-Pill-Chart – DO NOT assume the raw IDs survive verbatim.
  • Two distinct Custom nodes that differ only in characters the sanitiser collapses (e.g. "my.module" and "my-module") collide on the same class. Pick module/component IDs that are already CSS-identifier-safe to avoid surprise.
  • The fuaran-kind-custom base class is always present on the outer wrapper, so styling-by-class against fuaran-kind-custom { ... } catches every Custom node regardless of sanitisation.

5.1 The IFuaranRuntime.TryRenderCustom runtime hook (Phase 12.F)

Pre-12.F, the renderer always emitted the labelled-placeholder fallback for every Custom node. Phase 12.F gives IFuaranRuntime a new abstract member so hosts can register real renderers per (moduleId, componentId):

abstract TryRenderCustom :
    moduleId : string * componentId : string * props : Map<string, JVal> -> ReactElement option

Renderer dispatch order:

  1. ctx.Runtime.TryRenderCustom(moduleId, componentId, props) – registered renderer wins.
  2. On None, emit a <div class="fuaran-custom-placeholder"> containing the labelled body the renderer used pre-12.F.

The runtime ships two registration surfaces:

  • Fuaran.UI.Renderer.Runtime.MutableRuntime – .NET-side (tests, Fable-compiler-not-required hosts). Diagnostic shape for the other substrate members.
  • Fuaran.UI.Renderer.BrowserRuntime – browser-side (the default for Fable apps). Browser-shaped Call / Notify / Navigate / SetState / InvokeAiTool, plus the Custom registry.

Both expose:

member _.RegisterCustomRenderer
    (moduleId : string, componentId : string, renderFn : Map<string, JVal> -> ReactElement)
    : unit

Registration is consumer-side. AI emitting kind: "custom" MUST NOT assume any specific renderer is registered – that's a host concern, kept opaque from the typed-tree contract.

5.2 Placeholder class change (Phase 12.F)

The pre-12.F inner <div class="fuaran-custom"> placeholder is gone. The labelled-fallback body is now wrapped in <div class="fuaran-custom-placeholder">, which is the class that carries the dashed-border styling. Reasons:

  • The previous .fuaran-custom class conflicted with the per-instance fuaran-custom-{moduleId}-{componentId} class on the outer wrapper.
  • Hosts that register a renderer via TryRenderCustom get their element rendered verbatim – they should not inherit the dashed-border placeholder styling. Scoping the dashed-border rule to .fuaran-custom-placeholder keeps the registered-renderer surface clean.
  • The outer wrapper carries data-fuaran-node-id for every Kind including Custom, so LayoutObserver / SnapshotRegistry (Phase 12.G) now cover Custom nodes uniformly.

Consumers who styled against .fuaran-custom directly should switch to either .fuaran-kind-custom (catches every Custom node, registered or placeholder) or .fuaran-custom-placeholder (only the labelled fallback).

6. Anti-patterns

  • Don't override .fuaran-node base styling. It's the universal-marker class; touching it propagates to every Fuaran node and is almost never what you want. Override at the variable layer (--fuaran-tone-*) or at the per-kind / per-spec hook instead.
  • Don't redefine the tone variables only at the per-component layer. --fuaran-tone-brand-bg is the single source of truth; binding --fuaran-callout-brand-bg separately defeats the contract and means new tone-bearing components (Phase 12.K's --fuaran-emphasis-loud-border-style additions) won't pick up your colour.
  • Don't forget to safelist the dynamic suffixes (Section 3) in Tailwind JIT configs. The class strings are computed at render-time; the JIT scanner won't see them. Safelist with safelist: ['fuaran-tab-active', 'fuaran-stepper-step-active', 'fuaran-stack-vertical', 'fuaran-stack-horizontal', 'fuaran-stack-wrap', 'fuaran-tabs-horizontal', 'fuaran-tabs-vertical', 'fuaran-split-pane-left', 'fuaran-split-pane-right', { pattern: /^fuaran-spacer-(small|medium|large)$/ }, 'fuaran-skeleton-row', 'fuaran-progress-indeterminate', 'fuaran-sparkline-empty', 'fuaran-button-unwired', 'fuaran-label-value-row-emphasis', { pattern: /^fuaran-heading-(eyebrow|caption|lead)$/ }, { pattern: /^fuaran-(callout|progress|pill|metric)-(default|subdued|brand|success|warning|critical|info)$/ }, { pattern: /^fuaran-badge-(neutral|brand|success|warning|critical|info)$/ }, { pattern: /^fuaran-button-(primary|secondary|tertiary|destructive)$/ }, { pattern: /^fuaran-motion-(none|pulse-during-load|fade-in-on-mount|slide-in-from-below|shake-on-error|rotate-on-refresh|slide-in-from-right|expand-collapse)$/ }].
  • Don't add per-component variables like --fuaran-button-brand-fill. The renderer composes tone × component via class names, not per-component variables. A --fuaran-button-brand-fill would double the contract surface and break the §4l down-shift portability promise (the Apache 2.0 reference CSS is the canonical class vocabulary; per-component variables would have to be re-invented for every consumer-side fork).
  • Don't ship fuaran-reference.css as the sole styling surface and call it done. Reference CSS is a working default; consumers who already own design tokens (--color-brand, shadcn --primary, MUI --mui-palette-primary-main) bridge per docs/THEME-BRIDGE-GUIDE.md and let their existing design system flow through to the --fuaran-tone-* surface.
  • Don't drop the var(--X, fallback) form when amending the reference CSS or authoring a host stylesheet that re-implements pieces of it. A consumer that forgets to wire one variable should still see something recognisable for that one node – not a silently-broken empty rule. Phase 12.H rewrote the reference CSS to use fallbacks everywhere precisely because a real consumer's pre-bridge state rendered Fuaran with ~100% styling loss when the variables were unset; fallbacks turn that into "looks like the reference theme" rather than "looks like raw markup".

See also