Skip to content

Commit bc83378

Browse files
xuyushun441-sysos-zhuangclaude
authored
docs(adr): ADR-0065 SDUI styling model — scoped style-objects over arbitrary Tailwind (#2210)
Records the decision that metadata-authored SDUI styling is a scoped style-object with model-owned responsive breakpoints (token-constrained values), compiled to per-component id-scoped CSS at render time — NOT arbitrary Tailwind class strings. Mirrors Builder.io's proven model. Adds a runnable proof under examples/app-showcase: a reference compiler (style-object + breakpoints -> id-scoped CSS) + a showcase authoring shape + a test asserting the four properties the decision rests on (id-scoping, generated @media, arbitrary-value passthrough, token resolution). 5/5 green. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent bdf9cdb commit bc83378

4 files changed

Lines changed: 416 additions & 0 deletions

File tree

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
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).
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
5+
import {
6+
compileBlockStyles,
7+
compilePageStyles,
8+
BREAKPOINTS,
9+
} from './sdui-styling/compile-block-styles.js';
10+
import { pricingShowcase } from './sdui-styling/pricing-showcase.js';
11+
12+
/**
13+
* Verifies the SDUI styling model (ADR-0065) actually delivers the four
14+
* properties the decision rests on. In the "demonstrated AND verified" spirit
15+
* of the showcase: a model that merely *declares* a styling shape but compiles
16+
* to colliding / build-dependent / responsive-inverting CSS would slip past a
17+
* shape-only assertion — but not these.
18+
*/
19+
describe('SDUI styling model — scoped style-objects (ADR-0065)', () => {
20+
// (1) id-scoping: every rule targets `.{id}` — never a global/unscoped
21+
// selector. This is what makes two independently-built stylesheets unable to
22+
// collide, and removes the whole two-build cascade war.
23+
it('scopes every rule to the component id (no global selectors)', () => {
24+
const css = compileBlockStyles('plan_solo', {
25+
large: { padding: '24px' },
26+
small: { padding: '12px' },
27+
});
28+
expect(css).toContain('.plan_solo {');
29+
expect(css).toContain('@media (max-width: 640px) { .plan_solo {');
30+
// No bare/global declaration leaks outside an id scope.
31+
expect(css).not.toMatch(/(^|\n)\s*padding:/); // padding only ever inside `.plan_solo { … }`
32+
33+
// Two different blocks → disjoint selectors → cannot collide.
34+
const a = compileBlockStyles('block_a', { large: { color: 'red' } });
35+
const b = compileBlockStyles('block_b', { large: { color: 'blue' } });
36+
expect(a).toContain('.block_a {');
37+
expect(b).toContain('.block_b {');
38+
expect(a).not.toContain('block_b');
39+
});
40+
41+
// (2) responsive = generated @media owned by the model (desktop-first,
42+
// max-width), NOT author-written `md:` classes. Deletes the
43+
// layer-vs-media-query inversion class.
44+
it('emits proper @media rules for breakpoints (no variant classes)', () => {
45+
const css = compileBlockStyles('plan_solo', {
46+
large: { padding: '24px' },
47+
medium: { padding: '20px' },
48+
small: { padding: '12px' },
49+
xsmall: { padding: '8px' },
50+
});
51+
expect(css).toContain(`@media (max-width: ${BREAKPOINTS.medium}px)`);
52+
expect(css).toContain(`@media (max-width: ${BREAKPOINTS.small}px)`);
53+
expect(css).toContain(`@media (max-width: ${BREAKPOINTS.xsmall}px)`);
54+
// The base (`large`) is unconditional — not wrapped in a media query.
55+
expect(css.split('\n')[0]).toBe('.plan_solo { padding: 24px; }');
56+
// Author never writes a breakpoint variant class.
57+
expect(css).not.toContain('md:');
58+
expect(css).not.toContain('sm:');
59+
});
60+
61+
// (3) build-independence: arbitrary values pass through verbatim. This is the
62+
// property arbitrary Tailwind classes CANNOT guarantee (JIT scans source at
63+
// build; metadata is never scanned).
64+
it('passes arbitrary values through verbatim (zero build step)', () => {
65+
const css = compileBlockStyles('odd', {
66+
large: { fontSize: '13px', color: '#1a2b3c', gridTemplateColumns: 'repeat(3, 1fr)' },
67+
});
68+
expect(css).toContain('font-size: 13px;');
69+
expect(css).toContain('color: #1a2b3c;');
70+
// camelCase → kebab-case, value untouched.
71+
expect(css).toContain('grid-template-columns: repeat(3, 1fr);');
72+
});
73+
74+
// (4) token resolution: values may be design tokens → consistency + an
75+
// enumerable, AI-safe surface. Tokens pass through as `var(--…)`.
76+
it('passes design-token values through as CSS variables', () => {
77+
const css = compilePageStyles(pricingShowcase);
78+
expect(css).toContain('.plan_solo {');
79+
expect(css).toContain('padding: var(--space-6);');
80+
expect(css).toContain('border-radius: var(--radius-lg);');
81+
// Showcase responsive shrink applied via the model, scoped.
82+
expect(css).toContain('@media (max-width: 640px) { .plan_solo {');
83+
expect(css).toContain('.plan_price {');
84+
expect(css).toContain('font-size: 44px;'); // arbitrary value alongside tokens
85+
});
86+
87+
// Guard mirrors Builder.io: no id → no CSS (never an unscoped global rule).
88+
it('emits nothing for a block with no id', () => {
89+
expect(compileBlockStyles('', { large: { padding: '24px' } })).toBe('');
90+
});
91+
});
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Reference compiler for the SDUI styling model (ADR-0065).
5+
*
6+
* Turns a per-component, breakpoint-keyed style-object into **id-scoped CSS**,
7+
* the way Builder.io's SDK does (`createCssClass` →
8+
* `Builder.io SDK: packages/sdks/src/helpers/css.ts`). This is the open
9+
* *mechanism* prototype: pure `data → CSS string`, no Tailwind, no build-time
10+
* class scanning. It exists here as the thing the showcase test verifies; the
11+
* production home is `@objectstack/spec`/objectui (ADR-0065 follow-up).
12+
*
13+
* Desktop-first, matching Builder.io
14+
* (`packages/sdks/src/constants/device-sizes.ts:34`, `@media (max-width: …)`):
15+
* `large` is the unconditional base; `medium`/`small`/`xsmall` are max-width
16+
* overrides. Responsive is owned by the *model*, never by author-written
17+
* `md:`-style variant classes — which is what deletes the layer-vs-media-query
18+
* inversion class entirely (ADR-0065 §Decision-2).
19+
*/
20+
21+
export type StyleMap = Record<string, string>;
22+
23+
export interface ResponsiveStyles {
24+
/** Unconditional base (desktop-first). */
25+
large?: StyleMap;
26+
medium?: StyleMap;
27+
small?: StyleMap;
28+
xsmall?: StyleMap;
29+
}
30+
31+
/** max-width breakpoints (px). Mirrors Builder.io's default device sizes. */
32+
export const BREAKPOINTS: Record<'medium' | 'small' | 'xsmall', number> = {
33+
medium: 991,
34+
small: 640,
35+
xsmall: 479,
36+
};
37+
38+
const camelToKebab = (k: string): string =>
39+
k.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
40+
41+
/** A style-map → `prop: value;` declarations. Values pass through **verbatim** —
42+
* that verbatim passthrough is the whole point: arbitrary values (`13px`,
43+
* `#1a2b3c`) and design tokens (`var(--space-6)`) work with zero build step. */
44+
const mapToDeclarations = (m: StyleMap): string =>
45+
Object.entries(m)
46+
.map(([k, v]) => `${camelToKebab(k)}: ${v};`)
47+
.join(' ');
48+
49+
/**
50+
* Compile one component's responsive style-object to id-scoped CSS.
51+
* Returns '' for a missing id (no global, unscoped rule is ever emitted —
52+
* that scoping is what makes two independently-built stylesheets unable to
53+
* collide; ADR-0065 §Decision-1/2).
54+
*/
55+
export function compileBlockStyles(id: string, styles: ResponsiveStyles): string {
56+
if (!id) return '';
57+
const rules: string[] = [];
58+
if (styles.large) {
59+
rules.push(`.${id} { ${mapToDeclarations(styles.large)} }`);
60+
}
61+
for (const size of ['medium', 'small', 'xsmall'] as const) {
62+
const s = styles[size];
63+
if (s) {
64+
rules.push(
65+
`@media (max-width: ${BREAKPOINTS[size]}px) { .${id} { ${mapToDeclarations(s)} } }`,
66+
);
67+
}
68+
}
69+
return rules.join('\n');
70+
}
71+
72+
/** Compile a whole page's blocks, concatenating their scoped CSS. */
73+
export function compilePageStyles(
74+
blocks: Array<{ id: string; responsiveStyles?: ResponsiveStyles }>,
75+
): string {
76+
return blocks
77+
.filter((b) => b.responsiveStyles)
78+
.map((b) => compileBlockStyles(b.id, b.responsiveStyles as ResponsiveStyles))
79+
.filter(Boolean)
80+
.join('\n');
81+
}

0 commit comments

Comments
 (0)