diff --git a/.changeset/record-highlights-field-readonly.md b/.changeset/record-highlights-field-readonly.md new file mode 100644 index 0000000000..8f09b7642c --- /dev/null +++ b/.changeset/record-highlights-field-readonly.md @@ -0,0 +1,49 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): `RecordHighlightsField` declares `readonly` (#5176) + +The object form of a `record:highlights` entry now declares an optional +`readonly: boolean`. It marks a highlight chip as non-editable — use it for +columns a hook or automation maintains, which must not be hand-edited from the +record header. + +```ts +{ + type: 'record:highlights', + properties: { + fields: [ + 'name', + { name: 'supply_share', type: 'number', readonly: true }, + ], + }, +} +``` + +**Why this is a spec change and not a renderer detail.** The renderer's +`HeaderHighlight` gate already refuses inline editing on a chip carrying +`readonly`, but the key was not declared here — and the object member is not +`.strict()`, so `RecordHighlightsField` **silently stripped** it: + +``` +input { fields: [ { name: 'supply_share', readonly: true, type: 'number' } ] } +parsed { fields: [ { name: 'supply_share', type: 'number' } ] } +``` + +That worked end to end only because per-component props are not parsed on the +live load path today (`PageComponentSchema.properties` is +`z.record(z.string(), z.unknown())`, so the bag rides through untouched). The +moment that gate is wired up, an authored `readonly` becomes either a silent +strip — a machine-owned column quietly editable again, with no diagnostic +anywhere — or a hard parse error. Declaring the key makes the authored +declaration and the enforced behaviour the same fact, which is what ADR-0049 +asks for: it is enforced on arrival, not declared-and-inert. + +For authors — including AI authors — the key now appears in the generated +component reference, and a misspelling (`readOnly`, `read_only`) is a wrong key +rather than a second de-facto contract the renderer happens to honour. + +Purely additive: `readonly` is optional and no default is materialized, so an +entry that does not author it parses exactly as before, and the bare-string form +of a highlight field is unchanged. diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index df837e7062..d342317cb9 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -300,7 +300,7 @@ const result = AIChatWindowProps.parse(data); ## RecordHighlightsField -Highlight field: bare name, or `{name,label?,icon?,type?}` +Highlight field: bare name, or `{name,label?,icon?,type?,readonly?}` ### Union Options @@ -322,6 +322,7 @@ Type: `string` | **label** | `string` | optional | Display label (overrides schema label) | | **icon** | `string` | optional | Icon name (lucide icon key) | | **type** | `string` | optional | Override cell renderer type (rare) | +| **readonly** | `boolean` | optional | Render this chip read-only — suppresses inline editing on the highlight card. Use for hook/automation-maintained columns that must not be hand-edited from the record header. | --- @@ -334,7 +335,7 @@ Type: `string` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **fields** | `string \| { name: string; label?: string; icon?: string; type?: string }[]` | ✅ | Key fields to highlight (1-7 fields max, typically displayed as prominent cards). Each item may be a bare field name or `{name, label?, icon?, type?}` for inline overrides. | +| **fields** | `string \| { name: string; label?: string; icon?: string; type?: string; … }[]` | ✅ | Key fields to highlight (1-7 fields max, typically displayed as prominent cards). Each item may be a bare field name or `{name, label?, icon?, type?, readonly?}` for inline overrides. | | **layout** | `Enum<'horizontal' \| 'vertical'>` | ✅ | Layout orientation for highlight fields | | **aria** | `{ ariaLabel?: string; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index 2607f37eba..c172e0e8b8 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -212,6 +212,47 @@ describe('RecordHighlightsProps', () => { it('should reject missing fields', () => { expect(() => RecordHighlightsProps.parse({})).toThrow(); }); + + // #5176 — `readonly` is a declared key on the object member of + // RecordHighlightsField. objectui's HeaderHighlight gate reads it to keep a + // hook-maintained column non-editable; before it was declared the object + // member (non-strict) silently stripped it, so the authored intent never + // reached the renderer contract at all. + it('should preserve an authored readonly on an object-form highlight field', () => { + const props = { + fields: [{ name: 'supply_share', readonly: true, type: 'number' }], + }; + const result = RecordHighlightsProps.parse(props); + const entry = result.fields[0] as { name: string; readonly?: boolean; type?: string }; + expect(entry.name).toBe('supply_share'); + expect(entry.type).toBe('number'); + expect(entry.readonly).toBe(true); + }); + + it('should preserve readonly: false rather than dropping it', () => { + const result = RecordHighlightsProps.parse({ fields: [{ name: 'amount', readonly: false }] }); + const entry = result.fields[0] as { readonly?: boolean }; + expect(entry.readonly).toBe(false); + }); + + it('should leave readonly undefined when it is not authored (no default materialized)', () => { + const result = RecordHighlightsProps.parse({ fields: [{ name: 'amount' }] }); + const entry = result.fields[0] as { readonly?: boolean }; + expect(entry).not.toHaveProperty('readonly'); + expect(entry.readonly).toBeUndefined(); + }); + + it('should reject a non-boolean readonly instead of silently stripping it', () => { + expect(() => RecordHighlightsProps.parse({ fields: [{ name: 'amount', readonly: 'yes' }] })).toThrow(); + }); + + it('should still accept bare-string and other object-form highlight fields', () => { + const result = RecordHighlightsProps.parse({ + fields: ['name', { name: 'status', label: 'State', icon: 'flag' }], + }); + expect(result.fields[0]).toBe('name'); + expect(result.fields[1]).toEqual({ name: 'status', label: 'State', icon: 'flag' }); + }); }); describe('ComponentPropsMap', () => { diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index d07e00fca0..e456af17c1 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -223,11 +223,18 @@ export const RecordHighlightsField = z.union([ label: z.string().optional().describe('Display label (overrides schema label)'), icon: z.string().optional().describe('Icon name (lucide icon key)'), type: z.string().optional().describe('Override cell renderer type (rare)'), + // #5176 — declared because it is already enforced: the renderer's + // HeaderHighlight gate refuses inline editing on a chip carrying it. Kept + // as a declared key (ADR-0049 enforce-or-remove, satisfied on arrival) + // rather than an undeclared key the renderer happens to honour — an + // undeclared key is silently stripped here, which turns a machine-owned + // column editable again with no diagnostic anywhere. + readonly: z.boolean().optional().describe('Render this chip read-only — suppresses inline editing on the highlight card. Use for hook/automation-maintained columns that must not be hand-edited from the record header.'), }), -]).describe('Highlight field: bare name, or {name,label?,icon?,type?}'); +]).describe('Highlight field: bare name, or {name,label?,icon?,type?,readonly?}'); export const RecordHighlightsProps = z.object({ - fields: z.array(RecordHighlightsField).min(1).max(7).describe('Key fields to highlight (1-7 fields max, typically displayed as prominent cards). Each item may be a bare field name or {name, label?, icon?, type?} for inline overrides.'), + fields: z.array(RecordHighlightsField).min(1).max(7).describe('Key fields to highlight (1-7 fields max, typically displayed as prominent cards). Each item may be a bare field name or {name, label?, icon?, type?, readonly?} for inline overrides.'), layout: z.enum(['horizontal', 'vertical']).default('horizontal').describe('Layout orientation for highlight fields'), /** ARIA accessibility */ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'),