Skip to content

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 the cr-* classes on this instance (behavior + a11y + data-part stay). Global unstyled = just don’t import styles/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 full styles/components.css bundle). Simplest; one import.
  • Base + per-component — import the thin chassis once, then one file per component you actually use:
    import "@alebianco/cr-design-system/base"; // resets, chassis, responsive, shared primitives
    import "@alebianco/cr-design-system/styles/button.css";
    import "@alebianco/cr-design-system/styles/calendar.css";
    Slugs match the catalog ids (button, calendar, toggle-chip, …); the full list is styles/parts/manifest.json. Import base first — it carries the cross-cutting layers the component rules build on.
  • Nothingunstyled per instance, or just import no CSS globally, and style via pt / data-part yourself.

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, … } }}. class is merged with the base class; style is 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 class concatenates (global first) while style merges 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’s dt. Prefer the finer per-component tokens (below) so an override is surgical: dt={{ "--cr-tabs-indicator": "#f0f" }} retargets only the active underline, where the coarse dt={{ "--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 field group — 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’s form.field.*).
  • Single accent knob — 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, …
GroupRepresentative 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, not data-part/data-state: those are the consumer hooks that survive unstyled, so theming through them would defeat the opt-out (the styling would come back once the classes were dropped).

Two sources of localised text, and no third:

  • Anything Intl can derive — month/weekday names (CrCalendar, CrLineChart) and relative time (CrRelativeTime) — comes from Intl, 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 Intl cannot derive — ships as built-in English in lib/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.

CrLineChart pins "en-US" in zoneParts/zWeekday on purpose: those use Intl as a timezone calculator and read numeric formatToParts, 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/CrFormRowCrCheckbox, CrChoiceGroupCrChoice, CrFormCrFormRow, CrInputCrIcon, CrKeyHintsCrKbd. 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. CrChoiceGroup previously spread ptAttrs(…, "choice") onto <CrChoice>, which set stray props on the component instead of styling it.

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 object
import { 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 key
import { CrContext } from "@alebianco/cr-design-system/vue";
app.provide(CrContext.key, { pt: { … }, locale: "it", messages: { … } });
// Svelte — the same pair, via setContext
setContext(CrContext.key, { pt: { … }, locale: "it", messages: { … } });
// Qwik — a ContextId
useContextProvider(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 a state. receiver inside a JSX spread — the same quirk that once needed the {...(state.pta())} post-processor patch (see the note below). Passing plain functions of props.* keeps the spread correct on every target.
  • It must not be named pt. Angular emits @Input() pt for the prop, so any pt member 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.

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">; // CrTabs
dt?: 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 (CrHooksonMounted / 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/onUnMount on some targets — a deps-less onUpdate emits nowhere, and Solid/Qwik emit no unmount at all. Both are restored from the artifact by build/build-fix-lifecycle.mjs (Solid onCleanup + createEffect, Qwik a returned cleanup from useVisibleTask$), so all three hooks fire on all six targets. --check fails if any component loses one. CrKeyHints merges its own window listeners into the same onMount/onUnMount as 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.mjs runs a real tsc over the emitted declarations with negative fixtures (a typo’d part, a malformed hook), but the fixtures compile against Svelte’s index.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 hit Cannot find name 'CrPassThrough'. build/build-pkg-types.mjs inlines lib/pt-types.ts into each generated index.d.ts to keep them self-contained. Guarded by tests/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.

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 a setAttributes(el, ptAttrs(this.pt, "root")) call in the class body, but Mitosis HTML-escapes the string literal to &quot;root&quot; — valid in a template, a syntax error in JS. build/build-fix-angular.mjs (in the build:components chain, with a --check mode) unescapes only the setAttributes(...) lines, leaving template HTML untouched. Surfacing the contract library-wide is what first exercised this (only Button had an esbuild-compiled Angular test before).
  • dt custom-properties in a style object 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 binds dt as [ngStyle]='ptStyle(pt, dt, "root")'; NgStyle._setStyle sets RendererStyleFlags2.DashCase whenever the key contains a - (every --cr-* does), and the DOM renderer’s setStyle then applies it via el.style.setProperty(name, value) — the correct path for custom properties. No scoped-<style> workaround is needed. (React uses the style object directly; Vue/Svelte/Solid/Qwik pass custom props through their style bindings; Svelte’s stringifyStyles only kebab-cases uppercase, so --sig-work survives.) Guard: tests/cross-fw-contract.test.mjs asserts the [ngStyle] dt binding on Angular.

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 (consumer onClick runs 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 providing CrContext configured all 80 others and not this one, and vice versa.) It also wires pt.hooks through 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”
CapabilityPortable (one source)Notes
data-part / data-state hooksevery 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 (CrFormCrFormRow did not, so cr-form__row/cr-check/cr-input survived)
pt: merge class, set style, inject attrsclass merged; attrs spread
pt: inject handlersone JSX-cased key (onClick) on every target
pt/dt: typed part namesbeyond PrimeVueCrPassThrough<"root"|"tab">; PrimeVue uses any
pt: handler chaining (both run)beyond PrimeVuevia ptHandler(); PrimeVue’s mergeProps defaults to false
pt.hooks lifecycleonMounted/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 overrideQwik 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.