Conversation
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>
…peater-row-property-localisation
Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 56 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 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): Which tree this was computed onThis run read A worktree cut from an older # 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
|
Fixes #17508
Clause-②: no — enumerating a repeater's row children in a
*.form.tsand 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 atype(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 Schemaitems.properties[k].titlewhen it declares none — andos i18n extractwalks a form field's declaredfields, so only a declared child ever gets ametadataForms.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:
labelequal to the item schema's own.meta({ title })so the extractor's English source and the schema title stay one string. Notypeon a row child: the row widgets stay schema-derived.en(generated from those labels) plus hand-authoredzh-CN/ja-JP/es-ESfor every leaf.packages/platform-objects/src/apps/translations/repeater-row-properties.test.ts, in the shapedashboard-header-children.test.tsestablished 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 itsensource.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 instrumentpackages/spec/src/kernel/repeater-item-titles.test.tsuses), atorigin/main=e3b3cdd2df3, 2026-09-20T11:12Z:The total is unchanged; the titled share moved, exactly as the three sibling cards landed since would predict — #17505 paid
dashboard:widgets(17) anddashboard:globalFilters(10), #17506 paidfield:optionsandobject:fields.options(6 each, oneSelectOptionSchema). 87 + 27 + 12 = 126, and 14 + 4 = 18.Of the 504, 12 leaves already existed (
dashboard:header.actionsfrom #17227, and four each onobject:fields.optionsandpage:variables), so 112 keys per locale are new — 448 new leaves, 336 of them hand-authored translations.Controls on the census, as required: lit —
dashboard:header.actionsresolves with exactly['label','actionUrl','actionType','icon'], all titled; dark —agent/tool/hook/positiondeclare 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.*andobjects.*leaf whose translated value differs from itsensource, 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 itsobject.fields.options.labeltwin already carries)Name→ 名称 / 名前 / Nombre (30 uses each) ·Type→ 类型 / 型 / Tipo (8) ·Description→ 描述 / 説明 / Descripción (18)Filter→ 筛选 / フィルター / Filtro ·Scope→ 范围 / スコープ / Ámbito ·Options→ 选项 / 選択肢 / OpcionesTimeout (ms)→ the existing 超时(毫秒) / タイムアウト(ms) / Tiempo de espera (ms) ·Input Schemafollows the existingOutput Schema→ 输入 Schema / 入力スキーマ / Esquema de entradafield.options.*mirrors itsobject.fields.options.*twin verbatim,Color de opciónincluded — the sameSelectOptionSchema, so the same wordshelpText: widget 组件 / ウィジェット / widget, dimension 维度 / ディメンション / dimensión, measure 度量 / メジャー / medida, node 节点 / ノード / nodo, edge 连线 / エッジ / conexión, region 区域 / リージョン / regiónTwo deliberate departures, both stated rather than hidden:
IDis qualified by its row, following the catalog's ownReference ID→ 引用 ID / 参照 ID / ID de referencia: 节点 ID / ノード ID / ID de nodo. An unqualifiedIDwould be byte-identical to itsensource in all three locales, which the pin's last test refuses — and rightly, since a column head readingIDin a Chinese panel is the untranslated state this card exists to end.Datasetis translated (数据集 / データセット / Conjunto de datos) although the catalogs carry it untranslated elsewhere. That existingDatasetis anen-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 emittedhumanizeFieldPath(path)as the English source, andresolveMetadataFormSchemaTitleswrote that text back over the item schema's authored title: the panel's column head readSourcein every locale whilePageVariableSchema.sourcedeclares.meta({ title: 'Written By' }). Two English names for one column, the unauthored one winning.The form now declares the label, so
enbecomesWritten Byand 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
view.columns/view.sort/view.tabs— untouched. The census surfaces all three (14 + 2 + 9 = 25 untitled row properties), and all three enumerate no children, so they contribute nothing to this diff. Their titling is [finding] Untitled repeater row properties:view.columns(14),view.tabs(9),view.sort(2) — BLOCKED on #17360 #17507's, which is open and unclaimed. The pin assertsviewis outside its population, so [finding] Untitled repeater row properties:view.columns(14),view.tabs(9),view.sort(2) — BLOCKED on #17360 #17507 will have to add the leaves with the titles — by design.retiredKey()tombstones — not titled. The census excludes any row property whose description opens[REMOVED], andflow.nodes[].outputSchemais the pinned dark control for that exclusion.object.fields.optionskeeps its curated four-key subset (defaultand the per-optionvisibleWhenCEL predicate are not added as inputs) — that subset is a declared entry inmetadata-form-zod-reconciliation.test.ts's ledger, and adding them would widen the offered authoring surface against a recorded decision.Verification
Base
origin/main81e12e186f3(merged in), head1cf7f2ae8ca. Exit codes captured before any pipe throughout.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 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 returnedPREREQUISITE NOT MET(exit 3, not a finding):check:i18nandcheck:dual-build-cjs-loadsboth 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).@objectstack/spec: 501 files / 14655 tests passed,typecheckclean.@objectstack/platform-objects: 41 files / 583 tests passed,typecheckclean.eslint . --no-inline-config --format jsonover eslint's own declared population of 6926 files at1cf7f2ae8ca, 0 errors, 0 warnings, exit 0.@objectstack/specis unaliased inpackages/platform-objects/vitest.config.ts, so the forms come fromdist/).zh-CNreport.blocks.chartback to itsensource. Result:1 failed | 7 passed, ontranslated locales carry their own text for every leaf, not a copy of the source. Restored;git status --porcelainempty.label: 'Operator'fromskill.form.ts'striggerConditionschild. Markerlabel: "Operator"inpackages/spec/dist: 6 files at baseline → 0 after the mutate build → 6 after the restore build. Mutated run2 failed | 6 passed, namingskill:triggerConditions.operatoron bothevery enumerated row child declares a labelanden: each leaf IS the form's declared label. Restored run8 passed,ablation-dist-preflightgreen on presence and on a clean tree, source blob back to itsHEADhash byte-for-byte.label: 'Operator'(single quotes, as the source has it) whiletsupemitslabel: "Operator"intodist— so the--absentpre-flight passed vacuously, on a marker that had never been indistat all. The run above is the re-take with the corrected anchor; the numbers quoted are that run's.*.form.ts, and none holds the translation catalogs. Firing control: the same map resolvespackages/spec/src/data/filter.zod.tsto feat(spec)!: publish the $-prefix key ban the normalized filter enforces, and make the ratchet able to see it #19335 andpackages/spec/src/shared/polarity-axes.tsto fix(spec): answer an axis-silent key with both ends of the range, not the cheaper-spelled one #19318, so it does seepackages/spec/srcholders. Dark control:packages/spec/src/zzz-no-such.zod.tsresolves 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--absentmode 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 printsrestore leg: ... 1 path still differs from HEADand 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 atitlethat is already there".setSchemaTitleAtPathinpackages/spec/src/system/i18n-resolver.tsreturns{ ...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'sfields.optionscomment reads "the offered inputs are exactlySelectOptionSchema's authorable keys, minusvisibleWhen".defaultis also not offered, so the sentence under-counts by one. The behaviour is correct and ledgered as asubsetinmetadata-form-zod-reconciliation.test.ts; only the comment's arithmetic is stale.Generated by Claude Code