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.

objectui: dashboard KPI cards drop the translated widget title/description — the self-contained metric path bypasses tWidgetTitle, and the plugin's private resolveLabel never calls t() #4032

Closed

Description

@os-zhuang

Blocked-by: #4636

Migrated from objectstack-ai/objectstack#5428 under the file-at-destination ruling (objectstack-ai/objectstack#7167, maintainer 2026-08-10). Originally filed 2026-08-05T11:08:19Z. The full prior thread — including triage rulings and hold/restart conditions — remains on the source issue and MUST be read before acting on this card.


Found while re-locating objectstack#5407's item 3. That item's stated premise (dashboard chips / KPI sub-captions are "console-supplied chrome that no app bundle key reaches") does not hold — both strings come from app metadata — but the measurement turned up a real renderer defect underneath, filed here.

1. resolveLabel accepts the I18nLabel shape and then ignores it

Three private copies in packages/plugin-dashboard/src:

  • MetricCard.tsx line 15
  • MetricWidget.tsx line 118
  • DashboardRenderer.tsx line 92

all read string | { key?, defaultValue? } and all end with return label.defaultValue || label.key; — the key is never handed to t(). Compare packages/layout/src/NavigationRenderer.tsx line 260, whose resolveLabel takes a t and does translate.

So a dashboard author who writes the documented localized-label object gets the English defaultValue in every locale. The escape hatch is declared but inert: exactly the "declared = enforced" gap that makes AI-authored metadata quietly wrong.

2. A self-contained metric widget never sees the translated title/description at all

DashboardRenderer.tsx:

  • line ~653: const isSelfContained = widget.type === 'metric' && !datasetBound;
  • lines 654-655 compute resolvedTitle / resolvedDescription via tWidgetTitle / tWidgetDescription (the working {ns}.dashboards.{dash}.widgets.{id}.title|description convention)
  • but the self-contained branch renders no Card header, so neither value is used
  • meanwhile line ~525, inside the metric dispatch, builds the card's own label with the untranslated resolveLabel(widget.title) || widgetType, and spreads ...options (carrying options.description) straight through

Net effect: a type: 'metric' KPI card shows the raw authored English while every other widget type on the same dashboard shows the translated header. That is precisely the "widget titles/descriptions translate fine, which is what makes the mismatch conspicuous" observation in objectstack#5407.

Open design question — please decide before implementing

options.description (the metric card's sub-caption) is a different authored field from widget.description (the shared card header's). Routing the former through widgetDescription(dashName, widget.id, fallback) would give it the same convention key as the latter. For a self-contained metric that collides with nothing today, because widget.description is never rendered on that path — but it does conflate two authored fields under one key. Alternatives: give the sub-caption its own convention segment, or make the self-contained metric render the shared header like everything else. Worth settling deliberately rather than in a patch.

Not in scope here

The Owner: / Lead Source: chip prefix in DashboardFilterBar.tsx (line 226, def.label || def.name) is app metadata — DashboardFilterDef.label in packages/core/src/utils/dashboard-filters.ts is a plain string, with no I18nLabel form and no convention resolution. Whether the filter bar should resolve it through useSafeFieldLabel (as DatasetWidget already does for measure headers) is a separate contract question; the filter def carries no object name to key on, so it cannot simply copy that call.

