Skip to content

Commit 2be5c1f

Browse files
os-zhuangclaude
andauthored
feat(showcase): page-local state demo + flip variables @experimental→live (ADR-0049) (#2269)
The page-variable end-to-end loop is now implemented in objectui (page state injected as `page.<var>`, element:record_picker writes via `source`, visibility gates live), so promote PageSchema.variables from @experimental to live and ship a worked example. - spec: replace the @experimental marker on PageSchema.variables with live docs; enrich PageVariableSchema (binding direction: a variable names its writer component via `source`). - spec liveness ledger: flip page.variables experimental→live with the objectui consumer cited (packages/spec/liveness/page.json) — passes check:liveness. - showcase: new master/detail page `showcase_page_variables` — a record picker writes `selectedProjectId`; a detail panel gates on `page.selectedProjectId`. Wired into navigation + the app config. - dogfood test: asserts the wiring is coherent and the gating predicates flip with the variable (mutually exclusive empty/detail states) + registration. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 7697a0e commit 2be5c1f

8 files changed

Lines changed: 268 additions & 20 deletions

File tree

.changeset/page-variables-live.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
Promote `PageSchema.variables` from @experimental to live (ADR-0049)
6+
7+
Page-local state is now wired end-to-end (runtime in objectui#1957: page
8+
variables are injected into the visible/CEL expression context as `page.<var>`,
9+
and `element:record_picker` writes a variable via its `source` binding). The
10+
spec docs are updated to describe the now-live behaviour and the binding
11+
direction, and the liveness ledger entry is flipped `experimental → live`.

examples/app-showcase/objectstack.config.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import { ChartGalleryDashboard, OpsDashboard } from './src/dashboards/index.js';
2222
import { ShowcaseTaskDataset, ShowcaseProjectDataset } from './src/datasets/index.js';
2323
import { allReports } from './src/reports/index.js';
2424
import { allActions } from './src/actions/index.js';
25-
import { ComponentGalleryPage, ProjectWorkspacePage, ProjectDetailPage, TaskWorkbenchPage, TaskTriagePage, TaskBoardPage, TaskCalendarPage, TaskGalleryPage, TaskSchedulePage, TaskTimelinePage, TaskMapPage, TaskAllViewsPage, ActiveProjectsPage, TaskDetailPage, AccountDetailPage, ReviewQueuePage, NewProjectWizardPage, MyWorkPage, SettingsPage, StylingGalleryPage, CommandCenterPage } from './src/pages/index.js';
25+
import { ComponentGalleryPage, ProjectWorkspacePage, ProjectDetailPage, TaskWorkbenchPage, TaskTriagePage, TaskBoardPage, TaskCalendarPage, TaskGalleryPage, TaskSchedulePage, TaskTimelinePage, TaskMapPage, TaskAllViewsPage, ActiveProjectsPage, TaskDetailPage, AccountDetailPage, ReviewQueuePage, NewProjectWizardPage, MyWorkPage, SettingsPage, StylingGalleryPage, CommandCenterPage, PageVariablesPage } from './src/pages/index.js';
2626
import { allFlows } from './src/flows/index.js';
2727
import { allWebhooks } from './src/webhooks/index.js';
2828
import { allHooks } from './src/hooks/index.js';
@@ -156,7 +156,7 @@ export default defineStack({
156156
apps: [ShowcaseApp],
157157
portals: allPortals,
158158
views: [TaskViews, ProjectViews, InquiryViews, BusinessUnitViews],
159-
pages: [ComponentGalleryPage, ProjectWorkspacePage, ProjectDetailPage, TaskWorkbenchPage, TaskTriagePage, TaskBoardPage, TaskCalendarPage, TaskGalleryPage, TaskSchedulePage, TaskTimelinePage, TaskMapPage, TaskAllViewsPage, ActiveProjectsPage, TaskDetailPage, AccountDetailPage, ReviewQueuePage, NewProjectWizardPage, MyWorkPage, SettingsPage, StylingGalleryPage, CommandCenterPage],
159+
pages: [ComponentGalleryPage, ProjectWorkspacePage, ProjectDetailPage, TaskWorkbenchPage, TaskTriagePage, TaskBoardPage, TaskCalendarPage, TaskGalleryPage, TaskSchedulePage, TaskTimelinePage, TaskMapPage, TaskAllViewsPage, ActiveProjectsPage, TaskDetailPage, AccountDetailPage, ReviewQueuePage, NewProjectWizardPage, MyWorkPage, SettingsPage, StylingGalleryPage, CommandCenterPage, PageVariablesPage],
160160
dashboards: [ChartGalleryDashboard, OpsDashboard],
161161
books: allBooks,
162162
datasets: [ShowcaseTaskDataset, ShowcaseProjectDataset],

examples/app-showcase/src/apps/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ export const ShowcaseApp = App.create({
6666
children: [
6767
{ id: 'nav_gallery', type: 'page', pageName: 'showcase_component_gallery', label: 'Component Gallery', icon: 'layout-template' },
6868
{ id: 'nav_styling_gallery', type: 'page', pageName: 'showcase_styling_gallery', label: 'Styling (ADR-0065)', icon: 'palette' },
69+
{ id: 'nav_page_variables', type: 'page', pageName: 'showcase_page_variables', label: 'Page Variables', icon: 'mouse-pointer-click' },
6970
{ id: 'nav_project_workspace', type: 'page', pageName: 'showcase_project_workspace', label: 'New Project + Tasks', icon: 'folder-plus' },
7071
// ADR-0047 interface mode: same object as nav_tasks, curated surface.
7172
{ id: 'nav_task_workbench', type: 'page', pageName: 'showcase_task_workbench', label: 'Task Workbench', icon: 'sliders-horizontal' },

examples/app-showcase/src/pages/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export { MyWorkPage } from './my-work.page.js';
1515
export { SettingsPage } from './settings.page.js';
1616
export { StylingGalleryPage } from './styling-gallery.page.js';
1717
export { CommandCenterPage } from './command-center.page.js';
18+
export { PageVariablesPage } from './page-variables.page.js';
1819
export {
1920
TaskBoardPage,
2021
TaskCalendarPage,
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { definePage } from '@objectstack/spec/ui';
4+
5+
/**
6+
* Page Variables — master/detail driven by page-local state (PageSchema.variables).
7+
*
8+
* Demonstrates the end-to-end page-variable loop:
9+
* 1. `variables` declares `selectedProjectId`, fed by the component whose id
10+
* is `project_picker` (PageVariableSchema.source = that component id).
11+
* 2. `element:record_picker` (id: project_picker) writes the chosen project's
12+
* id into that variable on selection.
13+
* 3. Sibling components gate on `page.selectedProjectId` via `visibility`:
14+
* the empty-state hint shows while nothing is picked; the detail panel
15+
* appears the moment a project is chosen — re-evaluated live, no reload.
16+
*
17+
* This is the canonical low-code "filtered detail" pattern: one picker drives
18+
* what the rest of the page shows, with no custom code.
19+
*/
20+
export const PageVariablesPage = definePage({
21+
name: 'showcase_page_variables',
22+
label: 'Page Variables (Master/Detail)',
23+
icon: 'mouse-pointer-click',
24+
type: 'app',
25+
template: 'header-sidebar-main',
26+
isDefault: false,
27+
// Page-local state. `selectedProjectId` is written by the `project_picker`
28+
// element (source = its component id) and read by predicates as `page.selectedProjectId`.
29+
variables: [
30+
{ name: 'selectedProjectId', type: 'record_id', source: 'project_picker' },
31+
],
32+
regions: [
33+
{
34+
name: 'header',
35+
width: 'full',
36+
components: [
37+
{
38+
type: 'page:header',
39+
properties: {
40+
title: 'Page Variables',
41+
subtitle: 'Pick a project — page-local state drives what shows below, live.',
42+
},
43+
},
44+
],
45+
},
46+
{
47+
name: 'main',
48+
width: 'large',
49+
components: [
50+
{
51+
type: 'element:text',
52+
properties: {
53+
content:
54+
'This page declares a `selectedProjectId` variable. The record picker writes the selected project into it; the detail panel below is gated on `page.selectedProjectId` and only appears once you choose. No custom code — just metadata.',
55+
variant: 'body',
56+
},
57+
},
58+
{
59+
type: 'element:record_picker',
60+
id: 'project_picker',
61+
dataSource: { object: 'showcase_project', limit: 50 },
62+
properties: {
63+
label: 'Project',
64+
labelField: 'name',
65+
placeholder: 'Choose a project…',
66+
},
67+
},
68+
// Empty state — visible until a project is chosen.
69+
{
70+
type: 'element:text',
71+
id: 'empty_hint',
72+
visibility: "page.selectedProjectId == ''",
73+
properties: {
74+
content: '↑ Select a project above to reveal its detail panel.',
75+
variant: 'caption',
76+
},
77+
},
78+
// Detail panel — gated on the page variable. Appears once a project is picked.
79+
{
80+
type: 'element:divider',
81+
id: 'detail_divider',
82+
visibility: "page.selectedProjectId != ''",
83+
},
84+
{
85+
type: 'element:text',
86+
id: 'detail_heading',
87+
visibility: "page.selectedProjectId != ''",
88+
properties: {
89+
content: '✓ Project selected',
90+
variant: 'subheading',
91+
},
92+
},
93+
{
94+
type: 'element:text',
95+
id: 'detail_body',
96+
visibility: "page.selectedProjectId != ''",
97+
properties: {
98+
content:
99+
'This panel is gated on `page.selectedProjectId != ""`. It became visible the instant the picker wrote the variable — the same page-local state any other component (or its data filter) can read.',
100+
variant: 'body',
101+
},
102+
},
103+
],
104+
},
105+
],
106+
});
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
5+
import { PageVariablesPage } from '../src/pages/index.js';
6+
import stack from '../objectstack.config.js';
7+
import { ShowcaseApp } from '../src/apps/index.js';
8+
9+
/**
10+
* Dogfood gate for page-local state (PageSchema.variables, ADR-0049).
11+
*
12+
* In the "demonstrated AND verified" spirit: it is not enough that the page
13+
* *declares* a variable and a picker. These assertions prove the wiring is
14+
* coherent end-to-end — the variable names a real writer component, the gating
15+
* predicates reference that variable, and (crucially) the predicates actually
16+
* gate the way the demo claims when the variable flips. A page that merely
17+
* looked plausible but mis-wired the `source` id or inverted a predicate would
18+
* pass a shape-only check but fail here.
19+
*/
20+
21+
type AnyComponent = {
22+
type: string;
23+
id?: string;
24+
visibility?: unknown;
25+
[k: string]: unknown;
26+
};
27+
28+
/** Flatten every component across the page's regions. */
29+
function allComponents(page: typeof PageVariablesPage): AnyComponent[] {
30+
const out: AnyComponent[] = [];
31+
for (const region of page.regions ?? []) {
32+
for (const c of region.components ?? []) out.push(c as AnyComponent);
33+
}
34+
return out;
35+
}
36+
37+
/** Extract a predicate's CEL source whether stored as a bare string or the
38+
* normalized `{ dialect, source }` envelope that definePage produces. */
39+
function predicateSource(visibility: unknown): string | undefined {
40+
if (typeof visibility === 'string') return visibility;
41+
if (visibility && typeof visibility === 'object' && typeof (visibility as any).source === 'string') {
42+
return (visibility as any).source;
43+
}
44+
return undefined;
45+
}
46+
47+
/** Evaluate a (simple, comparison-only) CEL predicate against a page scope.
48+
* Sufficient for the `==` / `!=` predicates this page uses. */
49+
function evalPredicate(source: string, page: Record<string, unknown>): boolean {
50+
// eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
51+
const fn = new Function('page', `"use strict"; return (${source});`) as (
52+
p: Record<string, unknown>,
53+
) => boolean;
54+
return Boolean(fn(page));
55+
}
56+
57+
describe('Page Variables showcase — page-local state (ADR-0049)', () => {
58+
it('parses and declares the selectedProjectId variable bound to the picker', () => {
59+
expect(PageVariablesPage.name).toBe('showcase_page_variables');
60+
61+
const vars = PageVariablesPage.variables ?? [];
62+
const sel = vars.find((v) => v.name === 'selectedProjectId');
63+
expect(sel, 'selectedProjectId variable must exist').toBeTruthy();
64+
expect(sel!.type).toBe('record_id');
65+
// source names the WRITER component id.
66+
expect(sel!.source).toBe('project_picker');
67+
});
68+
69+
it('ships a record picker whose id matches the variable source', () => {
70+
const picker = allComponents(PageVariablesPage).find((c) => c.id === 'project_picker');
71+
expect(picker, 'a component with id project_picker must exist').toBeTruthy();
72+
expect(picker!.type).toBe('element:record_picker');
73+
// It binds to a real object so the picker has something to load.
74+
expect((picker as any).dataSource?.object).toBe('showcase_project');
75+
});
76+
77+
it('gates its detail panel on the variable — hidden until a project is picked, shown after', () => {
78+
const comps = allComponents(PageVariablesPage);
79+
const gated = comps.filter((c) => c.visibility !== undefined);
80+
// Empty-hint + divider + heading + body — every gated node references the variable.
81+
expect(gated.length).toBeGreaterThanOrEqual(2);
82+
83+
const empty = { page: { selectedProjectId: '' } as Record<string, unknown> };
84+
const picked = { page: { selectedProjectId: 'proj_42' } as Record<string, unknown> };
85+
86+
let shownWhenEmpty = 0;
87+
let shownWhenPicked = 0;
88+
for (const c of gated) {
89+
const src = predicateSource(c.visibility);
90+
expect(src, `gated component ${c.id} must carry a predicate`).toBeTruthy();
91+
// Every gating predicate is about the page variable.
92+
expect(src).toContain('page.selectedProjectId');
93+
if (evalPredicate(src!, empty.page)) shownWhenEmpty++;
94+
if (evalPredicate(src!, picked.page)) shownWhenPicked++;
95+
}
96+
97+
// The empty-state hint shows only when nothing is picked; the detail panel
98+
// (divider + heading + body) shows only after a pick. So the visible set
99+
// strictly flips between the two states — proving the variable drives the UI.
100+
expect(shownWhenEmpty).toBeGreaterThanOrEqual(1); // the empty hint
101+
expect(shownWhenPicked).toBeGreaterThanOrEqual(1); // the detail panel
102+
// The empty-state predicate and the detail predicates are mutually exclusive:
103+
// no gated node is visible in BOTH states.
104+
for (const c of gated) {
105+
const src = predicateSource(c.visibility)!;
106+
const inEmpty = evalPredicate(src, empty.page);
107+
const inPicked = evalPredicate(src, picked.page);
108+
expect(inEmpty && inPicked, `component ${c.id} should not be visible in both states`).toBe(false);
109+
}
110+
});
111+
112+
it('is registered in the app config and reachable from navigation', () => {
113+
const pageNames = (stack.pages ?? []).map((p: any) => p.name);
114+
expect(pageNames).toContain('showcase_page_variables');
115+
116+
// Navigation has a link to the page.
117+
const flat = JSON.stringify(ShowcaseApp.navigation ?? []);
118+
expect(flat).toContain('showcase_page_variables');
119+
});
120+
});

packages/spec/liveness/page.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@
2323
"note": "objectui PageView (app-shell/src/views/PageView.tsx) + components/renderers/layout/page.tsx dispatch on the page kind (record/home/app/utility/list). Only these render — roadmap types removed (PAGE_TYPE_ROADMAP, framework#2265)."
2424
},
2525
"variables": {
26-
"status": "experimental",
27-
"note": "Page-local state. objectui mounts PageVariablesProvider + usePageVariables (react/src/hooks/usePageVariables.tsx) with default-value init, but no shipped consumer reads/writes them end-to-end (no element:record_picker writer; not injected into the visible/CEL context). Spec field is @experimental. Complete-or-remove tracked separately."
26+
"status": "live",
27+
"note": "Page-local state (ADR-0049). objectui injects variables into the visible/CEL expression context as `page.<var>` (react/src/SchemaRenderer.tsx) and ships element:record_picker, which writes its selection into the variable named by PageVariableSchema.source (components/src/renderers/basic/record-picker.tsx); usePageVariableBinding resolves writer->variable. Showcase: showcase_page_variables (master/detail — picker drives a detail panel's visibility). objectui#1957."
2828
},
2929
"object": {
3030
"status": "live",

packages/spec/src/ui/page.zod.ts

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -120,15 +120,26 @@ export const PageComponentSchema = lazySchema(() => z.object({
120120

121121
/**
122122
* Page Variable Schema
123-
* Defines local state for the page.
124-
* Variables can be bound to interactive elements (e.g. element:record_picker, element:filter).
123+
* Local, in-memory page state. Runtime-live (ADR-0049): the renderer mounts the
124+
* declared variables, exposes them to expressions as `page.<name>`, and lets an
125+
* interactive element write one via `source`. A write re-evaluates dependent
126+
* `visibility` / binding predicates immediately — the master/detail and
127+
* filtered-dashboard pattern with no custom code.
128+
*
129+
* Binding direction: a variable names the **writer** component, not the other
130+
* way round. `{ name: 'selectedProjectId', source: 'project_picker' }` means the
131+
* component whose `id` is `project_picker` (e.g. an `element:record_picker`)
132+
* writes the user's selection into `selectedProjectId`; predicates then read it
133+
* as `page.selectedProjectId`.
125134
*/
126135
export const PageVariableSchema = lazySchema(() => z.object({
127-
name: z.string().describe('Variable name'),
136+
name: z.string().describe('Variable name. Exposed to expressions as `page.<name>`.'),
128137
type: z.enum(['string', 'number', 'boolean', 'object', 'array', 'record_id']).default('string'),
129-
defaultValue: z.unknown().optional(),
130-
/** Source element binding (e.g. element:record_picker writes to this variable) */
131-
source: z.string().optional().describe('Component ID that writes to this variable'),
138+
defaultValue: z.unknown().optional()
139+
.describe('Initial value. Defaults to a type-appropriate empty value when omitted.'),
140+
/** Source element binding — the component id that writes this variable. */
141+
source: z.string().optional()
142+
.describe('Component id that writes this variable (e.g. an element:record_picker whose `id` matches).'),
132143
}));
133144

134145
// BlankPageLayoutItemSchema / BlankPageLayoutSchema removed — the `blank` page
@@ -292,17 +303,15 @@ export const PageSchema = lazySchema(() => z.object({
292303
type: PageTypeSchema.default('record').describe('Page type'),
293304

294305
/**
295-
* Page-local state variables.
296-
*
297-
* @experimental The state container is wired (the runtime mounts a
298-
* `PageVariablesProvider` + `usePageVariables` hook with default-value init),
299-
* but the end-to-end loop is not proven: no shipped element writes a variable
300-
* (e.g. `element:record_picker` → `source`) and page variables are not yet
301-
* injected into the `visible`/binding expression context. Authoring variables
302-
* is therefore mostly inert today — treat as a preview surface until a
303-
* consumer + an example/proof land.
306+
* Page-local state variables (ADR-0049). Runtime-live: the renderer mounts the
307+
* declared variables, exposes each to expressions as `page.<name>`, and lets an
308+
* interactive element write one via its `source` binding (e.g.
309+
* `element:record_picker` → `source`). A write re-evaluates dependent
310+
* `visibility` / binding predicates immediately — the master/detail and
311+
* filtered-dashboard pattern, with no custom code. See {@link PageVariableSchema}.
304312
*/
305-
variables: z.array(PageVariableSchema).optional().describe('Local page state variables (experimental — see schema doc)'),
313+
variables: z.array(PageVariableSchema).optional()
314+
.describe('Local page state, exposed to expressions as `page.<name>` and writable by interactive elements via `source` (master/detail, filtered dashboards).'),
306315

307316
/** Context */
308317
object: z.string().optional().describe('Bound object (for Record pages)'),

0 commit comments

Comments
 (0)