You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 97233b9
Browse filesBrowse the repository at this point in the historyBrowse files
fix(spec): title every authorable row property on dashboard.widgets and dashboard.globalFilters (#18505)
Fixes#17505
Clause-②: yes — no authorable key moves, but every titled row property
gains a `title` node in the emitted JSON Schema, a published artifact.
Changeset is `minor` accordingly (`AGENTS.md` Post-Task Checklist step
3: `yes` takes at least `minor`). The `needs:contract-review` carrier is
the seat's and is already on the card; this PR neither hangs nor strips
it.
## What this does
Studio renders a `type: 'repeater'` field as a table whose column
headers read `items.properties[k].title ?? k` off the schema from
`z.toJSONSchema(...)`. With no `title` the fallback arm runs in
**every** locale, English included, so the maker saw `requiresService`,
`filterBindings` and `optionsFrom` inside an otherwise translated panel.
`resolveMetadataFormSchemaTitles` only ever REPLACES a title already
present, so the English default has to live on the schema.
This applies the mechanism ruled in #16458 and already landed on
`dashboard.header.actions` (PR #17227) and on the `ai/skill`,
`ui/report` and `ui/page` carriers (PR #17500). No new contract
decision.
## The row-property set, re-derived on today's tree
The card's `17 + 10 = 27` is a 2026-09-10 reading, so it was re-derived
through the platform's own predicate (`z.toJSONSchema` over
`getMetadataTypeSchema`, `io: 'input'`) rather than by regexing source —
using the derivation helpers copied byte-identical out of
`repeater-item-titles.test.ts` itself.
| carrier | authorable row properties | untitled before | untitled after
|
|---|---|---|---|
| `dashboard:widgets` | 17 | 17 | 0 |
| `dashboard:globalFilters` | 10 | 10 | 0 |
**It is still 27, and the card is NOT stale.** `#17616` removed
`stageOrder` from widget types that do not read it, but `stageOrder`
lives inside `options` — one level BELOW the repeater's row properties,
where `options` itself is the single row property. A refusal at that
depth cannot move this count.
## The emitted schema, before and after
Read as Studio reads it — the property node first, never the `$ref`
target, because a `.meta({ title })` on a schema zod hoists into `$defs`
is emitted as a sibling of the `$ref`.
- before: `widgets.items.properties.id.title` = `undefined`
- after: `widgets.items.properties.id.title` = `"Widget ID"`, in
**both** `io: 'input'` and `io: 'output'`
- after: `globalFilters.items.properties.targetWidgets.title` = `"Target
Widgets"`
## What must NOT get a title, and what was checked
- **The five `retiredKey()` tombstones on this row** — `actionUrl`,
`actionType`, `actionIcon`, `responsive`, `aria` — are untouched and
still emit `title: undefined` in both io modes. A tombstone declares a
key unwritable; an authoring label would advertise it as writable. They
are #17502's subject and are not addressed here.
- **Every other repeater carrier is unchanged in the same run.** The
other twenty carriers hold their exact counts — `field:options` 6,
`object:fields.options` 6, `view:columns` 14, `view:sort` 2, `view:tabs`
9 still untitled — so no shared item schema leaked a title sideways.
- **Non-repeater and composite siblings are unchanged**: dashboard
top-level `columns` and `header.showTitle` still emit no `title`, which
is what keeps the two absence controls in
`packages/rest/src/meta-types-schema-titles.test.ts` valid (3/3 green).
## One control had to be re-pointed, not deleted
`dashboard.test.ts` used `widgets[].id` as its "a sibling with no
authored title has none" control — proving the emitter invents no
titles. This change titles that property, so the control was re-pointed
onto a **tombstone** (`widgets[].actionUrl`) and given a lit leg
(`widgets[].id` is now `'Widget ID'`). It now pins the tombstone rule
instead of borrowing a coincidence, which is strictly stronger than what
it replaced.
## The ledger is paid, not weakened
`repeater-item-titles.test.ts` is exact in both directions, so a paid
debt left in the set is as red as an unpaid one. Both `dashboard:*`
entries are **deleted**; five remain. Verified by name in the verbose
run: `dashboard:widgets` and `dashboard:globalFilters` now execute
without the `(ledger: still owed titles)` arm, while the five survivors
keep it.
## Verification
| check | result |
|---|---|
| `pnpm --filter @objectstack/spec build` | exit 0 (lock verdict
`command-exit 0`) |
| `pnpm --filter @objectstack/spec test` | **483 files / 13773 tests
passed** |
| `pnpm --filter @objectstack/spec typecheck` | exit 0 |
| `pnpm --filter @objectstack/spec check:generated` | all **15**
artifacts up to date, `check:authorable-surface` included |
| `packages/rest` `meta-types-schema-titles.test.ts` | 3/3 passed |
| `packages/platform-objects` translations | 8 files / 100 tests passed
|
`check:authorable-surface` green is the load-bearing one for the
declared fence: **`packages/spec/authorable-surface.base.json` did not
move and was not touched**, so the in-flight anchor-only PR that owns it
is unaffected.
## Acceptance notes
- `dashboard.form.ts` deliberately does **not** enumerate the `widgets`
/ `globalFilters` children. That enumeration is the i18n-catalog half
(`os i18n extract` emits `REPEATER.PROPERTY` keys from the form's
declared `label`s) and belongs to #17508, a decision card. The spec-side
pin that holds a form `label` equal to a schema `title` is scoped to
`header.actions` only, so nothing here reds without it.
- noted, not filed: the card and its triage both say "six `retiredKey()`
tombstones ... (5 on `dashboard.widgets`)". On today's tree the two
carriers in this card hold exactly **5**, all on `widgets`, and
`globalFilters` holds **0** — so the sixth is not on either carrier of
this card. 承接者: #17502, whose subject is the tombstone set.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6)_
Co-authored-by: Claude <noreply@anthropic.com>
`dashboard.widgets[]` (17) and `dashboard.globalFilters[]` (10) — every authorable row property of these two repeaters now carries a JSON Schema `title`, so Studio's property-panel table prints an authoring label instead of the raw machine key (#17505).
6
+
7
+
`Clause-②: yes` — no authorable key moves, but each row property gains a `title` node in the emitted JSON Schema, which is a published artifact.
8
+
9
+
Studio renders a `type: 'repeater'` field as a table whose column headers read `items.properties[k].title ?? k` off the schema derived by `z.toJSONSchema(...)`. With no `title` the fallback arm runs in **every** locale, English included, so the maker saw `requiresService`, `filterBindings` and `optionsFrom` inside an otherwise translated panel. That is a missing authoring label in the contract, not a translation gap — the English default has to live on the schema, because `resolveMetadataFormSchemaTitles` only ever REPLACES a `title` that is already there.
10
+
11
+
-**Mechanism unchanged** — this applies the one ruled in #16458 and already landed on `dashboard.header.actions` and on the `ai/skill`, `ui/report` and `ui/page` carriers: `.meta({ title })` on the zod item schema, beside the existing `.describe()` rather than in place of it.
12
+
-**The debt record is deleted, not suppressed.**`repeater-item-titles.test.ts` keeps an exact, shrink-only ledger: a carrier in it must still be untitled, so paying a debt and leaving the entry behind is as red as never paying it. Both `dashboard:*` entries are gone from that set; five remain (`field:options`, `object:fields.options`, `view:columns`, `view:sort`, `view:tabs`).
13
+
- ⛔ **No tombstone was titled.** The five `retiredKey()` keys on this row (`actionUrl`, `actionType`, `actionIcon`, `responsive`, `aria`) declare their keys unwritable; an authoring label would advertise them as writable. All five still emit `title: undefined` in both `io: 'input'` and `io: 'output'`, and the sibling control in `dashboard.test.ts` was re-pointed onto one of them so the rule is now pinned rather than assumed.
14
+
15
+
Measured through the platform's own predicate (`z.toJSONSchema` over `getMetadataTypeSchema`, `io: 'input'`), not by regexing source: `dashboard:widgets` untitled 17 → 0 and `dashboard:globalFilters` untitled 10 → 0, with all twenty other repeater carriers unchanged in the same run.
* dimension without the dataset declaring it, and `widget-dimension-unknown`
1029
1030
* is what holds that line for authored dashboards.
1030
1031
*/
1031
-
field: z.string().describe('Field name to filter on — at the authoring layer it resolves against the object behind each bound widget\'s dataset (`dataset.object`), not against that dataset\'s declared `dimensions`; enforced by the lint rule `dashboard-filter-field-unknown` (severity error)'),
1032
+
field: z.string().describe('Field name to filter on — at the authoring layer it resolves against the object behind each bound widget\'s dataset (`dataset.object`), not against that dataset\'s declared `dimensions`; enforced by the lint rule `dashboard-filter-field-unknown` (severity error)')
1033
+
.meta({title: 'Field'}),
1032
1034
1033
1035
/**
1034
1036
* Source object for i18n label resolution (#7804): when set, this filter's
* keyed by. `optionsFrom.object` already proves the schema is willing to
1047
1049
* name an object here — this reuses that same primitive one level up.
1048
1050
*/
1049
-
object: z.string().optional().describe('Object whose `fields.<object>.<field>` translation-bundle entry resolves this filter\'s field label and option labels'),
1051
+
object: z.string().optional().describe('Object whose `fields.<object>.<field>` translation-bundle entry resolves this filter\'s field label and option labels')
1052
+
.meta({title: 'Object'}),
1050
1053
1051
1054
/** Display label for the filter */
1052
-
label: I18nLabelSchema.optional().describe('Display label for the filter'),
1055
+
label: I18nLabelSchema.optional().describe('Display label for the filter').meta({title: 'Label'}),
0 commit comments