Skip to content

feat(spec): a repeater property panel names its columns in every locale, not only English - #19345

Draft
os-litant wants to merge 5 commits into
mainfrom
claude/issue-17508-repeater-row-property-localisation
Draft

os-litant wants to merge 5 commits into
mainfrom
claude/issue-17508-repeater-row-property-localisation

Conversation

@os-litant

Copy link
Copy Markdown
Collaborator

Fixes #17508

Clause-②: no — enumerating a repeater's row children in a *.form.ts and adding catalog leaves adds no key to a published payload and moves no accept set; the same author input parses identically before and after. Verified against the diff: no schema file is touched, no .describe() or .meta() moves, and no row child declares a type (so no new form input is offered for anything).

What this lands

Studio renders a type: 'repeater' as a table. Its column names come from the form's declared row children when the form declares any, and from the served JSON Schema items.properties[k].title when it declares none — and os i18n extract walks a form field's declared fields, so only a declared child ever gets a metadataForms.TYPE.fields['PATH.PROP'] key. #17232 (PR #17500) authored the English .meta({ title }) on thirteen item schemas and #17505 / #17506 on four more, but none of them had a catalog channel: every one of those column heads reached a Chinese, Japanese or Spanish author in English.

Both halves land together, as the ruling on this card requires:

  1. The form-child enumeration — 112 row properties across fifteen repeaters, each label equal to the item schema's own .meta({ title }) so the extractor's English source and the schema title stay one string. No type on a row child: the row widgets stay schema-derived.
  2. All four catalogsen (generated from those labels) plus hand-authored zh-CN / ja-JP / es-ES for every leaf.
  3. The pin, packages/platform-objects/src/apps/translations/repeater-row-properties.test.ts, in the shape dashboard-header-children.test.ts established for feat(spec): item-level property names for the dashboard header-action repeater #17227, including its last test: a translated leaf is never a copy of its en source.

The re-derived population

The card's figures are a 2026-09-10 reading at 76ddab772f. Re-derived on this branch's base through the platform's own predicate (z.toJSONSchema(getMetadataTypeSchema(type), { unrepresentable: 'any', io: 'input' }), the same instrument packages/spec/src/kernel/repeater-item-titles.test.ts uses), at origin/main = e3b3cdd2df3, 2026-09-20T11:12Z:

card, 2026-09-10 measured, 2026-09-20
catalog leaves for full localisation 604 604 (151 row properties x 4)
leaves belonging to titled properties 348 504 (126 x 4)
row properties titled 87 126
carriers fully titled 14 18

The total is unchanged; the titled share moved, exactly as the three sibling cards landed since would predict — #17505 paid dashboard:widgets (17) and dashboard:globalFilters (10), #17506 paid field:options and object:fields.options (6 each, one SelectOptionSchema). 87 + 27 + 12 = 126, and 14 + 4 = 18.

Of the 504, 12 leaves already existed (dashboard:header.actions from #17227, and four each on object:fields.options and page:variables), so 112 keys per locale are new — 448 new leaves, 336 of them hand-authored translations.

Controls on the census, as required: litdashboard:header.actions resolves with exactly ['label','actionUrl','actionType','icon'], all titled; darkagent / tool / hook / position declare no repeater at all and contribute nothing, and a fabricated carrier id resolves to nothing. Both are asserted in the pin, not only measured by hand.

Translation quality, and how it was checked per locale

A glossary was built mechanically from the pre-change catalogs: every metadataForms.* and objects.* leaf whose translated value differs from its en source, indexed by the English string — 1342 distinct English strings with at least one translation. Every new label was then looked up in it, and the catalog's dominant existing rendering reused where one existed:

  • Label → 显示名称 / 表示名 / Etiqueta (18 / 19 / 20 existing uses, and the exact values its object.fields.options.label twin already carries)
  • Name → 名称 / 名前 / Nombre (30 uses each) · Type → 类型 / 型 / Tipo (8) · Description → 描述 / 説明 / Descripción (18)
  • Filter → 筛选 / フィルター / Filtro · Scope → 范围 / スコープ / Ámbito · Options → 选项 / 選択肢 / Opciones
  • Timeout (ms) → the existing 超时(毫秒) / タイムアウト(ms) / Tiempo de espera (ms) · Input Schema follows the existing Output Schema → 输入 Schema / 入力スキーマ / Esquema de entrada
  • field.options.* mirrors its object.fields.options.* twin verbatim, Color de opción included — the same SelectOptionSchema, so the same words
  • domain nouns come from the carriers' own already-translated helpText: widget 组件 / ウィジェット / widget, dimension 维度 / ディメンション / dimensión, measure 度量 / メジャー / medida, node 节点 / ノード / nodo, edge 连线 / エッジ / conexión, region 区域 / リージョン / región

Two deliberate departures, both stated rather than hidden:

  • A bare ID is qualified by its row, following the catalog's own Reference ID → 引用 ID / 参照 ID / ID de referencia: 节点 ID / ノード ID / ID de nodo. An unqualified ID would be byte-identical to its en source in all three locales, which the pin's last test refuses — and rightly, since a column head reading ID in a Chinese panel is the untranslated state this card exists to end.
  • Dataset is translated (数据集 / データセット / Conjunto de datos) although the catalogs carry it untranslated elsewhere. That existing Dataset is an en-echo — untranslated debt, not a chosen loanword — so this introduces a term for a concept the catalogs do not yet name rather than a second term for one they do.

The one existing string that moves

page.variables' children were enumerated (by #3786's round) without labels. The extractor therefore emitted humanizeFieldPath(path) as the English source, and resolveMetadataFormSchemaTitles wrote that text back over the item schema's authored title: the panel's column head read Source in every locale while PageVariableSchema.source declares .meta({ title: 'Written By' }). Two English names for one column, the unauthored one winning.

