|
| 1 | +# ADR-0065: SDUI styling model — scoped style-objects over arbitrary Tailwind classes |
| 2 | + |
| 3 | +**Status**: Proposed (2026-06-22) |
| 4 | +**Deciders**: ObjectStack Protocol Architects |
| 5 | +**Builds on**: [ADR-0026](./0026-client-ui-plugin-distribution.md) (client UI distribution), [ADR-0016](./0016-studio-package-authoring-and-publish.md) (package authoring/publish), [ADR-0049](./0049-no-unenforced-security-properties.md) (spec must not promise what the runtime can't deliver) |
| 6 | +**Consumers**: `@objectstack/spec` (UI component envelope), the objectui renderers (`@object-ui/components`), cloud SDUI page authoring, the AI metadata-authoring agents. |
| 7 | +**Surfaced by**: a Cloud Pricing page (`com.objectstack.cloud` `page/pricing`) that rendered with blank plan headings, then — on investigation — turned out to "work" only by coincidence. |
| 8 | + |
| 9 | +--- |
| 10 | + |
| 11 | +## TL;DR |
| 12 | + |
| 13 | +The SDUI styling story to date is **arbitrary Tailwind class strings carried in |
| 14 | +page metadata and passed through to the DOM** (`element:text`/`page:card` etc. |
| 15 | +forward `schema.className`). Investigation shows this is **structurally unsound** |
| 16 | +for a platform where pages are authored *separately from* — and *by parties who |
| 17 | +cannot rebuild* — the renderer, and where the author is increasingly an **AI**. |
| 18 | + |
| 19 | +Three independent failure axes, any one of which is disqualifying, all bite at once: |
| 20 | + |
| 21 | +1. **Compilation.** Tailwind is JIT-compiled at *build* time, scanning *source*. |
| 22 | + The renderer (objectui Console) is a shipped, frozen artifact whose CSS scans |
| 23 | + only objectui's own `src` (`objectui: apps/console/src/index.css:12-19`), |
| 24 | + **never** the page metadata. There is **no safelist**. So a class in metadata |
| 25 | + produces CSS *only if it coincidentally also appears in objectui source*. |
| 26 | + (The Pricing page renders today purely because all 16 of its classes happen |
| 27 | + to be common ones objectui already uses — luck, not a contract. Any |
| 28 | + arbitrary-value class — `text-[27px]`, `bg-[#1a2b3c]`, `grid-cols-7` — is |
| 29 | + silently dead.) |
| 30 | + |
| 31 | +2. **Two-build cascade + responsive inversion.** Because the customer's metadata |
| 32 | + project builds independently from the renderer, you inherently get **two |
| 33 | + Tailwind stylesheets**. Tailwind utilities have equal specificity, so priority |
| 34 | + falls to source order — which is undefined across two builds. CSS cascade |
| 35 | + `@layer` fixes the flat case but **outranks media-query/source-order**, so a |
| 36 | + higher layer's *unconditional* (mobile-base) utility silently defeats a lower |
| 37 | + layer's `md:` utility *at all breakpoints* — responsive intent inverts. |
| 38 | + Per-element this is structural: every rendered element carries the renderer's |
| 39 | + own classes **and** the author's className, straddling two layers. |
| 40 | + |
| 41 | +3. **AI authorship.** The author is often a cheap model. Arbitrary Tailwind is a |
| 42 | + ~thousands-of-classes + infinite-arbitrary-value + variant + cascade surface — |
| 43 | + a maximal footgun. Correctness must be *designed in*, not hoped for. (In the |
| 44 | + session that surfaced this, a frontier model authoring the page made four |
| 45 | + distinct mistakes: overclaimed the capability, tripped a latent renderer bug, |
| 46 | + "verified" with a methodologically false-positive screenshot, and picked |
| 47 | + classes that worked only by luck.) |
| 48 | + |
| 49 | +**Decision.** SDUI styling is expressed as a **scoped style-object with |
| 50 | +model-owned responsive breakpoints**, whose *values* are constrained to design |
| 51 | +tokens — **not** arbitrary Tailwind classes. Styles compile to **per-component, |
| 52 | +id-scoped CSS at render time** (build-independent, collision-free). Arbitrary |
| 53 | +`className` is demoted to a **gated escape hatch** that only a runtime |
| 54 | +single-engine path may honour. This mirrors the proven model of **Builder.io**, |
| 55 | +the most battle-tested "author content separately, render in arbitrary hosts" |
| 56 | +platform. |
| 57 | + |
| 58 | +--- |
| 59 | + |
| 60 | +## Context |
| 61 | + |
| 62 | +### What we have |
| 63 | + |
| 64 | +UI components carry a loose `className` passthrough. The objectui renderers apply |
| 65 | +it directly — e.g. `objectui: packages/components/src/renderers/basic/elements.tsx:87` |
| 66 | +(`cn(VARIANT_CLASS, ALIGN_CLASS, schema?.className)`). The spec does **not** even |
| 67 | +formally model a styling field (`packages/spec/src/ui/component.zod.ts` defines |
| 68 | +per-component `properties` + `children`, no `style`). So styling today is "write |
| 69 | +Tailwind into metadata and hope the renderer's CSS bundle happens to contain it." |
| 70 | + |
| 71 | +### Why "just compile at build time" doesn't rescue it |
| 72 | + |
| 73 | +- **Folding the page files into the renderer's Tailwind `@source`** works *only* |
| 74 | + for pages that are static source *inside the renderer's own build*. The |
| 75 | + Console is a shipped dev tool; the customer's metadata project is a **separate |
| 76 | + build the platform never sees**. A single shared build is impossible. |
| 77 | +- **A safelist / `@source inline(...)`** yields a *bounded* palette, not the |
| 78 | + arbitrary power that motivated raw className in the first place. |
| 79 | +- **A shared Tailwind preset** (lock version + share `@theme`/breakpoints + |
| 80 | + emit into a reserved `@layer`) makes two independent builds *coexist*, but |
| 81 | + cannot remove the responsive-inversion tail (§Decision-2 above): build-time |
| 82 | + approaches structurally cannot. |
| 83 | +- **Runtime single-engine JIT** (one engine over the composed DOM) *is* correct, |
| 84 | + but pays a permanent runtime cost (client engine / server compile+cache) and |
| 85 | + is the *only* build-time-free way to keep arbitrary classes correct. |
| 86 | + |
| 87 | +The honest constraint: **"two independent builds + arbitrary Tailwind + correct |
| 88 | +responsive + zero runtime cost" — pick three.** |
| 89 | + |
| 90 | +### Precedent: Builder.io |
| 91 | + |
| 92 | +Builder.io solves exactly this shape (visual content authored separately, |
| 93 | +rendered into arbitrary host stacks) and **does not bet on Tailwind classes**: |
| 94 | + |
| 95 | +- Styles are CSS **objects**, per breakpoint — |
| 96 | + `Builder.io SDK: packages/sdks/src/types/builder-block.ts:42` |
| 97 | + (`responsiveStyles?: { large?, medium?, small?, xsmall?: Partial<CSSStyleDeclaration> }`). |
| 98 | +- Responsive is an **explicit breakpoint map in the data model** (desktop-first: |
| 99 | + `large` is base, smaller sizes override via `@media (max-width: …)` — |
| 100 | + `Builder.io SDK: packages/sdks/src/constants/device-sizes.ts:34`), **not** |
| 101 | + `md:` utility variants the author writes. |
| 102 | +- At render, each block's styles compile to **id-scoped CSS** — |
| 103 | + `Builder.io SDK: packages/sdks/src/helpers/css.ts` (`createCssClass` emits |
| 104 | + `.${block.id} { … }`, wrapping smaller breakpoints in `@media`) injected via |
| 105 | + an inlined `<style>` (`…/components/block/components/block-styles.lite.tsx`). |
| 106 | +- `className` exists only as an **optional passthrough** for hosts that already |
| 107 | + ship Tailwind — carrying the exact "only works if the host compiled it" caveat. |
| 108 | + |
| 109 | +This dissolves all three failure axes: nothing to scan at build (styles are |
| 110 | +data → CSS at render), nothing to collide (id-scoped, no shared utility layer), |
| 111 | +and responsive is clean generated `@media` owned by the model. |
| 112 | + |
| 113 | +--- |
| 114 | + |
| 115 | +## Decision |
| 116 | + |
| 117 | +1. **Styling primitive = scoped style-object.** The UI component envelope gains |
| 118 | + an optional `style` (base) and `responsiveStyles` (`large`/`medium`/`small`/ |
| 119 | + `xsmall` CSS-property maps). The renderer compiles these to **per-component, |
| 120 | + id-scoped CSS** at render time. No author-written class strings are required |
| 121 | + for styling. |
| 122 | + |
| 123 | +2. **Responsive is model-owned.** Breakpoints are expressed as the |
| 124 | + `responsiveStyles` map (or higher-level responsive props on components like |
| 125 | + `columns`), generated into proper `@media` rules. Authors **never write |
| 126 | + breakpoint variant classes** (`md:` …). This deletes the entire |
| 127 | + layer-vs-media-query failure class. |
| 128 | + |
| 129 | +3. **Values are token-constrained.** Style-object values resolve against a |
| 130 | + curated design-token palette (spacing/color/radius/typography as |
| 131 | + CSS variables). This is what Builder.io *lacks* (and is criticised for — |
| 132 | + inconsistent output); we add it for visual consistency **and** to make the |
| 133 | + surface enumerable/validatable for AI authors. |
| 134 | + |
| 135 | +4. **Arbitrary `className` is a gated escape hatch, not the interface.** It may |
| 136 | + be honoured **only** on a runtime single-engine path, and (in cloud) gated to |
| 137 | + an advanced/paid tier behind a render+review check. It is never what the AI |
| 138 | + reaches for by default. |
| 139 | + |
| 140 | +5. **Authoring is verified in-loop.** Because styles are now structured data, the |
| 141 | + authoring tool validates them against the spec schema (reject + self-correct), |
| 142 | + and the publish path may add a headless render + VLM "looks right?" gate. |
| 143 | + Correctness by construction + verification, not author virtue. |
| 144 | + |
| 145 | +This ADR is the **mechanism** (open, in framework + objectui); per-tier policy |
| 146 | +and the VLM gate are **cloud** concerns (open-core boundary, cf. ADR-0026). |
| 147 | + |
| 148 | +--- |
| 149 | + |
| 150 | +## Consequences |
| 151 | + |
| 152 | +- **Positive.** Styling becomes **build-independent** (data → CSS at render → |
| 153 | + arbitrary *values* always work), **collision-free** (id-scoped, no two-build |
| 154 | + cascade war, no `@layer` gymnastics, no safelist), **responsive-correct** |
| 155 | + (model breakpoints → generated `@media`), and **AI-safe** (structured, |
| 156 | + schema-validated, token-bounded data instead of a class-string DSL). |
| 157 | +- **Negative / cost.** A render-time CSS-gen step (cheap: object→string, *not* a |
| 158 | + Tailwind engine). Per-component `<style>`/scoped rules instead of shared |
| 159 | + utility reuse (slightly larger, uncached-across-blocks CSS — acceptable, and |
| 160 | + cacheable by content hash). Token constraint must be designed (the palette is |
| 161 | + now a platform artifact). Existing className-styled pages (e.g. the Pricing |
| 162 | + page) migrate to the new primitive. |
| 163 | +- **Follow-up.** (a) Add `style`/`responsiveStyles` to `@objectstack/spec` UI |
| 164 | + envelope. (b) A reference compiler (`style-object + breakpoints → id-scoped |
| 165 | + CSS`) as the open mechanism. (c) objectui renderer consumes it. (d) Define the |
| 166 | + token palette. (e) Migrate cloud's built-in `*.page.ts` off raw className. |
| 167 | + (f) cloud: tier policy + render/VLM gate for the className escape hatch. |
| 168 | +- **Validation.** A showcase under `examples/app-showcase` exercises the |
| 169 | + primitive and a unit test asserts the four properties the decision rests on: |
| 170 | + **id-scoping**, **generated `@media` for breakpoints**, **arbitrary values |
| 171 | + pass through verbatim** (build-independence), and **token-var resolution**. |
| 172 | + |
| 173 | +--- |
| 174 | + |
| 175 | +## Non-goals |
| 176 | + |
| 177 | +- **Not** a general-purpose CSS-in-JS framework, animation system, or a Tailwind |
| 178 | + replacement for hand-written app code. This governs **metadata-authored SDUI** |
| 179 | + surfaces only. |
| 180 | +- **Not** banning Tailwind inside objectui's own hand-built renderer components |
| 181 | + (those are scanned source — they compile fine). |
| 182 | +- **Not** mandating the runtime single-engine path now. Runtime JIT is the |
| 183 | + *only* way to keep the arbitrary-className escape hatch correct, but adopting |
| 184 | + it is a separate, cost-gated decision (and unnecessary for the token primitive). |
0 commit comments