diff --git a/.changeset/sdui-page-styling-guardrail.md b/.changeset/sdui-page-styling-guardrail.md new file mode 100644 index 000000000..0f9d2e3a4 --- /dev/null +++ b/.changeset/sdui-page-styling-guardrail.md @@ -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. diff --git a/content/docs/protocol/objectui/layout-dsl.mdx b/content/docs/protocol/objectui/layout-dsl.mdx index 2c5872876..ec5f73a90 100644 --- a/content/docs/protocol/objectui/layout-dsl.mdx +++ b/content/docs/protocol/objectui/layout-dsl.mdx @@ -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 diff --git a/docs/adr/0080-ai-authored-ui-jsx-source.md b/docs/adr/0080-ai-authored-ui-jsx-source.md index 8f4b2be21..97a711eaf 100644 --- a/docs/adr/0080-ai-authored-ui-jsx-source.md +++ b/docs/adr/0080-ai-authored-ui-jsx-source.md @@ -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 `` (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.) diff --git a/docs/adr/0081-trusted-react-page-tier.md b/docs/adr/0081-trusted-react-page-tier.md index 1b4dcc80f..f81990198 100644 --- a/docs/adr/0081-trusted-react-page-tier.md +++ b/docs/adr/0081-trusted-react-page-tier.md @@ -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 `` (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'`. diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index 7b59734fa..66d1711fd 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -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, @@ -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); + 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); diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 23d16f10e..1881525c6 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -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, diff --git a/packages/lint/src/validate-page-source-styling.test.ts b/packages/lint/src/validate-page-source-styling.test.ts new file mode 100644 index 000000000..a5f3bacff --- /dev/null +++ b/packages/lint/src/validate-page-source-styling.test.ts @@ -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
; }')); + 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', '')); + 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
; }")); + expect(f).toEqual([]); + }); + + it('passes an html page that uses structured props + a style object', () => { + const f = validatePageSourceStyling(page('html', '')); + 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', '')); + expect(f.length).toBe(1); + }); +}); diff --git a/packages/lint/src/validate-page-source-styling.ts b/packages/lint/src/validate-page-source-styling.ts new file mode 100644 index 000000000..997864f72 --- /dev/null +++ b/packages/lint/src/validate-page-source-styling.ts @@ -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; +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 instead of hand-rolled overlays." + : "Lay out with the components' structured props (, ) and add CSS via a JSON style object style={{\"color\":\"hsl(var(--foreground))\"}}; do not use Tailwind className.", + }); + } + return findings; +} diff --git a/packages/spec/scripts/build-react-blocks-contract.ts b/packages/spec/scripts/build-react-blocks-contract.ts index 11a9baad0..6779cfa1e 100644 --- a/packages/spec/scripts/build-react-blocks-contract.ts +++ b/packages/spec/scripts/build-react-blocks-contract.ts @@ -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 (a pre-styled Sheet/Dialog) — never hand-roll a fixed inset-0 backdrop.", blocks, }; fs.writeFileSync(OUT_JSON, JSON.stringify(contract, null, 2) + '\n'); diff --git a/skills/objectstack-ui/SKILL.md b/skills/objectstack-ui/SKILL.md index 265923d71..7e4314afa 100644 --- a/skills/objectstack-ui/SKILL.md +++ b/skills/objectstack-ui/SKILL.md @@ -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 @@ -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: ` -
-

Release Notes

- -
`, + +

Release Notes

+ +
`, }); ``` @@ -772,10 +772,12 @@ The source is real React executed at render by the runtime. The injected scope a component; `` 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. `` honors -`objectName` / `mode` / `recordId` / `onSuccess` / `onCancel`; `` 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. `` honors `objectName` / `mode` / `recordId` / +`formType` / `onSuccess` / `onCancel`; `` 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 @@ -797,18 +799,14 @@ function Page() { const [sel, setSel] = React.useState(null); const [reload, setReload] = React.useState(0); return ( -
-
- setSel(r)} /> -
-
- {sel - ? { setSel(null); setReload((k) => k + 1); }} /> - :

Select a project to edit.

} -
+
+ setSel(r)} /> + {sel + ? { setSel(null); setReload((k) => k + 1); }} /> + :

Select a project to edit.

}
); }`, @@ -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 + (``, `` 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 `` — 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) diff --git a/skills/objectstack-ui/contracts/react-blocks.contract.json b/skills/objectstack-ui/contracts/react-blocks.contract.json index c5d55c572..85dfa2fa9 100644 --- a/skills/objectstack-ui/contracts/react-blocks.contract.json +++ b/skills/objectstack-ui/contracts/react-blocks.contract.json @@ -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 (a pre-styled Sheet/Dialog) — never hand-roll a fixed inset-0 backdrop.", "blocks": [ { "tag": "ObjectForm", diff --git a/skills/objectstack-ui/references/react-blocks.md b/skills/objectstack-ui/references/react-blocks.md index 7f7768a77..326714fe2 100644 --- a/skills/objectstack-ui/references/react-blocks.md +++ b/skills/objectstack-ui/references/react-blocks.md @@ -7,7 +7,7 @@ description: Props each injected component accepts in kind:'react' page source ( # React-tier component contract (ADR-0081) -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. +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 (a pre-styled Sheet/Dialog) — never hand-roll a fixed inset-0 backdrop. **kind**: `data` = declarative config (from the spec schema — the authoritative source) · `binding` = connects the block to data · `controlled` = drive from React state · `callback` = a React function the block calls.