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
79 changes: 79 additions & 0 deletions .changeset/record-highlights-icon-retired.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
---
"@objectstack/spec": minor
---

feat(spec): retire `record:highlights` highlight-field `icon` — advertised on six surfaces, drawn by nothing (#10054, ADR-0049)

<!-- adr-0087: registered record-highlights-field-icon-removed -->

**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep
launch-window convention ships it as `minor`; the migration prescription is
registered under protocol major 18, where `os migrate meta` users will look).

`icon` on the object arm of `RecordHighlightsField` (`fields: [{ name, label?,
icon?, … }]` on a `record:highlights` component) was a real authoring surface
advertised on six author-facing surfaces — the union's own describe, the
`fields` describe, the lint entry-shape prose, the reference docs, and
objectui's input description — with ZERO read points, measured at the
2026-08-20 census in every direction: objectui's renderer normalizes the
authored object and carries `icon: f?.icon` into `HeaderHighlight`, whose chip
has no icon slot (its only `icon` occurrence is a button `size="icon"`); the
key is structurally unable to travel `useRegisterHighlightFields`, which
registers `names: string[]`; the Studio block designer publishes the field
list as a `string[]` input, so the key was never designer-publishable; and
every in-tree `record:highlights` producer authors bare string arrays. So an
authored `icon` parsed clean and was drawn by nothing — the #8691
reference-rail-`icon` shape, on the highlight chip.

**What is refused:** `icon` on an object-form highlight field. The arm is
`strictObject`, so the key is deleted from the shape and the unknown-key
rejection carries the retirement prescription via the arm's `guidance` entry
(fully-qualified key, why it was inert, the no-replacement guidance, the
`os migrate meta` pointer) — surfaced through the zod-4 union collapse by
`packages/lint/src/zod-issue-format.ts`'s arm unpacking.

**What stays accepted:** bare-string entries and `{name, label?, type?,
readonly?}` objects parse byte-identically. `readonly` behaviour is untouched
— it is the arm's one enforced key (#5176, HeaderHighlight's inline-edit
gate). There is no replacement for `icon`: the highlight chip renders label
and value only.

The retirement kit:

- strict deletion + `guidance` prescription at the schema
(`packages/spec/src/ui/component.zod.ts`); the two advertising describes
(the union's and `RecordHighlightsProps.fields`') no longer spell the key
- ADR-0087 registration: retired-key entry `ui/RecordHighlightsField:icon` and
the D2 conversion `record-highlights-field-icon-removed` (protocol 18),
wired into the step-18 chain — `os migrate meta --from 17` strips the key
from the object entries of every `record:highlights` `fields[]` (pure
lossless delete; it never had an effect to lose)
- pin tests (`component.test.ts` — the old parse-survival pin respells to the
surviving surface; a refusal pin asserts the named `unrecognized_keys`
rejection and its prescription through the union collapse)
- generated baselines/docs follow the schema (spec-changes, upgrade guide,
reference docs); `packages/lint`'s entry-shape prose corrected
- objectui's plugin-detail input-description advertisement is cross-repo and
follows on its own card

## FROM → TO

```ts
// before — parsed green; the renderer normalized `icon` into a chip with no
// icon slot, so the strip rendered identically with or without it
{
type: 'record:highlights',
properties: {
fields: ['status', { name: 'budget', label: 'Budget', icon: 'dollar-sign' }],
},
}

// after — delete the key; nothing replaces it (the chip renders label and
// value only)
{
type: 'record:highlights',
properties: {
fields: ['status', { name: 'budget', label: 'Budget' }],
},
}
```
5 changes: 2 additions & 3 deletions content/docs/references/ui/component.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -551,7 +551,7 @@ const result = AIChatWindowProps.parse(data);

## RecordHighlightsField

Highlight field: bare name, or `{name,label?,icon?,type?,readonly?}`
Highlight field: bare name, or `{name,label?,type?,readonly?}`

### Union Options

Expand All@@ -571,7 +571,6 @@ Type: `string`
| :--- | :--- | :--- | :--- |
| **name** | `string` | ✅ | Field name on the record |
| **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. |

Expand All@@ -586,7 +585,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?, readonly?}` for inline overrides. |
| **fields** | `(string \| { name: string; label?: string; type?: string; readonly?: boolean })[]` | ✅ | Key fields to highlight (1-7 fields max, typically displayed as prominent cards). Each item may be a bare field name or `{name, label?, type?, readonly?}` for inline overrides. |
| **layout** | `Enum<'horizontal' \| 'vertical'>` | optional (default: `"horizontal"`) | Layout orientation for highlight fields |
| **aria** | `{ ariaLabel?: string \| Record<string, string>; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |

Expand Down
2 changes: 1 addition & 1 deletion packages/lint/src/zod-issue-format.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,7 +22,7 @@
* exist to replace. `aggregate.groupBy` is a union (`ChartGroupBySchema` —
* bare field name or `{ field, dateGranularity?, alias? }`) and so is
* `RecordHighlightsProps.fields[]` (`RecordHighlightsField` — bare field
* name or `{ name, label?, icon?, type?, readonly? }`), so this is the
* name or `{ name, label?, type?, readonly? }`), so this is the
* common path on both surfaces. **As of #5583 it is also the only thing
* carrying a STRICT rejection out of a union arm**: `ChartGroupBySchema`'s
* object arm is a `strictObject` now, and the `unrecognized_keys` it raises
Expand Down
145 changes: 145 additions & 0 deletions packages/spec/src/conversions/registry.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7256,6 +7256,150 @@ const metricFiltersRemoved: MetadataConversion = {
},
};

/**
* `record:highlights` highlight-field `icon` — a declared, advertised key with
* zero read points (#10054, ADR-0049 enforce-or-remove; maintainer ruling
* 2026-08-21, executing the 2026-08-20 census verdict).
*
* The dead-end is complete in every direction the census measured: objectui's
* renderer normalizes the authored object and carries `icon: f?.icon` into
* `HeaderHighlight`, whose chip has NO icon slot (its only `icon` occurrence
* is a button `size="icon"`); the key is structurally unable to travel
* `useRegisterHighlightFields`, which registers `names: string[]`; and the
* Studio block designer publishes the field list as a `string[]` input, so the
* key was never designer-publishable either. Meanwhile six author-facing
* surfaces advertised it — the union's own describe, the `fields` describe,
* the lint entry-shape prose, the reference docs, and objectui's input
* description. The exact #8691 reference-rail-`icon` shape, on the highlight
* chip.
*
* **A pure lossless delete.** The key never had an effect to lose: the chip
* renders label and value only, so there is no rewrite target — deleting the
* key preserves observed behaviour exactly.
*
* ⚠️ Scoped by component `type` AND by entry shape: `icon` is live on half a
* dozen other components (`element:button`, header actions, …), and the
* bare-string entries of the same `fields` array are not carriers — only the
* OBJECT entries of a `record:highlights` `fields[]` are touched. The
* neighbouring `readonly` key is LIVE (#5176 — HeaderHighlight's inline-edit
* gate reads it) and survives untouched.
*/
const recordHighlightsFieldIconRemoved: MetadataConversion = {
id: 'record-highlights-field-icon-removed',
toMajor: 18,
retiredFromLoadPath: true,
surface: 'page.component.record:highlights.fields[].icon',
summary:
"record:highlights highlight-field key 'icon' removed (#10054, ADR-0049 — no render path: "
+ 'the highlight chip has no icon slot, the register hook carries field names only, and the '
+ 'Studio designer publishes the field list as plain strings, so an authored icon was '
+ 'accepted and drawn by nothing)',
apply(stack, emit) {
return mapPageComponents(stack, (component, path) => {
if (component.type !== 'record:highlights') return component;
const properties = component.properties;
if (!isDict(properties)) return component;
const fields = properties.fields;
if (!Array.isArray(fields)) return component;
let touched = false;
const nextFields = fields.map((entry, i) => {
// Bare-string entries are not carriers — untouched, same reference.
if (!isDict(entry)) return entry;
const stripped = stripKeys(entry, ['icon'], emit, `${path}.properties.fields[${i}]`);
if (stripped !== entry) touched = true;
return stripped;
});
if (!touched) return component;
return { ...component, properties: { ...properties, fields: nextFields } };
});
},
fixture: {
before: {
pages: [
{
name: 'project_detail',
regions: [
{
name: 'main',
components: [
{
type: 'record:highlights',
properties: {
fields: [
// Bare-string entry — the shape every in-tree producer
// authors; not a carrier, rides through untouched.
'status',
// The retired shape: an object entry carrying `icon`.
{ name: 'budget', label: 'Budget', icon: 'dollar-sign' },
// The LIVE neighbour keys survive — `readonly` is the
// #5176 enforced key, `type` the renderer override.
{ name: 'health', type: 'number', readonly: true },
],
},
},
// `icon` on a component that is not record:highlights — live
// there, and not this entry's key.
{ type: 'element:button', properties: { label: 'Open', icon: 'external-link' } },
],
},
],
},
// The named-slot shape (#6776 reach): a highlights strip authored into
// a slotted record page's `highlights` slot.
{
name: 'task_detail',
kind: 'slotted',
regions: [],
slots: {
highlights: {
type: 'record:highlights',
properties: { fields: [{ name: 'priority', icon: 'flag' }] },
},
},
},
],
},
after: {
pages: [
{
name: 'project_detail',
regions: [
{
name: 'main',
components: [
{
type: 'record:highlights',
properties: {
fields: [
'status',
{ name: 'budget', label: 'Budget' },
{ name: 'health', type: 'number', readonly: true },
],
},
},
{ type: 'element:button', properties: { label: 'Open', icon: 'external-link' } },
],
},
],
},
{
name: 'task_detail',
kind: 'slotted',
regions: [],
slots: {
highlights: {
type: 'record:highlights',
properties: { fields: [{ name: 'priority' }] },
},
},
},
],
},
// Two notices: the region-level object entry and the slotted one.
expectedNotices: 2,
},
};

export const CONVERSIONS_BY_MAJOR: Readonly<Record<number, readonly MetadataConversion[]>> = {
11: [flowNodeHttpRename, pageKindJsxToHtml, flowNodeFilterAlias, objectCompactLayoutRename],
13: [stackRolesToPositions, owdLegacyReadAliases, sharingRecipientRoleToPosition],
Expand DownExpand Up@@ -7335,6 +7479,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly<Record<number, readonly MetadataConv
elementFilterRemoved,
fieldColumnListsCanonicalized,
metricFiltersRemoved,
recordHighlightsFieldIconRemoved,
],
};

Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

// #10054 — ADR-0049 enforce-or-remove (maintainer ruling 2026-08-21, executing
// the 2026-08-20 census verdict). `icon` on the object arm of
// `RecordHighlightsField` was declared, described (`Icon name (lucide icon
// key)`), and advertised on six author-facing surfaces — with ZERO read
// points, measured in every direction: objectui's renderer normalizes the
// authored object and carries `icon: f?.icon` into `HeaderHighlight`, whose
// chip has NO icon slot (its only `icon` occurrence is a button
// `size="icon"`); the key is structurally unable to travel
// `useRegisterHighlightFields`, which registers `names: string[]`; the Studio
// block designer publishes the field list as a `string[]` input, so the key
// was never designer-publishable; and all in-tree `record:highlights`
// producers author bare string arrays. The exact shape #8691 recorded for the
// reference-rail `icon`: declared and normalized, drawn by nothing — an
// authored value parsed clean and cost the author silently. The neighbouring
// `readonly` key is LIVE (#5176, HeaderHighlight's inline-edit gate) and is
// untouched.
//
// Registered under 18, not 17: v17.0.0 was cut before this landed, so the
// removal ships on the 17.x line (launch-window convention: accept-set
// narrowings ride minor releases) and the prescription lives at the major
// boundary where `migrate meta` users look (the #8495 / PR #8666 precedent).
// The object arm is `strictObject`, so the route is strict deletion + a
// `guidance` entry carrying the prescription (no retiredKey tombstone — the
// key is out of the walked shape entirely, and the refusal is the arm's own
// named `unrecognized_keys`, unpacked through the zod-4 union collapse by
// `packages/lint/src/zod-issue-format.ts`; the `data/Metric:filters` route).
// Sources are rewritten by the D2 conversion
// `record-highlights-field-icon-removed`, which strips the key from the object
// entries of every `record:highlights` `fields[]` (pure lossless delete — the
// chip renders label and value only, so the key never had an effect to lose).
export const entry = 'ui/RecordHighlightsField:icon';
45 changes: 44 additions & 1 deletion packages/spec/src/migrations/registry.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5111,14 +5111,26 @@ const step18: MigrationStep = {
'objectui\'s ThemeEngine/ThemeContext and their unit tests are retained. Semantic ' +
'rather than mechanical: an authored palette has no lossless target (N themes vs ' +
'M apps is a judgment), so the entry prescribes the hand move instead of deleting ' +
'authored content silently.',
'authored content silently. ' +
'It also retires the `record:highlights` highlight-field `icon` (#10054, ADR-0049 ' +
'enforce-or-remove; maintainer ruling 2026-08-21, executing the 2026-08-20 census ' +
'verdict): a declared key with zero read points in any direction — objectui\'s renderer ' +
'normalized the authored object and carried `icon` into a highlight chip with no icon ' +
'slot, `useRegisterHighlightFields` registers field NAMES only (structurally unable to ' +
'carry it), and the Studio designer publishes the field list as plain strings — while ' +
'six author-facing surfaces advertised the key (the #8691 reference-rail-`icon` shape, ' +
'on the highlight chip). The mechanical conversion strips the key from the object ' +
'entries of every `record:highlights` `fields[]` (pure lossless delete — the chip ' +
'renders label and value only, so it never had an effect to lose); there is no ' +
'replacement, and the live neighbour `readonly` (#5176) is untouched.',
conversionIds: [
'field-malformed-scale-precision-removed',
'record-chatter-position-vocabulary',
'element-input-target-variable-removed',
'element-filter-removed',
'field-column-lists-canonicalized',
'metric-filters-removed',
'record-highlights-field-icon-removed',
],
semantic: [
// One file per entry under `entries/semantic/`, concatenated here sorted by
Expand DownExpand Up@@ -6736,6 +6748,37 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly<Record<number, readonly string[]>>
// `element-input-target-variable-removed` (a page component IS a stack
// collection member, unlike the `kernel/Manifest:loading` family).
'ui/ElementTextInputProps:targetVariable',
// #10054 — ADR-0049 enforce-or-remove (maintainer ruling 2026-08-21, executing
// the 2026-08-20 census verdict). `icon` on the object arm of
// `RecordHighlightsField` was declared, described (`Icon name (lucide icon
// key)`), and advertised on six author-facing surfaces — with ZERO read
// points, measured in every direction: objectui's renderer normalizes the
// authored object and carries `icon: f?.icon` into `HeaderHighlight`, whose
// chip has NO icon slot (its only `icon` occurrence is a button
// `size="icon"`); the key is structurally unable to travel
// `useRegisterHighlightFields`, which registers `names: string[]`; the Studio
// block designer publishes the field list as a `string[]` input, so the key
// was never designer-publishable; and all in-tree `record:highlights`
// producers author bare string arrays. The exact shape #8691 recorded for the
// reference-rail `icon`: declared and normalized, drawn by nothing — an
// authored value parsed clean and cost the author silently. The neighbouring
// `readonly` key is LIVE (#5176, HeaderHighlight's inline-edit gate) and is
// untouched.
//
// Registered under 18, not 17: v17.0.0 was cut before this landed, so the
// removal ships on the 17.x line (launch-window convention: accept-set
// narrowings ride minor releases) and the prescription lives at the major
// boundary where `migrate meta` users look (the #8495 / PR #8666 precedent).
// The object arm is `strictObject`, so the route is strict deletion + a
// `guidance` entry carrying the prescription (no retiredKey tombstone — the
// key is out of the walked shape entirely, and the refusal is the arm's own
// named `unrecognized_keys`, unpacked through the zod-4 union collapse by
// `packages/lint/src/zod-issue-format.ts`; the `data/Metric:filters` route).
// Sources are rewritten by the D2 conversion
// `record-highlights-field-icon-removed`, which strips the key from the object
// entries of every `record:highlights` `fields[]` (pure lossless delete — the
// chip renders label and value only, so the key never had an effect to lose).
'ui/RecordHighlightsField:icon',
// </os-generated retired-key:18>
],
};
Expand Down
Loading
Loading