Styling Contract — pt / dt / unstyled
A PrimeVue-shaped styling API on the Mitosis single-source model. Prototyped on
Tabs, Menu, Modal, now rolled out library-wide: every functional component
takes unstyled / pt / dt, exposes a data-part="root" (plus per-part and
data-state hooks), and routes its classes/attrs/tokens through the shared
lib/pt.ts helpers. The only exclusions are the signature/decoration components
(Ascii, Cat, Sigil, Shape, Drip, Breach, Chrome, Bezel, Palette, ArrowRail) —
their look is the identity, not something consumers retheme. A guard test
(tests/styling-contract.test.mjs) enforces this split in both directions.
Everything here is verified against the compiled output of all six targets
(pnpm run build:components), the type-check, and the SSR/e2e/framework tests.
The portable layer (one .lite source → all six targets)
Section titled “The portable layer (one .lite source → all six targets)”Every styled part carries a stable data-part (and, where it has state, a
data-state) so you can target internals from CSS without depending on our
cr-* class names — the Ark/Radix-style hook, and exactly what PrimeVue’s base
emits (data-pc-section). Three props drive the rest:
unstyled— drop thecr-*classes on this instance (behavior + a11y +data-partstay). Global unstyled = just don’t importstyles/components.css.
Partial style imports (ship only the CSS you use)
Section titled “Partial style imports (ship only the CSS you use)”The stylesheet ships three ways, so a consumer pays only for what they render (PrimeVue-style), without giving up the one-import option:
- Everything —
@alebianco/cr-design-system/components(the fullstyles/components.cssbundle). Simplest; one import. - Base + per-component — import the thin chassis once, then one file per
component you actually use:
Slugs match the catalog ids (import "@alebianco/cr-design-system/base"; // resets, chassis, responsive, shared primitivesimport "@alebianco/cr-design-system/styles/button.css";import "@alebianco/cr-design-system/styles/calendar.css";
button,calendar,toggle-chip, …); the full list isstyles/parts/manifest.json. Importbasefirst — it carries the cross-cutting layers the component rules build on. - Nothing —
unstyledper instance, or just import no CSS globally, and style viapt/data-partyourself.
The parts are a lossless projection of the bundle — generated by
build/build-styles.mjs (every rule lands in exactly one of base.css or a
parts/*.css, guarded by pnpm run verify:styles), so the split can never drift
from the authored styles/components.css. Tailwind consumers get a fourth path:
the @theme layer purges to just the utilities in use.
-
pt(pass-through) — per part:pt={{ tab: { class, style, "data-testid", onClick, … } }}.classis merged with the base class;styleis applied to the part; every other key (attributes, and handlers for events the component doesn’t own) is spread onto the part. Parts are documented per component (e.g. Tabs:root·tab).Across the two tiers (global then instance) the same section merges key-wise, and
classconcatenates (global first) whilestylemerges per property — so an app-level default is never silently dropped by an instance that sets one key of its own. Nested sections merge recursively, so both rules hold at depth. -
dt(design tokens) — a map of CSS custom properties applied to the root and inherited by the parts. Instance-scoped token override, same idea as PrimeVue’sdt. Prefer the finer per-component tokens (below) so an override is surgical:dt={{ "--cr-tabs-indicator": "#f0f" }}retargets only the active underline, where the coarsedt={{ "--sig-work": "#f0f" }}would repaint every work-signal in the subtree.
<CrTabs tabs={["A","B"]} active={1} unstyled // no cr-* on this instance pt={{ tab: { class: "px-3", "data-testid": "tab" } }} dt={{ "--cr-tabs-indicator": "oklch(0.7 0.2 320)" }} // just the underline/>Finer component tokens (the PrimeVue-dt granularity)
Section titled “Finer component tokens (the PrimeVue-dt granularity)”The token system has a component tier (tokens/tokens.json → "component",
emitted into dist/control-room.css as --cr-<comp>-*, each defaulting to a
semantic/primitive token). styles/components.css consumes those vars, so dt
can override one part/state without disturbing the global palette. The pattern
matches the pre-existing --cr-btn-* / --cr-panel-* / --cr-chip-* groups and
is now rolled out library-wide across the functional components.
Two conventions keep the token count small:
- Shared
fieldgroup — the text-control family (Input, Textarea, Select, Combobox, NumberField, DateTime, Cron, Pin, TagsInput) reads one surface:--cr-field-bg/fg/border/placeholder/focus/error. Retheme the whole form layer once (mirrors PrimeVue’sform.field.*). - Single
accentknob — where a component’s active/selected/indicator colour came from a signal, one token drives every derived shade (e.g.--cr-table-accent= sort indicator and selected-row tint). Same shape on--cr-tree-accent,--cr-accordion-accent,--cr-segmented-accent-bg,--cr-stepper-accent-bg,--cr-pager-accent-bg,--cr-nav-active-bg, …
| Group | Representative tokens |
|---|---|
| Tabs / Menu / Modal | --cr-tabs-indicator · --cr-menu-item-hover-bg · --cr-modal-backdrop¹ |
| Form field (shared) | --cr-field-bg · --cr-field-fg · --cr-field-border · --cr-field-focus · --cr-field-error |
| Check / Switch / Slider | --cr-check-bg · --cr-check-checked · --cr-switch-track-on · --cr-slider-thumb |
| Popover / Drawer / Tooltip / HoverCard | --cr-popover-bg · --cr-drawer-backdrop · --cr-tooltip-bg · --cr-hovercard-bg |
| Table / Tree / Accordion / Segmented | --cr-table-accent · --cr-tree-accent · --cr-accordion-accent · --cr-segmented-accent-bg |
| Pager / Stepper / Avatar / Kbd / Breadcrumb / DataGrid | --cr-pager-accent-bg · --cr-stepper-accent-bg · --cr-avatar-bg · --cr-kbd-bg · --cr-breadcrumb-current-fg · --cr-datagrid-bg |
| Nav / Masthead / Spinner / Skeleton / Alert | --cr-nav-active-bg · --cr-masthead-accent · --cr-spinner-accent · --cr-skeleton-bg · --cr-alert-bg (+ per-variant --cr-alert-key) |
¹ --cr-modal-backdrop (and --cr-drawer-backdrop) default in :root so the look
is preserved; a per-instance dt override of the backdrop is best-effort — it
relies on ::backdrop inheriting from its <dialog>, modern browsers only.
Signal-driven components (Tag, Tile, Progress, Toast) intentionally get
no per-component tokens — their colour is the semantic signal, so retheme them
through the signal tokens (--sig-work, --sig-done, …).
Guards in tests/styling-contract.test.mjs keep this honest: each token must be
defined in control-room.css and consumed in components.css — if
either half reverts to a coarse global, surgical dt silently breaks and the test
fails. That check runs over the whole component tier, derived from
tokens.json rather than a hand-kept list, so a new group is covered the moment
it is authored. A token that is declared but never consumed is the failure mode it
exists to catch: it reads as a supported dt knob in this table while overriding
it does nothing. (Three such tokens — --cr-radio-bg, --cr-radio-dot,
--cr-spinner-track — were removed when the exhaustive check was added; there is
no CrRadio, and cr-spinner-track was only ever a @keyframes name.)
Kept deliberately on
.cr-*classes, notdata-part/data-state: those are the consumer hooks that surviveunstyled, so theming through them would defeat the opt-out (the styling would come back once the classes were dropped).
Localisation
Section titled “Localisation”Two sources of localised text, and no third:
- Anything
Intlcan derive — month/weekday names (CrCalendar,CrLineChart) and relative time (CrRelativeTime) — comes fromIntl, keyed on the resolved locale. No translation table ships, and every locale works on day one. - Everything else — the ~29 accessible names and visible labels
Intlcannot derive — ships as built-in English inlib/messages.ts, overridable per key.
Both cascade on the same rule as pt:
locale: props.locale → context locale → "en"messages: props.labels[key] → context messages["<Comp>.<key>"] → built-in English<CrModal labels={{ close: "Chiudi" }} /> // one instance<CrCalendar locale="it" /> // one instance// …or app-wide, through the same context as pt:{ locale: "it", messages: { "CrModal.close": "Chiudi" } }A message may be a function for the counted/interpolated cases
(page: (n) => "Pagina " + n), so a translation controls word order rather than
being forced into string concatenation.
CrLineChartpins"en-US"inzoneParts/zWeekdayon purpose: those useIntlas a timezone calculator and read numericformatToParts, where a locale-dependent string would be a bug. Only display names take the user’s locale.
The parent tier: reaching a nested component
Section titled “The parent tier: reaching a nested component”A component rendered inside another is reachable through the parent’s pt, using
PrimeVue’s nested-section convention:
<CrTable pt={{ checkbox: { root: { "data-testid": "row-select" } } }} />// …inside CrTable:<CrCheckbox pt={ptNested(pt, "checkbox")} />The section’s value is a pt object for the child, not an attribute bag, so
ptAttrs drops any object-valued key — a nested section can never leak onto the DOM
as checkbox="[object Object]".
Precedence is global (context) → component (props.pt), component wins — two
tiers, resolved by ptResolve(cr, props.pt, "<Name>").
A forwarded parent section is not a third tier: it arrives in the child as its
props.pt, occupying the instance slot. So a child rendered inside a parent sees
global(child) → forwarded-section, and a pt passed directly to that child would
replace the parent’s forward rather than layer over it. (CrFormRow is the one
place that merges deliberately, so its delegation attributes survive alongside a
consumer’s section — see the note at its CrCheckbox.)
Every nesting site forwards: CrTable/CrDataGrid/CrFormRow → CrCheckbox,
CrChoiceGroup → CrChoice, CrForm → CrFormRow, CrInput → CrIcon,
CrKeyHints → CrKbd. A guard in tests/pt-chaining.test.mjs scans the sources
and fails on any nested component that does not receive a pt, because an
unforwarded child is silently unreachable — nothing else would break.
A nested section must be handed over with
ptNested, never spread.CrChoiceGrouppreviously spreadptAttrs(…, "choice")onto<CrChoice>, which set stray props on the component instead of styling it.
The cascade: global → component
Section titled “The cascade: global → component”App-level defaults come from a context provider; an instance’s own pt wins:
props.pt over context pt[ComponentName]Merged per part, so a global { tab: { class } } and an instance
{ tab: { "data-x": 1 } } both survive on the tab. Within a part the instance key
wins — except class (concatenates, global first) and style (merges per
property), so an app-level default is never silently dropped. Nested sections merge
recursively, so both rules hold one level down too.
Providing it. The context is exported as CrContext from every target’s entry
point, in that framework’s own idiom — without it the tier is unreachable, which is
the whole point of exporting it:
// React / Solid — a context objectimport { CrContext, CrModal } from "@alebianco/cr-design-system/react";<CrContext.Provider value={{ pt: { CrModal: { root: { class: "shadow-lg" } } }, locale: "it", messages: { "CrModal.close": "Chiudi" } }}> <App /></CrContext.Provider>// Vue — a { cr, key } pair; provide under its Symbol keyimport { CrContext } from "@alebianco/cr-design-system/vue";app.provide(CrContext.key, { pt: { … }, locale: "it", messages: { … } });
// Svelte — the same pair, via setContextsetContext(CrContext.key, { pt: { … }, locale: "it", messages: { … } });
// Qwik — a ContextIduseContextProvider(CrContext, { pt: { … }, locale: "it", messages: { … } });
// Angular — an @Injectable({providedIn:"root"}) singleton: set its fields at// bootstrap, or override the provider.inject(CrContext).locale = "it";Every field is optional and defaults to empty, so an app that provides nothing —
or provides only locale — behaves exactly as one with no provider at all. The
shape is typed as CrGlobalConfig.
Internally each component calls ptResolve(cr, props.pt, "CrX") inline at every
helper call site. Two constraints force that shape, both learned the hard way:
- It must not be held in a
state.field. Mitosis mishandles astate.receiver inside a JSX spread — the same quirk that once needed the{...(state.pta())}post-processor patch (see the note below). Passing plain functions ofprops.*keeps the spread correct on every target. - It must not be named
pt. Angular emits@Input() ptfor the prop, so anyptmember on the class collides with it.
The context object itself is undefined when nothing provides it (Vue’s inject
and Svelte’s getContext both return undefined), which is the normal case for SSR
and for apps that never set defaults — ptResolve handles that and returns
props.pt untouched.
Typed pt / dt (beyond PrimeVue)
Section titled “Typed pt / dt (beyond PrimeVue)”pt is not any. Each component names its own parts in the type, so the part
names autocomplete and a typo is a compile error:
pt?: CrPassThrough<"root" | "tab">; // CrTabsdt?: CrDesignTokens;<CrTabs pt={{ tabb: { class: "x" } }} />// error TS2561: 'tabb' does not exist in type 'CrPassThrough<"root" | "tab">'.// Did you mean to write 'tab'?hooks is typed too (CrHooks — onMounted / onUpdated / onUnmounted), so
{ hooks: { onMounted: "nope" } } fails to compile. PrimeVue types both its sections
and hooks as any; its docs note an IDE extension is “being planned” to recover the
autocomplete this gives you directly.
hooks resolves through the same cascade as the sections, so app-level
instrumentation works: pt={{ CrModal: { hooks: { onMounted } } }} on the provider
fires for every modal. (Components read them via ptHooks(ptResolve(…)); reading
props.pt.hooks directly would make the global tier a silent no-op.)
Per-target note: Mitosis drops
onUpdate/onUnMounton some targets — a deps-lessonUpdateemits nowhere, and Solid/Qwik emit no unmount at all. Both are restored from the artifact bybuild/build-fix-lifecycle.mjs(SolidonCleanup+createEffect, Qwik a returned cleanup fromuseVisibleTask$), so all three hooks fire on all six targets.--checkfails if any component loses one.CrKeyHintsmerges its own window listeners into the sameonMount/onUnMountas its hooks, because Mitosis keeps only the last of each per component.
The types live in lib/pt-types.ts and are deliberately framework-agnostic (no
React JSX namespace, no Vue StyleValue) because they are inlined into every
target’s index.d.ts — so Vue/Svelte/Solid/Angular consumers get the same checking
as React/Qwik, not just the TSX targets.
Coverage note:
tests/pt-types.test.mjsruns a realtscover the emitted declarations with negative fixtures (a typo’d part, a malformed hook), but the fixtures compile against Svelte’sindex.d.ts. The declarations are generated from one shared source, so the types are identical across targets; the proof is single-target.
Build note: component prop blocks are copied verbatim per source file, so a type merely imported from
lib/would be referenced but never declared, and every non-TSX consumer would hitCannot find name 'CrPassThrough'.build/build-pkg-types.mjsinlineslib/pt-types.tsinto each generatedindex.d.tsto keep them self-contained. Guarded bytests/pt-types.test.mjs, which type-checks real snippets — asserting both that valid usage compiles and that a bad part name or hook does not.
Handler chaining (beyond PrimeVue)
Section titled “Handler chaining (beyond PrimeVue)”A handler passed through pt runs alongside the component’s own — both fire, on
all six targets, from one source:
<CrTabs tabs={["A","B"]} pt={{ tab: { onClick: () => analytics.track("tab") } }} // runs… onChange={(i) => setTab(i)} // …and selection still works/>Consumer-first is deliberate: the consumer observes the event before the component
mutates state, and a preventDefault() is visible to the component’s handler. A
consumer that throws is caught, so it can never strand the component’s behaviour.
PrimeVue does not guarantee this — its ptOptions.mergeProps defaults to false, so
by default one handler replaces the other.
How it works, and why it isn’t a merge. The component’s own handler stays in JSX
(so every target binds it in its native idiom — onClick, on:click, (click),
onClick$) and calls ptHandler() from inside:
onClick={(event) => state.onTabClick(event, i)}// …in state:onTabClick(event, i) { ptHandler(props.pt, "tab", "onClick", event); // consumer state.select(i); // component}The tidier-looking design — compose both functions and return the composite from
ptAttrs — is not portable. Verified against svelte 4.2: on:click={fn} compiles
to a real listen() call, while a spread {...{onClick: fn}} compiles to
set_attributes with no listener at all, so the handler becomes a dead DOM
attribute and the component’s own behaviour silently disappears. Keeping the handler
in JSX sidesteps that; ptAttrs therefore strips on* keys, so a handler can
never also arrive through the spread and double-fire on the targets that do bind
spread events.
Guarded by tests/pt-chaining.test.mjs, which asserts both the mechanism (executed
for real against the compiled pt.ts) and the wiring (every target keeps its native
binding and calls ptHandler).
Implementation is one shared, framework-agnostic module — lib/pt.ts — with
four pure functions: ptClass() (unstyled + class-merge), ptAttrs() (spread the
bag minus class/style/handlers/reserved), ptStyle() (dt + pt style), and
ptHandler() (invoke the consumer’s handler for one part/event). Components import it as
../lib/pt.ts (the explicit .ts lets the package build rewrite the specifier to
.js); build/build-barrels.mjs copies the source into each target’s output tree
so the relative import resolves for the bundler, the type-check, and the shipped
package. Because these are plain functions of props.pt/props.dt (no state.
receiver), Mitosis state-processes the arguments correctly inside a JSX spread —
which is what let the earlier {...(state.pta())} post-processor patch be removed.
Mitosis codegen quirks this requires (patched in the build, not worked around in userland)
Section titled “Mitosis codegen quirks this requires (patched in the build, not worked around in userland)”- Angular escapes string args inside spread handlers.
{...ptAttrs(props.pt, "root")}compiles to asetAttributes(el, ptAttrs(this.pt, "root"))call in the class body, but Mitosis HTML-escapes the string literal to"root"— valid in a template, a syntax error in JS.build/build-fix-angular.mjs(in thebuild:componentschain, with a--checkmode) unescapes only thesetAttributes(...)lines, leaving template HTML untouched. Surfacing the contract library-wide is what first exercised this (only Button had an esbuild-compiled Angular test before). dtcustom-properties in astyleobject survive on all six targets, including Angular. Earlier notes flagged Angular as unable to take a CSS-variable style object — that is stale for the supported Angular (≥17). Verified from the framework source: Mitosis bindsdtas[ngStyle]='ptStyle(pt, dt, "root")';NgStyle._setStylesetsRendererStyleFlags2.DashCasewhenever the key contains a-(every--cr-*does), and the DOM renderer’ssetStylethen applies it viael.style.setProperty(name, value)— the correct path for custom properties. No scoped-<style>workaround is needed. (React uses thestyleobject directly; Vue/Svelte/Solid/Qwik pass custom props through their style bindings; Svelte’sstringifyStylesonly kebab-cases uppercase, so--sig-worksurvives.) Guard:tests/cross-fw-contract.test.mjsasserts the[ngStyle]dt binding on Angular.
The escape hatch: per-target overrides
Section titled “The escape hatch: per-target overrides”Where the portable layer can’t reach full native fidelity, Mitosis lets you
hand-write a whole file for one target that replaces the generated output:
drop it at overrides/<target>/components/<Name>.<ext> and build:components uses
it verbatim for that target only.
overrides/vue/components/CrTabs.vue demonstrates the corners the single source
can’t do, using Vue’s own primitives:
- function-form pt reactive to internal state:
pt.tab = ({ active }) => ({...}) - listener chaining via Vue
mergeProps(consumeronClickruns and selection still fires) - global pt via
inject(CrContext.key, …)— the same context every generated component reads, so one provider configures the whole library. (It previously injected a string key,'crGlobalPT', which made this component the one exception to the documented tier: an app providingCrContextconfigured all 80 others and not this one, and vice versa.) It also wirespt.hooksthrough that cascade.
Verified: after one build, Vue’s CrTabs is the override; React/Svelte/Solid/Qwik/
Angular are generated from CrTabs.lite.tsx (see tests/styling-contract.test.mjs).
Cost: an override is a full replacement, not a patch — that file is now
hand-maintained and no longer tracks CrTabs.lite.tsx. Budget it for the few
components/targets where native reactivity is worth it; it’s the PrimeTek
three-codebases cost, scoped.
Coverage vs PrimeVue — what the portable layer reaches
Section titled “Coverage vs PrimeVue — what the portable layer reaches”| Capability | Portable (one source) | Notes |
|---|---|---|
data-part / data-state hooks | ✅ | every part, every target |
unstyled (global + per-instance) | ✅ | a composing component must FORWARD it to its children, or the opt-out is a no-op on the parts they render (CrForm → CrFormRow did not, so cr-form__row/cr-check/cr-input survived) |
pt: merge class, set style, inject attrs | ✅ | class merged; attrs spread |
pt: inject handlers | ✅ | one JSX-cased key (onClick) on every target |
pt/dt: typed part names | ✅ beyond PrimeVue | CrPassThrough<"root"|"tab">; PrimeVue uses any |
pt: handler chaining (both run) | ✅ beyond PrimeVue | via ptHandler(); PrimeVue’s mergeProps defaults to false |
pt.hooks lifecycle | ✅ | onMounted/onUpdated/onUnmounted, all six targets; two need a build-level restore (see above) |
pt: nested sections (parent tier) | ✅ | ptNested; the section is the child’s pt, named for the child (checkbox, kbd, …) |
dt: instance token override | ✅ (React/Vue/Svelte/Solid/Qwik) | Angular needs scoped-<style> runtime |
pt function-form (state-reactive attrs) | ❌ portable → ✅ via override | Qwik async blocks it from one source |
| global pt / locale / messages (app-level) | ✅ | context tier via the exported CrContext; ptResolve(cr, props.pt, "CrX") |
Net: the ~90%-by-usage (hooks, unstyled, class/style/attr merge, instance tokens) is one portable source; the reactive/native corners are a budgeted per-target override.