Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/sdui-page-styling-guardrail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@objectstack/lint": minor
"@objectstack/cli": minor
---

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.
2 changes: 1 addition & 1 deletion content/docs/protocol/objectui/layout-dsl.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ write the page body as a string. Pick by what the page needs:
| `kind` | You write | JavaScript | Use when |
|:-------|:----------|:-----------|:---------|
| `full` / `slotted` *(this doc)* | structured `regions` / `slots` | — | record, detail, home, and app layouts from the component catalogue |
| `html` | constrained JSX = HTML + Tailwind + registered components, **parsed, never executed** | none | free-form layout, landing pages, or dashboards that *compose* blocks — no interactivity |
| `html` | constrained JSX = registered components + safe native HTML, **parsed, never executed** | none | free-form layout, landing pages, or dashboards that *compose* blocks — no interactivity |
| `react` | **real React** (hooks, `.map`, `onClick`, expressions) | executed in the app | complex interactive business UIs — master/detail, wizards, state-driven filters |

For `html` / `react`, the page body is the `source` string (it is the
Expand Down
2 changes: 2 additions & 0 deletions docs/adr/0080-ai-authored-ui-jsx-source.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@

---

> **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).

## TL;DR

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.)
Expand Down
2 changes: 2 additions & 0 deletions docs/adr/0081-trusted-react-page-tier.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@

---

> **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).

## TL;DR

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'`.
Expand Down
15 changes: 14 additions & 1 deletion packages/cli/src/commands/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { loadConfig } from '../utils/config.js';
import { validateStackExpressions } from '@objectstack/lint';
import { validateWidgetBindings } from '@objectstack/lint';
import { validateResponsiveStyles } from '@objectstack/lint';
import { validateJsxPages, validateReactPages, validateReactPageProps } from '@objectstack/lint';
import { validateJsxPages, validateReactPages, validateReactPageProps, validatePageSourceStyling } from '@objectstack/lint';
import {
printHeader,
printKV,
Expand Down Expand Up @@ -273,6 +273,19 @@ export default class Validate extends Command {
this.exit(1);
}

// 3e. Source-tier page styling (ADR-0065): Tailwind className in a
// kind:'html'/'react' page source silently no-ops (the build never
// scans authored metadata) — warn with the inline-style fix.
if (!flags.json) printStep('Checking source-page styling (ADR-0065)...');
const sourceStyleFindings = validatePageSourceStyling(result.data as Record<string, unknown>);
const sourceStyleWarnings = sourceStyleFindings.filter((f) => f.severity === 'warning');
if (!flags.json) {
for (const w of sourceStyleWarnings.slice(0, 50)) {
console.log(chalk.yellow(` \u26a0 ${w.where}: ${w.message}`));
console.log(chalk.dim(` ${w.hint}`));
}
}

// 4. Collect and display stats
const stats = collectMetadataStats(config);

Expand Down
2 changes: 2 additions & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ export { validateReactPages } from './validate-react-pages.js';
export type { ReactPageFinding, ReactPageSeverity } from './validate-react-pages.js';
export { validateReactPageProps } from './validate-react-page-props.js';
export type { ReactPropFinding, ReactPropSeverity } from './validate-react-page-props.js';
export { validatePageSourceStyling, PAGE_SOURCE_CLASSNAME } from './validate-page-source-styling.js';
export type { SourceStyleFinding, SourceStyleSeverity } from './validate-page-source-styling.js';

export {
validateRecordTitle,
Expand Down
40 changes: 40 additions & 0 deletions packages/lint/src/validate-page-source-styling.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
import { describe, it, expect } from 'vitest';
import { validatePageSourceStyling, PAGE_SOURCE_CLASSNAME } from './validate-page-source-styling.js';

const page = (kind: string, source: string) => ({ pages: [{ name: 'p', kind, source }] });

describe('validatePageSourceStyling (ADR-0065 guardrail)', () => {
it('flags Tailwind className in a react source page', () => {
const f = validatePageSourceStyling(page('react', 'function Page(){ return <div className="grid grid-cols-5 p-8" />; }'));
expect(f.length).toBe(1);
expect(f[0].rule).toBe(PAGE_SOURCE_CLASSNAME);
expect(f[0].severity).toBe('warning');
expect(f[0].hint).toMatch(/hsl\(var\(--/);
});

it('flags className in an html source page (with the html-specific hint)', () => {
const f = validatePageSourceStyling(page('html', '<flex className="gap-4"><text content="hi" /></flex>'));
expect(f.length).toBe(1);
expect(f[0].hint).toMatch(/structured props/);
});

it('passes a react page styled with inline style + hsl tokens', () => {
const f = validatePageSourceStyling(page('react', "function Page(){ return <div style={{ color: 'hsl(var(--foreground))' }} />; }"));
expect(f).toEqual([]);
});

it('passes an html page that uses structured props + a style object', () => {
const f = validatePageSourceStyling(page('html', '<flex direction="col" gap={4} style={{"padding":"16px"}} />'));
expect(f).toEqual([]);
});

it('ignores structured (full/slotted) pages without source', () => {
expect(validatePageSourceStyling({ pages: [{ name: 'p', kind: 'full', regions: [] }] })).toEqual([]);
});

it('also covers the deprecated jsx alias', () => {
const f = validatePageSourceStyling(page('jsx', '<flex className="p-4" />'));
expect(f.length).toBe(1);
});
});
65 changes: 65 additions & 0 deletions packages/lint/src/validate-page-source-styling.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// Build-time guardrail for SDUI source-tier page styling (ADR-0065 / ADR-0080 /
// ADR-0081). A `kind:'html'` or `kind:'react'` page's `source` is RUNTIME
// metadata — the console's build-time Tailwind only scans the renderer's own
// source, never an authored page string. So a Tailwind `className` in page
// source silently produces NO CSS (the exact ADR-0065 failure: styling that
// "works only by coincidence" when the class happens to be one objectui already
// ships). This rule flags authored `className` attributes in source-tier pages
// before render, with the actionable fix.
//
// It is the styling counterpart to the react-prop gate: a pure
// `(stack) => Finding[]` rule (ADR-0019), run from `os validate`/`compile` and
// reusable by AI authoring so the agent self-corrects.

export type SourceStyleSeverity = 'error' | 'warning';

export interface SourceStyleFinding {
severity: SourceStyleSeverity;
rule: string;
where: string;
path: string;
message: string;
hint: string;
}

export const PAGE_SOURCE_CLASSNAME = 'page-source-className-tailwind';

type AnyRec = Record<string, unknown>;
const asArray = (v: unknown): AnyRec[] => (Array.isArray(v) ? (v as AnyRec[]) : []);

// `className=` as a JSX attribute: name, optional ws, `=`, then `"`/`'`/`{`.
const CLASSNAME_ATTR = /\bclassName\s*=\s*["'{]/g;

export function validatePageSourceStyling(stack: AnyRec): SourceStyleFinding[] {
const findings: SourceStyleFinding[] = [];
const pages = asArray(stack.pages);
for (let p = 0; p < pages.length; p++) {
const page = pages[p];
if (!page) continue;
const kind = page.kind;
if (kind !== 'html' && kind !== 'react' && kind !== 'jsx') continue;
const source = page.source;
if (typeof source !== 'string' || source.trim() === '') continue;
const name = String(page.name ?? `#${p}`);

CLASSNAME_ATTR.lastIndex = 0;
let count = 0;
while (CLASSNAME_ATTR.exec(source) !== null) count++;
if (count === 0) continue;

findings.push({
severity: 'warning',
rule: PAGE_SOURCE_CLASSNAME,
where: `page "${name}"`,
path: `pages[${p}].source`,
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).`,
hint:
kind === 'react'
? "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."
: "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.",
});
}
return findings;
}
2 changes: 1 addition & 1 deletion packages/spec/scripts/build-react-blocks-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ const contract = {
version: 2,
adr: 'ADR-0081',
source: 'GENERATED from packages/spec/src/ui/react-blocks.ts — data props from the spec zod schemas, binding/controlled/callback from the React overlay.',
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.",
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.",
blocks,
};
fs.writeFileSync(OUT_JSON, JSON.stringify(contract, null, 2) + '\n');
Expand Down
60 changes: 39 additions & 21 deletions skills/objectstack-ui/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -731,7 +731,7 @@ by what the page needs:
| `kind` | Author writes | JS runs? | Use when |
|:--|:--|:--|:--|
| `full` / `slotted` | structured `regions` / `slots` (no `source`) | — | record/detail/home layouts from the component catalogue |
| `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 |
| `html` | constrained JSX = registered components + safe native HTML, **parsed, never executed** | no | free-form layout / landing / dashboard that just *composes* blocks — no interactivity |
| `react` | **real React** (hooks, `.map`, `onClick`, expressions) | yes (main React tree) | complex interactive business UIs — master/detail, wizards, state-driven filters |

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

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

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

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

**In the source tiers (`kind:'html'` / `kind:'react'`) the same rule holds — no
Tailwind `className` — but the primitive differs:**

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

---

## Docs — Package Documentation (ADR-0046)
Expand Down
2 changes: 1 addition & 1 deletion skills/objectstack-ui/contracts/react-blocks.contract.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"version": 2,
"adr": "ADR-0081",
"source": "GENERATED from packages/spec/src/ui/react-blocks.ts — data props from the spec zod schemas, binding/controlled/callback from the React overlay.",
"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.",
"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.",
"blocks": [
{
"tag": "ObjectForm",
Expand Down
Loading