The form now declares the label, so en becomes Written By and the three translations are re-authored with it (写入组件 / 書き込み元 / Escrito por). That is the only pre-existing leaf this PR changes; everything else is additive. The pin's new every-row-child-declares-a-label test is what makes the class impossible to re-enter.

Fenced out, and seen

Verification

Base origin/main 81e12e186f3 (merged in), head 1cf7f2ae8ca. Exit codes captured before any pipe throughout.

  • Gate familiesnode scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 83 commands on this tree; all 83 run with exit codes recorded and reconciled: 83 derived, 83 run, 0 NOT-MEASURED, 0 UNRUN — "a DERIVED zero — all 83 recorded an exit code and none of them is 3". Two first returned PREREQUISITE NOT MET (exit 3, not a finding): check:i18n and check:dual-build-cjs-loads both needed built output. Both re-run green after a full build — check-i18n-bundles: OK (9 package(s) — all bundles in sync, no undeclared authoring keys).
  • Tests@objectstack/spec: 501 files / 14655 tests passed, typecheck clean. @objectstack/platform-objects: 41 files / 583 tests passed, typecheck clean.
  • Repo-wide lint — not narrowed: eslint . --no-inline-config --format json over eslint's own declared population of 6926 files at 1cf7f2ae8ca, 0 errors, 0 warnings, exit 0.
  • Ablation — the pin can fail, twice, on both of its load-bearing halves. Both legs rebuilt and verified against the artifact the suite actually resolves (@objectstack/spec is unaliased in packages/platform-objects/vitest.config.ts, so the forms come from dist/).
    • Catalog leg (no build needed — the catalogs are source to this suite): set zh-CN report.blocks.chart back to its en source. Result: 1 failed | 7 passed, on translated locales carry their own text for every leaf, not a copy of the source. Restored; git status --porcelain empty.
    • Form leg: dropped label: 'Operator' from skill.form.ts's triggerConditions child. Marker label: "Operator" in packages/spec/dist: 6 files at baseline → 0 after the mutate build → 6 after the restore build. Mutated run 2 failed | 6 passed, naming skill:triggerConditions.operator on both every enumerated row child declares a label and en: each leaf IS the form's declared label. Restored run 8 passed, ablation-dist-preflight green on presence and on a clean tree, source blob back to its HEAD hash byte-for-byte.
    • ⚠️ The first form-leg attempt was a void reading and is reported as such. Its marker was spelled label: 'Operator' (single quotes, as the source has it) while tsup emits label: "Operator" into dist — so the --absent pre-flight passed vacuously, on a marker that had never been in dist at all. The run above is the re-take with the corrected anchor; the numbers quoted are that run's.
  • Serial constraints re-taken first-hand at 2026-09-20T12:17Z, not inherited: the changed-file page of all 35 open PRs, 281 file rows. No open PR holds any *.form.ts, and none holds the translation catalogs. Firing control: the same map resolves packages/spec/src/data/filter.zod.ts to feat(spec)!: publish the $-prefix key ban the normalized filter enforces, and make the ratchet able to see it #19335 and packages/spec/src/shared/polarity-axes.ts to fix(spec): answer an axis-silent key with both ends of the range, not the cheaper-spelled one #19318, so it does see packages/spec/src holders. Dark control: packages/spec/src/zzz-no-such.zod.ts resolves to nothing.

Acceptance notes

