Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/highlights-readonly-authoring-surface-3407.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
20 changes: 19 additions & 1 deletion packages/plugin-detail/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
// `<RecordHighlights readonly>`, 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' },
],
});
Expand Down
Loading