Skip to content

Commit 99e1912

Browse files
feat(spec): retire the metric sub-caption — the widget translation subCaption key and translateDashboard's options.description overlay (#21342)
Fixes #21257 Clause-②: no (narrowing) Retires the metric sub-caption at both ends, objectstack first. This executes ruling **C** on objectstack-ai/objectui#11389 (batch #264 item 5, maintainer 「同意264」), which reverses #5428 item 4. A dashboard widget keeps one authored description, `widget.description`. It renders as the card-header subtitle and is translated by the widget node's `description` key. No `options.description` comeback, and no new widget-level key. ## What changes 1. **The translation member is retired.** In `packages/spec/src/system/translation.zod.ts`, `subCaption` on the widget translation node is now a `retiredKey()` tombstone. Its prescription is: delete the entry; translate the card subtitle through the widget's `description` entry; run `os migrate meta --from 17`. The node is spread into the per-app bundle entry, the platform bundle entry and the `translation` item, and all three refuse the key. 2. **The overlay is removed.** `translateDashboard` no longer writes `options.description`, and it carries `options` through by reference. The `attr` union of `lookupWidgetAttr` drops `'subCaption'`, and the `WidgetLike.options` and `translateDashboard` docblocks say so. 3. **The census asserts nothing writes the key.** `scripts/check-widget-option-census.mjs`: `NON_DECLARED_MEMBERS` is empty. The `description` row left together with its witness and its self-test cases. The row checks (stale row, witness gone, witness only in prose) still run against a fixture ledger, through a new `ledger` parameter on `judge`. A new battery pins `description` coming back into the census as `UNDECLARED-AND-UNLEDGERED`, and the self-test floor goes from 16 to 17. The gate's own `WITNESS GONE` text prescribes taking the key out of the census, so `@objectstack/sdui-parser`'s `CONSUMED_WIDGET_OPTION_KEYS` drops `description` in the same change. An authored `options.description` now draws the `unconsumed-widget-option` warning, which is a warning and fails nothing. 4. **Docblocks** in `ui/dashboard.zod.ts`, `sdui-parser/src/dashboard-widget-options.ts` and `platform-objects/.../source-hash.ts` now state the retired state and point at `widget.description`. Retirement kit, per `.claude/skills/spec-property-retirement`: - **D2 conversion.** `translation-widget-sub-caption-removed` (protocol 18, `MAJOR_18_CONVERSIONS` order 55, `retiredAfter: '17.6.0'`). It strips the key from bundle entries and bare items, and is retired from the load path. Stored `translation` rows replay it through `applyConversionsToStoredItem`. - **D3 entry.** The semantic entry `translation-widget-sub-caption-retired` and its `STEP18_RATIONALE` fragment (order 60). The registry region was regenerated with `gen:migration-registry`. - **Liveness.** The `translation.json` `dashboards` row has new evidence and a new note, and `verifiedAt` is 2026-10-02. The member sits below the ledger's walk boundary, inside the `widgets` record, so it has no row of its own. - **Generated.** `content/docs/references/system/translation.mdx` was regenerated by `check:generated --fix`, which judged only `check:docs` stale. Nothing was hand-edited. - **Docs.** `content/docs/ui/translations.mdx` is corrected at the table row, the callout and the packaged-strings table. - **Changeset.** `.changeset/21257-widget-sub-caption-retired.md` bumps `@objectstack/spec`, `@objectstack/sdui-parser` and `@objectstack/lint` by `minor`. It has a `**BREAKING**` banner, a FROM-to-TO table and the ADR-0087 marker `registered translation-widget-sub-caption-removed, translation-widget-sub-caption-retired`. - **No `RETIRED_KEYS_BY_MAJOR` row.** The key sits under two records, and the nested-key resolver deliberately does not traverse `additionalProperties`. The component-copy `submitLabel` retirement (commit d173125) made the same choice. ## The `subtitle` alias: refused, not repointed The old table carried `subtitle: 'subCaption'`. With `subCaption` tombstoned, that row is the shape the alias-integrity audit refuses by name. Measured by putting the row back in: `alias-integrity.test.ts` went red with "`subtitle` -> `subCaption` — `subCaption` is a tombstone; it accepts nothing". Every precedent in the tree for an alias whose target was retired moves the alias into `guidance` with a refusal: chart `accessibility` / `ariaProps`, postgres `passwd` / `pwd`, permission `restore` / `purge`, app `home` / `homepage` / `landingpage`, and component-copy `submit`. None of them repoints the alias. Repointing it at `description` would also silently change what the word is taken to mean. The alias existed because a `subtitle` on a metric widget meant the caption under the number. So `subtitle` is now a `guidance` entry that names both readings: card-header copy belongs under `description`; a caption under the value has nowhere to render, so delete it. This keeps the four-axis 「过渡也从紧」 rule: no rename window and no silent repoint. The change is a refusal message, not an accept-set change: `subtitle` was never accepted. ## Measurements - **Producers (objectstack `main` `393ae878d3`, re-read at `1371dc980c`).** - `git grep subCaption` over `examples/`, `packages/platform-objects` and `apps/` returns 0 hits. Control: widget translation entries exist in the showcase and platform bundles (`widgets:` appears 4 times in `examples/app-showcase/src/system/translations/index.ts` and once in each `platform-objects` locale file). - Authored widget `options` blocks in the 6 `*.dashboard.ts` sources under `examples/` and `packages/platform-objects`: 0. The two `options:` hits are global-filter option arrays. Control: `widget.description` is authored, for example on `pipeline.dashboard.ts`. - `app-multi-package` has no dashboards. - So: 0 producers. This is not a stop. - **objectui pin check (`.objectui-sha` `31971ff1e28f`).** The pinned objectui still reads `subCaption` and `options.description`: `useObjectLabel.widgetSubCaption`, `widgetSubCaption.ts`, `DatasetWidget`, `DashboardRenderer`, and its own `sdui-parser` census. That is objectui#11389's reader half. It imports no symbol this PR removes: `git grep` at the pin finds no import of `WidgetLike`, `translateDashboard` or `lookupWidgetAttr`. Control: the pin does import from `@objectstack/spec` elsewhere, for example `spec-symbol-parity.test.ts`. No exported symbol is removed here, and the bundle-shaped `subCaption` fixtures at the pin are untyped. So there is no build break, and no sibling fix rides this landing. - **Lockstep window.** objectui's `CONSUMED_WIDGET_OPTION_KEYS` at the pin still lists `description`, and objectui#11389 drops it with the reader. Until then this copy is one member short. That errs in the safe direction: this copy warns on a key that objectui's strict authoring face already refuses. `check:sdui-lockstep` compares grammar, codes and the containment predicate, not this array, and it is green. The window is documented at the array. ## Verification (final head `8438e24995`, which is `3f1c6d9c4d` with `origin/main` `1371dc980c` merged) - `pnpm --filter @objectstack/spec build`, then `check:generated`. 1 of 15 artifacts was stale (`check:docs`), and `--fix` regenerated only that one. - Spec tests: - `vitest run --project local` (at `3f1c6d9c4d`): 597 files, 17487 passed, 1 todo. - `vitest run --project repo`: 48 files, 849 passed. - `pnpm --filter @objectstack/spec typecheck`: exit 0. - Other package tests: - `@objectstack/sdui-parser`: test 218 passed; typecheck exit 0. - `@objectstack/lint`: test 5575 passed after the merge; typecheck exit 0. - `@objectstack/platform-objects` (comment-only change): test 948 passed; typecheck exit 0. - `node scripts/check-widget-option-census.mjs --self-test`: 17 cases pass. The real run is green with 5 declared keys in 5 members and 0 non-declared. - Derived gates. `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 137 commands at `8438e24995`. All 137 ran with recorded exit codes, and `--ran` reconciles: 136 run, 1 NOT-MEASURED, 0 UNRUN. - The NOT-MEASURED one is `check:pm-dispatch-gates`. Two foreground runs were killed at the container cap (580s and 560s) before reaching a verdict; CI runs it. - Five gates first exited 3 (PREREQUISITE NOT MET) and passed after the CLI and client-react closures were built: `check:i18n`, `check:i18n-walk-parity`, `check:skill-examples`, `check:lean-entry-closure`, `check:dual-build-cjs-loads`. - **Reverse verification (cross-package type).** I planted `subCaption` on `widget_total_users` in `packages/platform-objects/src/apps/translations/en.ts`, which is typed `TranslationData` through spec's rebuilt `dist`. `tsc --noEmit` went red: `en.ts(210,69)`, error TS2322, a `string` not assignable to the `'[REMOVED] Key retired: …': never` mark. The file was restored, the blob equals HEAD, and `git diff HEAD` is empty. My first attempt was a no-op: `ablation-replace` refused because the replacement contained the anchor. It wrote nothing. - **Ablations, one-shot, each restored to the HEAD blob.** - Re-adding an `options.description` write in `translateDashboard` turned the 3 new resolver pins red. - Swapping the tombstone for `z.string().optional()` turned 3 translation pins red. The `subtitle` pin stays green because it is independent of the tombstone. - Restoring the `subtitle -> subCaption` alias turned alias-integrity red, as quoted above. - **eslint, a declared narrowing.** I ran `eslint --no-inline-config --format json` on the 13 changed `.ts` / `.mjs` files: 13 files linted, 0 errors, 0 warnings, and none ignored, because an ignored file would have shown as a warning. `eslint.config.mjs` states that type-aware linting is never enabled (no `parserOptions.project`), so this diff cannot move a verdict on an untouched file. The repo-wide `pnpm lint` belongs to CI. ## Acceptance notes - Two example comments still describe the dataset-bound read set as "plus the `description` sub-caption": `examples/app-crm/src/dashboards/pipeline.dashboard.ts:41` and `examples/app-todo/src/dashboards/task.dashboard.ts:22`. That is true of objectui's renderer at the pin, and false once objectui#11389 lands. These are comments only and outside the claimed file surface, so they are not edited here. No carrier is named. - `DashboardWidgetOptionsSchema` stays `passthrough`, as ruled when the bag was opened, so the spec still parses an authored `options.description`. What now names it is the `unconsumed-widget-option` warning at `os validate`, `os build` and `os lint`. This is an observation, not a finding: 0 producers were measured. - The card body asked for `Clause-②: yes (narrowing)`. The measured arm is `no (narrowing)`, which matches the claim: no surface widens. The `subtitle` change alters a refusal message only, and the new conversion and semantic entries are registry rows, not an accept-set or export widening. - `#20274` regenerates `liveness/state-counts*` and `content/docs/references/**`. This PR changes no state count; the `dashboards` row stays `live`. It does change `references/system/translation.mdx`. If the two collide, the second to land regenerates through `scripts/pm/os-regen-merge.sh`. --- _Generated by [Claude Code](https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6c5bef5 commit 99e1912