Seen while working here, left alone — none is in this card's scope and none is filed:

  • scripts/ablation-dist-preflight.mjs's --absent mode conflates two independent questions in one exit code. Its own header says --absent "is two things at once: the mode for a DELETE ablation, and the restore leg of a PLANT one", but its tree limb hard-codes the restore-leg reading — it prints restore leg: ... 1 path still differs from HEAD and exits 1 whenever the working tree is dirty. A DELETE ablation's mutate leg is necessarily dirty (the mutation is the dirt), so that shape can never pass its own pre-flight, and the failure text instructs the author to restore the very mutation being measured. Reproduced here: a driver that trusted the exit code aborted the ablation at exit 93 with the dist assertion already printed green above it. This is a reproducible defect in an instrument with a named repro, so it is proposed for a card in the report rather than fixed here.
  • packages/spec/src/kernel/repeater-item-titles.test.ts's header states the bundle overlay "only ever REPLACES a title that is already there". setSchemaTitleAtPath in packages/spec/src/system/i18n-resolver.ts returns { ...node, title } unconditionally, so it also creates one. The claim is inert for that file's verdicts (an untitled property with no catalog leaf is untouched either way), so this is a prose inaccuracy, not a defect.
  • object.form.ts's fields.options comment reads "the offered inputs are exactly SelectOptionSchema's authorable keys, minus visibleWhen". default is also not offered, so the sentence under-counts by one. The behaviour is correct and ledgered as a subset in metadata-form-zod-reconciliation.test.ts; only the comment's arithmetic is stale.

Generated by Claude Code

Studio renders a `type: 'repeater'` as a table whose column names come from
the form's declared row children when there are any, and from the JSON Schema
`items.properties[k].title` otherwise. `os i18n extract` only emits a
`metadataForms.<type>.fields['<path>.<prop>']` key for a DECLARED child, so an
un-enumerated repeater had no catalog channel at all: its column heads read
the English `.meta({ title })` in every locale.

Enumerate all 112 row properties of the fifteen repeaters that declared none,
each `label` equal to the item schema's own title so the extractor's English
source and the schema title stay one string. No `type` on a row child — the
row widgets stay schema-derived. `page.variables` and `object.fields.options`
already enumerated their children without labels, which let the extractor's
humanized fallback override the authored title (`variables.source` read
"Source" against the schema's "Written By"); both now carry explicit labels.

`view.columns` / `view.sort` / `view.tabs` are untitled and belong to #17507.
`object.fields.options` stays the curated four-key subset its #3786 ledger
entry declares.

Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho
Co-authored-by: Claude <noreply@anthropic.com>
…n all four catalogs

`os i18n extract` now emits a `metadataForms.<type>.fields['<path>.<prop>']`
key for each of the 112 row properties the forms enumerate, and this lands the
`en` source plus hand-authored `zh-CN` / `ja-JP` / `es-ES` copy for every one
of them — the Studio property panel's repeater column heads read in the
author's own language instead of English in every locale.

Each term reuses the word the catalogs already use for the concept: `Label` is
the dominant 显示名称 / 表示名 / Etiqueta, `Filter` 筛选 / フィルター / Filtro,
`Widget` 组件 / ウィジェット / Widget, `Timeout (ms)` the existing
超时(毫秒)/ タイムアウト(ms)/ Tiempo de espera (ms), and `field.options.*`
mirrors its `object.fields.options.*` twin verbatim, `Color de opción`
included. A bare `ID` is qualified by its row the way `Reference ID` already
is (节点 ID / ノード ID / ID de nodo).

`page.variables.source`'s English source moves from the extractor's humanized
"Source" to the schema's authored "Written By", so its three translations are
re-authored with it; every other leaf here is new.

The pin is `repeater-row-properties.test.ts`, in the shape
`dashboard-header-children.test.ts` established for #17227 and with its last
test: a translated leaf is never a copy of its `en` source. Its population is
derived from the forms, so a repeater enumerated later is red on the day it
lands.

Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/platform-objects, @objectstack/spec, touching 130 documentable anchor(s).

56 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 13d52947d81aca235133ee9619d80723a7c63348.

11 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see

Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 13d52947d81aca235133ee9619d80723a7c63348packageMentionDocs.

Which tree this was computed on

This run read content/docs from 5b815bb980deee0b62bf7588af5a42bad89a3987 — the merge of head 74dcce59635a0b7d0f1f5b487a0e8dc0bd806da1 into base 13d52947d81aca235133ee9619d80723a7c63348, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 5b815bb980deee0b62bf7588af5a42bad89a3987 && git checkout 5b815bb980deee0b62bf7588af5a42bad89a3987
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 13d52947d81aca235133ee9619d80723a7c63348 74dcce59635a0b7d0f1f5b487a0e8dc0bd806da1 && git checkout -B drift-repro 13d52947d81aca235133ee9619d80723a7c63348 && git merge --no-ff 74dcce59635a0b7d0f1f5b487a0e8dc0bd806da1

node scripts/docs-audit/affected-docs.mjs --json 13d52947d81aca235133ee9619d80723a7c63348

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 13d52947d81aca235133ee9619d80723a7c63348 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Decision] Repeater row-property localisation: 604 catalog leaves across four locales — who authors the translated copy?

2 participants