feat(spec): give TranslationBundle a flows surface for screen-flow wizard copy (#7646) - #7763
feat(spec): give TranslationBundle a flows surface for screen-flow wizard copy (#7646)#7763os-zhuang wants to merge 2 commits into
flows surface for screen-flow wizard copy (#7646)#7763Conversation
…wizard copy (#7646) A `type: 'screen'` flow is a wizard the user reads — a heading and a list of labelled inputs — and the translation bundle had no group for any of it. Not a drifted key: no key. A translator had nowhere to put a screen title or a screen field label, and the strict shapes refused whatever group they invented, so a fully localized app still rendered its wizard in the source language. Adds `flows` to the shared translation shape, so it lands on both doors (the file-authored bundle and the `translation` metadata item) by construction: flows.<flow_name>.label flows.<flow_name>.screens.<node_id>.title flows.<flow_name>.screens.<node_id>.fields.<field_name>.{label,placeholder} Addressing is measured against what the runner already holds — `Flow.name`, `FlowNode.id` (forwarded verbatim as `ScreenSpec.nodeId`) and `ScreenFieldConfig.name` (forwarded as `ScreenFieldSpec.name`) — rather than invented, so the surface cannot be keyed by names nothing produces. The per-field key face is measured against `ScreenFieldConfigSchema`: `label` and `placeholder` are declared, `help` is not (the screen field has no help-shaped key, so it would validate and never render — the ADR-0078 shape #6080 kept out of the page-component face). `help` and `options` ride the unknown-key guidance instead. Runner chrome (Cancel/Submit) stays in the console's own message catalog, per the maintainer ruling on #7646. Spec half of a contract-first split: the `flows` liveness row is `planned` with an author warning, and flips to `live` when the objectui runner half lands. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HXyTbnqdXNR7cccv5jioAs
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 106 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 7 release-owned page(s) also reference the affected code. These are read-only:
|
…undary (#7646) The bundle's own reference page enumerates every translatable surface and keeps an honest-limits list; adding a group without touching it leaves the page describing a bundle that no longer exists. Adds the `flows` row to "What you can translate" and a boundary bullet saying what is true today: the group is declared and addressed the way the runner resolves it, no shipped runner reads it yet (the liveness row is `planned` and the compile lint warns), a screen field has no help text to translate, and runner chrome lives in the console's message catalog. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HXyTbnqdXNR7cccv5jioAs
os-zhuang
left a comment
There was a problem hiding this comment.
PM step-7 review (spec lane, session_01JY2Q5Xto1u8YHADgrZDTnk) — ACCEPT. (Recorded as a comment review: the shared bot identity cannot APPROVE its own PR.)
Direction matches the maintainer-accepted ruling on #7646 (2026-08-11, seat #6017 triage sweep): the bundle gains a flows group, and runner chrome (Cancel/Submit) stays in the console's own message catalog rather than per-app bundles.
What carried the review:
- Addressing is the runner's own —
Flow.name/FlowNode.id(client'sScreenSpec.nodeId) /ScreenFieldConfig.name, each verified against the declaring schema rather than mirrored from the report. The declared-but-unresolvable trap is tested, not just argued. helpcorrectly excluded:ScreenFieldConfigSchemahas nothing help-shaped, so it ridesguidanceinstead of becoming a slot that validates and never renders — consistent with #6080/ADR-0078. Theoptionsexclusion (unconstrainedoptions[].valuecan't anchor a value-keyed map) is likewise measured.- Both doors get the group by construction (shared
translationDataShape), and the metadata-item door is pinned in tests (#3778's lesson). - Liveness honesty:
planned+authorWarnrather thanlive— with per-child rows because the two halves resolve through different consumers and can flip on different days. - Hot-file discipline: deliberately leaving the
FLOW_SCREEN_COPY_KEYSresolver helper out because #7634 was in flight oni18n-resolver.tswas the right call; that helper travels with the objectui runner card when the group flips tolive.
Landing note (PM-handled, no dev action): this PR regenerates content/docs/references/**, an os-regen routed tree, so it lands via the one-at-a-time relay — it's queued behind #7758 and #7759. I'll run its sync lap and flip when its slot arrives.
Generated by Claude Code
Fixes #7646
A
type: 'screen'flow is a wizard the user reads — a heading, a list of labelled inputs — and the translation bundle had no group for any of it. Not a drifted key: no key. The bundle's surfaces were objects, apps, dashboards, pages, settings and metadata forms, so a translator had nowhere to put a screen title or a screen field label, and the strict shapes (correctly) refused whatever group they invented. HotCRM finished all four locales and retired its i18n exemption ledger, and itslead_conversionwizard still rendered "Conversion Details / Create Opportunity? / Opportunity Name" in English on a zh-CN console.The ruling this implements
Recorded on #7646 (2026-08-11), quoted verbatim from the issue comment:
The new group
Added to the shared
translationDataShape(), so it lands on both doors by construction — the file-authored bundle (TranslationDataSchema) and thetranslationmetadata item (TranslationItemSchema). That shared-shape placement is the thing #3778's item-only guard got wrong and #4001 fixed; a group added to one door only is the asymmetry this file keeps paying to remove.Key addressing — measured, with evidence
The PM assumption to falsify here was "which identifiers does the runner actually resolve against". Each level's key is an identifier a consumer already holds at render time, not a new naming scheme:
Flow.name(machine name,^[a-z_][a-z0-9_]*$)packages/spec/src/automation/flow.zod.ts:614FlowNode.idpackages/spec/src/automation/flow.zod.ts:263ScreenSpec.nodeId,packages/spec/src/contracts/automation-service.ts:138ScreenFieldConfig.namepackages/spec/src/automation/builtin-node-config.zod.ts:382ScreenFieldSpec.name,packages/spec/src/contracts/automation-service.ts:105nodeIdrather than a screen index or the node label: it is what correlates a resume back to its pause point, so it is the one screen identifier guaranteed stable and present client-side. An index renumbers whenever a step is inserted, and the nodelabelis itself display copy. A translation surface addressed by keys nothing produces is the declared-but-unresolvable trap this lane exists to close, so the addressing is pinned by a test that builds a realFlowSchemawith a screen node and derives every bundle key from the parsed flow.Screen
titleoverlaysconfig.title; a screen that declares none shows its nodelabel(ScreenConfigSchema.title— "falls back to the node label"), so this one key covers whichever of the two the runner ends up drawing. Same one-string-one-spelling rulepages.< name >.titlefollows overlabel.Per-field key face — measured against the screen-field schema
The ruling's list was label / placeholder / help. Two of the three are real; the third is not, and is refused rather than declared:
label,placeholder— declared.ScreenFieldConfigSchemadeclares both.help— NOT declared. That schema isname/label/type/required/options/defaultValue/placeholder/visibleWhen, with nothing help-shaped at all. Declaringhelpwould parse clean and translate nothing — the ADR-0078 shape 翻译契约 pages 无组件文案面:.strict() 四键且 title/subtitle 归 page:header,卡片/KPI 文案无键可写;dashboards.widgets 同按 id 定位却有 title/description 翻译位 #6080 deliberately kept out of the page-component face for exactly this reason. Unlike 翻译契约 pages 无组件文案面:.strict() 四键且 title/subtitle 归 page:header,卡片/KPI 文案无键可写;dashboards.widgets 同按 id 定位却有 title/description 翻译位 #6080'shelp, there is no honest key to alias it to either:placeholderis the in-input hint, not help text, so re-pointing it there would translate the wrong string. It rides the unknown-keyguidanceinstead, together withhelpText/hint/tooltip/description, and the rejection says the field has no such copy.options— also guidance, not a key.ScreenFieldConfig.options[].valueisz.unknown()(numbers and booleans are legal), so the value-keyed mapobjects.< object >.fields.< field >.optionsuses cannot address them unambiguously.Runner chrome is not here
Cancel/Submit are the console's own words in every app; putting them in a per-app bundle would ask every app to re-translate the platform. No key for them on any of the three levels, and a test pins that.
Family conventions mirrored
ObjectTranslationDataSchema.labelstates: partial translation is the normal state, and requiring completeness fills bundles with restated source strings that mask real coverage gaps. One locale of a bundle may carryflowswhile another does not.strictObjectat all three levels withsurface/history/aliases/guidance, so an unknown key is refused by name instead of stripped.label/titletrap alias one level down (a flow's headline islabel, a screen's istitle) — the same tabledashboards.widgetsandpages.componentscarry.flow→flowsis offered as the rename it is;screensis guidance, not an alias, because screen copy nests under the flow that owns it — the content moves, it is not re-spelled. That is theapp/appsdistinctionTRANSLATION_KEY_GUIDANCEwas built to draw.Liveness —
planned, notliveNo shipped screen-flow runner reads this group yet, so the new
flowsrow inpackages/spec/liveness/translation.jsonisplannedwithauthorWarn+authorHint, drilled one level overlabel/screens. Precedent:app.navigation.runAction, the contract-first half of the SDUI deep-link promotion, which isplannedfor the same reason and flips tolivewhen its objectui consumer lands.plannedrather thandeadis load-bearing —deadmeans declared with no consumer and no plan, and this group was ruled into the vocabulary specifically so the runner half could be built against it.Scope boundary held
The resolver-side helper — a
FLOW_SCREEN_COPY_KEYSsibling ofPAGE_COMPONENT_COPY_KEYS, which lives inpackages/spec/src/system/i18n-resolver.ts— is deliberately not in this change: #7634 is in flight on that file, and this card is the schema surface. Recorded as a follow-up rather than collided with. Likewisepackages/lint/src/validate-translation-references.ts(which skips unrecognised top-level namespaces, soflowsis inert there rather than broken) and the CLI extract/coverage utilities.Changeset
@objectstack/spec: minor. The accepted authoring surface widens — a bundle previously rejected for carryingflowsnow parses — which is an additive vocabulary change, not a fix to existing behaviour and not a break.Verification
Reverse-verified via a patch file (never
git stash): predicted direction plain red, since the surface is new and its pins address it directly. Removing the schema hunk turned 11 of the block's tests red — the fully-populated parse, the partial-locale parse, the addressing pin, the alias/guidance pins, the JSON-Schema describe pin and the item-door pin — and restoring it returned 110/110.Generated closure committed:
authorable-surface/system.json,liveness/state-counts.md,content/docs/references/system/translation.mdx, the strictness-ledger counts.Generated by Claude Code