17 files changed

Lines changed: 573 additions & 206 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/sdui-parser': minor
4+
'@objectstack/lint': minor
5+
---
6+
7+
The metric sub-caption is retired at both ends. A dashboard widget keeps one authored description, `widget.description`, which renders as the card-header subtitle and is translated by the widget's `description` translation key. The widget translation key `subCaption` is refused, and the server no longer writes a widget's `options.description`.
8+
9+
Clause-②: no (narrowing)
10+
11+
<!-- adr-0087: registered translation-widget-sub-caption-removed, translation-widget-sub-caption-retired -->
12+
13+
**What is retired.** `dashboards.DASHBOARD.widgets.WIDGET.subCaption` in a translation bundle (`defineTranslationBundle`, `stack.translations`, the platform bundle) and in a registered `translation` item. It overlaid a caption under a metric's value onto the widget's `options.description`. The dashboard schema never declared `options.description`, and no authored widget wrote it, so `translateDashboard`'s overlay was the key's only writer. That overlay is removed: `translateDashboard` now translates a widget's `title` and `description` and carries `options` through untouched.
14+
15+
**BREAKING** — an accept-set narrowing, shipped as `minor` under the launch-window convention.
16+
17+
### FROM → TO
18+
19+
| wrote | write instead |
20+
| --- | --- |
21+
| `dashboards.DASHBOARD.widgets.WIDGET.subCaption: 'TEXT'` | delete the entry. If the copy belongs on the card, put it in the widget's `description` and translate it under `dashboards.DASHBOARD.widgets.WIDGET.description`. |
22+
| `dashboards.DASHBOARD.widgets.WIDGET.subtitle: 'TEXT'` | `subtitle` was only ever a rename suggestion for `subCaption`. Card-header copy goes under `description`; a caption under the value has nowhere to render, so delete it. |
23+
24+
**The one-line fix: delete every `subCaption:` entry under `dashboards.*.widgets.*` in your translation bundles.** `os migrate meta --from 17` lists the mechanical edits for existing sources; stored `translation` items are converted when they are read.
25+
26+
**What an author now sees.** Writing `subCaption` fails `tsc` (its input type is the retired-key mark) and fails the parse with a prescription naming the widget's `description`. Writing `subtitle` on a widget translation fails the parse with both readings named, instead of a rename suggestion onto a key that is refused next. `os validate`, `os build` and `os lint` now raise the `unconsumed-widget-option` warning on an authored widget `options.description`, like any other options key the dataset-bound render path does not read. It is a warning, so none of the three fails on it.
27+
28+
**Measured producers: none.** Zero `subCaption` entries and zero authored widget `options.description` in the four example apps (`app-crm`, `app-todo`, `app-showcase`, `app-multi-package`) and in the bundles `@objectstack/platform-objects` ships, so no shipped exit code changes.
29+
30+
### The retirement kit
31+
32+
- **Tombstone.** `subCaption` is a `retiredKey()` tombstone on the widget translation node, so the refusal carries the prescription on all three faces the node is spread into (per-app bundle entry, platform bundle entry, `translation` item). The node sits under two records (`dashboards`, `widgets`), below the authorable-surface walk, so it has no `RETIRED_KEYS_BY_MAJOR` row, the same as the `submitLabel` component-copy key before it.
33+
- **The former alias.** The `subtitle` → `subCaption` rename suggestion moves to the node's `guidance` table. An alias whose target is a tombstone is the shape the alias-integrity audit refuses, and repointing it at `description` would silently change what the word is taken to mean.
34+
- **Conversion.** `translation-widget-sub-caption-removed` (protocol 18) strips the key from bundle entries and bare translation items as a lossless delete. It is retired from the load path, so authors are refused at parse while stored rows and `os migrate meta` replay it. Its D3 record is the semantic entry `translation-widget-sub-caption-retired`.
35+
- **`@objectstack/sdui-parser`.** `CONSUMED_WIDGET_OPTION_KEYS` drops `description`, its one undeclared member, which existed only because the overlay wrote it. `check:widget-option-census`'s `NON_DECLARED_MEMBERS` ledger is now empty, so the census asserts that nothing writes an undeclared key into `options`.

