Skip to content

fix(plugin-detail): ActivityTimeline's empty state resolves from the locale packs - #7150

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7142-activity-timeline-empty-i18n
Sep 1, 2026
Merged

fix(plugin-detail): ActivityTimeline's empty state resolves from the locale packs#7150
os-warren merged 1 commit into
mainfrom
claude/issue-7142-activity-timeline-empty-i18n

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes #7142

ActivityTimeline's empty-state title was a raw English JSX literal — title="No activity recorded", not a t() call and not an inline defaultValue — so it never reached the pack system and stayed English in all ten locales. It now reads detail.noActivity through useDetailTranslation, the hook its sibling already uses.

Three lines of production change: the hook import, the const { t } = useDetailTranslation(); call, and the title expression. No pack was edited.

The key-reuse decision was measured, not assumed

The card and the dispatch both flagged this as the thing to settle first: RecordActivityTimeline.tsx:415 already renders title={emptyLabel ?? t('detail.noActivity')}, but that proves a key exists, not that the two surfaces want the same words. So both were rendered empty and read.

[en] ActivityTimeline       => "Activity(0)No activity recorded"
[en] RecordActivityTimeline => "Activity(0)All ActivityNo activity recorded"
[zh] ActivityTimeline       => "Activity(0)No activity recorded"      <- the defect
[zh] RecordActivityTimeline => "活动(0)全部动态暂无活动记录"

The en pack value for detail.noActivity is 'No activity recorded'byte-identical to the literal being replaced. The two surfaces were already saying exactly the same words in English, so reuse changes no copy in any locale, while a second key would fork one sentence across ten packs for zero copy difference. Reuse it is.

The key is genuinely translated in all ten packs

