i18n: a generated en bundle, a hand-written zh-CN, and a coverage gate that discovers - #103

Merged
os-warren merged 3 commits into
mainfrom
claude/issue-18-i18n-bundles
Sep 1, 2026
Merged

i18n: a generated en bundle, a hand-written zh-CN, and a coverage gate that discovers#103
os-warren merged 3 commits into
mainfrom
claude/issue-18-i18n-bundles

Conversation

@os-warren

@os-warrenos-warren commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#18

objectstack.config.ts advertised supportedLocales: ['en', 'zh-CN'] while dulyTranslations was []. The app claimed Chinese and shipped none of it: every authored label reached a Chinese deployment in English. This closes that.

Verified end to end in a browser: 231 bundle keys, four gates green at 0a70856, and the screens below are a real zh-CN session against pnpm demo.


The constraint that decided the design: en is generated

A hand-edited en entry wins at render time, so the screen and the code disagree while every gate stays green. The usual answer — generate a file, add a staleness check — is only as good as whoever remembers to re-run the generator.

So there is no en literal anywhere in this diff. src/translations/en.ts derives the bundle at config load from the same walk the gate uses. Nothing is stored, so nothing can go stale, and there is nothing a person could usefully hand-edit: changing an English string means changing the label in src/objects/, src/views/ or src/apps/, which is the only place it was ever true.

That property is pinned rather than asserted in a comment — buildEnglishBundle is run over synthetic metadata and the output has to follow the input, which a hand-written bundle could not do. It also goes through defineTranslationBundle, so the strict TranslationDataSchema refuses a mis-built key at load rather than shipping it unread.

The coverage gate discovers; it does not enumerate

src/translations/authored-text.ts visits every string leaf in the metadata and demands a verdict per normalised path ([] for an array index, {} for a record key). A path with no verdict is a finding, and the gate fails naming it. The default is "unknown → red"; the tables are the exception list.

Three verdicts:

verdictmeaning
translatedisplay text with a bundle key — goes into en, zh-CN must carry it
untranslatabledisplay text with no key anywhere in the platform's schema — named, counted, filed, never silently dropped
machinenot display text (a machine name, a CEL source, an icon, a colour, a filter token)

What is translatable at all is the platform's answer, not ours: TRANSLATABLE_METADATA_TYPES is imported from @objectstack/spec/system ({ view, action, object, app, dashboard, page }) and pinned, so the day datasets become translatable the pin goes red and the walk gets extended instead of quietly staying behind. View keys come from the platform's own expandViewContainer, so _views.default cannot drift from the registry name the resolver looks up.

Beyond the two directions the card asks for, the gate fails on: an unclassified path, a stale exemption (an untranslatable verdict that stopped matching anything), prose hiding inside a subtree declared opaque, two strings claiming one key, and a metadata collection in objectstack.config.ts that has never been classified.

It caught #69 on merge, which is the argument for building it this way. Merging main turned it red twice over: the exemptions for the three notify nodes' inline title / message failed as stale because #69 removed those strings, and nine paths #69 introduced (config.template, config.templateData.*) had no verdict. Neither is a string this gate could have been told about in advance. The inline-notification count is now pinned at zero so that gap cannot silently reopen.

Email templates are deliberately not walked

They are not bundle-keyed. translation.zod.ts does not mention them, EmailTemplateDefinitionSchema.translations is z.ZodNever, and the platform resolves a template by (name, locale) — so a translation is a sibling row, not a key. Demanding a bundle key for a template subject would make this gate permanently and unfixably red.

That skip is named with its mechanism in COLLECTION_HANDLING rather than being implied by omission — a type that localizes differently must not look like a type the walk forgot. The equivalent question is asked of the row shape instead, against #69's real rows: one row per (name, locale) for every supportedLocales entry, and each locale row carrying its own words rather than a copy of the source row's. That second check compares literal text with {{holes}} stripped, because subject: '{{{subject}}}' is identical in both rows on purpose — it renders the task's own subject line, and flagging it would be flagging the data.

Terminology

Six words carry product meaning a literal translation loses. The decisions live in the bundle, with their reasoning, because they have to be consistent across 231 keys and are not recoverable from the English string alone.

termzh-CNwhy, and what it must not read as
duty职责the recurring obligation itself — the rule that produces tasks. 职责 is the ordinary word for role-attached duty (岗位职责). ⛔ not 任务 (that is a task), ⛔ not 义务 (legal/moral)
task任务one dispatched occurrence. Paired with 职责 it keeps "a duty is not a task" as sharp in Chinese as in English
period周期, field 所属周期bare 周期 beside 频率 reads as the cadence, not the window — so period_key is "the period it belongs to"
standing常设the register of 常设机构 / 常设委员会: permanent by establishment, by nature not a thing that finishes. ⛔ not 长期 / 持续, which read as an ongoing task — exactly the misreading that sends people looking for the tick box the product refuses to have
governed组织认定"the organisation established it". ⛔ not 纳入考核 ("counted towards assessment") — accurate about the metrics, wrong about a product that scores nobody
caliber口径统计口径 — the standard term. No user-facing label carries it today; recorded so nobody invents a second word later

due_offset_days.label took the judgement the issue's round-2 comment flagged: the parenthetical had to survive as an explanation, so it is 「偏移天数(0 = 锚点当天)」 rather than a word-for-word rendering of "anchor day", and its help text keeps both worked examples per anchor, translated for sense.

Verified in a browser, in Chinese

pnpm demo, a Chromium context with locale: 'zh-CN' and Accept-Language: zh-CN.

The console chrome does not stay English. This was the expected finding and it is not what happens — the platform ships its own zh-CN console bundle and picks it up from the browser locale. 新建 / 导入 / 行内编辑 / 筛选 / 分组 / 排序 / 搜索… / 5 条记录 / 已选择 1 项 / 清除 are all the platform's. Nothing to file against objectui.

task listby unit

Object label, description, view tabs (全部任务 / 我的本周 / 逾期 / 停滞), column headers, option badges (待办 / 进行中, 岗位职责库 / 自行申报), nav groups and every nav item are ours and all render. The English strings visible are seed data (Emissions return — Northgate), correctly untranslated.

Screenshots are on a throwaway branch (claude/issue-18-screenshots) so no binaries enter this diff.

Two gaps the walk predicted, confirmed on screen

dashboard

Dashboard title, description and all five widget titles/descriptions are Chinese — and directly under each metric tile the dataset measure label renders Untouched > 14 days, Untouched > 30 days, Oldest touch.

bulk toolbar

The selection bar reads 已选择 1 项 · Complete · Skip · 清除 — two English words between two Chinese ones, which is exactly the failure mode that reads as a styling quirk rather than a missing translation.

Display text no bundle can reach — filed, not worked around

Per AGENTS.md rule 9 these are filed upstream rather than papered over. Each is a declared verdict with a stated reason, and the exact set is asserted, so a new one fails the gate and one that becomes translatable fails as stale.

surfacecountaudiencefiled
bulkActionDefs label / confirm / params35end userobjectstack-ai/objectstack#14253
dataset label / description / dimension / measure labels26end user (chart axes, tile captions)objectstack-ai/objectstack#14253
custom validation rule message11end user (write refusals)objectstack-ai/objectstack#14253
flow label / description / node / edge labels39designer (Studio)
job, hook, position, permission-set label / description18operator

The validation-message one is worth calling out against the card's scope line ("… validation message … gets a key"): measured on objectql 17.2.0, a rule's message is emitted verbatim ({ field, code: 'rule_violation', message: rule.message }). messages['validation.field.*'] overrides the platform's built-in field catalog — which already ships zh-CN — not an authored rule. So a deployment gets platform refusals in Chinese and author-written refusals in English, and no bundle key can currently change that.

Separately filed: objectstack-ai/objectstack#14254translateAction ignores _actions.ACTION.description and _actions.ACTION.params.*, which TranslationItemSchema declares andvalidateTranslationReferences lints. Those keys are authored here anyway, for the same reason the gantt's viewMode stays authored in src/views/task.view.ts: the key is the spec's own, it is served to REST/OpenAPI/MCP callers today, and it starts rendering the moment the resolver is fixed.

The platform already owns half of this — as a warning

@objectstack/rest ships validateTranslationReferences (translation-target-unknown / translation-option-key-unknown), which covers the reverse direction over objects, fields, option values, views, sections, actions, params, apps, nav ids, dashboards and widgets. Measured by renaming one bundle key to duly_task_undoo:

VALIDATE EXIT=0
✓ Validation passed (419ms)
⚠ locale "zh-CN" · object "duly_task" · action "duly_task_undoo": Translations are keyed to
action "duly_task_undoo", which is defined by neither object "duly_task"'s `actions` nor a
`stack.actions` entry bound to it. The button keeps its source-locale label.
Did you mean "duly_task_undo"?

A warning, exit 0. This gate makes it an error and adds the forward direction, which nothing upstream checks.

Tests

test/i18n-coverage.test.ts — 29 tests. Reverse-verified rather than assumed; each mutation was confirmed on disk by an anchored count before the reading was taken, and restored by an EXIT/INT/TERM trap:

mutationresult
delete objects.duly_task.fields.status.label from zh-CNexit 1 — 2 red, naming the key
rename duly_task_undoduly_task_undoo in zh-CNexit 1 — 3 red, including "every zh-CN key still has a source"
add placeholder: to duly_task.note (a slot the walk had never seen)exit 1 — tripwire fires, naming object.fields{}.placeholder

Plus self-tests on synthetic metadata for every rule, including that a stack with no authored text makes every exemption stale, that prose inside an opaque subtree is caught, and both email-template rules (missing sibling, and a locale row that copies the source row's wording).

Gates

All four green on 0a70856, the final commit, after merging main (which had taken #69):

validate=0 typecheck=0 test=0 build=0 @ 0a70856
✓ Validation passed (one expected warning: hierarchy-security, per AGENTS.md)
Test Files 28 passed (28)
Tests 703 passed (703)
✓ Build complete — dist/objectstack.json (166.8 KB)

Bundle confirmed in the artifact and served live: GET /api/v1/i18n/localesen, zh-CN; GET /api/v1/i18n/translations/zh-CN returns the tree.

No changeset — this repo has no changeset mechanism (no .changeset/, no dependency, no script, no mention in AGENTS.md). The four gates are the whole contract.

Recorded gap

Filed as #102 rather than done here: the bundle half of the gate compares against zh-CNby name, so a third locale added later would satisfy the locale-set assertion and have its key coverage checked by nothing. The email-template half already iterates supportedLocales generically and does not have this problem. Left out deliberately — #18's scope is the first Chinese deployment — and filed so it is a decision on the record rather than a surprise.


Generated by Claude Code

os-warrenand others added 3 commits September 1, 2026 16:06
…rage gate
`dulyTranslations` was empty while the config advertised `zh-CN`, so every
authored label reached a Chinese deployment in English.
- `src/translations/authored-text.ts` walks every string leaf in the metadata
and demands a verdict per normalised path. A path with no verdict is a
finding, so a new key is a red test rather than an unchecked string.
- `src/translations/en.ts` derives the English bundle from that walk. There is
no English literal to hand-edit, so `en` cannot drift from the source.
- `src/translations/zh-CN.ts` is hand-written, 231 keys, with the terminology
decisions (duty/task/period/standing/governed/caliber) recorded in the file.
- `test/i18n-coverage.test.ts` fails on a declared label with no bundle key, a
bundle key with no source, an unclassified path, a stale exemption, prose
inside an opaque subtree, and an email template with no sibling row per
supported locale.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
Merging main made the gate red, which is the gate working:
- The `untranslatable` exemptions for the three `notify` nodes' inline
`title` / `message` failed as STALE — #69 removed those strings, so the
exemption list described metadata that no longer exists.
- Nine string paths #69 introduced (`config.template`, `config.templateData.*`)
had no verdict and failed the discovery tripwire.
Classified them: `template` is a template NAME resolved by `(name, locale)`,
`templateData` is a render payload. The inline-notification count is pinned at
zero so the gap cannot silently reopen as inline copy.
Email templates are NOT bundle-keyed and are deliberately not walked — they
localize by row. The gate now asserts the row-shaped equivalent against real
rows: one row per (name, locale) for every supported locale, and each locale
row carrying its own words rather than a copy of the source row's.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 16:25
@os-warren
os-warren merged commit 7624c99 into mainSep 1, 2026
1 check passed
os-warren added a commit that referenced this pull request Sep 1, 2026
#103's coverage gate went red on merge, exactly as it is built to: the two new
field labels and the new widget had no bundle key, three dataset slots had no
verdict, and the zh-CN dashboard description still explained why lateness was
missing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

i18n — en source bundle, zh-CN translation, CI coverage gate

1 participant

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

i18n: a generated en bundle, a hand-written zh-CN, and a coverage gate that discovers - #103

Merged
os-warren merged 3 commits into
mainfrom
claude/issue-18-i18n-bundles
Sep 1, 2026
Merged

i18n: a generated en bundle, a hand-written zh-CN, and a coverage gate that discovers#103
os-warren merged 3 commits into
mainfrom
claude/issue-18-i18n-bundles

Conversation

@os-warren

@os-warrenos-warren commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#18

objectstack.config.ts advertised supportedLocales: ['en', 'zh-CN'] while dulyTranslations was []. The app claimed Chinese and shipped none of it: every authored label reached a Chinese deployment in English. This closes that.

Verified end to end in a browser: 231 bundle keys, four gates green at 0a70856, and the screens below are a real zh-CN session against pnpm demo.


The constraint that decided the design: en is generated

A hand-edited en entry wins at render time, so the screen and the code disagree while every gate stays green. The usual answer — generate a file, add a staleness check — is only as good as whoever remembers to re-run the generator.

So there is no en literal anywhere in this diff. src/translations/en.ts derives the bundle at config load from the same walk the gate uses. Nothing is stored, so nothing can go stale, and there is nothing a person could usefully hand-edit: changing an English string means changing the label in src/objects/, src/views/ or src/apps/, which is the only place it was ever true.

That property is pinned rather than asserted in a comment — buildEnglishBundle is run over synthetic metadata and the output has to follow the input, which a hand-written bundle could not do. It also goes through defineTranslationBundle, so the strict TranslationDataSchema refuses a mis-built key at load rather than shipping it unread.

The coverage gate discovers; it does not enumerate

src/translations/authored-text.ts visits every string leaf in the metadata and demands a verdict per normalised path ([] for an array index, {} for a record key). A path with no verdict is a finding, and the gate fails naming it. The default is "unknown → red"; the tables are the exception list.

Three verdicts:

verdictmeaning
translatedisplay text with a bundle key — goes into en, zh-CN must carry it
untranslatabledisplay text with no key anywhere in the platform's schema — named, counted, filed, never silently dropped
machinenot display text (a machine name, a CEL source, an icon, a colour, a filter token)

What is translatable at all is the platform's answer, not ours: TRANSLATABLE_METADATA_TYPES is imported from @objectstack/spec/system ({ view, action, object, app, dashboard, page }) and pinned, so the day datasets become translatable the pin goes red and the walk gets extended instead of quietly staying behind. View keys come from the platform's own expandViewContainer, so _views.default cannot drift from the registry name the resolver looks up.

Beyond the two directions the card asks for, the gate fails on: an unclassified path, a stale exemption (an untranslatable verdict that stopped matching anything), prose hiding inside a subtree declared opaque, two strings claiming one key, and a metadata collection in objectstack.config.ts that has never been classified.

It caught #69 on merge, which is the argument for building it this way. Merging main turned it red twice over: the exemptions for the three notify nodes' inline title / message failed as stale because #69 removed those strings, and nine paths #69 introduced (config.template, config.templateData.*) had no verdict. Neither is a string this gate could have been told about in advance. The inline-notification count is now pinned at zero so that gap cannot silently reopen.

Email templates are deliberately not walked

They are not bundle-keyed. translation.zod.ts does not mention them, EmailTemplateDefinitionSchema.translations is z.ZodNever, and the platform resolves a template by (name, locale) — so a translation is a sibling row, not a key. Demanding a bundle key for a template subject would make this gate permanently and unfixably red.

That skip is named with its mechanism in COLLECTION_HANDLING rather than being implied by omission — a type that localizes differently must not look like a type the walk forgot. The equivalent question is asked of the row shape instead, against #69's real rows: one row per (name, locale) for every supportedLocales entry, and each locale row carrying its own words rather than a copy of the source row's. That second check compares literal text with {{holes}} stripped, because subject: '{{{subject}}}' is identical in both rows on purpose — it renders the task's own subject line, and flagging it would be flagging the data.

Terminology

Six words carry product meaning a literal translation loses. The decisions live in the bundle, with their reasoning, because they have to be consistent across 231 keys and are not recoverable from the English string alone.

termzh-CNwhy, and what it must not read as
duty职责the recurring obligation itself — the rule that produces tasks. 职责 is the ordinary word for role-attached duty (岗位职责). ⛔ not 任务 (that is a task), ⛔ not 义务 (legal/moral)
task任务one dispatched occurrence. Paired with 职责 it keeps "a duty is not a task" as sharp in Chinese as in English
period周期, field 所属周期bare 周期 beside 频率 reads as the cadence, not the window — so period_key is "the period it belongs to"
standing常设the register of 常设机构 / 常设委员会: permanent by establishment, by nature not a thing that finishes. ⛔ not 长期 / 持续, which read as an ongoing task — exactly the misreading that sends people looking for the tick box the product refuses to have
governed组织认定"the organisation established it". ⛔ not 纳入考核 ("counted towards assessment") — accurate about the metrics, wrong about a product that scores nobody
caliber口径统计口径 — the standard term. No user-facing label carries it today; recorded so nobody invents a second word later

due_offset_days.label took the judgement the issue's round-2 comment flagged: the parenthetical had to survive as an explanation, so it is 「偏移天数(0 = 锚点当天)」 rather than a word-for-word rendering of "anchor day", and its help text keeps both worked examples per anchor, translated for sense.

Verified in a browser, in Chinese

pnpm demo, a Chromium context with locale: 'zh-CN' and Accept-Language: zh-CN.

The console chrome does not stay English. This was the expected finding and it is not what happens — the platform ships its own zh-CN console bundle and picks it up from the browser locale. 新建 / 导入 / 行内编辑 / 筛选 / 分组 / 排序 / 搜索… / 5 条记录 / 已选择 1 项 / 清除 are all the platform's. Nothing to file against objectui.

task listby unit

Object label, description, view tabs (全部任务 / 我的本周 / 逾期 / 停滞), column headers, option badges (待办 / 进行中, 岗位职责库 / 自行申报), nav groups and every nav item are ours and all render. The English strings visible are seed data (Emissions return — Northgate), correctly untranslated.

Screenshots are on a throwaway branch (claude/issue-18-screenshots) so no binaries enter this diff.

Two gaps the walk predicted, confirmed on screen

dashboard

Dashboard title, description and all five widget titles/descriptions are Chinese — and directly under each metric tile the dataset measure label renders Untouched > 14 days, Untouched > 30 days, Oldest touch.

bulk toolbar

The selection bar reads 已选择 1 项 · Complete · Skip · 清除 — two English words between two Chinese ones, which is exactly the failure mode that reads as a styling quirk rather than a missing translation.

Display text no bundle can reach — filed, not worked around

Per AGENTS.md rule 9 these are filed upstream rather than papered over. Each is a declared verdict with a stated reason, and the exact set is asserted, so a new one fails the gate and one that becomes translatable fails as stale.

surfacecountaudiencefiled
bulkActionDefs label / confirm / params35end userobjectstack-ai/objectstack#14253
dataset label / description / dimension / measure labels26end user (chart axes, tile captions)objectstack-ai/objectstack#14253
custom validation rule message11end user (write refusals)objectstack-ai/objectstack#14253
flow label / description / node / edge labels39designer (Studio)
job, hook, position, permission-set label / description18operator

The validation-message one is worth calling out against the card's scope line ("… validation message … gets a key"): measured on objectql 17.2.0, a rule's message is emitted verbatim ({ field, code: 'rule_violation', message: rule.message }). messages['validation.field.*'] overrides the platform's built-in field catalog — which already ships zh-CN — not an authored rule. So a deployment gets platform refusals in Chinese and author-written refusals in English, and no bundle key can currently change that.

Separately filed: objectstack-ai/objectstack#14254translateAction ignores _actions.ACTION.description and _actions.ACTION.params.*, which TranslationItemSchema declares andvalidateTranslationReferences lints. Those keys are authored here anyway, for the same reason the gantt's viewMode stays authored in src/views/task.view.ts: the key is the spec's own, it is served to REST/OpenAPI/MCP callers today, and it starts rendering the moment the resolver is fixed.

The platform already owns half of this — as a warning

@objectstack/rest ships validateTranslationReferences (translation-target-unknown / translation-option-key-unknown), which covers the reverse direction over objects, fields, option values, views, sections, actions, params, apps, nav ids, dashboards and widgets. Measured by renaming one bundle key to duly_task_undoo:

VALIDATE EXIT=0
✓ Validation passed (419ms)
⚠ locale "zh-CN" · object "duly_task" · action "duly_task_undoo": Translations are keyed to
action "duly_task_undoo", which is defined by neither object "duly_task"'s `actions` nor a
`stack.actions` entry bound to it. The button keeps its source-locale label.
Did you mean "duly_task_undo"?

A warning, exit 0. This gate makes it an error and adds the forward direction, which nothing upstream checks.

Tests

test/i18n-coverage.test.ts — 29 tests. Reverse-verified rather than assumed; each mutation was confirmed on disk by an anchored count before the reading was taken, and restored by an EXIT/INT/TERM trap:

mutationresult
delete objects.duly_task.fields.status.label from zh-CNexit 1 — 2 red, naming the key
rename duly_task_undoduly_task_undoo in zh-CNexit 1 — 3 red, including "every zh-CN key still has a source"
add placeholder: to duly_task.note (a slot the walk had never seen)exit 1 — tripwire fires, naming object.fields{}.placeholder

Plus self-tests on synthetic metadata for every rule, including that a stack with no authored text makes every exemption stale, that prose inside an opaque subtree is caught, and both email-template rules (missing sibling, and a locale row that copies the source row's wording).

Gates

All four green on 0a70856, the final commit, after merging main (which had taken #69):

validate=0 typecheck=0 test=0 build=0 @ 0a70856
✓ Validation passed (one expected warning: hierarchy-security, per AGENTS.md)
Test Files 28 passed (28)
Tests 703 passed (703)
✓ Build complete — dist/objectstack.json (166.8 KB)

Bundle confirmed in the artifact and served live: GET /api/v1/i18n/localesen, zh-CN; GET /api/v1/i18n/translations/zh-CN returns the tree.

No changeset — this repo has no changeset mechanism (no .changeset/, no dependency, no script, no mention in AGENTS.md). The four gates are the whole contract.

Recorded gap

Filed as #102 rather than done here: the bundle half of the gate compares against zh-CNby name, so a third locale added later would satisfy the locale-set assertion and have its key coverage checked by nothing. The email-template half already iterates supportedLocales generically and does not have this problem. Left out deliberately — #18's scope is the first Chinese deployment — and filed so it is a decision on the record rather than a surprise.


Generated by Claude Code

os-warrenand others added 3 commits September 1, 2026 16:06
…rage gate
`dulyTranslations` was empty while the config advertised `zh-CN`, so every
authored label reached a Chinese deployment in English.
- `src/translations/authored-text.ts` walks every string leaf in the metadata
and demands a verdict per normalised path. A path with no verdict is a
finding, so a new key is a red test rather than an unchecked string.
- `src/translations/en.ts` derives the English bundle from that walk. There is
no English literal to hand-edit, so `en` cannot drift from the source.
- `src/translations/zh-CN.ts` is hand-written, 231 keys, with the terminology
decisions (duty/task/period/standing/governed/caliber) recorded in the file.
- `test/i18n-coverage.test.ts` fails on a declared label with no bundle key, a
bundle key with no source, an unclassified path, a stale exemption, prose
inside an opaque subtree, and an email template with no sibling row per
supported locale.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
Merging main made the gate red, which is the gate working:
- The `untranslatable` exemptions for the three `notify` nodes' inline
`title` / `message` failed as STALE — #69 removed those strings, so the
exemption list described metadata that no longer exists.
- Nine string paths #69 introduced (`config.template`, `config.templateData.*`)
had no verdict and failed the discovery tripwire.
Classified them: `template` is a template NAME resolved by `(name, locale)`,
`templateData` is a render payload. The inline-notification count is pinned at
zero so the gap cannot silently reopen as inline copy.
Email templates are NOT bundle-keyed and are deliberately not walked — they
localize by row. The gate now asserts the row-shaped equivalent against real
rows: one row per (name, locale) for every supported locale, and each locale
row carrying its own words rather than a copy of the source row's.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 16:25
@os-warren
os-warren merged commit 7624c99 into mainSep 1, 2026
1 check passed
os-warren added a commit that referenced this pull request Sep 1, 2026
#103's coverage gate went red on merge, exactly as it is built to: the two new
field labels and the new widget had no bundle key, three dataset slots had no
verdict, and the zh-CN dashboard description still explained why lateness was
missing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

i18n — en source bundle, zh-CN translation, CI coverage gate

1 participant

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

i18n: a generated en bundle, a hand-written zh-CN, and a coverage gate that discovers - #103

Merged
os-warren merged 3 commits into
mainfrom
claude/issue-18-i18n-bundles
Sep 1, 2026
Merged

i18n: a generated en bundle, a hand-written zh-CN, and a coverage gate that discovers#103
os-warren merged 3 commits into
mainfrom
claude/issue-18-i18n-bundles

Conversation

@os-warren

@os-warrenos-warren commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#18

objectstack.config.ts advertised supportedLocales: ['en', 'zh-CN'] while dulyTranslations was []. The app claimed Chinese and shipped none of it: every authored label reached a Chinese deployment in English. This closes that.

Verified end to end in a browser: 231 bundle keys, four gates green at 0a70856, and the screens below are a real zh-CN session against pnpm demo.


The constraint that decided the design: en is generated

A hand-edited en entry wins at render time, so the screen and the code disagree while every gate stays green. The usual answer — generate a file, add a staleness check — is only as good as whoever remembers to re-run the generator.

So there is no en literal anywhere in this diff. src/translations/en.ts derives the bundle at config load from the same walk the gate uses. Nothing is stored, so nothing can go stale, and there is nothing a person could usefully hand-edit: changing an English string means changing the label in src/objects/, src/views/ or src/apps/, which is the only place it was ever true.

That property is pinned rather than asserted in a comment — buildEnglishBundle is run over synthetic metadata and the output has to follow the input, which a hand-written bundle could not do. It also goes through defineTranslationBundle, so the strict TranslationDataSchema refuses a mis-built key at load rather than shipping it unread.

The coverage gate discovers; it does not enumerate

src/translations/authored-text.ts visits every string leaf in the metadata and demands a verdict per normalised path ([] for an array index, {} for a record key). A path with no verdict is a finding, and the gate fails naming it. The default is "unknown → red"; the tables are the exception list.

Three verdicts:

verdictmeaning
translatedisplay text with a bundle key — goes into en, zh-CN must carry it
untranslatabledisplay text with no key anywhere in the platform's schema — named, counted, filed, never silently dropped
machinenot display text (a machine name, a CEL source, an icon, a colour, a filter token)

What is translatable at all is the platform's answer, not ours: TRANSLATABLE_METADATA_TYPES is imported from @objectstack/spec/system ({ view, action, object, app, dashboard, page }) and pinned, so the day datasets become translatable the pin goes red and the walk gets extended instead of quietly staying behind. View keys come from the platform's own expandViewContainer, so _views.default cannot drift from the registry name the resolver looks up.

Beyond the two directions the card asks for, the gate fails on: an unclassified path, a stale exemption (an untranslatable verdict that stopped matching anything), prose hiding inside a subtree declared opaque, two strings claiming one key, and a metadata collection in objectstack.config.ts that has never been classified.

It caught #69 on merge, which is the argument for building it this way. Merging main turned it red twice over: the exemptions for the three notify nodes' inline title / message failed as stale because #69 removed those strings, and nine paths #69 introduced (config.template, config.templateData.*) had no verdict. Neither is a string this gate could have been told about in advance. The inline-notification count is now pinned at zero so that gap cannot silently reopen.

Email templates are deliberately not walked

They are not bundle-keyed. translation.zod.ts does not mention them, EmailTemplateDefinitionSchema.translations is z.ZodNever, and the platform resolves a template by (name, locale) — so a translation is a sibling row, not a key. Demanding a bundle key for a template subject would make this gate permanently and unfixably red.

That skip is named with its mechanism in COLLECTION_HANDLING rather than being implied by omission — a type that localizes differently must not look like a type the walk forgot. The equivalent question is asked of the row shape instead, against #69's real rows: one row per (name, locale) for every supportedLocales entry, and each locale row carrying its own words rather than a copy of the source row's. That second check compares literal text with {{holes}} stripped, because subject: '{{{subject}}}' is identical in both rows on purpose — it renders the task's own subject line, and flagging it would be flagging the data.

Terminology

Six words carry product meaning a literal translation loses. The decisions live in the bundle, with their reasoning, because they have to be consistent across 231 keys and are not recoverable from the English string alone.

termzh-CNwhy, and what it must not read as
duty职责the recurring obligation itself — the rule that produces tasks. 职责 is the ordinary word for role-attached duty (岗位职责). ⛔ not 任务 (that is a task), ⛔ not 义务 (legal/moral)
task任务one dispatched occurrence. Paired with 职责 it keeps "a duty is not a task" as sharp in Chinese as in English
period周期, field 所属周期bare 周期 beside 频率 reads as the cadence, not the window — so period_key is "the period it belongs to"
standing常设the register of 常设机构 / 常设委员会: permanent by establishment, by nature not a thing that finishes. ⛔ not 长期 / 持续, which read as an ongoing task — exactly the misreading that sends people looking for the tick box the product refuses to have
governed组织认定"the organisation established it". ⛔ not 纳入考核 ("counted towards assessment") — accurate about the metrics, wrong about a product that scores nobody
caliber口径统计口径 — the standard term. No user-facing label carries it today; recorded so nobody invents a second word later

due_offset_days.label took the judgement the issue's round-2 comment flagged: the parenthetical had to survive as an explanation, so it is 「偏移天数(0 = 锚点当天)」 rather than a word-for-word rendering of "anchor day", and its help text keeps both worked examples per anchor, translated for sense.

Verified in a browser, in Chinese

pnpm demo, a Chromium context with locale: 'zh-CN' and Accept-Language: zh-CN.

The console chrome does not stay English. This was the expected finding and it is not what happens — the platform ships its own zh-CN console bundle and picks it up from the browser locale. 新建 / 导入 / 行内编辑 / 筛选 / 分组 / 排序 / 搜索… / 5 条记录 / 已选择 1 项 / 清除 are all the platform's. Nothing to file against objectui.

task listby unit

Object label, description, view tabs (全部任务 / 我的本周 / 逾期 / 停滞), column headers, option badges (待办 / 进行中, 岗位职责库 / 自行申报), nav groups and every nav item are ours and all render. The English strings visible are seed data (Emissions return — Northgate), correctly untranslated.

Screenshots are on a throwaway branch (claude/issue-18-screenshots) so no binaries enter this diff.

Two gaps the walk predicted, confirmed on screen

dashboard

Dashboard title, description and all five widget titles/descriptions are Chinese — and directly under each metric tile the dataset measure label renders Untouched > 14 days, Untouched > 30 days, Oldest touch.

bulk toolbar

The selection bar reads 已选择 1 项 · Complete · Skip · 清除 — two English words between two Chinese ones, which is exactly the failure mode that reads as a styling quirk rather than a missing translation.

Display text no bundle can reach — filed, not worked around

Per AGENTS.md rule 9 these are filed upstream rather than papered over. Each is a declared verdict with a stated reason, and the exact set is asserted, so a new one fails the gate and one that becomes translatable fails as stale.

surfacecountaudiencefiled
bulkActionDefs label / confirm / params35end userobjectstack-ai/objectstack#14253
dataset label / description / dimension / measure labels26end user (chart axes, tile captions)objectstack-ai/objectstack#14253
custom validation rule message11end user (write refusals)objectstack-ai/objectstack#14253
flow label / description / node / edge labels39designer (Studio)
job, hook, position, permission-set label / description18operator

The validation-message one is worth calling out against the card's scope line ("… validation message … gets a key"): measured on objectql 17.2.0, a rule's message is emitted verbatim ({ field, code: 'rule_violation', message: rule.message }). messages['validation.field.*'] overrides the platform's built-in field catalog — which already ships zh-CN — not an authored rule. So a deployment gets platform refusals in Chinese and author-written refusals in English, and no bundle key can currently change that.

Separately filed: objectstack-ai/objectstack#14254translateAction ignores _actions.ACTION.description and _actions.ACTION.params.*, which TranslationItemSchema declares andvalidateTranslationReferences lints. Those keys are authored here anyway, for the same reason the gantt's viewMode stays authored in src/views/task.view.ts: the key is the spec's own, it is served to REST/OpenAPI/MCP callers today, and it starts rendering the moment the resolver is fixed.

The platform already owns half of this — as a warning

@objectstack/rest ships validateTranslationReferences (translation-target-unknown / translation-option-key-unknown), which covers the reverse direction over objects, fields, option values, views, sections, actions, params, apps, nav ids, dashboards and widgets. Measured by renaming one bundle key to duly_task_undoo:

VALIDATE EXIT=0
✓ Validation passed (419ms)
⚠ locale "zh-CN" · object "duly_task" · action "duly_task_undoo": Translations are keyed to
action "duly_task_undoo", which is defined by neither object "duly_task"'s `actions` nor a
`stack.actions` entry bound to it. The button keeps its source-locale label.
Did you mean "duly_task_undo"?

A warning, exit 0. This gate makes it an error and adds the forward direction, which nothing upstream checks.

Tests

test/i18n-coverage.test.ts — 29 tests. Reverse-verified rather than assumed; each mutation was confirmed on disk by an anchored count before the reading was taken, and restored by an EXIT/INT/TERM trap:

mutationresult
delete objects.duly_task.fields.status.label from zh-CNexit 1 — 2 red, naming the key
rename duly_task_undoduly_task_undoo in zh-CNexit 1 — 3 red, including "every zh-CN key still has a source"
add placeholder: to duly_task.note (a slot the walk had never seen)exit 1 — tripwire fires, naming object.fields{}.placeholder

Plus self-tests on synthetic metadata for every rule, including that a stack with no authored text makes every exemption stale, that prose inside an opaque subtree is caught, and both email-template rules (missing sibling, and a locale row that copies the source row's wording).

Gates

All four green on 0a70856, the final commit, after merging main (which had taken #69):

validate=0 typecheck=0 test=0 build=0 @ 0a70856
✓ Validation passed (one expected warning: hierarchy-security, per AGENTS.md)
Test Files 28 passed (28)
Tests 703 passed (703)
✓ Build complete — dist/objectstack.json (166.8 KB)

Bundle confirmed in the artifact and served live: GET /api/v1/i18n/localesen, zh-CN; GET /api/v1/i18n/translations/zh-CN returns the tree.

No changeset — this repo has no changeset mechanism (no .changeset/, no dependency, no script, no mention in AGENTS.md). The four gates are the whole contract.

Recorded gap

Filed as #102 rather than done here: the bundle half of the gate compares against zh-CNby name, so a third locale added later would satisfy the locale-set assertion and have its key coverage checked by nothing. The email-template half already iterates supportedLocales generically and does not have this problem. Left out deliberately — #18's scope is the first Chinese deployment — and filed so it is a decision on the record rather than a surprise.


Generated by Claude Code

os-warrenand others added 3 commits September 1, 2026 16:06
…rage gate
`dulyTranslations` was empty while the config advertised `zh-CN`, so every
authored label reached a Chinese deployment in English.
- `src/translations/authored-text.ts` walks every string leaf in the metadata
and demands a verdict per normalised path. A path with no verdict is a
finding, so a new key is a red test rather than an unchecked string.
- `src/translations/en.ts` derives the English bundle from that walk. There is
no English literal to hand-edit, so `en` cannot drift from the source.
- `src/translations/zh-CN.ts` is hand-written, 231 keys, with the terminology
decisions (duty/task/period/standing/governed/caliber) recorded in the file.
- `test/i18n-coverage.test.ts` fails on a declared label with no bundle key, a
bundle key with no source, an unclassified path, a stale exemption, prose
inside an opaque subtree, and an email template with no sibling row per
supported locale.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
Merging main made the gate red, which is the gate working:
- The `untranslatable` exemptions for the three `notify` nodes' inline
`title` / `message` failed as STALE — #69 removed those strings, so the
exemption list described metadata that no longer exists.
- Nine string paths #69 introduced (`config.template`, `config.templateData.*`)
had no verdict and failed the discovery tripwire.
Classified them: `template` is a template NAME resolved by `(name, locale)`,
`templateData` is a render payload. The inline-notification count is pinned at
zero so the gap cannot silently reopen as inline copy.
Email templates are NOT bundle-keyed and are deliberately not walked — they
localize by row. The gate now asserts the row-shaped equivalent against real
rows: one row per (name, locale) for every supported locale, and each locale
row carrying its own words rather than a copy of the source row's.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 16:25
@os-warren
os-warren merged commit 7624c99 into mainSep 1, 2026
1 check passed
os-warren added a commit that referenced this pull request Sep 1, 2026
#103's coverage gate went red on merge, exactly as it is built to: the two new
field labels and the new widget had no bundle key, three dataset slots had no
verdict, and the zh-CN dashboard description still explained why lateness was
missing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

i18n — en source bundle, zh-CN translation, CI coverage gate

1 participant

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

i18n: a generated en bundle, a hand-written zh-CN, and a coverage gate that discovers - #103

Merged
os-warren merged 3 commits into
mainfrom
claude/issue-18-i18n-bundles
Sep 1, 2026
Merged

i18n: a generated en bundle, a hand-written zh-CN, and a coverage gate that discovers#103
os-warren merged 3 commits into
mainfrom
claude/issue-18-i18n-bundles

Conversation

@os-warren

@os-warrenos-warren commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#18

objectstack.config.ts advertised supportedLocales: ['en', 'zh-CN'] while dulyTranslations was []. The app claimed Chinese and shipped none of it: every authored label reached a Chinese deployment in English. This closes that.

Verified end to end in a browser: 231 bundle keys, four gates green at 0a70856, and the screens below are a real zh-CN session against pnpm demo.


The constraint that decided the design: en is generated

A hand-edited en entry wins at render time, so the screen and the code disagree while every gate stays green. The usual answer — generate a file, add a staleness check — is only as good as whoever remembers to re-run the generator.

So there is no en literal anywhere in this diff. src/translations/en.ts derives the bundle at config load from the same walk the gate uses. Nothing is stored, so nothing can go stale, and there is nothing a person could usefully hand-edit: changing an English string means changing the label in src/objects/, src/views/ or src/apps/, which is the only place it was ever true.

That property is pinned rather than asserted in a comment — buildEnglishBundle is run over synthetic metadata and the output has to follow the input, which a hand-written bundle could not do. It also goes through defineTranslationBundle, so the strict TranslationDataSchema refuses a mis-built key at load rather than shipping it unread.

The coverage gate discovers; it does not enumerate

src/translations/authored-text.ts visits every string leaf in the metadata and demands a verdict per normalised path ([] for an array index, {} for a record key). A path with no verdict is a finding, and the gate fails naming it. The default is "unknown → red"; the tables are the exception list.

Three verdicts:

verdictmeaning
translatedisplay text with a bundle key — goes into en, zh-CN must carry it
untranslatabledisplay text with no key anywhere in the platform's schema — named, counted, filed, never silently dropped
machinenot display text (a machine name, a CEL source, an icon, a colour, a filter token)

What is translatable at all is the platform's answer, not ours: TRANSLATABLE_METADATA_TYPES is imported from @objectstack/spec/system ({ view, action, object, app, dashboard, page }) and pinned, so the day datasets become translatable the pin goes red and the walk gets extended instead of quietly staying behind. View keys come from the platform's own expandViewContainer, so _views.default cannot drift from the registry name the resolver looks up.

Beyond the two directions the card asks for, the gate fails on: an unclassified path, a stale exemption (an untranslatable verdict that stopped matching anything), prose hiding inside a subtree declared opaque, two strings claiming one key, and a metadata collection in objectstack.config.ts that has never been classified.

It caught #69 on merge, which is the argument for building it this way. Merging main turned it red twice over: the exemptions for the three notify nodes' inline title / message failed as stale because #69 removed those strings, and nine paths #69 introduced (config.template, config.templateData.*) had no verdict. Neither is a string this gate could have been told about in advance. The inline-notification count is now pinned at zero so that gap cannot silently reopen.

Email templates are deliberately not walked

They are not bundle-keyed. translation.zod.ts does not mention them, EmailTemplateDefinitionSchema.translations is z.ZodNever, and the platform resolves a template by (name, locale) — so a translation is a sibling row, not a key. Demanding a bundle key for a template subject would make this gate permanently and unfixably red.

That skip is named with its mechanism in COLLECTION_HANDLING rather than being implied by omission — a type that localizes differently must not look like a type the walk forgot. The equivalent question is asked of the row shape instead, against #69's real rows: one row per (name, locale) for every supportedLocales entry, and each locale row carrying its own words rather than a copy of the source row's. That second check compares literal text with {{holes}} stripped, because subject: '{{{subject}}}' is identical in both rows on purpose — it renders the task's own subject line, and flagging it would be flagging the data.

Terminology

Six words carry product meaning a literal translation loses. The decisions live in the bundle, with their reasoning, because they have to be consistent across 231 keys and are not recoverable from the English string alone.

termzh-CNwhy, and what it must not read as
duty职责the recurring obligation itself — the rule that produces tasks. 职责 is the ordinary word for role-attached duty (岗位职责). ⛔ not 任务 (that is a task), ⛔ not 义务 (legal/moral)
task任务one dispatched occurrence. Paired with 职责 it keeps "a duty is not a task" as sharp in Chinese as in English
period周期, field 所属周期bare 周期 beside 频率 reads as the cadence, not the window — so period_key is "the period it belongs to"
standing常设the register of 常设机构 / 常设委员会: permanent by establishment, by nature not a thing that finishes. ⛔ not 长期 / 持续, which read as an ongoing task — exactly the misreading that sends people looking for the tick box the product refuses to have
governed组织认定"the organisation established it". ⛔ not 纳入考核 ("counted towards assessment") — accurate about the metrics, wrong about a product that scores nobody
caliber口径统计口径 — the standard term. No user-facing label carries it today; recorded so nobody invents a second word later

due_offset_days.label took the judgement the issue's round-2 comment flagged: the parenthetical had to survive as an explanation, so it is 「偏移天数(0 = 锚点当天)」 rather than a word-for-word rendering of "anchor day", and its help text keeps both worked examples per anchor, translated for sense.

Verified in a browser, in Chinese

pnpm demo, a Chromium context with locale: 'zh-CN' and Accept-Language: zh-CN.

The console chrome does not stay English. This was the expected finding and it is not what happens — the platform ships its own zh-CN console bundle and picks it up from the browser locale. 新建 / 导入 / 行内编辑 / 筛选 / 分组 / 排序 / 搜索… / 5 条记录 / 已选择 1 项 / 清除 are all the platform's. Nothing to file against objectui.

task listby unit

Object label, description, view tabs (全部任务 / 我的本周 / 逾期 / 停滞), column headers, option badges (待办 / 进行中, 岗位职责库 / 自行申报), nav groups and every nav item are ours and all render. The English strings visible are seed data (Emissions return — Northgate), correctly untranslated.

Screenshots are on a throwaway branch (claude/issue-18-screenshots) so no binaries enter this diff.

Two gaps the walk predicted, confirmed on screen

dashboard

Dashboard title, description and all five widget titles/descriptions are Chinese — and directly under each metric tile the dataset measure label renders Untouched > 14 days, Untouched > 30 days, Oldest touch.

bulk toolbar

The selection bar reads 已选择 1 项 · Complete · Skip · 清除 — two English words between two Chinese ones, which is exactly the failure mode that reads as a styling quirk rather than a missing translation.

Display text no bundle can reach — filed, not worked around

Per AGENTS.md rule 9 these are filed upstream rather than papered over. Each is a declared verdict with a stated reason, and the exact set is asserted, so a new one fails the gate and one that becomes translatable fails as stale.

surfacecountaudiencefiled
bulkActionDefs label / confirm / params35end userobjectstack-ai/objectstack#14253
dataset label / description / dimension / measure labels26end user (chart axes, tile captions)objectstack-ai/objectstack#14253
custom validation rule message11end user (write refusals)objectstack-ai/objectstack#14253
flow label / description / node / edge labels39designer (Studio)
job, hook, position, permission-set label / description18operator

The validation-message one is worth calling out against the card's scope line ("… validation message … gets a key"): measured on objectql 17.2.0, a rule's message is emitted verbatim ({ field, code: 'rule_violation', message: rule.message }). messages['validation.field.*'] overrides the platform's built-in field catalog — which already ships zh-CN — not an authored rule. So a deployment gets platform refusals in Chinese and author-written refusals in English, and no bundle key can currently change that.

Separately filed: objectstack-ai/objectstack#14254translateAction ignores _actions.ACTION.description and _actions.ACTION.params.*, which TranslationItemSchema declares andvalidateTranslationReferences lints. Those keys are authored here anyway, for the same reason the gantt's viewMode stays authored in src/views/task.view.ts: the key is the spec's own, it is served to REST/OpenAPI/MCP callers today, and it starts rendering the moment the resolver is fixed.

The platform already owns half of this — as a warning

@objectstack/rest ships validateTranslationReferences (translation-target-unknown / translation-option-key-unknown), which covers the reverse direction over objects, fields, option values, views, sections, actions, params, apps, nav ids, dashboards and widgets. Measured by renaming one bundle key to duly_task_undoo:

VALIDATE EXIT=0
✓ Validation passed (419ms)
⚠ locale "zh-CN" · object "duly_task" · action "duly_task_undoo": Translations are keyed to
action "duly_task_undoo", which is defined by neither object "duly_task"'s `actions` nor a
`stack.actions` entry bound to it. The button keeps its source-locale label.
Did you mean "duly_task_undo"?

A warning, exit 0. This gate makes it an error and adds the forward direction, which nothing upstream checks.

Tests

test/i18n-coverage.test.ts — 29 tests. Reverse-verified rather than assumed; each mutation was confirmed on disk by an anchored count before the reading was taken, and restored by an EXIT/INT/TERM trap:

mutationresult
delete objects.duly_task.fields.status.label from zh-CNexit 1 — 2 red, naming the key
rename duly_task_undoduly_task_undoo in zh-CNexit 1 — 3 red, including "every zh-CN key still has a source"
add placeholder: to duly_task.note (a slot the walk had never seen)exit 1 — tripwire fires, naming object.fields{}.placeholder

Plus self-tests on synthetic metadata for every rule, including that a stack with no authored text makes every exemption stale, that prose inside an opaque subtree is caught, and both email-template rules (missing sibling, and a locale row that copies the source row's wording).

Gates

All four green on 0a70856, the final commit, after merging main (which had taken #69):

validate=0 typecheck=0 test=0 build=0 @ 0a70856
✓ Validation passed (one expected warning: hierarchy-security, per AGENTS.md)
Test Files 28 passed (28)
Tests 703 passed (703)
✓ Build complete — dist/objectstack.json (166.8 KB)

Bundle confirmed in the artifact and served live: GET /api/v1/i18n/localesen, zh-CN; GET /api/v1/i18n/translations/zh-CN returns the tree.

No changeset — this repo has no changeset mechanism (no .changeset/, no dependency, no script, no mention in AGENTS.md). The four gates are the whole contract.

Recorded gap

Filed as #102 rather than done here: the bundle half of the gate compares against zh-CNby name, so a third locale added later would satisfy the locale-set assertion and have its key coverage checked by nothing. The email-template half already iterates supportedLocales generically and does not have this problem. Left out deliberately — #18's scope is the first Chinese deployment — and filed so it is a decision on the record rather than a surprise.


Generated by Claude Code

os-warrenand others added 3 commits September 1, 2026 16:06
…rage gate
`dulyTranslations` was empty while the config advertised `zh-CN`, so every
authored label reached a Chinese deployment in English.
- `src/translations/authored-text.ts` walks every string leaf in the metadata
and demands a verdict per normalised path. A path with no verdict is a
finding, so a new key is a red test rather than an unchecked string.
- `src/translations/en.ts` derives the English bundle from that walk. There is
no English literal to hand-edit, so `en` cannot drift from the source.
- `src/translations/zh-CN.ts` is hand-written, 231 keys, with the terminology
decisions (duty/task/period/standing/governed/caliber) recorded in the file.
- `test/i18n-coverage.test.ts` fails on a declared label with no bundle key, a
bundle key with no source, an unclassified path, a stale exemption, prose
inside an opaque subtree, and an email template with no sibling row per
supported locale.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
Merging main made the gate red, which is the gate working:
- The `untranslatable` exemptions for the three `notify` nodes' inline
`title` / `message` failed as STALE — #69 removed those strings, so the
exemption list described metadata that no longer exists.
- Nine string paths #69 introduced (`config.template`, `config.templateData.*`)
had no verdict and failed the discovery tripwire.
Classified them: `template` is a template NAME resolved by `(name, locale)`,
`templateData` is a render payload. The inline-notification count is pinned at
zero so the gap cannot silently reopen as inline copy.
Email templates are NOT bundle-keyed and are deliberately not walked — they
localize by row. The gate now asserts the row-shaped equivalent against real
rows: one row per (name, locale) for every supported locale, and each locale
row carrying its own words rather than a copy of the source row's.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 16:25
@os-warren
os-warren merged commit 7624c99 into mainSep 1, 2026
1 check passed
os-warren added a commit that referenced this pull request Sep 1, 2026
#103's coverage gate went red on merge, exactly as it is built to: the two new
field labels and the new widget had no bundle key, three dataset slots had no
verdict, and the zh-CN dashboard description still explained why lateness was
missing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

i18n — en source bundle, zh-CN translation, CI coverage gate

1 participant

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

i18n: a generated en bundle, a hand-written zh-CN, and a coverage gate that discovers - #103

Merged
os-warren merged 3 commits into
mainfrom
claude/issue-18-i18n-bundles
Sep 1, 2026
Merged

i18n: a generated en bundle, a hand-written zh-CN, and a coverage gate that discovers#103
os-warren merged 3 commits into
mainfrom
claude/issue-18-i18n-bundles

Conversation

@os-warren

@os-warrenos-warren commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#18

objectstack.config.ts advertised supportedLocales: ['en', 'zh-CN'] while dulyTranslations was []. The app claimed Chinese and shipped none of it: every authored label reached a Chinese deployment in English. This closes that.

Verified end to end in a browser: 231 bundle keys, four gates green at 0a70856, and the screens below are a real zh-CN session against pnpm demo.


The constraint that decided the design: en is generated

A hand-edited en entry wins at render time, so the screen and the code disagree while every gate stays green. The usual answer — generate a file, add a staleness check — is only as good as whoever remembers to re-run the generator.

So there is no en literal anywhere in this diff. src/translations/en.ts derives the bundle at config load from the same walk the gate uses. Nothing is stored, so nothing can go stale, and there is nothing a person could usefully hand-edit: changing an English string means changing the label in src/objects/, src/views/ or src/apps/, which is the only place it was ever true.

That property is pinned rather than asserted in a comment — buildEnglishBundle is run over synthetic metadata and the output has to follow the input, which a hand-written bundle could not do. It also goes through defineTranslationBundle, so the strict TranslationDataSchema refuses a mis-built key at load rather than shipping it unread.

The coverage gate discovers; it does not enumerate

src/translations/authored-text.ts visits every string leaf in the metadata and demands a verdict per normalised path ([] for an array index, {} for a record key). A path with no verdict is a finding, and the gate fails naming it. The default is "unknown → red"; the tables are the exception list.

Three verdicts:

verdictmeaning
translatedisplay text with a bundle key — goes into en, zh-CN must carry it
untranslatabledisplay text with no key anywhere in the platform's schema — named, counted, filed, never silently dropped
machinenot display text (a machine name, a CEL source, an icon, a colour, a filter token)

What is translatable at all is the platform's answer, not ours: TRANSLATABLE_METADATA_TYPES is imported from @objectstack/spec/system ({ view, action, object, app, dashboard, page }) and pinned, so the day datasets become translatable the pin goes red and the walk gets extended instead of quietly staying behind. View keys come from the platform's own expandViewContainer, so _views.default cannot drift from the registry name the resolver looks up.

Beyond the two directions the card asks for, the gate fails on: an unclassified path, a stale exemption (an untranslatable verdict that stopped matching anything), prose hiding inside a subtree declared opaque, two strings claiming one key, and a metadata collection in objectstack.config.ts that has never been classified.

It caught #69 on merge, which is the argument for building it this way. Merging main turned it red twice over: the exemptions for the three notify nodes' inline title / message failed as stale because #69 removed those strings, and nine paths #69 introduced (config.template, config.templateData.*) had no verdict. Neither is a string this gate could have been told about in advance. The inline-notification count is now pinned at zero so that gap cannot silently reopen.

Email templates are deliberately not walked

They are not bundle-keyed. translation.zod.ts does not mention them, EmailTemplateDefinitionSchema.translations is z.ZodNever, and the platform resolves a template by (name, locale) — so a translation is a sibling row, not a key. Demanding a bundle key for a template subject would make this gate permanently and unfixably red.

That skip is named with its mechanism in COLLECTION_HANDLING rather than being implied by omission — a type that localizes differently must not look like a type the walk forgot. The equivalent question is asked of the row shape instead, against #69's real rows: one row per (name, locale) for every supportedLocales entry, and each locale row carrying its own words rather than a copy of the source row's. That second check compares literal text with {{holes}} stripped, because subject: '{{{subject}}}' is identical in both rows on purpose — it renders the task's own subject line, and flagging it would be flagging the data.

Terminology

Six words carry product meaning a literal translation loses. The decisions live in the bundle, with their reasoning, because they have to be consistent across 231 keys and are not recoverable from the English string alone.

termzh-CNwhy, and what it must not read as
duty职责the recurring obligation itself — the rule that produces tasks. 职责 is the ordinary word for role-attached duty (岗位职责). ⛔ not 任务 (that is a task), ⛔ not 义务 (legal/moral)
task任务one dispatched occurrence. Paired with 职责 it keeps "a duty is not a task" as sharp in Chinese as in English
period周期, field 所属周期bare 周期 beside 频率 reads as the cadence, not the window — so period_key is "the period it belongs to"
standing常设the register of 常设机构 / 常设委员会: permanent by establishment, by nature not a thing that finishes. ⛔ not 长期 / 持续, which read as an ongoing task — exactly the misreading that sends people looking for the tick box the product refuses to have
governed组织认定"the organisation established it". ⛔ not 纳入考核 ("counted towards assessment") — accurate about the metrics, wrong about a product that scores nobody
caliber口径统计口径 — the standard term. No user-facing label carries it today; recorded so nobody invents a second word later

due_offset_days.label took the judgement the issue's round-2 comment flagged: the parenthetical had to survive as an explanation, so it is 「偏移天数(0 = 锚点当天)」 rather than a word-for-word rendering of "anchor day", and its help text keeps both worked examples per anchor, translated for sense.

Verified in a browser, in Chinese

pnpm demo, a Chromium context with locale: 'zh-CN' and Accept-Language: zh-CN.

The console chrome does not stay English. This was the expected finding and it is not what happens — the platform ships its own zh-CN console bundle and picks it up from the browser locale. 新建 / 导入 / 行内编辑 / 筛选 / 分组 / 排序 / 搜索… / 5 条记录 / 已选择 1 项 / 清除 are all the platform's. Nothing to file against objectui.

task listby unit

Object label, description, view tabs (全部任务 / 我的本周 / 逾期 / 停滞), column headers, option badges (待办 / 进行中, 岗位职责库 / 自行申报), nav groups and every nav item are ours and all render. The English strings visible are seed data (Emissions return — Northgate), correctly untranslated.

Screenshots are on a throwaway branch (claude/issue-18-screenshots) so no binaries enter this diff.

Two gaps the walk predicted, confirmed on screen

dashboard

Dashboard title, description and all five widget titles/descriptions are Chinese — and directly under each metric tile the dataset measure label renders Untouched > 14 days, Untouched > 30 days, Oldest touch.

bulk toolbar

The selection bar reads 已选择 1 项 · Complete · Skip · 清除 — two English words between two Chinese ones, which is exactly the failure mode that reads as a styling quirk rather than a missing translation.

Display text no bundle can reach — filed, not worked around

Per AGENTS.md rule 9 these are filed upstream rather than papered over. Each is a declared verdict with a stated reason, and the exact set is asserted, so a new one fails the gate and one that becomes translatable fails as stale.

surfacecountaudiencefiled
bulkActionDefs label / confirm / params35end userobjectstack-ai/objectstack#14253
dataset label / description / dimension / measure labels26end user (chart axes, tile captions)objectstack-ai/objectstack#14253
custom validation rule message11end user (write refusals)objectstack-ai/objectstack#14253
flow label / description / node / edge labels39designer (Studio)
job, hook, position, permission-set label / description18operator

The validation-message one is worth calling out against the card's scope line ("… validation message … gets a key"): measured on objectql 17.2.0, a rule's message is emitted verbatim ({ field, code: 'rule_violation', message: rule.message }). messages['validation.field.*'] overrides the platform's built-in field catalog — which already ships zh-CN — not an authored rule. So a deployment gets platform refusals in Chinese and author-written refusals in English, and no bundle key can currently change that.

Separately filed: objectstack-ai/objectstack#14254translateAction ignores _actions.ACTION.description and _actions.ACTION.params.*, which TranslationItemSchema declares andvalidateTranslationReferences lints. Those keys are authored here anyway, for the same reason the gantt's viewMode stays authored in src/views/task.view.ts: the key is the spec's own, it is served to REST/OpenAPI/MCP callers today, and it starts rendering the moment the resolver is fixed.

The platform already owns half of this — as a warning

@objectstack/rest ships validateTranslationReferences (translation-target-unknown / translation-option-key-unknown), which covers the reverse direction over objects, fields, option values, views, sections, actions, params, apps, nav ids, dashboards and widgets. Measured by renaming one bundle key to duly_task_undoo:

VALIDATE EXIT=0
✓ Validation passed (419ms)
⚠ locale "zh-CN" · object "duly_task" · action "duly_task_undoo": Translations are keyed to
action "duly_task_undoo", which is defined by neither object "duly_task"'s `actions` nor a
`stack.actions` entry bound to it. The button keeps its source-locale label.
Did you mean "duly_task_undo"?

A warning, exit 0. This gate makes it an error and adds the forward direction, which nothing upstream checks.

Tests

test/i18n-coverage.test.ts — 29 tests. Reverse-verified rather than assumed; each mutation was confirmed on disk by an anchored count before the reading was taken, and restored by an EXIT/INT/TERM trap:

mutationresult
delete objects.duly_task.fields.status.label from zh-CNexit 1 — 2 red, naming the key
rename duly_task_undoduly_task_undoo in zh-CNexit 1 — 3 red, including "every zh-CN key still has a source"
add placeholder: to duly_task.note (a slot the walk had never seen)exit 1 — tripwire fires, naming object.fields{}.placeholder

Plus self-tests on synthetic metadata for every rule, including that a stack with no authored text makes every exemption stale, that prose inside an opaque subtree is caught, and both email-template rules (missing sibling, and a locale row that copies the source row's wording).

Gates

All four green on 0a70856, the final commit, after merging main (which had taken #69):

validate=0 typecheck=0 test=0 build=0 @ 0a70856
✓ Validation passed (one expected warning: hierarchy-security, per AGENTS.md)
Test Files 28 passed (28)
Tests 703 passed (703)
✓ Build complete — dist/objectstack.json (166.8 KB)

Bundle confirmed in the artifact and served live: GET /api/v1/i18n/localesen, zh-CN; GET /api/v1/i18n/translations/zh-CN returns the tree.

No changeset — this repo has no changeset mechanism (no .changeset/, no dependency, no script, no mention in AGENTS.md). The four gates are the whole contract.

Recorded gap

Filed as #102 rather than done here: the bundle half of the gate compares against zh-CNby name, so a third locale added later would satisfy the locale-set assertion and have its key coverage checked by nothing. The email-template half already iterates supportedLocales generically and does not have this problem. Left out deliberately — #18's scope is the first Chinese deployment — and filed so it is a decision on the record rather than a surprise.


Generated by Claude Code

os-warrenand others added 3 commits September 1, 2026 16:06
…rage gate
`dulyTranslations` was empty while the config advertised `zh-CN`, so every
authored label reached a Chinese deployment in English.
- `src/translations/authored-text.ts` walks every string leaf in the metadata
and demands a verdict per normalised path. A path with no verdict is a
finding, so a new key is a red test rather than an unchecked string.
- `src/translations/en.ts` derives the English bundle from that walk. There is
no English literal to hand-edit, so `en` cannot drift from the source.
- `src/translations/zh-CN.ts` is hand-written, 231 keys, with the terminology
decisions (duty/task/period/standing/governed/caliber) recorded in the file.
- `test/i18n-coverage.test.ts` fails on a declared label with no bundle key, a
bundle key with no source, an unclassified path, a stale exemption, prose
inside an opaque subtree, and an email template with no sibling row per
supported locale.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
Merging main made the gate red, which is the gate working:
- The `untranslatable` exemptions for the three `notify` nodes' inline
`title` / `message` failed as STALE — #69 removed those strings, so the
exemption list described metadata that no longer exists.
- Nine string paths #69 introduced (`config.template`, `config.templateData.*`)
had no verdict and failed the discovery tripwire.
Classified them: `template` is a template NAME resolved by `(name, locale)`,
`templateData` is a render payload. The inline-notification count is pinned at
zero so the gap cannot silently reopen as inline copy.
Email templates are NOT bundle-keyed and are deliberately not walked — they
localize by row. The gate now asserts the row-shaped equivalent against real
rows: one row per (name, locale) for every supported locale, and each locale
row carrying its own words rather than a copy of the source row's.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 16:25
@os-warren
os-warren merged commit 7624c99 into mainSep 1, 2026
1 check passed
os-warren added a commit that referenced this pull request Sep 1, 2026
#103's coverage gate went red on merge, exactly as it is built to: the two new
field labels and the new widget had no bundle key, three dataset slots had no
verdict, and the zh-CN dashboard description still explained why lateness was
missing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

i18n — en source bundle, zh-CN translation, CI coverage gate

1 participant

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

i18n: a generated en bundle, a hand-written zh-CN, and a coverage gate that discovers - #103

Merged
os-warren merged 3 commits into
mainfrom
claude/issue-18-i18n-bundles
Sep 1, 2026
Merged

i18n: a generated en bundle, a hand-written zh-CN, and a coverage gate that discovers#103
os-warren merged 3 commits into
mainfrom
claude/issue-18-i18n-bundles

Conversation

@os-warren

@os-warrenos-warren commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#18

objectstack.config.ts advertised supportedLocales: ['en', 'zh-CN'] while dulyTranslations was []. The app claimed Chinese and shipped none of it: every authored label reached a Chinese deployment in English. This closes that.

Verified end to end in a browser: 231 bundle keys, four gates green at 0a70856, and the screens below are a real zh-CN session against pnpm demo.


The constraint that decided the design: en is generated

A hand-edited en entry wins at render time, so the screen and the code disagree while every gate stays green. The usual answer — generate a file, add a staleness check — is only as good as whoever remembers to re-run the generator.

So there is no en literal anywhere in this diff. src/translations/en.ts derives the bundle at config load from the same walk the gate uses. Nothing is stored, so nothing can go stale, and there is nothing a person could usefully hand-edit: changing an English string means changing the label in src/objects/, src/views/ or src/apps/, which is the only place it was ever true.

That property is pinned rather than asserted in a comment — buildEnglishBundle is run over synthetic metadata and the output has to follow the input, which a hand-written bundle could not do. It also goes through defineTranslationBundle, so the strict TranslationDataSchema refuses a mis-built key at load rather than shipping it unread.

The coverage gate discovers; it does not enumerate

src/translations/authored-text.ts visits every string leaf in the metadata and demands a verdict per normalised path ([] for an array index, {} for a record key). A path with no verdict is a finding, and the gate fails naming it. The default is "unknown → red"; the tables are the exception list.

Three verdicts:

verdictmeaning
translatedisplay text with a bundle key — goes into en, zh-CN must carry it
untranslatabledisplay text with no key anywhere in the platform's schema — named, counted, filed, never silently dropped
machinenot display text (a machine name, a CEL source, an icon, a colour, a filter token)

What is translatable at all is the platform's answer, not ours: TRANSLATABLE_METADATA_TYPES is imported from @objectstack/spec/system ({ view, action, object, app, dashboard, page }) and pinned, so the day datasets become translatable the pin goes red and the walk gets extended instead of quietly staying behind. View keys come from the platform's own expandViewContainer, so _views.default cannot drift from the registry name the resolver looks up.

Beyond the two directions the card asks for, the gate fails on: an unclassified path, a stale exemption (an untranslatable verdict that stopped matching anything), prose hiding inside a subtree declared opaque, two strings claiming one key, and a metadata collection in objectstack.config.ts that has never been classified.

It caught #69 on merge, which is the argument for building it this way. Merging main turned it red twice over: the exemptions for the three notify nodes' inline title / message failed as stale because #69 removed those strings, and nine paths #69 introduced (config.template, config.templateData.*) had no verdict. Neither is a string this gate could have been told about in advance. The inline-notification count is now pinned at zero so that gap cannot silently reopen.

Email templates are deliberately not walked

They are not bundle-keyed. translation.zod.ts does not mention them, EmailTemplateDefinitionSchema.translations is z.ZodNever, and the platform resolves a template by (name, locale) — so a translation is a sibling row, not a key. Demanding a bundle key for a template subject would make this gate permanently and unfixably red.

That skip is named with its mechanism in COLLECTION_HANDLING rather than being implied by omission — a type that localizes differently must not look like a type the walk forgot. The equivalent question is asked of the row shape instead, against #69's real rows: one row per (name, locale) for every supportedLocales entry, and each locale row carrying its own words rather than a copy of the source row's. That second check compares literal text with {{holes}} stripped, because subject: '{{{subject}}}' is identical in both rows on purpose — it renders the task's own subject line, and flagging it would be flagging the data.

Terminology

Six words carry product meaning a literal translation loses. The decisions live in the bundle, with their reasoning, because they have to be consistent across 231 keys and are not recoverable from the English string alone.

termzh-CNwhy, and what it must not read as
duty职责the recurring obligation itself — the rule that produces tasks. 职责 is the ordinary word for role-attached duty (岗位职责). ⛔ not 任务 (that is a task), ⛔ not 义务 (legal/moral)
task任务one dispatched occurrence. Paired with 职责 it keeps "a duty is not a task" as sharp in Chinese as in English
period周期, field 所属周期bare 周期 beside 频率 reads as the cadence, not the window — so period_key is "the period it belongs to"
standing常设the register of 常设机构 / 常设委员会: permanent by establishment, by nature not a thing that finishes. ⛔ not 长期 / 持续, which read as an ongoing task — exactly the misreading that sends people looking for the tick box the product refuses to have
governed组织认定"the organisation established it". ⛔ not 纳入考核 ("counted towards assessment") — accurate about the metrics, wrong about a product that scores nobody
caliber口径统计口径 — the standard term. No user-facing label carries it today; recorded so nobody invents a second word later

due_offset_days.label took the judgement the issue's round-2 comment flagged: the parenthetical had to survive as an explanation, so it is 「偏移天数(0 = 锚点当天)」 rather than a word-for-word rendering of "anchor day", and its help text keeps both worked examples per anchor, translated for sense.

Verified in a browser, in Chinese

pnpm demo, a Chromium context with locale: 'zh-CN' and Accept-Language: zh-CN.

The console chrome does not stay English. This was the expected finding and it is not what happens — the platform ships its own zh-CN console bundle and picks it up from the browser locale. 新建 / 导入 / 行内编辑 / 筛选 / 分组 / 排序 / 搜索… / 5 条记录 / 已选择 1 项 / 清除 are all the platform's. Nothing to file against objectui.

task listby unit

Object label, description, view tabs (全部任务 / 我的本周 / 逾期 / 停滞), column headers, option badges (待办 / 进行中, 岗位职责库 / 自行申报), nav groups and every nav item are ours and all render. The English strings visible are seed data (Emissions return — Northgate), correctly untranslated.

Screenshots are on a throwaway branch (claude/issue-18-screenshots) so no binaries enter this diff.

Two gaps the walk predicted, confirmed on screen

dashboard

Dashboard title, description and all five widget titles/descriptions are Chinese — and directly under each metric tile the dataset measure label renders Untouched > 14 days, Untouched > 30 days, Oldest touch.

bulk toolbar

The selection bar reads 已选择 1 项 · Complete · Skip · 清除 — two English words between two Chinese ones, which is exactly the failure mode that reads as a styling quirk rather than a missing translation.

Display text no bundle can reach — filed, not worked around

Per AGENTS.md rule 9 these are filed upstream rather than papered over. Each is a declared verdict with a stated reason, and the exact set is asserted, so a new one fails the gate and one that becomes translatable fails as stale.

surfacecountaudiencefiled
bulkActionDefs label / confirm / params35end userobjectstack-ai/objectstack#14253
dataset label / description / dimension / measure labels26end user (chart axes, tile captions)objectstack-ai/objectstack#14253
custom validation rule message11end user (write refusals)objectstack-ai/objectstack#14253
flow label / description / node / edge labels39designer (Studio)
job, hook, position, permission-set label / description18operator

The validation-message one is worth calling out against the card's scope line ("… validation message … gets a key"): measured on objectql 17.2.0, a rule's message is emitted verbatim ({ field, code: 'rule_violation', message: rule.message }). messages['validation.field.*'] overrides the platform's built-in field catalog — which already ships zh-CN — not an authored rule. So a deployment gets platform refusals in Chinese and author-written refusals in English, and no bundle key can currently change that.

Separately filed: objectstack-ai/objectstack#14254translateAction ignores _actions.ACTION.description and _actions.ACTION.params.*, which TranslationItemSchema declares andvalidateTranslationReferences lints. Those keys are authored here anyway, for the same reason the gantt's viewMode stays authored in src/views/task.view.ts: the key is the spec's own, it is served to REST/OpenAPI/MCP callers today, and it starts rendering the moment the resolver is fixed.

The platform already owns half of this — as a warning

@objectstack/rest ships validateTranslationReferences (translation-target-unknown / translation-option-key-unknown), which covers the reverse direction over objects, fields, option values, views, sections, actions, params, apps, nav ids, dashboards and widgets. Measured by renaming one bundle key to duly_task_undoo:

VALIDATE EXIT=0
✓ Validation passed (419ms)
⚠ locale "zh-CN" · object "duly_task" · action "duly_task_undoo": Translations are keyed to
action "duly_task_undoo", which is defined by neither object "duly_task"'s `actions` nor a
`stack.actions` entry bound to it. The button keeps its source-locale label.
Did you mean "duly_task_undo"?

A warning, exit 0. This gate makes it an error and adds the forward direction, which nothing upstream checks.

Tests

test/i18n-coverage.test.ts — 29 tests. Reverse-verified rather than assumed; each mutation was confirmed on disk by an anchored count before the reading was taken, and restored by an EXIT/INT/TERM trap:

mutationresult
delete objects.duly_task.fields.status.label from zh-CNexit 1 — 2 red, naming the key
rename duly_task_undoduly_task_undoo in zh-CNexit 1 — 3 red, including "every zh-CN key still has a source"
add placeholder: to duly_task.note (a slot the walk had never seen)exit 1 — tripwire fires, naming object.fields{}.placeholder

Plus self-tests on synthetic metadata for every rule, including that a stack with no authored text makes every exemption stale, that prose inside an opaque subtree is caught, and both email-template rules (missing sibling, and a locale row that copies the source row's wording).

Gates

All four green on 0a70856, the final commit, after merging main (which had taken #69):

validate=0 typecheck=0 test=0 build=0 @ 0a70856
✓ Validation passed (one expected warning: hierarchy-security, per AGENTS.md)
Test Files 28 passed (28)
Tests 703 passed (703)
✓ Build complete — dist/objectstack.json (166.8 KB)

Bundle confirmed in the artifact and served live: GET /api/v1/i18n/localesen, zh-CN; GET /api/v1/i18n/translations/zh-CN returns the tree.

No changeset — this repo has no changeset mechanism (no .changeset/, no dependency, no script, no mention in AGENTS.md). The four gates are the whole contract.

Recorded gap

Filed as #102 rather than done here: the bundle half of the gate compares against zh-CNby name, so a third locale added later would satisfy the locale-set assertion and have its key coverage checked by nothing. The email-template half already iterates supportedLocales generically and does not have this problem. Left out deliberately — #18's scope is the first Chinese deployment — and filed so it is a decision on the record rather than a surprise.


Generated by Claude Code

os-warrenand others added 3 commits September 1, 2026 16:06
…rage gate
`dulyTranslations` was empty while the config advertised `zh-CN`, so every
authored label reached a Chinese deployment in English.
- `src/translations/authored-text.ts` walks every string leaf in the metadata
and demands a verdict per normalised path. A path with no verdict is a
finding, so a new key is a red test rather than an unchecked string.
- `src/translations/en.ts` derives the English bundle from that walk. There is
no English literal to hand-edit, so `en` cannot drift from the source.
- `src/translations/zh-CN.ts` is hand-written, 231 keys, with the terminology
decisions (duty/task/period/standing/governed/caliber) recorded in the file.
- `test/i18n-coverage.test.ts` fails on a declared label with no bundle key, a
bundle key with no source, an unclassified path, a stale exemption, prose
inside an opaque subtree, and an email template with no sibling row per
supported locale.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
Merging main made the gate red, which is the gate working:
- The `untranslatable` exemptions for the three `notify` nodes' inline
`title` / `message` failed as STALE — #69 removed those strings, so the
exemption list described metadata that no longer exists.
- Nine string paths #69 introduced (`config.template`, `config.templateData.*`)
had no verdict and failed the discovery tripwire.
Classified them: `template` is a template NAME resolved by `(name, locale)`,
`templateData` is a render payload. The inline-notification count is pinned at
zero so the gap cannot silently reopen as inline copy.
Email templates are NOT bundle-keyed and are deliberately not walked — they
localize by row. The gate now asserts the row-shaped equivalent against real
rows: one row per (name, locale) for every supported locale, and each locale
row carrying its own words rather than a copy of the source row's.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 16:25
@os-warren
os-warren merged commit 7624c99 into mainSep 1, 2026
1 check passed
os-warren added a commit that referenced this pull request Sep 1, 2026
#103's coverage gate went red on merge, exactly as it is built to: the two new
field labels and the new widget had no bundle key, three dataset slots had no
verdict, and the zh-CN dashboard description still explained why lateness was
missing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

i18n — en source bundle, zh-CN translation, CI coverage gate

1 participant

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

i18n: a generated en bundle, a hand-written zh-CN, and a coverage gate that discovers - #103

Merged
os-warren merged 3 commits into
mainfrom
claude/issue-18-i18n-bundles
Sep 1, 2026
Merged

i18n: a generated en bundle, a hand-written zh-CN, and a coverage gate that discovers#103
os-warren merged 3 commits into
mainfrom
claude/issue-18-i18n-bundles

Conversation

@os-warren

@os-warrenos-warren commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#18

objectstack.config.ts advertised supportedLocales: ['en', 'zh-CN'] while dulyTranslations was []. The app claimed Chinese and shipped none of it: every authored label reached a Chinese deployment in English. This closes that.

Verified end to end in a browser: 231 bundle keys, four gates green at 0a70856, and the screens below are a real zh-CN session against pnpm demo.


The constraint that decided the design: en is generated

A hand-edited en entry wins at render time, so the screen and the code disagree while every gate stays green. The usual answer — generate a file, add a staleness check — is only as good as whoever remembers to re-run the generator.

So there is no en literal anywhere in this diff. src/translations/en.ts derives the bundle at config load from the same walk the gate uses. Nothing is stored, so nothing can go stale, and there is nothing a person could usefully hand-edit: changing an English string means changing the label in src/objects/, src/views/ or src/apps/, which is the only place it was ever true.

That property is pinned rather than asserted in a comment — buildEnglishBundle is run over synthetic metadata and the output has to follow the input, which a hand-written bundle could not do. It also goes through defineTranslationBundle, so the strict TranslationDataSchema refuses a mis-built key at load rather than shipping it unread.

The coverage gate discovers; it does not enumerate

src/translations/authored-text.ts visits every string leaf in the metadata and demands a verdict per normalised path ([] for an array index, {} for a record key). A path with no verdict is a finding, and the gate fails naming it. The default is "unknown → red"; the tables are the exception list.

Three verdicts:

verdictmeaning
translatedisplay text with a bundle key — goes into en, zh-CN must carry it
untranslatabledisplay text with no key anywhere in the platform's schema — named, counted, filed, never silently dropped
machinenot display text (a machine name, a CEL source, an icon, a colour, a filter token)

What is translatable at all is the platform's answer, not ours: TRANSLATABLE_METADATA_TYPES is imported from @objectstack/spec/system ({ view, action, object, app, dashboard, page }) and pinned, so the day datasets become translatable the pin goes red and the walk gets extended instead of quietly staying behind. View keys come from the platform's own expandViewContainer, so _views.default cannot drift from the registry name the resolver looks up.

Beyond the two directions the card asks for, the gate fails on: an unclassified path, a stale exemption (an untranslatable verdict that stopped matching anything), prose hiding inside a subtree declared opaque, two strings claiming one key, and a metadata collection in objectstack.config.ts that has never been classified.

It caught #69 on merge, which is the argument for building it this way. Merging main turned it red twice over: the exemptions for the three notify nodes' inline title / message failed as stale because #69 removed those strings, and nine paths #69 introduced (config.template, config.templateData.*) had no verdict. Neither is a string this gate could have been told about in advance. The inline-notification count is now pinned at zero so that gap cannot silently reopen.

Email templates are deliberately not walked

They are not bundle-keyed. translation.zod.ts does not mention them, EmailTemplateDefinitionSchema.translations is z.ZodNever, and the platform resolves a template by (name, locale) — so a translation is a sibling row, not a key. Demanding a bundle key for a template subject would make this gate permanently and unfixably red.

That skip is named with its mechanism in COLLECTION_HANDLING rather than being implied by omission — a type that localizes differently must not look like a type the walk forgot. The equivalent question is asked of the row shape instead, against #69's real rows: one row per (name, locale) for every supportedLocales entry, and each locale row carrying its own words rather than a copy of the source row's. That second check compares literal text with {{holes}} stripped, because subject: '{{{subject}}}' is identical in both rows on purpose — it renders the task's own subject line, and flagging it would be flagging the data.

Terminology

Six words carry product meaning a literal translation loses. The decisions live in the bundle, with their reasoning, because they have to be consistent across 231 keys and are not recoverable from the English string alone.

termzh-CNwhy, and what it must not read as
duty职责the recurring obligation itself — the rule that produces tasks. 职责 is the ordinary word for role-attached duty (岗位职责). ⛔ not 任务 (that is a task), ⛔ not 义务 (legal/moral)
task任务one dispatched occurrence. Paired with 职责 it keeps "a duty is not a task" as sharp in Chinese as in English
period周期, field 所属周期bare 周期 beside 频率 reads as the cadence, not the window — so period_key is "the period it belongs to"
standing常设the register of 常设机构 / 常设委员会: permanent by establishment, by nature not a thing that finishes. ⛔ not 长期 / 持续, which read as an ongoing task — exactly the misreading that sends people looking for the tick box the product refuses to have
governed组织认定"the organisation established it". ⛔ not 纳入考核 ("counted towards assessment") — accurate about the metrics, wrong about a product that scores nobody
caliber口径统计口径 — the standard term. No user-facing label carries it today; recorded so nobody invents a second word later

due_offset_days.label took the judgement the issue's round-2 comment flagged: the parenthetical had to survive as an explanation, so it is 「偏移天数(0 = 锚点当天)」 rather than a word-for-word rendering of "anchor day", and its help text keeps both worked examples per anchor, translated for sense.

Verified in a browser, in Chinese

pnpm demo, a Chromium context with locale: 'zh-CN' and Accept-Language: zh-CN.

The console chrome does not stay English. This was the expected finding and it is not what happens — the platform ships its own zh-CN console bundle and picks it up from the browser locale. 新建 / 导入 / 行内编辑 / 筛选 / 分组 / 排序 / 搜索… / 5 条记录 / 已选择 1 项 / 清除 are all the platform's. Nothing to file against objectui.

task listby unit

Object label, description, view tabs (全部任务 / 我的本周 / 逾期 / 停滞), column headers, option badges (待办 / 进行中, 岗位职责库 / 自行申报), nav groups and every nav item are ours and all render. The English strings visible are seed data (Emissions return — Northgate), correctly untranslated.

Screenshots are on a throwaway branch (claude/issue-18-screenshots) so no binaries enter this diff.

Two gaps the walk predicted, confirmed on screen

dashboard

Dashboard title, description and all five widget titles/descriptions are Chinese — and directly under each metric tile the dataset measure label renders Untouched > 14 days, Untouched > 30 days, Oldest touch.

bulk toolbar

The selection bar reads 已选择 1 项 · Complete · Skip · 清除 — two English words between two Chinese ones, which is exactly the failure mode that reads as a styling quirk rather than a missing translation.

Display text no bundle can reach — filed, not worked around

Per AGENTS.md rule 9 these are filed upstream rather than papered over. Each is a declared verdict with a stated reason, and the exact set is asserted, so a new one fails the gate and one that becomes translatable fails as stale.

surfacecountaudiencefiled
bulkActionDefs label / confirm / params35end userobjectstack-ai/objectstack#14253
dataset label / description / dimension / measure labels26end user (chart axes, tile captions)objectstack-ai/objectstack#14253
custom validation rule message11end user (write refusals)objectstack-ai/objectstack#14253
flow label / description / node / edge labels39designer (Studio)
job, hook, position, permission-set label / description18operator

The validation-message one is worth calling out against the card's scope line ("… validation message … gets a key"): measured on objectql 17.2.0, a rule's message is emitted verbatim ({ field, code: 'rule_violation', message: rule.message }). messages['validation.field.*'] overrides the platform's built-in field catalog — which already ships zh-CN — not an authored rule. So a deployment gets platform refusals in Chinese and author-written refusals in English, and no bundle key can currently change that.

Separately filed: objectstack-ai/objectstack#14254translateAction ignores _actions.ACTION.description and _actions.ACTION.params.*, which TranslationItemSchema declares andvalidateTranslationReferences lints. Those keys are authored here anyway, for the same reason the gantt's viewMode stays authored in src/views/task.view.ts: the key is the spec's own, it is served to REST/OpenAPI/MCP callers today, and it starts rendering the moment the resolver is fixed.

The platform already owns half of this — as a warning

@objectstack/rest ships validateTranslationReferences (translation-target-unknown / translation-option-key-unknown), which covers the reverse direction over objects, fields, option values, views, sections, actions, params, apps, nav ids, dashboards and widgets. Measured by renaming one bundle key to duly_task_undoo:

VALIDATE EXIT=0
✓ Validation passed (419ms)
⚠ locale "zh-CN" · object "duly_task" · action "duly_task_undoo": Translations are keyed to
action "duly_task_undoo", which is defined by neither object "duly_task"'s `actions` nor a
`stack.actions` entry bound to it. The button keeps its source-locale label.
Did you mean "duly_task_undo"?

A warning, exit 0. This gate makes it an error and adds the forward direction, which nothing upstream checks.

Tests

test/i18n-coverage.test.ts — 29 tests. Reverse-verified rather than assumed; each mutation was confirmed on disk by an anchored count before the reading was taken, and restored by an EXIT/INT/TERM trap:

mutationresult
delete objects.duly_task.fields.status.label from zh-CNexit 1 — 2 red, naming the key
rename duly_task_undoduly_task_undoo in zh-CNexit 1 — 3 red, including "every zh-CN key still has a source"
add placeholder: to duly_task.note (a slot the walk had never seen)exit 1 — tripwire fires, naming object.fields{}.placeholder

Plus self-tests on synthetic metadata for every rule, including that a stack with no authored text makes every exemption stale, that prose inside an opaque subtree is caught, and both email-template rules (missing sibling, and a locale row that copies the source row's wording).

Gates

All four green on 0a70856, the final commit, after merging main (which had taken #69):

validate=0 typecheck=0 test=0 build=0 @ 0a70856
✓ Validation passed (one expected warning: hierarchy-security, per AGENTS.md)
Test Files 28 passed (28)
Tests 703 passed (703)
✓ Build complete — dist/objectstack.json (166.8 KB)

Bundle confirmed in the artifact and served live: GET /api/v1/i18n/localesen, zh-CN; GET /api/v1/i18n/translations/zh-CN returns the tree.

No changeset — this repo has no changeset mechanism (no .changeset/, no dependency, no script, no mention in AGENTS.md). The four gates are the whole contract.

Recorded gap

Filed as #102 rather than done here: the bundle half of the gate compares against zh-CNby name, so a third locale added later would satisfy the locale-set assertion and have its key coverage checked by nothing. The email-template half already iterates supportedLocales generically and does not have this problem. Left out deliberately — #18's scope is the first Chinese deployment — and filed so it is a decision on the record rather than a surprise.


Generated by Claude Code

os-warrenand others added 3 commits September 1, 2026 16:06
…rage gate
`dulyTranslations` was empty while the config advertised `zh-CN`, so every
authored label reached a Chinese deployment in English.
- `src/translations/authored-text.ts` walks every string leaf in the metadata
and demands a verdict per normalised path. A path with no verdict is a
finding, so a new key is a red test rather than an unchecked string.
- `src/translations/en.ts` derives the English bundle from that walk. There is
no English literal to hand-edit, so `en` cannot drift from the source.
- `src/translations/zh-CN.ts` is hand-written, 231 keys, with the terminology
decisions (duty/task/period/standing/governed/caliber) recorded in the file.
- `test/i18n-coverage.test.ts` fails on a declared label with no bundle key, a
bundle key with no source, an unclassified path, a stale exemption, prose
inside an opaque subtree, and an email template with no sibling row per
supported locale.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
Merging main made the gate red, which is the gate working:
- The `untranslatable` exemptions for the three `notify` nodes' inline
`title` / `message` failed as STALE — #69 removed those strings, so the
exemption list described metadata that no longer exists.
- Nine string paths #69 introduced (`config.template`, `config.templateData.*`)
had no verdict and failed the discovery tripwire.
Classified them: `template` is a template NAME resolved by `(name, locale)`,
`templateData` is a render payload. The inline-notification count is pinned at
zero so the gap cannot silently reopen as inline copy.
Email templates are NOT bundle-keyed and are deliberately not walked — they
localize by row. The gate now asserts the row-shaped equivalent against real
rows: one row per (name, locale) for every supported locale, and each locale
row carrying its own words rather than a copy of the source row's.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 16:25
@os-warren
os-warren merged commit 7624c99 into mainSep 1, 2026
1 check passed
os-warren added a commit that referenced this pull request Sep 1, 2026
#103's coverage gate went red on merge, exactly as it is built to: the two new
field labels and the new widget had no bundle key, three dataset slots had no
verdict, and the zh-CN dashboard description still explained why lateness was
missing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

i18n — en source bundle, zh-CN translation, CI coverage gate

1 participant

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

i18n: a generated en bundle, a hand-written zh-CN, and a coverage gate that discovers - #103

Merged
os-warren merged 3 commits into
mainfrom
claude/issue-18-i18n-bundles
Sep 1, 2026
Merged

i18n: a generated en bundle, a hand-written zh-CN, and a coverage gate that discovers#103
os-warren merged 3 commits into
mainfrom
claude/issue-18-i18n-bundles

Conversation

@os-warren

@os-warrenos-warren commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#18

objectstack.config.ts advertised supportedLocales: ['en', 'zh-CN'] while dulyTranslations was []. The app claimed Chinese and shipped none of it: every authored label reached a Chinese deployment in English. This closes that.

Verified end to end in a browser: 231 bundle keys, four gates green at 0a70856, and the screens below are a real zh-CN session against pnpm demo.


The constraint that decided the design: en is generated

A hand-edited en entry wins at render time, so the screen and the code disagree while every gate stays green. The usual answer — generate a file, add a staleness check — is only as good as whoever remembers to re-run the generator.

So there is no en literal anywhere in this diff. src/translations/en.ts derives the bundle at config load from the same walk the gate uses. Nothing is stored, so nothing can go stale, and there is nothing a person could usefully hand-edit: changing an English string means changing the label in src/objects/, src/views/ or src/apps/, which is the only place it was ever true.

That property is pinned rather than asserted in a comment — buildEnglishBundle is run over synthetic metadata and the output has to follow the input, which a hand-written bundle could not do. It also goes through defineTranslationBundle, so the strict TranslationDataSchema refuses a mis-built key at load rather than shipping it unread.

The coverage gate discovers; it does not enumerate

src/translations/authored-text.ts visits every string leaf in the metadata and demands a verdict per normalised path ([] for an array index, {} for a record key). A path with no verdict is a finding, and the gate fails naming it. The default is "unknown → red"; the tables are the exception list.

Three verdicts:

verdictmeaning
translatedisplay text with a bundle key — goes into en, zh-CN must carry it
untranslatabledisplay text with no key anywhere in the platform's schema — named, counted, filed, never silently dropped
machinenot display text (a machine name, a CEL source, an icon, a colour, a filter token)

What is translatable at all is the platform's answer, not ours: TRANSLATABLE_METADATA_TYPES is imported from @objectstack/spec/system ({ view, action, object, app, dashboard, page }) and pinned, so the day datasets become translatable the pin goes red and the walk gets extended instead of quietly staying behind. View keys come from the platform's own expandViewContainer, so _views.default cannot drift from the registry name the resolver looks up.

Beyond the two directions the card asks for, the gate fails on: an unclassified path, a stale exemption (an untranslatable verdict that stopped matching anything), prose hiding inside a subtree declared opaque, two strings claiming one key, and a metadata collection in objectstack.config.ts that has never been classified.

It caught #69 on merge, which is the argument for building it this way. Merging main turned it red twice over: the exemptions for the three notify nodes' inline title / message failed as stale because #69 removed those strings, and nine paths #69 introduced (config.template, config.templateData.*) had no verdict. Neither is a string this gate could have been told about in advance. The inline-notification count is now pinned at zero so that gap cannot silently reopen.

Email templates are deliberately not walked

They are not bundle-keyed. translation.zod.ts does not mention them, EmailTemplateDefinitionSchema.translations is z.ZodNever, and the platform resolves a template by (name, locale) — so a translation is a sibling row, not a key. Demanding a bundle key for a template subject would make this gate permanently and unfixably red.

That skip is named with its mechanism in COLLECTION_HANDLING rather than being implied by omission — a type that localizes differently must not look like a type the walk forgot. The equivalent question is asked of the row shape instead, against #69's real rows: one row per (name, locale) for every supportedLocales entry, and each locale row carrying its own words rather than a copy of the source row's. That second check compares literal text with {{holes}} stripped, because subject: '{{{subject}}}' is identical in both rows on purpose — it renders the task's own subject line, and flagging it would be flagging the data.

Terminology

Six words carry product meaning a literal translation loses. The decisions live in the bundle, with their reasoning, because they have to be consistent across 231 keys and are not recoverable from the English string alone.

termzh-CNwhy, and what it must not read as
duty职责the recurring obligation itself — the rule that produces tasks. 职责 is the ordinary word for role-attached duty (岗位职责). ⛔ not 任务 (that is a task), ⛔ not 义务 (legal/moral)
task任务one dispatched occurrence. Paired with 职责 it keeps "a duty is not a task" as sharp in Chinese as in English
period周期, field 所属周期bare 周期 beside 频率 reads as the cadence, not the window — so period_key is "the period it belongs to"
standing常设the register of 常设机构 / 常设委员会: permanent by establishment, by nature not a thing that finishes. ⛔ not 长期 / 持续, which read as an ongoing task — exactly the misreading that sends people looking for the tick box the product refuses to have
governed组织认定"the organisation established it". ⛔ not 纳入考核 ("counted towards assessment") — accurate about the metrics, wrong about a product that scores nobody
caliber口径统计口径 — the standard term. No user-facing label carries it today; recorded so nobody invents a second word later

due_offset_days.label took the judgement the issue's round-2 comment flagged: the parenthetical had to survive as an explanation, so it is 「偏移天数(0 = 锚点当天)」 rather than a word-for-word rendering of "anchor day", and its help text keeps both worked examples per anchor, translated for sense.

Verified in a browser, in Chinese

pnpm demo, a Chromium context with locale: 'zh-CN' and Accept-Language: zh-CN.

The console chrome does not stay English. This was the expected finding and it is not what happens — the platform ships its own zh-CN console bundle and picks it up from the browser locale. 新建 / 导入 / 行内编辑 / 筛选 / 分组 / 排序 / 搜索… / 5 条记录 / 已选择 1 项 / 清除 are all the platform's. Nothing to file against objectui.

task listby unit

Object label, description, view tabs (全部任务 / 我的本周 / 逾期 / 停滞), column headers, option badges (待办 / 进行中, 岗位职责库 / 自行申报), nav groups and every nav item are ours and all render. The English strings visible are seed data (Emissions return — Northgate), correctly untranslated.

Screenshots are on a throwaway branch (claude/issue-18-screenshots) so no binaries enter this diff.

Two gaps the walk predicted, confirmed on screen

dashboard

Dashboard title, description and all five widget titles/descriptions are Chinese — and directly under each metric tile the dataset measure label renders Untouched > 14 days, Untouched > 30 days, Oldest touch.

bulk toolbar

The selection bar reads 已选择 1 项 · Complete · Skip · 清除 — two English words between two Chinese ones, which is exactly the failure mode that reads as a styling quirk rather than a missing translation.

Display text no bundle can reach — filed, not worked around

Per AGENTS.md rule 9 these are filed upstream rather than papered over. Each is a declared verdict with a stated reason, and the exact set is asserted, so a new one fails the gate and one that becomes translatable fails as stale.

surfacecountaudiencefiled
bulkActionDefs label / confirm / params35end userobjectstack-ai/objectstack#14253
dataset label / description / dimension / measure labels26end user (chart axes, tile captions)objectstack-ai/objectstack#14253
custom validation rule message11end user (write refusals)objectstack-ai/objectstack#14253
flow label / description / node / edge labels39designer (Studio)
job, hook, position, permission-set label / description18operator

The validation-message one is worth calling out against the card's scope line ("… validation message … gets a key"): measured on objectql 17.2.0, a rule's message is emitted verbatim ({ field, code: 'rule_violation', message: rule.message }). messages['validation.field.*'] overrides the platform's built-in field catalog — which already ships zh-CN — not an authored rule. So a deployment gets platform refusals in Chinese and author-written refusals in English, and no bundle key can currently change that.

Separately filed: objectstack-ai/objectstack#14254translateAction ignores _actions.ACTION.description and _actions.ACTION.params.*, which TranslationItemSchema declares andvalidateTranslationReferences lints. Those keys are authored here anyway, for the same reason the gantt's viewMode stays authored in src/views/task.view.ts: the key is the spec's own, it is served to REST/OpenAPI/MCP callers today, and it starts rendering the moment the resolver is fixed.

The platform already owns half of this — as a warning

@objectstack/rest ships validateTranslationReferences (translation-target-unknown / translation-option-key-unknown), which covers the reverse direction over objects, fields, option values, views, sections, actions, params, apps, nav ids, dashboards and widgets. Measured by renaming one bundle key to duly_task_undoo:

VALIDATE EXIT=0
✓ Validation passed (419ms)
⚠ locale "zh-CN" · object "duly_task" · action "duly_task_undoo": Translations are keyed to
action "duly_task_undoo", which is defined by neither object "duly_task"'s `actions` nor a
`stack.actions` entry bound to it. The button keeps its source-locale label.
Did you mean "duly_task_undo"?

A warning, exit 0. This gate makes it an error and adds the forward direction, which nothing upstream checks.

Tests

test/i18n-coverage.test.ts — 29 tests. Reverse-verified rather than assumed; each mutation was confirmed on disk by an anchored count before the reading was taken, and restored by an EXIT/INT/TERM trap:

mutationresult
delete objects.duly_task.fields.status.label from zh-CNexit 1 — 2 red, naming the key
rename duly_task_undoduly_task_undoo in zh-CNexit 1 — 3 red, including "every zh-CN key still has a source"
add placeholder: to duly_task.note (a slot the walk had never seen)exit 1 — tripwire fires, naming object.fields{}.placeholder

Plus self-tests on synthetic metadata for every rule, including that a stack with no authored text makes every exemption stale, that prose inside an opaque subtree is caught, and both email-template rules (missing sibling, and a locale row that copies the source row's wording).

Gates

All four green on 0a70856, the final commit, after merging main (which had taken #69):

validate=0 typecheck=0 test=0 build=0 @ 0a70856
✓ Validation passed (one expected warning: hierarchy-security, per AGENTS.md)
Test Files 28 passed (28)
Tests 703 passed (703)
✓ Build complete — dist/objectstack.json (166.8 KB)

Bundle confirmed in the artifact and served live: GET /api/v1/i18n/localesen, zh-CN; GET /api/v1/i18n/translations/zh-CN returns the tree.

No changeset — this repo has no changeset mechanism (no .changeset/, no dependency, no script, no mention in AGENTS.md). The four gates are the whole contract.

Recorded gap

Filed as #102 rather than done here: the bundle half of the gate compares against zh-CNby name, so a third locale added later would satisfy the locale-set assertion and have its key coverage checked by nothing. The email-template half already iterates supportedLocales generically and does not have this problem. Left out deliberately — #18's scope is the first Chinese deployment — and filed so it is a decision on the record rather than a surprise.


Generated by Claude Code

os-warrenand others added 3 commits September 1, 2026 16:06
…rage gate
`dulyTranslations` was empty while the config advertised `zh-CN`, so every
authored label reached a Chinese deployment in English.
- `src/translations/authored-text.ts` walks every string leaf in the metadata
and demands a verdict per normalised path. A path with no verdict is a
finding, so a new key is a red test rather than an unchecked string.
- `src/translations/en.ts` derives the English bundle from that walk. There is
no English literal to hand-edit, so `en` cannot drift from the source.
- `src/translations/zh-CN.ts` is hand-written, 231 keys, with the terminology
decisions (duty/task/period/standing/governed/caliber) recorded in the file.
- `test/i18n-coverage.test.ts` fails on a declared label with no bundle key, a
bundle key with no source, an unclassified path, a stale exemption, prose
inside an opaque subtree, and an email template with no sibling row per
supported locale.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
Merging main made the gate red, which is the gate working:
- The `untranslatable` exemptions for the three `notify` nodes' inline
`title` / `message` failed as STALE — #69 removed those strings, so the
exemption list described metadata that no longer exists.
- Nine string paths #69 introduced (`config.template`, `config.templateData.*`)
had no verdict and failed the discovery tripwire.
Classified them: `template` is a template NAME resolved by `(name, locale)`,
`templateData` is a render payload. The inline-notification count is pinned at
zero so the gap cannot silently reopen as inline copy.
Email templates are NOT bundle-keyed and are deliberately not walked — they
localize by row. The gate now asserts the row-shaped equivalent against real
rows: one row per (name, locale) for every supported locale, and each locale
row carrying its own words rather than a copy of the source row's.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 16:25
@os-warren
os-warren merged commit 7624c99 into mainSep 1, 2026
1 check passed
os-warren added a commit that referenced this pull request Sep 1, 2026
#103's coverage gate went red on merge, exactly as it is built to: the two new
field labels and the new widget had no bundle key, three dataset slots had no
verdict, and the zh-CN dashboard description still explained why lateness was
missing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

i18n — en source bundle, zh-CN translation, CI coverage gate

1 participant

@os-warren