Skip to content

Commit ec7175d

Browse files
os-zhuangclaude
andauthored
fix(sdui): no Tailwind in page source — guardrail + corrected guidance (ADR-0065) (#2495)
A kind:'html'/'react' page's `source` is runtime metadata, so the console's build-time Tailwind never scans it — authored utility classNames silently produce no CSS (the ADR-0065 failure that the Task Desk modal hit). The page fixes landed in the showcase; this closes the loop so AI authors don't repeat the mistake. Prevention (the guardrail): - New `@objectstack/lint` rule `validatePageSourceStyling` flags `className` attributes in source-tier pages, wired into `os validate` as step 3e — a warning with the actionable fix (react: inline style + hsl(var(--token)) + ObjectForm formType overlays; html: structured props + a JSON style object). Verified: os validate now flags all 7 showcase source pages; 6 unit tests. Corrected guidance (so the contract/docs/skill no longer teach the mistake): - react-blocks contract generator note → "do NOT use Tailwind className; style with inline style + hsl(var(--token)); overlays via ObjectForm formType" (regenerated contract.json + react-blocks.md). - objectstack-ui SKILL.md: html + react examples and prose rewritten off Tailwind; the ADR-0065 styling section gains a source-tier subsection. - layout-dsl.mdx: html tier row no longer says "HTML + Tailwind". - ADR-0080 / ADR-0081: amendment notes pointing to ADR-0065 — the tiers stand, only the styling primitive changes; never author Tailwind in page source. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b136f7c commit ec7175d

12 files changed

Lines changed: 174 additions & 26 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@objectstack/lint": minor
3+
"@objectstack/cli": minor
4+
---
5+
6+
Add the source-page styling guardrail (ADR-0065): `os validate`/`os build` now flags Tailwind `className` in `kind:'html'`/`kind:'react'` page source, which silently produces no CSS because the build never scans authored metadata. New `validatePageSourceStyling` rule with an actionable inline-style/`hsl(var(--token))` fix; also corrects the react-blocks contract, the objectstack-ui skill, the layout-dsl docs, and ADR-0080/0081 away from the "HTML + Tailwind" framing.

content/docs/protocol/objectui/layout-dsl.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ write the page body as a string. Pick by what the page needs:
3232
| `kind` | You write | JavaScript | Use when |
3333
|:-------|:----------|:-----------|:---------|
3434
| `full` / `slotted` *(this doc)* | structured `regions` / `slots` || record, detail, home, and app layouts from the component catalogue |
35-
| `html` | constrained JSX = HTML + Tailwind + registered components, **parsed, never executed** | none | free-form layout, landing pages, or dashboards that *compose* blocks — no interactivity |
35+
| `html` | constrained JSX = registered components + safe native HTML, **parsed, never executed** | none | free-form layout, landing pages, or dashboards that *compose* blocks — no interactivity |
3636
| `react` | **real React** (hooks, `.map`, `onClick`, expressions) | executed in the app | complex interactive business UIs — master/detail, wizards, state-driven filters |
3737

3838
For `html` / `react`, the page body is the `source` string (it is the

docs/adr/0080-ai-authored-ui-jsx-source.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
1212
---
1313

14+
> **Amendment (2026-06-30 — ADR-0065 styling correction).** The "HTML + **Tailwind**" framing for page `source` is **superseded on styling**. A page's `source` is *runtime metadata*, so the console's build-time Tailwind never scans it — authored utility `className`s silently produce no CSS (the exact failure ADR-0065 was written to prevent; the Task Desk modal's `bg-black/50` backdrop rendered transparent). The tiers themselves stand; only the styling **primitive** changes: `kind:'html'` styles via the registered components' structured props + a JSON `style` object; `kind:'react'` styles via inline `style={{}}` with `hsl(var(--token))` theme colors and renders drawer/modal overlays through `<ObjectForm formType="drawer"|"modal">` (never a hand-rolled `fixed inset-0`). **Do not author Tailwind classes in page source.** See [ADR-0065](./0065-sdui-styling-model.md).
15+
1416
## TL;DR
1517

1618
1. **[model] AI authors a constrained *JSX text*, not a JSON tree.** The JSON `SchemaNode` tree is a fine *compile target* and a terrible *edit surface* — verbose, out-of-distribution for the model, noisy to diff, and it loses manual tweaks on every regeneration. AI reads/edits JSX+Tailwind (its strength); the tree is **derived**, never hand-edited. (This is how v0/Markdoc/MDX keep source = code, output = compiled.)

docs/adr/0081-trusted-react-page-tier.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
1212
---
1313

14+
> **Amendment (2026-06-30 — ADR-0065 styling correction).** The "HTML + **Tailwind**" framing for page `source` is **superseded on styling**. A page's `source` is *runtime metadata*, so the console's build-time Tailwind never scans it — authored utility `className`s silently produce no CSS (the exact failure ADR-0065 was written to prevent; the Task Desk modal's `bg-black/50` backdrop rendered transparent). The tiers themselves stand; only the styling **primitive** changes: `kind:'html'` styles via the registered components' structured props + a JSON `style` object; `kind:'react'` styles via inline `style={{}}` with `hsl(var(--token))` theme colors and renders drawer/modal overlays through `<ObjectForm formType="drawer"|"modal">` (never a hand-rolled `fixed inset-0`). **Do not author Tailwind classes in page source.** See [ADR-0065](./0065-sdui-styling-model.md).
15+
1416
## TL;DR
1517

1618
1. **[rename] `kind:'jsx'``kind:'html'`.** The constrained, parse-never-execute tier (ADR-0080) is renamed to match what it is: author-written **HTML + Tailwind** (expressed as constrained JSX) compiled to the SDUI tree. `'jsx'` stays as a **deprecated alias** (already-saved pages keep loading); all authored examples/docs move to `'html'`.

packages/cli/src/commands/validate.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { loadConfig } from '../utils/config.js';
1111
import { validateStackExpressions } from '@objectstack/lint';
1212
import { validateWidgetBindings } from '@objectstack/lint';
1313
import { validateResponsiveStyles } from '@objectstack/lint';
14-
import { validateJsxPages, validateReactPages, validateReactPageProps } from '@objectstack/lint';
14+
import { validateJsxPages, validateReactPages, validateReactPageProps, validatePageSourceStyling } from '@objectstack/lint';
1515
import {
1616
printHeader,
1717
printKV,
@@ -273,6 +273,19 @@ export default class Validate extends Command {
273273
this.exit(1);
274274
}
275275

276+
// 3e. Source-tier page styling (ADR-0065): Tailwind className in a
277+
// kind:'html'/'react' page source silently no-ops (the build never
278+
// scans authored metadata) — warn with the inline-style fix.
279+
if (!flags.json) printStep('Checking source-page styling (ADR-0065)...');
280+
const sourceStyleFindings = validatePageSourceStyling(result.data as Record<string, unknown>);
281+
const sourceStyleWarnings = sourceStyleFindings.filter((f) => f.severity === 'warning');
282+
if (!flags.json) {
283+
for (const w of sourceStyleWarnings.slice(0, 50)) {
284+
console.log(chalk.yellow(` \u26a0 ${w.where}: ${w.message}`));
285+
console.log(chalk.dim(` ${w.hint}`));
286+
}
287+
}
288+
276289
// 4. Collect and display stats
277290
const stats = collectMetadataStats(config);
278291

packages/lint/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ export { validateReactPages } from './validate-react-pages.js';
4141
export type { ReactPageFinding, ReactPageSeverity } from './validate-react-pages.js';
4242
export { validateReactPageProps } from './validate-react-page-props.js';
4343
export type { ReactPropFinding, ReactPropSeverity } from './validate-react-page-props.js';
44+
export { validatePageSourceStyling, PAGE_SOURCE_CLASSNAME } from './validate-page-source-styling.js';
45+
export type { SourceStyleFinding, SourceStyleSeverity } from './validate-page-source-styling.js';
4446

4547
export {
4648
validateRecordTitle,
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
import { describe, it, expect } from 'vitest';
3+
import { validatePageSourceStyling, PAGE_SOURCE_CLASSNAME } from './validate-page-source-styling.js';
4+
5+
const page = (kind: string, source: string) => ({ pages: [{ name: 'p', kind, source }] });
6+
7+
describe('validatePageSourceStyling (ADR-0065 guardrail)', () => {
8+
it('flags Tailwind className in a react source page', () => {
9+
const f = validatePageSourceStyling(page('react', 'function Page(){ return <div className="grid grid-cols-5 p-8" />; }'));
10+
expect(f.length).toBe(1);
11+
expect(f[0].rule).toBe(PAGE_SOURCE_CLASSNAME);
12+
expect(f[0].severity).toBe('warning');
13+
expect(f[0].hint).toMatch(/hsl\(var\(--/);
14+
});
15+
16+
it('flags className in an html source page (with the html-specific hint)', () => {
17+
const f = validatePageSourceStyling(page('html', '<flex className="gap-4"><text content="hi" /></flex>'));
18+
expect(f.length).toBe(1);
19+
expect(f[0].hint).toMatch(/structured props/);
20+
});
21+
22+
it('passes a react page styled with inline style + hsl tokens', () => {
23+
const f = validatePageSourceStyling(page('react', "function Page(){ return <div style={{ color: 'hsl(var(--foreground))' }} />; }"));
24+
expect(f).toEqual([]);
25+
});
26+
27+
it('passes an html page that uses structured props + a style object', () => {
28+
const f = validatePageSourceStyling(page('html', '<flex direction="col" gap={4} style={{"padding":"16px"}} />'));
29+
expect(f).toEqual([]);
30+
});
31+
32+
it('ignores structured (full/slotted) pages without source', () => {
33+
expect(validatePageSourceStyling({ pages: [{ name: 'p', kind: 'full', regions: [] }] })).toEqual([]);
34+
});
35+
36+
it('also covers the deprecated jsx alias', () => {
37+
const f = validatePageSourceStyling(page('jsx', '<flex className="p-4" />'));
38+
expect(f.length).toBe(1);
39+
});
40+
});
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Build-time guardrail for SDUI source-tier page styling (ADR-0065 / ADR-0080 /
4+
// ADR-0081). A `kind:'html'` or `kind:'react'` page's `source` is RUNTIME
5+
// metadata — the console's build-time Tailwind only scans the renderer's own
6+
// source, never an authored page string. So a Tailwind `className` in page
7+
// source silently produces NO CSS (the exact ADR-0065 failure: styling that
8+
// "works only by coincidence" when the class happens to be one objectui already
9+
// ships). This rule flags authored `className` attributes in source-tier pages
10+
// before render, with the actionable fix.
11+
//
12+
// It is the styling counterpart to the react-prop gate: a pure
13+
// `(stack) => Finding[]` rule (ADR-0019), run from `os validate`/`compile` and
14+
// reusable by AI authoring so the agent self-corrects.
15+
16+
export type SourceStyleSeverity = 'error' | 'warning';
17+
18+
export interface SourceStyleFinding {
19+
severity: SourceStyleSeverity;
20+
rule: string;
21+
where: string;
22+
path: string;
23+
message: string;
24+
hint: string;
25+
}
26+
27+
export const PAGE_SOURCE_CLASSNAME = 'page-source-className-tailwind';
28+
29+
type AnyRec = Record<string, unknown>;
30+
const asArray = (v: unknown): AnyRec[] => (Array.isArray(v) ? (v as AnyRec[]) : []);
31+
32+
// `className=` as a JSX attribute: name, optional ws, `=`, then `"`/`'`/`{`.
33+
const CLASSNAME_ATTR = /\bclassName\s*=\s*["'{]/g;
34+
35+
export function validatePageSourceStyling(stack: AnyRec): SourceStyleFinding[] {
36+
const findings: SourceStyleFinding[] = [];
37+
const pages = asArray(stack.pages);
38+
for (let p = 0; p < pages.length; p++) {
39+
const page = pages[p];
40+
if (!page) continue;
41+
const kind = page.kind;
42+
if (kind !== 'html' && kind !== 'react' && kind !== 'jsx') continue;
43+
const source = page.source;
44+
if (typeof source !== 'string' || source.trim() === '') continue;
45+
const name = String(page.name ?? `#${p}`);
46+
47+
CLASSNAME_ATTR.lastIndex = 0;
48+
let count = 0;
49+
while (CLASSNAME_ATTR.exec(source) !== null) count++;
50+
if (count === 0) continue;
51+
52+
findings.push({
53+
severity: 'warning',
54+
rule: PAGE_SOURCE_CLASSNAME,
55+
where: `page "${name}"`,
56+
path: `pages[${p}].source`,
57+
message: `${count} \`className\` attribute${count > 1 ? 's' : ''} in ${String(kind)}-source page — Tailwind utilities in page source silently produce no CSS (the build never scans authored metadata; ADR-0065).`,
58+
hint:
59+
kind === 'react'
60+
? "Style with inline style={{}} using hsl(var(--token)) theme colors (e.g. color:'hsl(var(--foreground))', background:'hsl(var(--card))'); render drawer/modal via <ObjectForm formType=\"drawer\"|\"modal\"> instead of hand-rolled overlays."
61+
: "Lay out with the components' structured props (<flex direction gap>, <grid columns>) and add CSS via a JSON style object style={{\"color\":\"hsl(var(--foreground))\"}}; do not use Tailwind className.",
62+
});
63+
}
64+
return findings;
65+
}

packages/spec/scripts/build-react-blocks-contract.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ const contract = {
9797
version: 2,
9898
adr: 'ADR-0081',
9999
source: 'GENERATED from packages/spec/src/ui/react-blocks.ts — data props from the spec zod schemas, binding/controlled/callback from the React overlay.',
100-
note: "Props each component accepts in kind:'react' page source. Reference blocks by their PascalCase tag. kind: data=declarative config (from the spec schema) · binding=connects to data · controlled=React state · callback=React function. Layout = plain HTML+Tailwind; these blocks are for DATA. Live data: const adapter = useAdapter(); adapter.find/findOne/create/update.",
100+
note: "Props each component accepts in kind:'react' page source. Reference blocks by their PascalCase tag. kind: data=declarative config (from the spec schema) · binding=connects to data · controlled=React state · callback=React function. These blocks are for DATA. Live data: const adapter = useAdapter(); adapter.find/findOne/create/update. STYLING (ADR-0065) — a page's source is runtime metadata, so the console's build-time Tailwind NEVER scans it: utility classNAMES silently produce no CSS. Do NOT use Tailwind className in page source. (a) Layout/chrome: inline style={} with hsl(var(--token)) theme colors — e.g. color:'hsl(var(--foreground))', background:'hsl(var(--card))', border:'1px solid hsl(var(--border))', and px/flex for layout. (b) Overlays: render <ObjectForm formType='drawer'|'modal' open onOpenChange> (a pre-styled Sheet/Dialog) — never hand-roll a fixed inset-0 backdrop.",
101101
blocks,
102102
};
103103
fs.writeFileSync(OUT_JSON, JSON.stringify(contract, null, 2) + '\n');

skills/objectstack-ui/SKILL.md

Lines changed: 39 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -731,7 +731,7 @@ by what the page needs:
731731
| `kind` | Author writes | JS runs? | Use when |
732732
|:--|:--|:--|:--|
733733
| `full` / `slotted` | structured `regions` / `slots` (no `source`) || record/detail/home layouts from the component catalogue |
734-
| `html` | constrained JSX = HTML + Tailwind + registered components, **parsed, never executed** | no | free-form layout / landing / dashboard that just *composes* blocks — AI's HTML+Tailwind strength, no interactivity |
734+
| `html` | constrained JSX = registered components + safe native HTML, **parsed, never executed** | no | free-form layout / landing / dashboard that just *composes* blocks — no interactivity |
735735
| `react` | **real React** (hooks, `.map`, `onClick`, expressions) | yes (main React tree) | complex interactive business UIs — master/detail, wizards, state-driven filters |
736736

737737
`source` is the source-of-truth in both source tiers; `regions` is ignored. A
@@ -753,10 +753,10 @@ on unknown tags / missing required props / forbidden constructs (event handlers,
753753
export const ReleaseNotesPage = definePage({
754754
name: 'release_notes', type: 'home', kind: 'html',
755755
source: `
756-
<section className="mx-auto max-w-3xl p-10">
757-
<h1 className="text-4xl font-bold">Release Notes</h1>
758-
<object-metric objectName="ticket" aggregate="count" label="Open tickets" className="mt-6" />
759-
</section>`,
756+
<flex direction="col" gap={6} style={{"maxWidth":"768px","margin":"0 auto","padding":"40px"}}>
757+
<h1 style={{"fontSize":"32px","fontWeight":700,"color":"hsl(var(--foreground))"}}>Release Notes</h1>
758+
<object-metric objectName="ticket" aggregate="count" label="Open tickets" />
759+
</flex>`,
760760
});
761761
```
762762

@@ -772,10 +772,12 @@ The source is real React executed at render by the runtime. The injected scope a
772772
component; `<Block type="…" …/>` is the escape hatch for any other type
773773
- `data` / `variables` / `page`
774774

775-
Compose **layout with plain HTML + Tailwind** (React's strength); use the injected
776-
blocks for data. Real component props/callbacks flow through — e.g. `<ObjectForm>` honors
777-
`objectName` / `mode` / `recordId` / `onSuccess` / `onCancel`; `<ListView>` honors
778-
`objectName` / `fields` / `onRowClick` / `navigation`.
775+
Compose **layout with inline `style={{…}}`** (real CSS — see *Styling*, below); use the
776+
injected blocks for data. **Do NOT use Tailwind `className`** — page source is runtime
777+
metadata the build never scans, so utility classes silently do nothing. Real component
778+
props/callbacks flow through — e.g. `<ObjectForm>` honors `objectName` / `mode` / `recordId` /
779+
`formType` / `onSuccess` / `onCancel`; `<ListView>` honors `objectName` / `fields` /
780+
`onRowClick` / `navigation`.
779781

780782
> **Do not guess props — read the contract.** Each injected block's full prop set
781783
> (name, type, `data`/`controlled`/`callback` kind, required, description) is the
@@ -797,18 +799,14 @@ function Page() {
797799
const [sel, setSel] = React.useState(null);
798800
const [reload, setReload] = React.useState(0);
799801
return (
800-
<div className="grid grid-cols-5 gap-6 p-8">
801-
<div className="col-span-3">
802-
<ListView key={reload} objectName="project"
803-
fields={['name','status','owner']} navigation={{ mode: 'none' }}
804-
onRowClick={(r) => setSel(r)} />
805-
</div>
806-
<div className="col-span-2">
807-
{sel
808-
? <ObjectForm objectName="project" mode="edit" recordId={sel.id}
809-
onSuccess={() => { setSel(null); setReload((k) => k + 1); }} />
810-
: <p className="text-slate-400">Select a project to edit.</p>}
811-
</div>
802+
<div style={{ display: 'grid', gridTemplateColumns: '3fr 2fr', gap: 24, padding: 32, alignItems: 'start' }}>
803+
<ListView key={reload} objectName="project"
804+
fields={['name','status','owner']} navigation={{ mode: 'none' }}
805+
onRowClick={(r) => setSel(r)} />
806+
{sel
807+
? <ObjectForm objectName="project" mode="edit" recordId={sel.id}
808+
onSuccess={() => { setSel(null); setReload((k) => k + 1); }} />
809+
: <p style={{ color: 'hsl(var(--muted-foreground))' }}>Select a project to edit.</p>}
812810
</div>
813811
);
814812
}`,
@@ -883,6 +881,26 @@ spec field is `PageComponentSchema.responsiveStyles` (`@objectstack/spec`,
883881
`examples/app-showcase/src/pages/styling-gallery.page.ts` (the "Styling
884882
(ADR-0065)" nav entry). See [ADR-0065](../../docs/adr/0065-sdui-styling-model.md).
885883

884+
**In the source tiers (`kind:'html'` / `kind:'react'`) the same rule holds — no
885+
Tailwind `className` — but the primitive differs:**
886+
887+
- **`kind:'html'`** — lay out with the registered components' own structured props
888+
(`<flex direction="col" gap={6}>`, `<grid columns={4}>` compile their *own*,
889+
already-shipped classes) and add CSS with a **`style` object written as JSON**
890+
(quoted keys/values): `style={{"padding":"40px","color":"hsl(var(--foreground))"}}`.
891+
A JS-style object (`{{padding: 40}}`) is parsed as a deferred expression and will
892+
NOT apply — keys and string values must be double-quoted.
893+
- **`kind:'react'`** — it's real React, so style with an ordinary inline
894+
**`style={{}}`** object using `hsl(var(--token))` theme colors:
895+
`color: 'hsl(var(--foreground))'`, `background: 'hsl(var(--card))'`,
896+
`border: '1px solid hsl(var(--border))'`, `borderRadius: 'var(--radius)'`. Tokens
897+
are HSL **triplets**, so always wrap them: `hsl(var(--card))`, never bare
898+
`var(--card)`; a translucent scrim is `hsl(0 0% 0% / 0.5)`. For a **drawer/modal**,
899+
render `<ObjectForm formType="drawer"|"modal" open onOpenChange={…}>` — it ships a
900+
pre-styled Sheet/Dialog with backdrop + animation; never hand-roll a
901+
`fixed inset-0` overlay (its utility classes won't compile, so it renders as
902+
unstyled boxes with no backdrop).
903+
886904
---
887905

888906
## Docs — Package Documentation (ADR-0046)

0 commit comments

Comments
 (0)