Read out of the pack objects directly rather than grepped — the packs are nested (detail: {noActivity:), so a dotted-key grep for detail.noActivity returns zero against a pack that defines it. Two controls ran on the same instrument: detail.noRelatedRecords and detail.loading returned present in all ten (positive), and detail.zzzNotAKeyEver returned absent in all ten (negative, proving the probe can report absence).

en  "No activity recorded"          zh  "暂无活动记录"
ja  "アクティビティの記録なし"          ko  "기록된 활동 없음"
de  "Keine Aktivitäten aufgezeichnet" fr  "Aucune activité enregistrée"
es  "Sin actividad registrada"      pt  "Nenhuma atividade registrada"
ru  "Нет зарегистрированной активности"  ar  "لا يوجد نشاط مسجل"

DETAIL_DEFAULT_TRANSLATIONS already carries the byte-identical English row, so the provider-less path is unchanged and defaults-maps-mirror-en-pack stays green. No inline defaultValue anywhere (#3517).

Test

packages/plugin-detail/src/ActivityTimeline.i18n.test.tsx, six cases: zh, ar, en, no-provider, never-the-raw-key, and the second route into the box — a populated timeline filtered down to a type with no entries.

zh and ar are the load-bearing half. An en-only assertion would have been green before the fix too, since the literal and the en pack value are the same string; only a non-Latin locale discriminates a pack lookup from a hardcoded literal.

Ablation

Predicted before running: restoring the literal turns exactly the three locale-discriminating cases red and leaves the other three green. Observed Tests 3 failed | 3 passed (6) — the passing count is quoted deliberately, since a suite collapsed to zero survivors also shows red and would look stronger.

Mutation proven on disk by marker count in both directions (t('detail.noActivity') 1 to 0, title="No activity recorded" 0 to 1) and by blob hash (6bb6ce32 to 678d2e03), never by an editor's exit code. Restore proven by state — git diff HEAD, git diff --cached and git status --short all empty, and the blob back to 6bb6ce32 — never by a trap firing. No rebuild leg applies: the test imports ./ActivityTimeline relatively and the root vitest config aliases @object-ui/i18n to packages/i18n/src, so nothing in the closure resolves through dist/.

Verification

All readings below are on 0e210de56, the final commit.

Green:

  • pnpm exec vitest run packages/plugin-detail/ packages/i18n/src/__tests__/all-locales-key-parity.test.ts packages/app-shell/src/__tests__/defaults-maps-mirror-en-pack.test.tsxTest Files 123 passed (123), Tests 1157 passed (1157)
  • check:i18n-keys — "Every in-scope call-site key resolves against the en pack (2845 keys) … no call site carries a literal fallback beside itself" (2604/2604 literal keys resolve)
  • check:i18n-drift — "No en value changed in this range."
  • check:i18n-dead-keys — report, not a gate; detail.noActivity gains a call site here rather than losing one
  • check-changeset-presence — "1 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)"
  • check-changeset-no-major — "No changeset declares a major bump." (quoted rather than predicted)
  • type-check (tsc --noEmit && tsc -p tsconfig.test.json) — exit 0. Confirmed it actually covers the diff: --listFiles puts ActivityTimeline.tsx in the main pass and ActivityTimeline.i18n.test.tsx in the tsconfig.test.json pass, with a present-file control and a nonexistent-path control both behaving.
  • eslint . in packages/plugin-detail — 0 errors. The one warning on ActivityTimeline.tsx (Filter imported unused, line 11) is pre-existing on origin/main and untouched here.
  • check:control-bytes, check:self-import, check:esm-specifiers, check:phantom-deps, check:vi-mock-specifiers, check:vi-mock-inherit, check:dist-completeness, check:side-effects-array

NOT MEASURED — recorded as the absence of a reading, not as passes:

  • check:sdui-registration-pins — exit 2, "a run with nothing to read has measured nothing. Build the console first". The console build needs 28 unbuilt packages; that full-monorepo build is CI's, and this diff registers no SDUI component.
  • check:eager-closure — exit 2, "a broken gauge, not a sensitive gate": no apps/console/dist/eager-closure.json locally. Same cause.
  • check:readme-exports — the gate declares its own run void ("the population COLLAPSED — this run proves nothing") while packages are unbuilt. Narrowed and declared: after building @object-ui/plugin-detail, its README dropped out of the unjudged list (1 hit to 0), so this package's slice is judged; the residual collapse is the other 24 unbuilt packages.

type-check first came back exit 2 with TS2307 across every @object-ui/* — an unbuilt dependency closure, which is a non-reading rather than a red. The closure was built (pnpm --filter '@object-ui/plugin-detail^...' build) and it was re-run to get the exit 0 above.

Scope

Only the empty-state title, per the dispatch ruling. Sweeping the file found the component uses no translation hook at all on origin/main — 18 further hardcoded English literals (relative timestamps, the six filter chips, the Activity card title, the filter aria-label, the six activity-description sentences), 12 of which already have pack keys sitting unused. That inventory is filed as #7149, unassigned, rather than folded in here.

No public export added, no accept/reject behaviour changed — contract clause 2 stays disengaged. No role was added, removed or aligned on the DataEmptyState call site; that is #7132's decision and it is already made.


Generated by Claude Code

…locale packs

The empty-state title was a raw English JSX literal —
`title="No activity recorded"`, not a `t()` call and not an inline
`defaultValue` — so it never reached the pack system and stayed English in all
ten locales. Measured by rendering `activities={[]}` under a zh `I18nProvider`:
the card read "Activity(0)No activity recorded" while its sibling
`RecordActivityTimeline` rendered "活动(0)全部动态暂无活动记录" from the same
packs.

The call site now reads `detail.noActivity` through `useDetailTranslation`, the
key the sibling already uses. Reusing it rather than minting a second key is a
measured decision, not an assumption: the `en` pack value for that key is
'No activity recorded', byte-identical to the literal it replaces, so both
surfaces were already saying the same words in English and a second key would
have forked one sentence across ten packs for no copy difference. The key is
already translated in all ten packs (read out of each pack object, with a
present-key control and an absent-key control), so no pack is edited here, and
`DETAIL_DEFAULT_TRANSLATIONS` already carries the byte-identical English row
that serves a provider-less host.
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 48 chunks) 3152.5 KB 3191.4 KB
Main entry chunk (gzip) 142.5 KB 350 KB
Entry file index-utltXYJm.js
Status PASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 15.33KB 5.59KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 5.13KB 2.35KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 512.30KB 116.52KB
core (index.js) 5.30KB 2.13KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 177.67KB 49.45KB
fields (index.js) 244.25KB 61.73KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 4.28KB 1.75KB
i18n (index.js) 3.44KB 1.39KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 33.40KB 8.71KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.98KB 10.98KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.55KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useResponsiveConfig.js) 1.37KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 11.71KB 4.29KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.92KB 12.93KB
plugin-charts (index.js) 65.67KB 18.57KB
plugin-chatbot (index.js) 190.53KB 45.18KB
plugin-dashboard (index.js) 132.61KB 34.56KB
plugin-designer (index.js) 212.87KB 43.19KB
plugin-detail (index.js) 248.95KB 63.55KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 133.11KB 32.61KB
plugin-gantt (index.js) 165.21KB 40.37KB
plugin-grid (index.js) 202.31KB 54.66KB
plugin-kanban (index.js) 53.21KB 14.66KB
plugin-list (index.js) 113.19KB 27.60KB
plugin-map (index.js) 20.20KB 6.66KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 43.51KB 11.94KB
plugin-timeline (index.js) 29.34KB 8.47KB
plugin-tree (index.js) 8.98KB 3.08KB
plugin-view (index.js) 85.90KB 21.12KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 81.07KB 26.86KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 3.11KB 1.48KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 5.41KB 2.34KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 4.93KB 2.24KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 10.35KB 3.60KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.74KB 1.41KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.72KB 2.24KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Copy link
Copy Markdown
Collaborator Author

✅ Reviewed and armed

All 30 check runs in one call at perPage=100: 27 success, 3 skipped, 0 failed, 0 in_progress. Undrafted, auto-merge SQUASH enabled at 0e210de56.

You verified the thing I got wrong, instead of taking my word

I told you to determine which i18n system this file belongs to rather than trust me — because I gave the wrong answer on objectui#7121, where engine.* strings turned out to live in a two-locale en/zh table outside the pack system, and following me would have added nine dead entries.

You went to the code: plugin-detail uses the ten-pack system (en/zh/ja/ko/de/fr/es/pt/ru/ar) reached through useDetailTranslation.ts:9 (createSafeTranslation over @object-ui/i18n), with DETAIL_DEFAULT_TRANSLATIONS as the provider-less mirror — not the metadata-admin table. Naming the file you verified it in is what makes that checkable rather than another assertion.

The pack probe avoided the trap it needed to

⛔ A dotted-key grep for detail.noActivity returns zero against the en pack that defines it, because the packs are nested. You read the key out of each pack object via tsx instead, with two controls: positive (detail.noRelatedRecords + detail.loading present in all ten) and negative (detail.zzzNotAKeyEver absent from all ten). That pairing is what makes "present in all ten" a reading rather than a hope — and all ten carry real translations, not English fallbacks.

This seat has been bitten by exactly that false zero before. Good.

Ruling 2 settled by rendering, not by assuming

The en value for detail.noActivity is byte-identical to the literal being replaced. So both surfaces already said the same words, and minting a second key would have forked one sentence across ten packs for zero copy difference. That is the right call, and it is only knowable by looking at both surfaces rather than reasoning that "two components might want different phrasing."

Ablation 3 failed | 3 passed (6), predicted before running — zh, ar and filtered-zh red, en / no-provider / raw-key green. The passing count is stated because a collapse to zero survivors also shows red. No rebuild leg claimed and that was established, not assumed: the test imports relatively and the root config aliases @object-ui/i18n to packages/i18n/src, so nothing resolves through dist/.

Three NOT MEASURED, named rather than counted

check:sdui-registration-pins and check:eager-closure both exit 2 on an unbuilt console — the absence of a reading, not a pass. The third is the interesting one: check:readme-exports declares its own run void while packages are unbuilt, and rather than leave it there you built @object-ui/plugin-detail and showed its README left the unjudged list (1 → 0), so this package's slice is judged and the residual collapse belongs to the other 24. That is a partial reading reported honestly instead of being rounded to either green or red.

⚠️ What my scoping decision actually shipped

I fenced this card to the empty state and you held the fence, filing objectui#7149 rather than widening. Correct — and worth stating plainly: this ships one translated string into a component that is otherwise entirely untranslated. A zh card now reads "Activity(0)暂无活动记录" — empty state right, card title beside it still English. An improvement, and visibly half-done.

The deeper finding is not "18 more literals." It is that the component used no translation hook at all, while its sibling renders the identical surface fully in Chinese. objectui#7149 unblocks when this lands.


Generated by Claude Code

Merged via the queue into main with commit 854cba3 Sep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7142-activity-timeline-empty-i18n branch September 1, 2026 07:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ActivityTimeline's empty state is a hardcoded English literal, so a zh session reads "No activity recorded"

2 participants