Amends 0302 (decision 2 grows; the
open consequence on the runtime
BrandService is settled) and
0291 (decision 8: no separate
brand repository; decision 9: the engine is ported in named pieces; decision 1 is satisfied).
Reads with 0310,
0316 and 0084 (the
theming and brand rows), each of which carries a Note.Context
Three theming engines exist.@stackra/tokens holds the Theme schema, the stackra theme and the web and native
renderers (0302 decision 2), but it has no notion of a
parent theme, no contrast or separation check, and its native output carries no palette. The archived basalt engine, which
0291 decision 9 said to port, has tiers, inheritance, an
accent-separation rule, a tenant verdict that refuses a colour with a suggestion, and the generators that built every brand
kit, but it is a second schema with a dependency that no longer exists. A product’s runtime theming package is a third
engine, and it adjusts a failing colour silently. Meanwhile @stackra/tokens ships, under assets/data/stackra/, a kit
record that names the Figentra brand as its parent and lists the family’s products and accents, which an MIT package may not
carry (0302 decision 4,
0316 decision 1).
The Figentra OS brand record of 2026-10-24 stacks four layers (Stackra, Figentra, product, tenant) and binds them: a layer
below Figentra changes colour, never shape; the fonts stay; every layer passes WCAG AA in both schemes; a failing tenant
colour is refused with a suggestion; the banner, acting-as, danger and warning colours are reserved. Five sessions were
consulted and all chose to grow @stackra/tokens; the owner decided on 2026-10-24, confirmed that the rights assignment
of 0291 decision 1 is signed, and answered the open questions. This ADR records the Stackra side. The Figentra side
(the Figentra theme, the product accents, the marks and the kit outputs) is the Figentra OS design record’s.
Decision
-
One engine, one schema.
Themein@stackra/tokensis the only token schema, and@stackra/tokensis the only code that computes a colour. The basalt engine retires as a running engine, every kit’s token JSON retires, and the product runtime theming package retires onto this engine once its consumers have moved. Nobody adds a theming mechanism beside it. A planned client package may hold client state only (the scheme, the material mode, the text size, which resolved CSS to load); it never computes colours. -
A brand above Stackra is a layer, not a theme. A
ThemeLayeris aThemeDiff(the existingstackra.theme-diffshape) plusextends(its parent’s name), anid(any string, a tenant public id included), a derived kebab-casenamefor the resolved theme, and an optionalLayerPolicy.resolveTheme(base, layers, rootPolicy?)is a pure fold over any number of layers that never throws: it refuses a layer whoseextendsis not the current name, checks every changed path against the policy in force, merges, recomputeshexLightandhexDarkfrom the oklch value of every changed colour, and narrows the policy for the layers below. It returns aThemeVerdict: accepted with the theme, or refused withThemeFindings (snake_case codes:wrong_parent,path_not_allowed,path_locked,policy_widened,invalid_value,hex_drift,contrast_too_low) and the suggestions of decision 4. -
Policies are data a brand owner writes; the gates are opt-in. A
LayerPolicyhasallowandlockpath patterns (a trailing*only;lockwins), extraContrastPairs andSeparationRules. A lower layer may only narrow it. The tiers of the old engine become policies, and Stackra encodes no brand rule: which paths Figentra allows, which roles it locks, and whether fonts may change are entries in Figentra’s policies, approved by the owner.defineThemekeeps checking grammar and completeness only, so every existing theme and every framework user’s theme keeps working. The contrast and separation checks run throughcheckTheme(theme, pairs?), whichresolveThemeand thestackratheme’s own tests call. -
Refuse with a suggestion, never adjust.
checkThememeasures the default AA pairs (role pairs only, in both schemes; palette pairs belong to thestackratheme test) and any pairs the policy adds, over what the components actually paint: HeroUI’scolor-mixderived soft and hover values on the web and on native, not the declared tokens alone. A translucent colour is composited over its background or skipped with a finding; nothing throws. A failing colour is refused, andsuggestColourreturns the nearest lightness per scheme that clears every contrast pair naming that path, for the form to show. A submitted accent is kept as given;deriveAccentfills only hover, pressed, soft and soft-foreground from it, per scheme, and moves the link only when asked. No fixed-lightness derivation and no silent correction exists anywhere in the engine. -
Separation is a notice, and a family test.
checkSeparationports the old rule (at least 25 degrees of hue or 0.1 of lightness, hue wrap-around, the hue test skipped when either chroma is under 0.03) and runs only where a policy names neighbours; the recommended neighbours for a tenant accent are danger, warning, success and the banner tokens. For a tenant it is a notice, as in the old engine: the verdict is accepted withrequiresIconAndLabelset, and the user interface must then show every status with an icon and a label, never by colour alone. The separation of the product accents from each other is a test in the Figentra brand package over its own registry; Stackra ships no family list, and Stackra’s steel accent is outside that test. -
Paths are stable, and stored diffs are versioned. A stored layer names token paths, so a rename in
@stackra/tokensmust not refuse a tenant’s brand or break the public resolve endpoint. A path-stability test refuses a rename without an alias; the stored diff carries the token-set version (stackra.theme-diff/2), andresolveThemeapplies the alias map before it checks paths. -
Native parity.
renderNativeCsswrites every palette entry in both scheme blocks and registers each as--color-<name>: var(--<name>)in@theme, so Uniwind makes a class for it; the palette stays inthemeVariables().stackra, and the mobile theme store sends those entries to Uniwind too.themeToDtcgemits the DTCG 2025.10 object forms, and the per-token hex serves email and sign-in surfaces. -
The open-core line.
@stackra/tokensholds the schema, the checks, the renderers, thestackratheme and its OFL fonts, and nothing else: no brand data, no family names, no marks, no logo rules, no asset manifest and no asset generation.assets/data/stackra/leaves the package: thedecidedrecord joins the marks inapps/docs/src/assets/brand/, the minimums become in-code pairs and tests, and thestackratheme’s kit-equality test becomes a frozen in-code snapshot. The signature colour and thecursortoken move with the marks or take neutral names. A neutrality test refuses any product or family name undersrc/orassets/. Only then does the package dropprivateand publish a snapshot (0177, STA-210), because Figentra OS consumes Stackra from the registry (0200). -
Two new token groups in the
stackratheme.palette.banner-sandbox,banner-stagingandbanner-acting-as, each with a-foreground, for the banner of 0280 decision 5, which@stackra/paneldraws, so Figentra can lock them; andpalette.chart-1tochart-5, a categorical set checked at 3:1 against the surface, which the@stackra/ui-prostylesheet (STA-458) maps onto HeroUI Pro’s chart variables so a very light or very dark product accent cannot push a chart below 3:1. -
Kit generation is Figentra OS tooling. The brand kit tool lives in figentra/os at
tools/brand-kit, an unpublished workspace member that imports@stackra/tokensand never copies it, and is the only producer of Figentra and product assets. It holds no colour logic: every colour comes from a resolved theme. Logo selection rules and the asset manifest are typed constants beside the marks, not part ofTheme. It keeps a generator for every Latin and Arabic lockup and endorsement, a determinism check on its outputs, and a literal scan that refuses a hard-coded brand colour in a consumer. Its outliner, fonts and baselines are the Figentra OS design record’s. -
Where the Figentra layers live. Every product’s accent layer lives in
@figentra/brand(figentra/ospackages/brand), so one test checks the family’s separation; each product’s marks, lockups and app icons live in that product’s own repository under its all-rights-reservedNOTICE(0024 decision 2), so no product receives another’s unreleased identity. A product identity is an accent and a mark; type and radius exceptions are refused. Product user interfaces default to the solid material, with glass on showcase surfaces only. Tenant fonts are not offered. -
Runtime placement. Tenant branding is the branding module of the Figentra OS experience service. It stores
tenant layers only (the platform and product layers come from
@figentra/brandat build time), resolves the cascade with locks as policy paths, refuses with suggestions, caches the rendered web CSS and native variables per revision, and feeds the identity provider’s label policy and the email layout from the hex values. The same pure functions run offline, on a till included. White-label requires Pro and up (0215). A tenant picks its app icon from a closed, prebuilt set. This settles theBrandServicethat 0302 and 0084 left unplaced. -
Ports, under 0291. The rights assignment of 0291
decision 1 is signed, so named modules of the archived basalt engine (separation, the suggestion, the merge and
policy rules, the logo selector, the raster and SVG readers, the artefact applier, the literal scan) and the
approved kit’s generators are ported (P) into
@stackra/tokensandtools/brand-kitrespectively, each package with oneORIGIN.mdline (decision 4). Property-based tests come over as example tables; no property-testing library is added. No separate brand repository is created, which amends decision 8.
Consequences
- A framework user keeps today’s
defineThemeand full freedom: the gates arrive only with a policy. Figentra, the products and the tenants get the gates through their layers. - Product layers inherit Stackra’s type, spacing, radius, motion, fonts and materials, so a product adds an accent and a mark and nothing else; a wish for its own fonts or radius is an owner decision recorded in that product’s repository (0227 keeps product decisions there) and refused by policy until then.
- The web and mobile theme studios, their presets and the mobile theme store move onto layers in one wave with the
native renderer, after the open token pull requests land;
Theme’s shape does not change, which keeps the wave small. - A tenant’s accent near a reserved colour is allowed and labelled, not refused, so support never fights a brand over a hue; a tenant’s accent that fails AA is refused with the colour that would pass.
@stackra/tokenspublishes, and figentra/os pins it. Until the assets leave the package, nothing publishes.- Books, decks, stationery and print are build artefacts of the kit tool, generated on demand and not committed; app assets are committed per product.
Enforcement
- planned — tests in
@stackra/tokens: the default pairs on thestackratheme in both schemes; a policy table forresolveTheme(parent, allow, lock, narrowing, hex recompute);hex_drift; the painted-colour check against HeroUI’s derived values; the native palette snapshot; the DTCG object-form shapes; the path-stability test with the alias map; the neutrality test; the committed-CSS-equals-generator test of 0302. - planned — a test in
@stackra/ui-prothat lists every CSS variable HeroUI Pro reads against the theme and the chart mapping (STA-458). - planned — in
@figentra/brand: the family separation test over every product accent, and a test that the Figentra layer changes only the paths its policy allows. - planned —
pnpm brand-kit checkin figentra/os: output determinism, re-measured contrast and construction drift. pnpm validate’s plannedORIGIN.mdcheck of 0291 decision 4 covers the ported packages.
Alternatives
- Revive the basalt engine as the generator on top of
@stackra/tokens. Not chosen: a second schema, a dead dependency, tiers that allow full product identities against the brand record, and two producers of the same HeroUI variables. - Promote the product runtime theming package to the shared engine. Not chosen: a third model in a product repository, no inheritance, no materials, no native palette, and it adjusts colours silently.
- A separate private brand repository for the kits, as 0291 decision 8 said. Not chosen: the kit reads its inputs
from
@figentra/brandin figentra/os, so a second repository means two private repositories to keep in sync and a release before every asset change. - Gates inside
defineTheme. Not chosen: it would refuse every existing theme and every framework user’s theme; the gates are opt-in through policies.
Supersedes and sources
Supersedes: none. Amends, by Note: 0302 (decision 2 and theBrandService consequence), 0291 (decisions
1, 8 and 9). Reads with 0310,
0316, 0084,
0280, 0215,
0177,
0200 and
0024. The two design-system ADRs still in review (the steel accent and glass
quiet buttons; materials) take a Note when they land: the accent and the materials inherit through layers, and products
default to solid. Sources: the owner’s decisions of 2026-10-24 on the branding engine, after a consultation of the
design-system, framework, OS and ADR sessions; the Figentra OS design record (docs/product/brand.md in figentra/os),
which holds the Figentra side; restated in this ADR’s own words.