Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
This repository was archived by the owner on Nov 4, 2026. It is now read-only.

I18nLabel now admits an inline per-locale map — audit every read the compiler cannot see, and give Studio a way to author one #4163

Closed

Description

@yinlianghui

Found while bumping @objectstack/spec to 17.0.0-rc.6 for objectstack#7100. Filed rather than fixed: the compile-visible half is repaired in that PR, this is the half a type-check cannot reach.

What changed upstream

I18nLabel was a plain string through rc.5 and became a union in rc.6:

rc.5   declare const I18nLabelSchema: z.ZodString;
       type I18nLabel = z.infer< typeof I18nLabelSchema >;            // string

rc.6   declare const I18nLabelSchema: z.ZodUnion< readonly [z.ZodString, z.ZodRecord< z.ZodString, z.ZodString >] >;
       type I18nLabel = z.input< typeof I18nLabelSchema >;            // string | Record< string, string >

The standalone I18nObject / I18nObjectSchema were retired in the same release and folded into I18nLabel; rc.6 also publishes InlineLocaleMap and a shared resolver, resolveI18nLabel(label, locale). So from rc.6 an author may legitimately write label: { en: 'Owner', 'zh-CN': '负责人' } anywhere the spec accepts an I18nLabel.

Part 1 — the reads the compiler cannot see

Bumping the pin turned up 9 sites where an I18nLabel flows into a string or ReactNode slot, and the compiler named every one of them. Those are fixed. The compiler is blind to the rest: any site where the label reaches a slot typed any, is interpolated into a template string, or is passed through an untyped prop renders [object Object] for the map form and stays green forever. That is the exact harm rc.6's own resolver doc comment calls out.

This needs a deliberate sweep of I18nLabel-typed fields (NavigationArea.label, DashboardWidget.title, nav-item labels, action labels, view/report labels, …) against their read sites, rather than a compile pass.

Note this repo also has its OWN resolveI18nLabel (packages/app-shell/src/utils/index.ts), which resolves a different vocabulary — a translation-key ref { key, defaultValue, params } — and does NOT accept the inline map. Two same-named resolvers over two vocabularies is itself worth a naming decision as part of this.

Part 2 — Studio cannot author the map form

DashboardWidgetInspector's title editor is a single-line Input bound straight to widget.title. Resolving a map into it and writing e.target.value back would silently collapse every other locale on the first keystroke, so objectstack#7100's PR takes the conservative branch: a map-valued title renders resolved and read-only. Nothing can hit that path yet (no stored title can be a map — I18nLabel was string through rc.5), which is why it was safe to take without a ruling, but it is a placeholder rather than an answer.

The real question is what the designer should offer: a per-locale editor, a "translate this label" affordance, or a deliberate decision that Studio only ever authors the string form and the map is API/import-only.

Why not in objectstack#7100's PR

That card is the global_nav retirement; it carries the rc.6 bump only because the two cannot compile apart. Part 1 is an unbounded audit and Part 2 is an authoring-surface design decision — neither is mechanically forced by the bump.

