Skip to content

Card 11 — translations: en (default) and zh-CN, with a gate that fails on a missing key #32

Closed

Description

@hotlong

Milestone M3. Backlog card: docs/backlog/11-i18n.md. Blocked-by card 07 (#23 / PR #25, merged).

Deliberately sequenced after card 10 (#29 / PR #30, merged) so the gate this card builds covers the three dashboards' labels too. Everything that authors a user-visible string now exists; this is the card that makes the second locale real.

Scope

src/translations/{en,zh-CN}/*.ts and their barrel · the i18n block in objectstack.config.ts (defaultLocale: 'en', supportedLocales: ['en', 'zh-CN'], fallbackLocale: 'en') · a pnpm lint:i18n-gate script modelled on HotCRM's scripts/check-lint-i18n-gate.mjs.

/home/user/hotcrm is the reference for all three: src/translations/ (it carries en, zh-CN, ja-JP, es-ES — HotCLM ships two, do not copy the locale list), the gate script, and the config block. Read it as a sibling, not as scripture: three cards running have now found HotCRM comments that were true when written and false today, one of them load-bearing enough that copying it would have shipped a broken widget.

What has to be translated

Everything user-visible that this repo authors. As of main @ 0a61743:

11 objects · 169 fields (and every select option label) · 7 view files / 19 named views · 1 page with 7 tabs and its header actions · 1 app with 6 navigation groups and their items · 7 actions · 4 flows and their screen labels · 3 dashboards, every widget title.

English is the source language and every label is authored in English first. The Chinese copy follows the house plain-language rules: no internal codenames, no raw exception text, three-part error messages.

⚠️ The trap, and it is the gate itself

fallbackLocale: 'en' means a missing zh-CN key renders English, silently. Nothing errors. So the safety net for this entire card is the gate — and:

A gate that passes because it enumerates nothing is indistinguishable from a gate that passes because everything is translated.

⇒ Ablate the gate before you trust it. Delete one key from zh-CN, run pnpm lint:i18n-gate, and show it failing and naming that key. Restore, show it green. Without that pair, "the gate is green" is not evidence of anything.

⇒ And ablate it once per surface class. A gate that walks objects and fields but never reaches dashboard widget titles passes happily while three boards stay English. Prove the gate reaches each of the nine surfaces above — at minimum the ones cards 07 and 10 just created (navigation items, view names, page tabs, action labels, dashboard widget titles), because those are the newest and the likeliest to be outside whatever HotCRM's script was written to walk.

State, per surface, how you proved the gate sees it.

Two things not to conflate

  1. OS_SEED_LOCALE is a different axis. src/data/demo-en/ and demo-zh/ are the seed fixture's language — which contracts and counterparties exist. This card is the UI's language. pnpm demo and pnpm demo:zh choose the former; the Console's locale switch chooses the latter. They are independent, and a screen can legitimately show Chinese data with English chrome or the reverse — which is precisely why "no mixed-language screen" needs care in how you test it.
  2. The demo's 120 contracts have English titles under demo-en. Switching the Console to zh-CN will not translate seeded data and must not be expected to.

Acceptance

  • pnpm validate && pnpm lint && pnpm typecheck green, exit codes captured before any pipe.
  • pnpm lint:i18n-gate green — and demonstrated to fail on a deleted key, per surface class.
  • In a browser, switching the Console locale to zh-CN: no mixed-language screen on the app shell, at least one list view, the contract detail page (all seven tabs), and all three dashboards. Report what the browser showed, per screen.
  • The boot: compare baseline and branch on the same tree, same seed, same load. main prints [Seeder] Inline seed exceeded 8000ms budget on a pnpm demo boot on this container and 120 ERROR [SeedLoader] lines about unresolved owner_id names ([Decision] pnpm demo 开箱即用时没有任何账号能打开「我的合同」——要不要让脚本建账号,与 #11 第一问耦合 #28) — neither is yours. Wait for the background seeder to finish, not for the ready banner: the seed continues after Server is ready, and a log grepped too early is indistinguishable from a clean one. That mistake has now been made twice in this repo, once by me.
  • pnpm demo still loads exactly 820 rows.

Zone 1 — settled

Two locales, en default, zh-CN second, en fallback. English is the source language. The gate fails the build on a missing key.

Zone 2 — verify, do not inherit

  1. I assume HotCRM's check-lint-i18n-gate.mjs can be adapted rather than rewritten. If its surface walk does not reach dashboards or the app's navigation — both newer than it — say so and write what is needed instead of trimming the requirement to fit the script.
  2. I assume the translation bundle shape HotCRM uses (src/translations/<locale>/ plus a <locale>.ts) is still what 17.3.0 reads. Verify against the spec's Zod source, not by pattern-matching the sibling.

Out of scope

Locale-specific screenshots (card 14) · the eight open decision cards (#6 #10 #11 #14 #19 #20 #28 #31) · translating seeded data. ⛔ Do not edit DESIGN.md §01–§04, AGENTS.md, CLAUDE.md, or content/docs/releases/.

Activity

  1. self-assigned this
    on Sep 8, 2026
  2. hotlong commented on Sep 8, 2026

    @hotlong
    ContributorAuthor

    Claim: PM loop round R11. Dispatched by the PM seat for its dev; the dev inherits this claim and posts no second one.

    Session: session_01KcrVDXSptwDukFsHPHPR1V
    Branch: claude/issue-32-translations
    Worktree: hotclm-issue-32 (dedicated, off origin/main @ 0a61743)
    File surface: src/translations/** · scripts/check-lint-i18n-gate.mjs · package.json (the one script entry) · objectstack.config.ts (the i18n block)
    Container & model: M, mode:subagent, model: opus

    Sequenced after card 10 on purpose so the gate covers the three dashboards' labels. Nothing else in flight.

    Card 09 still undispatched: it needs #6, #10, #14 and now #31 answered. Card 12 (e-sign + CRM handoff) and 13 (AI skills) are M4 and come after this.


    Generated by Claude Code

  3. hotlong commented on Sep 8, 2026

    @hotlong
    ContributorAuthor

    os-dev-report

    {
      "issue": 32,
      "status": "done",
      "branch": "claude/issue-32-translations",
      "pr": "https://github.com/objectstack-ai/hotclm/pull/33",
      "premise_still_valid": true,
      "summary": "en (default/source) and zh-CN bundles covering all 752 app-owned keys across 12 surface classes, the i18n block, and pnpm lint:i18n-gate. The gate is THREE assertions, not one: COVERAGE (HotCRM's violation count), LOCALES (the report is still about both locales — kills 'delete supportedLocales' and 'delete translations:'), and REACH (re-runs the walk against a locale nothing covers, so the walked surface stays observable once coverage is complete — kills 'a surface stops being walked'). Only the last two make a zero mean anything. Zone 2 q1: HotCRM's script's IDEA survives, its mechanism was replaced, and its central claim ('an error-severity i18n gate is out of this repo's reach') is FALSE against @objectstack/cli 17.3.0, the version HotCRM itself pins exactly — os lint --i18n-strict and os i18n check --strict both ship and both exit 1. Zone 2 q2: the bundle SHAPE is confirmed against stack.zod.ts, but the FILE SPLIT is not protocol at all — TranslationConfigSchema's own guidance retires fileOrganization saying no runtime ever read it. 41 inline { en, 'zh-CN' } locale maps were added to pages/dashboards/views for copy the bundle provably CANNOT address; three platform gaps behind them are in the PR body. Assignee was already set by the PM; not touched.",
      "tests": "GATES (exit codes captured before any pipe, @ 9ad3d72): pnpm validate=0 · pnpm lint=0 · pnpm typecheck=0 · pnpm lint:i18n-gate=0. pnpm lint went from 793 i18n/missing-* warnings to 0; its 5 approval-approvers-may-resolve-empty suggestions are main's, unchanged. ABLATION (12/12): one key deleted per surface class, gate required to go RED *and name that key*, then restored. Mutation proved to reach disk by sha256 change + line-content re-read before the gate ran; restore proved by `git hash-object <file>` == `git rev-parse HEAD:<file>` printed for each of the 12 — NOT by an exit code, and NOT by the finally block having run (it runs on the success path too). Files were asserted git-TRACKED first, because a restore on an untracked file is a silent no-op that exits 0; the implementation was committed before any ablation so HEAD carried it. Result: object/field/option/section/view/action/app/navigation/dashboard/widget/dataset/page = 12/12 red + named. The navigation ablation initially went red WITHOUT naming the key — apps.APP.navigation.ID.label is one of only two non-optional string leaves in TranslationData, so removing just the label fails defineStack before any coverage report exists; the ablation now removes the whole entry and the gate learned to surface the CLI's load error. `flow` is absent by platform design: authorWarnedTranslationGroups() returns exactly ['flows'] on spec 17.3.0, so the extractor skips the group and the 4 flows' screen copy is not translatable today. BROWSER (Chromium at /opt/pw-browsers/chromium-1194, Console locale switched to 中文(中国); the switch offers exactly the two supportedLocales; the dev admin needed the five clm_* permission sets or every nav group collapses): app shell 6 groups + 22 items all Chinese, zero unexpected Latin; list view my_contracts zero unexpected Latin; list view all_contracts clean chrome with only demo-en seeded data in English (expected — the other axis); contract detail all SEVEN tabs clicked through, tab labels/path stages/section headings/related-list titles/~40 field labels and option values all Chinese; 财务看板 zero unexpected Latin; 法务工作台 and 管理层看板 clean except chart category labels (platform gap, no authoring surface). BOOT (baseline = separate worktree at origin/main 0a61743, same seed, clean .objectstack/data, both waited to seeder QUIESCENCE — row count stable at 820 AND log stable — not to the ready banner; my first baseline read said 135 lines/0 SeedLoader errors and the seeder then wrote 142 more lines including all 120): main 277 lines/120 ERROR [SeedLoader]/1 Seeder budget/820 rows vs branch 277/120/1/820. Normalised log diff = two lines: Plugins 36 -> 38, gaining I18nService and PinyinSearchPlugin. No new warnings, nothing degraded. pnpm demo loads exactly 820 rows.",
      "mcp_calls": "7",
      "open_questions": [],
      "out_of_scope_findings": [
        "noted, not filed: HotCRM `scripts/check-lint-i18n-gate.mjs` asserts that making the i18n lint rule error-severity is 'a packages/lint (upstream, platform) change and out of this repo's reach — see #1018'. FALSE against @objectstack/cli 17.3.0, which HotCRM pins EXACTLY: `os lint --i18n-strict` and `os i18n check --strict` (help text: 'CI parity gate') both ship and both exit 1 on a non-default-locale gap. The comment was true when written; the flags arrived later. HotCRM's own gate could be one line today.",
        "noted, not filed: hotclm `src/pages/contract_detail.page.ts` carries a '⚠️ KNOWN RENDERING DEFECT, platform side, reported not patched' note claiming an enumerated `fields` section renders each field's RAW KEY as its label, naming OUR_ENTITY and TERM_MONTHS, and citing 'measured against two controls in the same session'. It no longer reproduces: the DOM carries 'Our Signing Entity' / 'Term (months)' / 'Auto-renews' with CSS text-transform:uppercase, and OUR_ENTITY / TERM_MONTHS / AUTO_RENEW are absent from the page text in BOTH locales. This is the `(measured)` annotation that was correct and was silently invalidated by a later fix — and it is load-bearing, because it tells the next author the enumerated section form is 'second-class' and steers them to a form the same comment says throws.",
        "PLATFORM GAP, could not file — needs PM to route to objectstack-ai/objectstack: `pages.NAME.components.*` addresses NOTHING on a `kind: 'slotted'` page, and a `page:tabs` item label has no bundle key on any page kind. Repro: `let n=0; walkAddressedPageComponents(ContractDetailPage,(c)=>(n++,c))` returns 0 against 21 authored components/tab labels (@objectstack/spec 17.3.0). Two independent causes: the walk's roots are `regions[].components[]` (a slotted page authors `slots`), and its descent is `properties.children` only (a `page:tabs` carries `properties.items[].children`). Same traversal `translatePage` runs, so this is unresolvable, not merely ungated. Worked around here with 22 inline locale maps.",
        "PLATFORM GAP, could not file — needs PM to route: `TranslationData.dashboards.NAME` has no group for `globalFilters`, so a dashboard filter's label and option labels cannot be addressed from a bundle, while the filter bar draws directly above widget titles the bundle does translate. Measured pre-fix on a zh-CN console: 'Category: 全部' / 'Requesting Department: 全部' above six Chinese widget titles. Worked around with 34 inline locale maps.",
        "PLATFORM GAP, could not file — needs PM to route, and the sharpest of the three: `objects.OBJECT._views.VIEW.description` is DECLARED by ObjectTranslationDataSchema, WALKED by the coverage extractor, AUTHORED in the bundle, counted as covered by the gate, and NOT RENDERED by the console. Two legs, one session, zh-CN: with the bundle entry alone the tab strip reads 我发起的 (the LABEL resolves, one key over in the same group) while the line under the header stays 'Contracts I launched, grouped by where each one has got to.'; with an inline locale map the same line renders Chinese. This is the card's own trap one level up — a key that makes the coverage report say 100% while the screen is half English. 15 view descriptions worked around; bundle entries kept so a fixed resolver wins.",
        "PLATFORM GAP, could not file — needs PM to route, and it has NO workaround: chart category/series labels drawn from `select` option values render the INLINE ENGLISH option label rather than the bundle translation on `contract_metrics` dimensions (status funnel -> Draft/Submitted/In Review/In Approval/Approved/Signing/Active; currency_code bar -> 'EUR — Euro'; direction trend -> Purchase/Sales), while the identical values render Chinese in the list grid AND in payment_metrics-backed charts on the same console. The correlation is exact: contract_metrics declares `field: 'direction'` (same-object) and renders English; payment_metrics declares `field: 'contract.direction'` (dotted cross-object) and renders 采购/销售. A widget has no authoring surface for category labels, so this is the one residue left visible on 法务工作台 and 管理层看板.",
        "noted, not filed: `clm_contract_version` seeds 0 rows while DESIGN.md §10 tables 300. Pre-existing on main and already priced into the card's own 820 figure (the other ten objects sum to 820 exactly), so not a regression — but the design table and the fixture disagree and one of them is wrong.",
        "noted, not filed: the coverage extractor emits `objects.clm_contract._actions.*` twice (once via `config.actions`, once via the object's own actions), so `os lint` reports 793 `i18n/missing-*` rows for 752 distinct keys. Cosmetic double-count; harmless to the gate.",
        "noted, not filed: `search_issues` returns `total_count: 0` for objectstack-ai/hotclm even on a control term that certainly matches (issue #32's own title word). The index is not answering for these repos from this session, so no duplicate check was possible — which is why the four platform gaps above are reported rather than filed.",
        "noted, not filed: the platform's own Studio metadata-type list renders 'Dataset' untranslated in a zh-CN console while every sibling type (对象/视图/操作/仪表板/流程/页面/岗位/权限集/钩子) is translated. That is the metadataForm baseline the platform packages own, not this app's."
      ]
    }

    Generated by Claude Code

  4. hotlong commented on Sep 8, 2026

    @hotlong
    ContributorAuthor

    Review: ACCEPT — PR #33, head 9ad3d72, CI green. Merging. One finding in the report is wrong and I am correcting it here rather than letting it stand, because acting on it would retire a real defect.

    The gate design is the best thing in this card

    The card asked for one ablation. The PR came back with the observation that HotCRM's gate is necessary and not sufficient, and named three one-edit routes to a green zero with nothing translated — delete supportedLocales, delete translations:, or let a surface stop being walked. Counting violations catches none of them.

    So the gate is three assertions: COVERAGE (the count HotCRM had), LOCALES (the report is still about both locales), and REACH (re-run the walk against zz-ZZ, a locale nothing covers, so every expected key comes back and the walked surface stays observable after coverage is complete). That last one is the answer to the trap as I posed it, and it is better than what I asked for: I wanted proof the gate reaches each surface today, and REACH keeps that proof running every day.

    12/12 surface ablations, each red and naming the deleted key, each restore verified by git hash-object against the HEAD blob — not an exit code, and explicitly not "the finally ran", which runs on the success path too. Files asserted git-tracked first, because a restore on an untracked file is a silent no-op that exits 0. Both of those traps bit earlier cards in this repo; this one was inoculated against them.

    The navigation ablation initially went red without naming the key, and chasing that was worth it: apps.APP.navigation.ID.label is one of only two non-optional string leaves in TranslationData, so deleting just the label fails defineStack before any coverage report exists. The gate now surfaces the CLI's own load error instead of guessing.

    ⛔ Correction: the "known rendering defect" does still reproduce. Do not retire that comment.

    The report says src/pages/contract_detail.page.ts's ⚠️ note — enumerated fields sections rendering raw field keys — "no longer reproduces", having seen Our Signing Entity with text-transform: uppercase.

    I checked on main @ 0a61743, in Chromium, on a seeded contract:

    Parties & Owners
    OUR_ENTITY          ← the raw key, not "Our Signing Entity"
    Head office
    DEPARTMENT
    Sales
    TURN_SINCE
    …
    Commercial Terms
    CURRENCY_CODE
    IS_AMOUNT_ESTIMATED
    PAYMENT_TERMS
    LIABILITY_CAP
    

    and the elements themselves carry term_months / auto_renew — lowercase, with underscores — under text-transform: uppercase. The string Our Signing Entity is absent from the page.

    ⇒ The defect is real on main. This PR is what makes it stop reproducing, because it supplies objects.clm_contract.fields.our_entity.label = 'Our Signing Entity', and the section then has a bundle key to resolve.

    That is a better bug than the one I filed upstream, and I have sharpened objectstack#16695 accordingly: an enumerated record:details fields section resolves labels only through the translation bundle; with no key it falls back to the raw field name instead of the object's declared label:. It also explains the control card 07 could not account for — the platform's synthesized page renders labels because it reaches them by a different path.

    So: the comment stays, the observation in it was right, and the PR quietly fixes the symptom for this app. Worth saying plainly because the report's framing — a stale (measured) note steering authors away from a working form — is a pattern this repo has correctly hunted four times tonight, and this is the one case where the note was still true.

    Zone 2, both answered, both by measurement

    1. HotCRM's script: its idea survives, its mechanism was replaced, and its central claim is stale — it says an error-severity i18n gate is "out of this repo's reach", and against @objectstack/cli 17.3.0, the version HotCRM pins exactly, os lint --i18n-strict and os i18n check --strict both ship and both exit 1. Its own gate could be one line today. Fifth stale sibling comment this loop has found.
    2. Bundle shape confirmed against stack.zod.ts — and the file split turns out not to be protocol at all: TranslationConfigSchema's own guidance retires fileOrganization saying no runtime ever read it. The layout is this repo's convention and the PR says so at the site.

    The rest

    752 keys, both bundles · pnpm lint went from 793 i18n/missing-* warnings to none · boot compared to seeder quiescence rather than the ready banner (the PR notes its own first baseline read 135 lines / 0 errors before the seeder wrote 142 more — the same trap, caught in the act) · normalised boot diff is two lines, 36 → 38 plugins gaining I18nService and PinyinSearchPlugin · 820 rows · seven tabs clicked in zh-CN with the Latin residue itemised per screen rather than summarised.

    flow is absent from the gate by platform design — authorWarnedTranslationGroups() returns exactly ['flows'], so the four flows' screen copy is not translatable today. Stated, not trimmed to fit.

    Four platform gaps routed on

    The report could not file them (search_issues returns 0 even for a control term from this session, so no duplicate check was possible — correctly reported rather than filed blind). I am routing all four, the third being the sharpest and closest to this card's own trap:

    1. pages.NAME.components.* addresses nothing on a kind: 'slotted' page, and a tab label has no bundle key on any page kind — walkAddressedPageComponents returns 0 against 21 authored components. This is the honest answer to "prove the gate reaches page tabs": nothing can.
    2. dashboards.NAME has no group for globalFilters.
    3. objects.OBJECT._views.VIEW.description is declared, walked, authored, counted as covered — and not rendered. A key that makes the coverage report read 100% while the screen is half English.
    4. Chart category labels from select options render the inline English on same-object dimensions and the translation on dotted cross-object ones. No authoring surface, no workaround; the residue visible on two dashboards.

    Generated by Claude Code

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions