إنتقل إلى المحتوى الرئيسي

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) with id, 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 check refuses 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 rasd so unlayered host CSS wins. Native: useTheme() returns frozen tokens and a memoised StyleSheet factory; no styling-engine dependency.
  • Every default component publishes stable parts: class="rasd-<Component>__<part>" plus data-scope="rasd" data-part="<part>", state as data-*; hosts override per part with classNames / styles / render, replace whole components through the registry, or go unstyled and 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 uses fontFamilyRtl, lineHeightRtl and 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.keyTyperasd-lightrasd-darkrasd-high-contrastrasd-field
color.primaryColor#0A58CA#7CACF8#003A8C=
color.onPrimaryColor#FFFFFF#0B1F3A#FFFFFF=
color.surfaceColor#FFFFFF#0F172A#FFFFFF=
color.onSurfaceColor#111827#F1F5F9#000000=
color.surfaceVariantColor#F3F4F6#1E293B#FFFFFF=
color.onSurfaceVariantColor#374151#CBD5E1#000000=
color.outlineColor#6B7280#94A3B8#000000=
color.errorColor#B3261E#F87171#A4000F=
color.successColor#0F7B3F#4ADE80#005A2B=
color.warningColor#9A5B00#FBBF24#7A4A00=
color.infoColor{color.primary}#93C5FD{color.primary}=
color.focusColor{color.primary}#93C5FD#005FCC=
color.scrimColorrgba(17,24,39,0.5)rgba(0,0,0,0.65)rgba(0,0,0,0.6)=
typography.fontFamilyFontFamilysystem-ui, -apple-system, 'Segoe UI', Roboto, 'Noto Sans', sans-serif===
typography.fontFamilyRtlFontFamily'Noto Sans Arabic', system-ui, sans-serif===
typography.fontFamilyMonoFontFamilyui-monospace, 'Roboto Mono', monospace===
typography.baseSizePx16==18
typography.scaleRatio1.125===
typography.lineHeightRatio1.5===
typography.lineHeightRtlRatio1.7===
typography.weights.{regular,medium,bold}FontWeight400 / 500 / 700===
typography.maxFontScalenumber2.0===
spacing.unit / .stack / .pagePx4 / 16 / 16==4 / 24 / 16
radius.{sm,md,lg,full}Px4 / 8 / 12 / 999===
elevation.smElevation{y:1, blur:2, color:rgba(17,24,39,.08), elevation:1}{…, color:rgba(0,0,0,.45)}none (transparent)=
elevation.mdElevation{y:2, blur:8, color:rgba(17,24,39,.12), elevation:3}{…, .55}none=
elevation.lgElevation{y:8, blur:24, color:rgba(17,24,39,.16), elevation:8}{…, .65}none=
motion.durationMsMs150===
motion.easingstringcubic-bezier(0.2,0.8,0.2,1)===
motion.reducedenumauto===
control.minTouchPx48=== (48; floor at compact, 56 at spacious)
control.heightPx48=== (48; floor at compact, 56 at spacious)
control.borderWidthPx1=22
control.focusRingWidthPx2=33
control.focusRingOffsetPx2===

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.

ComponentAccepted keysMeaning
Buttonradius (sm|md|lg|full|none or px), weight, heightPrimary/secondary/ghost buttons incl. PageNav
FieldWrapperlabelWeight, requiredMarker (asterisk|text|none)Label row of every question
TextField, NumberField, DateField, TimeField, DateTimeField, SearchSelect, BarcodeFieldheight, radiusInput box
SelectOnevariant (radio|dropdown|chips|buttons|likert), columns (1–6)Default when the element sets no appearance.variant
SelectMultiplevariant (checkbox|dropdown|chips|buttons), columnsidem
Ratingicon (star|number|smiley)
Groupvariant (section|card|collapsible|field-list), radius
Repeatvariant (cards|list), showIndex
Notestyle (info|warning|success), radius
ProgressBarstyle (bar|steps|text), position (top|bottom|none)
PageNavsticky, position (bottom|inline)Sticky nav must never obscure focus (SC 2.4.11)
ErrorSummaryposition (top|inline), sticky

2. Inheritance: extends, deep-merge, aliases

  1. Chain resolution. extends names 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 }) accepts string | RasdTheme (17 · API reference §11) and, when the partial's own extends string matches parentTheme.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 is RasdError RASD_THEME_EXTENDS. Resolution is eager: createTheme returns a fully merged RasdTheme whose extends is kept for provenance only, so a resolved theme never needs its parents at runtime (offline-safe, cacheable in storage.kv).
  2. Deep-merge, parent first: objects merge recursively; scalars, arrays and null-free leaves in the child replace the parent's; a font family entry in assets.fonts replaces the parent's array for that family wholesale; ext merges 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.
  3. Aliases "{group.key(.sub)}" are resolved after the whole chain and the active mode overlays are merged (so {color.primary} inside modes.dark refers to the dark primary). Depth ≤ 4; cycles are RASD_THEME_ALIAS; an alias to a missing path is an error in rasd theme check and falls back to the rasd-light value at runtime with a console warning.
  4. 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()).

ModeOverlay keyWeb default signalNative default signalRoot attribute
Colour schememodes.light / modes.dark (the one equal to theme.mode is never applied)prefers-color-schemeuseColorScheme()data-color-scheme="light|dark"
Contrastmodes.highContrastprefers-contrast: more, forced-colors: activeAccessibilityInfo.isHighTextContrastEnabled (Android), isDarkerSystemColorsEnabled (iOS)data-contrast="normal|high"
Densitymodes.compact / modes.spacious (base = comfortable)none — theme density or hostnone — theme density or hostdata-density="compact|comfortable|spacious"
Reduced motionmodes.reducedMotion (default overlay durationMs: 0)prefers-reduced-motion: reduceAccessibilityInfo.isReduceMotionEnableddata-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; Pxpx except typography.baseSizerem (161rem, so browser zoom and user font settings scale the form); ratios/weights unitless; motion.durationMsms; 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.

ComponentParts
Formroot, header, logo, title, body, footer, watermark (slot where Watermark mounts)
Pageroot, title, description, elements
PageNavroot, prev, next, finalize, saveDraft, indicator
ProgressBarroot, track, fill, label, step
ErrorSummaryroot, title, list, item, link
FieldWrapperroot, labelRow, label, requiredMarker, hint, guidanceToggle, guidance, media, control, message (data-severity)
Grouproot, header, title, description, toggle, content
Repeatroot, header, title, count, list, add, empty
RepeatItemroot, header, title, actions, moveUp, moveDown, remove, content
Noteroot, icon, content, toggle
Buttonroot, icon, label, spinner
Dialogbackdrop, root, title, body, actions
Watermarkroot, text (soft limited license state — 15 · Licensing §5.3; restylable, never removable)
LicenseBannerroot, message, action, dismiss
SummaryRowroot, label, value, edit (review/summary mode)
TextFieldroot, input, prefix, suffix, counter, clear
NumberFieldroot, input, unit, increment, decrement
DateField / TimeField / DateTimeFieldroot, input, trigger, picker, pickerHeader, pickerGrid, pickerCell, secondaryCalendar
SelectOne / SelectMultipleroot, list, option, optionInput, optionLabel, optionMedia, otherInput, trigger, menu, menuItem, empty, selectedCount
SearchSelectroot, combobox, input, clear, listbox, option, optionLabel, empty, loading, selectedCount (auto-selected for large or dataset-backed lists, 06 §11)
RankListroot, list, item, handle, index, moveUp, moveDown
Ratingroot, item, icon, label
RangeSliderroot, track, fill, thumb, value, min, max, tick
Checkboxroot, input, box, label
ConsentBlockroot, text, version, control, signature, withdraw, timestamp
MatrixGridroot, table, headerRow, headerCell, row, rowHeader, cell, cellControl
GeoPointFieldroot, capture, status, accuracy, coordinates, map, manual
GeoPathField (geotrace / geoshape)root, map, start, stop, points, status
ImageFieldroot, capture, gallery, preview, thumbnail, remove, retake, annotate, progress
AudioField / VideoFieldroot, record, stop, play, timer, waveform, preview, remove, progress
FileFieldroot, picker, list, item, name, size, remove, progress
BarcodeFieldroot, scan, viewfinder, result, manualInput
SignatureFieldroot, canvas, clear, done, preview
UnknownElementroot, message
Iconroot (+ 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, Slider07 · 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; labels md weight medium; page titles xl bold; hints sm; never below 12 px after scaling.
  • Arabic and per-script fallback. fontFamilyRtl and lineHeightRtl (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; no letter-spacing on Arabic; subsets must keep mark/mkmk/rlig and the bidi controls (U+061C, U+200C–200F, U+2066–2069) (13 · i18n). Agency Latin faces (Lato, Proxima Nova, Univers) have no Arabic glyphs, so fontFamilyRtl is mandatory whenever an RTL locale is declared; rasd theme check warns otherwise.
  • Web embedding. assets.fonts[family][].src.web is a self-hosted WOFF2 (relative paths resolve against document.baseURI); toCss() emits @font-face with font-display: swap and unicode-range; @rasd/pwa precaches them in rasd-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-font config plugin (fonts available at startup, weight-aware family definitions on Android); development / Expo Go: useFonts(). The family key in assets.fonts must equal the family name the platform registers; @rasd/themes exports fonts.notoSansArabic and fonts.notoNaskhArabic (FontAsset = WOFF2 URLs for web + TTF asset paths for native, 17 · API reference §11) and the helper fontAssets(theme) listing { family, weight, file } for the host's expo-font plugin config; the default Noto Sans Arabic faces (400/500/700, static) ship under @rasd/themes/fonts/.
  • Font scaling. allowFontScaling stays on; every Text/TextInput sets maxFontSizeMultiplier = typography.maxFontScale (default 2.0, < 1.2 fails the linter); web uses rem and min-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 (auto default) → settings.localeMeta[locale].dir → built-in RTL list → ltr; applied as dir on .rasd-root and Yoga direction on the native root — never I18nManager.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 physical left/right in @layer rasd. Native styles use start/end, marginStart/End, insetInlineStart/End. Directional icons carry data-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: compact for desktop review screens, comfortable default, spacious for gloves/outdoor. Controls render at control.height as min-height so text scaling grows them; hit targets never drop below control.minTouch, which the resolver clamps up to 48 px in every density (00 §10) — compact buys its density from spacing, not from touch targets, so the WCAG 24 px hard floor is never approached. Control heights are 48 / 48 / 56 across compact / 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/themesvalidateTheme(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)MinimumRationale
onSurface / surface, onSurfaceVariant / surfaceVariant, onPrimary / primary4.5:1SC 1.4.3 body text
error, warning, success, info / surface4.5:1rendered as message text
primary / surface3: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:1input borders, focus ring against adjacent colours, SC 1.4.11
every pair above, per overlay (light, dark, highContrast)as abovemodes 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. theme is RasdTheme | string — a theme object (usually the return value of createTheme()) 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 with validateTheme() and cached by the host in storage.kv under theme:<id> (as already-merged RasdTheme JSON, §2) so offline launches never wait on the network; when the cached copy is missing the host passes the bundled id it extends (or rasd-light). An unknown string id given to the provider renders with rasd-light and 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 provider theme prop wins when set; otherwise a known themeId (bundled id, or a host theme the host resolves — e.g. from its storage.kv cache) is used with overrides (validated as ThemePartial) 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 in ThemeContext only, 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 $type colour/dimension/fontFamily/fontWeight/duration/shadow/typography groups to Rasd tokens (Display-P3/OKLCH colours are converted to sRGB hex with a warning; rem dimensions multiplied by 16); toDtcg(theme) emits a .tokens.json with $extensions["dev.rasd"] carrying components, modes and assets so 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 ship migrateTheme(theme, to) in @rasd/themes and rasd 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 own version (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.json validates all bundled themes, examples/theme-agency-blue.json, and rejects a theme with extends: null and incomplete tokens.
  • createTheme(partial, { extends: 'rasd-field' }) deep-merges per §2 (component keys, per-family font replacement, per-vendor ext) and resolves aliases after mode overlays; cycles raise RASD_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-motion and the native AccessibilityInfo signals select the matching overlays; mode prop and setMode() override them; motion.durationMs is 0 under reduced motion.
  • Every component in §5.2 renders every listed part with class, data-scope, data-component, data-part and documented state attributes; renaming is caught by a snapshot test of the parts manifest.
  • classNames/styles/render per part accept values and functions of state with the precedence of §5.1; assertAccessibleContract fails a TextField replacement that drops aria-describedby.
  • unstyled renders 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 use lineHeightRtl.
  • rasd theme check passes all four bundled themes and the example; fails a theme whose onSurface/surface is 4.4:1; warns on primary 3.2:1 used as text; errors on minTouch: 20 and 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 flat classNames={{ input }} on <FormRenderer> is a TypeScript error and a dev-mode warning.
  • rasd-field renders 48 px controls at comfortable, 48 px at compact (floor), 56 px at spacious; rasd-light renders 48 / 48 / 56 (Playwright bounding boxes, RN layout snapshot).
  • Injection: a theme whose color.primary is "red; } .rasd-root { display:none" and whose typography.fontFamily contains </style> is rejected by validateTheme() 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 mismatching tenantJson.extends id throws RASD_THEME_EXTENDS; an unknown string id passed to <RasdProvider theme> renders rasd-light with 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).

SituationBehaviourSurfaced 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 rendersRasdError RASD_THEME_INVALID via createTheme (throws) / provider onError (never thrown in render)
Unknown extends id, chain > 8, cycle, id/object mismatchRejected at createThemeRASD_THEME_EXTENDS
Alias to a missing path or alias cycleLinter error; at runtime the rasd-light value is used for that leafRASD_THEME_ALIAS (lint) / one console warning (runtime)
Contrast below the §9 minimumsLinter 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 colourRASD_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-dependentdev-mode console warning; rasd theme check cannot see it, so the airplane-mode test in §13 does
Raw box-shadow string on nativeNo shadow renderedlint warning
Overlay yields control.minTouch < 48 after mergeResolver 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 tokensEvery 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 stringsRASD_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 themesEach .rasd-root scopes its own variables; no :root writes; portals inherit through portalContainer
Theme swap on a low-end deviceResolver 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 inlineLint warning; storage.kv still stores it, but the builder editor refuses inline data URIs for fonts and logoslint 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-field control 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 at spacious). Remaining question: should rasd-field default to density: "spacious" rather than comfortable?
  • Should the provider grow a themes map (id → RasdTheme) so a JSON theme can extends a host theme by id at runtime, or is createTheme(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 logo slot in the Form header enough, or do agencies need a full header/footer template with clear-space rules?

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