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 30d21f6
Browse filesBrowse the repository at this point in the historyBrowse files
feat(spec,lint,cli): a page:tabs item label gains the key pages.PAGE.components.TABS.items.VALUE.label (#25005)
Closes#24968
Clause-②: yes (widening)
## What changes
A `page:tabs` item's `label` had no translation key. `TranslationBundle`
`pages.PAGE.components` is keyed by component id. A tab item is not a
component and carries no id. Spec `translatePage` descended
`items[].children` but never read `items[].label`. HotCRM measured this
on `sales_home_page`: three English tabs under a translated header, in
zh-CN, ja-JP and es-ES.
- **Spec** (`packages/spec/src/system/translation.zod.ts`):
`pages.PAGE.components.TABS_ID.items.VALUE.label` is a declared key. The
component entry gains `items`, a record keyed by the tab item's `value`.
Each entry is closed (`strictObject`, surface "this page tab item
translation"). `title`, `name` and `text` are renames onto `label`. (The
`tabs` alias onto `items` was dropped in `f18a2f55dd`.)
- **Resolver** (`packages/spec/src/system/i18n-resolver.ts`):
`translatePage` overlays each item's `label` from the key. It resolves
across the locale chain item by item, under the same `addressed`
collision arbitration as the component copy. Only `page:tabs` components
are read. Two new exports are shared by the resolver, the extractor and
the lint check, so the three cannot drift: `PAGE_TABS_COMPONENT_TYPE`
and `pageTabItemKey(item)`.
- **Walk** (`walkAddressedPageComponents`): the walk used to rebuild a
panel list from the authored node, so a visitor's edit to a panel's own
key (its `label`) was overwritten. A panel's own keys now come from the
visitor's return when the visitor keeps the list's authored shape (same
length, an object at every panel position). Panel `children` are still
descended from the authored document, so the walk still owns the
descent. A visitor that changes the shape gets the authored list back.
The docblocks state this, and a new walk test pins it.
- **`objectstack validate`**
(`packages/lint/src/validate-translation-references.ts`): the check
builds a per-page universe with spec's `walkAddressedPageComponents`,
the same walk `translatePage` runs. A `components.ID.items.VALUE` key
resolves over a declared `page:tabs` item `value`. Otherwise it is
refused as `translation-target-unknown`, in the existing one-line
verdict style:
- an unknown value (an index token such as `tab-2` included): "...which
`page:tabs` "home_tabs" on page "sales_home_page" gives no item as its
`value`, so the tab keeps its source-locale label.", followed by "Did
you mean";
- a `page:accordion`: "...whose panels have no stable key, so nothing
resolves them.", with a fix line pointing at the inline locale map;
- another component type: "...declares as a `record:related_list`, not a
`page:tabs`...";
- an undeclared component id: "...a component id page "P" does not
declare...", followed by "Did you mean" over the page's tabs components.
- **`os i18n extract` / coverage**
(`packages/cli/src/utils/i18n-extract.ts`): `emitPageComponentCopy`
writes a slot for every valued item of an addressed `page:tabs`. A
reader exists (`translatePage`), so the slot is demanded, which differs
from #24943. An inline locale map on the label is recorded as authored,
through the same `pushEntry` path as the component keys.
## Why this spelling (shape (a) over (b))
- **Keyed by `value`, under the tabs component's id.**
`PageTabsProps.items[]` already declares the identifier the key needs.
`value` is the stable `?tab=` token that the renderer reads and
restores, so it is unique within the strip. The #14749 ruling (Q3 = C3,
quoted in the extractor's header) recorded the direction "identity
first: `component.id` / `section.name` / `tabs item.value`". Adding an
`id` to tab items (shape (b)) would give one tab two identifiers. It
would also make `components.ID` address something that is not a
component, and it would need a new authorable key on a published schema.
Shape (a) adds no authorable page key and reuses the grammar the face
already has, the same way `dashboards.D.widgets.ID` and
`flows.F.screens.NODE` key a child by the identifier its consumer
already holds.
- **An item without a `value` has no key.** Its renderer token is
`tab-INDEX`. The #14749 ruling forbids array-index keys, because
reordering two tabs would swap their translations with every check still
green. The lint verdict names the remedy: give the item a `value`.
- **Inline locale maps keep working.** `translatePage` writes only where
the bundle has an entry. An item the bundle does not key keeps its map,
and a bundle entry wins where both exist. This is the rule the component
copy already follows.
- **Accordion is out on purpose.** `page:accordion` items have no
author-settable identity (the renderer derives `panel-INDEX`, and
`value` is refused on them with guidance), so their labels stay an
inline-map surface.
## Tests
- **Repro first**: commit `757aadb573` adds the tests before the fix. At
that commit, on a rebuilt spec dist, the spec suite had 9 failing cases
(4 schema, 4 resolver, 1 walk), the lint suite had 7 (5 refusal cases
plus the two one-line arm coverage checks), and the new CLI file did not
load, because the closure was not built yet.
- **Flipped pin**: the `BOUNDARY` case in `i18n-resolver.test.ts` ("a
tab panel label has no bundle key") said "If a key is added, this pin is
the one to flip". It is flipped: its inline-map half is kept as a
CONTROL, and the new `translatePage — page:tabs item labels` block pins
the key.
- **Controls**: panel children keep their own `components.ID` keys; the
tabs component's own `label` key composes with its item keys; the same
`items` key on a `page:accordion` translates nothing; an item without a
`value` is untouched even if `tab-2` is keyed; the locale-chain fallback
works key by key; the component keys of the page validate clean beside
an item key; a page the stack does not declare is not judged.
- **Package runs** (merged head `c8a248af73`, local base `origin/main`
`7adfaec2a1`):
- `pnpm --filter @objectstack/spec test`: 703 files, 21377 passed, 1
todo.
- `pnpm --filter @objectstack/lint test`: 152 files, 7033 passed.
- `pnpm --filter @objectstack/cli exec vitest run --project unit`: 316
files, 4629 passed, 29 skipped. Two published-subpath pins first refused
because `packages/cli` itself was not built. That is a prerequisite
refusal, not a measurement. After building the CLI they passed, with the
new file: 3 files, 37 passed. The `integration` tier is declared to CI,
since the diff touches no spawn entry.
- `typecheck` for spec, lint and cli: exit 0 (each runs `tsc --noEmit`
and `check:test-typecheck`).
- **Generated artifacts**: `pnpm --filter @objectstack/spec
check:generated --fix` regenerated `api-surface/system.json` and
`export-origins/system.json` (the two new exports) and the
strictness-ledger counts (`system.md`). The reference doc
`translation.mdx` is unchanged, because its `components` cell is
truncated with an ellipsis before `items`.
- **Reverse verification** (after the fix was committed). Each mutation
went through `scripts/ablation-replace.mjs`: the anchor hit once, and
the restore was proven by `blob == HEAD` with an empty `git diff HEAD`.
Each suite reads `src`.
- A1, the `translatePageTabItems` call removed: spec resolver suite `4
failed | 363 passed`.
- A2, the walk forced back to the authored panel keys: `5 failed | 362
passed` (the 4 above plus the walk pin).
- A3, lint accepting every value: `4 failed | 190 passed` (the two value
refusals and the two one-line arm-coverage checks).
- A4, the extractor's item slot removed: the CLI file `4 failed | 4
passed`.
- **Derived gates** (`dispatch-gates --commands`, reconciled with
`--ran` with exit codes recorded): 94 derived, 93 run, all exit 0.
`check:i18n`, `check:i18n-coverage` and `check:i18n-walk-parity` first
refused with exit 3 (the CLI was not built). After the build they were
re-run and exited 0: `check:i18n` reports 10 packages in sync,
`check:i18n-coverage` reports 14 configs with none new, and
`check:i18n-walk-parity` reports 11 declared groups, 9 walked and 2
exempted. NOT MEASURED: `check:dual-build-cjs-loads`, reason:
PREREQUISITE NOT MET (exit 3), because packages this diff does not touch
have no `dist` without a whole-repo `pnpm build`. Declared to CI.
`check:nul-bytes`: exit 0.
- **eslint, narrowed to the 8 touched source and test files**
(`--no-inline-config --format json`, at `c8a248af73`): 8 files, 0
errors, 0 warnings. The config declares no type-aware linting
(`eslint.config.mjs`: no `parserOptions.project`, no typed rules), so
this diff cannot change the verdict on any untouched file.
## Review tier
`packages/spec` is a published schema, so this PR owes a
`CONTRACT_REVIEW_TIER` review of record before it enters the queue
(pm-dispatch `references/contract-review.md`). It touches no governed
surface (`.claude/**`, `docs/adr/**`, `AGENTS.md`, workflows,
`skills/**`), so no Tier S or Tier H landing rule applies.
## Liveness
`packages/spec/liveness/translation.json`'s `pages` row is `live` and
not drilled, so its blanket verdict covers the new member. The reader is
`translatePage`, which the REST metadata read applies through
`translateMetadataDocument`. objectui's `page:tabs` renders a string
label as is.
## Acceptance notes
- `objectstack validate` still judges no other `pages.PAGE` member. An
unknown page name, an unknown component id carrying only copy keys, and
an unknown copy target are not checked by this rule today. This PR
judges only the `items` member it adds, and skips pages the stack does
not declare.
- `page:accordion` panel labels have no bundle key (no stable identity).
This is deliberate and recorded in the schema docblock.
- `#24947` (this seat's open PR) also touches `i18n-extract.ts`, in a
different function (bulk-confirm template slots). No textual overlap is
expected.
- The branch carries a `wip:` commit (`818b89e351`) between the repro
and the fix commit. This PR is squash-landed, so it does not reach
`main`. I did not rewrite the history.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
feat(spec): a `page:tabs` item label gains a translation key, `pages.<page>.components.<tabsId>.items.<value>.label`
8
+
9
+
Clause-②: yes (widening)
10
+
11
+
-**Before.**`pages.<page>.components` is keyed by component id, and a `page:tabs` item is not a component and carries no id, so a tab label had no key. `items` was refused as an unknown key, and `translatePage` never read `items[].label`. A localized page kept its tab strip in the authoring language.
12
+
-**Now.**`pages.<page>.components.<tabsId>.items.<value>.label` is a declared key. It is addressed by the tabs component's own `id` and the item's `value` (the stable `?tab=` token), and `translatePage` overlays it onto that item's `label`, across the locale chain. The entry is closed: `title`, `name` and `text` are renames onto `label`. An item without a `value` has no key, because its renderer token is index-derived and an array index is never a bundle key. Author a `value` on the item, or keep localizing that label with an inline locale map.
13
+
-**`walkAddressedPageComponents`.** A visitor's edit to a panel's own keys (its `label`) now stands when the visitor keeps the panel list's authored shape. Panel children are still descended from the authored document.
14
+
-**`objectstack validate`.**`@objectstack/lint`'s translation-reference check accepts the key over a declared `page:tabs` item `value`. It refuses it as `translation-target-unknown` over an unknown value (with "Did you mean"), over a component of another type (a `page:accordion` is pointed at the inline locale map, since its panels have no stable key), and under a component id the page does not declare.
15
+
-**`os i18n extract` / coverage.** The extractor writes a slot for every valued tab item, and the coverage check demands it. An inline locale map on the item label is counted as authored, as before.
16
+
-**Unchanged.** Every existing `pages` key, and an inline locale map on a tab label that the bundle does not key.
Copy file name to clipboardExpand all lines: content/docs/ui/translations.mdx
+1Lines changed: 1 addition & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -80,6 +80,7 @@ export default defineStack({
80
80
| Dataset dimension and measure labels |`datasets.<name>.dimensions.<dimension>.label` / `datasets.<name>.measures.<measure>.label`|
81
81
| Page labels and `page:header` copy |`pages.<name>.label` / `description` / `title` / `subtitle` — on a `kind: 'slotted'` page the header under `slots.header` is the page's header |
82
82
| Page component copy, by component id |`pages.<name>.components.<id>.title` / `description` / `label` / `placeholder` / `emptyText` — reached under `regions[].components[]` and `slots.<slot>`, through `properties.children` and a `page:tabs` / `page:accordion` panel's `items[].children`|
83
+
| Page tab labels (`page:tabs` items) |`pages.<name>.components.<tabsId>.items.<value>.label` — keyed by the tabs component's `id` and the item's `value`; an item without a `value` has no key (localize it with an inline locale map), and `page:accordion` panels have none |
83
84
| Screen-flow wizards (flow label, screen headings and body text, screen field copy) |`flows.<flow>.label` / `flows.<flow>.screens.<node_id>.title` / `.description` / `.fields.<field>.label` / `.placeholder` / `.inlineHelpText` — a screen field's help text is `inlineHelpText`, the key the field itself declares, not `help`; see the boundary note below |
84
85
| Global actions, messages |`globalActions`, `messages`|
85
86
| Settings UI shell copy (the source badge on a settings row) |`settingsCommon.sourceLabels.<layer>` — the per-namespace settings copy under `settings` is **platform-only**: an app bundle carrying it is refused by name, and the platform's own strings are translated in `@objectstack/service-settings`'s bundle |
0 commit comments