‎content/docs/references/system/translation.mdx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,7 @@ Translation data for a single object
231231
| **label** | `string` | optional | Translated dashboard title |
232232
| **description** | `string` | optional | Translated dashboard description |
233233
| **actions** | `Record<string, { label?: string }>` | optional | Header action label translations keyed by action url/key |
234-
| **widgets** | `Record<string, { title?: string; description?: string; subCaption?: string }>` | optional | Widget translations keyed by widget id |
234+
| **widgets** | `Record<string, { title?: string; description?: string }>` | optional | Widget translations keyed by widget id |
235235
| **globalFilters** | `Record<string, { label?: string; options?: Record<string, string> }>` | optional | Global-filter translations keyed by the filter `name` (a filter that authors no `name` is keyed by its `field`) |
236236

237237
### Nested Shape: `PlatformTranslationData.datasets[string]`
@@ -429,7 +429,7 @@ Translation data for a single object
429429
| **label** | `string` | optional | Translated dashboard title |
430430
| **description** | `string` | optional | Translated dashboard description |
431431
| **actions** | `Record<string, { label?: string }>` | optional | Header action label translations keyed by action url/key |
432-
| **widgets** | `Record<string, { title?: string; description?: string; subCaption?: string }>` | optional | Widget translations keyed by widget id |
432+
| **widgets** | `Record<string, { title?: string; description?: string }>` | optional | Widget translations keyed by widget id |
433433
| **globalFilters** | `Record<string, { label?: string; options?: Record<string, string> }>` | optional | Global-filter translations keyed by the filter `name` (a filter that authors no `name` is keyed by its `field`) |
434434

435435
### Nested Shape: `TranslationData.datasets[string]`
@@ -588,7 +588,7 @@ Translation data for a single object
588588
| **label** | `string` | optional | Translated dashboard title |
589589
| **description** | `string` | optional | Translated dashboard description |
590590
| **actions** | `Record<string, { label?: string }>` | optional | Header action label translations keyed by action url/key |
591-
| **widgets** | `Record<string, { title?: string; description?: string; subCaption?: string }>` | optional | Widget translations keyed by widget id |
591+
| **widgets** | `Record<string, { title?: string; description?: string }>` | optional | Widget translations keyed by widget id |
592592
| **globalFilters** | `Record<string, { label?: string; options?: Record<string, string> }>` | optional | Global-filter translations keyed by the filter `name` (a filter that authors no `name` is keyed by its `field`) |
593593

594594
### Nested Shape: `TranslationItem.datasets[string]`

‎content/docs/ui/translations.mdx‎

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ export default defineStack({
7474
| Custom validation-rule messages | `objects.<name>._validations.<rule>.message` |
7575
| App navigation | `apps.<app>.navigation.<id>.label` |
7676
| Dashboard label / description | `dashboards.<name>.label` / `description` |
77-
| Dashboard widget title / description / sub-caption | `dashboards.<name>.widgets.<widgetId>.title` / `description` / `subCaption` |
77+
| Dashboard widget title / description (the card-header subtitle) | `dashboards.<name>.widgets.<widgetId>.title` / `description` |
7878
| Dashboard global-filter label and static option labels | `dashboards.<name>.globalFilters.<filterName>.label` / `.options.<value>` — the key is the filter's `name`, or its `field` when it authors no `name`; an option is keyed by its `value` spelled as a string |
7979
| Analytics dataset label / description | `datasets.<name>.label` / `description` |
8080
| Dataset dimension and measure labels | `datasets.<name>.dimensions.<dimension>.label` / `datasets.<name>.measures.<measure>.label` |
@@ -95,13 +95,16 @@ in a zh-CN workspace for every consumer except the Console, which happened to
9595
re-resolve them client-side against its own copy of the bundle.
9696

9797
<Callout type="info">
98-
**A widget's `subtitle` alias resolves to `subCaption`, not `description`
99-
(#5428 item 4, #7862).** The metric widget's sub-caption — the string under
100-
the number — is the authored `widget.options.description`, a *different*
101-
authored field from `widget.description` (the copy under the card header).
102-
Two authored fields get two keys (「两个作者字段两个 key」): `description`
103-
translates `widget.description`, `subCaption` translates
104-
`widget.options.description`, and neither reaches the other's field.
98+
**A widget has one translatable description; the metric sub-caption is
99+
retired (#21257).** `widget.description` renders as the card-header subtitle
100+
and is translated by `dashboards.<name>.widgets.<widgetId>.description`.
101+
The `subCaption` key, which used to overlay a caption under a metric's value
102+
onto `widget.options.description`, is refused at parse with its prescription,
103+
and the server no longer writes `options.description`. A `subtitle` entry is
104+
refused too, naming both readings: card-header copy belongs under
105+
`description`; a caption under the value has nowhere to render, so delete
106+
it. `os migrate meta --from 17` lists the `subCaption` entries to delete from
107+
existing bundles.
105108
</Callout>
106109

107110
<Callout type="info">
@@ -185,7 +188,7 @@ with the same string in the item as the package shipped it:
185188

186189
| Packaged type | Strings compared, and how each is matched |
187190
|:---|:---|
188-
| Dashboard | its `label` and `description`; each widget's `title`, `description` and sub-caption (`subCaption`), matched by widget `id`; each global filter's `label` and static option labels, matched by the filter key and the option `value` |
191+
| Dashboard | its `label` and `description`; each widget's `title` and `description`, matched by widget `id`; each global filter's `label` and static option labels, matched by the filter key and the option `value` |
189192
| View | its `label` and `description`; each bulk action's `label`, `confirmText` and `confirmLabel`, and each of its params' `label`, `help` and `placeholder`, matched by `name` |
190193

191194
- **Only the edited strings move.** A widget the org left alone on an edited

‎packages/lint/src/validate-dashboard-widget-options.test.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,6 @@ describe('validateDashboardWidgetOptions — the SDUI widget-option check at the
5353
// schema refuses it on any other type, so the control widget is a funnel.
5454
const values: AnyRec = {
5555
dateGranularity: 'month',
56-
description: 'sub-caption',
5756
limit: 10,
5857
sortBy: 'total_amount',
5958
sortOrder: 'desc',

‎packages/platform-objects/src/apps/translations/source-hash.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -277,9 +277,11 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
277277
* (`apps.setup.navigation.group_overview.label`).
278278
*
279279
* A generic deep walk rather than an enumeration of the known shapes: the
280-
* sections grow keys (`subCaption`, `pages.<n>.components.<id>.<copyKey>`) and
281-
* an enumeration would silently stop covering the new ones — the same
282-
* declared-but-unwalked failure this module exists to close.
280+
* sections grow keys (`dashboards.<n>.globalFilters.<key>.options.<value>`,
281+
* `pages.<n>.components.<id>.<copyKey>`) and an enumeration would silently stop
282+
* covering the new ones — the same declared-but-unwalked failure this module
283+
* exists to close. (They shrink too: the widget `subCaption` key this sentence
284+
* once cited was retired, and a deep walk needs no edit for that either.)
283285
*/
284286
export function collectSourceLeaves(data: TranslationData | undefined): Map<string, string> {
285287
return collectLeavesOf(data, HAND_AUTHORED_SECTIONS);

‎packages/sdui-parser/src/__tests__/dashboard-widget-options.test.ts‎

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -122,23 +122,22 @@ describe('the census the expectations derive from is not vacuous', () => {
122122
expect([...CONSUMED_WIDGET_OPTION_KEYS].sort()).toEqual([...CONSUMED_WIDGET_OPTION_KEYS]);
123123
});
124124

125-
it('the accepted set is exactly objectui\'s — the lockstep pin this copy cannot re-derive', () => {
125+
it('the accepted set is the five declared query keys — the lockstep pin this copy cannot re-derive', () => {
126126
// This repo has no dashboard renderer, so the read-site half of the census
127127
// is not measurable here (objectui owns it). What IS measurable here is the
128128
// DECLARED half: the five query keys below are exactly the five properties
129129
// `DashboardWidgetOptionsSchema` declares in
130130
// `packages/spec/src/ui/dashboard.zod.ts` — the spec ships from THIS repo,
131131
// so a declared key landing there without landing here would turn this
132-
// module into a false positive on legal metadata. `description` is the
133-
// sixth, undeclared, member: the metric sub-caption channel that
134-
// `translateDashboard` writes into `options` (see `WidgetLike.options` in
135-
// `packages/spec/src/system/i18n-resolver.ts`), also a read site in this
136-
// repo. Re-read both files when this pin fails.
132+
// module into a false positive on legal metadata. There is no undeclared
133+
// member: `description`, the metric sub-caption `translateDashboard` used
134+
// to write into `options`, was retired at both ends (objectstack#21257,
135+
// ruling C on objectui#11389), and objectui's copy drops it with the
136+
// reader in objectui#11389. Re-read both files when this pin fails.
137137
// The declared half of that sentence is re-derived by
138138
// `scripts/check-widget-option-census.mjs`; this pin is the array's shape.
139139
expect([...CONSUMED_WIDGET_OPTION_KEYS]).toEqual([
140140
'dateGranularity',
141-
'description',
142141
'limit',
143142
'sortBy',
144143
'sortOrder',
@@ -192,18 +191,33 @@ describe('the ruled first case: gauge options.invert (objectui#5709)', () => {
192191
it('the emitted diagnostic is byte-equal to objectui\'s, field for field', () => {
193192
// The lockstep claim is about the WHOLE envelope, not just the code: a
194193
// message that drifted by one word is a different author-visible dialect.
194+
// The printed set omits `description` (objectstack#21257); objectui's
195+
// copy prints it until objectui#11389 drops the retired sub-caption read.
195196
const found = unconsumed(dash({ ...slaGauge, options: { invert: true } }));
196197
expect(found).toEqual([
197198
{
198199
severity: 'warning',
199200
code: 'unconsumed-widget-option',
200201
message:
201202
'<dashboard> widget "sla_compliance_gauge" (gauge): options.invert reaches no renderer — ' +
202-
'dashboard widget renderers read only: dateGranularity, description, limit, sortBy, sortOrder, stageOrder',
203+
'dashboard widget renderers read only: dateGranularity, limit, sortBy, sortOrder, stageOrder',
203204
tag: 'dashboard',
204205
},
205206
]);
206207
});
208+
209+
it('`options.description` draws the warning — the retired metric sub-caption is no consumed key (objectstack#21257)', () => {
210+
// Flipped, not deleted: until objectstack#21257 `description` was the
211+
// census's one undeclared member, because `translateDashboard` wrote the
212+
// metric sub-caption into it. Ruling C on objectui#11389 retired that
213+
// channel at both ends, so the key is now judged like any other.
214+
const found = unconsumed(
215+
dash({ id: 'won', type: 'metric', dataset: 'sales', values: ['won_amount'], options: { description: 'vs last quarter' } }),
216+
);
217+
expect(found).toHaveLength(1);
218+
expect(found[0]!.severity).toBe('warning');
219+
expect(found[0]!.message).toContain('options.description reaches no renderer');
220+
});
207221
});
208222

209223
describe('the port is ADDITIVE — it must not move this copy\'s accept/reject set', () => {

‎packages/sdui-parser/src/dashboard-widget-options.ts‎

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@
2424
* objectui's `src/dashboard-widget-options.ts` SAVE FOR ONE TOKEN, called out
2525
* at the site itself: the emitted `code` is spelled as an inline literal here
2626
* and as the constant there, because this repo runs a vocabulary gate objectui
27-
* does not. The emitted `code`, `severity`, `message` and the whole census
27+
* does not. One census MEMBER differs too, for a bounded window: objectui's
28+
* copy at the pin still lists `description`, which this one dropped with the
29+
* retired metric sub-caption (see `CONSUMED_WIDGET_OPTION_KEYS`). The emitted `code`, `severity`, `message` and the whole census
2830
* scope are identical, and `__tests__/dashboard-widget-options.test.ts`
2931
* re-derives that rather than trusting it — including an explicit pin that the
3032
* literal equals `UNCONSUMED_WIDGET_OPTION`. Change these functions only
@@ -50,17 +52,16 @@
5052
* dateGranularity, sortBy, sortOrder, limit (query-affecting, framework#3588)
5153
* stageOrder (funnel stage order — the only type that reads it)
5254
*
53-
* plus ONE undeclared key with a real read site:
54-
*
55-
* description — the metric-card sub-caption channel. `translateDashboard`
56-
* OVERLAYS the `widgets.{id}.subCaption` translation onto this key, and that
57-
* pipeline lives IN THIS REPO: `packages/spec/src/system/i18n-resolver.ts`
58-
* documents `WidgetLike.options` as "the renderer-extras bag …
59-
* `translateDashboard` writes exactly one key into it — `description`"
60-
* (objectstack#5428 item 4, objectstack#7862). Warning on a key the
61-
* platform's own translation pipeline writes would be a false positive on
62-
* legal metadata, so it is in the accepted set even though the dataset-bound
63-
* render path does not currently display it.
55+
* and nothing undeclared. `description` used to be the one undeclared member:
56+
* the metric-card sub-caption channel, which `translateDashboard` overlaid from
57+
* a `widgets.{id}.subCaption` translation (objectstack#5428 item 4,
58+
* objectstack#7862), so warning on it would have flagged the platform's own
59+
* output. Ruling C on objectui#11389 retired that channel at both ends,
60+
* objectstack first (objectstack#21257): the overlay is gone, the bundle key is
61+
* a tombstone, and a widget keeps ONE authored description, `widget.description`
62+
* — never `options.description`. With no writer left, `description` left this
63+
* census, so an `options.description` now draws the warning like any other key
64+
* the dataset-bound path is not meant to read.
6465
*
6566
* Notably NOT consumed on the path a widget really renders through:
6667
* `thresholds` and `format`. Both were widely believed to work; both draw this
@@ -114,12 +115,18 @@ export const DASHBOARD_WIDGET_HOST_TYPES: ReadonlySet<string> = new Set([
114115

115116
/**
116117
* The accepted set: every `options` key with a renderer read site on the
117-
* dataset-bound path, plus the sub-caption convention key. Alphabetical; the
118-
* warning message prints it verbatim. Derivation and evidence: file header.
118+
* dataset-bound path. Alphabetical; the warning message prints it verbatim.
119+
* Derivation and evidence: file header.
120+
*
121+
* ⚠️ One member short of objectui's copy until objectui#11389 lands: objectui's
122+
* renderer at the pin still reads `options.description` for the retired metric
123+
* sub-caption, and its census lists it; that card drops the read and the member
124+
* together. The window errs in the safe direction — this copy warns on a key
125+
* objectui's strict authoring face already refuses — and no served document
126+
* carries the key, because its only writer was the retired overlay.
119127
*/
120128
export const CONSUMED_WIDGET_OPTION_KEYS: readonly string[] = [
121129
'dateGranularity',
122-
'description',
123130
'limit',
124131
'sortBy',
125132
'sortOrder',

0 commit comments

Comments
 (0)