5151 * pages.<page>.label / .description
5252 * pages.<page>.title / .subtitle (from the page's `page:header` component)
5353 * pages.<page>.components.<id>.<key> (per-component copy, #6080)
54+ * flows.<flow>.label
55+ * flows.<flow>.screens.<node_id>.title (#7646 / #11287)
56+ * flows.<flow>.screens.<node_id>.fields.<field>.label
57+ * flows.<flow>.screens.<node_id>.fields.<field>.placeholder
5458 * metadataForms.<type>.label / .description
5559 * metadataForms.<type>.sections.<section>.label / .description
5660 * metadataForms.<type>.fields.<dotPath>.label / .helpText / .placeholder
6569 */
6670
6771import type { TranslationBundle , TranslationData } from '@objectstack/spec/system' ;
68- import { METADATA_FORM_REGISTRY , PAGE_COMPONENT_COPY_KEYS } from '@objectstack/spec/system' ;
72+ import {
73+ METADATA_FORM_REGISTRY ,
74+ PAGE_COMPONENT_COPY_KEYS ,
75+ FLOW_SCREEN_COPY_KEYS ,
76+ FLOW_SCREEN_FIELD_COPY_KEYS ,
77+ } from '@objectstack/spec/system' ;
6978import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel' ;
7079import { deriveFieldGroupLayout } from '@objectstack/spec/data' ;
7180import { expandViewContainer } from '@objectstack/spec/ui' ;
@@ -110,6 +119,7 @@ export interface ExpectedEntry {
110119 | 'dashboard'
111120 | 'widget'
112121 | 'page'
122+ | 'flow'
113123 | 'metadataType'
114124 | 'metadataFormSection'
115125 | 'metadataFormField' ;
@@ -119,6 +129,8 @@ export interface ExpectedEntry {
119129 appName ?: string ;
120130 /** Metadata type name when applicable (for `--filter` matching). */
121131 metadataType ?: string ;
132+ /** Flow name when applicable (for `--filter` matching). */
133+ flowName ?: string ;
122134}
123135
124136export type FillStrategy = 'empty' | 'default' | 'todo' ;
@@ -249,7 +261,7 @@ function pushViewEmptyState(out: ExpectedEntry[], viewPath: string[], view: any,
249261 }
250262}
251263
252- type EntryScope = Pick < ExpectedEntry , 'objectName' | 'appName' | 'metadataType' > ;
264+ type EntryScope = Pick < ExpectedEntry , 'objectName' | 'appName' | 'metadataType' | 'flowName' > ;
253265
254266/** Narrow to a usable source string; an empty string is not authored text. */
255267function inlineText ( value : unknown ) : string | undefined {
@@ -873,6 +885,9 @@ export function collectExpectedEntries(config: any): ExpectedEntry[] {
873885 }
874886 }
875887
888+ // ── Screen flows (`flows.<flow>.screens.<node>.…`, #7646 / #11287) ─
889+ walkScreenFlows ( config , out ) ;
890+
876891 // ── Object sections (fieldGroups + authored form/page sections) ───
877892 // Deliberately a pass of its own: the two authoring surfaces live in
878893 // `objects`, `views` and `pages`, and one section may be declared by more
@@ -893,6 +908,105 @@ export function collectExpectedEntries(config: any): ExpectedEntry[] {
893908 return out ;
894909}
895910
911+ // ─── Screen flows (`flows.<flow>.screens.<node_id>.…`) ─────────────────
912+
913+ /**
914+ * The one flow-node type whose copy the bundle addresses. Spelled once here
915+ * rather than at each guard; the resolver's own constant is module-private,
916+ * and `translateScreenNode` filters on exactly this value.
917+ */
918+ const SCREEN_NODE_TYPE = 'screen' ;
919+
920+ /**
921+ * Emit the screen-flow copy surface (#7646, resolver landed in #11287).
922+ *
923+ * **The hole this closes.** A `type: 'screen'` flow is a wizard the user
924+ * reads — a heading and a list of labelled inputs — and this walker had no
925+ * pass for it, so `os lint` could not report a screen-flow copy gap and
926+ * `os i18n extract` never scaffolded the keys. HotCRM measured
927+ * `0 i18n/missing-*` on a tree whose six screen dialogs rendered English in
928+ * all four locales: the gate was green because the surface was invisible to
929+ * it, not because the app was translated.
930+ *
931+ * **The key face is IMPORTED, never restated.** {@link FLOW_SCREEN_COPY_KEYS}
932+ * and {@link FLOW_SCREEN_FIELD_COPY_KEYS} are exported by
933+ * `@objectstack/spec/system` precisely so this scaffolder and the resolver
934+ * that reads the bundle cannot drift — a local copy, however correct on the
935+ * day it is written, is the drift the export exists to make impossible. The
936+ * schema↔list agreement is pinned spec-side in `translation.test.ts`.
937+ *
938+ * Addressing (`translation.zod.ts`, `flows`): flow by `Flow.name`, screen by
939+ * `FlowNode.id` (the client's `ScreenSpec.nodeId`), field by
940+ * `ScreenFieldConfig.name` — every level an identifier some consumer already
941+ * holds at render time.
942+ *
943+ * Two seeding rules worth stating, both measured against what the reader sees
944+ * rather than against which key the author happened to fill in:
945+ *
946+ * - **A screen's `title` falls back to the node `label`.** The executor builds
947+ * the wire title as `config.title ?? node.label` (`ScreenSpec.title`), and
948+ * `translateFlow` overlays the bundle onto `config.title` for that reason —
949+ * one key covers whichever of the two the runner draws. So the seed, and the
950+ * `inline` the coverage gate judges, is that same pair: a screen with only a
951+ * canvas label still shows English text a translator owes a translation for.
952+ * - **A field's `label` falls back to its `name`.** `ScreenFieldConfig.label`
953+ * is optional and forwarded as-is (`ScreenFieldSpec.label`), so the runner
954+ * renders the field name when the author wrote no label. That is a derived
955+ * fallback nobody authored — {@link pushDerived}, so the skeleton stays
956+ * usable while the gate demands no translation of a string that does not
957+ * exist.
958+ *
959+ * A screen node whose `waitForInput` is `false` is deliberately NOT skipped:
960+ * `translateFlow` overlays every screen node, and a walker that skipped one
961+ * would re-open the extractable-but-ungated gap in miniature.
962+ */
963+ function walkScreenFlows ( config : any , out : ExpectedEntry [ ] ) : void {
964+ const flows : any [ ] = Array . isArray ( config ?. flows ) ? config . flows : [ ] ;
965+ for ( const flow of flows ) {
966+ const flowName = typeof flow ?. name === 'string' && flow . name . length > 0 ? flow . name : undefined ;
967+ if ( ! flowName ) continue ;
968+ const scope : EntryScope = { flowName } ;
969+
970+ // `flows.<flow>.label` — `lookupFlowLabel`'s key. `Flow.label` is required
971+ // by the schema, so this is authored text in practice; `pushOptional`
972+ // keeps a label-less flow from seeding an empty string anyway.
973+ pushOptional ( out , [ 'flows' , flowName , 'label' ] , flow . label , 'flow' , scope ) ;
974+
975+ const nodes : any [ ] = Array . isArray ( flow . nodes ) ? flow . nodes : [ ] ;
976+ for ( const node of nodes ) {
977+ if ( ! node || typeof node !== 'object' || node . type !== SCREEN_NODE_TYPE ) continue ;
978+ const nodeId = typeof node . id === 'string' && node . id . length > 0 ? node . id : undefined ;
979+ // No id, no key: `translateScreenNode` cannot address the node either.
980+ if ( ! nodeId ) continue ;
981+ const cfg = node . config && typeof node . config === 'object' ? node . config : { } ;
982+ const screenRoot = [ 'flows' , flowName , 'screens' , nodeId ] ;
983+
984+ for ( const key of FLOW_SCREEN_COPY_KEYS ) {
985+ const authored = key === 'title'
986+ ? ( inlineText ( cfg [ key ] ) ?? inlineText ( node . label ) )
987+ : inlineText ( cfg [ key ] ) ;
988+ pushOptional ( out , [ ...screenRoot , key ] , authored , 'flow' , scope ) ;
989+ }
990+
991+ const fields : any [ ] = Array . isArray ( cfg . fields ) ? cfg . fields : [ ] ;
992+ for ( const field of fields ) {
993+ const fieldName = typeof field ?. name === 'string' && field . name . length > 0 ? field . name : undefined ;
994+ // An item with an empty name is dropped by the runner too.
995+ if ( ! fieldName ) continue ;
996+ const fieldRoot = [ ...screenRoot , 'fields' , fieldName ] ;
997+ for ( const key of FLOW_SCREEN_FIELD_COPY_KEYS ) {
998+ const authored = inlineText ( field [ key ] ) ;
999+ if ( key === 'label' ) {
1000+ pushDerived ( out , [ ...fieldRoot , key ] , authored ?? fieldName , authored , 'flow' , scope ) ;
1001+ } else {
1002+ pushOptional ( out , [ ...fieldRoot , key ] , authored , 'flow' , scope ) ;
1003+ }
1004+ }
1005+ }
1006+ }
1007+ }
1008+ }
1009+
8961010/**
8971011 * Iterate the canonical metadata form registry and emit translation entries
8981012 * for every metadata type's display label/description, plus the section and
@@ -1006,6 +1120,7 @@ function passesFilter(entry: ExpectedEntry, filter?: RegExp): boolean {
10061120 if ( entry . objectName && filter . test ( entry . objectName ) ) return true ;
10071121 if ( entry . appName && filter . test ( entry . appName ) ) return true ;
10081122 if ( entry . metadataType && filter . test ( entry . metadataType ) ) return true ;
1123+ if ( entry . flowName && filter . test ( entry . flowName ) ) return true ;
10091124 // Allow matching against the joined path so users can target e.g. ^dashboards\.system_
10101125 return filter . test ( entry . path . join ( '.' ) ) ;
10111126}
0 commit comments