diff --git a/.changeset/highlights-readonly-authoring-surface-3407.md b/.changeset/highlights-readonly-authoring-surface-3407.md new file mode 100644 index 000000000..d4fb03454 --- /dev/null +++ b/.changeset/highlights-readonly-authoring-surface-3407.md @@ -0,0 +1,35 @@ +--- +"@object-ui/plugin-detail": patch +--- + +`record:highlights` publishes the `readonly` entry key, so an AI author can discover it from the manifest + +`readonly` on a `fields[]` entry has been enforced for a while — the renderer copies it +through normalization and `HeaderHighlight`'s editability gate refuses inline editing on a +chip carrying it (objectstack#5077) — and `@objectstack/spec` declares it on +`RecordHighlightsField` (objectstack#5176 / PR #5607). The block's own published authoring +surface never mentioned it: the `fields` input still spelled the entry shape +`{name,label?,icon?,type?}`, and since the registry `inputs` are what +`gen-manifest.ts` serializes into `sdui.manifest.json`, an author reading the manifest was +told the key did not exist. The `fields` description now states the full entry shape and +what `readonly` does, which is the discoverability the manifest is for. + +`readonly` is documented **inside** the `fields` description rather than declared as an +input of its own, because that is where the contract puts it. The spec's +`RecordHighlightsProps` has exactly three top-level keys (`fields`, `layout`, `aria`) and +carries `readonly` per ENTRY. A top-level `{ name: 'readonly', type: 'boolean' }` input +would publish a key the platform silently discards: the generated `sdui.manifest.json` and +`sdui-intrinsics.d.ts` would advertise a `readonly` prop, the manifest gate validates +top-level props only and would raise no diagnostic, `RecordHighlightsProps` is a plain +`z.object` so the unknown key is stripped on parse without error, and the renderer — which +reads `field.readonly` per entry — would never see it. An author who trusted that surface +would be left with the machine-owned column still hand-editable and no diagnostic anywhere +explaining why. `ComponentInput` is flat by design, so an array-of-objects input publishes +its member keys in prose, as `record:path.stages` and `record:alert.action` already do. + +A new spec-parity test derives both directions from `@objectstack/spec` at runtime instead +of restating today's key list: every key of `RecordHighlightsField`'s object arm must be +named in the `fields` description, and the block must declare no top-level input that +`RecordHighlightsProps` does not accept. Nothing previously cross-checked the registry +`inputs` against the spec, so both drift directions were silent. No runtime behaviour +changes. diff --git a/packages/plugin-detail/src/__tests__/recordHighlightsInputs.spec-parity.test.ts b/packages/plugin-detail/src/__tests__/recordHighlightsInputs.spec-parity.test.ts new file mode 100644 index 000000000..444868825 --- /dev/null +++ b/packages/plugin-detail/src/__tests__/recordHighlightsInputs.spec-parity.test.ts @@ -0,0 +1,108 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * `record:highlights` — the published authoring surface stays in parity with + * `@objectstack/spec` RecordHighlights* (objectui#3407, objectstack#5176). + * + * The registry `inputs` ARE the published contract: `gen-manifest.ts` + * serializes them into `sdui.manifest.json` (the save-gate + parser whitelist) + * and into `sdui-intrinsics.d.ts` (the JSX authoring type surface). Nothing in + * the repo cross-checks them against the spec, so both drift directions are + * silent and both are harmful: + * + * - a spec ENTRY key that no input mentions is a key an AI author cannot + * discover (the complaint that opened #3407: `readonly` was enforced by the + * HeaderHighlight gate and honoured by the renderer, but the `fields` + * description still spelled the entry shape `{name,label?,icon?,type?}`); + * - a top-level input the spec does not declare is worse than undocumented, + * it is actively misleading. `RecordHighlightsProps` is a plain `z.object`, + * so an unknown top-level key is STRIPPED on parse with no error, the + * manifest gate only validates top-level props and raises no diagnostic, + * and the renderer never sees it. The manifest would be telling authors to + * write something the platform throws away. + * + * Both assertions derive their expectation from the spec at runtime rather than + * restating today's key list, so a spec change fails here instead of quietly + * widening the gap. + */ + +import { describe, it, expect } from 'vitest'; +import { ComponentRegistry } from '@object-ui/core'; +import { RecordHighlightsField, RecordHighlightsProps } from '@objectstack/spec/ui'; +import '../index'; + +/** Keys of the object arm of the spec's `RecordHighlightsField` union. */ +function specEntryKeys(): string[] { + const union = RecordHighlightsField as unknown as { + def?: { options?: unknown[] }; + _def?: { options?: unknown[] }; + }; + const arms = union.def?.options ?? union._def?.options ?? []; + for (const arm of arms) { + const shape = (arm as { shape?: unknown; _def?: { shape?: unknown } }).shape + ?? (arm as { _def?: { shape?: unknown } })._def?.shape; + const resolved = typeof shape === 'function' ? (shape as () => object)() : shape; + if (resolved && typeof resolved === 'object') return Object.keys(resolved); + } + return []; +} + +/** Top-level keys of the spec's `RecordHighlightsProps`. */ +function specTopLevelKeys(): string[] { + const obj = RecordHighlightsProps as unknown as { + shape?: unknown; + _def?: { shape?: unknown }; + }; + const shape = obj.shape ?? obj._def?.shape; + const resolved = typeof shape === 'function' ? (shape as () => object)() : shape; + return resolved && typeof resolved === 'object' ? Object.keys(resolved) : []; +} + +const config = () => ComponentRegistry.getConfig('record:highlights'); +const inputs = () => config()?.inputs ?? []; +const fieldsInput = () => inputs().find((i) => i.name === 'fields'); + +describe('record:highlights — registry inputs vs @objectstack/spec', () => { + it('is registered with a non-empty `inputs` surface', () => { + expect(config()).toBeDefined(); + expect(inputs().length).toBeGreaterThan(0); + expect(inputs().map((i) => i.name)).toContain('fields'); + }); + + it('the spec really carries `readonly` per ENTRY, not top-level', () => { + // Guards the premise the rest of the file rests on. If a future spec moves + // `readonly` up to the props object, this fails and the `inputs` shape + // above should be revisited — a top-level input would then be correct. + expect(specEntryKeys()).toContain('readonly'); + expect(specTopLevelKeys()).not.toContain('readonly'); + }); + + it('a top-level `readonly` is silently stripped by the spec, so it must not be published', () => { + // The concrete harm: no throw, no diagnostic, key gone. + const parsed = RecordHighlightsProps.parse({ fields: ['amount'], readonly: true }); + expect(parsed).not.toHaveProperty('readonly'); + // …while the per-entry spelling survives, which is the one authors need. + const perEntry = RecordHighlightsProps.parse({ fields: [{ name: 'amount', readonly: true }] }); + expect(perEntry.fields[0]).toMatchObject({ name: 'amount', readonly: true }); + }); + + it('every spec entry key is discoverable from the `fields` input description', () => { + const description = fieldsInput()?.description ?? ''; + expect(description).not.toBe(''); + const undocumented = specEntryKeys().filter((key) => !description.includes(key)); + expect(undocumented).toEqual([]); + // The key this issue was filed for, named explicitly so the regression is + // legible if the derived check above is ever loosened. + expect(description).toContain('readonly'); + }); + + it('declares no top-level input the spec does not accept', () => { + const allowed = new Set(specTopLevelKeys()); + const offSpec = inputs().map((i) => i.name).filter((name) => !allowed.has(name)); + expect(offSpec).toEqual([]); + }); +}); diff --git a/packages/plugin-detail/src/index.tsx b/packages/plugin-detail/src/index.tsx index 2846a506a..81cccfcad 100644 --- a/packages/plugin-detail/src/index.tsx +++ b/packages/plugin-detail/src/index.tsx @@ -278,8 +278,26 @@ ComponentRegistry.register('highlights', RecordHighlightsRenderer, { label: 'Highlights Panel', icon: 'Star', // Mirrors @objectstack/spec RecordHighlightsProps. + // + // `readonly` is documented INSIDE the `fields` description, not declared as + // an input of its own, because that is where the contract puts it: the spec's + // `RecordHighlightsField` carries `readonly` on each ENTRY, while + // `RecordHighlightsProps` has exactly three top-level keys (fields, layout, + // aria). A top-level `{ name: 'readonly', type: 'boolean' }` here would look + // like the fix for "the manifest never mentions readonly" and would instead + // publish a key the platform silently discards: the generated + // `sdui.manifest.json` and `sdui-intrinsics.d.ts` would advertise + // ``, the manifest gate validates top-level props + // only and would raise no diagnostic, the spec strips the unknown key on + // parse without error, and the renderer — which reads `field.readonly` per + // entry — would never see it. An author who trusted that surface would be + // left with the machine-owned column still hand-editable and nothing + // anywhere saying why. `ComponentInput` is flat by design (`name` = "must + // match schema property"), so an array-of-objects input publishes its member + // keys in prose, the same way `record:path.stages` and `record:alert.action` + // do. objectui#3407 / objectstack#5176. inputs: [ - { name: 'fields', type: 'array', label: 'Fields', required: true, description: 'Key fields to highlight (1-7), bare names or {name,label?,icon?,type?}' }, + { name: 'fields', type: 'array', label: 'Fields', 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', label: 'Layout', enum: ['horizontal', 'vertical'], defaultValue: 'horizontal', description: 'Layout orientation for highlight fields' }, ], });