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
70 changes: 70 additions & 0 deletions .changeset/9280-record-highlights-entry-icon-retired.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
---
'@object-ui/types': minor
'@object-ui/plugin-detail': minor
---

Retire `icon` from the `record:highlights` `fields[]` entry, across all three layers that
carried it (objectui#9280).

**Breaking, deliberately.** `{ name: 'amount', icon: 'dollar-sign' }` on a
`record:highlights` entry no longer type-checks. Nothing that worked stops working: the key
was never authorable in the first place, and this change is what makes `tsc` say so.

`@objectstack/spec` `RecordHighlightsProps.fields[]`'s object arm declares exactly
`name`/`label`/`type`/`readonly` behind a `never` catchall — it is `$strict`, so an unlisted
key is REFUSED, not stripped, and the refusal takes the WHOLE document with it. Re-measured
on this branch against the installed pin (17.4.0) rather than inherited from the card, with
three controls in the same pass so the instrument is not blind:

```
fields[] object-arm keys : ["label","name","readonly","type"] catchall: never ($strict)
{ fields: [{ name:'x', label:'L' }] } GREEN <- CONTROL
{ fields: [{ name:'x', icon:'star' }] } RED invalid_union at fields.0
{ fields: [{ name:'x', zzzNonsense:1 }] } RED invalid_union at fields.0 <- CONTROL
{ fields: ['x'] } GREEN <- CONTROL
```

A declared key parses green, so the arm is not refusing everything; an arbitrary key is
refused with the SAME code as `icon`, so `icon` was not special-cased; the bare-string arm is
untouched, so only the object arm moved.

FROM → TO, per layer:

- `packages/types/src/record-components.ts` — `RecordHighlightsComponentProps.fields[]`'s
object arm: `{ name; label?; icon?; type?; readonly? }` → **`{ name; label?; type?; readonly? }`**.
The key is **removed, not tombstoned**: the contract's arm is `$strict`, so the refusal an
author needs already exists upstream and arrives named (`invalid_union` at the entry). A
`?: never` tombstone buys nothing here — it is the remedy for a non-strict mirror that
would otherwise strip in silence, which is not this arm.
- `packages/plugin-detail/src/renderers/record-highlights.tsx` — the entry normalizer stops
copying `icon: f?.icon` into the normalized entry. That read was **unreachable**, not
merely unused: no author could feed it past the `$strict` arm, and `HeaderHighlight`
renders no `.icon` on the far side either, so the copy had no consumer in either
direction.
- `packages/plugin-detail/src/index.tsx` — the registry manifest's `fields` input description
sketched the entry as `{name,label?,icon?,type?,readonly?}` → **`{name,label?,type?,readonly?}`**.
The `inputs` ARE the published contract (`gen-manifest.ts` serializes them into
`sdui.manifest.json` and `sdui-intrinsics.d.ts`), so leaving the sketch standing would have
gone on teaching AI and human authors a key that gets the whole document refused at publish.

**Migration.** Nothing in this repository has to change. An in-repo census over every
`record:highlights` region (109 regions, all tracked files) scores an entry-level `icon`
**0**, against `readonly` **23** in the same pass over the same regions — the instrument was
not blind. If you author `icon` on a highlight entry in your own metadata, delete it: it was
already causing the publish to refuse the document whole. Whether a highlight chip *should*
be able to carry an icon is a separate question this change does not answer — the route for
that is an upstream `@objectstack/spec` widening, on its own card, ⛔ never a redeclaration
here.

⚠️ `sections[].icon` on `RecordDetailsComponentProps` is a **different key on a different
face** and is **unaffected**: the contract declares it and `DetailSection` genuinely draws
it. Two keys sharing a word in one file are not the same key.

Pinned in `packages/types/src/__tests__/record-highlights-fields-icon-9280.test.ts` across
three instruments that do not see the same thing — a `tsc` `@ts-expect-error` leg with a
`{name,label}` control that stays green, `safeParse` legs against the installed spec
artifact, and a source-text read whose lit control is that very `sections[].icon` member, so
an empty result on the highlights arm is a reading rather than a matcher that cannot match.
`packages/plugin-detail/src/__tests__/recordHighlightsInputs.spec-parity.test.ts` gains the
REVERSE direction it was missing: it already failed when a spec entry key went undocumented,
and now also fails when the description advertises an entry key the spec refuses.
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,34 @@ describe('record:highlights — registry inputs vs @objectstack/spec', () => {
expect(description).toContain('readonly');
});

it('the `fields` entry-shape sketch advertises no key the spec refuses', () => {
// The REVERSE direction of the check above, and the one objectui#9280 was
// filed for: the description sketched the entry as
// `{name,label?,icon?,type?,readonly?}` while the spec's object arm is
// `$strict` over four keys, so the manifest was teaching authors a key that
// gets the WHOLE document refused at publish. Under-documenting a key is a
// discoverability bug; over-advertising one is an impossible promise.
const description = fieldsInput()?.description ?? '';
const sketch = /\{([a-zA-Z?,\s]+)\}/.exec(description)?.[1];
expect(sketch, 'the description must keep an entry-shape sketch to check').toBeDefined();

const advertised = (sketch ?? '')
.split(',')
.map((k) => k.trim().replace(/\?$/, ''))
.filter(Boolean)
.sort();

// Derived from the spec at runtime, so a spec that WIDENS the arm fails
// here instead of leaving the sketch quietly short.
expect(advertised).toEqual(specEntryKeys().sort());

// ⭐ LIT CONTROL for this matcher: it really does read keys out of the
// sketch rather than returning an empty list that trivially compares
// equal. `name` is the one key the arm cannot lose.
expect(advertised).toContain('name');
expect(advertised).not.toContain('icon');
});

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));
Expand Down
2 changes: 1 addition & 1 deletion packages/plugin-detail/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -637,7 +637,7 @@ ComponentRegistry.register('highlights', RecordHighlightsRenderer, {
// un-gated (pinned as `MULTI_KIND_MEMBER_CONTRACTS` in the repo-wide parity
// gate). objectui#3407 / objectstack#5176.
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: 'fields', type: 'array', required: true, description: 'Key fields to highlight (1-7), bare names or {name,label?,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' },
],
});
Expand Down
13 changes: 11 additions & 2 deletions packages/plugin-detail/src/renderers/record-highlights.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ export const RecordHighlightsRenderer: React.FC<RecordHighlightsRendererProps> =
required.every((p) => perms.can(objectName, p as any));

const rawFields: any[] = Array.isArray(schema.fields) ? schema.fields : [];
// Normalize: accepts either bare strings or { name, label?, icon?, type?, readonly? }.
// Normalize: accepts either bare strings or { name, label?, type?, readonly? }
// — the four keys the contract's object arm declares, and no fifth.
//
// `readonly` is copied through deliberately: HeaderHighlight's editability
// gate has always consulted `field.readonly`, but this map used to rebuild
Expand All @@ -59,13 +60,21 @@ export const RecordHighlightsRenderer: React.FC<RecordHighlightsRendererProps> =
// never fire from authored metadata (objectstack#5077). Rebuilding key-by-key
// rather than spreading keeps the entry shape closed — an undeclared key is
// still not silently forwarded to the strip.
//
// `icon` was copied through here until objectui#9280 and that read was
// UNREACHABLE, not merely unused: `@objectstack/spec`
// `RecordHighlightsProps.fields[]`'s object arm is `$strict` (a `never`
// catchall over `name`/`label`/`type`/`readonly`), so a document carrying
// `icon` is refused WHOLE at publish and no author could ever feed this
// branch. `HeaderHighlight` renders no `.icon` either, so the copy also had
// no consumer on the far side. Retired in both directions rather than left
// standing as a read for a key nothing can author.
const normalized = rawFields.map((f) =>
typeof f === 'string'
? { name: f }
: {
name: f?.name,
label: f?.label,
icon: f?.icon,
type: f?.type,
readonly: f?.readonly === true,
},
Expand Down
Loading
Loading