diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c14bca44e..af9bd8c45b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -180,6 +180,13 @@ jobs: - 'skills/**' - '.github/workflows/scaffold-e2e.yml' - '.claude/skills/spec-property-retirement/SKILL.md' + # @objectstack/lint's production-witness suite reads the repo-root + # SDUI manifest (#12924) — verbatim per the declaration in + # scripts/cross-package-test-inputs.mjs (check:ci-filter-parity + # holds this list to the declarations; the examples/ page files + # the same suite declares are covered by entries the core filter + # already carries). + - 'sdui.manifest.json' test: # Sharded 6-way BY PACKAGE: a core-touching PR ran the affected suite diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index c13f30a6e6..f3910cac8c 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -553,6 +553,23 @@ jobs: - name: sdui-parser stays in lockstep with objectui's copy run: pnpm check:sdui-lockstep + # The checked-in SDUI public-tier manifest (#12924, ruled 2026-08-29: wire + # it). `resolveSduiManifest()` degrades to parse-only SILENTLY when the + # repo-root `sdui.manifest.json` is absent or unreadable, so nothing in + # the production path ever says "the gate lost its whitelist" — this step + # is the thing that notices. Offline record checks only (existence, shape, + # sha256 vs the provenance record, record pin vs `.objectui-sha`): the + # regenerate-and-compare half runs where a regeneration can (the Node + # generator needs the published @object-ui set — see + # scripts/gen-sdui-manifest-node.mjs), and a pin bump goes red HERE until + # it has been re-run — the same moment the lockstep step above forces the + # parser parity re-verification, with an objectui checkout guaranteed in + # hand. Unfiltered for the same reason as its neighbour: filtering to the + # artefact would go dormant on exactly the PR that moves the pin. + # Direct invocation per the GATE INVOCATION IDIOM at the top of this file. + - name: SDUI manifest is present, intact and fresh at the objectui pin + run: node scripts/check-sdui-manifest.mjs --self-test && node scripts/check-sdui-manifest.mjs + # pm-dispatch SKILL.md line ratchet (#7341 / #5925 item 7): the PM skill # is read in full by every seat session and every Routine fire, so its # size is a per-round tax on the whole fleet. The #7341 extraction moved @@ -4747,6 +4764,18 @@ jobs: - name: Check docs YAML examples parse against live spec schemas run: pnpm --filter @objectstack/spec run check:yaml-examples + # ADR-0082 D4's spec↔registry react-block declaration-parity ratchet, fed + # by the checked-in repo-root manifest (#12924 execution point 1: the same + # artefact resolves this gate's EXTERNAL_INPUT_REQUIRED standing — the + # input it always lacked now lives in the tree, held fresh by the + # check-sdui-manifest step in the lint job). Baseline-ratcheted: only a + # divergence NEW since packages/spec/react-declaration-parity.baseline.json + # reds, so wiring it per-PR promotes the cut-rc-only run without + # manufacturing a standing red. Reads spec src/ through tsx like its + # neighbours — no dist. ~5 s. + - name: Ratchet spec-vs-registry react declaration parity against the checked-in manifest + run: MANIFEST="$PWD/sdui.manifest.json" pnpm --filter @objectstack/spec check:react-declaration-parity --baseline react-declaration-parity.baseline.json --strict + # Same anti-drift class as the gates above, for the generated translation # bundles in packages/platform-objects/src/apps/translations/. Nothing # regenerated them either, so they rotted three ways at once (#3670): diff --git a/packages/lint/src/sdui-jsx-baseline.json b/packages/lint/src/sdui-jsx-baseline.json new file mode 100644 index 0000000000..6eefe57235 --- /dev/null +++ b/packages/lint/src/sdui-jsx-baseline.json @@ -0,0 +1,39 @@ +{ + "//": [ + "RATCHET-TO-ZERO ledger for the first live run of the wired SDUI JSX gate (#12924,", + "ruled 2026-08-29: wire it; first-wiring noise handled per ui#6779's ratchet-to-zero —", + "itemized, dated, visible, and NOT an exemption). Every row is a diagnostic the wired", + "`validateJsxPages` (real repo-root sdui.manifest.json, real shipped pages) emits TODAY.", + "", + "Direction 1 — a finding NOT covered by a row is a NEW violation: fix the page (or, if", + "the vocabulary legitimately grew, regenerate the manifest); never grow this file.", + "Direction 2 — a row the live run no longer produces is a STALE entry: delete it in the", + "same PR that fixed the page (or that landed the vocabulary the tags were waiting for).", + "The ledger only shrinks. Zero rows = delete the file and the census assertion inverts", + "to 'wired run is clean'.", + "", + "Why these 232 exist at all: the public tier deliberately declares no intrinsic HTML", + "tags (57 curated blocks; div/a/p absent; `html` is the tier-C escape hatch), while the", + "three shipped html pages author with div/a — every error below is a plain HTML tag,", + "none is a real authoring mistake (measured 2026-08-29, re-measured 2026-08-30). The", + "not-a-container rows are objectui#6740 (flex registers without isContainer). What the", + "html tier's legal vocabulary IS remains a producer-side question (objectui); this", + "ledger keeps the gate live and honest while that question settles." + ], + "recordedAt": "2026-08-30", + "findings": [ + { "page": "showcase_capability_map", "rule": "jsx-forbidden-tag", "severity": "error", "tag": "a", "count": 17 }, + { "page": "showcase_capability_map", "rule": "jsx-forbidden-tag", "severity": "error", "tag": "div", "count": 23 }, + { "page": "showcase_capability_map", "rule": "jsx-not-a-container", "severity": "warning", "tag": "flex", "count": 14 }, + { "page": "showcase_capability_map", "rule": "jsx-unknown-component", "severity": "error", "tag": "a", "count": 17 }, + { "page": "showcase_capability_map", "rule": "jsx-unknown-component", "severity": "error", "tag": "div", "count": 23 }, + { "page": "showcase_command_center_jsx", "rule": "jsx-forbidden-tag", "severity": "error", "tag": "div", "count": 29 }, + { "page": "showcase_command_center_jsx", "rule": "jsx-not-a-container", "severity": "warning", "tag": "flex", "count": 12 }, + { "page": "showcase_command_center_jsx", "rule": "jsx-unknown-component", "severity": "error", "tag": "div", "count": 29 }, + { "page": "showcase_start_here", "rule": "jsx-forbidden-tag", "severity": "error", "tag": "a", "count": 4 }, + { "page": "showcase_start_here", "rule": "jsx-forbidden-tag", "severity": "error", "tag": "div", "count": 27 }, + { "page": "showcase_start_here", "rule": "jsx-not-a-container", "severity": "warning", "tag": "flex", "count": 6 }, + { "page": "showcase_start_here", "rule": "jsx-unknown-component", "severity": "error", "tag": "a", "count": 4 }, + { "page": "showcase_start_here", "rule": "jsx-unknown-component", "severity": "error", "tag": "div", "count": 27 } + ] +} diff --git a/packages/lint/src/validate-jsx-pages.production-witness.test.ts b/packages/lint/src/validate-jsx-pages.production-witness.test.ts new file mode 100644 index 0000000000..f5a16eb356 --- /dev/null +++ b/packages/lint/src/validate-jsx-pages.production-witness.test.ts @@ -0,0 +1,164 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Production-path witness + first-wiring ratchet for the SDUI JSX gate +// (#12924, maintainer ruling 2026-08-29: wire it; execution point 3 demands a +// witness that REALLY PARSES the checked-in manifest into `validateTree`). +// +// ── Why this file exists, stated as the blind spot it closes ────────────── +// +// Every other guard on `validateTree` in this repo constructs its manifest +// IN MEMORY, so a green suite was compatible with the production gate being +// parse-only for the whole life of the code — no test resolved a real +// artefact, because there was nothing to resolve (#12924's finding). These +// tests read the REAL repo-root `sdui.manifest.json` from DISK, feed it +// through the REAL production entry points, and pin the arming delta itself. +// +// Two cross-package inputs, both declared so the graph can see them +// (`check:examples-live-imports`, `@objectstack/lint#test` inputs in +// turbo.json): the repo-root artefact, and the three shipped html pages. + +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +import { validateJsxPages } from './validate-jsx-pages.js'; +import { runAuthoringRules } from './authoring-rules.js'; + +import { CapabilityMapPage } from '../../../examples/app-showcase/src/ui/pages/capability-map.page.js'; +import { CommandCenterJsxPage } from '../../../examples/app-showcase/src/ui/pages/command-center-jsx.page.js'; +import { StartHerePage } from '../../../examples/app-showcase/src/ui/pages/start-here.page.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +interface LedgerRow { + page: string; + rule: string; + severity: string; + tag: string; + count: number; +} +// readFileSync rather than a JSON module import: under NodeNext the latter +// needs an import attribute (TS1543), which would add to this package's frozen +// TEST_DEBT tsc count for no behavioral gain. +const ledger = JSON.parse(readFileSync(join(HERE, 'sdui-jsx-baseline.json'), 'utf8')) as { + findings: LedgerRow[]; +}; + +/** Walk up to the workspace root — the directory holding pnpm-workspace.yaml. */ +function findUp(predicate: (dir: string) => boolean): string { + let dir = HERE; + for (;;) { + if (predicate(dir)) return dir; + const parent = dirname(dir); + if (parent === dir) throw new Error('workspace root not found from ' + HERE); + dir = parent; + } +} +const REPO = findUp((dir) => existsSync(join(dir, 'pnpm-workspace.yaml'))); + +// The artefact, from DISK — the same bytes `resolveSduiManifest()` (packages/ +// cli, path 1: join(process.cwd(), 'sdui.manifest.json')) picks up when the +// gate runs from the repo root. Loud absence: an absent artefact silently +// reverts production to parse-only, so this read failing IS the regression. +const ARTEFACT = join(REPO, 'sdui.manifest.json'); +const manifest = JSON.parse(readFileSync(ARTEFACT, 'utf8')); + +describe('production witness: the checked-in manifest reaches validateTree', () => { + it('is the real artefact (57-component public tier, no intrinsic HTML tags)', () => { + const keys = Object.keys(manifest.components); + expect(keys.length).toBeGreaterThan(0); + // The vocabulary facts the ratchet below stands on. If a regeneration + // legitimately changes them, the ledger is re-derived in the same PR. + expect(keys).toContain('flex'); + expect(keys).toContain('html'); + expect(keys).not.toContain('div'); + }); + + it('arms full validation through validateJsxPages: manifest-only diagnostics fire', () => { + const stack = { + pages: [ + { + name: 'witness_page', + kind: 'html', + // `flex` is a real public component; `no-such-block` is not. Only + // validateTree (fed by the DISK manifest) can tell them apart — + // parse-only cannot emit unknown-component at all. + source: '', + }, + ], + }; + const wired = validateJsxPages(stack, { manifest }); + const rules = new Set(wired.map((f) => f.rule)); + expect(rules).toContain('jsx-unknown-component'); // no-such-block, judged by the manifest + expect(rules).toContain('jsx-unknown-prop'); // notARealProp on flex, judged by flex's declared inputs + + // The arming delta itself: the SAME stack, parse-only, emits neither. + const parseOnly = validateJsxPages(stack); + const parseOnlyRules = new Set(parseOnly.map((f) => f.rule)); + expect(parseOnlyRules).not.toContain('jsx-unknown-component'); + expect(parseOnlyRules).not.toContain('jsx-unknown-prop'); + }); + + it('threads through the production registry entry (runAuthoringRules ctx.sduiManifest)', () => { + // The same seam `os validate`/`os build`/`os lint` drive: authoring-rules' + // validateJsxPages entry reads ctx.sduiManifest — never a lookalike call. + const stack = { + pages: [{ name: 'witness_page', kind: 'html', source: '' }], + }; + const withManifest = runAuthoringRules('validate', { + normalized: stack, + sduiManifest: manifest, + }); + expect(withManifest.some((f) => f.rule === 'jsx-unknown-component')).toBe(true); + + const without = runAuthoringRules('validate', { normalized: stack }); + expect(without.some((f) => f.rule === 'jsx-unknown-component')).toBe(false); + }); +}); + +describe('first-wiring ratchet: the shipped pages against the wired gate (ui#6779 ratchet-to-zero)', () => { + it('wired census over the three shipped html pages equals the ledger — both directions', () => { + const stack = { pages: [CapabilityMapPage, CommandCenterJsxPage, StartHerePage] }; + const findings = validateJsxPages(stack as never, { manifest }); + + const census = new Map(); + for (const f of findings) { + const page = /page "([^"]+)"/.exec(f.where)?.[1] ?? '(unknown page)'; + const tag = /<([a-zA-Z0-9:_-]+)>/.exec(f.where)?.[1] ?? '(no tag)'; + const key = `${page}|${f.rule}|${f.severity}|${tag}`; + census.set(key, (census.get(key) ?? 0) + 1); + } + + const recorded = new Map( + ledger.findings.map((r): [string, number] => [`${r.page}|${r.rule}|${r.severity}|${r.tag}`, r.count]), + ); + + const newViolations: string[] = []; + for (const [key, count] of census) { + const allowed = recorded.get(key) ?? 0; + if (count > allowed) newViolations.push(`${key} — live ${count} vs ledger ${allowed}`); + } + const stale: string[] = []; + for (const [key, count] of recorded) { + const live = census.get(key) ?? 0; + if (live < count) stale.push(`${key} — ledger ${count} vs live ${live}`); + } + + expect( + newViolations, + 'NEW wired-gate findings beyond the ratchet ledger. Fix the page (or regenerate the manifest ' + + 'if the vocabulary legitimately grew) — never grow packages/lint/src/sdui-jsx-baseline.json.', + ).toEqual([]); + expect( + stale, + 'STALE ledger rows — the live run no longer produces them. Ratchet-to-zero: delete these rows ' + + 'from packages/lint/src/sdui-jsx-baseline.json in this same PR.', + ).toEqual([]); + }); + + it('parse-only over the same pages stays clean (today\'s pre-wiring behavior, pinned)', () => { + const stack = { pages: [CapabilityMapPage, CommandCenterJsxPage, StartHerePage] }; + expect(validateJsxPages(stack as never)).toEqual([]); + }); +}); diff --git a/scripts/check-ci-filter-parity.mjs b/scripts/check-ci-filter-parity.mjs index f4dafa275e..b73662d58c 100644 --- a/scripts/check-ci-filter-parity.mjs +++ b/scripts/check-ci-filter-parity.mjs @@ -603,14 +603,18 @@ export async function selfTest() { // single-file `crosspkg` entry the way #10848's SKILL.md is. Plus, since // #12201, the one declaration under the `skills/**` root that card added // (the export-list corpus gate reads the published catalog from inside - // @objectstack/spec). Ten plus one plus two plus one: the rollback now - // uncovers fourteen. This pin is judged over the LIVE declaration table on + // @objectstack/spec). Plus, since #12924, the one repo-root declaration that + // card added (the checked-in SDUI manifest artefact @objectstack/lint's + // production-witness suite reads from the workspace root), covered only + // through its own single-file `crosspkg` entry the way #10848's SKILL.md is. + // Ten plus one plus two plus one plus one: the rollback now uncovers + // fifteen. This pin is judged over the LIVE declaration table on // purpose: a declaration added under a root the rollback keeps leaves the // count alone, one under a new root moves it and is recorded here by name. const preFix = judge(fixtureWorkflow({ core: real.filters?.core, crosspkg: ['scripts/**'] }), CROSS_PACKAGE_TEST_INPUTS); assert( - new Set(uncoveredGlobs(preFix)).size === 14, - `rolling \`crosspkg\` back to its pre-#10015 list uncovers the ten it fixed plus #10848's one plus #10178's two plus #12201's one -- got ${new Set(uncoveredGlobs(preFix)).size}`, + new Set(uncoveredGlobs(preFix)).size === 15, + `rolling \`crosspkg\` back to its pre-#10015 list uncovers the ten it fixed plus #10848's one plus #10178's two plus #12201's one plus #12924's one -- got ${new Set(uncoveredGlobs(preFix)).size}`, ); assert( uncoveredGlobs(preFix).includes('skills/**'), @@ -628,6 +632,10 @@ export async function selfTest() { uncoveredGlobs(preFix).includes('skills/objectstack-automation/SKILL.md'), `-- and #10178 added the automation skill file, by name`, ); + assert( + uncoveredGlobs(preFix).includes('sdui.manifest.json'), + `-- and #12924 added the repo-root SDUI manifest artefact, by name`, + ); // ── (7) WIRING: the gate and its self-test really run in CI ────────────── const SELF = 'scripts/check-ci-filter-parity.mjs'; @@ -652,7 +660,7 @@ export async function selfTest() { `same-root-different-file case observed failing and then covered by naming the file, a glob covered by ` + `\`core\`, one covered only by \`crosspkg\` and one covered by neither judged separately in one table, the ` + `stale-entry direction, seven refusals over subjects that could not be read, the checked-in ci.yml, the ` + - `pre-#10015 rollback uncovering the ten it fixed plus #10848's one plus #10178's two plus #12201's one, ` + + `pre-#10015 rollback uncovering the ten it fixed plus #10848's one plus #10178's two plus #12201's one plus #12924's one, ` + `and the CI wiring read out of lint.yml.`, ); return 0; diff --git a/scripts/check-sdui-manifest.mjs b/scripts/check-sdui-manifest.mjs new file mode 100644 index 0000000000..915b0acc00 --- /dev/null +++ b/scripts/check-sdui-manifest.mjs @@ -0,0 +1,217 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * check-sdui-manifest — freshness + integrity gate for the repo-root + * `sdui.manifest.json` (#12924, ruled 2026-08-29: wire it, 签入 + 新鲜度门禁). + * + * node scripts/check-sdui-manifest.mjs # gate this tree + * node scripts/check-sdui-manifest.mjs --self-test # verify the checker itself + * + * ## What rots here, and which check catches it + * + * The artefact is a SYNC of objectui's public-tier registry (generated from the + * published `@object-ui/*` packages at the version `.objectui-sha` ships — see + * `scripts/gen-sdui-manifest-node.mjs`). Checked-in syncs rot; the ruling's + * words for the failure mode are 「同步一次就烂」. Offline, per-PR: + * + * 1. ABSENCE / SHAPE — the artefact exists at the repo root, parses, and + * carries a non-empty `components` map whose entries agree with their + * keys. An absent artefact silently reverts `validateJsxPages` to + * parse-only (`resolveSduiManifest()` degrades by design), so absence + * here is the loudest failure, never a skip. Same for the record file. + * 2. TAMPER — sha256(artefact) equals the record. The manifest is + * generator-owned; a hand edit is invisible to every consumer (the + * resolver JSON.parses whatever is there), so the hash is the only + * instrument that notices one. + * 3. STALENESS — the record's `objectuiSha` equals the live `.objectui-sha`. + * A pin bump changes which registry the shipped console runs, so the bump + * PR goes red HERE until the manifest is regenerated against the new pin + * — the same moment `check-sdui-lockstep` already forces a parser parity + * re-verification, and the moment an objectui checkout is guaranteed to + * exist (the bump required one). + * + * ## What this deliberately does NOT do, and where that risk is held + * + * No per-PR regeneration: that would put an npm-registry network dependency + * inside a required lint job — the exact shape `check-sdui-lockstep`'s header + * declines, for the same reasons. Under an unchanged pin the published inputs + * are immutable, so content drift per-PR is not a live axis. The residual + * axis — this repo's OWN adapter (`manifestFromConfigs`) changing while the + * pin stands still — is held by `packages/sdui-parser`'s lockstep gate and + * unit suite, and by regeneration being byte-deterministic (measured: two runs + * from the same install, identical sha256), so the remedy this gate prints + * always converges. + * + * Absence is loud, everywhere (#13014/#4690): every input is asserted before + * any verdict; a missing one exits 1 naming which. No `⚠` + exit 0. + */ + +import { createHash } from 'node:crypto'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { isEntrypoint } from './invoked-as.mjs'; + +const DEFAULT_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); + +/** Gate one tree. Returns a list of problems; empty = green. */ +export function checkTree(root) { + const problems = []; + const artefactPath = join(root, 'sdui.manifest.json'); + const recordPath = join(root, 'scripts', 'sdui-manifest.record.json'); + const pinPath = join(root, '.objectui-sha'); + + if (!existsSync(artefactPath)) { + problems.push( + 'sdui.manifest.json is MISSING at the repo root. `resolveSduiManifest()` degrades to parse-only\n' + + ' silently, so this gate is the thing that notices. Regenerate: node scripts/gen-sdui-manifest-node.mjs', + ); + return problems; // every later check reads it + } + const raw = readFileSync(artefactPath, 'utf8'); + + let manifest; + try { + manifest = JSON.parse(raw); + } catch (e) { + problems.push(`sdui.manifest.json does not parse as JSON: ${e.message}`); + return problems; + } + const components = manifest?.components; + if (!components || typeof components !== 'object' || Array.isArray(components)) { + problems.push('sdui.manifest.json has no `components` object — not a component manifest.'); + return problems; + } + const keys = Object.keys(components); + if (keys.length === 0) problems.push('sdui.manifest.json declares 0 components — an empty whitelist would red every page.'); + for (const k of keys) { + if (components[k]?.type !== k) { + problems.push(`components[${JSON.stringify(k)}].type is ${JSON.stringify(components[k]?.type)} — key/type disagree.`); + break; // one example is enough; this shape is generator-owned + } + } + + if (!existsSync(recordPath)) { + problems.push('scripts/sdui-manifest.record.json is MISSING — provenance unknown. Regenerate to re-record.'); + return problems; + } + let record; + try { + record = JSON.parse(readFileSync(recordPath, 'utf8')); + } catch (e) { + problems.push(`scripts/sdui-manifest.record.json does not parse: ${e.message}`); + return problems; + } + for (const field of ['objectuiSha', 'objectuiPackagesVersion', 'sha256', 'components']) { + if (record[field] === undefined) problems.push(`record is missing \`${field}\`.`); + } + if (problems.length) return problems; + + const sha256 = createHash('sha256').update(raw).digest('hex'); + if (sha256 !== record.sha256) { + problems.push( + `sdui.manifest.json sha256 ${sha256.slice(0, 12)}… does not match the record ${String(record.sha256).slice(0, 12)}…\n` + + ' The artefact is generator-owned — never hand-edit it. Regenerate: node scripts/gen-sdui-manifest-node.mjs', + ); + } + if (keys.length !== record.components) { + problems.push(`artefact has ${keys.length} components, record says ${record.components}.`); + } + + if (!existsSync(pinPath)) { + problems.push('.objectui-sha is MISSING — cannot judge freshness.'); + return problems; + } + const pin = readFileSync(pinPath, 'utf8').trim(); + if (pin !== record.objectuiSha) { + problems.push( + `.objectui-sha has moved to ${pin.slice(0, 12)}… but sdui.manifest.json was generated at ${String(record.objectuiSha).slice(0, 12)}…\n` + + ' A pin bump changes which registry the shipped console runs; the manifest must follow it (同步一次就烂 is\n' + + ' the failure mode this gate exists for). Regenerate against the new pin:\n' + + ' node scripts/gen-sdui-manifest-node.mjs --objectui-version {the @object-ui version the new pin ships}\n' + + " (read it from the objectui checkout's packages/core/package.json — the bump already required that checkout).", + ); + } + return problems; +} + +function selfTest() { + const mk = (mutate) => { + const root = mkdtempSync(join(tmpdir(), 'sdui-manifest-check-')); + // fixture tree: artefact + record + pin, green by construction + const manifest = { components: { flex: { type: 'flex', inputs: [] } } }; + const raw = JSON.stringify(manifest, null, 2); + const scriptsDir = join(root, 'scripts'); + mkdirSync(scriptsDir, { recursive: true }); + writeFileSync(join(root, '.objectui-sha'), 'a'.repeat(40) + '\n'); + writeFileSync(join(root, 'sdui.manifest.json'), raw); + writeFileSync( + join(scriptsDir, 'sdui-manifest.record.json'), + JSON.stringify( + { + objectuiSha: 'a'.repeat(40), + objectuiPackagesVersion: '0.0.0-selftest', + sha256: createHash('sha256').update(raw).digest('hex'), + components: 1, + }, + null, + 2, + ), + ); + mutate?.(root); + return root; + }; + + const cases = [ + ['green fixture passes', mk(), 0], + ['missing artefact is RED', mk((r) => rmSync(join(r, 'sdui.manifest.json'))), 1], + ['hand-edited artefact (hash mismatch) is RED', mk((r) => writeFileSync(join(r, 'sdui.manifest.json'), '{"components":{"flex":{"type":"flex"}}}')), 1], + ['moved pin is RED', mk((r) => writeFileSync(join(r, '.objectui-sha'), 'b'.repeat(40))), 1], + ['empty components is RED', mk((r) => { + const raw = JSON.stringify({ components: {} }, null, 2); + writeFileSync(join(r, 'sdui.manifest.json'), raw); + const rec = JSON.parse(readFileSync(join(r, 'scripts', 'sdui-manifest.record.json'), 'utf8')); + rec.sha256 = createHash('sha256').update(raw).digest('hex'); + rec.components = 0; + writeFileSync(join(r, 'scripts', 'sdui-manifest.record.json'), JSON.stringify(rec)); + }), 1], + ['missing record is RED', mk((r) => rmSync(join(r, 'scripts', 'sdui-manifest.record.json'))), 1], + ]; + + let failures = 0; + for (const [name, root, want] of cases) { + const problems = checkTree(root); + const got = problems.length ? 1 : 0; + if (got !== want) { + failures++; + console.error(`✗ self-test: ${name} — expected ${want ? 'RED' : 'GREEN'}, got ${got ? 'RED' : 'GREEN'}`); + for (const p of problems) console.error(` ${p}`); + } + } + if (failures) { + console.error(`✗ check-sdui-manifest self-test: ${failures} case(s) failed.`); + process.exit(1); + } + console.log(`✓ check-sdui-manifest self-test: ${cases.length} cases behave (green passes; absence, tamper, moved pin, emptiness are RED).`); +} + +if (isEntrypoint(import.meta.url)) { + if (process.argv.includes('--self-test')) { + selfTest(); + } else { + const problems = checkTree(DEFAULT_ROOT); + if (problems.length) { + console.error('✗ check-sdui-manifest:'); + for (const p of problems) console.error(` ${p}`); + process.exit(1); + } + const record = JSON.parse(readFileSync(join(DEFAULT_ROOT, 'scripts', 'sdui-manifest.record.json'), 'utf8')); + console.log( + `✓ check-sdui-manifest: sdui.manifest.json is present, intact (sha256 ${String(record.sha256).slice(0, 12)}…, ` + + `${record.components} components) and fresh at objectui pin ${String(record.objectuiSha).slice(0, 12)}….`, + ); + } +} diff --git a/scripts/cross-package-test-inputs.mjs b/scripts/cross-package-test-inputs.mjs index 5b5b6aebb8..83724d68c6 100644 --- a/scripts/cross-package-test-inputs.mjs +++ b/scripts/cross-package-test-inputs.mjs @@ -441,6 +441,18 @@ export const CROSS_PACKAGE_TEST_INPUTS = { 'examples/app-showcase/src/data/objects/contact.object.ts', 'examples/app-showcase/src/system/translations/index.ts', 'examples/app-showcase/src/ui/views/contact.view.ts', + // src/validate-jsx-pages.production-witness.test.ts (#12924) imports the + // three shipped html pages LIVE and holds the wired gate's census over + // them equal to the ratchet-to-zero ledger (sdui-jsx-baseline.json), so + // an edit to any page — or to the repo-root manifest whose vocabulary + // judges them — must re-run this package's suite. The manifest is the + // checked-in producer artefact `resolveSduiManifest()` picks up from + // the repo root; regenerating it moves the census, so it is an input + // in exactly the #7802 sense. + 'examples/app-showcase/src/ui/pages/capability-map.page.ts', + 'examples/app-showcase/src/ui/pages/command-center-jsx.page.ts', + 'examples/app-showcase/src/ui/pages/start-here.page.ts', + 'sdui.manifest.json', ], heldBy: { // `const commandsDir = join(repoRoot, 'packages/cli/src/commands')` diff --git a/scripts/gen-sdui-manifest-node.mjs b/scripts/gen-sdui-manifest-node.mjs new file mode 100644 index 0000000000..cef1b73b7f --- /dev/null +++ b/scripts/gen-sdui-manifest-node.mjs @@ -0,0 +1,194 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * gen-sdui-manifest-node — regenerate the repo-root `sdui.manifest.json` from + * objectui's PUBLISHED registry packages, without a browser. + * + * node scripts/gen-sdui-manifest-node.mjs # temp npm install (network) + * node scripts/gen-sdui-manifest-node.mjs --modules-root DIR # use a preinstalled node_modules parent + * node scripts/gen-sdui-manifest-node.mjs --objectui-version V # override the version to install + * + * ## What this produces, and from what + * + * The ADR-0080 public-tier component manifest that `resolveSduiManifest()` + * (packages/cli) picks up from the project root, switching `validateJsxPages` + * from parse-only to full `validateTree` validation (#12924, ruled 2026-08-29: + * wire it). The enumeration mirrors objectui's own generator page + * (`apps/console/dev/manifest-dump.tsx`) exactly: eager-import the 16 registry + * modules IN ITS ORDER, read `ComponentRegistry.getPublicConfigs()`, and + * serialize through `manifestFromConfigs` from `@objectstack/sdui-parser` — + * the hoisted, lockstep-pinned copy of objectui's adapter + * (`pnpm check:sdui-lockstep` holds the two copies byte-equal). + * + * ## Why plain Node is a valid producer (the browser-only claim is expired) + * + * `packages/spec/CHANGELOG.md` records (twice, byte-identically) that only a + * real browser can enumerate the registry. Measured false on 2026-08-29 + * (objectui#6741) and re-measured on 2026-08-30 against published + * `@object-ui/*` 17.6.0: all 16 modules import under plain Node once `.css` + * imports resolve to an empty module — the ONLY failure without the hook is + * `ERR_UNKNOWN_FILE_EXTENSION .css` on plugin-dashboard/plugin-map, a loader + * limitation, not a browser API. 57 configs, 0 lazy stubs, ~4.5 s, no + * Playwright, no objectui build. The browser route (`pnpm sdui:manifest`) + * still exists for operators holding an objectui checkout; both routes + * serialize the same registry through the same adapter. + * + * ## Versioning contract + * + * The manifest must describe the registry the SHIPPED console runs — i.e. the + * `@object-ui/*` version that `.objectui-sha` ships. This script installs that + * version from npm; the version is read from `scripts/sdui-manifest.record.json` + * unless `--objectui-version` overrides it. After a pin bump, pass the version + * the new pin carries (its `packages/core/package.json` in the objectui + * checkout the bump already required) — `check-sdui-manifest.mjs` goes red on + * a moved pin until this script has been re-run and the record re-written. + * + * ## Preconditions (all loud) + * + * `packages/sdui-parser/dist` must exist (`pnpm --filter @objectstack/sdui-parser build`): + * the adapter is consumed exactly as production consumes it. Absence exits 1. + */ + +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { pathToFileURL, fileURLToPath } from 'node:url'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const RECORD_PATH = join(ROOT, 'scripts', 'sdui-manifest.record.json'); +const OUT_PATH = join(ROOT, 'sdui.manifest.json'); +const PARSER_DIST = join(ROOT, 'packages', 'sdui-parser', 'dist', 'index.mjs'); + +/** The registration set, in objectui's `apps/console/dev/manifest-dump.tsx` order. */ +const REGISTRY_MODULES = [ + '@object-ui/components', + '@object-ui/plugin-grid', + '@object-ui/plugin-form', + '@object-ui/plugin-view', + '@object-ui/plugin-list', + '@object-ui/plugin-detail', + '@object-ui/plugin-dashboard', + '@object-ui/plugin-charts', + '@object-ui/plugin-kanban', + '@object-ui/plugin-calendar', + '@object-ui/plugin-gantt', + '@object-ui/plugin-timeline', + '@object-ui/plugin-map', + '@object-ui/plugin-markdown', + '@object-ui/plugin-report', + '@object-ui/plugin-tree', +]; + +function fail(msg) { + console.error(`✗ gen-sdui-manifest-node: ${msg}`); + process.exit(1); +} + +function arg(name) { + const i = process.argv.indexOf(name); + return i > -1 ? process.argv[i + 1] : undefined; +} + +if (!existsSync(PARSER_DIST)) { + fail( + `packages/sdui-parser/dist is missing — the adapter is consumed as production consumes it.\n` + + ` Run: pnpm --filter @objectstack/sdui-parser build`, + ); +} + +const pinPath = join(ROOT, '.objectui-sha'); +if (!existsSync(pinPath)) fail('.objectui-sha is missing — cannot record provenance.'); +const pin = readFileSync(pinPath, 'utf8').trim(); + +let record = {}; +if (existsSync(RECORD_PATH)) { + try { + record = JSON.parse(readFileSync(RECORD_PATH, 'utf8')); + } catch { + fail(`${RECORD_PATH} exists but does not parse — fix or delete it first.`); + } +} + +const version = arg('--objectui-version') ?? record.objectuiPackagesVersion; +if (!version) { + fail( + 'no --objectui-version and no version in scripts/sdui-manifest.record.json.\n' + + " Pass the @object-ui/* version the pin ships (the objectui checkout's packages/core/package.json).", + ); +} + +let modulesRoot = arg('--modules-root'); +if (modulesRoot) { + if (!existsSync(join(modulesRoot, 'node_modules'))) { + fail(`--modules-root ${modulesRoot} has no node_modules/ — point it at a directory whose install carries the @object-ui set.`); + } +} else { + modulesRoot = mkdtempSync(join(tmpdir(), 'sdui-manifest-gen-')); + const deps = Object.fromEntries(REGISTRY_MODULES.concat('@object-ui/core').map((m) => [m, version])); + deps.react = '18.3.1'; + deps['react-dom'] = '18.3.1'; + writeFileSync( + join(modulesRoot, 'package.json'), + JSON.stringify({ name: 'sdui-manifest-gen', private: true, type: 'module', dependencies: deps }, null, 2), + ); + console.error(`→ installing @object-ui/* ${version} into ${modulesRoot} (npm, network)...`); + execFileSync('npm', ['install', '--no-audit', '--no-fund', '--loglevel=error'], { + cwd: modulesRoot, + stdio: ['ignore', 'inherit', 'inherit'], + }); +} + +// The one loader accommodation plain Node needs: `.css` side-effect imports +// (plugin-dashboard, plugin-map) resolve to an empty module. Everything else +// is stock resolution from the install above. +const runner = ` +import { register } from 'node:module'; +register('data:text/javascript,' + encodeURIComponent( + 'export async function resolve(s, c, n) {' + + ' if (s.endsWith(".css")) return { url: "data:text/javascript,", shortCircuit: true };' + + ' return n(s, c);' + + '}'), import.meta.url); +const MODULES = ${JSON.stringify(REGISTRY_MODULES)}; +const failures = []; +for (const m of MODULES) { + try { await import(m); } catch (e) { failures.push(m + ': ' + String(e).slice(0, 200)); } +} +if (failures.length) { + console.error('IMPORT FAILURES:\\n' + failures.join('\\n')); + process.exit(1); +} +const { ComponentRegistry } = await import('@object-ui/core'); +const parser = await import(${JSON.stringify(pathToFileURL(PARSER_DIST).href)}); +const configs = ComponentRegistry.getPublicConfigs(); +if (!configs.length) { console.error('getPublicConfigs() returned 0 configs'); process.exit(1); } +const lazy = configs.filter((c) => c.lazy); +if (lazy.length) { console.error('lazy stubs present (their inputs would be missing): ' + lazy.map((c) => c.type).join(', ')); process.exit(1); } +const manifest = parser.manifestFromConfigs(configs); +if (!Object.keys(manifest.components).length) { console.error('manifestFromConfigs produced 0 components'); process.exit(1); } +// Same serialization as objectui's dump (JSON.stringify(manifest, null, 2), no trailing newline). +process.stdout.write(JSON.stringify(manifest, null, 2)); +`; +const runnerPath = join(modulesRoot, 'sdui-manifest-runner.mjs'); +writeFileSync(runnerPath, runner); +console.error(`→ enumerating the registry (${REGISTRY_MODULES.length} modules)...`); +const json = execFileSync(process.execPath, [runnerPath], { cwd: modulesRoot, maxBuffer: 64 * 1024 * 1024 }).toString(); + +const manifest = JSON.parse(json); +const count = Object.keys(manifest.components).length; +writeFileSync(OUT_PATH, json); +const sha256 = createHash('sha256').update(json).digest('hex'); +const nextRecord = { + '//': record['//'] ?? [], + objectuiSha: pin, + objectuiPackagesVersion: version, + generator: 'scripts/gen-sdui-manifest-node.mjs', + generatedAt: new Date().toISOString().slice(0, 10), + sha256, + components: count, +}; +writeFileSync(RECORD_PATH, JSON.stringify(nextRecord, null, 2) + '\n'); +console.error(`✓ wrote sdui.manifest.json (${count} components, ${Buffer.byteLength(json)} bytes, sha256 ${sha256.slice(0, 12)}…)`); +console.error(`✓ re-recorded scripts/sdui-manifest.record.json at pin ${pin.slice(0, 12)} / @object-ui ${version}`); diff --git a/scripts/sdui-manifest.record.json b/scripts/sdui-manifest.record.json new file mode 100644 index 0000000000..7694e8d66d --- /dev/null +++ b/scripts/sdui-manifest.record.json @@ -0,0 +1,18 @@ +{ + "//": [ + "Provenance record for the repo-root `sdui.manifest.json` (ADR-0080; #12924 wiring ruling 2026-08-29).", + "The artefact is objectui's public-tier component manifest, generated from the PUBLISHED", + "@object-ui/* packages at the version `.objectui-sha` ships, via scripts/gen-sdui-manifest-node.mjs", + "(same enumeration as objectui's apps/console/dev/manifest-dump.tsx: eager-import the 16 registry", + "modules, ComponentRegistry.getPublicConfigs(), manifestFromConfigs from @objectstack/sdui-parser).", + "`scripts/check-sdui-manifest.mjs` holds this record equal to the artefact and to the live pin:", + "a pin bump without regeneration goes RED there — that is the anti-rot half of the freshness gate.", + "Regenerate + re-record: node scripts/gen-sdui-manifest-node.mjs (see its header for the offline mode)." + ], + "objectuiSha": "9602dc820450dda956843c6cfe5b329bcf88c757", + "objectuiPackagesVersion": "17.6.0", + "generator": "scripts/gen-sdui-manifest-node.mjs", + "generatedAt": "2026-08-30", + "sha256": "49211fee7792cf51174930dc2c1be2169d5175f77a83491f68f90b3f0c19e69d", + "components": 57 +} diff --git a/sdui.manifest.json b/sdui.manifest.json new file mode 100644 index 0000000000..78f870e42f --- /dev/null +++ b/sdui.manifest.json @@ -0,0 +1,2339 @@ +{ + "components": { + "object-grid": { + "type": "object-grid", + "namespace": "plugin-grid", + "inputs": [ + { + "name": "objectName", + "type": "string", + "required": true + }, + { + "name": "columns", + "type": "array", + "description": "Columns to show, either field names (`[\"name\", \"email\"]`) or column objects (`[{ field: \"name\", label: \"Full Name\", width: 200 }]`). The canonical spelling — the deprecated `fields` is only read when this is absent." + }, + { + "name": "filter", + "type": "array", + "description": "Filter criteria in JSON-rules form. The canonical spelling — the deprecated `defaultFilters` is only read when this is absent." + }, + { + "name": "label", + "type": "string", + "description": "Grid label, used as the table caption and as the export file title. The canonical spelling — the deprecated `title` is only read when this is absent." + }, + { + "name": "sort", + "type": "array", + "description": "Initial sort order, `[{ field, order }]`. The canonical spelling — the deprecated single-sort `defaultSort` is only read when this is absent." + }, + { + "name": "pagination", + "type": "object", + "description": "Pagination config, `{ pageSize, pageSizeOptions, … }`. Its presence is what enables paging; prefer it over the deprecated flat `pageSize` / `showPagination` pair." + }, + { + "name": "searchableFields", + "type": "array", + "description": "Fields the toolbar search box queries. A non-empty list is what enables search — prefer it over the deprecated boolean `showSearch`, which cannot say WHICH fields to search." + }, + { + "name": "data", + "type": "object", + "description": "Data source configuration — a `ViewData` object discriminated by `provider`: `{ provider: \"object\", object }` (what an omitted `data` falls back to, using `objectName`), `{ provider: \"api\", read, write }`, `{ provider: \"value\", items: [...] }` for inline rows that bypass the object query, or `{ provider: \"schema\", schemaId }`. The canonical spelling — the deprecated `staticData` is the array-only shortcut for the `value` provider, so inline rows go under `items` here rather than in a bare array." + }, + { + "name": "rowHeight", + "type": "enum", + "enum": [ + "compact", + "short", + "medium", + "tall", + "extra_tall" + ], + "description": "Row density. An unrecognised value falls back to `compact` rather than erroring." + }, + { + "name": "frozenColumns", + "type": "number", + "description": "How many leading columns stay pinned while the grid scrolls horizontally." + }, + { + "name": "resizable", + "type": "boolean", + "description": "Let users drag column borders to resize. The canonical spelling — the deprecated `resizableColumns` is only read when this is absent." + }, + { + "name": "reorderableColumns", + "type": "boolean", + "description": "Let users drag columns into a different order." + }, + { + "name": "showColumnTypeIcons", + "type": "boolean", + "description": "Show a field-type icon in each column header. Off by default — the type is usually obvious from the cell content, and the icons compete with the column labels." + }, + { + "name": "rowColor", + "type": "object", + "description": "Rules that colour whole rows from a field value." + }, + { + "name": "conditionalFormatting", + "type": "array", + "description": "Row/cell styling rules. Accepts both the ObjectUI `{ field, operator, value }` form and the spec expression form `{ condition, style }`." + }, + { + "name": "grouping", + "type": "object", + "description": "Group rows by one or more fields into collapsible sections." + }, + { + "name": "aggregations", + "type": "array", + "description": "Per-group roll-ups shown in group headers, `[{ field, type: \"sum\" | \"count\" | \"avg\" | \"min\" | \"max\" | \"count_distinct\" }]`. Needs `grouping` to have anything to roll up." + }, + { + "name": "selection", + "type": "object", + "description": "Selection config, `{ type: \"none\" | \"single\" | \"multiple\" }`. The canonical spelling — the deprecated boolean/string `selectable` is only read when this is absent." + }, + { + "name": "rowActions", + "type": "array", + "description": "Names of actions offered on each row’s menu." + }, + { + "name": "bulkActions", + "type": "array", + "description": "Names of actions offered once rows are selected. Needs a multi-row `selection` to be reachable." + }, + { + "name": "batchActions", + "type": "array", + "description": "Legacy alias of `bulkActions`, and the one the renderer reads FIRST when both are set. Prefer `bulkActions` in new schemas." + }, + { + "name": "bulkActionDefs", + "type": "array", + "description": "Full inline bulk-action definitions, for actions that are not named entries in the object’s action set. Use `bulkActions` when the action already exists." + }, + { + "name": "editable", + "type": "boolean", + "description": "Enable inline cell editing (double-click or Enter opens a cell)." + }, + { + "name": "singleClickEdit", + "type": "boolean", + "description": "With `editable`, a single click opens the cell instead of a double-click. Has no effect on a non-editable grid." + }, + { + "name": "navigation", + "type": "object", + "description": "What a row click does, `{ mode: \"page\" | \"drawer\" | \"modal\" | \"split\" | \"none\", … }`." + }, + { + "name": "operations", + "type": "object", + "description": "Toggles for the built-in create/read/update/delete/export/import affordances, e.g. `{ delete: false }`." + }, + { + "name": "exportOptions", + "type": "object", + "description": "Export config, `{ formats, maxRecords, includeHeaders, fileNamePrefix }`. Needs `operations.export` to be reachable from the toolbar." + } + ] + }, + "list-view": { + "type": "list-view", + "namespace": "plugin-list", + "inputs": [ + { + "name": "objectName", + "type": "string", + "required": true + }, + { + "name": "viewType", + "type": "enum", + "enum": [ + { + "label": "Grid", + "value": "grid" + }, + { + "label": "Kanban", + "value": "kanban" + }, + { + "label": "Gallery", + "value": "gallery" + }, + { + "label": "Calendar", + "value": "calendar" + }, + { + "label": "Timeline", + "value": "timeline" + }, + { + "label": "Gantt", + "value": "gantt" + }, + { + "label": "Map", + "value": "map" + } + ] + }, + { + "name": "columns", + "type": "array" + }, + { + "name": "filter", + "type": "array" + }, + { + "name": "sort", + "type": "array" + }, + { + "name": "options", + "type": "object" + } + ] + }, + "object-form": { + "type": "object-form", + "namespace": "plugin-form", + "inputs": [ + { + "name": "objectName", + "type": "string", + "required": true + }, + { + "name": "fields", + "type": "array" + }, + { + "name": "mode", + "type": "enum", + "enum": [ + "create", + "edit", + "view" + ] + }, + { + "name": "formType", + "type": "enum", + "enum": [ + "simple", + "tabbed", + "wizard", + "split", + "drawer", + "modal" + ] + }, + { + "name": "sections", + "type": "array" + }, + { + "name": "title", + "type": "string" + }, + { + "name": "description", + "type": "string" + }, + { + "name": "layout", + "type": "enum", + "enum": [ + "vertical", + "horizontal", + "inline", + "grid" + ] + }, + { + "name": "columns", + "type": "number" + }, + { + "name": "defaultTab", + "type": "string" + }, + { + "name": "tabPosition", + "type": "enum", + "enum": [ + "top", + "bottom", + "left", + "right" + ] + }, + { + "name": "allowSkip", + "type": "boolean" + }, + { + "name": "showStepIndicator", + "type": "boolean" + }, + { + "name": "splitDirection", + "type": "enum", + "enum": [ + "horizontal", + "vertical" + ] + }, + { + "name": "splitSize", + "type": "number" + }, + { + "name": "splitResizable", + "type": "boolean" + }, + { + "name": "drawerSide", + "type": "enum", + "enum": [ + "top", + "bottom", + "left", + "right" + ] + }, + { + "name": "drawerWidth", + "type": "string" + }, + { + "name": "modalSize", + "type": "enum", + "enum": [ + "sm", + "default", + "lg", + "xl", + "full" + ] + }, + { + "name": "modalCloseButton", + "type": "boolean", + "description": "Show the modal presentation’s close button. Read at ObjectForm.tsx:361 and honoured by ModalForm." + }, + { + "name": "contentLayout", + "type": "enum", + "enum": [ + "simple", + "tabbed" + ], + "description": "How the modal presentation lays out sections. `tabbed` needs more than one section to differ from `simple` (ModalForm.tsx:638)." + }, + { + "name": "confirmOnDiscard", + "type": "boolean", + "description": "Ask before discarding unsaved edits when a drawer/modal form is dismissed. Set `false` to close immediately." + }, + { + "name": "recordId", + "type": "string", + "description": "The record to load in `edit` / `view` mode. Leave unset for `create`." + }, + { + "name": "customFields", + "type": "array", + "description": "Field definitions merged over the set generated from object metadata. With inline definitions and no data source, this becomes the only field source." + }, + { + "name": "initialValues", + "type": "object", + "description": "Values to prefill in `create` mode." + }, + { + "name": "initialData", + "type": "object", + "description": "Alternate spelling of `initialValues` that the drawer/modal presentations read FIRST (`schema.initialData || schema.initialValues`). Prefer `initialValues` in new schemas." + }, + { + "name": "readOnly", + "type": "boolean", + "description": "Render every field read-only, whatever `mode` says." + }, + { + "name": "submitText", + "type": "string" + }, + { + "name": "cancelText", + "type": "string" + }, + { + "name": "nextText", + "type": "string", + "description": "Label of the next-step button (wizard)." + }, + { + "name": "prevText", + "type": "string", + "description": "Label of the previous-step button (wizard)." + }, + { + "name": "showSubmit", + "type": "boolean" + }, + { + "name": "showCancel", + "type": "boolean" + }, + { + "name": "showReset", + "type": "boolean" + }, + { + "name": "submitBehavior", + "type": "object", + "description": "Declarative post-submit behaviour, one of `{ kind: \"thank-you\", title?, message? }`, `{ kind: \"redirect\", url, delayMs? }`, `{ kind: \"continue\" }`, `{ kind: \"next-record\" }`. When present it takes precedence over `successMessage` / `navigateOnSuccess` / `resetOnSuccess`." + }, + { + "name": "successMessage", + "type": "string", + "description": "Toast shown after a successful submit. Ignored when `submitBehavior` or `navigateOnSuccess` is set." + }, + { + "name": "navigateOnSuccess", + "type": "string", + "description": "Path to navigate to after a successful create/update. Supports `{id}` / `{recordId}` interpolation from the saved record and is same-origin-guarded. Takes precedence over `successMessage`." + }, + { + "name": "resetOnSuccess", + "type": "boolean", + "description": "Clear the form after a successful submit instead of keeping the saved values." + }, + { + "name": "mobile", + "type": "object", + "description": "Phone-only presentation overrides, e.g. `{ stepper: \"auto\", stepperMinFields: 8, fullscreenLongText: true }`." + } + ] + }, + "embeddable-form": { + "type": "embeddable-form", + "namespace": "plugin-form", + "inputs": [ + { + "name": "formId", + "type": "string", + "required": true + }, + { + "name": "objectName", + "type": "string", + "required": true + }, + { + "name": "title", + "type": "string" + }, + { + "name": "description", + "type": "string" + }, + { + "name": "fields", + "type": "array" + }, + { + "name": "allowMultiple", + "type": "boolean" + } + ] + }, + "object-master-detail-form": { + "type": "object-master-detail-form", + "namespace": "plugin-form", + "inputs": [ + { + "name": "objectName", + "type": "string", + "required": true + }, + { + "name": "mode", + "type": "enum", + "enum": [ + "create", + "edit" + ] + }, + { + "name": "sections", + "type": "array" + }, + { + "name": "details", + "type": "array", + "required": true + }, + { + "name": "recordId", + "type": "string", + "description": "The parent record to load in `edit` mode. Leave unset for `create`." + }, + { + "name": "formType", + "type": "string", + "description": "How the PARENT half of the form is presented. The detail grids below it are unaffected." + }, + { + "name": "fields", + "type": "array", + "description": "Which parent fields to show, in order. Ignored when `sections` is given — sections carry their own field lists." + }, + { + "name": "title", + "type": "string" + }, + { + "name": "submitText", + "type": "string", + "description": "Label of the button that saves the parent and every detail row in one batch." + }, + { + "name": "cancelText", + "type": "string" + }, + { + "name": "showSubmit", + "type": "boolean" + }, + { + "name": "initialValues", + "type": "object", + "description": "Values to prefill on the PARENT record in `create` mode." + }, + { + "name": "initialData", + "type": "object", + "description": "Alternate spelling of `initialValues` the renderer also reads (MasterDetailForm.tsx:602). Prefer `initialValues` in new schemas." + }, + { + "name": "taxRateField", + "type": "string", + "description": "Name of the field ON THE CHILD object that holds each line’s tax rate. Feeds the line-items totals row; leave unset when the detail rows carry no tax." + } + ] + }, + "object-kanban": { + "type": "object-kanban", + "namespace": "plugin-kanban", + "inputs": [ + { + "name": "objectName", + "type": "string", + "required": true + }, + { + "name": "columns", + "type": "array" + } + ] + }, + "object-calendar": { + "type": "object-calendar", + "namespace": "plugin-calendar", + "inputs": [ + { + "name": "objectName", + "type": "string", + "required": true + }, + { + "name": "calendar", + "type": "object", + "description": "startDateField, endDateField, titleField, colorField" + } + ] + }, + "object-gantt": { + "type": "object-gantt", + "namespace": "plugin-gantt", + "inputs": [ + { + "name": "objectName", + "type": "string", + "required": true + }, + { + "name": "gantt", + "type": "object", + "description": "startDateField, endDateField, titleField, progressField, percentageField, colorField, dependenciesField" + } + ] + }, + "object-timeline": { + "type": "object-timeline", + "namespace": "plugin-timeline", + "inputs": [ + { + "name": "objectName", + "type": "string", + "required": true + }, + { + "name": "variant", + "type": "enum", + "enum": [ + "vertical", + "horizontal", + "gantt" + ] + } + ] + }, + "object-map": { + "type": "object-map", + "namespace": "plugin-map", + "inputs": [ + { + "name": "objectName", + "type": "string", + "required": true + }, + { + "name": "map", + "type": "object", + "description": "latitudeField, longitudeField, titleField" + } + ] + }, + "object-metric": { + "type": "object-metric", + "namespace": "plugin-dashboard", + "inputs": [ + { + "name": "objectName", + "type": "string", + "required": true + }, + { + "name": "label", + "type": "string" + }, + { + "name": "aggregate", + "type": "object", + "description": "Aggregation config: { field, function, groupBy }" + }, + { + "name": "icon", + "type": "string" + }, + { + "name": "description", + "type": "string", + "description": "Helper text rendered under the value." + }, + { + "name": "title", + "type": "string", + "description": "Heading of the drill-down panel. Defaults to `label` — set it only when the records list wants a different name from the tile." + }, + { + "name": "filter", + "type": "array", + "description": "Criteria the aggregation is scoped by. The same filter narrows the drill-down list, so the number and the records behind it always agree." + }, + { + "name": "colorVariant", + "type": "enum", + "enum": [ + "default", + "blue", + "teal", + "orange", + "purple", + "success", + "warning", + "danger" + ], + "description": "Colour of the icon container. Semantic, not decorative: `success` / `warning` / `danger` should track what the number means." + }, + { + "name": "variant", + "type": "enum", + "enum": [ + "card", + "bare" + ], + "description": "`card` draws the tile’s own surface; `bare` drops it, for a metric already sitting inside a card." + }, + { + "name": "format", + "type": "string", + "description": "Numeral-style format pattern, e.g. `0,0`, `$0,0`, `0%`. Use `currency` instead of hard-coding a currency symbol here." + }, + { + "name": "currency", + "type": "string", + "description": "ISO 4217 code, e.g. `USD`. Enables locale-aware currency formatting of the value." + }, + { + "name": "prefix", + "type": "string", + "description": "Static text placed before the formatted value." + }, + { + "name": "suffix", + "type": "string", + "description": "Static text placed after the formatted value." + }, + { + "name": "invert", + "type": "boolean", + "description": "Display `1 - value` — for gauges whose good direction is down, such as error rate shown as uptime." + }, + { + "name": "fallbackValue", + "type": "string", + "description": "Value shown when no data source resolves. For static/demo tiles; a bound metric should not need it." + }, + { + "name": "trend", + "type": "object", + "description": "Static trend badge: `{ value, label, direction }`. Use `compareTo` instead when the trend should be computed from data." + }, + { + "name": "compareTo", + "type": "object", + "description": "Period-over-period comparison, `{ kind: \"previousPeriod\" }` or `{ kind: \"previousYear\" }` — the computed alternative to a static `trend`." + }, + { + "name": "drillDown", + "type": "object", + "description": "Click-through config that opens the records behind the number." + } + ] + }, + "object-chart": { + "type": "object-chart", + "namespace": "plugin-charts", + "inputs": [ + { + "name": "objectName", + "type": "string", + "required": true + }, + { + "name": "data", + "type": "array", + "description": "Optional static data" + }, + { + "name": "filter", + "type": "array" + }, + { + "name": "aggregate", + "type": "object", + "description": "Aggregation config: { field, function, groupBy }" + }, + { + "name": "drillDown", + "type": "object", + "description": "Segment drill config: { enabled?, filter?, title?, target?: 'drawer' | 'dialog', columns?, maxRows? }. Present = on; {} is enough. Clicking a segment opens the underlying records filtered by the clicked category." + } + ] + }, + "dashboard": { + "type": "dashboard", + "namespace": "view", + "inputs": [ + { + "name": "widgets", + "type": "array", + "description": "The widget tree — the spec’s DashboardWidget[]. Each widget binds a dataset (ADR-0021) and may carry a layout ({ x, y, w, h }) and filterBindings. When omitted the dashboard renders an empty grid." + }, + { + "name": "label", + "type": [ + "string", + "object" + ], + "description": "Display name, shown as the header title when `header` is declared — a string or an inline per-locale map such as { en, \"zh-CN\" }. Spec-canonical spelling; the legacy `title` spelling is not authoring surface." + }, + { + "name": "description", + "type": [ + "string", + "object" + ], + "description": "Header description shown under the title — a string or an inline per-locale map. Rendered only when `header` is declared and `header.showDescription` is not false." + }, + { + "name": "header", + "type": "object", + "description": "Header block: { showTitle?, showDescription?, actions? }. Strict — the contract rejects any other key. Renders nothing (zero pixels) when everything it would show is suppressed." + }, + { + "name": "globalFilters", + "type": "array", + "description": "Dashboard-level filter bar — the spec’s GlobalFilter[]. Filter values live as dashboard variables (readable in widget expressions as page.) and are AND-merged into each bound widget’s query per its filterBindings." + }, + { + "name": "dateRange", + "type": "object", + "description": "Built-in date-range filter: { field?, defaultRange?, allowCustomRange? }. `defaultRange` takes the spec’s date presets plus \"custom\"; the bound field defaults to created_at." + }, + { + "name": "refreshInterval", + "type": "number", + "description": "Auto-refresh period in seconds. Zero or a negative value disables the timer, and it only runs when the host wires an onRefresh handler." + }, + { + "name": "columns", + "type": "number" + }, + { + "name": "gap", + "type": "number" + }, + { + "name": "className", + "type": "string" + } + ] + }, + "object-pivot": { + "type": "object-pivot", + "namespace": "plugin-dashboard", + "inputs": [ + { + "name": "objectName", + "type": "string", + "required": true + }, + { + "name": "title", + "type": "string" + }, + { + "name": "rowField", + "type": "string", + "required": true + }, + { + "name": "columnField", + "type": "string", + "required": true + }, + { + "name": "valueField", + "type": "string", + "required": true + }, + { + "name": "aggregation", + "type": "enum", + "enum": [ + { + "label": "Sum", + "value": "sum" + }, + { + "label": "Count", + "value": "count" + }, + { + "label": "Average", + "value": "avg" + }, + { + "label": "Min", + "value": "min" + }, + { + "label": "Max", + "value": "max" + } + ] + }, + { + "name": "showRowTotals", + "type": "boolean" + }, + { + "name": "showColumnTotals", + "type": "boolean" + }, + { + "name": "filter", + "type": "array" + }, + { + "name": "format", + "type": "string" + } + ] + }, + "record:details": { + "type": "record:details", + "namespace": "record", + "inputs": [ + { + "name": "columns", + "type": "enum", + "enum": [ + "1", + "2", + "3", + "4" + ], + "description": "Number of columns for field layout (1-4)" + }, + { + "name": "sections", + "type": "array", + "description": "Field groups rendered as the detail body, in order. Every entry is an OBJECT — `{ name?, label?, columns?, fields }` — a bare section-id string is NOT accepted (the spec retired that spelling in objectstack#5611, and the renderer reads name/label/fields off each entry, so a string entry renders no fields at all). `fields` (required) are the field names shown in this section, in order. `label` is the section heading; omit it for an untitled, borderless section. `name` is a stable snake_case identifier and the i18n anchor — the heading resolves through objects.._sections..label, so a section without a name shows its authored label in every locale. `columns` (1-4) is THIS section's field-grid width; omit it and the renderer derives the width. Authoring `sections` at all makes it the only source of the detail body; omit it and the body falls back to the object's highlightFields." + }, + { + "name": "fields", + "type": "array", + "description": "Explicit field list (overrides highlightFields)" + }, + { + "name": "hideFields", + "type": "array", + "description": "Field names to omit from the body — applied to the top-level `fields` list AND to every section's `fields`. Bare field names only. Authors rarely need it: the synth pipeline fills it with the fields already shown in `record:highlights`, and hand-authored pages get the same dedup live from HighlightFieldsContext, so its purpose is suppressing a field you do not want repeated (the page H1 title field is dropped for you too). Hiding every field of a section leaves that section out entirely." + }, + { + "name": "inlineEdit", + "type": "boolean", + "description": "Offer the per-field double-click / pencil inline-edit affordances in the detail body. On by default, and an OPT-OUT only: the value is combined with the object's own editability (system, engine-owned, append-only and better-auth objects are not user-editable unless they opened userActions.edit) and with the server's effective API operation set for the object, so `false` always wins while `true` cannot open editing the platform refuses. The edit session and the atomic Save bar are hosted by the page, so one draft spans the highlights strip and this body." + }, + { + "name": "showHeader", + "type": "boolean", + "description": "Render the detail body's own title / follow-star / copy-id chip above the fields. Off by default because this block is normally composed under a `page:header` that already draws that chrome, and turning it on there shows the record title twice. Set it true only when this block is the whole page." + } + ] + }, + "record:highlights": { + "type": "record:highlights", + "namespace": "record", + "inputs": [ + { + "name": "fields", + "type": "array", + "required": true, + "description": "Key fields to highlight (1-7), bare names or {name,label?,icon?,type?,readonly?}. Set readonly: true on an entry to render that chip read-only — it suppresses the inline-edit affordance and the HeaderHighlight editability gate enforces it. Use it for hook/automation-maintained columns that must not be hand-edited from the record header; marking the OBJECT field readonly instead would also strip the hook's own write-back." + }, + { + "name": "layout", + "type": "enum", + "enum": [ + "horizontal", + "vertical" + ], + "description": "Layout orientation for highlight fields" + } + ] + }, + "record:related_list": { + "type": "record:related_list", + "namespace": "record", + "inputs": [ + { + "name": "objectName", + "type": "string", + "required": true, + "description": "Related object name (e.g. \"task\")" + }, + { + "name": "relationshipField", + "type": "string", + "required": true, + "description": "Field on the related object pointing back to this record" + }, + { + "name": "relationshipValueField", + "type": "string", + "description": "Which field OF THIS PARENT record `relationshipField` stores. Defaults to \"id\"; set it to the field a name-keyed junction points at (e.g. \"name\" when sys_user_position.position holds sys_position.name). The resolved value drives three things at once — the list filter, the Add-picker link value, and the pre-filled create form — so they cannot drift apart. While the parent record is still loading, a non-\"id\" field resolves to null and the list holds its fetch rather than querying on an empty value." + }, + { + "name": "columns", + "type": "array", + "required": true, + "description": "Fields to display in the related list" + }, + { + "name": "sort", + "type": "array" + }, + { + "name": "limit", + "type": "number", + "description": "Records to display initially" + }, + { + "name": "filter", + "type": "array", + "description": "Additional filter criteria, as spec `ViewFilterRule` entries (`[{ field, operator, value }]`). AND-combined with the parent relationship condition, never a replacement for it: it can only narrow this record's children. Also the key a per-element `dataSource` binding's composed filter lands on." + }, + { + "name": "title", + "type": "string" + }, + { + "name": "showViewAll", + "type": "boolean" + }, + { + "name": "actions", + "type": "array", + "description": "Action IDs available for related records" + }, + { + "name": "add", + "type": "object", + "description": "Adds an \"Add\" button that assigns EXISTING records instead of creating one — the m2m/junction case. Shape: `{ picker: { object, valueField?, labelField?, filter? }, linkField?, label? }`. `picker.object` (required) is the object whose records the dialog offers. `picker.valueField` is the field of the picked record used as the link value (default \"id\"); `picker.labelField` is the column shown in the picker rows (default \"name\", and the other columns are derived from that object's schema). With `linkField` set, selecting records CREATES rows in this list's own object as `{ [relationshipField]: parentValue, [linkField]: pickedId }` — the junction case; omit `linkField` and the picked child is RE-PARENTED instead, by setting its own `relationshipField` to this parent. `label` is the button text (default \"Add\", localizable inline). Setting `add` also enables generic link removal on rows when no host delete handler is wired. `picker.filter` restricts which records the dialog offers — a list of `{ field, operator, value }` rules in the same vocabulary as this list's own `filter`, applied as a hard constraint the user cannot widen (it never appears as an editable filter row)." + } + ] + }, + "record:path": { + "type": "record:path", + "namespace": "record", + "inputs": [ + { + "name": "statusField", + "type": "string", + "required": true, + "description": "Field representing the current status/stage" + }, + { + "name": "stages", + "type": "array", + "description": "Explicit stage definitions [{ value, label }] (else derived from field metadata)" + } + ] + }, + "record:line_items": { + "type": "record:line_items", + "namespace": "record", + "inputs": [ + { + "name": "childObject", + "type": "string", + "required": true + }, + { + "name": "relationshipField", + "type": "string", + "required": true + }, + { + "name": "columns", + "type": "array", + "required": true + }, + { + "name": "totalField", + "type": "string" + }, + { + "name": "amountField", + "type": "string" + } + ] + }, + "record:activity": { + "type": "record:activity", + "namespace": "record", + "inputs": [ + { + "name": "types", + "type": "array", + "description": "Allow-list of feed item types to show (comment, field_change, task, event, email, call, note, file, record_create, record_delete, approval, sharing, system). Omit for all; unrecognised entries are ignored." + }, + { + "name": "filterMode", + "type": "enum", + "enum": [ + "all", + "comments_only", + "changes_only", + "tasks_only" + ], + "description": "Filter the timeline dropdown starts on. The user can still change it; an unrecognised value falls back to \"all\"." + }, + { + "name": "showFilterToggle", + "type": "boolean", + "description": "Expose the activity-type filter dropdown in the panel header" + }, + { + "name": "limit", + "type": "number", + "description": "Items per page. Also caps the scoped sys_activity read; \"Load more\" grows the window by this much." + }, + { + "name": "showCompleted", + "type": "boolean", + "description": "Include completed activities (sys_activity type \"completed\", which surfaces as a task item). Off by default." + }, + { + "name": "unifiedTimeline", + "type": "boolean", + "description": "Mix field changes and comments in one timeline (Airtable style). Off keeps the panel a discussion stream — field changes stay in record:history." + }, + { + "name": "showCommentInput", + "type": "boolean", + "description": "Show the composer. Requires a host discussion context to persist the comment (record detail pages provide one); without it the feed is read-only." + }, + { + "name": "enableMentions", + "type": "boolean", + "description": "Offer @-mention autocomplete in the composer, from the host discussion context's user list. Off withholds the suggestions." + }, + { + "name": "enableReactions", + "type": "boolean", + "description": "Show emoji reactions on feed items. Toggling one requires a host discussion context; without it existing reactions still render, read-only." + }, + { + "name": "enableThreading", + "type": "boolean", + "description": "Group replies under their parent comment. Posting a reply requires a host discussion context." + }, + { + "name": "showSubscriptionToggle", + "type": "boolean", + "description": "NOT IMPLEMENTED — no record-subscription backend exists yet, so this renders nothing whatever it is set to. Declared for spec parity only (objectui#3165)." + } + ] + }, + "record:discussion": { + "type": "record:discussion", + "namespace": "record", + "inputs": [ + { + "name": "position", + "type": "enum", + "enum": [ + "bottom", + "right", + "left" + ], + "description": "Where the panel docks relative to the record body" + }, + { + "name": "width", + "type": "string", + "description": "Panel width as a CSS value (side positions only)" + }, + { + "name": "collapsible", + "type": "boolean" + }, + { + "name": "defaultCollapsed", + "type": "boolean" + }, + { + "name": "feed", + "type": "object", + "description": "Activity-feed config nested inside the panel — same shape as record:activity" + } + ] + }, + "record:history": { + "type": "record:history", + "namespace": "record", + "inputs": [ + { + "name": "limit", + "type": "number", + "description": "Maximum history entries to display" + }, + { + "name": "emptyText", + "type": "string", + "description": "Copy shown when the record has no history" + }, + { + "name": "unknownUserText", + "type": "string", + "description": "Copy substituted when an entry has no resolvable actor" + } + ] + }, + "record:quick_actions": { + "type": "record:quick_actions", + "namespace": "record", + "inputs": [ + { + "name": "actionNames", + "type": "array", + "description": "Action names to expose, in order — resolved from the actions declared on the object. With no names (and no host-supplied actions) nothing is looked up and the bar renders its empty placeholder" + }, + { + "name": "requiredPermissions", + "type": "array", + "description": "Hide the whole bar unless the user holds these permissions" + }, + { + "name": "location", + "type": "enum", + "enum": [ + "list_toolbar", + "list_item", + "record_header", + "record_more", + "record_related", + "record_section" + ], + "description": "Which declared action location this bar renders" + }, + { + "name": "align", + "type": "enum", + "enum": [ + "start", + "center", + "end" + ] + }, + { + "name": "inline", + "type": "boolean", + "description": "Render in the flow instead of folding into the record header" + }, + { + "name": "variant", + "type": "string", + "description": "Passed to the Button primitive; a per-action variant overrides it" + }, + { + "name": "size", + "type": "string", + "description": "Passed to the Button primitive; a per-action size overrides it" + } + ] + }, + "record:reference_rail": { + "type": "record:reference_rail", + "namespace": "record", + "inputs": [ + { + "name": "hideEmpty", + "type": "boolean", + "description": "Drop the rail entirely when no entries resolve" + } + ] + }, + "record:alert": { + "type": "record:alert", + "namespace": "record", + "inputs": [ + { + "name": "severity", + "type": "enum", + "enum": [ + "info", + "warning", + "error", + "success" + ] + }, + { + "name": "title", + "type": [ + "string", + "object" + ], + "description": "Accepts an inline translation map ({ en, \"zh-CN\", … })" + }, + { + "name": "body", + "type": [ + "string", + "object" + ], + "description": "Accepts an inline translation map ({ en, \"zh-CN\", … })" + }, + { + "name": "visible", + "type": "string", + "description": "Expression gating the banner against the current record" + }, + { + "name": "icon", + "type": "string", + "description": "Lucide icon name; defaults to the severity icon" + }, + { + "name": "action", + "type": "object", + "description": "{ actionName, label?, variant? } — the action the banner offers" + }, + { + "name": "dismissible", + "type": "boolean" + }, + { + "name": "dismissKey", + "type": "string", + "description": "Stable key the dismissal is remembered under" + } + ] + }, + "page:tabs": { + "type": "page:tabs", + "namespace": "page", + "isContainer": true, + "inputs": [ + { + "name": "items", + "type": "array", + "required": true, + "description": "Tab definitions [{ label, value?, icon?, count?, visibleWhen?, children }] — value is the stable ?tab= URL token, count auto-derives from record:related_list descendants when omitted" + }, + { + "name": "tabStyle", + "type": "enum", + "enum": [ + "line", + "card", + "pill" + ] + }, + { + "name": "position", + "type": "enum", + "enum": [ + "top", + "left" + ] + }, + { + "name": "alwaysShowStrip", + "type": "boolean", + "description": "Keep the tab strip visible when only one tab survives. Default false: a lone pill is clutter rather than an affordance, so a one-tab strip is hidden and its panel renders bare. Count the tabs AFTER each item visibleWhen predicate has been evaluated — a page authored with four tabs of which three are conditional reaches this rule whenever the other three are false." + } + ] + }, + "page:card": { + "type": "page:card", + "namespace": "page", + "isContainer": true, + "inputs": [ + { + "name": "title", + "type": [ + "string", + "object" + ], + "description": "Accepts an inline translation map ({ en, \"zh-CN\", … })" + }, + { + "name": "bordered", + "type": "boolean" + }, + { + "name": "children", + "type": "slot", + "description": "Card content components, in order (the card body slot)" + }, + { + "name": "footer", + "type": "slot" + } + ] + }, + "page:accordion": { + "type": "page:accordion", + "namespace": "page", + "isContainer": true, + "inputs": [ + { + "name": "items", + "type": "array", + "required": true, + "description": "Panel definitions [{ label, icon?, collapsed?, children }] — collapsed: false opens a panel by default" + }, + { + "name": "allowMultiple", + "type": "boolean" + }, + { + "name": "variant", + "type": "enum", + "enum": [ + "flush", + "card" + ] + } + ] + }, + "page:section": { + "type": "page:section", + "namespace": "page", + "isContainer": true, + "inputs": [ + { + "name": "children", + "type": "slot", + "description": "Child components rendered inside this container, in order" + } + ] + }, + "page:footer": { + "type": "page:footer", + "namespace": "page", + "isContainer": true, + "inputs": [ + { + "name": "children", + "type": "slot", + "description": "Child components rendered inside this container, in order" + } + ] + }, + "page:sidebar": { + "type": "page:sidebar", + "namespace": "page", + "isContainer": true, + "inputs": [ + { + "name": "children", + "type": "slot", + "description": "Child components rendered inside this container, in order" + } + ] + }, + "element:text": { + "type": "element:text", + "namespace": "element", + "inputs": [ + { + "name": "content", + "type": [ + "string", + "object" + ], + "required": true, + "description": "Accepts an inline translation map ({ en, \"zh-CN\", … })" + }, + { + "name": "variant", + "type": "enum", + "enum": [ + "heading", + "subheading", + "body", + "caption" + ] + }, + { + "name": "align", + "type": "enum", + "enum": [ + "left", + "center", + "right" + ] + } + ] + }, + "element:number": { + "type": "element:number", + "namespace": "element", + "inputs": [ + { + "name": "object", + "type": "string", + "required": true, + "description": "Object the aggregate runs over" + }, + { + "name": "aggregate", + "type": "enum", + "required": true, + "enum": [ + "count", + "sum", + "avg", + "min", + "max" + ] + }, + { + "name": "field", + "type": "string", + "description": "Measure field (required for every aggregate except count)" + }, + { + "name": "filter", + "type": "array" + }, + { + "name": "format", + "type": "enum", + "enum": [ + "number", + "currency", + "percent" + ] + }, + { + "name": "prefix", + "type": "string" + }, + { + "name": "suffix", + "type": "string" + } + ] + }, + "element:button": { + "type": "element:button", + "namespace": "element", + "inputs": [ + { + "name": "label", + "type": [ + "string", + "object" + ], + "required": true, + "description": "Accepts an inline translation map ({ en, \"zh-CN\", … })" + }, + { + "name": "action", + "type": "object", + "description": "Inline ActionDef executed on click (url / navigation / api / script / modal / flow); omitted → renders inert" + }, + { + "name": "variant", + "type": "enum", + "enum": [ + "primary", + "secondary", + "danger", + "ghost", + "link" + ] + }, + { + "name": "size", + "type": "enum", + "enum": [ + "small", + "medium", + "large" + ] + }, + { + "name": "icon", + "type": "string", + "description": "Lucide icon name" + }, + { + "name": "iconPosition", + "type": "enum", + "enum": [ + "left", + "right" + ] + }, + { + "name": "disabled", + "type": "boolean" + } + ] + }, + "element:definition-list": { + "type": "element:definition-list", + "namespace": "element", + "inputs": [ + { + "name": "items", + "type": "array", + "required": true, + "description": "Term/description pairs [{ term, description }]" + }, + { + "name": "columns", + "type": "enum", + "enum": [ + "1", + "2" + ] + }, + { + "name": "inline", + "type": "boolean", + "description": "Term and description on one baseline-aligned row" + } + ] + }, + "element:repeater": { + "type": "element:repeater", + "namespace": "element", + "inputs": [ + { + "name": "object", + "type": "string", + "required": true, + "description": "Object whose records the list repeats over" + }, + { + "name": "titleField", + "type": "string" + }, + { + "name": "fields", + "type": "array", + "description": "Columns per row — bare names or { field, label? }" + }, + { + "name": "filter", + "type": "array" + }, + { + "name": "sort", + "type": "array" + }, + { + "name": "limit", + "type": "number" + }, + { + "name": "emptyText", + "type": "string" + }, + { + "name": "divided", + "type": "boolean", + "description": "Separator between rows" + } + ] + }, + "action:button": { + "type": "action:button", + "namespace": "action", + "inputs": [ + { + "name": "name", + "type": "string" + }, + { + "name": "label", + "type": "string" + }, + { + "name": "icon", + "type": "string" + }, + { + "name": "type", + "type": "enum", + "enum": [ + "script", + "url", + "modal", + "flow", + "api" + ] + }, + { + "name": "target", + "type": "string" + }, + { + "name": "variant", + "type": "enum", + "enum": [ + "default", + "primary", + "secondary", + "destructive", + "outline", + "ghost" + ] + }, + { + "name": "size", + "type": "enum", + "enum": [ + "sm", + "md", + "lg" + ] + }, + { + "name": "className", + "type": "string" + } + ] + }, + "action:group": { + "type": "action:group", + "namespace": "action", + "inputs": [ + { + "name": "name", + "type": "string" + }, + { + "name": "label", + "type": "string" + }, + { + "name": "icon", + "type": "string" + }, + { + "name": "actions", + "type": "object" + }, + { + "name": "display", + "type": "enum", + "enum": [ + "inline", + "dropdown" + ] + }, + { + "name": "variant", + "type": "enum", + "enum": [ + "default", + "secondary", + "outline", + "ghost" + ] + }, + { + "name": "size", + "type": "enum", + "enum": [ + "sm", + "md", + "lg" + ] + }, + { + "name": "className", + "type": "string" + } + ] + }, + "action:menu": { + "type": "action:menu", + "namespace": "action", + "inputs": [ + { + "name": "label", + "type": "string" + }, + { + "name": "icon", + "type": "string" + }, + { + "name": "actions", + "type": "object" + }, + { + "name": "variant", + "type": "enum", + "enum": [ + "default", + "secondary", + "outline", + "ghost" + ] + }, + { + "name": "className", + "type": "string" + } + ] + }, + "action:icon": { + "type": "action:icon", + "namespace": "action", + "inputs": [ + { + "name": "name", + "type": "string" + }, + { + "name": "label", + "type": "string" + }, + { + "name": "icon", + "type": "string" + }, + { + "name": "type", + "type": "enum", + "enum": [ + "script", + "url", + "modal", + "flow", + "api" + ] + }, + { + "name": "target", + "type": "string" + }, + { + "name": "variant", + "type": "enum", + "enum": [ + "default", + "secondary", + "destructive", + "outline", + "ghost" + ] + }, + { + "name": "className", + "type": "string" + } + ] + }, + "flex": { + "type": "flex", + "namespace": "ui", + "inputs": [ + { + "name": "direction", + "type": "enum", + "enum": [ + "row", + "col", + "row-reverse", + "col-reverse" + ] + }, + { + "name": "justify", + "type": "enum", + "enum": [ + "start", + "end", + "center", + "between", + "around", + "evenly" + ] + }, + { + "name": "align", + "type": "enum", + "enum": [ + "start", + "end", + "center", + "baseline", + "stretch" + ] + }, + { + "name": "gap", + "type": "number", + "description": "Gap between items (0-8)" + }, + { + "name": "wrap", + "type": "boolean", + "description": "Allow flex items to wrap" + }, + { + "name": "className", + "type": "string" + } + ] + }, + "grid": { + "type": "grid", + "namespace": "ui", + "isContainer": true, + "inputs": [ + { + "name": "columns", + "type": "number", + "description": "Number of columns on mobile devices" + }, + { + "name": "smColumns", + "type": "number", + "description": "Columns at sm breakpoint (>640px)" + }, + { + "name": "mdColumns", + "type": "number", + "description": "Columns at md breakpoint (>768px)" + }, + { + "name": "lgColumns", + "type": "number", + "description": "Columns at lg breakpoint (>1024px)" + }, + { + "name": "xlColumns", + "type": "number", + "description": "Columns at xl breakpoint (>1280px)" + }, + { + "name": "gap", + "type": "number", + "description": "Gap between items (0-12)" + }, + { + "name": "className", + "type": "string" + } + ] + }, + "stack": { + "type": "stack", + "namespace": "ui", + "isContainer": true, + "inputs": [ + { + "name": "direction", + "type": "enum", + "enum": [ + "col", + "row", + "col-reverse", + "row-reverse" + ] + }, + { + "name": "gap", + "type": "number" + }, + { + "name": "align", + "type": "enum", + "enum": [ + "start", + "end", + "center", + "stretch", + "baseline" + ] + }, + { + "name": "justify", + "type": "enum", + "enum": [ + "start", + "end", + "center", + "between", + "around", + "evenly" + ] + }, + { + "name": "className", + "type": "string" + } + ] + }, + "card": { + "type": "card", + "namespace": "ui", + "isContainer": true, + "inputs": [ + { + "name": "title", + "type": "string" + }, + { + "name": "description", + "type": "string" + }, + { + "name": "className", + "type": "string" + } + ] + }, + "tabs": { + "type": "tabs", + "namespace": "ui", + "inputs": [ + { + "name": "defaultValue", + "type": "string", + "required": true + }, + { + "name": "className", + "type": "string" + }, + { + "name": "items", + "type": "array" + } + ] + }, + "accordion": { + "type": "accordion", + "namespace": "ui", + "inputs": [ + { + "name": "accordionType", + "type": "enum", + "enum": [ + "single", + "multiple" + ] + }, + { + "name": "collapsible", + "type": "boolean" + }, + { + "name": "items", + "type": "array", + "description": "Array of { trigger, content, value }" + }, + { + "name": "className", + "type": "string" + } + ] + }, + "container": { + "type": "container", + "namespace": "ui", + "isContainer": true, + "inputs": [ + { + "name": "maxWidth", + "type": "enum", + "enum": [ + { + "label": "None (cancel max-width)", + "value": false + }, + { + "label": "sm", + "value": "sm" + }, + { + "label": "md", + "value": "md" + }, + { + "label": "lg", + "value": "lg" + }, + { + "label": "xl", + "value": "xl" + }, + { + "label": "2xl", + "value": "2xl" + }, + { + "label": "3xl", + "value": "3xl" + }, + { + "label": "4xl", + "value": "4xl" + }, + { + "label": "5xl", + "value": "5xl" + }, + { + "label": "6xl", + "value": "6xl" + }, + { + "label": "7xl", + "value": "7xl" + }, + { + "label": "full", + "value": "full" + }, + { + "label": "screen", + "value": "screen" + } + ] + }, + { + "name": "padding", + "type": "number", + "description": "Padding value (0, 1-8, 10, 12, 16)" + }, + { + "name": "centered", + "type": "boolean" + }, + { + "name": "className", + "type": "string" + } + ] + }, + "page:header": { + "type": "page:header", + "namespace": "page", + "inputs": [ + { + "name": "title", + "type": [ + "string", + "object" + ], + "description": "Supports {field} interpolation and inline translation maps; falls back to the record title" + }, + { + "name": "subtitle", + "type": [ + "string", + "object" + ], + "description": "Same interpolation as Title" + }, + { + "name": "actions", + "type": "array", + "description": "Action buttons rendered in the header, before any host-injected system actions" + }, + { + "name": "breadcrumb", + "type": "boolean" + }, + { + "name": "recordChrome", + "type": "boolean", + "description": "Set false for the bare h1 header on non-record pages" + }, + { + "name": "showStar", + "type": "boolean" + }, + { + "name": "showCopyId", + "type": "boolean" + }, + { + "name": "maxVisible", + "type": "number", + "description": "How many header actions render as inline buttons on desktop before the rest fold into the overflow menu. A positive integer — the contract rejects 0 and fractional values. Two kinds of action are routed to the overflow menu regardless of this budget and never occupy an inline slot: an action whose locations declare record_more without record_header, and any action with component action:menu." + }, + { + "name": "mobileMaxVisible", + "type": "number", + "description": "The same inline-button budget on mobile viewports, where horizontal room is scarce. A positive integer, defaulting to 1; the desktop half is Max Inline Actions, and the overflow routing rules stated there apply unchanged." + } + ] + }, + "text": { + "type": "text", + "namespace": "ui", + "inputs": [ + { + "name": "content", + "type": "string", + "required": true + } + ] + }, + "image": { + "type": "image", + "namespace": "ui", + "inputs": [ + { + "name": "src", + "type": "string" + }, + { + "name": "alt", + "type": "string" + }, + { + "name": "className", + "type": "string" + } + ] + }, + "icon": { + "type": "icon", + "namespace": "ui", + "inputs": [ + { + "name": "icon", + "type": "string" + }, + { + "name": "size", + "type": "number" + }, + { + "name": "color", + "type": "string" + }, + { + "name": "className", + "type": "string" + } + ] + }, + "markdown": { + "type": "markdown", + "namespace": "plugin-markdown", + "inputs": [ + { + "name": "content", + "type": "string", + "required": true + }, + { + "name": "className", + "type": "string" + } + ] + }, + "element:divider": { + "type": "element:divider", + "namespace": "element", + "inputs": [] + }, + "badge": { + "type": "badge", + "namespace": "ui", + "inputs": [ + { + "name": "label", + "type": "string" + }, + { + "name": "variant", + "type": "enum", + "enum": [ + "default", + "secondary", + "destructive", + "outline" + ] + }, + { + "name": "className", + "type": "string" + } + ] + }, + "alert": { + "type": "alert", + "namespace": "ui", + "inputs": [ + { + "name": "title", + "type": "string", + "required": true + }, + { + "name": "description", + "type": "string" + }, + { + "name": "variant", + "type": "enum", + "enum": [ + "default", + "destructive" + ] + }, + { + "name": "className", + "type": "string" + } + ] + }, + "button": { + "type": "button", + "namespace": "ui", + "inputs": [ + { + "name": "label", + "type": "string" + }, + { + "name": "variant", + "type": "enum", + "enum": [ + "default", + "secondary", + "destructive", + "outline", + "ghost", + "link" + ] + }, + { + "name": "size", + "type": "enum", + "enum": [ + "default", + "sm", + "lg", + "icon" + ] + }, + { + "name": "className", + "type": "string" + } + ] + }, + "html": { + "type": "html", + "namespace": "ui", + "inputs": [ + { + "name": "html", + "type": "string", + "description": "Raw HTML content" + } + ] + } + } +} \ No newline at end of file diff --git a/turbo.json b/turbo.json index 49f6fe3cdb..f0c79427b9 100644 --- a/turbo.json +++ b/turbo.json @@ -136,7 +136,11 @@ "$TURBO_ROOT$/packages/services/service-automation/src/**", "$TURBO_ROOT$/examples/app-showcase/src/data/objects/contact.object.ts", "$TURBO_ROOT$/examples/app-showcase/src/system/translations/index.ts", - "$TURBO_ROOT$/examples/app-showcase/src/ui/views/contact.view.ts" + "$TURBO_ROOT$/examples/app-showcase/src/ui/views/contact.view.ts", + "$TURBO_ROOT$/examples/app-showcase/src/ui/pages/capability-map.page.ts", + "$TURBO_ROOT$/examples/app-showcase/src/ui/pages/command-center-jsx.page.ts", + "$TURBO_ROOT$/examples/app-showcase/src/ui/pages/start-here.page.ts", + "$TURBO_ROOT$/sdui.manifest.json" ] }, "@objectstack/cloud-connection#test": {