Skip to content

Commit 97233b9

Browse files
os-warrenclaude
andauthored
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>
1 parent 8ca7aaf commit 97233b9

4 files changed

Lines changed: 63 additions & 36 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
`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.

‎packages/spec/src/kernel/repeater-item-titles.test.ts‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -84,16 +84,17 @@ const FORMS: ReadonlyArray<readonly [string, unknown]> = [
8484
* Carriers still owed titles, as measured on `origin/main` at
8585
* e758131b3900eb13260f03643e295ca6d625c42b. SHRINK-ONLY — see the header.
8686
*
87-
* The four `dashboard.*` / `view.*` / `field.*` entries were fenced out of
88-
* #17232's round by in-flight PRs on their carrier files (#17474 `dashboard.zod.ts`,
89-
* #17360 `view.zod.ts`, #17477 `field.zod.ts` — `field.options` and
90-
* `object.fields.options` are the same `SelectOptionSchema`). This pin
91-
* OBSERVES them without editing them, which is why the count below is the
92-
* whole class and not the slice one PR could reach.
87+
* The remaining `view.*` / `field.*` entries were fenced out of #17232's
88+
* round by in-flight PRs on their carrier files (#17360 `view.zod.ts`,
89+
* #17477 `field.zod.ts` — `field.options` and `object.fields.options` are
90+
* the same `SelectOptionSchema`). This pin OBSERVES them without editing
91+
* them, which is why the set below is the rest of the class and not the
92+
* slice one PR could reach.
93+
*
94+
* `dashboard:widgets` and `dashboard:globalFilters` were paid by #17505 and
95+
* DELETED from this set — a paid debt leaves no entry behind.
9396
*/
9497
const LEDGER: ReadonlySet<string> = new Set([
95-
'dashboard:widgets',
96-
'dashboard:globalFilters',
9798
'field:options',
9899
'object:fields.options',
99100
'view:columns',

‎packages/spec/src/ui/dashboard.test.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -769,8 +769,16 @@ describe('#16458 — DashboardHeaderAction fields carry an item-level `title`',
769769
}
770770
// Control — a sibling item property with no authored title has none:
771771
// the pin above is reading a title, not a default the emitter invents.
772+
// The control is a `retiredKey()` TOMBSTONE, which is the one widget row
773+
// property that must stay untitled on purpose: it declares the key
774+
// unwritable, and an authoring label would advertise it as writable.
775+
// (`widgets[].id` held this role until its carrier was titled.)
772776
const widgetProps = js.properties.widgets.items.properties;
773-
expect(widgetProps.id.title).toBeUndefined();
777+
expect(widgetProps.actionUrl.title).toBeUndefined();
778+
expect(widgetProps.actionUrl.description).toMatch(/^\[REMOVED\] /);
779+
// Lit — the authorable sibling really does carry one, so the line above
780+
// measures the tombstone rule, not an emitter that never writes titles.
781+
expect(widgetProps.id.title).toBe('Widget ID');
774782
});
775783
}
776784

‎packages/spec/src/ui/dashboard.zod.ts‎

Lines changed: 30 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -535,22 +535,22 @@ export const DashboardWidgetSchema = lazySchema(() => strictObject({
535535
guidanceSets: WIDGET_GUIDANCE_SETS,
536536
}, {
537537
/** Unique widget identifier (snake_case, used for targetWidgets references) */
538-
id: SnakeCaseIdentifierSchema.describe('Unique widget identifier (snake_case)'),
538+
id: SnakeCaseIdentifierSchema.describe('Unique widget identifier (snake_case)').meta({ title: 'Widget ID' }),
539539

540540
/** Widget Title */
541-
title: I18nLabelSchema.optional().describe('Widget title'),
541+
title: I18nLabelSchema.optional().describe('Widget title').meta({ title: 'Title' }),
542542

543543
/** Widget Description (displayed below the title) */
544-
description: I18nLabelSchema.optional().describe('Widget description text below the header'),
544+
description: I18nLabelSchema.optional().describe('Widget description text below the header').meta({ title: 'Description' }),
545545

546546
/** Visualization Type */
547-
type: ChartTypeSchema.default(WIDGET_TYPE_DEFAULT).describe('Visualization type'),
547+
type: ChartTypeSchema.default(WIDGET_TYPE_DEFAULT).describe('Visualization type').meta({ title: 'Visualization Type' }),
548548

549549
/** Chart Configuration */
550-
chartConfig: ChartConfigSchema.optional().describe('Chart visualization configuration'),
550+
chartConfig: ChartConfigSchema.optional().describe('Chart visualization configuration').meta({ title: 'Chart Configuration' }),
551551

552552
/** Color variant for the widget (e.g., KPI card accent color) */
553-
colorVariant: WidgetColorVariantSchema.optional().describe('Widget color variant for theming'),
553+
colorVariant: WidgetColorVariantSchema.optional().describe('Widget color variant for theming').meta({ title: 'Color Variant' }),
554554

555555
/**
556556
* Runtime capability gate — widget is hidden when the named object is
@@ -561,13 +561,13 @@ export const DashboardWidgetSchema = lazySchema(() => strictObject({
561561
* Set explicitly to the dataset's base object when the widget should be
562562
* gated on that object's availability.
563563
*/
564-
requiresObject: z.string().optional().describe('Hide the widget unless the named object is registered'),
564+
requiresObject: z.string().optional().describe('Hide the widget unless the named object is registered').meta({ title: 'Requires Object' }),
565565

566566
/**
567567
* Runtime capability gate — widget is hidden when the named kernel
568568
* service is not registered. Mirrors `NavigationItem.requiresService`.
569569
*/
570-
requiresService: z.string().optional().describe('Hide the widget unless the named kernel service is registered'),
570+
requiresService: z.string().optional().describe('Hide the widget unless the named kernel service is registered').meta({ title: 'Requires Service' }),
571571

572572
// `actionUrl` / `actionType` / `actionIcon` REMOVED (#5010, ADR-0049 D2):
573573
// the three keys described a per-widget header action BUTTON that no renderer
@@ -583,7 +583,7 @@ export const DashboardWidgetSchema = lazySchema(() => strictObject({
583583
actionIcon: retiredKey(WIDGET_ACTION_RETIRED('actionIcon')),
584584

585585
/** Presentation-scope filter (MongoDB-style), ANDed into the dataset query as `runtimeFilter`. */
586-
filter: FilterConditionSchema.optional().describe('Presentation-scope filter (runtimeFilter)'),
586+
filter: FilterConditionSchema.optional().describe('Presentation-scope filter (runtimeFilter)').meta({ title: 'Filter' }),
587587

588588
/**
589589
* Period-over-period comparison window.
@@ -689,7 +689,7 @@ export const DashboardWidgetSchema = lazySchema(() => strictObject({
689689
*/
690690
dimension: z.string().optional()
691691
.describe('Time dimension to shift; omit when the selection has exactly one dated time dimension'),
692-
}).optional().describe('Period-over-period comparison window ({ kind, dimension? })'),
692+
}).optional().describe('Period-over-period comparison window ({ kind, dimension? })').meta({ title: 'Compare To' }),
693693

694694
/**
695695
* ADR-0021 — the semantic-layer `dataset` this widget binds to. The widget
@@ -699,11 +699,11 @@ export const DashboardWidgetSchema = lazySchema(() => strictObject({
699699
* author-facing analytics shape (the legacy inline `object` + `categoryField`
700700
* + `valueField` + `aggregate` query was removed in the single-form cutover).
701701
*/
702-
dataset: SnakeCaseIdentifierSchema.describe('Dataset name to bind (ADR-0021)'),
702+
dataset: SnakeCaseIdentifierSchema.describe('Dataset name to bind (ADR-0021)').meta({ title: 'Dataset' }),
703703
/** Dimension names (from the dataset) for X / group / split. */
704-
dimensions: z.array(z.string()).optional().describe('Dimension names — X/group/split'),
704+
dimensions: z.array(z.string()).optional().describe('Dimension names — X/group/split').meta({ title: 'Dimensions' }),
705705
/** Measure names (from the dataset) for the value axis. */
706-
values: z.array(z.string()).min(1).describe('Measure names — Y (at least one)'),
706+
values: z.array(z.string()).min(1).describe('Measure names — Y (at least one)').meta({ title: 'Values' }),
707707

708708
/**
709709
* Layout Position (React-Grid-Layout style)
@@ -758,10 +758,10 @@ export const DashboardWidgetSchema = lazySchema(() => strictObject({
758758
y: z.number(),
759759
w: z.number(),
760760
h: z.number(),
761-
}).optional().describe('Grid layout position (auto-flowed when omitted)'),
761+
}).optional().describe('Grid layout position (auto-flowed when omitted)').meta({ title: 'Layout' }),
762762

763763
/** Widget specific options (colors, legend, etc.) — see {@link DashboardWidgetOptionsSchema}. */
764-
options: DashboardWidgetOptionsSchema.optional().describe('Widget specific configuration'),
764+
options: DashboardWidgetOptionsSchema.optional().describe('Widget specific configuration').meta({ title: 'Options' }),
765765

766766
/**
767767
* Per-widget bindings from a dashboard-level filter (referenced by its
@@ -773,14 +773,15 @@ export const DashboardWidgetSchema = lazySchema(() => strictObject({
773773
* (dateRange: `dateRange.field ?? 'created_at'`)
774774
*/
775775
filterBindings: z.record(z.string(), z.union([z.string(), z.literal(false)])).optional()
776-
.describe("Per-widget dashboard-filter bindings: filter name → this widget's field, or false to opt out"),
776+
.describe("Per-widget dashboard-filter bindings: filter name → this widget's field, or false to opt out")
777+
.meta({ title: 'Filter Bindings' }),
777778

778779
/**
779780
* Rule ids of build diagnostics intentionally suppressed on this widget
780781
* (e.g. `'table-count-only'` when a single-row summary table is deliberate).
781782
* Consumed by `objectstack build` / `objectstack lint`; no runtime effect.
782783
*/
783-
suppressWarnings: z.array(z.string()).optional().describe('Build diagnostic rule ids suppressed on this widget'),
784+
suppressWarnings: z.array(z.string()).optional().describe('Build diagnostic rule ids suppressed on this widget').meta({ title: 'Suppress Warnings' }),
784785

785786
// `responsive` REMOVED (#4876): authorable and inert, exactly like the
786787
// same-named `view.responsive` retired four days earlier (#3896 close-out).
@@ -1004,7 +1005,7 @@ export const GlobalFilterSchema = lazySchema(() => strictObject({
10041005
* Defaults to `field`. The name `"dateRange"` is reserved for the built-in
10051006
* dashboard date range.
10061007
*/
1007-
name: z.string().optional().describe('Stable filter name (variable key); defaults to field'),
1008+
name: z.string().optional().describe('Stable filter name (variable key); defaults to field').meta({ title: 'Name' }),
10081009

10091010
/**
10101011
* Field name to filter on — at the authoring layer it resolves against the
@@ -1028,7 +1029,8 @@ export const GlobalFilterSchema = lazySchema(() => strictObject({
10281029
* dimension without the dataset declaring it, and `widget-dimension-unknown`
10291030
* is what holds that line for authored dashboards.
10301031
*/
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' }),
10321034

10331035
/**
10341036
* Source object for i18n label resolution (#7804): when set, this filter's
@@ -1046,13 +1048,14 @@ export const GlobalFilterSchema = lazySchema(() => strictObject({
10461048
* keyed by. `optionsFrom.object` already proves the schema is willing to
10471049
* name an object here — this reuses that same primitive one level up.
10481050
*/
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' }),
10501053

10511054
/** 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' }),
10531056

10541057
/** Filter input type */
1055-
type: z.enum(['text', 'select', 'date', 'number', 'lookup']).optional().describe('Filter input type'),
1058+
type: z.enum(['text', 'select', 'date', 'number', 'lookup']).optional().describe('Filter input type').meta({ title: 'Input Type' }),
10561059

10571060
/** Static options for select/lookup filters */
10581061
options: z.array(strictObject({
@@ -1062,19 +1065,19 @@ export const GlobalFilterSchema = lazySchema(() => strictObject({
10621065
}, {
10631066
value: z.union([z.string(), z.number(), z.boolean()]).describe('Option value'),
10641067
label: I18nLabelSchema,
1065-
})).optional().describe('Static filter options'),
1068+
})).optional().describe('Static filter options').meta({ title: 'Options' }),
10661069

10671070
/** Dynamic data binding for filter options */
1068-
optionsFrom: GlobalFilterOptionsFromSchema.optional().describe('Dynamic filter options from object'),
1071+
optionsFrom: GlobalFilterOptionsFromSchema.optional().describe('Dynamic filter options from object').meta({ title: 'Options From' }),
10691072

10701073
/** Default filter value */
1071-
defaultValue: z.union([z.string(), z.number(), z.boolean()]).optional().describe('Default filter value'),
1074+
defaultValue: z.union([z.string(), z.number(), z.boolean()]).optional().describe('Default filter value').meta({ title: 'Default Value' }),
10721075

10731076
/** Filter application scope */
1074-
scope: z.enum(['dashboard', 'widget']).default('dashboard').describe('Filter application scope'),
1077+
scope: z.enum(['dashboard', 'widget']).default('dashboard').describe('Filter application scope').meta({ title: 'Scope' }),
10751078

10761079
/** Widget IDs to apply this filter to (when scope is widget) */
1077-
targetWidgets: z.array(z.string()).optional().describe('Widget IDs to apply this filter to'),
1080+
targetWidgets: z.array(z.string()).optional().describe('Widget IDs to apply this filter to').meta({ title: 'Target Widgets' }),
10781081
})
10791082
// #4614 — the date `defaultValue` vocabulary check. Attached by identifier,
10801083
// not inlined: the export is the rule a `.shape` mirror re-attaches (#16489),

0 commit comments

Comments
 (0)