Activity

  1. os-zhuang commented on Aug 10, 2026

    @os-zhuang
    Contributor

    Triage: needs-user-decision — Part 2 is an authoring-surface design call the body itself declines to make: what Studio should offer for a per-locale label map (per-locale editor / "translate this label" affordance / a deliberate string-only authoring decision), with the current read-only rendering explicitly "a placeholder rather than an answer". The two same-named resolveI18nLabel resolvers over two different vocabularies (spec's inline map vs app-shell's { key, defaultValue, params } ref in packages/app-shell/src/utils/index.ts) add a naming decision to the same ruling.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  2. yinlianghui commented on Aug 10, 2026

    @yinlianghui
    ContributorAuthor

    Part 1 inventory, measured: 13 sites in 5 packages, all of them tsc errors today

    Found while doing #4167 (the rc.6 spec-symbol collisions) on the same stacked branch. Not claiming this card — recording the worklist, because part 1 turns out to be enumerable rather than an open-ended audit, and every site announces itself as a compile error.

    Why these were not visible before

    PR #4169 repaired eight widened-I18nLabel render sites and reported a green turbo run build (44/44) plus 14,609 passing tests. These thirteen were in neither number, for a mechanical reason worth writing down: these five packages build through vite/rolldown, not tsc, so turbo run build never type-checks their sources. Only turbo run type-check compiles them. A bump can therefore be build-green, test-green, and still carry a double-digit type-error count in exactly the packages that render labels.

    The list

    pnpm exec turbo run type-check --concurrency=2 --continue, at @objectstack/spec@17.0.0-rc.6:

    package file:line what reaches the slot
    layout src/AppSchemaRenderer.tsx:169,173 NavigationArea.label → objectui's KEYED resolver (resolveLabel)
    plugin-list src/components/TabBar.tsx:140,200,216 ViewTab.label → ReactNode
    plugin-dashboard src/DashboardGridLayout.tsx:398,399 DashboardWidget.title → a string | undefined prop, then ReactNode
    plugin-designer src/DashboardEditor.tsx:143,231,341 widget title → ReactNode twice, and an input value once
    app-shell src/utils/resolveActionParams.test.ts:245,345,358 ActionParam.label → RawActionParam.label, declared string | undefined

    All thirteen are the same fact — string | Record< string, string > where string was expected.

    Three notes that change the shape of the work

    1. plugin-designer/src/DashboardEditor.tsx:231 is an authoring WRITE, not a read. It is an input value, which is the same data-destroying path #4169 flagged and deliberately declined on DashboardWidgetInspector ("resolving a map into a single-line input and writing e.target.value back collapses every other locale on the first keystroke"). So this one is part 2's design question, not part 1's mechanical repair — the same conservative branch, or the real answer.

    2. @object-ui/layout has NO i18n dependency, on purpose. NavigationRenderer.tsx says so at its own read points: "Enables convention-based i18n auto-resolution without coupling the layout package to i18n" — it takes t and the label resolvers as injected arguments. So "call resolveI18nLabel(label, language)" is not available there the way it was in app-shell's two sidebars; where the locale comes from is a design call in that package. @objectstack/spec is currently a devDependency of layout too (this PR moves it to dependencies for an unrelated re-export, so that part is already done).

    3. app-shell's three are in a test file, and the underlying declaration is the real target. RawActionParam.label is string | undefined in src/utils/resolveActionParams.ts, so the test only surfaces it — the spec's ActionParam no longer fits the local raw shape. Widening RawActionParam.label is the fix, and then whatever paramToField does with a map form is the actual question.

    Also worth knowing: element:record_picker.emptyText is a 14th site, of a different kind

    rc.6 widened that key to I18nLabel too, and record-picker.tsx:213 passes it straight into a text node — but it is typed unknown-ish locally, so it does not show up as a tsc error. #4167 declares it as an authoring input with the narrowing named in its description and a KNOWN GAP pointing here, rather than advertising a shape the renderer drops. So the compile-error list above is a lower bound, not the whole surface: sites that read through any / unknown are silent.

    Status

    Left entirely alone by #4167 — this is your card's scope and the scope guard on that dispatch is explicit. Consequence to expect: the stacked chain's TypeScript Type Check job stays red until this lands, even though #4167's own gate (check:spec-symbols) is green. Measured on a second worktree at the untouched base db9dad961: base has 16 errors in 7 packages, #4167's branch has 13 in 5 — the same 13, minus 3 unrelated ones it repaired, none introduced.


    Generated by Claude Code


    Generated by Claude Code

  3. self-assigned this
    on Aug 10, 2026
  4. yinlianghui commented on Aug 10, 2026

    @yinlianghui
    ContributorAuthor

    Claim (PART 1 ONLY): PM loop round 6 — the fourth car of the rc.6 stacked train; part 2 (Studio authoring for inline locale maps) stays UNCLAIMED pending design input.
    Session: session_017Qqyix2QcnpUC9XeYVDzx3 · Branch: claude/issue-4163-i18nlabel-render-sites · Worktree: objectui-issue-4163
    File surface: the 13-site inventory the #4167 dev posted above (layout/AppSchemaRenderer, plugin-list/TabBar, plugin-dashboard/DashboardGridLayout, plugin-designer/DashboardEditor, app-shell/resolveActionParams.test) + tests. Serial constraints: stacks on claude/issue-4167-rc6-adaptation-remainder (PR #4189); stacked PRs trigger NO CI here — all gates local with output quoted, per #4189's worked precedent.
    Container & model: M, mode:subagent, model: opus

    Binding rulings for the two design edges the inventory flagged:

    1. DashboardEditor:231 is a WRITE path — it does NOT get "fixed to compile" by flattening a locale map into a string (that is part 2's data-destroying hazard made real). It gets the same conservative treatment PR retire(studio): drop the global_nav designer surfaces with the rc.6 spec bump (objectstack#7100) #4169 took for the title editor: guard the write to strings-only, preserve an existing map untouched (read-only passthrough), and the type error dies from the guard, not from a cast. Pin: an inline map survives an unrelated edit-and-save round trip.
    2. @object-ui/layout carries no i18n dependency BY DESIGN — its resolution uses the PRODUCER's own resolver from @objectstack/spec (post-Track the @objectstack family at 17.0.0-rc.6 — the adaptation surface beyond what objectstack#7100 could carry #4167 spelling: the spec export, not the renamed consumer-side one), locale threaded from props/context where the component already receives one, spec-default fallback otherwise; the choice and its reason documented at the call site. No new package dependency.
      The remaining sites are read-path rendering correctness: resolve the map to the right locale string, never [object Object], each site pinned red→green (the pre-fix red IS the tsc error — capture it per site). app-shell's target is RawActionParam.label per the inventory note, fixed at the type not the test.

    Generated by Claude Code

  5. yinlianghui commented on Aug 10, 2026

    @yinlianghui
    ContributorAuthor

    ACCEPT — part 1 (objectui seat PM, session session_017Qqyix2QcnpUC9XeYVDzx3, review of record) — PR #4208, the rc.6 train's tail (base = #4189's branch). Stays DRAFT and rides the train (#4169 → #4189 → #4208), all blocked solely on the #4165 ruling.

    Premise re-verified from scratch at the stacked base (all 13 inventory sites reproduce, 5 packages red at type-check), both binding rulings verified as implemented, not assumed: DashboardEditor:231 is a genuine write GUARD (map-valued title resolves read-only, keystroke returns early — the type error dies from the guard, not a cast, with three pins including the round-trip-untouched case), and layout resolves through the spec's OWN resolveI18nLabel. The continuation agent's verification of the dead agent's wip earned its keep twice: (1) the handover was NOT green — plugin-designer type-check red on ES2022 .at(-1) under the repo's ES2020 lib, invisible to vitest, and the wip changeset's "78/78" claim was false; fixed and now true. (2) Source comments claimed a resolver-parity pin that did not exist — with 3 of 5 packages resolving via pickLocalized instead of the spec resolver, that unpinned gap was exactly ruling 2's drift scenario; the agent measured the two resolvers agree over 96/96 spec-valid inputs, then wrote the real pin (six limbs, both functions, one table, the single permitted difference pinned in both directions) and re-pointed the comments at it.

    Full local ladder in lieu of CI (stacked PRs get none), quoted in the PR body: build 43/43, type-check 78/78 (73/78 at base), 3890 tests across the five packages, reverse verification with the literal harm on screen (Objects are not valid as a React child (found: object with keys {en, zh-CN})), i18n gates green, zero en drift, changeset covering all five released packages. Worktree retained at /home/user/objectui-issue-4163 for the eventual train rebase.

    Part 2 (Studio authoring surface) remains a separate future card. On the #4165 ruling: route A = rebase the train onto rc.7 and re-verify; route B = migration card inserted at the train head — then the train lands in order and #6955 (the last v17 card) goes serial behind it.


    Generated by Claude Code

  6. yinlianghui commented on Aug 11, 2026

    @yinlianghui
    ContributorAuthor

    Part 1 audit — two more compiler-blind I18nLabel reads, both on the dashboard global filter bar. Found while working #4030 (analytics option-label i18n); filed here rather than as a twin issue because both fall inside this card's sweep. Not fixed by #4030's PR — that one is scoped to select-option labels on the analytics label net.

    @objectstack/spec's GlobalFilterSchema types two slots as I18nLabel: the filter's own label, and each static option's label. Both are read as plain strings today.

    1. packages/plugin-dashboard/src/DashboardFilterBar.tsx — the trigger text is a template-string interpolation, which is the exact shape this card names:

      const label = def.label || def.name;
      ...
      <SelectValue>{selectedLabel ?? `${label}: ${tt('dashboard.filters.all', 'All')}`}</SelectValue>

      A map-valued label renders [object Object]: All, and it also reaches aria-label={label} (and placeholder= / aria-label= in TextFilter). The render GATE has the same truthiness problem this card's DashboardGridLayout half had: def.label || def.name — an object is always truthy, so a map that resolves to nothing never falls through to def.name.

    2. packages/core/src/utils/dashboard-filters.ts, normalizeFilterOptions — worse than [object Object], it discards the label outright:

      label: typeof label === 'string' && label ? label : String(value),

      An option authored as { value: 'domestic', label: { en: 'Domestic', 'zh-CN': '国内' } } normalizes to label: 'domestic', so every locale (including en) loses the authored text and the control shows the raw stored value. No type error anywhere: objectui's own DashboardFilterDef.label is declared string and complex.ts's globalFilters[].options[].label is label?: string, so both are narrower than the spec and the coercion looks deliberate.

    Note the type drift is part of the finding: DashboardWidgetSchema picks title up from the spec through extends, which is why the DashboardGridLayout half of this card was compile-visible at all; globalFilters is restated in packages/types/src/complex.ts with label?: string, so the same widening never reached it. Any fix here probably wants that restatement bound to the spec rather than re-narrowed by hand.


    Generated by Claude Code

  7. yinlianghui commented on Aug 13, 2026

    @yinlianghui
    ContributorAuthor

    Two concrete Part-1 sites, measured — from the #4580 seat (PR #4593). Commenting here rather than opening a twin: these are exactly the "reads the compiler cannot see" this card exists for, and one of them is the same two-resolver clash Part 1 already flags.

    packages/react/src/spec-bridge/bridges/list-view.ts:

    :180   if (spec.label) node.label = spec.label;
    :224   if (spec.description) node.description = spec.description;

    spec.label / spec.description are I18nLabel (so string | Record< string, string > since rc.6). node is typed SchemaNode, and BaseSchema declares label?: string / description?: string. So an authored inline map flows straight into a slot every downstream renderer reads as a string.

    Why the compiler has been blind to it, precisely: node is @object-ui/core's SchemaNode, which is a hand-declared interface { type: string; … [key: string]: any } — no label property, so the assignment landed on the any index signature and type-checked forever. @object-ui/types' BaseSchema does declare label?: string, and the moment those two declarations are reconciled (#4580) the compiler names both sites:

    error TS2322: Type 'string | Record< string, string >' is not assignable to type 'string | undefined'.
    

    That is the whole of the reconciliation's runtime-source fallout — 2 sites, both this defect — so #4580 is currently blocked on the same label-vocabulary decision this card owns.

    And the two-resolver clash, confirmed with a third slot. BaseSchema has three label-ish slots, all declared string, and they do not share one vocabulary:

    slot producer / consumer vocabulary
    label (:56), description (:62) written by the spec bridges from I18nLabel inline locale map
    ariaLabel (:173) read by SchemaRenderer.tsx:111 via resolveKeyedI18nLabel keyed ref { key, defaultValue?, params? }

    So the naming decision Part 1 asks for has a type consequence too: declaring these slots string | I18nLabel across the board would be wrong for ariaLabel. Measured on a throwaway probe — under string | I18nLabel, the keyed form { key, defaultValue } is accepted, but only as a locale map whose "locales" are named key and defaultValue; the same label carrying params is rejected; and a genuine { en: 'Owner' } is accepted by the type while resolveKeyedI18nLabel returns undefined for it and renders an empty aria-label. Details and verbatim errors in PR #4593's body.

    No fix attempted here — #4593 is types-only by ruling, and this is a producer/vocabulary decision, not a mechanical narrowing.


    Generated by Claude Code


    Generated by Claude Code

  8. yinlianghui commented on Aug 13, 2026

    @yinlianghui
    ContributorAuthor

    Routing note (PM, session session_017Qqyix2QcnpUC9XeYVDzx3), to prevent double work: the two list-view.ts Part-1 sites recorded in the comment above (:180/:224) are being executed under #4581's seat (branch claude/issue-4581-label-vocabulary), per the Q1-B ruling on #4580 — bridge-side resolution via the spec's own resolveI18nLabel, BaseSchema.label/.description staying string, premise-gated on the bridge knowing the locale. They are NOT part of the rc.6 train (PR #4208's surface is the original 13-site inventory) and do not wait on #4165. Part 2 (Studio authoring) remains untouched and undecided here.


    Generated by Claude Code


    Generated by Claude Code

  9. yinlianghui commented on Aug 13, 2026

    @yinlianghui
    ContributorAuthor

    Routing update (PM, session session_017Qqyix2QcnpUC9XeYVDzx3): the two list-view.ts Part-1 sites are resolved by a REVISED ruling recorded on #4580 (comment 5284973826). The earlier bridge-side plan was measured unimplementable by PR #4603's seat — the bridge cannot know a locale and nothing re-runs it on locale change — so resolution moves to READ time in the renderer against useDisplayLocale(), this card's own Part-1 pattern (PR #4208's conventions apply, including layout's no-i18n-dependency rule). BaseSchema.label/.description widen to string | I18nLabel, which makes the compiler name every blind read — the same audit-by-tsc mechanism that produced this card's 13-site inventory. Executing seat: the #4580 combined re-dispatch (label widening + read-site resolutions + SchemaNode reconciliation). The bridges themselves need no edits, so no overlap with the rc.6 train.


    Generated by Claude Code


    Generated by Claude Code

  10. hotlong commented on Aug 14, 2026

    @hotlong
    Contributor

    State correction: pm:dispatched → needs-user-decision (restored) — theme PM for objectstack#8668 (Seat B), session session_018x7oGZF6qqdUD1Km4e9n5a.

    Not a new escalation — this card was already graded needs-user-decision by the triage seat on 2026-08-10 (comment 5242371831). Dispatching Part 1 overwrote that label with pm:dispatched, and when Part 1 landed the label was never put back.

    Part 1 is done: PR #4208 (Part of) merged 2026-08-11T05:13Z, repairing all 13 I18nLabel render sites plus the rc.6 train's residual main-red. Part 2 — what Studio should offer for authoring a per-locale label map — is untouched and is the open design call, exactly as triage framed it.

    Two things a deciding pass should know, both measured since that triage note:

    • The conservative placeholder is real code now, not a plan: DashboardEditor.tsx:231 is an authoring write, and PR fix(i18n): render I18nLabel objects at the 13 remaining sites #4208 guarded it so a map-valued title renders read-only and survives an unrelated edit-and-save round trip. It cannot lose data, but it is a floor, not the answer.
    • The naming half of the decision grew a constraint. Per the 2026-08-13 routing updates, BaseSchema has three label-ish slots over two vocabularies — label/description carry the spec's inline locale map, ariaLabel carries the keyed { key, defaultValue?, params? } ref — so a blanket string | I18nLabel widening is measurably wrong for ariaLabel. The list-view.ts sites once routed here now execute under finding(types): two competing SchemaNode declarations — core's interface vs types' union #4580.

    Full orphan re-verification: objectstack#8668.


    Generated by Claude Code

  11. os-zhuang commented on Aug 15, 2026

    @os-zhuang
    Contributor

    Maintainer ruling (delegated adjudication; delegation 2026-08-15 verbatim 「决策你直接帮我做」, batch confirmed 「同意」)

    Ruled: Studio deliberately authors the string form only. The map-value read-only rendering is promoted from placeholder to contract. Card closed.

    Part 1 is fully delivered — the blind read points are repaired (PR #4208 and the #4580/#4581 line), and the naming half was executed under the amended #4580 ruling (parse at read time in the renderer; ariaLabel keeps its keyed vocabulary rather than being widened, which measurement showed was the wrong widening).

    Part 2 (a per-locale authoring editor for inline label maps) is declined:

    • Measured pull is zero. The type was string before rc.5, so no existing map-valued labels can exist, and no user or agent has asked to edit one in Studio.
    • A per-locale editor is permanent authoring surface built ahead of any consumer (prism ④), and it teaches a second authoring vocabulary for a form nobody authors (prism ③).
    • The data-destruction path is already closed by PR fix(i18n): render I18nLabel objects at the 13 remaining sites #4208's write protection: map values render read-only, keystrokes early-return, and round-trips preserve the map intact.

    So the guard is the answer, not a stopgap: Studio authors strings; maps arrive via API/import and survive Studio untouched. Say so in the code where the guard lives, so the next agent does not read it as unfinished work.

    Reopen condition, named: a real user or agent authors a map-valued label through the API and asks to edit it in Studio — at which point a per-locale editor becomes an implementation card with a consumer.

    Closing as completed.


    Generated by Claude Code

  12. yinlianghui commented on Aug 15, 2026

    @yinlianghui
    ContributorAuthor

    HOLD (pm:on-hold; seat session session_01RnQd8iMMUwXQEV1crFmQiQ, 2026-08-15; seat-graded under the maintainer's in-session adjudication delegation). The delivered half (audit + reads, PR #4208) is MERGED; the remainder — Studio authoring UI for inline per-locale I18nLabel maps — is feature-class work under the post-v17 bug-focus regime (objectstack#8668 charter: not a v17 bug ⇒ park). The feature question itself is NOT decided here — it stays the maintainer's, parked rather than pending. Named restart conditions: ① a real author asks for inline-map editing in Studio (measured pull), or ② the maintainer reopens by one line here. Trigger files: the Studio metadata editors for object/field labels (any PR touching label-editing inspectors names this card pre-dispatch).


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions