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
5 changes: 5 additions & 0 deletions .changeset/sdui-jsx-page-spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@objectstack/spec": minor
---

ADR-0080: `PageSchema` gains `kind: 'jsx'` + `source` (the authoritative JSX text, compiled to the tree at save time) + `requires`, with a completeness `superRefine` — a jsx page with no source fails loudly (ADR-0078).
76 changes: 76 additions & 0 deletions examples/app-showcase/src/pages/command-center-jsx.page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { definePage } from '@objectstack/spec/ui';

/**
* Command Center — a `kind:'jsx'` page (ADR-0080). The entire layout is
* authored as a constrained JSX + Tailwind *string*; at save time
* `@objectstack/sdui-parser` compiles it (parse, never execute) into the SDUI
* tree, which the normal PageRenderer / SchemaRenderer renders. Every tag is a
* real registered component — `flex`, `grid`, `card`, `text`, `badge`, `stack`.
*
* Demonstrates what the fixed page schema cannot: Tailwind-freeform layout that
* still composes the platform's real components. Browser-verified.
*/
export const CommandCenterJsxPage = definePage({
name: 'showcase_command_center_jsx',
label: 'Command Center (JSX)',
type: 'home',
kind: 'jsx',
source: `
<flex direction="col" className="min-h-screen gap-10 bg-gradient-to-br from-slate-50 via-white to-indigo-50 p-10">

<flex direction="col" className="gap-3">
<badge className="w-fit rounded-full bg-indigo-100 px-3 py-1 text-xs font-semibold uppercase tracking-widest text-indigo-700" label="Operations · JSX-source page" />
<text className="block text-5xl font-bold tracking-tight text-slate-900" content="Command Center" />
<text className="block max-w-2xl text-base leading-relaxed text-slate-500" content="This whole page is authored as constrained JSX + Tailwind and compiled to the SDUI tree — parsed, never executed. Every card is a real registered component." />
</flex>

<grid columns={4} className="gap-5">
<card className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<text className="block text-sm font-medium text-slate-500" content="Open Tasks" />
<text className="mt-3 block text-4xl font-bold text-slate-900" content="128" />
<text className="mt-2 block text-xs font-semibold text-emerald-600" content="▲ 12% vs last week" />
</card>
<card className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<text className="block text-sm font-medium text-slate-500" content="In Progress" />
<text className="mt-3 block text-4xl font-bold text-slate-900" content="47" />
<text className="mt-2 block text-xs font-semibold text-amber-600" content="● 9 due today" />
</card>
<card className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<text className="block text-sm font-medium text-slate-500" content="Completed" />
<text className="mt-3 block text-4xl font-bold text-slate-900" content="1,902" />
<text className="mt-2 block text-xs font-semibold text-emerald-600" content="▲ 4% this month" />
</card>
<card className="rounded-2xl border border-indigo-300 bg-gradient-to-br from-indigo-500 to-violet-600 p-6 shadow-md">
<text className="block text-sm font-medium text-indigo-100" content="Cycle Time" />
<text className="mt-3 block text-4xl font-bold text-white" content="2.4d" />
<text className="mt-2 block text-xs font-semibold text-indigo-100" content="▼ 18% faster" />
</card>
</grid>

<grid columns={3} className="gap-5">
<card className="col-span-2 rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<text className="block text-lg font-semibold text-slate-900" content="Weekly Throughput" />
<flex direction="row" className="mt-8 items-end gap-4">
<flex className="h-12 w-full rounded-lg bg-indigo-400" />
<flex className="h-20 w-full rounded-lg bg-indigo-500" />
<flex className="h-16 w-full rounded-lg bg-indigo-400" />
<flex className="h-28 w-full rounded-lg bg-violet-500" />
<flex className="h-14 w-full rounded-lg bg-indigo-400" />
<flex className="h-24 w-full rounded-lg bg-indigo-500" />
<flex className="h-10 w-full rounded-lg bg-indigo-300" />
</flex>
</card>
<card className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<text className="block text-lg font-semibold text-slate-900" content="Recent Activity" />
<stack className="mt-4 gap-3">
<flex direction="row" className="items-center gap-3"><flex className="h-2 w-2 rounded-full bg-emerald-500" /><text className="text-sm text-slate-600" content="Onboarding flow shipped" /></flex>
<flex direction="row" className="items-center gap-3"><flex className="h-2 w-2 rounded-full bg-indigo-500" /><text className="text-sm text-slate-600" content="12 tasks moved to Review" /></flex>
<flex direction="row" className="items-center gap-3"><flex className="h-2 w-2 rounded-full bg-amber-500" /><text className="text-sm text-slate-600" content="SLA breach on #4821" /></flex>
<flex direction="row" className="items-center gap-3"><flex className="h-2 w-2 rounded-full bg-slate-300" /><text className="text-sm text-slate-600" content="Sprint 42 planning" /></flex>
</stack>
</card>
</grid>
</flex>`,
});
1 change: 1 addition & 0 deletions examples/app-showcase/src/pages/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export { MyWorkPage } from './my-work.page.js';
export { SettingsPage } from './settings.page.js';
export { StylingGalleryPage } from './styling-gallery.page.js';
export { CommandCenterPage } from './command-center.page.js';
export { CommandCenterJsxPage } from './command-center-jsx.page.js';
export { PageVariablesPage } from './page-variables.page.js';
export { ContactFormPage } from './contact-form.page.js';
export {
Expand Down
8 changes: 8 additions & 0 deletions packages/spec/liveness/page.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@
"type": "page",
"_note": "PageSchema (UI). Renderers live in objectui, so consumers are cited as prose in `note` (not `evidence`, which resolves framework file:line). Seeded from the Studio page-design dogfood (framework#2254/#2261/#2265). Containers (variables/regions/interfaceConfig/slots/aria) classified at top level — one drill level, no divergent sub-statuses. The removed roadmap page types (record_review/blank) and their config fields (recordReview/blankLayout) were hard-removed — no longer authorable.",
"props": {
"source": {
"status": "live",
"note": "JSX-source page authoring (ADR-0080). Consumer: objectui PageRenderer compiles `source` via @object-ui/sdui-parser into the SchemaNode tree (parse, never execute) and renders it — components/src/renderers/layout/page.tsx (kind:'jsx' branch). Browser-verified in the Command Center showcase."
},
"requires": {
"status": "planned",
"note": "Plugin namespaces the JSX `source` references (ADR-0080). Inferred at compile time; save/load enforcement of plugin presence is deferred (M3b) — declared, not enforced yet."
},
"name": {
"status": "live",
"note": "page identity; objectui page renderer + runtime route/metadata key."
Expand Down
36 changes: 28 additions & 8 deletions packages/spec/src/ui/page.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,8 +358,8 @@ export const PageSchema = lazySchema(() => z.object({
*
* Only meaningful when `type === 'record'`. Ignored otherwise.
*/
kind: z.enum(['full', 'slotted']).default('full')
.describe('Page override mode: full (default) or slotted (partial overrides)'),
kind: z.enum(['full', 'slotted', 'jsx']).default('full')
.describe('Page override mode: full | slotted | jsx (ADR-0080 JSX-source authoring)'),

/**
* Slot override map for slotted record pages.
Expand All @@ -384,13 +384,33 @@ export const PageSchema = lazySchema(() => z.object({
tabs: z.union([PageComponentSchema, z.array(PageComponentSchema)]).optional(),
discussion: z.union([PageComponentSchema, z.array(PageComponentSchema)]).optional(),
}).optional().describe('Slot override map for slotted pages'),

/**
* JSX-source authoring (ADR-0080). When `kind === 'jsx'`, `source` is the
* source-of-truth: a constrained JSX/HTML+Tailwind text compiled by
* `@objectstack/sdui-parser` into the SchemaNode tree at SAVE time — parse,
* never execute. `regions` then hold the DERIVED tree (a cache; the source
* wins on any mismatch). For `full`/`slotted` pages `source` is unused.
*/
source: z.string().optional()
.describe("JSX-source page text — authoritative when kind==='jsx'; compiled to the tree by @objectstack/sdui-parser at save time (parse, never execute)"),
/** Plugin namespaces the JSX source references — inferred at compile, checked at save AND load (ADR-0048 provenance). */
requires: z.array(z.string()).optional()
.describe('Plugin namespaces the JSX source references (validated at save and load)'),
}).superRefine((page, ctx) => {
// ADR-0080 + ADR-0078 (completeness): a `kind:'jsx'` page with no `source`
// is silently inert — fail loudly at author time, do not render an empty page.
if (page.kind === 'jsx' && !(typeof page.source === 'string' && page.source.trim().length > 0)) {
ctx.addIssue({
code: 'custom',
path: ['source'],
message: "A jsx page requires a non-empty `source` (ADR-0080: JSX is the source-of-truth).",
});
}
}));
// PageSchema has no cross-field (`superRefine`) requirements by design. It once
// required `recordReview`/`blankLayout` for the `record_review`/`blank` types
// (both removed — unrendered roadmap, see PAGE_TYPE_ROADMAP) and `slots` for
// `kind: 'slotted'` (dropped — an empty slotted page validly renders the
// synthesized default). Each of those was a "required-but-unauthorable field
// blocks the Studio create form" trap; none survives.
// PageSchema's only cross-field rule is the ADR-0080 jsx-source completeness
// check above. It once also required `recordReview`/`blankLayout` and `slots`
// (all removed — unrendered roadmap / "required-but-unauthorable" Studio traps).

export type Page = z.infer<typeof PageSchema>;
/** Authoring input for {@link Page} — defaulted fields are optional. */
Expand Down