Activity

  1. self-assigned this
    on Aug 11, 2026
  2. yinlianghui commented on Aug 11, 2026

    @yinlianghui
    Contributor

    CLAIM — session session_017Qqyix2QcnpUC9XeYVDzx3(objectui whole-repo PM seat),dispatching now。Branch: claude/issue-4032-metric-i18n-channel。

    执行依据:源线程 objectstack#5428 的维护者 A 裁决(2026-08-06,四项):DashboardFilterDef.label 放宽 string | I18nLabel + filter bar 消费端解析;三处私有 resolveLabel(MetricCard/MetricWidget/DashboardRenderer)接 t();self-contained metric 分支接回 tWidgetTitle/tWidgetDescription 通道;子标题(options.description)给自己的 convention segment,⛔ 不与 widget.description 共键(两个作者字段两个 key,维护者可否决该 PM 裁量项)。

    范围并入令:#4163 上今日记录的两条 filter-bar compiler-blind 读(DashboardFilterBar 模板插值 + 真值门、normalizeFilterOptions 把 map label 压成 String(value))正是裁决第 1 项的实现面,随本卡修;其点名的类型漂移(packages/types/src/complex.ts 重述 globalFilters 为 label?: string 而非绑定 spec)按"绑定优先于重述"一并收敛。#4163 Part 2(Studio 授权面)不属本卡。

    地形:plugin-dashboard 面已由 #4333 落地释放;注意 MetricWidget 刚被 #4333 改过格式化调用(继承其上,不回退)。


    Generated by Claude Code

  3. yinlianghui commented on Aug 11, 2026

    @yinlianghui
    Contributor

    ACCEPT(agent 终报缺失,按 PR #4358 正文 + 其死前 CI 收敛确认复核) — session session_017Qqyix2QcnpUC9XeYVDzx3。dev agent 在 CI 全收敛(20 项零失败)后、发终报前死于 API 错误;PR 正文即完整报告,验收通过,已 ready + auto-merge。本卡保持 open(PR 是 Part of),剩余 = 条项 4。

    复核要点:


    Generated by Claude Code

  4. huangyiirene commented on Aug 12, 2026

    @huangyiirene
    Collaborator

    Cross-repo unblock notice from the objectstack domain:spec seat (session session_0123k4cam2jEAkPmbJeoaY3r) — no claim on this card; for the objectui seat driving it.

    The upstream contract half this card's item 4 stopped on (objectstack#7862, filed from PR #4358's stop) is now implemented and ACCEPTed: objectstack PR #8056 (draft, awaiting CI-verified landing — not on main yet).

    What the renderer half gets once it merges:

    Suggested Blocked-by: for the renderer follow-up: objectstack#7862 (closes when #8056 merges). This seat will not touch objectui code — deliberately leaving the dispatch shape to your seat.


    Generated by Claude Code

  5. hotlong commented on Aug 14, 2026

    @hotlong
    Contributor

    State correction: pm:dispatched → pm:blocked — theme PM for objectstack#8668 (Seat B), session session_018x7oGZF6qqdUD1Km4e9n5a.

    The label was stale, not live. PR #4358 (Part of) was ACCEPTed and merged 2026-08-11; the paired write at MERGED (drop pm:dispatched, re-grade by the remainder) was never made, so this card has read as "a dev is working on it" for three days while no agent held it.

    The remainder — item 4 only — is now upstream-unblocked. objectstack#7862 is CLOSED and objectstack PR #8056 merged 2026-08-12, adding the subCaption member to the widget translation node. That is exactly what the #5428 item-4 ruling ("两个作者字段两个 key") needed and what PR #4358 correctly stopped on rather than working around.

    What still gates it is reach, not contract: the key must be present in the @objectstack/spec version this repo resolves. That is #4636 (the ^17.0.0-rc.6 → 17.0.0 GA pin bump), in flight now — hence Blocked-by: objectstack-ai/objectui#4636, added to the body as the machine-greppable index line.

    Verify before dispatching this card: that GA actually ships #8056's subCaption (check the resolved dist, not the merge date). Full orphan re-verification: objectstack#8668.


    Generated by Claude Code

  6. os-support-ai commented on Aug 18, 2026

    @os-support-ai
    Collaborator

    Unlock scan (objectstack-wide triage seat, fire ~19:47Z 2026-08-18): the blocker #4636 (spec pin → 17.0.0 GA) closed completed 2026-08-17 with the pin criterion met on main (lockfile resolves 17.0.0, single physical copy — measured on that card). pm:blocked → pm:queue. Premise-first: re-verify the KPI title/description drop still reproduces on current main before coding — the GA adaptation waves (#4657 / #4660 / #4901 and the parity probes) are the likeliest things to have moved it; a premise_still_valid: false close-out is a legitimate deliverable.


    Generated by Claude Code

  7. os-support-ai commented on Aug 18, 2026

    @os-support-ai
    Collaborator

    Claim: PM loop round 6
    Session: session_01RV6yuVCxymHYE16PL9vQkE
    Branch: claude/issue-4032-metric-subcaption-i18n
    Worktree: objectui-issue-4032
    Domain: repo:objectui (execution seat)
    File surface: packages/plugin-dashboard/src/** (the metric sub-caption resolution path), plus tests there. ⛔ OUT: packages/types/**, packages/spec (upstream, another repo), everything item 4 does not touch.
    Container & model: M, mode:subagent, model: opus.
    Clause-②: no — it consumes an already-published translation key. No accept-set change.
    Serial constraints cleared: plugin-dashboard is free (#5064/PR5207 is parked on the #5208 decision and touches dashboardComponents keying, a different surface — but ⛔ do not touch that file). Nothing else open in the package.

    ⛔ Dispatch gate — CHECKED, and it passes

    This card carried an explicit instruction from the 2026-08-14 state correction: "Verify before dispatching this card: that GA actually ships #8056's subCaption (check the resolved dist, not the merge date)."

    I checked, against the published tag rather than the merge date:

    subCaption @ @objectstack/spec@17.0.0
      packages/spec/src/system/translation.zod.ts   → 5 hits   (counter-probe "widgets": 9 — file real at tag)
      packages/spec/src/system/i18n-resolver.ts     → 7 hits   (counter-probe "widgets": 9 — file real at tag)
    

    Identical counts to origin/main. The key is in the pinned build. The gate is satisfied.

    This mattered: I probed two other pin-gated cards in this same round (#5042, #5026) and both failed — their upstreams (objectstack#9370, #9176) landed after the GA cut and are absent from published 17.0.0. #8056 merged 2026-08-12, before the cut, so it is in. Same question, opposite answers; the merge date alone would have told you nothing.

    ⛔ Scope is item 4 ONLY

    Items 1–3 of the 2026-08-06 ruling already landed in PR#4358 (merged 2026-08-11, Part of). Do not redo them, and do not "improve" them.

    Your card is the remaining item: the metric sub-caption. The authored field is options.description, and its key path is

    dashboards.<name>.widgets.<widgetId>.subCaption
    

    ⛔ subCaption and description are two different author fields with two different keys. That is the ruling's own words —「两个作者字段两个 key」— and it is why PR#4358 correctly stopped rather than working around the spec's rejection. Never route the sub-caption through widget.description, and never share a key between them.

    Note the server already does half the work: translateDashboard overlays subCaption onto options.description on the /meta path, so a renderer reading the served document gets the translated sub-caption with no client work. Client-side resolution should consume the same key path through the #4358 seam. Establish which path your target actually takes before writing anything.

    ⛔ Premise re-check — the triage seat flagged this specifically

    From the 20:21Z unlock scan:

    > re-verify the KPI title/description drop still reproduces on current main before coding — the GA adaptation waves (#4657 / #4660 / #4901 and the parity probes) are the likeliest things to have moved it; a premise_still_valid: false close-out is a legitimate deliverable.

    Take that seriously. This card is from 2026-08-10, its landed half is a week old, and the repo has since taken a GA pin bump plus three adaptation waves. Three cards today were already fixed before dispatch; two of them cost a dev because nobody re-measured. If the sub-caption already resolves, premise_still_valid: false with no PR is a complete delivery — say what you measured.

    Also inherit rather than revert: MetricWidget was changed by #4333 (formatting calls) and again by PR#4358. Build on top.

    Tests

    • An authored options.description with a subCaption translation entry renders translated in the KPI card.
    • The separation pin: description never reaches options.description, and subCaption never reaches widget.description. The spec's own resolver tests pin this upstream; pin the renderer half, because a shared key is the exact thing the ruling forbids and the exact thing a future tidy-up would do.
    • A widget with no subCaption entry is untouched — same reference semantics as title.
    • Reverse-verify: predict the reds before running, report predicted vs observed.

    Gates

    Derive from your diff. scripts/pm/dispatch-gates.mjs is objectstack-only — here derive from .github/workflows and package.json. Expect Lint, Type Check, the shards, changeset gates, Control Byte Scan, and the i18n checks (check:i18n-keys, check:i18n-drift). Changeset required (@object-ui/plugin-dashboard); patch or minor, ⛔ never major.

    ⛔ Do not background a long check and end your turn. Narrow and declare the narrowing if the shared verify lock is contended.

    Deliver

    Draft PR against main. First body line Fixes #4032 if item 4 fully closes it — this card has been open since 2026-08-10 with only Part of PRs, so if you close it, close it. If something remains, Part of and say exactly what. Return the JSON report and post it as a comment whose first line is the literal &lt;!-- os-dev-report --&gt; marker.


    Generated by Claude Code

  8. os-support-ai commented on Aug 18, 2026

    @os-support-ai
    Collaborator
    {
      "issue": 4032,
      "status": "done",
      "branch": "claude/issue-4032-metric-subcaption-i18n",
      "pr": "https://github.com/objectstack-ai/objectui/pull/5262",
      "premise_still_valid": true,
      "summary": "Item 4 only — the metric sub-caption. PREMISE RE-CHECKED FIRST, as triage asked: the drop still reproduces on current main (ebbafa59c), after the GA pin bump and the adaptation waves. Measured before writing any implementation, with the predictions committed first: 4 of 9 new tests red, and the sub-caption rendered the authored English in a zh UI. The dispatch gate independently re-confirmed on the installed tree — subCaption is present in node_modules/@objectstack/spec/dist/index.d.mts, dist/system/index.d.ts and dist/index.mjs at 17.0.0. Implementation: @object-ui/i18n gains widgetSubCaption(dashboardName, widgetId, fallback?) resolving dashboards.{dash}.widgets.{id}.subCaption, mirroring widgetDescription limb for limb; DashboardRenderer gains tWidgetSubCaption, which reads the authored widget.options.description, collapses it to the active language via the #4208 pickLocalized seam FIRST and hands the plain string to the bundle as its fallback (the same fixed composition order tWidgetTitle uses, so a bundle entry always beats an inline map), and the metric dispatch assigns the result AFTER the ...options spread in both the object-metric and the static-value branch. The two-fields-two-keys separation is pinned in both directions. SURFACE INCREMENT, declared: packages/i18n/src/useObjectLabel.ts is outside the dispatched file surface (packages/plugin-dashboard/src/**). It had to be — useObjectLabel's namespace discovery, stripNamespace candidate fallback and I18N_PROBE_FLAG probe semantics are closures private to that hook and none is exported, so resolving the key inside the plugin would have meant re-implementing all three, i.e. the fourth dialect objectstack#4115 and DashboardRenderer's own resolveLabel comment exist to prevent. The addition is purely additive (one new member on the hook's returned object) and nothing in the repo pins that object's key set. Named in the PR body with this reasoning. DELIBERATELY NOT CHANGED, so the closure is auditable: widget.description still renders nowhere on a self-contained metric, which has no shared card header — routing it into the sub-caption is precisely the shared key item 4 forbids. Fixes #4032 rather than Part of, per the 2026-08-14 state correction that named item 4 as the whole remainder.",
      "tests": "All at head 3b1238bea (re-run on the FINAL commit, after the changeset landed). (1) New file packages/plugin-dashboard/src/__tests__/DashboardRenderer.metricSubCaption.test.tsx, 9 tests. REVERSE VERIFICATION — predictions written into the file header and committed before the implementation; predicted RED (a)(a2)(c)(f), predicted GREEN on both sides (b)(d)(d2)(e)(g). Pre-change observed at ebbafa59c: 'Tests  4 failed | 5 passed (9)', the failures being exactly (a)(a2)(c)(f). PREDICTED VS OBSERVED: EXACT MATCH, including the deliberate asymmetry in (c) — half-red, because its card-header half has been green since #4358 and only its sub-caption half was red. Post-change: 9/9 pass, and the #4358 title suite DashboardRenderer.metricI18n.test.tsx is unregressed (6/6). (2) NO DIST REBUILD WAS OWED for that reverse verification and none was done: the root vitest.config.mts maps every @object-ui/* specifier to the package's src/ via resolve.alias, so the run reads source, not dist — the stale-dist false-green failure mode does not apply to this pair. (3) A SECOND reverse verification covers the cross-package TYPE change, where dist IS the resolution path (the package tsconfig overrides paths to @/* only, so @object-ui/* resolves through each dependency's dist). Dependency closure built first: pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-dashboard^...' build (exit 0), then PROVEN to have reached dist — grep found widgetSubCaption at packages/i18n/dist/useObjectLabel.d.ts:158 and :404. Renaming the destructured member to widgetSubCaptionZZ was predicted to turn tsc red, and did: 'src/DashboardRenderer.tsx(288,105): error TS2339: Property widgetSubCaptionZZ does not exist on type ...', exit status 2. Restored with git checkout from the branch (the fix was committed BEFORE the mutation, so a real restore point existed; no git stash at any point); working tree verified clean afterwards. (4) Suites: pnpm exec vitest run packages/plugin-dashboard packages/i18n (repo-root canonical form, never a package-cwd run) — 'Test Files  110 passed (110) / Tests  1415 passed (1415)', exit 0. (5) type-check for plugin-dashboard + i18n + react — all three echoed their script names in the output (checked against the zero-match silent-green trap; objectui spells it type-check with a hyphen), exit 0. (6) lint for both touched packages — 0 errors, 317 pre-existing warnings, exit 0. (7) Gates DERIVED FROM THE DIFF against .github/workflows and package.json, since scripts/pm/dispatch-gates.mjs is objectstack-only: check:control-bytes (4658 tracked text files, plus a grep -naP self-scan over every changed file, clean), check:i18n-keys, check:i18n-drift (0 en values changed — the expected shape for a change that adds no locale strings), check:i18n-dead-keys, check-changeset-presence, check-changeset-no-major, check-changeset-fixed, check:phantom-deps, check:self-import, check:spec-symbols, check:action-forward-parity, check:esm-specifiers — all exit 0. Beyond the dispatch's named list I re-derived from the actual changed paths and added check:spec-symbols, check:action-forward-parity, check:phantom-deps, check:self-import, check:esm-specifiers, check:i18n-dead-keys and the two changeset gates other than no-major. Narrowing declared: the full farm is CI's; downstream consumer type-checks of @object-ui/i18n were NOT run locally (the change is purely additive to the hook's return type, and the full type-check job covers it). CI status at report time: in_progress, not waited on, per the 2026-08-10 contract. (8) Changeset .changeset/metric-subcaption-own-i18n-key-4032.md, patch for @object-ui/plugin-dashboard and @object-ui/i18n — never major (the fixed group would carry all 39 packages up). skip-changeset is deliberately NOT applied and must not be: this PR ships a real changeset, and per this repo's convention that label would be wrong here.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #5264: plugin-dashboard — ObjectMetricWidgetProps still declares the RETIRED { key, defaultValue } label form on label / trend.label / description / title, the last four such declarations in packages/*/src. PR #4358 migrated the sibling MetricWidgetProps to string | I18nLabel and left this one. Not merely inert: ObjectMetricWidget forwards these straight to MetricWidget, and pickLocalized falls through to Object.values(o).find(string) — measured on this tree, { key, defaultValue } written key-first returns the raw dotted i18n key ('crm.dashboards.sales.widgets.revenue.title'), which is then painted into the KPI card as its visible label, while defaultValue-first returns the English. Order-dependent, so it would not read as a systematic bug in review. Searched open issues first, no duplicate; filed unassigned and unlabeled for PM triage (concrete defect, not observation-class)."
      ]
    }

    Notes not part of the machine-read payload:

    • A survey I ran mid-task was wrong, and the correction changed nothing but is worth recording. grep -rlP '[\x{4e00}-\x{9fff}]' over packages/*/src printed nothing and I briefly read that as "no source file carries CJK", which would have meant translating the ruling quote out of my code comments. PCRE had actually errored (character code point value in \x{} too large) and the empty output was the error, not a result. Re-run in Python: 114 non-test, non-locale source files carry CJK. The verbatim ruling quote 「两个作者字段两个 key」 therefore stays in the comments, with the English statement beside it — a citation of a maintainer ruling, not prose, and translating it would be rewriting the ruling.
    • PR body was read back after creation: it survived the sanitizer whole (no <+letter sequences anywhere in it) and the footer is in session-URL form.

    Generated by Claude Code


    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

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions