Skip to content

Commit ecb6ca0

Browse files
spec: a flow screen field's help text is translatable — inlineHelpText joins the flows per-field face (#17306) (#21386)
Fixes #17306 Clause-②: yes (widening) The restart shape pre-written on the card (comment 5908590307, unlocked by 5947573357), checked against `origin/main` before any edit. **Part 2 lands. Part 1's premise was falsified by measurement, and nothing was flipped** (see *Premise check* below). The rulings this executes are A (5643444726) and A′ (5651909056): the screen-field keys ship together with their rendering (「声明即强制」). The rendering is objectui#9248 → objectui `81778b955`, and this repo's `.objectui-sha` pin `31971ff1e28f` carries it: REST `compare 81778b955575...31971ff1e28f` on objectui answers `ahead`, `ahead_by 100`, `behind_by 0`. ## What changes - **`FLOW_SCREEN_FIELD_COPY_KEYS`** (`@objectstack/spec/system`) is now `['label', 'placeholder', 'inlineHelpText']`, so a flow screen field's help text gets a per-field translation key. The key is the screen field's own spelling (`ScreenFieldConfig.inlineHelpText`, which is also the object field's spelling). Both overlays write the translation back onto that same key. - **`TranslationDataSchema`**: `flows.FLOW.screens.NODE_ID.fields.FIELD` declares `inlineHelpText`. Five help spellings (`help`, `helpText`, `hint`, `tooltip`, `description`) used to get guidance saying the face had no help key. They are now **aliases** onto `inlineHelpText`, so the `.strict()` refusal names the rename. `options` / `choices` / `values` keep their guidance. Nothing that parsed before is refused. - **`FlowScreenFieldLike`** gains `inlineHelpText?: string`. `translateFlow` needs no logic change, because `translateScreenField` spreads whatever the constant resolved. - **`liveness/translation.json`** (hand-kept): the `flows.screens` row was already `live`. It is re-read at the pin `31971ff1e`: every objectui pointer in `evidence` and `producer` is repinned from `f8a9d0fb`, `overlayFieldCopy` and `ScreenView` are added as readers, and `verifiedAt` is 2026-10-02. The `flows` container's `authorHint` and the stale help sentence in its note are corrected. **No status moved**, so `state-counts/` and the README count rows are unchanged (`check:liveness`: current, 10 `planned` in total, as before). - **`content/docs/ui/translations.mdx`**: the flows row now lists `.inlineHelpText`. The boundary note no longer says that a screen field has no help text, or that no runner reads the group. - **`content/docs/automation/flows.mdx`** (patch round 2, head `0b151ee532`): the screen-field paragraph that said `inlineHelpText` was "not translatable yet" now says it is translated under the flows face, beside `label` and `placeholder`, and links the flows row in Translations. This PR made the old sentence false; the at-tier record `5950219612` named it. - **Changeset** `@objectstack/spec: minor`, carrying the same `Clause-②: yes (widening)` line. ## Every reader of the constant, followed | Reader | Where | What it needed | |:---|:---|:---| | spec resolver | `i18n-resolver.ts#lookupFlowScreenFieldCopy` / `#translateScreenField` | Nothing. It walks the constant and spreads the result. New pin in `i18n-resolver.test.ts`. | | translation schema | the `flows` field node in `translation.zod.ts` | The member. Without it `.strict()` would refuse the key the extractor writes. The existing "declares exactly the keys" pin now iterates three keys. | | lint walk | `packages/lint/src/validate-translation-references.ts` | Nothing. It resolves flow, screen and field **names** and never reads copy keys. | | CLI extractor and coverage | `packages/cli/src/utils/i18n-extract.ts#walkScreenFlows` | Nothing in source, because it imports the constant. Four CLI pins listed literal keys and are updated. "Scaffolds a bundle the strict schema accepts" now parses a skeleton that carries `inlineHelpText`. | | objectui runner | objectui @31971ff1e `packages/app-shell/src/views/FlowRunner.tsx#overlayFieldCopy` | Nothing. It imports the constant (its header says the day the spec lists the key it is translated with no edit there). `copy[key]` is typed from the spec's `TranslationData`, which now carries the member. | ## Premise check: part 1 (the four `planned` liveness rows) does not exist The dispatch's mechanism assumption 1 was that some ledger holds `planned` rows for the screen-field keys `min`, `max`, `inlineHelpText` and `reference`. Measured at base `9360df4138`: - No ledger under `packages/spec/liveness/` has a row for any of the four. `git grep -n inlineHelpText -- packages/spec/liveness` hits only `field.json` (the OBJECT field's row) and `translation.json`. - `flow.json` stops at `nodes.config`, which is `z.record(z.string(), z.unknown())` (`packages/spec/src/automation/flow.zod.ts`), so the gate's walk cannot reach a node config key. - **Probe.** `children: { min: … }` was added under `flow.json`'s `nodes.config`, then `check:liveness` was run. It exited 1 with `✗ 1 UNCLASSIFIED … flow/nodes.config (declared children but property is not a container)`. The file was restored with `git checkout HEAD --`, and its blob hash equals HEAD's. A row for these keys cannot be added, so there is nothing to flip. - None of the four keys' `.describe()` carries a `planned` / `experimental` marker either. The rendering half the flip was meant to record is cited below instead. All four readers exist at the pin, so no key is held back. ## objectui readers at the pin (`31971ff1e`) - `min` / `max`: `ScreenView.tsx#ScreenFieldInput` puts the native `min` / `max` on the numeric input. `ScreenView.tsx#screenFieldBoundViolations` is the submit-time comparison (inclusive, present finite number, hidden fields skipped). `FlowRunner.tsx#FlowRunner` refuses the submit through it and names the field. - `inlineHelpText`: `ScreenView.tsx#ScreenView` draws it under the control, and the control names it in `aria-describedby`. - `reference`: `ScreenView.tsx#ScreenFieldInput` renders the shared `LookupField` widget over `field.reference` on a `type: 'lookup'` field. ## Tests (head `a7f3557b11`, after merging `origin/main` at `3937ad2f32`) - `pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2`: **598 files, 17531 passed, 1 todo**. - `pnpm --filter @objectstack/spec typecheck`: exit 0 (`tsc`, scripts, and `check:test-typecheck`: 52 files, 246 errors, 135 pinned signatures held). - `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2`: **245 files, 3465 passed**. The `integration` layer is declared to CI: this diff touches no spawn entry and no integration file. - `pnpm --filter @objectstack/cli typecheck`: exit 0. - `pnpm --filter @objectstack/spec check:generated`: all 15 generated artifacts up to date, against a `dist` rebuilt after the merge. - **Reverse verification** (one-off, not committed). A scratch module was compiled against the rebuilt `@objectstack/spec` `.d.ts`. It assigned `'inlineHelpText'` to `FlowScreenFieldCopyKey`, `{ inlineHelpText }` to the `TranslationData` flows field node, and `'help'` to `FlowScreenFieldCopyKey`. Result: exactly one error, on the `'help'` line (`TS2322 … not assignable to type '"label" | "placeholder" | "inlineHelpText"'`). The module was deleted and `git status` is clean. ## Gates (union run on `a7f3557b11`) `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived **109** commands with no paths passed. All 109 ran, plus `check:i18n`, `check:i18n-coverage` and `check:i18n-stale-fill`, measured for the dispatch's coverage question. **112 runs, all exit 0.** `--ran` reconciliation: `109 derived, 109 run, 0 NOT-MEASURED, 0 UNRUN`. - The first local run of three gates refused with exit 3, PREREQUISITE NOT MET (`check:skill-examples`, `check:dual-build-cjs-loads`, `check:i18n-walk-parity`). They were re-run after the build and are green on the final head. - **Translation coverage** (dispatch assumption 4): `check:i18n-coverage` OK (13 configs, 621 baselined, none new). `check:i18n` OK (9 packages in sync). No example app or platform bundle authors `inlineHelpText` on a flow screen field. The only hit in `examples/` is an object field, `app-showcase` `contact.object.ts`. The CLI's whole `flows.*` demand is also still held back by the `flows` row's `authorWarn`, which waits on the flow-label reader. - **Lint, narrowed.** Population read from eslint itself: all 6 changed `.ts` files report `isPathIgnored: false`. The changed `.md`, `.mdx` and `.json` files are outside the config's `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` files. `eslint --no-inline-config --format json` on the 6 files: **6 files, 0 errors, 0 warnings** on `a7f3557b11`. Invariance: `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`, no typed rules, as its own comment states), so this diff cannot move a verdict on any untouched file. The repo-wide `pnpm lint` is CI's run. ## NOT MEASURED - **Console Pin Gate** (objectui at the pin built against this spec): NOT MEASURED locally, because building objectui does not fit this container's foreground budget. CI does not run it either: the `console` path filter in `ci.yml` excludes `packages/spec/**` by design, so the check is `skipped` on this PR. What stands is the static reading at the pin, which the at-tier record `5950219612` verified independently: `FlowRunner.tsx` imports the constant, `overlayFieldCopy` indexes `copy[key]` with the widened key, `ScreenView.tsx` draws `inlineHelpText` under the control, and `scripts/build-console.sh` bundles this tree's spec into the console. ## Acceptance notes - The four screen-field keys have no liveness-ledger seat at all, because `nodes.config` is opaque to the walk (probe above). Their declared-vs-read reconciliation lives in `service-automation`'s `builtin-node-form-zod-ledger.test.ts` and `screen-input-contract.test.ts`, not in `liveness/`. Noted, not filed: this is the design of the ledger's one-drill-level boundary, not a defect. - `content/docs/ui/translations.mdx`'s next paragraph ("The day the runner lands and the row flips to `live` …") still describes the flow-label half correctly and is unchanged. - `origin/main` moved again after the merge (`d78bd011ea`, `11905a4f8b`: CI-filter parity and `os generate`). Neither touches this diff's files. The re-derivation printed the same 109 commands. ## Review round 1 - At-tier contract review **PASS** on `a7f3557b11` (comment `5950219612`). Its ③ escalated one sentence this PR made false, in `content/docs/automation/flows.mdx`. Patch round 2 (`0b151ee532`, +5 / -3 in that file alone) corrects it. No code moved. - The same record judged the five help spellings REFUSED with a rename to `inlineHelpText` (no second spelling admitted), `minor` / `Clause-②: yes (widening)` right, and part 1's falsification verified by reading the ledger and `check-liveness`'s source. --- _Generated by [Claude Code](https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3b4efa7 commit ecb6ca0

10 files changed

Lines changed: 167 additions & 104 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
A flow screen field's help text is translatable: the `flows` translation face carries `inlineHelpText` beside `label` and `placeholder` (#17306).
6+
7+
Clause-②: yes (widening)
8+
9+
- **`TranslationDataSchema`.** `flows.<flow>.screens.<node_id>.fields.<field>` accepts `inlineHelpText`, the key the screen field itself declares (`ScreenFieldConfig.inlineHelpText`, the object field's spelling). The console's screen dialog draws that text under the control, so a translated help line now renders in the active locale.
10+
- **`FLOW_SCREEN_FIELD_COPY_KEYS`** (`@objectstack/spec/system`) is `['label', 'placeholder', 'inlineHelpText']`. Its readers follow it without an edit: `translateFlow` overlays the key, `os i18n extract` scaffolds it, and objectui's `FlowRunner` overlays it on the field it draws. `FlowScreenFieldLike` gains the optional `inlineHelpText` member.
11+
- **Refusals.** `help`, `helpText`, `hint`, `tooltip` and `description` on a screen field translation are still refused, and the message now names the rename to `inlineHelpText`. They used to be told that the face had no help key. `options` is still refused with its guidance.
12+
13+
Nothing that parsed before is refused now. A bundle that never wrote a help line is unchanged.

‎content/docs/automation/flows.mdx‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -489,9 +489,11 @@ only, which is all a bound can do.
489489
hidden `visibleWhen` field does not fire — the client is the authority on what
490490
was on screen.
491491

492-
Not translatable yet: the flows translation bundle carries `label` and
493-
`placeholder` per field, so `inlineHelpText` renders in the authored language
494-
until that face grows a key for it.
492+
Translatable: a screen field's `inlineHelpText` is translated under
493+
`flows.<flow>.screens.<node_id>.fields.<field>.inlineHelpText`, beside `label`
494+
and `placeholder`, and the console's flow runner overlays it in the active
495+
locale — see the flows row in
496+
[Translations](/docs/ui/translations#what-you-can-translate).
495497

496498
**Screen (object form):**
497499

‎content/docs/ui/translations.mdx‎

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ export default defineStack({
8080
| Dataset dimension and measure labels | `datasets.<name>.dimensions.<dimension>.label` / `datasets.<name>.measures.<measure>.label` |
8181
| 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 |
8282
| 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-
| Screen-flow wizards (flow label, screen headings, screen field copy) | `flows.<flow>.label` / `flows.<flow>.screens.<node_id>.title` / `.fields.<field>.label` / `.placeholder` — see the boundary note below |
83+
| Screen-flow wizards (flow label, screen headings, screen field copy) | `flows.<flow>.label` / `flows.<flow>.screens.<node_id>.title` / `.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 |
8484
| Global actions, messages | `globalActions`, `messages` |
8585
| 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 |
8686
| A label written as an inline locale map (`label: { en: 'Members', 'zh-CN': '成员' }`) | Nowhere — it is written on the metadata and resolved at render time; see **Current boundaries** below |
@@ -382,15 +382,16 @@ Honest limits worth knowing before you plan around them:
382382
by rule name alone and so could not tell two objects' rules apart; the route
383383
above is object-scoped and shipped with its reader. ADR-0049's 2026-09-04
384384
amendment carries that record.
385-
- **The `flows` group is declared, not yet applied.** A screen flow's copy has
385+
- **The `flows` group is only partly applied.** A screen flow's copy has
386386
somewhere to live (#7646) and the keys are addressed the way the runner
387-
resolves them — flow name, screen node id, screen field name — but no shipped
388-
screen-flow runner reads the group yet, so a wizard still renders the strings
389-
authored on the flow. The liveness ledger carries it as `planned` and the
390-
compile lint warns when you author it. Two related limits are deliberate: a
391-
screen field has no help text to translate (it declares none), and the
392-
runner's own chrome — the Cancel and Submit buttons — belongs to the
393-
console's message catalog rather than your app's bundle.
387+
resolves them — flow name, screen node id, screen field name. The console's
388+
screen-flow runner reads `screens`: each screen's `title` and each field's
389+
`label`, `placeholder` and `inlineHelpText` render in the active locale. The
390+
flow's own `label` is read by nothing yet, so the liveness ledger carries the
391+
group as `planned` and the compile lint warns when you author it. The runner's
392+
own chrome — the Cancel and Submit buttons — is deliberately outside the
393+
group: it belongs to the console's message catalog rather than your app's
394+
bundle.
394395

395396
**So the tooling does not ask you for these keys either.** `os lint` does not
396397
report `flows.*` as missing translations, and `os i18n extract` does not

‎packages/cli/test/i18n-flow-liveness-gate.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,8 +193,10 @@ describe('the liveness gate on the i18n coverage walk', () => {
193193

194194
expect(keys).toEqual([
195195
'flows.lead_conversion.label',
196+
'flows.lead_conversion.screens.conversion_details.fields.create_opportunity.inlineHelpText',
196197
'flows.lead_conversion.screens.conversion_details.fields.create_opportunity.label',
197198
'flows.lead_conversion.screens.conversion_details.fields.create_opportunity.placeholder',
199+
'flows.lead_conversion.screens.conversion_details.fields.opportunity_name.inlineHelpText',
198200
'flows.lead_conversion.screens.conversion_details.fields.opportunity_name.label',
199201
'flows.lead_conversion.screens.conversion_details.fields.opportunity_name.placeholder',
200202
'flows.lead_conversion.screens.conversion_details.title',

‎packages/cli/test/i18n-flow-screen-coverage.test.ts‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,14 @@ const leadConversion = {
9090
description: 'Choose what this lead becomes.',
9191
fields: [
9292
{ name: 'create_opportunity', label: 'Create Opportunity?', type: 'boolean' },
93-
{ name: 'opportunity_name', label: 'Opportunity Name', placeholder: 'Acme - Q3 renewal' },
93+
{
94+
name: 'opportunity_name',
95+
label: 'Opportunity Name',
96+
placeholder: 'Acme - Q3 renewal',
97+
// Every per-field copy key is authored on this one field, so the
98+
// key-face pin below compares the whole spec list, not a subset.
99+
inlineHelpText: 'Shown on the quote',
100+
},
94101
],
95102
},
96103
},
@@ -137,6 +144,7 @@ describe('the screen-flow gap a green i18n gate could not see (#11485)', () => {
137144
expect(keys).toContain('flows.lead_conversion.screens.conversion_details.fields.create_opportunity.label');
138145
expect(keys).toContain('flows.lead_conversion.screens.conversion_details.fields.opportunity_name.label');
139146
expect(keys).toContain('flows.lead_conversion.screens.conversion_details.fields.opportunity_name.placeholder');
147+
expect(keys).toContain('flows.lead_conversion.screens.conversion_details.fields.opportunity_name.inlineHelpText');
140148
// The object surface IS translated, so nothing else is reported: the whole
141149
// report is the wizard. Before this bucket the same tree reported zero.
142150
expect(zh.length).toBeGreaterThan(0);
@@ -166,7 +174,7 @@ describe('the screen-flow gap a green i18n gate could not see (#11485)', () => {
166174
title: '转化详情',
167175
fields: {
168176
create_opportunity: { label: '创建商机?' },
169-
opportunity_name: { label: '商机名称', placeholder: 'Acme - 第三季度续约' },
177+
opportunity_name: { label: '商机名称', placeholder: 'Acme - 第三季度续约', inlineHelpText: '显示在报价单上' },
170178
},
171179
},
172180
summary: { title: '完成' },
@@ -192,8 +200,10 @@ describe('what the walker harvests from a screen flow', () => {
192200
it('keys screens by `FlowNode.id` and fields by `ScreenFieldConfig.name`', () => {
193201
expect(flowKeys({ flows: [leadConversion] }).sort()).toEqual([
194202
'flows.lead_conversion.label',
203+
'flows.lead_conversion.screens.conversion_details.fields.create_opportunity.inlineHelpText',
195204
'flows.lead_conversion.screens.conversion_details.fields.create_opportunity.label',
196205
'flows.lead_conversion.screens.conversion_details.fields.create_opportunity.placeholder',
206+
'flows.lead_conversion.screens.conversion_details.fields.opportunity_name.inlineHelpText',
197207
'flows.lead_conversion.screens.conversion_details.fields.opportunity_name.label',
198208
'flows.lead_conversion.screens.conversion_details.fields.opportunity_name.placeholder',
199209
'flows.lead_conversion.screens.conversion_details.title',
@@ -276,6 +286,7 @@ describe('`os i18n extract` scaffolds the flows skeleton', () => {
276286
expect(en.flows.lead_conversion.screens.conversion_details.fields.opportunity_name).toEqual({
277287
label: 'Opportunity Name',
278288
placeholder: 'Acme - Q3 renewal',
289+
inlineHelpText: 'Shown on the quote',
279290
});
280291
// The translator's empty slots — the vocabulary an author had no way to
281292
// discover before this pass existed.
@@ -424,13 +435,16 @@ describe('a screen inside an ADR-0031 region (#17511)', () => {
424435
// The exact face, so a key that should NOT exist fails here too. Eight of
425436
// these ten were absent before the descent landed; `flows.onboarding.label`
426437
// and `screens.welcome.title` are the two the flat walk already reached.
438+
// The two `inlineHelpText` rows joined with the per-field face (#17306).
427439
expect(flowKeys({ flows: [nestedOnboarding] }).sort()).toEqual([
428440
'flows.onboarding.label',
429441
'flows.onboarding.screens.accept_terms.title',
430442
'flows.onboarding.screens.card_details.title',
431443
'flows.onboarding.screens.payment_failed.title',
444+
'flows.onboarding.screens.pick_region.fields.notes.inlineHelpText',
432445
'flows.onboarding.screens.pick_region.fields.notes.label',
433446
'flows.onboarding.screens.pick_region.fields.notes.placeholder',
447+
'flows.onboarding.screens.pick_region.fields.region_code.inlineHelpText',
434448
'flows.onboarding.screens.pick_region.fields.region_code.label',
435449
'flows.onboarding.screens.pick_region.fields.region_code.placeholder',
436450
'flows.onboarding.screens.pick_region.title',

0 commit comments

Comments
 (0)