12 · Theming
Purpose: Specify the Rasd theme system end-to-end — the theme JSON contract, inheritance, modes, the resolution pipeline to CSS variables (web) and StyleSheet (native), the stable parts API, registry replacement, fonts, RTL, accessibility rules, the rasd theme check linter, runtime switching, builder theming, migration and testing.
Audience: Engineers building @rasd/themes, @rasd/react, @rasd/native, @rasd/builder and @rasd/cli; developers at adopting organisations who need to brand forms without forking the library.
TL;DR
- A theme is data: one JSON document (
schema/rasd-theme.schema.json,$id: https://schemas.rasd.dev/theme/v1.json) withid,extends,mode,tokens.{color,typography,spacing,radius,elevation,motion,control},modes,components,assets,ext(00 §10). - Four bundled themes ship in
@rasd/themes:rasd-light(root),rasd-dark,rasd-high-contrast,rasd-field(big-touch outdoor). All four pass WCAG 2.2 AA contrast;rasd theme checkrefuses themes that do not. - Themes inherit with
extends(deep-merge, aliases{group.key}resolved after merge). Modes — light/dark, high contrast, density, reduced motion — are partial overlays applied in a fixed order from OS signals unless the host forces them. - Web: resolved tokens become
--rasd-<group>-<key>custom properties on the provider root (.rasd-root[data-theme][data-color-scheme][data-contrast][data-density][dir]), never:root; default CSS lives in@layer rasdso unlayered host CSS wins. Native:useTheme()returns frozen tokens and a memoisedStyleSheetfactory; no styling-engine dependency. - Every default component publishes stable parts:
class="rasd-<Component>__<part>"plusdata-scope="rasd" data-part="<part>", state asdata-*; hosts override per part withclassNames/styles/render, replace whole components through the registry, or gounstyledand bring Tailwind or their own CSS. - Fonts are theme assets that work offline (self-hosted WOFF2 precached on web; static TTF/OTF on native); the default OFL Arabic family (Noto Sans Arabic) ships in
@rasd/themes; RTL usesfontFamilyRtl,lineHeightRtland logical properties only. - Interchange with W3C DTCG 2025.10 via
fromDtcg()/toDtcg(); visual regression runs per bundled theme × mode × direction × font scale in CI.
1. Theme model and the JSON contract
1.1 Top-level shape
The spine defines the shape (00 §10); this document fixes every key and its type. Numbers are CSS px on web and dp on native (font sizes are emitted as rem on web), durations are milliseconds, colours are #hex/rgb()/hsl()/transparent (no oklch/lab — React Native cannot parse them). Any token leaf may instead be a DTCG-style alias string such as "{color.primary}".
{
"$schema": "https://schemas.rasd.dev/theme/v1.json",
"rasdTheme": "1.0", // theme spec version MAJOR.MINOR (mirrors RFD `rasd`)
"id": "acme-field", "name": "ACME Field", "version": "1.0.0",
"extends": "rasd-field", // parent theme id; omitted ⇒ rasd-light; null ⇒ root theme (complete tokens required)
"mode": "light", // colour scheme the base tokens are designed for
"direction": "auto", // "auto" | "ltr" | "rtl"
"density": "comfortable", // default density when the host does not force one
"tokens": { "color": {}, "typography": {}, "spacing": {}, "radius": {}, "elevation": {}, "motion": {}, "control": {} },
"modes": { "dark": { "tokens": {} }, "highContrast": {}, "compact": {}, "spacious": {}, "reducedMotion": {} },
"components": { "Button": { "radius": "full" }, "SelectOne": { "variant": "buttons" } },
"assets": { "fonts": {}, "logo": {} },
"ext": {}
}
Two document shapes exist. The full theme (the root of the schema) is what @rasd/themes exports, what createTheme(partial, { extends }) returns and what <RasdProvider theme> accepts (RasdTheme | string — an object or a bundled id, 17 · API reference §11); the partial argument of createTheme is DeepPartial<RasdTheme> & { id: string } — it MUST carry id, MAY carry name/version/extends, and every other key is optional. The partial overlay (#/$defs/ThemePartial) is what RFD settings.theme.overrides carries (04 · Form schema): identity keys (id, name, version, extends) are forbidden there because an overlay never has an identity of its own — it is merged onto the theme the host resolved. Unknown properties are ignored and preserved within a MAJOR (rasd theme check warns).
1.2 Token groups, keys, types, defaults
The table lists every token, its type and its value in the four bundled themes. A cell "=" means "inherited from rasd-light". Components read only these tokens (never raw brand palettes), which is what lets a brand accent that fails 4.5:1 as text still be used for buttons and progress bars while text/border tokens stay compliant (research/07 §8).
| Group.key | Type | rasd-light | rasd-dark | rasd-high-contrast | rasd-field |
|---|---|---|---|---|---|
color.primary | Color | #0A58CA | #7CACF8 | #003A8C | = |
color.onPrimary | Color | #FFFFFF | #0B1F3A | #FFFFFF | = |
color.surface | Color | #FFFFFF | #0F172A | #FFFFFF | = |
color.onSurface | Color | #111827 | #F1F5F9 | #000000 | = |
color.surfaceVariant | Color | #F3F4F6 | #1E293B | #FFFFFF | = |
color.onSurfaceVariant | Color | #374151 | #CBD5E1 | #000000 | = |
color.outline | Color | #6B7280 | #94A3B8 | #000000 | = |
color.error | Color | #B3261E | #F87171 | #A4000F | = |
color.success | Color | #0F7B3F | #4ADE80 | #005A2B | = |
color.warning | Color | #9A5B00 | #FBBF24 | #7A4A00 | = |
color.info | Color | {color.primary} | #93C5FD | {color.primary} | = |
color.focus | Color | {color.primary} | #93C5FD | #005FCC | = |
color.scrim | Color | rgba(17,24,39,0.5) | rgba(0,0,0,0.65) | rgba(0,0,0,0.6) | = |
typography.fontFamily | FontFamily | system-ui, -apple-system, 'Segoe UI', Roboto, 'Noto Sans', sans-serif | = | = | = |
typography.fontFamilyRtl | FontFamily | 'Noto Sans Arabic', system-ui, sans-serif | = | = | = |
typography.fontFamilyMono | FontFamily | ui-monospace, 'Roboto Mono', monospace | = | = | = |
typography.baseSize | Px | 16 | = | = | 18 |
typography.scale | Ratio | 1.125 | = | = | = |
typography.lineHeight | Ratio | 1.5 | = | = | = |
typography.lineHeightRtl | Ratio | 1.7 | = | = | = |
typography.weights.{regular,medium,bold} | FontWeight | 400 / 500 / 700 | = | = | = |
typography.maxFontScale | number | 2.0 | = | = | = |
spacing.unit / .stack / .page | Px | 4 / 16 / 16 | = | = | 4 / 24 / 16 |
radius.{sm,md,lg,full} | Px | 4 / 8 / 12 / 999 | = | = | = |
elevation.sm | Elevation | {y:1, blur:2, color:rgba(17,24,39,.08), elevation:1} | {…, color:rgba(0,0,0,.45)} | none (transparent) | = |
elevation.md | Elevation | {y:2, blur:8, color:rgba(17,24,39,.12), elevation:3} | {…, .55} | none | = |
elevation.lg | Elevation | {y:8, blur:24, color:rgba(17,24,39,.16), elevation:8} | {…, .65} | none | = |
motion.durationMs | Ms | 150 | = | = | = |
motion.easing | string | cubic-bezier(0.2,0.8,0.2,1) | = | = | = |
motion.reduced | enum | auto | = | = | = |
control.minTouch | Px | 48 | = | = | = (48; floor at compact, 56 at spacious) |
control.height | Px | 48 | = | = | = (48; floor at compact, 56 at spacious) |
control.borderWidth | Px | 1 | = | 2 | 2 |
control.focusRingWidth | Px | 2 | = | 3 | 3 |
control.focusRingOffset | Px | 2 | = | = | = |
rasd-light is the only root theme (extends: null) and carries the modes.dark, modes.highContrast, modes.compact (minTouch/height 48 — the spine floor of 00 §10: compact tightens spacing, never touch targets; stack 12), modes.spacious (56, stack 24) and modes.reducedMotion (durationMs 0) overlays, so a plain <RasdProvider theme="rasd-light"> already follows prefers-color-scheme: dark. rasd-dark extends it with mode: "dark" and the dark tokens promoted to the base; rasd-high-contrast promotes the high-contrast overlay to the base (contrast is high even when the OS does not ask) and adds a black/white/#FFD54F dark overlay; rasd-field (the "big-touch outdoor" theme of 00 §3, 48 px controls per 02 · FR-037) keeps control.height/minTouch at 48 but bumps baseSize to 18, borderWidth to 2, focusRingWidth to 3 and spacing.stack to 24, pins modes.compact to 48/48 (its floor — a supervisor's desktop compact never shrinks field controls) and modes.spacious to 56/56 (gloves, sunlight), and sets components.SelectOne.variant: "buttons", PageNav.sticky: true. Themes that need larger comfortable controls extend rasd-field and set control.height/minTouch (e.g. 52 in 21 · Getting started §8).
Type notes: Px 0–4096; Ms 0–5000; Ratio (0, 4]; FontWeight 100–900 step 100; Elevation is an object { x?, y, blur, spread?, color, elevation? } (a raw CSS box-shadow string is legal but web-only — native falls back to no shadow, rasd theme check warns).
1.3 Assets
assets.fonts is keyed by family name exactly as used in typography.fontFamily*; each face is { weight, style?, src: { web?: '<self-hosted .woff2>', native?: '<static .ttf/.otf asset name>' }, unicodeRange?, display? }. assets.logo is { light, dark?, height?, alt? }, rendered by the Form logo part when the host enables it. Assets are references, never inline data (rasd theme check warns above 32 kB per document).
1.4 components overrides
components holds per-component token overrides read by the default components. Keys are component names from §5.2; values are the keys below (unknown keys are ignored with a warning). Element-level appearance in the form always wins over the theme, and per-part classNames/styles win over both.
| Component | Accepted keys | Meaning |
|---|---|---|
Button | radius (sm|md|lg|full|none or px), weight, height | Primary/secondary/ghost buttons incl. PageNav |
FieldWrapper | labelWeight, requiredMarker (asterisk|text|none) | Label row of every question |
TextField, NumberField, DateField, TimeField, DateTimeField, SearchSelect, BarcodeField | height, radius | Input box |
SelectOne | variant (radio|dropdown|chips|buttons|likert), columns (1–6) | Default when the element sets no appearance.variant |
SelectMultiple | variant (checkbox|dropdown|chips|buttons), columns | idem |
Rating | icon (star|number|smiley) | |
Group | variant (section|card|collapsible|field-list), radius | |
Repeat | variant (cards|list), showIndex | |
Note | style (info|warning|success), radius | |
ProgressBar | style (bar|steps|text), position (top|bottom|none) | |
PageNav | sticky, position (bottom|inline) | Sticky nav must never obscure focus (SC 2.4.11) |
ErrorSummary | position (top|inline), sticky |
2. Inheritance: extends, deep-merge, aliases
- Chain resolution.
extendsnames a bundled theme id (rasd-light,rasd-dark,rasd-high-contrast,rasd-field) or a host theme; a host parent is supplied as an object —createTheme(partial, { extends: parentTheme })acceptsstring | RasdTheme(17 · API reference §11) and, when the partial's ownextendsstring matchesparentTheme.id, resolves through it (a mismatch is an error). Omitted ⇒rasd-light;null⇒ root (must satisfy#/$defs/CompleteTokens). Depth ≤ 8; a cycle, an unknown parent id or an id/object mismatch isRasdErrorRASD_THEME_EXTENDS. Resolution is eager:createThemereturns a fully mergedRasdThemewhoseextendsis kept for provenance only, so a resolved theme never needs its parents at runtime (offline-safe, cacheable instorage.kv). - Deep-merge, parent first: objects merge recursively; scalars, arrays and
null-free leaves in the child replace the parent's; a font family entry inassets.fontsreplaces the parent's array for that family wholesale;extmerges per vendor key (child vendor object replaces parent vendor object);modes.<name>overlays are themselves deep-merged across the chain;components.<Name>merges key by key. There is no delete operator — to "unset" a component key, set it to the parent's default explicitly. - Aliases
"{group.key(.sub)}"are resolved after the whole chain and the active mode overlays are merged (so{color.primary}insidemodes.darkrefers to the dark primary). Depth ≤ 4; cycles areRASD_THEME_ALIAS; an alias to a missing path is an error inrasd theme checkand falls back to therasd-lightvalue at runtime with a console warning. - Validation happens after merge: the merged document must validate against the full schema; a partial that only makes sense on top of its parent is legal in isolation.
import { createTheme, rasdField } from '@rasd/themes';
// partial carries the identity (id required); the parent comes from opts.extends (bundled id or RasdTheme object)
export const acmeField = createTheme(
{ id: 'acme-field', name: 'ACME Field', tokens: { color: { primary: '#0B6EFD', focus: '{color.primary}' } }, components: { Button: { radius: 'full' } } },
{ extends: rasdField }, // equivalent: { extends: 'rasd-field' }
);
// a tenant theme that extends the host's own base theme (not bundled): pass the parent object
export const acmeDesert = createTheme({ id: 'acme-desert', extends: 'acme-field', tokens: { color: { primary: '#8A4B08' } } }, { extends: acmeField });
3. Modes as data
Modes are partial overlays under modes and are resolved per provider from OS signals unless the host forces them (<RasdProvider mode={{ colorScheme, contrast, density, reducedMotion }}> or useTheme().setMode()).
| Mode | Overlay key | Web default signal | Native default signal | Root attribute |
|---|---|---|---|---|
| Colour scheme | modes.light / modes.dark (the one equal to theme.mode is never applied) | prefers-color-scheme | useColorScheme() | data-color-scheme="light|dark" |
| Contrast | modes.highContrast | prefers-contrast: more, forced-colors: active | AccessibilityInfo.isHighTextContrastEnabled (Android), isDarkerSystemColorsEnabled (iOS) | data-contrast="normal|high" |
| Density | modes.compact / modes.spacious (base = comfortable) | none — theme density or host | none — theme density or host | data-density="compact|comfortable|spacious" |
| Reduced motion | modes.reducedMotion (default overlay durationMs: 0) | prefers-reduced-motion: reduce | AccessibilityInfo.isReduceMotionEnabled | data-reduced-motion="true|false" |
Overlays apply in the fixed order colour scheme → highContrast → density → reducedMotion, later overlays winning per key. motion.reduced: "always" applies the overlay unconditionally; "never" ignores the OS (allowed, but rasd theme check warns). Under forced-colors: active the web CSS additionally uses system colours (CanvasText, Highlight, ButtonText) for borders, focus rings and checkmarks regardless of tokens. Bold-text (isBoldTextEnabled, iOS) bumps weights.regular to weights.medium on native (07 · Renderer native §12). Signals are subscribed once per provider (matchMedia listeners / AccessibilityInfo events) and changes re-resolve without re-rendering field components (06 · Renderer React §2.2). The root attributes above (data-theme, data-color-scheme, data-contrast, data-density, data-reduced-motion, dir) and the part attributes (data-scope, data-part, data-component, §5.1) are ratified in 00 §10; density has exactly the three values compact | comfortable | spacious.
4. Resolution pipeline
flowchart LR
A["Theme JSON<br/>+ extends chain"] --> B["Deep-merge<br/>parent then child"]
B --> C["Apply mode overlays<br/>scheme, contrast, density, motion"]
C --> D["Resolve aliases<br/>depth up to 4"]
D --> E["Validate + lint<br/>rasd theme check rules"]
E --> F{Platform}
F -->|web| G["--rasd-* custom properties<br/>on .rasd-root (constructed sheet)"]
F -->|native| H["Frozen tokens + StyleSheet factory<br/>via useTheme()"]
G --> I["Default CSS in @layer rasd<br/>reads var(--rasd-...)"]
H --> J["Default RN components<br/>read tokens"]
The resolver is pure and lives in @rasd/themes: resolveTheme(theme, mode?) → ResolvedTokens with mode = { colorScheme?, contrast?, density?, reducedMotion?, direction? } (17 · API reference §11); the platform providers add fontScale to the memo key only. Results are memoised per (theme.id, theme.version, mode) — theme identity, not the form's definitionHash, which plays no role in theming. Budget: ≤ 2 ms for a merged theme with ≤ 200 leaves on a mid-range Android WebView; ≤ 20 kB min+gzip for @rasd/themes including the four bundled themes and the resolver.
4.1 Web: CSS custom properties
Naming is mechanical: variable name = JSON path with - separators, key names verbatim (camelCase preserved): tokens.color.onSurface → --rasd-color-onSurface; tokens.typography.weights.bold → --rasd-typography-weights-bold; tokens.control.minTouch → --rasd-control-minTouch. Values carry units by group: colours verbatim; Px → px except typography.baseSize → rem (16 ⇒ 1rem, so browser zoom and user font settings scale the form); ratios/weights unitless; motion.durationMs → ms; elevation.* → a box-shadow string. Derived variables the resolver adds: --rasd-typography-size-{xs,sm,md,lg,xl,2xl} (baseSize × scale^n, n = −2…3, in rem), --rasd-typography-fontFamily-active and --rasd-typography-lineHeight-active (swap to the Rtl values when dir="rtl"), --rasd-color-focusRing (focus colour with the ring width/offset composed as an outline shorthand). Component overrides emit --rasd-<Component>-<key> (e.g. --rasd-Button-radius: 999px).
Rules (06 · Renderer React §15): variables are set on .rasd-root[data-theme="<id>"][data-color-scheme][data-contrast][data-density][data-reduced-motion] — one rule per active combination, inserted lazily into a shared constructed stylesheet (adoptedStyleSheets) or a nonced <style> when cssNonce is set; never inline style attributes, never :root; @font-face rules for assets.fonts are emitted once per family; the default component CSS (@rasd/react/styles.css, ≤ 12 kB min+gzip) is @layer rasd.reset, rasd.base, rasd.components; and reads only var(--rasd-…). toCss(theme, { selector?: '.rasd-root[data-theme="<id>"]', layer?: 'rasd', modes?: 'all' | mode }) precompiles the same output for build-time use and the <rasd-form> shadow root (11 · PWA & embedding, 17 · API reference §11); modes: 'all' (default) emits one rule per overlay combination, a single mode object emits only that combination. light-dark() and color-scheme are progressive enhancement only — field Android WebViews predate them (research/07 §2).
4.2 Native: useTheme() and StyleSheet
// ThemeView — the return type of useTheme() ([17 · API reference §3](17-api-reference.md))
interface ThemeView {
theme: RasdTheme; // merged, aliases unresolved (what the author wrote)
tokens: ResolvedTokens; // after modes + aliases; numbers unitless (dp)
resolved: { colorScheme: 'light' | 'dark'; contrast: 'normal' | 'high'; density: 'compact' | 'comfortable' | 'spacious'; reducedMotion: boolean };
setMode(partial: Partial<{ colorScheme; contrast; density; reducedMotion }>): void; // undefined per key ⇒ back to OS signal
cssVar?(path: string): string; // web only: cssVar('color.primary') → 'var(--rasd-color-primary)' ([00 §11](00-decisions-and-conventions.md), signature in [17 §3.3](17-api-reference.md))
styles?<T>(factory: (t: ResolvedTokens) => T): T; // native only: memoised StyleSheet.create per (theme, mode) key
}
On native the factory memoises per (theme.id, theme.version, colorScheme, contrast, density, reducedMotion, fontScale, direction) (07 · Renderer native §12); elevation maps to boxShadow (New Architecture; Rasd requires RN ≥ 0.81) with the elevation integer as the Android fallback; CSS generic family names in typography.fontFamily (system-ui, -apple-system, sans-serif) map to undefined (platform default) and only the first concrete family in the list is used (07 · Renderer native §12). Default components read theme.components; per-part styles win.
5. Parts, states and per-part overrides
5.1 Contract
Every default web component renders each of its parts with class="rasd-<Component>__<part>", data-scope="rasd", data-component="<Component>", data-part="<part>"; the <rasd-form> element additionally exposes part="root" and part="button-primary" for ::part(). State is data attributes: data-invalid, data-required, data-readonly, data-relevant, data-touched, data-checked, data-selected, data-disabled, data-focus-visible, data-busy, data-severity="error|warning|info", data-variant, data-status (attachments: pending|uploading|uploaded|failed), data-orientation. All of this is public API covered by semver: renaming a part or state attribute is a MAJOR change.
Overrides per part on <RasdProvider>, <FormRenderer> and each default component:
type PartValue<T, S> = T | ((state: S) => T);
interface PartProps<P extends string, S> {
classNames?: Partial<Record<P, PartValue<string, S>>>; // web
styles?: Partial<Record<P, PartValue<CSSProperties | StyleProp<ViewStyle | TextStyle>, S>>>; // web + native
render?: Partial<Record<P, (props: PartRenderProps, state: S) => ReactNode>>; // web + native; must spread props (ids, aria/accessibility)
}
On a default component the keys are its own parts (<TextField classNames={{ input: '…' }} />); on <RasdProvider> and <FormRenderer> the same props are keyed by component, then part (classNames={{ TextField: { input: '…' }, FieldWrapper: { label: '…' } }}), because part names such as root and input repeat across components (21 · Getting started §8). Precedence, lowest to highest: default CSS/StyleSheet → theme components → element appearance → provider classNames/styles → form classNames/styles → component classNames/styles/render. Values or functions of state; function results are memoised per state tuple.
5.2 Published parts list
Component names are the default component names of the registry (06 · Renderer React §5, 17 · API reference §3); the same names are the keys of theme components (§1.4), of provider/form classNames/styles/render (§5.1) and of createRegistry({ components }) (§6). Form is the root rendered by <FormRenderer> (class rasd-Form__root); the provider root is .rasd-root (§4.1), which is not a part.
| Component | Parts |
|---|---|
Form | root, header, logo, title, body, footer, watermark (slot where Watermark mounts) |
Page | root, title, description, elements |
PageNav | root, prev, next, finalize, saveDraft, indicator |
ProgressBar | root, track, fill, label, step |
ErrorSummary | root, title, list, item, link |
FieldWrapper | root, labelRow, label, requiredMarker, hint, guidanceToggle, guidance, media, control, message (data-severity) |
Group | root, header, title, description, toggle, content |
Repeat | root, header, title, count, list, add, empty |
RepeatItem | root, header, title, actions, moveUp, moveDown, remove, content |
Note | root, icon, content, toggle |
Button | root, icon, label, spinner |
Dialog | backdrop, root, title, body, actions |
Watermark | root, text (soft limited license state — 15 · Licensing §5.3; restylable, never removable) |
LicenseBanner | root, message, action, dismiss |
SummaryRow | root, label, value, edit (review/summary mode) |
TextField | root, input, prefix, suffix, counter, clear |
NumberField | root, input, unit, increment, decrement |
DateField / TimeField / DateTimeField | root, input, trigger, picker, pickerHeader, pickerGrid, pickerCell, secondaryCalendar |
SelectOne / SelectMultiple | root, list, option, optionInput, optionLabel, optionMedia, otherInput, trigger, menu, menuItem, empty, selectedCount |
SearchSelect | root, combobox, input, clear, listbox, option, optionLabel, empty, loading, selectedCount (auto-selected for large or dataset-backed lists, 06 §11) |
RankList | root, list, item, handle, index, moveUp, moveDown |
Rating | root, item, icon, label |
RangeSlider | root, track, fill, thumb, value, min, max, tick |
Checkbox | root, input, box, label |
ConsentBlock | root, text, version, control, signature, withdraw, timestamp |
MatrixGrid | root, table, headerRow, headerCell, row, rowHeader, cell, cellControl |
GeoPointField | root, capture, status, accuracy, coordinates, map, manual |
GeoPathField (geotrace / geoshape) | root, map, start, stop, points, status |
ImageField | root, capture, gallery, preview, thumbnail, remove, retake, annotate, progress |
AudioField / VideoField | root, record, stop, play, timer, waveform, preview, remove, progress |
FileField | root, picker, list, item, name, size, remove, progress |
BarcodeField | root, scan, viewfinder, result, manualInput |
SignatureField | root, canvas, clear, done, preview |
UnknownElement | root, message |
Icon | root (+ data-part="icon-directional" on mirrored icons) |
Native uses the same names for styles/render keys and sets testID="rasd-<Component>__<part>" in dev builds; native-only layout components (FormScroll, PageTransition, Sheet, DatePicker, Slider — 07 · Renderer native §4) publish their parts in that document under the same naming rule. A parts manifest generated at build time from the components themselves is the machine-readable source for this table and is what the snapshot test in §13 guards.
6. Replacing components, unstyled mode, bring-your-own CSS
Registry (06 · Renderer React §5): createRegistry({ components: { TextField: MyTextField, Button: MyButton }, renderers: { select_one: MySelect }, testers: [{ rank: 10, test: (el) => el.type === 'select_one' && choiceCount(el) > 20, component: SearchableSheet }] }). components swaps a default component everywhere (keys are the component names of §5.2 — field components and layout components alike); renderers swaps the component for one element type (keys are element type values, snake_case, or x:*); testers add conditional overrides (highest rank wins). Themes are not registry entries: a theme reaches the renderer through <RasdProvider theme> (§10). Custom element types (x:*) registered with defineElement() receive the same classNames/styles/render props and SHOULD publish their own parts under rasd-<Component>__<part> so hosts can theme them the same way. Replacements keep engine behaviour and MUST honour the accessible-props contract: render through FieldWrapper or spread field.inputProps (id, aria-labelledby, aria-describedby, aria-invalid, aria-required, dir, inputMode, autoComplete, onBlur; native: nativeID, accessibilityLabelledBy, accessibilityState). @rasd/testing ships assertAccessibleContract(Component) (00 §11, signature in 17 §14); the conformance suite fails a replacement that drops any of these.
Unstyled. <RasdProvider unstyled> (or per form) means the default CSS is not required and no visual is assumed: semantic markup, classes, data-part/state attributes and ARIA remain. Two supported patterns:
// Tailwind via classNames-as-functions (keyed by component, then part — §5.1)
<RasdProvider unstyled storage={storage} theme="rasd-field">
<FormRenderer definition={def} classNames={{
TextField: { input: ({ invalid }) => cn('min-h-12 w-full rounded-lg border px-3', invalid ? 'border-red-600' : 'border-slate-400') },
NumberField: { input: 'min-h-12 w-full rounded-lg border px-3 tabular-nums' },
FieldWrapper: { label: 'text-base font-medium', message: ({ severity }) => (severity === 'error' ? 'text-red-700' : 'text-amber-700') },
Button: { root: ({ variant }) => cn('min-h-12 rounded-full px-5 font-semibold', variant === 'primary' && 'bg-blue-700 text-white') },
}} />
</RasdProvider>
Tailwind users who keep the default CSS can instead target the stable classes with @apply (.rasd-TextField__input { @apply rounded-md border px-3; } — 21 · Getting started §8); either way min-h-*, never h-*, so font scaling can grow the control (§7).
/* Pure CSS (no JSX access), any framework */
[data-scope="rasd"][data-part="input"] { min-height: 48px; border-radius: 8px; }
[data-scope="rasd"][data-component="SelectOne"][data-part="option"][data-checked] { background: #E8F0FE; }
Hosts keeping the default CSS override it without !important because unlayered rules beat @layer rasd.
7. Typography and offline fonts
- Scale. Six derived sizes from
baseSize × scale^n; body =md; labelsmdweightmedium; page titlesxlbold; hintssm; never below 12 px after scaling. - Arabic and per-script fallback.
fontFamilyRtlandlineHeightRtl(1.7 default — Arabic needs 1.6–1.8) apply when the resolved direction is RTL, and per text run when a label's first strong character is Arabic-script; noletter-spacingon Arabic; subsets must keepmark/mkmk/rligand the bidi controls (U+061C,U+200C–200F,U+2066–2069) (13 · i18n). Agency Latin faces (Lato, Proxima Nova, Univers) have no Arabic glyphs, sofontFamilyRtlis mandatory whenever an RTL locale is declared;rasd theme checkwarns otherwise. - Web embedding.
assets.fonts[family][].src.webis a self-hosted WOFF2 (relative paths resolve againstdocument.baseURI);toCss()emits@font-facewithfont-display: swapandunicode-range;@rasd/pwaprecaches them inrasd-fonts-v1(CacheFirst, 365 d) — never a third-party CDN,font-src 'self'under CSP (11 · PWA). Default Arabic subset ≤ 120 kB. - Native embedding. Static TTF/OTF only — variable fonts are not cross-platform on Android and are rejected by
rasd theme check(research/07 §8). Production:expo-fontconfig plugin (fonts available at startup, weight-aware family definitions on Android); development / Expo Go:useFonts(). The family key inassets.fontsmust equal the family name the platform registers;@rasd/themesexportsfonts.notoSansArabicandfonts.notoNaskhArabic(FontAsset= WOFF2 URLs for web + TTF asset paths for native, 17 · API reference §11) and the helperfontAssets(theme)listing{ family, weight, file }for the host'sexpo-fontplugin config; the default Noto Sans Arabic faces (400/500/700, static) ship under@rasd/themes/fonts/. - Font scaling.
allowFontScalingstays on; everyText/TextInputsetsmaxFontSizeMultiplier = typography.maxFontScale(default 2.0, < 1.2 fails the linter); web usesremandmin-height, never fixed heights; layouts must survive 200 % zoom / 320 px reflow (SC 1.4.4, 1.4.10).
8. RTL, logical properties, density
- Direction:
theme.direction(autodefault) →settings.localeMeta[locale].dir→ built-in RTL list →ltr; applied asdiron.rasd-rootand Yogadirectionon the native root — neverI18nManager.forceRTL(13 · i18n §8). - All Rasd CSS uses logical properties (
margin-inline-start,padding-inline,inset-inline-end,text-align: start); a stylelint rule bans physicalleft/rightin@layer rasd. Native styles usestart/end,marginStart/End,insetInlineStart/End. Directional icons carrydata-part="icon-directional"and are mirrored (scaleX(-1)); checkmarks, clocks, media controls, pins are not. Progress and sliders fill from inline-start. - Density is a mode (§3), not a separate theme:
compactfor desktop review screens,comfortabledefault,spaciousfor gloves/outdoor. Controls render atcontrol.heightasmin-heightso text scaling grows them; hit targets never drop belowcontrol.minTouch, which the resolver clamps up to 48 px in every density (00 §10) —compactbuys its density from spacing, not from touch targets, so the WCAG 24 px hard floor is never approached. Control heights are 48 / 48 / 56 acrosscompact/comfortable/spacious.
9. Accessibility: contrast requirements and rasd theme check
Every bundled theme and every theme in examples/ must pass; the linter runs in the CLI (rasd theme check, @rasd/cli), in the builder theme editor and in CI, all through the same two pure functions exported by @rasd/themes — validateTheme(theme) → { ok, errors, warnings } (schema + structural rules below) and checkContrast(theme) → { pairs: [{ fg, bg, ratio, required, ok }], ok } (17 · API reference §11) — so a host can run the check at theme-upload time in its own tenant admin without shelling out. Contrast is WCAG 2.x relative luminance in sRGB (alpha colours are composited over their pair first); APCA is not used because WCAG 2.2 AA is the procurement bar (research/07 §7).
| Pair (foreground / background) | Minimum | Rationale |
|---|---|---|
onSurface / surface, onSurfaceVariant / surfaceVariant, onPrimary / primary | 4.5:1 | SC 1.4.3 body text |
error, warning, success, info / surface | 4.5:1 | rendered as message text |
primary / surface | 3:1 (warn < 4.5:1: "do not use as link text") | button fill, progress fill, SC 1.4.11 |
outline / surface, focus / surface (plus focus / primary and focus / outline when focusRingOffset < 2) | 3:1 | input borders, focus ring against adjacent colours, SC 1.4.11 |
every pair above, per overlay (light, dark, highContrast) | as above | modes are lint targets too |
Other rules: control.minTouch < 24 error (WCAG 2.2 SC 2.5.8 hard floor), < 48 warning (the spine's resolve-time floor — 00 §10; a theme authoring less is silently clamped up, so the warning tells the author their value is being ignored); focusRingWidth < 2 error; borderWidth < 1 error; maxFontScale < 1.2 error; motion.reduced: "never" warning; RTL locale declared without an Arabic-capable family in fontFamilyRtl warning; variable font asset error; raw box-shadow string warning; alias depth/cycle error; unknown property warning; document > 32 kB warning.
$ rasd theme check examples/theme-agency-blue.json --strict --modes light,dark,highContrast
✔ schema rasd-theme v1 · extends rasd-field → rasd-light · 0 unknown properties
✔ contrast 10 pairs × 3 modes · lowest 5.3:1 (light · success / surface)
✔ targets control.minTouch 48 (compact 48) · focusRingWidth 3 · maxFontScale 2
✔ fonts 2 families · 6 static faces · Arabic-capable family present for rtl
0 errors · 0 warnings
$ rasd theme check tenant/acme.json --json
✖ RASD_THEME_CONTRAST dark · color.onSurfaceVariant on surfaceVariant is 4.1:1 (min 4.5:1) → try #D3DCE6
Exit codes: 0 pass, 1 errors, 2 warnings under --strict; --json emits { ok, errors[], warnings[] }; --fix-suggest proposes the nearest shade that passes (e.g. a #2A93FC brand accent → #1F6EBC for text). Errors are RasdError codes RASD_THEME_INVALID, RASD_THEME_CONTRAST, RASD_THEME_ALIAS, RASD_THEME_EXTENDS.
10. Runtime: loading, switching, the form's theme hint
sequenceDiagram
participant Host
participant KV as storage.kv
participant Provider as RasdProvider
participant Resolver as themes resolver
Host->>KV: get "theme:agency-blue" (tenant theme cached by the host)
KV-->>Host: theme JSON, or miss (host falls back to a bundled id)
Host->>Provider: theme = RasdTheme object or bundled id
Provider->>Resolver: resolveTheme(theme, mode from OS signals)
Resolver-->>Provider: ResolvedTokens (memoised)
Provider->>Provider: write --rasd-* rule or freeze tokens, set data-* attributes
Host->>Provider: setMode(colorScheme dark) or a new theme prop
Provider->>Resolver: resolveTheme(theme, new mode)
Provider->>Provider: swap rule in under 16 ms, no field re-render
- Sources.
themeisRasdTheme | string— a theme object (usually the return value ofcreateTheme()) or a bundled id (rasd-light,rasd-dark,rasd-high-contrast,rasd-field) (06 · Renderer React §2.1, 17 · API reference §3). Tenant themes fetched from a host API are validated withvalidateTheme()and cached by the host instorage.kvundertheme:<id>(as already-mergedRasdThemeJSON, §2) so offline launches never wait on the network; when the cached copy is missing the host passes the bundled id it extends (orrasd-light). An unknown string id given to the provider renders withrasd-lightand one console warning (never a blank form). Theme loading never blocks first paint. - Form hint.
settings.theme = { themeId, overrides }is a hint (04 · Form schema): the providerthemeprop wins when set; otherwise a knownthemeId(bundled id, or a host theme the host resolves — e.g. from itsstorage.kvcache) is used withoverrides(validated asThemePartial) merged on top; the builder preview honours the hint. - Switching. Changing the provider
theme,mode, direction or an OS signal re-resolves and swaps the variables/StyleSheet; tokens live inThemeContextonly, so form fields do not re-render. Multiple providers with different themes coexist on one page. Budget: ≤ 16 ms on web, ≤ 1 frame on native for a 200-element form.
11. Theming the builder
The builder consumes the same theme JSON via useTheme() for its chrome, adds derived --rasd-builder-* tokens (canvasBg, panelBg, selection, dropIndicator, problemError/Warning) on .rasd-builder[data-theme][data-color-scheme][data-contrast][data-density][dir], ships its CSS in @layer rasd.builder with parts rasd-Builder__<part> and data-scope="rasd-builder", and renders the preview with the form's own theme hint inside a nested provider with CSS containment so the two never leak (08 · Builder). The builder's Theme tab (phase 2) edits and exports this same JSON, imports DTCG files, runs rasd theme check live and previews light/dark/high-contrast/density/RTL/font-scale 2.0 side by side.
12. Interchange and migrating themes across versions
- DTCG 2025.10 (research/07 §1):
fromDtcg(tokens, { theme: 'dark', contrast: 'high' })maps$typecolour/dimension/fontFamily/fontWeight/duration/shadow/typography groups to Rasd tokens (Display-P3/OKLCH colours are converted to sRGB hex with a warning;remdimensions multiplied by 16);toDtcg(theme)emits a.tokens.jsonwith$extensions["dev.rasd"]carryingcomponents,modesandassetsso the round trip is lossless. Style Dictionary / Terrazzo build the bundled defaults; customers never need a build to theme. - Theme spec versioning.
rasdTheme: "MAJOR.MINOR"; consumers ignore-and-preserve unknown properties within a MAJOR; MAJOR bumps shipmigrateTheme(theme, to)in@rasd/themesandrasd theme check --migrate; deprecated tokens keep emitting their old CSS variable as an alias for ≥ 12 months (two minors minimum) with a dev warning; the theme document's ownversion(semver) is for cache-busting and tenant rollout. Any theme version renders any form version (03 · Architecture §13).
13. Testing and acceptance criteria
Test matrix (Storybook 10 stories themes/all-elements + Playwright screenshots; RNTL snapshots + Maestro on Android API 24 low-end and iOS 17): 4 bundled themes + theme-agency-blue × light/dark × ltr/rtl × comfortable/compact × font scale 1.0/1.3/2.0 — pixel snapshots for the 1.0 scale subset (threshold 0.1 % diff), structural snapshots and axe (vitest-axe, zero violations) for the rest; rasd theme check --strict on every bundled and example theme; contract tests for registry replacements; a "two providers, two themes" page test; unstyled-mode test with a Tailwind stylesheet.
- Schema
schema/rasd-theme.schema.jsonvalidates all bundled themes,examples/theme-agency-blue.json, and rejects a theme withextends: nulland incomplete tokens. -
createTheme(partial, { extends: 'rasd-field' })deep-merges per §2 (component keys, per-family font replacement, per-vendorext) and resolves aliases after mode overlays; cycles raiseRASD_THEME_ALIAS/RASD_THEME_EXTENDS. - Web emits
--rasd-<group>-<key>and derived variables on.rasd-root[data-theme][data-color-scheme][data-contrast][data-density][data-reduced-motion][dir], never on:root; an unlayered host rule.rasd-TextField__input { border-radius: 0 }wins without!important. -
prefers-color-scheme: dark,prefers-contrast: more,prefers-reduced-motionand the nativeAccessibilityInfosignals select the matching overlays;modeprop andsetMode()override them;motion.durationMsis0under reduced motion. - Every component in §5.2 renders every listed part with class,
data-scope,data-component,data-partand documented state attributes; renaming is caught by a snapshot test of the parts manifest. -
classNames/styles/renderper part accept values and functions of state with the precedence of §5.1;assertAccessibleContractfails aTextFieldreplacement that dropsaria-describedby. -
unstyledrenders semantic markup with data-parts and passes axe with a host Tailwind stylesheet. - Airplane-mode reload renders Arabic labels with the bundled Noto Sans Arabic on web (from
rasd-fonts-v1) and native (embedded TTF); Arabic runs uselineHeightRtl. -
rasd theme checkpasses all four bundled themes and the example; fails a theme whoseonSurface/surfaceis 4.4:1; warns onprimary3.2:1 used as text; errors onminTouch: 20and a variable font. - Theme swap on a 200-element form completes in ≤ 16 ms (web) and re-renders zero field components (React Profiler assertion).
-
toDtcg(rasdLight)→fromDtcg()round-trips to a deep-equal theme. - Bundle:
@rasd/themes≤ 20 kB min+gzip;@rasd/react/styles.css≤ 12 kB. - Provider/form
classNames={{ TextField: { input } }}(component-keyed) and component<TextField classNames={{ input }}>(part-keyed) both apply; a flatclassNames={{ input }}on<FormRenderer>is a TypeScript error and a dev-mode warning. -
rasd-fieldrenders 48 px controls atcomfortable, 48 px atcompact(floor), 56 px atspacious;rasd-lightrenders 48 / 48 / 56 (Playwright bounding boxes, RN layout snapshot). - Injection: a theme whose
color.primaryis"red; } .rasd-root { display:none"and whosetypography.fontFamilycontains</style>is rejected byvalidateTheme()and, if forced past validation, is serialised as an inert custom-property value — the constructed stylesheet contains no new rule and the form still renders (§14). -
createTheme(tenantJson, { extends: parentObject })with a mismatchingtenantJson.extendsid throwsRASD_THEME_EXTENDS; an unknown string id passed to<RasdProvider theme>rendersrasd-lightwith exactly one console warning.
14. Failure modes, security and performance
Themes are host- or tenant-supplied input that ends up inside a stylesheet and inside StyleSheet.create; treat them like any other untrusted document (16 · Security & data protection).
| Situation | Behaviour | Surfaced as |
|---|---|---|
Theme fails schema validation (createTheme, provider theme object) | Theme rejected, provider keeps the previous theme (or rasd-light on first mount); the form always renders | RasdError RASD_THEME_INVALID via createTheme (throws) / provider onError (never thrown in render) |
Unknown extends id, chain > 8, cycle, id/object mismatch | Rejected at createTheme | RASD_THEME_EXTENDS |
| Alias to a missing path or alias cycle | Linter error; at runtime the rasd-light value is used for that leaf | RASD_THEME_ALIAS (lint) / one console warning (runtime) |
| Contrast below the §9 minimums | Linter error (CLI exit 1, builder editor blocks "Publish theme"); the runtime does not refuse to render — a live device must never lose a form over a colour | RASD_THEME_CONTRAST (lint only) |
| Font asset missing offline (SW cache miss / native asset not embedded) | Fallback family in fontFamily* renders (font-display: swap; native platform default); layout must not shift more than one line-height because sizes are rem/dp, not glyph-dependent | dev-mode console warning; rasd theme check cannot see it, so the airplane-mode test in §13 does |
Raw box-shadow string on native | No shadow rendered | lint warning |
Overlay yields control.minTouch < 48 after merge | Resolver clamps up to 48 and warns (00 §10; the WCAG 2.2 SC 2.5.8 24 px hard floor is therefore enforced at resolve time too, not only at lint time) | console warning; lint error below 24 |
| Injection through string tokens | Every emitted string is validated by the schema patterns first (Color grammar, FontFamily ≤ 300 chars, easing grammar, alias grammar) and then serialised as a CSS custom-property value through a serializer that rejects ;, {, }, <, >, \ and control characters — a hostile value can never open a new declaration, rule or </style>; assets.* paths are emitted only into @font-face src: url() / <img src> after URL parsing (javascript:/data: rejected; the host's CSP font-src/img-src remains the enforcement point); native never evaluates strings | RASD_THEME_INVALID |
CSP without 'unsafe-inline' | Variables go into a constructed stylesheet (adoptedStyleSheets) or a <style nonce> when cssNonce is set; font-src 'self'; no inline style attributes; no eval in the resolver | — |
| Two providers on one page with different themes | Each .rasd-root scopes its own variables; no :root writes; portals inherit through portalContainer | — |
| Theme swap on a low-end device | Resolver memoised per (theme.id, theme.version, mode) (§4); ≤ 2 ms resolve, ≤ 16 ms swap; zero field re-renders because tokens live in ThemeContext only (06 §2.2) | React Profiler assertion in CI |
| Tenant theme grows past 32 kB / fonts embedded inline | Lint warning; storage.kv still stores it, but the builder editor refuses inline data URIs for fonts and logos | lint warning |
Privacy: a theme carries no PII by design (ext is host payload and is neither logged nor synced by Rasd); the renderer never fetches a theme, a font or a logo from a Rasd-controlled host, so branding never leaks device activity (00 §2 P6).
Open questions
rasd-fieldcontrol size is fixed here at 48 px comfortable / 48 px compact floor / 56 px spacious, following 00 §10, 01 · Vision and 02 · Requirements FR-037; 13 · i18n §10 and §11 now agrees (48, with 56 only atspacious). Remaining question: shouldrasd-fielddefault todensity: "spacious"rather thancomfortable?- Should the provider grow a
themesmap (id →RasdTheme) so a JSON theme canextendsa host theme by id at runtime, or iscreateTheme(partial, { extends: parentObject })(§2) sufficient? - Where do tenant themes live server-side — a
GET /v1/themes/{id}RSP endpoint (not in 10 · Sync protocol v1) or purely host-managed? - Should the linter adopt APCA as an advisory metric alongside WCAG 2.x ratios for dark themes?
- Is a
logoslot in theFormheader enough, or do agencies need a full header/footer template with clear-space rules?
Related documents
00 · Decisions & conventions · 01 · Vision & scope · 02 · Requirements · 03 · Architecture · 04 · Form schema spec · 06 · Renderer React · 07 · Renderer native · 08 · Builder · 11 · PWA & embedding · 13 · i18n, RTL & accessibility · 15 · Licensing & billing · 16 · Security & data protection · 17 · API reference · 18 · Engineering practices · 19 · Roadmap · 21 · Getting started · Schema: rasd-theme.schema.json · Example: theme-agency-blue.json · Research: 07 · Theming & design tokens, 02 · Form-builder libraries, 13 · i18n runtime