Skip to content

plugin-dashboard: ObjectMetricWidgetProps still declares the RETIRED { key, defaultValue } label form — a TS consumer writing the declared shape renders the raw dotted i18n key as the visible label #5264

Description

@os-support-ai

Found while implementing #4032 item 4 (PR #5262). Not fixed there — different file, different defect class, filed rather than ridden along.

What

packages/plugin-dashboard/src/ObjectMetricWidget.tsx declares the key-reference label form on four members:

48:  label: string | { key?: string; defaultValue?: string };
54:    label?: string | { key?: string; defaultValue?: string };   // trend.label
62:  description?: string | { key?: string; defaultValue?: string };
89:  title?: string | { key?: string; defaultValue?: string };     // drill-down panel title

That vocabulary was RETIRED by the spec in rc.6 (objectstack#5055). PR #4358 migrated the sibling MetricWidgetProps to string | I18nLabel for exactly this reason, and its own doc comment says so — "It is not accepted here any more because the spec rejects it at authoring time". ObjectMetricWidgetProps was not migrated in the same pass. These four are the only remaining declarations of the shape in packages/*/src (grepped, excluding tests).

Why it is not merely inert

ObjectMetricWidget forwards label / description / trend straight to MetricWidget, which resolves them with pickLocalized. Handed the retired shape, pickLocalized misses every locale limb and falls through to its last resort — Object.values(o).find(v = typeof string) — which returns the FIRST string property in insertion order. Measured against packages/i18n/src/pickLocalized.ts on this tree:

pickLocalized({ key: 'crm.dashboards.sales.widgets.revenue.title', defaultValue: 'Total Revenue' }, 'zh')
  -> "crm.dashboards.sales.widgets.revenue.title"      // key written first
pickLocalized({ defaultValue: 'Total Revenue', key: 'crm.dashboards.sales.widgets.revenue.title' }, 'zh')
  -> "Total Revenue"                                   // defaultValue written first

So a TypeScript consumer who writes the shape the interface still invites — in the natural { key, defaultValue } order, which is how the retired form was always spelled — gets the raw dotted translation key painted into the KPI card as its visible label. The outcome depends on property order, which is why it would not read as a systematic bug in review.

The type is what makes this reachable: this is a public exported props interface, so the declaration is an invitation, and the runtime neither resolves the key nor rejects the object.

Suggested shape

Migrate the four members to string | I18nLabel, matching MetricWidgetProps. Worth checking in the same pass whether any call site or fixture in the repo still constructs the retired form (the #4032 fixture triage discipline: a fixture pinning the retired branch keeps passing precisely because nothing is produced any more).

Scope note

Deliberately NOT bundled into PR #5262. That PR is #4032 item 4 — the sub-caption's convention key — and it neither reads nor writes these declarations; folding an unrelated public-type migration into it would have widened its verification surface without evidence in its own tests.

Activity

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

    @os-support-ai
    CollaboratorAuthor

    Triage: pm:queue · Bug — a public exported props interface still invites the rc.6-retired { key, defaultValue } label form (objectstack#5055), and the runtime paints the raw dotted key with property-order-dependent behavior; the sibling MetricWidgetProps was already migrated for exactly this reason (#4358). Fix: migrate the four members to string | I18nLabel, plus the same-pass fixture sweep the card names (#4032 fixture-triage discipline: a fixture pinning the retired branch keeps passing precisely because nothing produces it any more).


    Generated by Claude Code

  2. os-support-ai commented on Aug 19, 2026

    @os-support-ai
    CollaboratorAuthor

    Claim: PM loop round 13
    Session: session_01RV6yuVCxymHYE16PL9vQkE
    Branch: claude/issue-5264-object-metric-label-i18n
    Worktree: objectui-issue-5264
    Domain: repo:objectui (plugin-dashboard)
    File surface: packages/plugin-dashboard/src/** — ObjectMetricWidgetProps + the fixtures the same-pass sweep names (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: opus
    Clause-②: yes — content limb. Migrating the four members from the retired { key, defaultValue } form to string | I18nLabel changes a published props interface's accept set. It narrows toward an already-retired contract (objectstack#5055 retired that form at rc.6) rather than widening, and the sibling MetricWidgetProps was migrated for exactly this reason in #4358 — but it is declared here rather than argued away.
    Serial constraints cleared: packages/plugin-dashboard was released minutes ago. It was held all round by the parked PR #5207 (#5064) — that PR merged at 07:10Z after its changeset was corrected major → minor per the maintainer's 2026-08-19 ruling. No open PR and no in-flight claim now touches this package. Sibling #5280 is dispatched in the same batch and writes only src/__tests__/DatasetWidget.test.tsx — disjoint from this card's surface, recorded here per the serialization discipline.

    ⚠️ PR #5207 just re-keyed dashboardComponents in src/index.tsx (11 PascalCase class names → 8 schema type keys). Merge main before opening the PR so you are working against that, and ⛔ do not carry any line number from this card — the package moved minutes ago.

    Triage's fix shape is binding and carried into the order: migrate the four members to string | I18nLabel, plus the same-pass fixture sweep — per #4032's fixture-triage discipline, a fixture pinning the retired branch keeps passing precisely because nothing produces it any more, so a green fixture is not evidence the branch is live.


    Generated by Claude Code

  3. added a commit that references this issue on Aug 19, 2026
    b526302
  4. os-support-ai commented on Aug 19, 2026

    @os-support-ai
    CollaboratorAuthor
    {
      "issue": 5264,
      "status": "done",
      "branch": "claude/issue-5264-object-metric-label-i18n",
      "pr": "https://github.com/objectstack-ai/objectui/pull/5303",
      "premise_still_valid": true,
      "summary": "Migrated the four ObjectMetricWidgetProps members (label, trend.label, description, title) from the rc.6-retired `{ key, defaultValue }` form to `string | I18nLabel`, matching the sibling MetricWidgetProps migration (#4358) verbatim. Also fixed the members' only reader in the same file: `drawerTitle` destructured `title?.defaultValue` / `label?.defaultValue`, a limb an inline locale map does not have, so an authored drill-down title resolved to '' and the panel silently fell back to the literal word 'Details' — both reads now go through `pickLocalized` against the same UI language the tile uses. CORRECTION TO THE CARD'S CLAUSE-② FRAMING, measured with tsc: this is NOT a narrowing. `I18nLabel`'s object half erases to `Record<string,string>` in the emitted .d.ts (the BCP-47 key regex is a Zod runtime refinement that does not survive into the type), so `{ key, defaultValue }` REMAINS structurally assignable — nothing stops type-checking. What starts type-checking is the inline per-locale map, which the old declaration rejected with TS2353; a consumer writing the only vocabulary the spec admits previously could not compile. The retired form is refused where refusal is expressible: I18nLabelSchema rejects it at authoring time in both property orders, pinned in the tests. #5207's re-key touched a different map and none of these four members; main is merged.",
      "tests": "All at 2c64c75f2 (branch head + merge commit). `pnpm exec vitest run packages/plugin-dashboard` from the repo root (per #3378): 'Test Files 65 passed (65) / Tests 590 passed (590)', including 12 new in src/__tests__/ObjectMetricWidget.i18nLabel.test.tsx. `pnpm --filter @object-ui/plugin-dashboard run type-check`: clean, and the output echoes `tsc --noEmit && tsc -p tsconfig.test.json` so the hyphenated script really ran rather than matching zero scripts. lint: 0 errors, 317 warnings, all pre-existing no-explicit-any (my files add none — a new unused-import warning I introduced was removed). Dependency closure built first via --filter '@object-ui/plugin-dashboard^...' build; emitted dist/ObjectMetricWidget.d.ts carries `string | I18nLabel`. Gates re-derived from the changed paths (objectui has no scripts/pm/dispatch-gates.mjs): check:control-bytes, check:phantom-deps, check:self-import, check:spec-symbols, check:doc-types, check:i18n-keys, check:i18n-drift, check:i18n-dead-keys, changeset:check, check-changeset-presence, type-check:coverage, lint:coverage — all exit 0. REVERSE VERIFICATION, directions predicted before running and written into the test file header: predicted 3 RED / 9 GREEN in vitest, observed exactly 'Tests 3 failed | 9 passed (12)' — the three reds are the two drawer-title cases (both got 'Details') and the key-first card/drawer coherence case, whose failure message reproduces the issue's own measurement: \"expected 'crm.dashboards.sales.widgets.revenue.…' to contain 'Total Revenue'\". type-check predicted RED, observed RED with 7 errors; one prediction missed — I wrote TS2353 and observed TS2322, same meaning, different code because the failures land in JSX attribute position rather than a fresh literal binding. Restore leg: source restored byte-identically (git diff --stat HEAD empty), 12/12 and type-check green again. ABLATION REBUILD: no rebuild is in the vitest leg's resolution path and none can hide a stale result — vitest.config.mts aliases every @object-ui/* specifier to that package's src/, and the ablated file is reached by a relative import (../index -> ../ObjectMetricWidget), always source. For the type-check leg tsconfig.test.json sets \"paths\": {} so @object-ui/types resolves through its built .d.ts; that artifact IS in the path, so the dependency closure was built before any measurement was read, and it is unchanged by the ablation either way.",
      "open_questions": [
        {
          "question": "TypeScript cannot express the rejection of the retired `{ key, defaultValue }` form on ANY `I18nLabel` surface in either repo, because `InlineLocaleMapSchema` is `z.record(z.string().regex(BCP47), z.string())` and the key regex is erased from the emitted type, leaving `Record<string,string>`. Every string key therefore type-checks. Should the spec narrow the emitted key type so `declared = enforced` holds at compile time, not only at publish time? This is a cross-repo spec change (@objectstack/spec), so it is raised rather than filed here.",
          "options": [
            "A — leave as-is: the spec's runtime schema rejects it at authoring time in both orders (verified), publish-time refusal is the contract boundary, and the emitted type stays simple.",
            "B — narrow InlineLocaleMap's key to a template-literal/branded locale-tag type in @objectstack/spec, so `{ key, defaultValue }` becomes a compile error on every I18nLabel surface at once.",
            "C — keep Record<string,string> but add a repo gate that greps for `{ key, defaultValue }` object literals in label position."
          ],
          "recommendation": "B, on the third axis — making AI-written metadata hard to get wrong. This defect existed for a full release precisely because the wrong shape compiled; a publish-time-only refusal catches it after the author has already written and shipped it, and the harm here (raw dotted key painted as a KPI label, property-order-dependent) is exactly the class that survives review. B fixes it once for every I18nLabel surface in both repos rather than per-interface. On real business need it is justified by a measured, shipped defect rather than a speculative surface; on long-term soundness it is contract-first (the constraint already exists in the schema — B stops throwing it away at the type boundary) instead of C's grep, which is a second de-facto contract. Cost: a template-literal key type is more complex to read and may need an escape hatch for computed keys, which is why this is the maintainer's call and not mine."
        }
      ],
      "out_of_scope_findings": [
        "filed as #5301: plugin-dashboard WidgetConfigPanel's private `resolveLabel` is the fourth copy of the resolver #4032 swept and is inverted — it returns \"\" for the spec's inline locale map and resolves only the retired form; because its result seeds the editable draft via normalizedConfig, a map-titled widget opens the config panel EMPTY and a save writes '' over the author's map. Filed unassigned and unlabelled (concrete defect, PM triages). Not fixed in this PR: different member surface, and the fix is not mechanical — resolveLabel has no locale in scope, so it needs a decision on which locale an authoring panel displays and what a save writes back."
      ]
    }

    Generated by Claude Code

  5. os-support-ai commented on Aug 19, 2026

    @os-support-ai
    CollaboratorAuthor

    Correction: my Clause-② framing was backwards, and tsc says so

    I would have classified this migration as a narrowing — retiring { key, defaultValue } in favour of string | I18nLabel reads like removing an accepted form. The dev measured it instead of accepting the framing, and it is not a narrowing:

    I18nLabel's object half erases to Record<string, string> in the emitted .d.ts. The BCP-47 key regex is a Zod runtime refinement that does not survive into the type. So { key, defaultValue } remains structurally assignable — nothing about this change stops it type-checking.

    What starts type-checking is the inline per-locale map, which the old declaration rejected with TS2353. Stated plainly:

    A consumer writing the only vocabulary the spec admits previously could not compile.

    So the accept set on the type axis widens, toward the contract — the opposite of what "retire the old form" suggests. The retired form is refused where refusal is actually expressible: I18nLabelSchema rejects it at authoring time in both property orders, now pinned in tests.

    Recording it because the wrong framing was mine and it was published in the dispatch order.

    The reader fix is the part users would have noticed

    drawerTitle destructured title?.defaultValue / label?.defaultValue — a limb an inline locale map does not have. An authored drill-down title therefore resolved to '' and the panel silently fell back to the literal word "Details". Both reads now go through pickLocalized against the same UI language the tile uses. A declaration migration that left this reader alone would have type-checked, passed, and still shown the wrong title.

    Escalated, not decided here

    The dev's open question is a genuine fork and it is not this seat's to settle: TypeScript cannot express the rejection of { key, defaultValue } on any I18nLabel surface in either repo, because InlineLocaleMapSchema is z.record(z.string().regex(BCP47), z.string()) and the key regex is erased — every string key type-checks.

    That is a @objectstack/spec contract change, so it goes to the spec seat's queue and the maintainer's inbox rather than being answered in an objectui PR. Filed as objectstack-ai/objectstack#9925. Its recommendation (narrow the key to a template-literal/branded locale-tag type) is carried across with the dev's own reasoning, including the cost it names — computed keys may need an escape hatch, which is exactly why it is a maintainer call.

    Two more things worth keeping

    Reverse verification named a missed prediction rather than rounding it off. Predicted 3 RED / 9 GREEN in vitest, observed exactly that — the failure text reproduces the issue's own measurement verbatim (expected 'crm.dashboards.sales.widgets.revenue.…' to contain 'Total Revenue'). For type-check it predicted RED and observed RED, but predicted error code TS2353 and observed TS2322 — same meaning, different code, because the failures land in JSX attribute position rather than a fresh literal binding. It reported the miss.

    The ablation's rebuild analysis is the standard to copy. The vitest leg has no artifact in the path (vitest.config.mts aliases every @object-ui/* to that package's src/, and the ablated file is reached by a relative import). But the type-check leg's tsconfig.test.json sets "paths": {}, so @object-ui/types resolves through its built .d.ts — that artifact is in the path. It built the dependency closure before reading any measurement, and noted the artifact is unchanged by the ablation either way. Most reports assert "no rebuild needed" globally; this one checked each leg separately and found they differed.

    PR #5303 is not being flipped to ready: Build & E2E is broken repo-wide (#5304, p0) and the maintainer ruled 2026-08-19 「先修 CI,PR 一律等着」.


    Generated by Claude Code

  6. added a commit that references this issue on Aug 23, 2026
    f6fc565
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions