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
84 changes: 84 additions & 0 deletions .changeset/component-props-header-tabs-accordion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
---
"@objectstack/spec": major
---

refactor(spec)!: finish #5775's count — 4 SDUI component props declared, `page:tabs.type` renamed to `tabStyle` (#6776)

#5775 reconciled `ComponentPropsMap` with the renderers that serve it and then
recorded that "the rest of the keys the renderers honour are declared". That
sentence did not hold. Re-counting against objectui `origin/main` found **five**
more author-facing props that objectui's renderers read and this schema did not
declare — so for each of them three platform authorities disagreed at once:
objectui's published manifest and generated `sdui-intrinsics.d.ts` told an author
(very often an AI author, ADR-0033) the key was **legal**, `validateComponentProps`
(#5068) reported it **undeclared**, and the renderer **honoured it anyway**. That
is #5435's shape — a platform authority pointing at a key its own gate rejects —
with the wrong half on the spec side this time.

Four are plain declarations. No behaviour changes; the contract catches up with
what has always shipped:

| Key | Type | Default | What it does |
| :-- | :-- | :-- | :-- |
| `page:header.recordChrome` | `boolean` | `true` | `false` drops the record chip and renders the bare heading — what a dashboard or landing page wants, since there is no record to describe |
| `page:header.showStar` | `boolean` | `true` | the follow (favourite) star beside the record title |
| `page:header.showCopyId` | `boolean` | `true` | the copy-record-id button beside the record title |
| `page:accordion.variant` | `'flush' \| 'card'` | `'flush'` | `flush` draws the divider under each panel; `card` leaves the border to each panel's own content |

The fifth is a rename, and the only one whose defect is structural rather than an
oversight.

## BREAKING: `page:tabs` property `type` → `tabStyle`

The concept — the tab strip's visual style, `line` / `card` / `pill` — was
declared all along, under a spelling **no author can write in most carriers**: a
props key named `type` collides with the page component's own dispatch key.
Three independent consequences, each measured on objectui `origin/main`:

- `SchemaRenderer.tsx:253,264` hoists `properties` onto the node but skips `type`
and `id` deliberately, or the inner value would shadow which renderer to
dispatch to. Its comment names this exact case.
- `sdui-parser`'s `BASE_PROPS` (`validate.ts:20-30`) contains `type`, and
`validate.ts:68` skips every base prop before the unknown/typed checks — so a
manifest input by that name is **never validated**.
- In the flat and JSX carriers a node reads
`{ type: 'page:tabs', items: [...], tabStyle: 'card' }`: `type` is the tag
name, and the declared spelling has nowhere left to go.

`tabStyle` is what objectui's registry publishes as the designer input and what
`containers.tsx:381` reads in every carrier. Converging on the spelling that
works rather than the one that declares well is #5775's `displayField` →
`labelField` again, and one spelling rather than two is Prime Directive #12 —
declaring `tabStyle` as an alias of `type` was considered and refused, because
the dialect that would survive is the one that silently fails to validate.

FROM → TO:

- `pages[].regions[].components[]` and `pages[].slots.<slot>` where
`type === 'page:tabs'`: `properties.type` → `properties.tabStyle`. The value
(`line` | `card` | `pill`) is unchanged.

**The one-line fix:** rename the key. `os migrate meta --from 16` rewrites it
automatically; the ADR-0087 D2 conversion is
`page-tabs-type-to-tab-style` (`retiredFromLoadPath` — the tombstone owns the
refusal, so a 17 loader does not accept the old spelling), and the tombstone
carries the same prescription at `tsc` and at parse time for anyone jumping
several majors at once.

## Also in this change

`mapPageComponents` (the conversion layer's page walk) now visits
`pages[].slots.<slot>` as well as `pages[].regions[].components[]`. Its comment
used to call region level "the whole surface", on the reasoning that everything
else lives inside a free-form `properties` bag — but `PageSchema.slots` is a
closed map of seven named slots, each declared
`z.union([PageComponentSchema, z.array(PageComponentSchema)])`, as typed as any
region component, and `packages/lint`'s `walkPageComponents` has always visited
both. #6776 is where the gap cost something: `page:tabs` **is** one of those
slots, all four in-repo authoring sites are `slots.tabs`, and a region-only
rewrite would have left `os migrate meta` unable to touch the only shape the key
is written in — while the tombstone promised it could. Every other page-component
conversion gains the same reach, in the direction it already declares.

<!-- adr-0087: registered page-tabs-type-to-tab-style -->

7 changes: 6 additions & 1 deletion content/docs/references/ui/component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ const result = AIChatWindowProps.parse(data);
| :--- | :--- | :--- | :--- |
| **items** | `{ label: string \| Record<string, string>; icon?: string; collapsed: boolean; children: any[] }[]` | ✅ | |
| **allowMultiple** | `boolean` | ✅ | Allow multiple panels to be expanded simultaneously |
| **variant** | `Enum<'flush' \| 'card'>` | ✅ | Panel framing: 'flush' draws a divider under each panel; 'card' leaves the border to each panel's own content |
| **aria** | `{ ariaLabel?: string \| Record<string, string>; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |


Expand Down Expand Up @@ -245,6 +246,9 @@ const result = AIChatWindowProps.parse(data);
| **icon** | `string` | optional | Icon name |
| **breadcrumb** | `boolean` | ✅ | Show breadcrumb |
| **actions** | `string[]` | optional | Action IDs to show in header |
| **recordChrome** | `boolean` | ✅ | Render the record chrome — the title as a record chip with its follow star and copy-id button. Set false on a non-record page (dashboard, landing) to fall back to the bare heading layout. |
| **showStar** | `boolean` | ✅ | Show the follow (favourite) star beside the record title. Part of the record chrome — no effect when `recordChrome` is false. |
| **showCopyId** | `boolean` | ✅ | Show the copy-record-id button beside the record title. Part of the record chrome — no effect when `recordChrome` is false. |
| **aria** | `{ ariaLabel?: string \| Record<string, string>; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |


Expand All @@ -256,7 +260,8 @@ const result = AIChatWindowProps.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **type** | `Enum<'line' \| 'card' \| 'pill'>` | optional | |
| **tabStyle** | `Enum<'line' \| 'card' \| 'pill'>` | optional | Tab-strip visual style: 'line' underlines the active tab, 'card' frames each tab, 'pill' renders rounded pills |
| **type** | `never` | optional | [REMOVED] `page:tabs` property `type` was removed in @objectstack/spec 17.0.0 (#6776, ADR-0087 D2) — a props key named `type` collides with the page component's own dispatch key, so it is unauthorable in the flat and JSX carriers and was never validated in them. Rename the key to `tabStyle`; the value (`line` \| `card` \| `pill`) is unchanged. Run `os migrate meta --from 16` to rewrite it automatically. |
| **position** | `Enum<'top' \| 'left'>` | optional | |
| **items** | `{ label: string \| Record<string, string>; icon?: string; visibleWhen?: string \| object; value?: string; … }[]` | ✅ | |
| **aria** | `{ ariaLabel?: string \| Record<string, string>; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |
Expand Down
3 changes: 3 additions & 0 deletions docs/protocol-upgrade-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,8 @@ The last of the #4001 enforce-or-remove batch lands on two more `ui/` files (#50

Last, it reconciles the SDUI component-props surface with the renderers that serve it (#5775). #5068 wired the first parse `ComponentPropsMap` ever had, and the corpus it landed on diverged in BOTH directions: keys objectui honours that the schema never declared, and keys the schema declared — one of them REQUIRED — that no renderer reads. The maintainer ruled direction A (2026-08-06), the #5611 rule again: the delivered and authorized shape is the contract. So the honoured keys are declared (`element:record_picker` `labelField`/`valueField`/`label`/`emptyText`, `record:path` `stages[].terminal`, `page:tabs` `items[].value`/`items[].count`, `page:card` `children`, and `children` on `page:section`/`page:footer`/`page:sidebar`, which were declared `EmptyProps` while their renderers rendered a child list), and four keys retire. Two are synonym renames: `element:record_picker.displayField` → `labelField` (the required key no renderer read, while `labelField ?? 'name'` is what actually renders the row — so an author who followed the schema got a picker listing `name` with no diagnostic, the ADR-0078 shape), and `page:card.body` → `children` (one composition key across every container; the card renderer already reads both, and the showcase authors `children`). Two are enforce-or-remove deletions: `element:record_picker.searchFields` and `.multiple` — the control is a shadcn single-select with no search input, binding ONE record id into a page variable, so `searchFields` narrowed nothing and `multiple: true` selected nothing extra while reporting success. Either returns the day the capability is implemented (#5021 / #4988). Not in scope, and deliberately: `page:card.visible` is a component-level visibility predicate written into `properties` and hoisted by the renderer — a page to rewrite onto the ADR-0089 `visibleWhen`, not a key to declare.

That count turned out to be incomplete, and #6776 finishes it: five more keys the renderers read were still undeclared. Four are plain additions with no behaviour change (`page:header` `recordChrome`/`showStar`/`showCopyId`, which select between the record-chip header and the bare heading a dashboard wants, and `page:accordion.variant`, which decides whether the accordion draws its own dividers or leaves the border to each panel). The fifth is a rename, and the only one in the family whose defect is structural rather than an oversight: the tab strip's visual style was declared as `page:tabs.type`, which collides with the page component's OWN dispatch key. objectui's `SchemaRenderer` refuses to hoist `properties.type` for exactly that reason, `sdui-parser`'s `BASE_PROPS` contains `type` and skips it before any validation runs, and in a flat or JSX carrier the node reads `{ type: 'page:tabs', … }` so the name is already taken. The key was therefore unauthorable in every carrier but the nested `properties` object, and unvalidated even there. It becomes `tabStyle` — the spelling objectui publishes and the renderer already reads first in the flat carriers — which is `displayField` → `labelField` again: converge on the spelling that works, not the one that declares well, and keep one spelling rather than two (Prime Directive #12).

Finally it narrows the aggregation vocabulary: `array_agg` and `string_agg` leave `AggregationFunction` (#6188, ADR-0049). The enum declared eight functions and the SQL family compiles five — `SqlDriver.mapAggregateFunc` and the Turso `RemoteTransport.aggregate` each lower `count`/`sum`/`avg`/`min`/`max` and route the rest to one refusal — so three were declared-but-unenforced against the backends this platform targets. What makes these two worse than an ordinary inert declaration is that another package had to carry a denylist for them: `service-analytics` subtracted `array_agg` and `string_agg` by name in `UNSUPPORTED_AGGREGATES`, because without that subtraction they reached the Cube strategy's `default` and returned `COUNT(*)` — a row count in place of the requested value, with no error and no log. The maintainer SPLIT the three rather than retiring them as a block (2026-08-07), and the split is the point: `count_distinct` STAYS and takes the enforce leg — one portable lowering (`COUNT(DISTINCT x)`), a dashboard staple, already lowered by `service-analytics` — with its SQL implementation following on its own card, so that declaration leads its implementation by decision rather than by drift. These two take the remove leg: display conveniences with no measured pull, and `string_agg` never had one shape to lower to (the delimiter is a second argument in PostgreSQL, a `SEPARATOR` clause in MySQL, a differently named function in SQL Server). This is an enum VALUE, not a key, so — as with `crypto.hash` above — there is no `retiredKey()` tombstone: the enum error map carries the prescription, keyed on the received value so only the two spellings that used to be legal are told they "were removed". Of the two authoring surfaces only one is stored metadata: the conversion rewrites `dataset.measures[].aggregate`, dropping the measure outright (a measure with neither `aggregate` nor `derived` fails the dataset's own refinement, so stripping just the key would emit an item that cannot parse) plus any derived measure the drop strands, with a notice each. Nothing is lost: `compileDataset` refused both by name already, so such a measure never produced a number. `QueryAST.aggregations[].function` is a request surface with no stored source — one semantic TODO below. The mongodb and in-memory backends that implemented these two are inside the #5499 freeze and are untouched; their code is simply no longer reachable through a spec-valid request.

One entry in this step is not a removal at all but a SECURE-DEFAULT FLIP, the shape protocol 12 last used for `api.requireAuth`: an omitted `ActionDescriptor.resumeAuthority` resolves to `'service'` instead of `'any'`, so a pausing node type that never states who may continue its pauses is refused on the generic resume route rather than open to it (#5561, ADR-0044's 2026-07-28 amendment). Nothing is removed and no metadata shape changes — the field has been optional since step one of the same issue — so tsc reports nothing and only the MEANING of silence moved. That is exactly why it needs a ledger entry: a third-party plugin author has no compile error to discover it with, and the one-line prescription (declare `resumeAuthority` on the descriptor) has to arrive before a user meets a run that will not continue.
Expand Down Expand Up @@ -278,6 +280,7 @@ The same descriptor loses a key in this step, and the pairing is the point (#674
| `record-picker-inert-keys-removed` | `page.component.element:record_picker.searchFields / page.component.element:record_picker.multiple` | record-picker component props 'searchFields'/'multiple' removed (#5775 — the control is a plain single-select with no search box; neither key had a reader) | retired — `migrate meta` only |
| `page-card-body-to-children` | `page.component.page:card.body` | page:card component prop 'body' → 'children' (#5775 — one composition key across every container; the card renderer already reads both) | retired — `migrate meta` only |
| `inline-action-api-params-to-body-extra` | `page.component.element:button.action.params` | inline type:'api' action prop 'params' (object form) → 'bodyExtra' (#5777 — the payload gets its own key; `params` stays the ActionParam[] definition array) | live — protocol 17 loader accepts the old shape |
| `page-tabs-type-to-tab-style` | `page.component.page:tabs.type` | page:tabs component prop 'type' → 'tabStyle' (#6776 — a props key named `type` collides with the node's dispatch key and is unauthorable in flat/JSX carriers; `tabStyle` is the spelling the renderer reads in all of them) | retired — `migrate meta` only |

### Semantic (delegated to you, with acceptance criteria)

Expand Down
5 changes: 4 additions & 1 deletion examples/app-showcase/src/ui/pages/project-detail.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,10 @@ export const ProjectDetailPage = definePage({
tabs: {
type: 'page:tabs',
properties: {
type: 'line',
// `tabStyle`, not `type` (#6776): a props key named `type` collides
// with the component node's own dispatch key, so the old spelling
// could not be written in a flat or JSX page at all.
tabStyle: 'line',
items: [
{
// Explicit details sections — each section's `fields` is a
Expand Down
12 changes: 9 additions & 3 deletions packages/lint/src/authoring-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -561,9 +561,15 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
// to enforce declarations the platform does not keep. The error upgrade is a
// separate step, once the warning-period inventory is empty.
//
// #5775 has since settled its half: `displayField` is retired in favour of the
// `labelField` the renderer actually reads, and the rest of the keys the
// renderers honour are declared. #5728 and two page rewrites are what remain.
// #5775 settled the record picker's half: `displayField` is retired in favour
// of the `labelField` the renderer actually reads. Its claim that "the rest of
// the keys the renderers honour are declared" did NOT hold — #6776 found five
// more (`page:header` `recordChrome`/`showStar`/`showCopyId`,
// `page:accordion.variant`, and the tab strip's visual style, whose declared
// spelling `page:tabs.type` collided with the component node's own dispatch
// key and so was unauthorable in the flat and JSX carriers). All five are
// declared as of #6776, the last as the renamed `tabStyle`. What remains
// before the error upgrade is #5728 and two page rewrites.
{
name: 'validateComponentProps',
tier: 'advisory',
Expand Down
42 changes: 42 additions & 0 deletions packages/lint/src/validate-component-props.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,48 @@ describe('validateComponentProps — value verdicts', () => {
expect(findings).toEqual([]);
});

/**
* #6776 — the five keys #5775's count missed. Each is read by an objectui
* renderer and each was reported here as an undeclared key, so objectui's
* published manifest and this gate disagreed about the same author's page.
* This is the acceptance test for the declaration half: the shapes that used
* to warn must now be silent.
*/
it('reports nothing on the page:header / page:accordion keys the renderers honour (#6776)', () => {
const findings = validateComponentProps(
stackWith([
// objectui `apps/console/src/preview-samples.ts:68` verbatim — the
// measured warning site, on a non-record page.
{ type: 'page:header', properties: { title: 'Welcome to the CRM', recordChrome: false } },
{ type: 'page:header', properties: { title: 'Lead', showStar: false, showCopyId: false } },
{ type: 'page:accordion', properties: { items: [], variant: 'card' } },
{ type: 'page:tabs', properties: { tabStyle: 'card', items: [] } },
]),
);
expect(findings).toEqual([]);
});

/**
* The other direction of the same card. `page:tabs.type` is now a tombstone,
* and a tombstone is REFUSED rather than reported as unknown — so the finding
* arrives as `component-props-invalid` carrying the rename prescription, not
* as `component-props-unknown-key` carrying nothing. The conversion that
* rewrites it is `retiredFromLoadPath`, so it deliberately does NOT run on
* this normalized input: an already-stored page keeps the old key and the
* author is told, which is the whole point of the tombstone.
*/
it('refuses the retired `page:tabs.type` by name, with the prescription (#6776)', () => {
const findings = validateComponentProps(
stackWith([{ type: 'page:tabs', properties: { type: 'card', items: [] } }]),
);
expect(invalid(findings).map((f) => f.path)).toEqual([
'pages[0].regions[0].components[0].properties.type',
]);
expect(invalid(findings)[0].message).toContain('tabStyle');
// Not an unknown key — the schema still declares it, as a tombstone.
expect(unknownKeys(findings)).toEqual([]);
});

/**
* The routing that keeps this gate whole across a future `strictObject`
* batch. `AriaPropsSchema` is the one CLOSED shape inside these props (#4001
Expand Down
Loading
Loading