fix(plugin-detail,i18n): RecordComments and PointInTimeRestore resolve their copy from the packs - #7172

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7163-timestamp-duplication-i18n
Sep 1, 2026
Merged

fix(plugin-detail,i18n): RecordComments and PointInTimeRestore resolve their copy from the packs#7172
os-warren merged 1 commit into
mainfrom
claude/issue-7163-timestamp-duplication-i18n

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7163

Measured on origin/main head d8ec8d6d4 (PR #7162's landing sha — the card's line numbers predate it, so everything below was re-derived, not inherited). Final commit 42f6c1480; every gate result quoted here was run on that tree with git status --porcelain empty.

The card's "byte-identical" premise is FALSE — verified mechanically

The title and both PM comments say the two formatTimestamp copies are byte-identical. They are not:

md5 b36edffbf4d6719482f17e1743ac2a4a RecordComments::formatTimestamp (18 lines, 597 bytes)
md5 846a6b780ea858ee9650f7a0eadf6f6c PointInTimeRestore::formatTimestamp (16 lines, 495 bytes)
diff exit=1 — 3 lines differ

RecordComments has a fourth d ago branch and falls through to toLocaleDateString(); PointInTimeRestore stops at hours and falls through to toLocaleString(). A revision list wants the time of day; a comment list does not. (The card body said as much — "the same, minus the days branch"; only the title and the dispatch generalised it.)

This decides the sharing question by measurement, per the scope ruling. Sharing is declined on two independent grounds: the two helpers have genuinely different tails, so unifying them would silently restyle one surface; and a shared helper needs a new export, which is Clause ② with the quota exhausted. Reported rather than routed around. The duplication is now three-way (ActivityTimeline, RecordComments, and this near-copy) and is filed separately as a finding.

What shipped

RecordComments.tsx — pure lookup swap, no new key, no copy change. The file was already wired to the packs (11 t('detail.…') refs); only its helper was hardcoded. It now resolves detail.justNow / minutesAgo / hoursAgo / daysAgo. Each mapping was checked for exactness, not adopted on the card's word:

literalkeyen pack valueverdict
'just now'detail.justNowjust nowexact
`${diffMins}m ago`detail.minutesAgo{{count}}m agoexact
`${diffHours}h ago`detail.hoursAgo{{count}}h agoexact
`${diffDays}d ago`detail.daysAgo{{count}}d agoexact

PointInTimeRestore.tsx — swept WHOLE, not partially wired. It used no translation hook at all (0 refs), so per the ruling it was finished in one pass rather than becoming a third half-done component: card title, empty state, field-count line, preview heading, snapshot heading, both (empty) placeholders, the snapshot dash, the restore confirmation, and all three buttons. 17 static t() call sites; zero English literals remain (each removed anchor greps to 0).

Ten new keys land in all ten packs and in DETAIL_DEFAULT_TRANSLATIONS. Three existing keys are reused rather than forked: detail.cancel, detail.activityEmptyValue (the key ActivityTimeline already uses for an absent old/new value in a field-change diff — the same concept), and detail.emptyValue.

Two shape rules observed:

One deliberate copy change, called out rather than quietly adopted: the snapshot panel's null placeholder was an EN DASH (, U+2013) written inline; it now resolves detail.emptyValue, which is an EM DASH (, U+2014) in all ten packs — the glyph the rest of the detail package already uses.

Pack verification method, with both controls

Read out of the pack objects by walking the nested path (packages/i18n/src/locales/index.ts -> builtInLocales), never a dotted-key grep — a grep for detail.justNow returns a false zero against the en pack that defines it, and the detail namespace is one of five in these packs that carry a justNow row.

  • Loader liveness: locales loaded: en,zh,ja,ko,de,fr,es,pt,ru,ar (count=10)
  • Positive controls: detail.back 10/10, detail.noActivity 10/10
  • Negative control: detail.zzzAbsentControl71630/10
  • The four existing relative-time keys: 10/10 each
  • The ten new keys after the change: 10/10 each, controls re-run in the same invocation and unchanged

Reachability finding (this splits the card, as #7149's did)

PointInTimeRestore has zero in-repo consumers. Repo-wide, excluding node_modules and dist, it appears only in its own file and twice in the package barrel (index.tsx:126 export, :154 type export). Nothing renders it — not DetailView, not apps/console. RecordComments, by contrast, is rendered by DetailView at two call sites (:1479, :1705).

So the user-visible defect today is RecordComments; PointInTimeRestore is public API a downstream consumer can mount but nothing in this repo does. It was still swept whole rather than deferred: it is exported, so it is shippable surface, and finishing it now is what stops the #7142 shape from recurring.

Nothing pinned the old literals — attributed, not counted

Repo-wide grep -F on 'just now' and 'm ago' returns hits across five packages. Attributed rather than counted: every test assertion among them belongs to packages/collaboration (a different package and namespace, useCollaborationTranslation) or to ActivityTimeline.remainingLiterals.i18n.test.tsx (#7162's suite, asserting on a different component). The two plugin-detail hits in RecordMetaFooter.tsx are comments, not literals — that file is already a correct t() call site. The PointInTimeRestore copy has exactly one hit per literal: the file itself. No test pinned either file's English.

Evidence

Ablation — direction predicted before running, mutation proven on disk, restore proven by state.

Predicted: reverting only the two .tsx files to d8ec8d6d4 (packs, defaults map and the new suites kept) turns the zh/ar assertions red while every en and provider-less assertion stays green by construction, since each en pack value is byte-identical to the literal being restored. Predicted 9 red / 8 green.

Observed, exactly:

Test Files 2 failed (2)
Tests 9 failed | 8 passed (17)
  • Mutation proven by blob hash AND marker count: A(HEAD)=49c08e56… vs A(disk)=b5ce3ad9…, B(HEAD)=0db4d714… vs B(disk)=cac43cf7…; injected marker t('detail.justNow') 0 in both, restored marker return 'just now'; 1 in both, Revision History literal back to 1.
  • Restore proven by state, not exit code: git diff HEAD produced 0 lines, git status --porcelain0 lines, and both on-disk hashes match their HEAD blobs exactly. The script carried trap restore EXIT INT TERM with absolute paths resolved from git rev-parse --show-toplevel, and the restore leg names HEAD explicitly (a bare git checkout -- path restores from the index, which the mutation had already written).
  • The 8 greens are green-by-construction and are kept deliberately: they guard that this was a lookup swap and not a copy change. Naming them: the en and provider-less legs of both suites, the two {{count}}-interpolation guards, and the two raw-key guards (a reverted file renders literals, not keys, so those cannot discriminate).

Resolution path stated, since an ablation is only valid if it is measured: the suites resolve source, not dist/. The components are same-package relative imports, and the root vitest config aliases @object-ui/i18n to packages/i18n/src (vitest.config.mts:275). No rebuild leg is required for this ablation, and the green run was measured against source too. The dependency closure was built anyway before the first run (pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-detail^...' build, exit 0).

Green runs (vitest paths root-relative per #3378, canonical invocation through the root config):

packages/plugin-detail/src/RecordComments.i18n.test.tsx
packages/plugin-detail/src/PointInTimeRestore.i18n.test.tsx
Test Files 2 passed (2) Tests 17 passed (17)
packages/plugin-detail/ packages/i18n/ (affected packages, whole)
Test Files 182 passed (182) Tests 2071 passed (2071)
all-locales-key-parity + de-quote-pairing-3876 + defaults-maps-mirror-en-pack
Test Files 3 passed (3) Tests 53 passed (53)

Gates — exit codes captured by redirect-then-capture, never read across a pipe; each verdict is the gate's own printed line:

  • check:i18n-keysexit 0"Every in-scope call-site key resolves against the en pack (2865 keys), every literal inline defaultValue matches the value the pack serves, every call site passes exactly the arguments that value has holes for…"
  • check:i18n-driftexit 0"Compared the ten locale packs at d8ec8d6 (merge-base with origin/main) with the working tree: 0 en value(s) changed (10 key(s) added, 0 removed…)"
  • check:i18n-dead-keysexit 0 (report, not a gate)
  • changeset:checkexit 0"All workspace packages are in the changeset fixed group." / "No changeset declares a major bump."
  • check-changeset-presence.mjsexit 0"15 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)"
  • check-changeset-overwrite.mjsexit 0"No pre-existing changeset was modified or deleted."
  • check:control-bytesexit 0"OK (scanned 5947 tracked text file(s); skipped 85 binary)"; plus a direct grep -naP control-char scan over all 16 changed files: 0 hits
  • type-check (plugin-detail + i18n) exit 0"Scope: 2 of 47 workspace projects" with both script names echoed, so not a zero-match silent pass
  • lint (plugin-detail + i18n, plain per-package eslint ., no --no-inline-config) exit 0 — warnings only, all pre-existing; the three on PointInTimeRestore.tsx are the RevisionEntryany fields at lines 25/27, which this diff does not touch

Not NOT-MEASURED:type-check runs tsc -p tsconfig.test.json, and --listFiles confirms both new suites are actually in that program (1 hit each; control zzzNoSuchFile.tsx = 0) — so the green genuinely covers the new test files rather than excluding them.

The de quote-pairing census did not move. The ⚠ carried forward from #7149 (a pack value with a quoted span shifts de-quote-pairing-3876.test.ts, 55 -> 58) does not apply: the German values added here contain no quote characters by construction, the census stays at 58, and that file is untouched — verified by running it green rather than by assuming.

Scope

Lane held: packages/plugin-detail/src/{RecordComments,PointInTimeRestore}.tsx, their new suites, and the ten locale packs. ActivityTimeline.tsx was read-only reference and is unmodified. No fenced package was entered. content/docs/releases/ untouched.


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3156.6 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-DagPPWNY.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.32KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.14KB49.57KB
fields (index.js)244.25KB61.73KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)66.84KB18.86KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)250.65KB63.91KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)205.53KB55.50KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.21KB27.60KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

PM review — domain:ui seat (session session_012wwHa4aaFybxXrfmfHioDM), reviewer of record

Verdict: ACCEPT. Arming for the queue on green. Three things in the report are the PM's to rule on rather than the implementer's, so I am ruling them here rather than leaving them in the PR body as open observations.

⭐ The ablation is the best-formed one this seat has reviewed this week

Direction predicted before running — 9 red / 8 green — and observed exactly (Tests 9 failed | 8 passed (17)). What makes it worth calling out is not the match, it is the reason the split was predictable: every en pack value is byte-identical to the literal it replaced, so en and provider-less assertions stay green by construction while zh/ar go red. That turns the 8 passes into a real instrument — they are the guard proving this was a lookup swap and not a copy change, which is precisely the claim an i18n PR most needs to establish and most easily fakes. Keeping them deliberately, and saying why, is the discipline working as intended.

Two details I want on the record because they are traps this seat has actually fallen into:

  • git checkout -- <path> restores from the INDEX, not HEAD — and the mutation had already written the index. Naming HEAD explicitly is the difference between a restore and a no-op that reports success. This is the same failure family as this seat's own instrument failure [WIP] Update documentation for project #11 (a malformed control that printed empty and was read as a 0).
  • Restore proven by state (git diff HEAD 0 lines, git status --porcelain 0 lines, on-disk hashes equal to HEAD blobs), never by an editor's exit code; mutation proven by blob hash and marker count on both files. Correct on both legs.

The NOT-MEASURED check is also right: --listFiles confirms both new suites are in the type-check program (1 hit each, control zzzNoSuchFile.tsx = 0), so the green covers the new files rather than silently excluding them. And the rebuild leg was measured unnecessary (source aliasing at vitest.config.mts:275), not assumed — then the closure was built anyway. That is the right order.

Ruling 1 — the whole-file sweep of a component with ZERO consumers: upheld

PointInTimeRestore is barrel-exported and rendered by nothing — not DetailView, not apps/console. The implementer swept all 17 sites anyway and flagged it as the kind of measurement that legitimately re-splits a card. Correct to flag; and the call to sweep stands, for the reason given: it is exported public API, and a third #7142-shaped half-done component is a worse outcome than translating a few strings nobody currently renders.

⚠️ But the measurement is more consequential than a scope note, and it does not belong buried in this PR. A zero-consumer exported component is exactly the ADR-0049 enforce-or-remove question — and this PR has just executed the enforce half without anyone asking whether remove was the right answer. That is not a reason to hold this PR (the work is done, correct, and cleanly revertible as one file plus its keys), but it is a reason to file the question. Filing it now, and linking it back here.

Ruling 2 — the EN DASH → EM DASH copy change: accepted, and reporting it was the right move

PointInTimeRestore's snapshot null placeholder was an inline EN DASH (U+2013) and now resolves detail.emptyValue, an EM DASH (U+2014) across all ten packs. This is a user-visible copy change riding an i18n card, which is normally exactly what I would send back.

It stands because the alternative is worse: forking a detail.emptyValueEnDash key to preserve one glyph would put a second spelling of one concept into ten packs — the multi-spelling class this repo is actively paying down (#7021 found the record-title key in three spellings). Reusing the key converges on the glyph the rest of the package already uses. ⭐ Reporting it rather than quietly adopting it is what makes it acceptable; an unreported glyph swap inside a 15-file i18n diff is invisible to review.

Ruling 3 — premise_still_valid: false scoped to A3.2 only: correct handling

The "byte-identical copies" claim was false; A3.1, A3.3 and A3.5 confirmed; the implementable core was sound and shipped. Falsifying one PM assumption without stopping, and saying precisely which one, is the behaviour the three-zone order exists to produce. Zone 2 is there to be falsified.

Out-of-scope handling: correct on both

On the report's own accounting

mcp_calls reported as 9 with the note that the GitHub copy says 8 because the verifying read-back was necessarily the 9th. Trivial in itself; not trivial as a habit. A report that corrects its own count against its own artifact is a report whose other numbers I can spend less time re-deriving.


Nothing to change. Undrafting and arming auto-merge SQUASH once the four test shards, Type Check, Lint and Bundle Analysis report green — currently in_progress, nothing red.


Generated by Claude Code

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RecordComments and PointInTimeRestore carry the same untranslated formatTimestampActivityTimeline just had — keys already exist in all ten packs

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

fix(plugin-detail,i18n): RecordComments and PointInTimeRestore resolve their copy from the packs - #7172

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7163-timestamp-duplication-i18n
Sep 1, 2026
Merged

fix(plugin-detail,i18n): RecordComments and PointInTimeRestore resolve their copy from the packs#7172
os-warren merged 1 commit into
mainfrom
claude/issue-7163-timestamp-duplication-i18n

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7163

Measured on origin/main head d8ec8d6d4 (PR #7162's landing sha — the card's line numbers predate it, so everything below was re-derived, not inherited). Final commit 42f6c1480; every gate result quoted here was run on that tree with git status --porcelain empty.

The card's "byte-identical" premise is FALSE — verified mechanically

The title and both PM comments say the two formatTimestamp copies are byte-identical. They are not:

md5 b36edffbf4d6719482f17e1743ac2a4a RecordComments::formatTimestamp (18 lines, 597 bytes)
md5 846a6b780ea858ee9650f7a0eadf6f6c PointInTimeRestore::formatTimestamp (16 lines, 495 bytes)
diff exit=1 — 3 lines differ

RecordComments has a fourth d ago branch and falls through to toLocaleDateString(); PointInTimeRestore stops at hours and falls through to toLocaleString(). A revision list wants the time of day; a comment list does not. (The card body said as much — "the same, minus the days branch"; only the title and the dispatch generalised it.)

This decides the sharing question by measurement, per the scope ruling. Sharing is declined on two independent grounds: the two helpers have genuinely different tails, so unifying them would silently restyle one surface; and a shared helper needs a new export, which is Clause ② with the quota exhausted. Reported rather than routed around. The duplication is now three-way (ActivityTimeline, RecordComments, and this near-copy) and is filed separately as a finding.

What shipped

RecordComments.tsx — pure lookup swap, no new key, no copy change. The file was already wired to the packs (11 t('detail.…') refs); only its helper was hardcoded. It now resolves detail.justNow / minutesAgo / hoursAgo / daysAgo. Each mapping was checked for exactness, not adopted on the card's word:

literalkeyen pack valueverdict
'just now'detail.justNowjust nowexact
`${diffMins}m ago`detail.minutesAgo{{count}}m agoexact
`${diffHours}h ago`detail.hoursAgo{{count}}h agoexact
`${diffDays}d ago`detail.daysAgo{{count}}d agoexact

PointInTimeRestore.tsx — swept WHOLE, not partially wired. It used no translation hook at all (0 refs), so per the ruling it was finished in one pass rather than becoming a third half-done component: card title, empty state, field-count line, preview heading, snapshot heading, both (empty) placeholders, the snapshot dash, the restore confirmation, and all three buttons. 17 static t() call sites; zero English literals remain (each removed anchor greps to 0).

Ten new keys land in all ten packs and in DETAIL_DEFAULT_TRANSLATIONS. Three existing keys are reused rather than forked: detail.cancel, detail.activityEmptyValue (the key ActivityTimeline already uses for an absent old/new value in a field-change diff — the same concept), and detail.emptyValue.

Two shape rules observed:

One deliberate copy change, called out rather than quietly adopted: the snapshot panel's null placeholder was an EN DASH (, U+2013) written inline; it now resolves detail.emptyValue, which is an EM DASH (, U+2014) in all ten packs — the glyph the rest of the detail package already uses.

Pack verification method, with both controls

Read out of the pack objects by walking the nested path (packages/i18n/src/locales/index.ts -> builtInLocales), never a dotted-key grep — a grep for detail.justNow returns a false zero against the en pack that defines it, and the detail namespace is one of five in these packs that carry a justNow row.

  • Loader liveness: locales loaded: en,zh,ja,ko,de,fr,es,pt,ru,ar (count=10)
  • Positive controls: detail.back 10/10, detail.noActivity 10/10
  • Negative control: detail.zzzAbsentControl71630/10
  • The four existing relative-time keys: 10/10 each
  • The ten new keys after the change: 10/10 each, controls re-run in the same invocation and unchanged

Reachability finding (this splits the card, as #7149's did)

PointInTimeRestore has zero in-repo consumers. Repo-wide, excluding node_modules and dist, it appears only in its own file and twice in the package barrel (index.tsx:126 export, :154 type export). Nothing renders it — not DetailView, not apps/console. RecordComments, by contrast, is rendered by DetailView at two call sites (:1479, :1705).

So the user-visible defect today is RecordComments; PointInTimeRestore is public API a downstream consumer can mount but nothing in this repo does. It was still swept whole rather than deferred: it is exported, so it is shippable surface, and finishing it now is what stops the #7142 shape from recurring.

Nothing pinned the old literals — attributed, not counted

Repo-wide grep -F on 'just now' and 'm ago' returns hits across five packages. Attributed rather than counted: every test assertion among them belongs to packages/collaboration (a different package and namespace, useCollaborationTranslation) or to ActivityTimeline.remainingLiterals.i18n.test.tsx (#7162's suite, asserting on a different component). The two plugin-detail hits in RecordMetaFooter.tsx are comments, not literals — that file is already a correct t() call site. The PointInTimeRestore copy has exactly one hit per literal: the file itself. No test pinned either file's English.

Evidence

Ablation — direction predicted before running, mutation proven on disk, restore proven by state.

Predicted: reverting only the two .tsx files to d8ec8d6d4 (packs, defaults map and the new suites kept) turns the zh/ar assertions red while every en and provider-less assertion stays green by construction, since each en pack value is byte-identical to the literal being restored. Predicted 9 red / 8 green.

Observed, exactly:

Test Files 2 failed (2)
Tests 9 failed | 8 passed (17)
  • Mutation proven by blob hash AND marker count: A(HEAD)=49c08e56… vs A(disk)=b5ce3ad9…, B(HEAD)=0db4d714… vs B(disk)=cac43cf7…; injected marker t('detail.justNow') 0 in both, restored marker return 'just now'; 1 in both, Revision History literal back to 1.
  • Restore proven by state, not exit code: git diff HEAD produced 0 lines, git status --porcelain0 lines, and both on-disk hashes match their HEAD blobs exactly. The script carried trap restore EXIT INT TERM with absolute paths resolved from git rev-parse --show-toplevel, and the restore leg names HEAD explicitly (a bare git checkout -- path restores from the index, which the mutation had already written).
  • The 8 greens are green-by-construction and are kept deliberately: they guard that this was a lookup swap and not a copy change. Naming them: the en and provider-less legs of both suites, the two {{count}}-interpolation guards, and the two raw-key guards (a reverted file renders literals, not keys, so those cannot discriminate).

Resolution path stated, since an ablation is only valid if it is measured: the suites resolve source, not dist/. The components are same-package relative imports, and the root vitest config aliases @object-ui/i18n to packages/i18n/src (vitest.config.mts:275). No rebuild leg is required for this ablation, and the green run was measured against source too. The dependency closure was built anyway before the first run (pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-detail^...' build, exit 0).

Green runs (vitest paths root-relative per #3378, canonical invocation through the root config):

packages/plugin-detail/src/RecordComments.i18n.test.tsx
packages/plugin-detail/src/PointInTimeRestore.i18n.test.tsx
Test Files 2 passed (2) Tests 17 passed (17)
packages/plugin-detail/ packages/i18n/ (affected packages, whole)
Test Files 182 passed (182) Tests 2071 passed (2071)
all-locales-key-parity + de-quote-pairing-3876 + defaults-maps-mirror-en-pack
Test Files 3 passed (3) Tests 53 passed (53)

Gates — exit codes captured by redirect-then-capture, never read across a pipe; each verdict is the gate's own printed line:

  • check:i18n-keysexit 0"Every in-scope call-site key resolves against the en pack (2865 keys), every literal inline defaultValue matches the value the pack serves, every call site passes exactly the arguments that value has holes for…"
  • check:i18n-driftexit 0"Compared the ten locale packs at d8ec8d6 (merge-base with origin/main) with the working tree: 0 en value(s) changed (10 key(s) added, 0 removed…)"
  • check:i18n-dead-keysexit 0 (report, not a gate)
  • changeset:checkexit 0"All workspace packages are in the changeset fixed group." / "No changeset declares a major bump."
  • check-changeset-presence.mjsexit 0"15 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)"
  • check-changeset-overwrite.mjsexit 0"No pre-existing changeset was modified or deleted."
  • check:control-bytesexit 0"OK (scanned 5947 tracked text file(s); skipped 85 binary)"; plus a direct grep -naP control-char scan over all 16 changed files: 0 hits
  • type-check (plugin-detail + i18n) exit 0"Scope: 2 of 47 workspace projects" with both script names echoed, so not a zero-match silent pass
  • lint (plugin-detail + i18n, plain per-package eslint ., no --no-inline-config) exit 0 — warnings only, all pre-existing; the three on PointInTimeRestore.tsx are the RevisionEntryany fields at lines 25/27, which this diff does not touch

Not NOT-MEASURED:type-check runs tsc -p tsconfig.test.json, and --listFiles confirms both new suites are actually in that program (1 hit each; control zzzNoSuchFile.tsx = 0) — so the green genuinely covers the new test files rather than excluding them.

The de quote-pairing census did not move. The ⚠ carried forward from #7149 (a pack value with a quoted span shifts de-quote-pairing-3876.test.ts, 55 -> 58) does not apply: the German values added here contain no quote characters by construction, the census stays at 58, and that file is untouched — verified by running it green rather than by assuming.

Scope

Lane held: packages/plugin-detail/src/{RecordComments,PointInTimeRestore}.tsx, their new suites, and the ten locale packs. ActivityTimeline.tsx was read-only reference and is unmodified. No fenced package was entered. content/docs/releases/ untouched.


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3156.6 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-DagPPWNY.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.32KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.14KB49.57KB
fields (index.js)244.25KB61.73KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)66.84KB18.86KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)250.65KB63.91KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)205.53KB55.50KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.21KB27.60KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

PM review — domain:ui seat (session session_012wwHa4aaFybxXrfmfHioDM), reviewer of record

Verdict: ACCEPT. Arming for the queue on green. Three things in the report are the PM's to rule on rather than the implementer's, so I am ruling them here rather than leaving them in the PR body as open observations.

⭐ The ablation is the best-formed one this seat has reviewed this week

Direction predicted before running — 9 red / 8 green — and observed exactly (Tests 9 failed | 8 passed (17)). What makes it worth calling out is not the match, it is the reason the split was predictable: every en pack value is byte-identical to the literal it replaced, so en and provider-less assertions stay green by construction while zh/ar go red. That turns the 8 passes into a real instrument — they are the guard proving this was a lookup swap and not a copy change, which is precisely the claim an i18n PR most needs to establish and most easily fakes. Keeping them deliberately, and saying why, is the discipline working as intended.

Two details I want on the record because they are traps this seat has actually fallen into:

  • git checkout -- <path> restores from the INDEX, not HEAD — and the mutation had already written the index. Naming HEAD explicitly is the difference between a restore and a no-op that reports success. This is the same failure family as this seat's own instrument failure [WIP] Update documentation for project #11 (a malformed control that printed empty and was read as a 0).
  • Restore proven by state (git diff HEAD 0 lines, git status --porcelain 0 lines, on-disk hashes equal to HEAD blobs), never by an editor's exit code; mutation proven by blob hash and marker count on both files. Correct on both legs.

The NOT-MEASURED check is also right: --listFiles confirms both new suites are in the type-check program (1 hit each, control zzzNoSuchFile.tsx = 0), so the green covers the new files rather than silently excluding them. And the rebuild leg was measured unnecessary (source aliasing at vitest.config.mts:275), not assumed — then the closure was built anyway. That is the right order.

Ruling 1 — the whole-file sweep of a component with ZERO consumers: upheld

PointInTimeRestore is barrel-exported and rendered by nothing — not DetailView, not apps/console. The implementer swept all 17 sites anyway and flagged it as the kind of measurement that legitimately re-splits a card. Correct to flag; and the call to sweep stands, for the reason given: it is exported public API, and a third #7142-shaped half-done component is a worse outcome than translating a few strings nobody currently renders.

⚠️ But the measurement is more consequential than a scope note, and it does not belong buried in this PR. A zero-consumer exported component is exactly the ADR-0049 enforce-or-remove question — and this PR has just executed the enforce half without anyone asking whether remove was the right answer. That is not a reason to hold this PR (the work is done, correct, and cleanly revertible as one file plus its keys), but it is a reason to file the question. Filing it now, and linking it back here.

Ruling 2 — the EN DASH → EM DASH copy change: accepted, and reporting it was the right move

PointInTimeRestore's snapshot null placeholder was an inline EN DASH (U+2013) and now resolves detail.emptyValue, an EM DASH (U+2014) across all ten packs. This is a user-visible copy change riding an i18n card, which is normally exactly what I would send back.

It stands because the alternative is worse: forking a detail.emptyValueEnDash key to preserve one glyph would put a second spelling of one concept into ten packs — the multi-spelling class this repo is actively paying down (#7021 found the record-title key in three spellings). Reusing the key converges on the glyph the rest of the package already uses. ⭐ Reporting it rather than quietly adopting it is what makes it acceptable; an unreported glyph swap inside a 15-file i18n diff is invisible to review.

Ruling 3 — premise_still_valid: false scoped to A3.2 only: correct handling

The "byte-identical copies" claim was false; A3.1, A3.3 and A3.5 confirmed; the implementable core was sound and shipped. Falsifying one PM assumption without stopping, and saying precisely which one, is the behaviour the three-zone order exists to produce. Zone 2 is there to be falsified.

Out-of-scope handling: correct on both

On the report's own accounting

mcp_calls reported as 9 with the note that the GitHub copy says 8 because the verifying read-back was necessarily the 9th. Trivial in itself; not trivial as a habit. A report that corrects its own count against its own artifact is a report whose other numbers I can spend less time re-deriving.


Nothing to change. Undrafting and arming auto-merge SQUASH once the four test shards, Type Check, Lint and Bundle Analysis report green — currently in_progress, nothing red.


Generated by Claude Code

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RecordComments and PointInTimeRestore carry the same untranslated formatTimestampActivityTimeline just had — keys already exist in all ten packs

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

fix(plugin-detail,i18n): RecordComments and PointInTimeRestore resolve their copy from the packs - #7172

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7163-timestamp-duplication-i18n
Sep 1, 2026
Merged

fix(plugin-detail,i18n): RecordComments and PointInTimeRestore resolve their copy from the packs#7172
os-warren merged 1 commit into
mainfrom
claude/issue-7163-timestamp-duplication-i18n

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7163

Measured on origin/main head d8ec8d6d4 (PR #7162's landing sha — the card's line numbers predate it, so everything below was re-derived, not inherited). Final commit 42f6c1480; every gate result quoted here was run on that tree with git status --porcelain empty.

The card's "byte-identical" premise is FALSE — verified mechanically

The title and both PM comments say the two formatTimestamp copies are byte-identical. They are not:

md5 b36edffbf4d6719482f17e1743ac2a4a RecordComments::formatTimestamp (18 lines, 597 bytes)
md5 846a6b780ea858ee9650f7a0eadf6f6c PointInTimeRestore::formatTimestamp (16 lines, 495 bytes)
diff exit=1 — 3 lines differ

RecordComments has a fourth d ago branch and falls through to toLocaleDateString(); PointInTimeRestore stops at hours and falls through to toLocaleString(). A revision list wants the time of day; a comment list does not. (The card body said as much — "the same, minus the days branch"; only the title and the dispatch generalised it.)

This decides the sharing question by measurement, per the scope ruling. Sharing is declined on two independent grounds: the two helpers have genuinely different tails, so unifying them would silently restyle one surface; and a shared helper needs a new export, which is Clause ② with the quota exhausted. Reported rather than routed around. The duplication is now three-way (ActivityTimeline, RecordComments, and this near-copy) and is filed separately as a finding.

What shipped

RecordComments.tsx — pure lookup swap, no new key, no copy change. The file was already wired to the packs (11 t('detail.…') refs); only its helper was hardcoded. It now resolves detail.justNow / minutesAgo / hoursAgo / daysAgo. Each mapping was checked for exactness, not adopted on the card's word:

literalkeyen pack valueverdict
'just now'detail.justNowjust nowexact
`${diffMins}m ago`detail.minutesAgo{{count}}m agoexact
`${diffHours}h ago`detail.hoursAgo{{count}}h agoexact
`${diffDays}d ago`detail.daysAgo{{count}}d agoexact

PointInTimeRestore.tsx — swept WHOLE, not partially wired. It used no translation hook at all (0 refs), so per the ruling it was finished in one pass rather than becoming a third half-done component: card title, empty state, field-count line, preview heading, snapshot heading, both (empty) placeholders, the snapshot dash, the restore confirmation, and all three buttons. 17 static t() call sites; zero English literals remain (each removed anchor greps to 0).

Ten new keys land in all ten packs and in DETAIL_DEFAULT_TRANSLATIONS. Three existing keys are reused rather than forked: detail.cancel, detail.activityEmptyValue (the key ActivityTimeline already uses for an absent old/new value in a field-change diff — the same concept), and detail.emptyValue.

Two shape rules observed:

One deliberate copy change, called out rather than quietly adopted: the snapshot panel's null placeholder was an EN DASH (, U+2013) written inline; it now resolves detail.emptyValue, which is an EM DASH (, U+2014) in all ten packs — the glyph the rest of the detail package already uses.

Pack verification method, with both controls

Read out of the pack objects by walking the nested path (packages/i18n/src/locales/index.ts -> builtInLocales), never a dotted-key grep — a grep for detail.justNow returns a false zero against the en pack that defines it, and the detail namespace is one of five in these packs that carry a justNow row.

  • Loader liveness: locales loaded: en,zh,ja,ko,de,fr,es,pt,ru,ar (count=10)
  • Positive controls: detail.back 10/10, detail.noActivity 10/10
  • Negative control: detail.zzzAbsentControl71630/10
  • The four existing relative-time keys: 10/10 each
  • The ten new keys after the change: 10/10 each, controls re-run in the same invocation and unchanged

Reachability finding (this splits the card, as #7149's did)

PointInTimeRestore has zero in-repo consumers. Repo-wide, excluding node_modules and dist, it appears only in its own file and twice in the package barrel (index.tsx:126 export, :154 type export). Nothing renders it — not DetailView, not apps/console. RecordComments, by contrast, is rendered by DetailView at two call sites (:1479, :1705).

So the user-visible defect today is RecordComments; PointInTimeRestore is public API a downstream consumer can mount but nothing in this repo does. It was still swept whole rather than deferred: it is exported, so it is shippable surface, and finishing it now is what stops the #7142 shape from recurring.

Nothing pinned the old literals — attributed, not counted

Repo-wide grep -F on 'just now' and 'm ago' returns hits across five packages. Attributed rather than counted: every test assertion among them belongs to packages/collaboration (a different package and namespace, useCollaborationTranslation) or to ActivityTimeline.remainingLiterals.i18n.test.tsx (#7162's suite, asserting on a different component). The two plugin-detail hits in RecordMetaFooter.tsx are comments, not literals — that file is already a correct t() call site. The PointInTimeRestore copy has exactly one hit per literal: the file itself. No test pinned either file's English.

Evidence

Ablation — direction predicted before running, mutation proven on disk, restore proven by state.

Predicted: reverting only the two .tsx files to d8ec8d6d4 (packs, defaults map and the new suites kept) turns the zh/ar assertions red while every en and provider-less assertion stays green by construction, since each en pack value is byte-identical to the literal being restored. Predicted 9 red / 8 green.

Observed, exactly:

Test Files 2 failed (2)
Tests 9 failed | 8 passed (17)
  • Mutation proven by blob hash AND marker count: A(HEAD)=49c08e56… vs A(disk)=b5ce3ad9…, B(HEAD)=0db4d714… vs B(disk)=cac43cf7…; injected marker t('detail.justNow') 0 in both, restored marker return 'just now'; 1 in both, Revision History literal back to 1.
  • Restore proven by state, not exit code: git diff HEAD produced 0 lines, git status --porcelain0 lines, and both on-disk hashes match their HEAD blobs exactly. The script carried trap restore EXIT INT TERM with absolute paths resolved from git rev-parse --show-toplevel, and the restore leg names HEAD explicitly (a bare git checkout -- path restores from the index, which the mutation had already written).
  • The 8 greens are green-by-construction and are kept deliberately: they guard that this was a lookup swap and not a copy change. Naming them: the en and provider-less legs of both suites, the two {{count}}-interpolation guards, and the two raw-key guards (a reverted file renders literals, not keys, so those cannot discriminate).

Resolution path stated, since an ablation is only valid if it is measured: the suites resolve source, not dist/. The components are same-package relative imports, and the root vitest config aliases @object-ui/i18n to packages/i18n/src (vitest.config.mts:275). No rebuild leg is required for this ablation, and the green run was measured against source too. The dependency closure was built anyway before the first run (pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-detail^...' build, exit 0).

Green runs (vitest paths root-relative per #3378, canonical invocation through the root config):

packages/plugin-detail/src/RecordComments.i18n.test.tsx
packages/plugin-detail/src/PointInTimeRestore.i18n.test.tsx
Test Files 2 passed (2) Tests 17 passed (17)
packages/plugin-detail/ packages/i18n/ (affected packages, whole)
Test Files 182 passed (182) Tests 2071 passed (2071)
all-locales-key-parity + de-quote-pairing-3876 + defaults-maps-mirror-en-pack
Test Files 3 passed (3) Tests 53 passed (53)

Gates — exit codes captured by redirect-then-capture, never read across a pipe; each verdict is the gate's own printed line:

  • check:i18n-keysexit 0"Every in-scope call-site key resolves against the en pack (2865 keys), every literal inline defaultValue matches the value the pack serves, every call site passes exactly the arguments that value has holes for…"
  • check:i18n-driftexit 0"Compared the ten locale packs at d8ec8d6 (merge-base with origin/main) with the working tree: 0 en value(s) changed (10 key(s) added, 0 removed…)"
  • check:i18n-dead-keysexit 0 (report, not a gate)
  • changeset:checkexit 0"All workspace packages are in the changeset fixed group." / "No changeset declares a major bump."
  • check-changeset-presence.mjsexit 0"15 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)"
  • check-changeset-overwrite.mjsexit 0"No pre-existing changeset was modified or deleted."
  • check:control-bytesexit 0"OK (scanned 5947 tracked text file(s); skipped 85 binary)"; plus a direct grep -naP control-char scan over all 16 changed files: 0 hits
  • type-check (plugin-detail + i18n) exit 0"Scope: 2 of 47 workspace projects" with both script names echoed, so not a zero-match silent pass
  • lint (plugin-detail + i18n, plain per-package eslint ., no --no-inline-config) exit 0 — warnings only, all pre-existing; the three on PointInTimeRestore.tsx are the RevisionEntryany fields at lines 25/27, which this diff does not touch

Not NOT-MEASURED:type-check runs tsc -p tsconfig.test.json, and --listFiles confirms both new suites are actually in that program (1 hit each; control zzzNoSuchFile.tsx = 0) — so the green genuinely covers the new test files rather than excluding them.

The de quote-pairing census did not move. The ⚠ carried forward from #7149 (a pack value with a quoted span shifts de-quote-pairing-3876.test.ts, 55 -> 58) does not apply: the German values added here contain no quote characters by construction, the census stays at 58, and that file is untouched — verified by running it green rather than by assuming.

Scope

Lane held: packages/plugin-detail/src/{RecordComments,PointInTimeRestore}.tsx, their new suites, and the ten locale packs. ActivityTimeline.tsx was read-only reference and is unmodified. No fenced package was entered. content/docs/releases/ untouched.


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3156.6 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-DagPPWNY.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.32KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.14KB49.57KB
fields (index.js)244.25KB61.73KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)66.84KB18.86KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)250.65KB63.91KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)205.53KB55.50KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.21KB27.60KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

PM review — domain:ui seat (session session_012wwHa4aaFybxXrfmfHioDM), reviewer of record

Verdict: ACCEPT. Arming for the queue on green. Three things in the report are the PM's to rule on rather than the implementer's, so I am ruling them here rather than leaving them in the PR body as open observations.

⭐ The ablation is the best-formed one this seat has reviewed this week

Direction predicted before running — 9 red / 8 green — and observed exactly (Tests 9 failed | 8 passed (17)). What makes it worth calling out is not the match, it is the reason the split was predictable: every en pack value is byte-identical to the literal it replaced, so en and provider-less assertions stay green by construction while zh/ar go red. That turns the 8 passes into a real instrument — they are the guard proving this was a lookup swap and not a copy change, which is precisely the claim an i18n PR most needs to establish and most easily fakes. Keeping them deliberately, and saying why, is the discipline working as intended.

Two details I want on the record because they are traps this seat has actually fallen into:

  • git checkout -- <path> restores from the INDEX, not HEAD — and the mutation had already written the index. Naming HEAD explicitly is the difference between a restore and a no-op that reports success. This is the same failure family as this seat's own instrument failure [WIP] Update documentation for project #11 (a malformed control that printed empty and was read as a 0).
  • Restore proven by state (git diff HEAD 0 lines, git status --porcelain 0 lines, on-disk hashes equal to HEAD blobs), never by an editor's exit code; mutation proven by blob hash and marker count on both files. Correct on both legs.

The NOT-MEASURED check is also right: --listFiles confirms both new suites are in the type-check program (1 hit each, control zzzNoSuchFile.tsx = 0), so the green covers the new files rather than silently excluding them. And the rebuild leg was measured unnecessary (source aliasing at vitest.config.mts:275), not assumed — then the closure was built anyway. That is the right order.

Ruling 1 — the whole-file sweep of a component with ZERO consumers: upheld

PointInTimeRestore is barrel-exported and rendered by nothing — not DetailView, not apps/console. The implementer swept all 17 sites anyway and flagged it as the kind of measurement that legitimately re-splits a card. Correct to flag; and the call to sweep stands, for the reason given: it is exported public API, and a third #7142-shaped half-done component is a worse outcome than translating a few strings nobody currently renders.

⚠️ But the measurement is more consequential than a scope note, and it does not belong buried in this PR. A zero-consumer exported component is exactly the ADR-0049 enforce-or-remove question — and this PR has just executed the enforce half without anyone asking whether remove was the right answer. That is not a reason to hold this PR (the work is done, correct, and cleanly revertible as one file plus its keys), but it is a reason to file the question. Filing it now, and linking it back here.

Ruling 2 — the EN DASH → EM DASH copy change: accepted, and reporting it was the right move

PointInTimeRestore's snapshot null placeholder was an inline EN DASH (U+2013) and now resolves detail.emptyValue, an EM DASH (U+2014) across all ten packs. This is a user-visible copy change riding an i18n card, which is normally exactly what I would send back.

It stands because the alternative is worse: forking a detail.emptyValueEnDash key to preserve one glyph would put a second spelling of one concept into ten packs — the multi-spelling class this repo is actively paying down (#7021 found the record-title key in three spellings). Reusing the key converges on the glyph the rest of the package already uses. ⭐ Reporting it rather than quietly adopting it is what makes it acceptable; an unreported glyph swap inside a 15-file i18n diff is invisible to review.

Ruling 3 — premise_still_valid: false scoped to A3.2 only: correct handling

The "byte-identical copies" claim was false; A3.1, A3.3 and A3.5 confirmed; the implementable core was sound and shipped. Falsifying one PM assumption without stopping, and saying precisely which one, is the behaviour the three-zone order exists to produce. Zone 2 is there to be falsified.

Out-of-scope handling: correct on both

On the report's own accounting

mcp_calls reported as 9 with the note that the GitHub copy says 8 because the verifying read-back was necessarily the 9th. Trivial in itself; not trivial as a habit. A report that corrects its own count against its own artifact is a report whose other numbers I can spend less time re-deriving.


Nothing to change. Undrafting and arming auto-merge SQUASH once the four test shards, Type Check, Lint and Bundle Analysis report green — currently in_progress, nothing red.


Generated by Claude Code

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RecordComments and PointInTimeRestore carry the same untranslated formatTimestampActivityTimeline just had — keys already exist in all ten packs

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

fix(plugin-detail,i18n): RecordComments and PointInTimeRestore resolve their copy from the packs - #7172

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7163-timestamp-duplication-i18n
Sep 1, 2026
Merged

fix(plugin-detail,i18n): RecordComments and PointInTimeRestore resolve their copy from the packs#7172
os-warren merged 1 commit into
mainfrom
claude/issue-7163-timestamp-duplication-i18n

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7163

Measured on origin/main head d8ec8d6d4 (PR #7162's landing sha — the card's line numbers predate it, so everything below was re-derived, not inherited). Final commit 42f6c1480; every gate result quoted here was run on that tree with git status --porcelain empty.

The card's "byte-identical" premise is FALSE — verified mechanically

The title and both PM comments say the two formatTimestamp copies are byte-identical. They are not:

md5 b36edffbf4d6719482f17e1743ac2a4a RecordComments::formatTimestamp (18 lines, 597 bytes)
md5 846a6b780ea858ee9650f7a0eadf6f6c PointInTimeRestore::formatTimestamp (16 lines, 495 bytes)
diff exit=1 — 3 lines differ

RecordComments has a fourth d ago branch and falls through to toLocaleDateString(); PointInTimeRestore stops at hours and falls through to toLocaleString(). A revision list wants the time of day; a comment list does not. (The card body said as much — "the same, minus the days branch"; only the title and the dispatch generalised it.)

This decides the sharing question by measurement, per the scope ruling. Sharing is declined on two independent grounds: the two helpers have genuinely different tails, so unifying them would silently restyle one surface; and a shared helper needs a new export, which is Clause ② with the quota exhausted. Reported rather than routed around. The duplication is now three-way (ActivityTimeline, RecordComments, and this near-copy) and is filed separately as a finding.

What shipped

RecordComments.tsx — pure lookup swap, no new key, no copy change. The file was already wired to the packs (11 t('detail.…') refs); only its helper was hardcoded. It now resolves detail.justNow / minutesAgo / hoursAgo / daysAgo. Each mapping was checked for exactness, not adopted on the card's word:

literalkeyen pack valueverdict
'just now'detail.justNowjust nowexact
`${diffMins}m ago`detail.minutesAgo{{count}}m agoexact
`${diffHours}h ago`detail.hoursAgo{{count}}h agoexact
`${diffDays}d ago`detail.daysAgo{{count}}d agoexact

PointInTimeRestore.tsx — swept WHOLE, not partially wired. It used no translation hook at all (0 refs), so per the ruling it was finished in one pass rather than becoming a third half-done component: card title, empty state, field-count line, preview heading, snapshot heading, both (empty) placeholders, the snapshot dash, the restore confirmation, and all three buttons. 17 static t() call sites; zero English literals remain (each removed anchor greps to 0).

Ten new keys land in all ten packs and in DETAIL_DEFAULT_TRANSLATIONS. Three existing keys are reused rather than forked: detail.cancel, detail.activityEmptyValue (the key ActivityTimeline already uses for an absent old/new value in a field-change diff — the same concept), and detail.emptyValue.

Two shape rules observed:

One deliberate copy change, called out rather than quietly adopted: the snapshot panel's null placeholder was an EN DASH (, U+2013) written inline; it now resolves detail.emptyValue, which is an EM DASH (, U+2014) in all ten packs — the glyph the rest of the detail package already uses.

Pack verification method, with both controls

Read out of the pack objects by walking the nested path (packages/i18n/src/locales/index.ts -> builtInLocales), never a dotted-key grep — a grep for detail.justNow returns a false zero against the en pack that defines it, and the detail namespace is one of five in these packs that carry a justNow row.

  • Loader liveness: locales loaded: en,zh,ja,ko,de,fr,es,pt,ru,ar (count=10)
  • Positive controls: detail.back 10/10, detail.noActivity 10/10
  • Negative control: detail.zzzAbsentControl71630/10
  • The four existing relative-time keys: 10/10 each
  • The ten new keys after the change: 10/10 each, controls re-run in the same invocation and unchanged

Reachability finding (this splits the card, as #7149's did)

PointInTimeRestore has zero in-repo consumers. Repo-wide, excluding node_modules and dist, it appears only in its own file and twice in the package barrel (index.tsx:126 export, :154 type export). Nothing renders it — not DetailView, not apps/console. RecordComments, by contrast, is rendered by DetailView at two call sites (:1479, :1705).

So the user-visible defect today is RecordComments; PointInTimeRestore is public API a downstream consumer can mount but nothing in this repo does. It was still swept whole rather than deferred: it is exported, so it is shippable surface, and finishing it now is what stops the #7142 shape from recurring.

Nothing pinned the old literals — attributed, not counted

Repo-wide grep -F on 'just now' and 'm ago' returns hits across five packages. Attributed rather than counted: every test assertion among them belongs to packages/collaboration (a different package and namespace, useCollaborationTranslation) or to ActivityTimeline.remainingLiterals.i18n.test.tsx (#7162's suite, asserting on a different component). The two plugin-detail hits in RecordMetaFooter.tsx are comments, not literals — that file is already a correct t() call site. The PointInTimeRestore copy has exactly one hit per literal: the file itself. No test pinned either file's English.

Evidence

Ablation — direction predicted before running, mutation proven on disk, restore proven by state.

Predicted: reverting only the two .tsx files to d8ec8d6d4 (packs, defaults map and the new suites kept) turns the zh/ar assertions red while every en and provider-less assertion stays green by construction, since each en pack value is byte-identical to the literal being restored. Predicted 9 red / 8 green.

Observed, exactly:

Test Files 2 failed (2)
Tests 9 failed | 8 passed (17)
  • Mutation proven by blob hash AND marker count: A(HEAD)=49c08e56… vs A(disk)=b5ce3ad9…, B(HEAD)=0db4d714… vs B(disk)=cac43cf7…; injected marker t('detail.justNow') 0 in both, restored marker return 'just now'; 1 in both, Revision History literal back to 1.
  • Restore proven by state, not exit code: git diff HEAD produced 0 lines, git status --porcelain0 lines, and both on-disk hashes match their HEAD blobs exactly. The script carried trap restore EXIT INT TERM with absolute paths resolved from git rev-parse --show-toplevel, and the restore leg names HEAD explicitly (a bare git checkout -- path restores from the index, which the mutation had already written).
  • The 8 greens are green-by-construction and are kept deliberately: they guard that this was a lookup swap and not a copy change. Naming them: the en and provider-less legs of both suites, the two {{count}}-interpolation guards, and the two raw-key guards (a reverted file renders literals, not keys, so those cannot discriminate).

Resolution path stated, since an ablation is only valid if it is measured: the suites resolve source, not dist/. The components are same-package relative imports, and the root vitest config aliases @object-ui/i18n to packages/i18n/src (vitest.config.mts:275). No rebuild leg is required for this ablation, and the green run was measured against source too. The dependency closure was built anyway before the first run (pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-detail^...' build, exit 0).

Green runs (vitest paths root-relative per #3378, canonical invocation through the root config):

packages/plugin-detail/src/RecordComments.i18n.test.tsx
packages/plugin-detail/src/PointInTimeRestore.i18n.test.tsx
Test Files 2 passed (2) Tests 17 passed (17)
packages/plugin-detail/ packages/i18n/ (affected packages, whole)
Test Files 182 passed (182) Tests 2071 passed (2071)
all-locales-key-parity + de-quote-pairing-3876 + defaults-maps-mirror-en-pack
Test Files 3 passed (3) Tests 53 passed (53)

Gates — exit codes captured by redirect-then-capture, never read across a pipe; each verdict is the gate's own printed line:

  • check:i18n-keysexit 0"Every in-scope call-site key resolves against the en pack (2865 keys), every literal inline defaultValue matches the value the pack serves, every call site passes exactly the arguments that value has holes for…"
  • check:i18n-driftexit 0"Compared the ten locale packs at d8ec8d6 (merge-base with origin/main) with the working tree: 0 en value(s) changed (10 key(s) added, 0 removed…)"
  • check:i18n-dead-keysexit 0 (report, not a gate)
  • changeset:checkexit 0"All workspace packages are in the changeset fixed group." / "No changeset declares a major bump."
  • check-changeset-presence.mjsexit 0"15 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)"
  • check-changeset-overwrite.mjsexit 0"No pre-existing changeset was modified or deleted."
  • check:control-bytesexit 0"OK (scanned 5947 tracked text file(s); skipped 85 binary)"; plus a direct grep -naP control-char scan over all 16 changed files: 0 hits
  • type-check (plugin-detail + i18n) exit 0"Scope: 2 of 47 workspace projects" with both script names echoed, so not a zero-match silent pass
  • lint (plugin-detail + i18n, plain per-package eslint ., no --no-inline-config) exit 0 — warnings only, all pre-existing; the three on PointInTimeRestore.tsx are the RevisionEntryany fields at lines 25/27, which this diff does not touch

Not NOT-MEASURED:type-check runs tsc -p tsconfig.test.json, and --listFiles confirms both new suites are actually in that program (1 hit each; control zzzNoSuchFile.tsx = 0) — so the green genuinely covers the new test files rather than excluding them.

The de quote-pairing census did not move. The ⚠ carried forward from #7149 (a pack value with a quoted span shifts de-quote-pairing-3876.test.ts, 55 -> 58) does not apply: the German values added here contain no quote characters by construction, the census stays at 58, and that file is untouched — verified by running it green rather than by assuming.

Scope

Lane held: packages/plugin-detail/src/{RecordComments,PointInTimeRestore}.tsx, their new suites, and the ten locale packs. ActivityTimeline.tsx was read-only reference and is unmodified. No fenced package was entered. content/docs/releases/ untouched.


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3156.6 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-DagPPWNY.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.32KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.14KB49.57KB
fields (index.js)244.25KB61.73KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)66.84KB18.86KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)250.65KB63.91KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)205.53KB55.50KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.21KB27.60KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

PM review — domain:ui seat (session session_012wwHa4aaFybxXrfmfHioDM), reviewer of record

Verdict: ACCEPT. Arming for the queue on green. Three things in the report are the PM's to rule on rather than the implementer's, so I am ruling them here rather than leaving them in the PR body as open observations.

⭐ The ablation is the best-formed one this seat has reviewed this week

Direction predicted before running — 9 red / 8 green — and observed exactly (Tests 9 failed | 8 passed (17)). What makes it worth calling out is not the match, it is the reason the split was predictable: every en pack value is byte-identical to the literal it replaced, so en and provider-less assertions stay green by construction while zh/ar go red. That turns the 8 passes into a real instrument — they are the guard proving this was a lookup swap and not a copy change, which is precisely the claim an i18n PR most needs to establish and most easily fakes. Keeping them deliberately, and saying why, is the discipline working as intended.

Two details I want on the record because they are traps this seat has actually fallen into:

  • git checkout -- <path> restores from the INDEX, not HEAD — and the mutation had already written the index. Naming HEAD explicitly is the difference between a restore and a no-op that reports success. This is the same failure family as this seat's own instrument failure [WIP] Update documentation for project #11 (a malformed control that printed empty and was read as a 0).
  • Restore proven by state (git diff HEAD 0 lines, git status --porcelain 0 lines, on-disk hashes equal to HEAD blobs), never by an editor's exit code; mutation proven by blob hash and marker count on both files. Correct on both legs.

The NOT-MEASURED check is also right: --listFiles confirms both new suites are in the type-check program (1 hit each, control zzzNoSuchFile.tsx = 0), so the green covers the new files rather than silently excluding them. And the rebuild leg was measured unnecessary (source aliasing at vitest.config.mts:275), not assumed — then the closure was built anyway. That is the right order.

Ruling 1 — the whole-file sweep of a component with ZERO consumers: upheld

PointInTimeRestore is barrel-exported and rendered by nothing — not DetailView, not apps/console. The implementer swept all 17 sites anyway and flagged it as the kind of measurement that legitimately re-splits a card. Correct to flag; and the call to sweep stands, for the reason given: it is exported public API, and a third #7142-shaped half-done component is a worse outcome than translating a few strings nobody currently renders.

⚠️ But the measurement is more consequential than a scope note, and it does not belong buried in this PR. A zero-consumer exported component is exactly the ADR-0049 enforce-or-remove question — and this PR has just executed the enforce half without anyone asking whether remove was the right answer. That is not a reason to hold this PR (the work is done, correct, and cleanly revertible as one file plus its keys), but it is a reason to file the question. Filing it now, and linking it back here.

Ruling 2 — the EN DASH → EM DASH copy change: accepted, and reporting it was the right move

PointInTimeRestore's snapshot null placeholder was an inline EN DASH (U+2013) and now resolves detail.emptyValue, an EM DASH (U+2014) across all ten packs. This is a user-visible copy change riding an i18n card, which is normally exactly what I would send back.

It stands because the alternative is worse: forking a detail.emptyValueEnDash key to preserve one glyph would put a second spelling of one concept into ten packs — the multi-spelling class this repo is actively paying down (#7021 found the record-title key in three spellings). Reusing the key converges on the glyph the rest of the package already uses. ⭐ Reporting it rather than quietly adopting it is what makes it acceptable; an unreported glyph swap inside a 15-file i18n diff is invisible to review.

Ruling 3 — premise_still_valid: false scoped to A3.2 only: correct handling

The "byte-identical copies" claim was false; A3.1, A3.3 and A3.5 confirmed; the implementable core was sound and shipped. Falsifying one PM assumption without stopping, and saying precisely which one, is the behaviour the three-zone order exists to produce. Zone 2 is there to be falsified.

Out-of-scope handling: correct on both

On the report's own accounting

mcp_calls reported as 9 with the note that the GitHub copy says 8 because the verifying read-back was necessarily the 9th. Trivial in itself; not trivial as a habit. A report that corrects its own count against its own artifact is a report whose other numbers I can spend less time re-deriving.


Nothing to change. Undrafting and arming auto-merge SQUASH once the four test shards, Type Check, Lint and Bundle Analysis report green — currently in_progress, nothing red.


Generated by Claude Code

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RecordComments and PointInTimeRestore carry the same untranslated formatTimestampActivityTimeline just had — keys already exist in all ten packs

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

fix(plugin-detail,i18n): RecordComments and PointInTimeRestore resolve their copy from the packs - #7172

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7163-timestamp-duplication-i18n
Sep 1, 2026
Merged

fix(plugin-detail,i18n): RecordComments and PointInTimeRestore resolve their copy from the packs#7172
os-warren merged 1 commit into
mainfrom
claude/issue-7163-timestamp-duplication-i18n

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7163

Measured on origin/main head d8ec8d6d4 (PR #7162's landing sha — the card's line numbers predate it, so everything below was re-derived, not inherited). Final commit 42f6c1480; every gate result quoted here was run on that tree with git status --porcelain empty.

The card's "byte-identical" premise is FALSE — verified mechanically

The title and both PM comments say the two formatTimestamp copies are byte-identical. They are not:

md5 b36edffbf4d6719482f17e1743ac2a4a RecordComments::formatTimestamp (18 lines, 597 bytes)
md5 846a6b780ea858ee9650f7a0eadf6f6c PointInTimeRestore::formatTimestamp (16 lines, 495 bytes)
diff exit=1 — 3 lines differ

RecordComments has a fourth d ago branch and falls through to toLocaleDateString(); PointInTimeRestore stops at hours and falls through to toLocaleString(). A revision list wants the time of day; a comment list does not. (The card body said as much — "the same, minus the days branch"; only the title and the dispatch generalised it.)

This decides the sharing question by measurement, per the scope ruling. Sharing is declined on two independent grounds: the two helpers have genuinely different tails, so unifying them would silently restyle one surface; and a shared helper needs a new export, which is Clause ② with the quota exhausted. Reported rather than routed around. The duplication is now three-way (ActivityTimeline, RecordComments, and this near-copy) and is filed separately as a finding.

What shipped

RecordComments.tsx — pure lookup swap, no new key, no copy change. The file was already wired to the packs (11 t('detail.…') refs); only its helper was hardcoded. It now resolves detail.justNow / minutesAgo / hoursAgo / daysAgo. Each mapping was checked for exactness, not adopted on the card's word:

literalkeyen pack valueverdict
'just now'detail.justNowjust nowexact
`${diffMins}m ago`detail.minutesAgo{{count}}m agoexact
`${diffHours}h ago`detail.hoursAgo{{count}}h agoexact
`${diffDays}d ago`detail.daysAgo{{count}}d agoexact

PointInTimeRestore.tsx — swept WHOLE, not partially wired. It used no translation hook at all (0 refs), so per the ruling it was finished in one pass rather than becoming a third half-done component: card title, empty state, field-count line, preview heading, snapshot heading, both (empty) placeholders, the snapshot dash, the restore confirmation, and all three buttons. 17 static t() call sites; zero English literals remain (each removed anchor greps to 0).

Ten new keys land in all ten packs and in DETAIL_DEFAULT_TRANSLATIONS. Three existing keys are reused rather than forked: detail.cancel, detail.activityEmptyValue (the key ActivityTimeline already uses for an absent old/new value in a field-change diff — the same concept), and detail.emptyValue.

Two shape rules observed:

One deliberate copy change, called out rather than quietly adopted: the snapshot panel's null placeholder was an EN DASH (, U+2013) written inline; it now resolves detail.emptyValue, which is an EM DASH (, U+2014) in all ten packs — the glyph the rest of the detail package already uses.

Pack verification method, with both controls

Read out of the pack objects by walking the nested path (packages/i18n/src/locales/index.ts -> builtInLocales), never a dotted-key grep — a grep for detail.justNow returns a false zero against the en pack that defines it, and the detail namespace is one of five in these packs that carry a justNow row.

  • Loader liveness: locales loaded: en,zh,ja,ko,de,fr,es,pt,ru,ar (count=10)
  • Positive controls: detail.back 10/10, detail.noActivity 10/10
  • Negative control: detail.zzzAbsentControl71630/10
  • The four existing relative-time keys: 10/10 each
  • The ten new keys after the change: 10/10 each, controls re-run in the same invocation and unchanged

Reachability finding (this splits the card, as #7149's did)

PointInTimeRestore has zero in-repo consumers. Repo-wide, excluding node_modules and dist, it appears only in its own file and twice in the package barrel (index.tsx:126 export, :154 type export). Nothing renders it — not DetailView, not apps/console. RecordComments, by contrast, is rendered by DetailView at two call sites (:1479, :1705).

So the user-visible defect today is RecordComments; PointInTimeRestore is public API a downstream consumer can mount but nothing in this repo does. It was still swept whole rather than deferred: it is exported, so it is shippable surface, and finishing it now is what stops the #7142 shape from recurring.

Nothing pinned the old literals — attributed, not counted

Repo-wide grep -F on 'just now' and 'm ago' returns hits across five packages. Attributed rather than counted: every test assertion among them belongs to packages/collaboration (a different package and namespace, useCollaborationTranslation) or to ActivityTimeline.remainingLiterals.i18n.test.tsx (#7162's suite, asserting on a different component). The two plugin-detail hits in RecordMetaFooter.tsx are comments, not literals — that file is already a correct t() call site. The PointInTimeRestore copy has exactly one hit per literal: the file itself. No test pinned either file's English.

Evidence

Ablation — direction predicted before running, mutation proven on disk, restore proven by state.

Predicted: reverting only the two .tsx files to d8ec8d6d4 (packs, defaults map and the new suites kept) turns the zh/ar assertions red while every en and provider-less assertion stays green by construction, since each en pack value is byte-identical to the literal being restored. Predicted 9 red / 8 green.

Observed, exactly:

Test Files 2 failed (2)
Tests 9 failed | 8 passed (17)
  • Mutation proven by blob hash AND marker count: A(HEAD)=49c08e56… vs A(disk)=b5ce3ad9…, B(HEAD)=0db4d714… vs B(disk)=cac43cf7…; injected marker t('detail.justNow') 0 in both, restored marker return 'just now'; 1 in both, Revision History literal back to 1.
  • Restore proven by state, not exit code: git diff HEAD produced 0 lines, git status --porcelain0 lines, and both on-disk hashes match their HEAD blobs exactly. The script carried trap restore EXIT INT TERM with absolute paths resolved from git rev-parse --show-toplevel, and the restore leg names HEAD explicitly (a bare git checkout -- path restores from the index, which the mutation had already written).
  • The 8 greens are green-by-construction and are kept deliberately: they guard that this was a lookup swap and not a copy change. Naming them: the en and provider-less legs of both suites, the two {{count}}-interpolation guards, and the two raw-key guards (a reverted file renders literals, not keys, so those cannot discriminate).

Resolution path stated, since an ablation is only valid if it is measured: the suites resolve source, not dist/. The components are same-package relative imports, and the root vitest config aliases @object-ui/i18n to packages/i18n/src (vitest.config.mts:275). No rebuild leg is required for this ablation, and the green run was measured against source too. The dependency closure was built anyway before the first run (pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-detail^...' build, exit 0).

Green runs (vitest paths root-relative per #3378, canonical invocation through the root config):

packages/plugin-detail/src/RecordComments.i18n.test.tsx
packages/plugin-detail/src/PointInTimeRestore.i18n.test.tsx
Test Files 2 passed (2) Tests 17 passed (17)
packages/plugin-detail/ packages/i18n/ (affected packages, whole)
Test Files 182 passed (182) Tests 2071 passed (2071)
all-locales-key-parity + de-quote-pairing-3876 + defaults-maps-mirror-en-pack
Test Files 3 passed (3) Tests 53 passed (53)

Gates — exit codes captured by redirect-then-capture, never read across a pipe; each verdict is the gate's own printed line:

  • check:i18n-keysexit 0"Every in-scope call-site key resolves against the en pack (2865 keys), every literal inline defaultValue matches the value the pack serves, every call site passes exactly the arguments that value has holes for…"
  • check:i18n-driftexit 0"Compared the ten locale packs at d8ec8d6 (merge-base with origin/main) with the working tree: 0 en value(s) changed (10 key(s) added, 0 removed…)"
  • check:i18n-dead-keysexit 0 (report, not a gate)
  • changeset:checkexit 0"All workspace packages are in the changeset fixed group." / "No changeset declares a major bump."
  • check-changeset-presence.mjsexit 0"15 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)"
  • check-changeset-overwrite.mjsexit 0"No pre-existing changeset was modified or deleted."
  • check:control-bytesexit 0"OK (scanned 5947 tracked text file(s); skipped 85 binary)"; plus a direct grep -naP control-char scan over all 16 changed files: 0 hits
  • type-check (plugin-detail + i18n) exit 0"Scope: 2 of 47 workspace projects" with both script names echoed, so not a zero-match silent pass
  • lint (plugin-detail + i18n, plain per-package eslint ., no --no-inline-config) exit 0 — warnings only, all pre-existing; the three on PointInTimeRestore.tsx are the RevisionEntryany fields at lines 25/27, which this diff does not touch

Not NOT-MEASURED:type-check runs tsc -p tsconfig.test.json, and --listFiles confirms both new suites are actually in that program (1 hit each; control zzzNoSuchFile.tsx = 0) — so the green genuinely covers the new test files rather than excluding them.

The de quote-pairing census did not move. The ⚠ carried forward from #7149 (a pack value with a quoted span shifts de-quote-pairing-3876.test.ts, 55 -> 58) does not apply: the German values added here contain no quote characters by construction, the census stays at 58, and that file is untouched — verified by running it green rather than by assuming.

Scope

Lane held: packages/plugin-detail/src/{RecordComments,PointInTimeRestore}.tsx, their new suites, and the ten locale packs. ActivityTimeline.tsx was read-only reference and is unmodified. No fenced package was entered. content/docs/releases/ untouched.


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3156.6 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-DagPPWNY.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.32KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.14KB49.57KB
fields (index.js)244.25KB61.73KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)66.84KB18.86KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)250.65KB63.91KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)205.53KB55.50KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.21KB27.60KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

PM review — domain:ui seat (session session_012wwHa4aaFybxXrfmfHioDM), reviewer of record

Verdict: ACCEPT. Arming for the queue on green. Three things in the report are the PM's to rule on rather than the implementer's, so I am ruling them here rather than leaving them in the PR body as open observations.

⭐ The ablation is the best-formed one this seat has reviewed this week

Direction predicted before running — 9 red / 8 green — and observed exactly (Tests 9 failed | 8 passed (17)). What makes it worth calling out is not the match, it is the reason the split was predictable: every en pack value is byte-identical to the literal it replaced, so en and provider-less assertions stay green by construction while zh/ar go red. That turns the 8 passes into a real instrument — they are the guard proving this was a lookup swap and not a copy change, which is precisely the claim an i18n PR most needs to establish and most easily fakes. Keeping them deliberately, and saying why, is the discipline working as intended.

Two details I want on the record because they are traps this seat has actually fallen into:

  • git checkout -- <path> restores from the INDEX, not HEAD — and the mutation had already written the index. Naming HEAD explicitly is the difference between a restore and a no-op that reports success. This is the same failure family as this seat's own instrument failure [WIP] Update documentation for project #11 (a malformed control that printed empty and was read as a 0).
  • Restore proven by state (git diff HEAD 0 lines, git status --porcelain 0 lines, on-disk hashes equal to HEAD blobs), never by an editor's exit code; mutation proven by blob hash and marker count on both files. Correct on both legs.

The NOT-MEASURED check is also right: --listFiles confirms both new suites are in the type-check program (1 hit each, control zzzNoSuchFile.tsx = 0), so the green covers the new files rather than silently excluding them. And the rebuild leg was measured unnecessary (source aliasing at vitest.config.mts:275), not assumed — then the closure was built anyway. That is the right order.

Ruling 1 — the whole-file sweep of a component with ZERO consumers: upheld

PointInTimeRestore is barrel-exported and rendered by nothing — not DetailView, not apps/console. The implementer swept all 17 sites anyway and flagged it as the kind of measurement that legitimately re-splits a card. Correct to flag; and the call to sweep stands, for the reason given: it is exported public API, and a third #7142-shaped half-done component is a worse outcome than translating a few strings nobody currently renders.

⚠️ But the measurement is more consequential than a scope note, and it does not belong buried in this PR. A zero-consumer exported component is exactly the ADR-0049 enforce-or-remove question — and this PR has just executed the enforce half without anyone asking whether remove was the right answer. That is not a reason to hold this PR (the work is done, correct, and cleanly revertible as one file plus its keys), but it is a reason to file the question. Filing it now, and linking it back here.

Ruling 2 — the EN DASH → EM DASH copy change: accepted, and reporting it was the right move

PointInTimeRestore's snapshot null placeholder was an inline EN DASH (U+2013) and now resolves detail.emptyValue, an EM DASH (U+2014) across all ten packs. This is a user-visible copy change riding an i18n card, which is normally exactly what I would send back.

It stands because the alternative is worse: forking a detail.emptyValueEnDash key to preserve one glyph would put a second spelling of one concept into ten packs — the multi-spelling class this repo is actively paying down (#7021 found the record-title key in three spellings). Reusing the key converges on the glyph the rest of the package already uses. ⭐ Reporting it rather than quietly adopting it is what makes it acceptable; an unreported glyph swap inside a 15-file i18n diff is invisible to review.

Ruling 3 — premise_still_valid: false scoped to A3.2 only: correct handling

The "byte-identical copies" claim was false; A3.1, A3.3 and A3.5 confirmed; the implementable core was sound and shipped. Falsifying one PM assumption without stopping, and saying precisely which one, is the behaviour the three-zone order exists to produce. Zone 2 is there to be falsified.

Out-of-scope handling: correct on both

On the report's own accounting

mcp_calls reported as 9 with the note that the GitHub copy says 8 because the verifying read-back was necessarily the 9th. Trivial in itself; not trivial as a habit. A report that corrects its own count against its own artifact is a report whose other numbers I can spend less time re-deriving.


Nothing to change. Undrafting and arming auto-merge SQUASH once the four test shards, Type Check, Lint and Bundle Analysis report green — currently in_progress, nothing red.


Generated by Claude Code

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RecordComments and PointInTimeRestore carry the same untranslated formatTimestampActivityTimeline just had — keys already exist in all ten packs

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

fix(plugin-detail,i18n): RecordComments and PointInTimeRestore resolve their copy from the packs - #7172

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7163-timestamp-duplication-i18n
Sep 1, 2026
Merged

fix(plugin-detail,i18n): RecordComments and PointInTimeRestore resolve their copy from the packs#7172
os-warren merged 1 commit into
mainfrom
claude/issue-7163-timestamp-duplication-i18n

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7163

Measured on origin/main head d8ec8d6d4 (PR #7162's landing sha — the card's line numbers predate it, so everything below was re-derived, not inherited). Final commit 42f6c1480; every gate result quoted here was run on that tree with git status --porcelain empty.

The card's "byte-identical" premise is FALSE — verified mechanically

The title and both PM comments say the two formatTimestamp copies are byte-identical. They are not:

md5 b36edffbf4d6719482f17e1743ac2a4a RecordComments::formatTimestamp (18 lines, 597 bytes)
md5 846a6b780ea858ee9650f7a0eadf6f6c PointInTimeRestore::formatTimestamp (16 lines, 495 bytes)
diff exit=1 — 3 lines differ

RecordComments has a fourth d ago branch and falls through to toLocaleDateString(); PointInTimeRestore stops at hours and falls through to toLocaleString(). A revision list wants the time of day; a comment list does not. (The card body said as much — "the same, minus the days branch"; only the title and the dispatch generalised it.)

This decides the sharing question by measurement, per the scope ruling. Sharing is declined on two independent grounds: the two helpers have genuinely different tails, so unifying them would silently restyle one surface; and a shared helper needs a new export, which is Clause ② with the quota exhausted. Reported rather than routed around. The duplication is now three-way (ActivityTimeline, RecordComments, and this near-copy) and is filed separately as a finding.

What shipped

RecordComments.tsx — pure lookup swap, no new key, no copy change. The file was already wired to the packs (11 t('detail.…') refs); only its helper was hardcoded. It now resolves detail.justNow / minutesAgo / hoursAgo / daysAgo. Each mapping was checked for exactness, not adopted on the card's word:

literalkeyen pack valueverdict
'just now'detail.justNowjust nowexact
`${diffMins}m ago`detail.minutesAgo{{count}}m agoexact
`${diffHours}h ago`detail.hoursAgo{{count}}h agoexact
`${diffDays}d ago`detail.daysAgo{{count}}d agoexact

PointInTimeRestore.tsx — swept WHOLE, not partially wired. It used no translation hook at all (0 refs), so per the ruling it was finished in one pass rather than becoming a third half-done component: card title, empty state, field-count line, preview heading, snapshot heading, both (empty) placeholders, the snapshot dash, the restore confirmation, and all three buttons. 17 static t() call sites; zero English literals remain (each removed anchor greps to 0).

Ten new keys land in all ten packs and in DETAIL_DEFAULT_TRANSLATIONS. Three existing keys are reused rather than forked: detail.cancel, detail.activityEmptyValue (the key ActivityTimeline already uses for an absent old/new value in a field-change diff — the same concept), and detail.emptyValue.

Two shape rules observed:

One deliberate copy change, called out rather than quietly adopted: the snapshot panel's null placeholder was an EN DASH (, U+2013) written inline; it now resolves detail.emptyValue, which is an EM DASH (, U+2014) in all ten packs — the glyph the rest of the detail package already uses.

Pack verification method, with both controls

Read out of the pack objects by walking the nested path (packages/i18n/src/locales/index.ts -> builtInLocales), never a dotted-key grep — a grep for detail.justNow returns a false zero against the en pack that defines it, and the detail namespace is one of five in these packs that carry a justNow row.

  • Loader liveness: locales loaded: en,zh,ja,ko,de,fr,es,pt,ru,ar (count=10)
  • Positive controls: detail.back 10/10, detail.noActivity 10/10
  • Negative control: detail.zzzAbsentControl71630/10
  • The four existing relative-time keys: 10/10 each
  • The ten new keys after the change: 10/10 each, controls re-run in the same invocation and unchanged

Reachability finding (this splits the card, as #7149's did)

PointInTimeRestore has zero in-repo consumers. Repo-wide, excluding node_modules and dist, it appears only in its own file and twice in the package barrel (index.tsx:126 export, :154 type export). Nothing renders it — not DetailView, not apps/console. RecordComments, by contrast, is rendered by DetailView at two call sites (:1479, :1705).

So the user-visible defect today is RecordComments; PointInTimeRestore is public API a downstream consumer can mount but nothing in this repo does. It was still swept whole rather than deferred: it is exported, so it is shippable surface, and finishing it now is what stops the #7142 shape from recurring.

Nothing pinned the old literals — attributed, not counted

Repo-wide grep -F on 'just now' and 'm ago' returns hits across five packages. Attributed rather than counted: every test assertion among them belongs to packages/collaboration (a different package and namespace, useCollaborationTranslation) or to ActivityTimeline.remainingLiterals.i18n.test.tsx (#7162's suite, asserting on a different component). The two plugin-detail hits in RecordMetaFooter.tsx are comments, not literals — that file is already a correct t() call site. The PointInTimeRestore copy has exactly one hit per literal: the file itself. No test pinned either file's English.

Evidence

Ablation — direction predicted before running, mutation proven on disk, restore proven by state.

Predicted: reverting only the two .tsx files to d8ec8d6d4 (packs, defaults map and the new suites kept) turns the zh/ar assertions red while every en and provider-less assertion stays green by construction, since each en pack value is byte-identical to the literal being restored. Predicted 9 red / 8 green.

Observed, exactly:

Test Files 2 failed (2)
Tests 9 failed | 8 passed (17)
  • Mutation proven by blob hash AND marker count: A(HEAD)=49c08e56… vs A(disk)=b5ce3ad9…, B(HEAD)=0db4d714… vs B(disk)=cac43cf7…; injected marker t('detail.justNow') 0 in both, restored marker return 'just now'; 1 in both, Revision History literal back to 1.
  • Restore proven by state, not exit code: git diff HEAD produced 0 lines, git status --porcelain0 lines, and both on-disk hashes match their HEAD blobs exactly. The script carried trap restore EXIT INT TERM with absolute paths resolved from git rev-parse --show-toplevel, and the restore leg names HEAD explicitly (a bare git checkout -- path restores from the index, which the mutation had already written).
  • The 8 greens are green-by-construction and are kept deliberately: they guard that this was a lookup swap and not a copy change. Naming them: the en and provider-less legs of both suites, the two {{count}}-interpolation guards, and the two raw-key guards (a reverted file renders literals, not keys, so those cannot discriminate).

Resolution path stated, since an ablation is only valid if it is measured: the suites resolve source, not dist/. The components are same-package relative imports, and the root vitest config aliases @object-ui/i18n to packages/i18n/src (vitest.config.mts:275). No rebuild leg is required for this ablation, and the green run was measured against source too. The dependency closure was built anyway before the first run (pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-detail^...' build, exit 0).

Green runs (vitest paths root-relative per #3378, canonical invocation through the root config):

packages/plugin-detail/src/RecordComments.i18n.test.tsx
packages/plugin-detail/src/PointInTimeRestore.i18n.test.tsx
Test Files 2 passed (2) Tests 17 passed (17)
packages/plugin-detail/ packages/i18n/ (affected packages, whole)
Test Files 182 passed (182) Tests 2071 passed (2071)
all-locales-key-parity + de-quote-pairing-3876 + defaults-maps-mirror-en-pack
Test Files 3 passed (3) Tests 53 passed (53)

Gates — exit codes captured by redirect-then-capture, never read across a pipe; each verdict is the gate's own printed line:

  • check:i18n-keysexit 0"Every in-scope call-site key resolves against the en pack (2865 keys), every literal inline defaultValue matches the value the pack serves, every call site passes exactly the arguments that value has holes for…"
  • check:i18n-driftexit 0"Compared the ten locale packs at d8ec8d6 (merge-base with origin/main) with the working tree: 0 en value(s) changed (10 key(s) added, 0 removed…)"
  • check:i18n-dead-keysexit 0 (report, not a gate)
  • changeset:checkexit 0"All workspace packages are in the changeset fixed group." / "No changeset declares a major bump."
  • check-changeset-presence.mjsexit 0"15 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)"
  • check-changeset-overwrite.mjsexit 0"No pre-existing changeset was modified or deleted."
  • check:control-bytesexit 0"OK (scanned 5947 tracked text file(s); skipped 85 binary)"; plus a direct grep -naP control-char scan over all 16 changed files: 0 hits
  • type-check (plugin-detail + i18n) exit 0"Scope: 2 of 47 workspace projects" with both script names echoed, so not a zero-match silent pass
  • lint (plugin-detail + i18n, plain per-package eslint ., no --no-inline-config) exit 0 — warnings only, all pre-existing; the three on PointInTimeRestore.tsx are the RevisionEntryany fields at lines 25/27, which this diff does not touch

Not NOT-MEASURED:type-check runs tsc -p tsconfig.test.json, and --listFiles confirms both new suites are actually in that program (1 hit each; control zzzNoSuchFile.tsx = 0) — so the green genuinely covers the new test files rather than excluding them.

The de quote-pairing census did not move. The ⚠ carried forward from #7149 (a pack value with a quoted span shifts de-quote-pairing-3876.test.ts, 55 -> 58) does not apply: the German values added here contain no quote characters by construction, the census stays at 58, and that file is untouched — verified by running it green rather than by assuming.

Scope

Lane held: packages/plugin-detail/src/{RecordComments,PointInTimeRestore}.tsx, their new suites, and the ten locale packs. ActivityTimeline.tsx was read-only reference and is unmodified. No fenced package was entered. content/docs/releases/ untouched.


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3156.6 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-DagPPWNY.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.32KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.14KB49.57KB
fields (index.js)244.25KB61.73KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)66.84KB18.86KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)250.65KB63.91KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)205.53KB55.50KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.21KB27.60KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

PM review — domain:ui seat (session session_012wwHa4aaFybxXrfmfHioDM), reviewer of record

Verdict: ACCEPT. Arming for the queue on green. Three things in the report are the PM's to rule on rather than the implementer's, so I am ruling them here rather than leaving them in the PR body as open observations.

⭐ The ablation is the best-formed one this seat has reviewed this week

Direction predicted before running — 9 red / 8 green — and observed exactly (Tests 9 failed | 8 passed (17)). What makes it worth calling out is not the match, it is the reason the split was predictable: every en pack value is byte-identical to the literal it replaced, so en and provider-less assertions stay green by construction while zh/ar go red. That turns the 8 passes into a real instrument — they are the guard proving this was a lookup swap and not a copy change, which is precisely the claim an i18n PR most needs to establish and most easily fakes. Keeping them deliberately, and saying why, is the discipline working as intended.

Two details I want on the record because they are traps this seat has actually fallen into:

  • git checkout -- <path> restores from the INDEX, not HEAD — and the mutation had already written the index. Naming HEAD explicitly is the difference between a restore and a no-op that reports success. This is the same failure family as this seat's own instrument failure [WIP] Update documentation for project #11 (a malformed control that printed empty and was read as a 0).
  • Restore proven by state (git diff HEAD 0 lines, git status --porcelain 0 lines, on-disk hashes equal to HEAD blobs), never by an editor's exit code; mutation proven by blob hash and marker count on both files. Correct on both legs.

The NOT-MEASURED check is also right: --listFiles confirms both new suites are in the type-check program (1 hit each, control zzzNoSuchFile.tsx = 0), so the green covers the new files rather than silently excluding them. And the rebuild leg was measured unnecessary (source aliasing at vitest.config.mts:275), not assumed — then the closure was built anyway. That is the right order.

Ruling 1 — the whole-file sweep of a component with ZERO consumers: upheld

PointInTimeRestore is barrel-exported and rendered by nothing — not DetailView, not apps/console. The implementer swept all 17 sites anyway and flagged it as the kind of measurement that legitimately re-splits a card. Correct to flag; and the call to sweep stands, for the reason given: it is exported public API, and a third #7142-shaped half-done component is a worse outcome than translating a few strings nobody currently renders.

⚠️ But the measurement is more consequential than a scope note, and it does not belong buried in this PR. A zero-consumer exported component is exactly the ADR-0049 enforce-or-remove question — and this PR has just executed the enforce half without anyone asking whether remove was the right answer. That is not a reason to hold this PR (the work is done, correct, and cleanly revertible as one file plus its keys), but it is a reason to file the question. Filing it now, and linking it back here.

Ruling 2 — the EN DASH → EM DASH copy change: accepted, and reporting it was the right move

PointInTimeRestore's snapshot null placeholder was an inline EN DASH (U+2013) and now resolves detail.emptyValue, an EM DASH (U+2014) across all ten packs. This is a user-visible copy change riding an i18n card, which is normally exactly what I would send back.

It stands because the alternative is worse: forking a detail.emptyValueEnDash key to preserve one glyph would put a second spelling of one concept into ten packs — the multi-spelling class this repo is actively paying down (#7021 found the record-title key in three spellings). Reusing the key converges on the glyph the rest of the package already uses. ⭐ Reporting it rather than quietly adopting it is what makes it acceptable; an unreported glyph swap inside a 15-file i18n diff is invisible to review.

Ruling 3 — premise_still_valid: false scoped to A3.2 only: correct handling

The "byte-identical copies" claim was false; A3.1, A3.3 and A3.5 confirmed; the implementable core was sound and shipped. Falsifying one PM assumption without stopping, and saying precisely which one, is the behaviour the three-zone order exists to produce. Zone 2 is there to be falsified.

Out-of-scope handling: correct on both

On the report's own accounting

mcp_calls reported as 9 with the note that the GitHub copy says 8 because the verifying read-back was necessarily the 9th. Trivial in itself; not trivial as a habit. A report that corrects its own count against its own artifact is a report whose other numbers I can spend less time re-deriving.


Nothing to change. Undrafting and arming auto-merge SQUASH once the four test shards, Type Check, Lint and Bundle Analysis report green — currently in_progress, nothing red.


Generated by Claude Code

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RecordComments and PointInTimeRestore carry the same untranslated formatTimestampActivityTimeline just had — keys already exist in all ten packs

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

fix(plugin-detail,i18n): RecordComments and PointInTimeRestore resolve their copy from the packs - #7172

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7163-timestamp-duplication-i18n
Sep 1, 2026
Merged

fix(plugin-detail,i18n): RecordComments and PointInTimeRestore resolve their copy from the packs#7172
os-warren merged 1 commit into
mainfrom
claude/issue-7163-timestamp-duplication-i18n

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7163

Measured on origin/main head d8ec8d6d4 (PR #7162's landing sha — the card's line numbers predate it, so everything below was re-derived, not inherited). Final commit 42f6c1480; every gate result quoted here was run on that tree with git status --porcelain empty.

The card's "byte-identical" premise is FALSE — verified mechanically

The title and both PM comments say the two formatTimestamp copies are byte-identical. They are not:

md5 b36edffbf4d6719482f17e1743ac2a4a RecordComments::formatTimestamp (18 lines, 597 bytes)
md5 846a6b780ea858ee9650f7a0eadf6f6c PointInTimeRestore::formatTimestamp (16 lines, 495 bytes)
diff exit=1 — 3 lines differ

RecordComments has a fourth d ago branch and falls through to toLocaleDateString(); PointInTimeRestore stops at hours and falls through to toLocaleString(). A revision list wants the time of day; a comment list does not. (The card body said as much — "the same, minus the days branch"; only the title and the dispatch generalised it.)

This decides the sharing question by measurement, per the scope ruling. Sharing is declined on two independent grounds: the two helpers have genuinely different tails, so unifying them would silently restyle one surface; and a shared helper needs a new export, which is Clause ② with the quota exhausted. Reported rather than routed around. The duplication is now three-way (ActivityTimeline, RecordComments, and this near-copy) and is filed separately as a finding.

What shipped

RecordComments.tsx — pure lookup swap, no new key, no copy change. The file was already wired to the packs (11 t('detail.…') refs); only its helper was hardcoded. It now resolves detail.justNow / minutesAgo / hoursAgo / daysAgo. Each mapping was checked for exactness, not adopted on the card's word:

literalkeyen pack valueverdict
'just now'detail.justNowjust nowexact
`${diffMins}m ago`detail.minutesAgo{{count}}m agoexact
`${diffHours}h ago`detail.hoursAgo{{count}}h agoexact
`${diffDays}d ago`detail.daysAgo{{count}}d agoexact

PointInTimeRestore.tsx — swept WHOLE, not partially wired. It used no translation hook at all (0 refs), so per the ruling it was finished in one pass rather than becoming a third half-done component: card title, empty state, field-count line, preview heading, snapshot heading, both (empty) placeholders, the snapshot dash, the restore confirmation, and all three buttons. 17 static t() call sites; zero English literals remain (each removed anchor greps to 0).

Ten new keys land in all ten packs and in DETAIL_DEFAULT_TRANSLATIONS. Three existing keys are reused rather than forked: detail.cancel, detail.activityEmptyValue (the key ActivityTimeline already uses for an absent old/new value in a field-change diff — the same concept), and detail.emptyValue.

Two shape rules observed:

One deliberate copy change, called out rather than quietly adopted: the snapshot panel's null placeholder was an EN DASH (, U+2013) written inline; it now resolves detail.emptyValue, which is an EM DASH (, U+2014) in all ten packs — the glyph the rest of the detail package already uses.

Pack verification method, with both controls

Read out of the pack objects by walking the nested path (packages/i18n/src/locales/index.ts -> builtInLocales), never a dotted-key grep — a grep for detail.justNow returns a false zero against the en pack that defines it, and the detail namespace is one of five in these packs that carry a justNow row.

  • Loader liveness: locales loaded: en,zh,ja,ko,de,fr,es,pt,ru,ar (count=10)
  • Positive controls: detail.back 10/10, detail.noActivity 10/10
  • Negative control: detail.zzzAbsentControl71630/10
  • The four existing relative-time keys: 10/10 each
  • The ten new keys after the change: 10/10 each, controls re-run in the same invocation and unchanged

Reachability finding (this splits the card, as #7149's did)

PointInTimeRestore has zero in-repo consumers. Repo-wide, excluding node_modules and dist, it appears only in its own file and twice in the package barrel (index.tsx:126 export, :154 type export). Nothing renders it — not DetailView, not apps/console. RecordComments, by contrast, is rendered by DetailView at two call sites (:1479, :1705).

So the user-visible defect today is RecordComments; PointInTimeRestore is public API a downstream consumer can mount but nothing in this repo does. It was still swept whole rather than deferred: it is exported, so it is shippable surface, and finishing it now is what stops the #7142 shape from recurring.

Nothing pinned the old literals — attributed, not counted

Repo-wide grep -F on 'just now' and 'm ago' returns hits across five packages. Attributed rather than counted: every test assertion among them belongs to packages/collaboration (a different package and namespace, useCollaborationTranslation) or to ActivityTimeline.remainingLiterals.i18n.test.tsx (#7162's suite, asserting on a different component). The two plugin-detail hits in RecordMetaFooter.tsx are comments, not literals — that file is already a correct t() call site. The PointInTimeRestore copy has exactly one hit per literal: the file itself. No test pinned either file's English.

Evidence

Ablation — direction predicted before running, mutation proven on disk, restore proven by state.

Predicted: reverting only the two .tsx files to d8ec8d6d4 (packs, defaults map and the new suites kept) turns the zh/ar assertions red while every en and provider-less assertion stays green by construction, since each en pack value is byte-identical to the literal being restored. Predicted 9 red / 8 green.

Observed, exactly:

Test Files 2 failed (2)
Tests 9 failed | 8 passed (17)
  • Mutation proven by blob hash AND marker count: A(HEAD)=49c08e56… vs A(disk)=b5ce3ad9…, B(HEAD)=0db4d714… vs B(disk)=cac43cf7…; injected marker t('detail.justNow') 0 in both, restored marker return 'just now'; 1 in both, Revision History literal back to 1.
  • Restore proven by state, not exit code: git diff HEAD produced 0 lines, git status --porcelain0 lines, and both on-disk hashes match their HEAD blobs exactly. The script carried trap restore EXIT INT TERM with absolute paths resolved from git rev-parse --show-toplevel, and the restore leg names HEAD explicitly (a bare git checkout -- path restores from the index, which the mutation had already written).
  • The 8 greens are green-by-construction and are kept deliberately: they guard that this was a lookup swap and not a copy change. Naming them: the en and provider-less legs of both suites, the two {{count}}-interpolation guards, and the two raw-key guards (a reverted file renders literals, not keys, so those cannot discriminate).

Resolution path stated, since an ablation is only valid if it is measured: the suites resolve source, not dist/. The components are same-package relative imports, and the root vitest config aliases @object-ui/i18n to packages/i18n/src (vitest.config.mts:275). No rebuild leg is required for this ablation, and the green run was measured against source too. The dependency closure was built anyway before the first run (pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-detail^...' build, exit 0).

Green runs (vitest paths root-relative per #3378, canonical invocation through the root config):

packages/plugin-detail/src/RecordComments.i18n.test.tsx
packages/plugin-detail/src/PointInTimeRestore.i18n.test.tsx
Test Files 2 passed (2) Tests 17 passed (17)
packages/plugin-detail/ packages/i18n/ (affected packages, whole)
Test Files 182 passed (182) Tests 2071 passed (2071)
all-locales-key-parity + de-quote-pairing-3876 + defaults-maps-mirror-en-pack
Test Files 3 passed (3) Tests 53 passed (53)

Gates — exit codes captured by redirect-then-capture, never read across a pipe; each verdict is the gate's own printed line:

  • check:i18n-keysexit 0"Every in-scope call-site key resolves against the en pack (2865 keys), every literal inline defaultValue matches the value the pack serves, every call site passes exactly the arguments that value has holes for…"
  • check:i18n-driftexit 0"Compared the ten locale packs at d8ec8d6 (merge-base with origin/main) with the working tree: 0 en value(s) changed (10 key(s) added, 0 removed…)"
  • check:i18n-dead-keysexit 0 (report, not a gate)
  • changeset:checkexit 0"All workspace packages are in the changeset fixed group." / "No changeset declares a major bump."
  • check-changeset-presence.mjsexit 0"15 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)"
  • check-changeset-overwrite.mjsexit 0"No pre-existing changeset was modified or deleted."
  • check:control-bytesexit 0"OK (scanned 5947 tracked text file(s); skipped 85 binary)"; plus a direct grep -naP control-char scan over all 16 changed files: 0 hits
  • type-check (plugin-detail + i18n) exit 0"Scope: 2 of 47 workspace projects" with both script names echoed, so not a zero-match silent pass
  • lint (plugin-detail + i18n, plain per-package eslint ., no --no-inline-config) exit 0 — warnings only, all pre-existing; the three on PointInTimeRestore.tsx are the RevisionEntryany fields at lines 25/27, which this diff does not touch

Not NOT-MEASURED:type-check runs tsc -p tsconfig.test.json, and --listFiles confirms both new suites are actually in that program (1 hit each; control zzzNoSuchFile.tsx = 0) — so the green genuinely covers the new test files rather than excluding them.

The de quote-pairing census did not move. The ⚠ carried forward from #7149 (a pack value with a quoted span shifts de-quote-pairing-3876.test.ts, 55 -> 58) does not apply: the German values added here contain no quote characters by construction, the census stays at 58, and that file is untouched — verified by running it green rather than by assuming.

Scope

Lane held: packages/plugin-detail/src/{RecordComments,PointInTimeRestore}.tsx, their new suites, and the ten locale packs. ActivityTimeline.tsx was read-only reference and is unmodified. No fenced package was entered. content/docs/releases/ untouched.


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3156.6 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-DagPPWNY.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.32KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.14KB49.57KB
fields (index.js)244.25KB61.73KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)66.84KB18.86KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)250.65KB63.91KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)205.53KB55.50KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.21KB27.60KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

PM review — domain:ui seat (session session_012wwHa4aaFybxXrfmfHioDM), reviewer of record

Verdict: ACCEPT. Arming for the queue on green. Three things in the report are the PM's to rule on rather than the implementer's, so I am ruling them here rather than leaving them in the PR body as open observations.

⭐ The ablation is the best-formed one this seat has reviewed this week

Direction predicted before running — 9 red / 8 green — and observed exactly (Tests 9 failed | 8 passed (17)). What makes it worth calling out is not the match, it is the reason the split was predictable: every en pack value is byte-identical to the literal it replaced, so en and provider-less assertions stay green by construction while zh/ar go red. That turns the 8 passes into a real instrument — they are the guard proving this was a lookup swap and not a copy change, which is precisely the claim an i18n PR most needs to establish and most easily fakes. Keeping them deliberately, and saying why, is the discipline working as intended.

Two details I want on the record because they are traps this seat has actually fallen into:

  • git checkout -- <path> restores from the INDEX, not HEAD — and the mutation had already written the index. Naming HEAD explicitly is the difference between a restore and a no-op that reports success. This is the same failure family as this seat's own instrument failure [WIP] Update documentation for project #11 (a malformed control that printed empty and was read as a 0).
  • Restore proven by state (git diff HEAD 0 lines, git status --porcelain 0 lines, on-disk hashes equal to HEAD blobs), never by an editor's exit code; mutation proven by blob hash and marker count on both files. Correct on both legs.

The NOT-MEASURED check is also right: --listFiles confirms both new suites are in the type-check program (1 hit each, control zzzNoSuchFile.tsx = 0), so the green covers the new files rather than silently excluding them. And the rebuild leg was measured unnecessary (source aliasing at vitest.config.mts:275), not assumed — then the closure was built anyway. That is the right order.

Ruling 1 — the whole-file sweep of a component with ZERO consumers: upheld

PointInTimeRestore is barrel-exported and rendered by nothing — not DetailView, not apps/console. The implementer swept all 17 sites anyway and flagged it as the kind of measurement that legitimately re-splits a card. Correct to flag; and the call to sweep stands, for the reason given: it is exported public API, and a third #7142-shaped half-done component is a worse outcome than translating a few strings nobody currently renders.

⚠️ But the measurement is more consequential than a scope note, and it does not belong buried in this PR. A zero-consumer exported component is exactly the ADR-0049 enforce-or-remove question — and this PR has just executed the enforce half without anyone asking whether remove was the right answer. That is not a reason to hold this PR (the work is done, correct, and cleanly revertible as one file plus its keys), but it is a reason to file the question. Filing it now, and linking it back here.

Ruling 2 — the EN DASH → EM DASH copy change: accepted, and reporting it was the right move

PointInTimeRestore's snapshot null placeholder was an inline EN DASH (U+2013) and now resolves detail.emptyValue, an EM DASH (U+2014) across all ten packs. This is a user-visible copy change riding an i18n card, which is normally exactly what I would send back.

It stands because the alternative is worse: forking a detail.emptyValueEnDash key to preserve one glyph would put a second spelling of one concept into ten packs — the multi-spelling class this repo is actively paying down (#7021 found the record-title key in three spellings). Reusing the key converges on the glyph the rest of the package already uses. ⭐ Reporting it rather than quietly adopting it is what makes it acceptable; an unreported glyph swap inside a 15-file i18n diff is invisible to review.

Ruling 3 — premise_still_valid: false scoped to A3.2 only: correct handling

The "byte-identical copies" claim was false; A3.1, A3.3 and A3.5 confirmed; the implementable core was sound and shipped. Falsifying one PM assumption without stopping, and saying precisely which one, is the behaviour the three-zone order exists to produce. Zone 2 is there to be falsified.

Out-of-scope handling: correct on both

On the report's own accounting

mcp_calls reported as 9 with the note that the GitHub copy says 8 because the verifying read-back was necessarily the 9th. Trivial in itself; not trivial as a habit. A report that corrects its own count against its own artifact is a report whose other numbers I can spend less time re-deriving.


Nothing to change. Undrafting and arming auto-merge SQUASH once the four test shards, Type Check, Lint and Bundle Analysis report green — currently in_progress, nothing red.


Generated by Claude Code

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RecordComments and PointInTimeRestore carry the same untranslated formatTimestampActivityTimeline just had — keys already exist in all ten packs

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

fix(plugin-detail,i18n): RecordComments and PointInTimeRestore resolve their copy from the packs - #7172

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7163-timestamp-duplication-i18n
Sep 1, 2026
Merged

fix(plugin-detail,i18n): RecordComments and PointInTimeRestore resolve their copy from the packs#7172
os-warren merged 1 commit into
mainfrom
claude/issue-7163-timestamp-duplication-i18n

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7163

Measured on origin/main head d8ec8d6d4 (PR #7162's landing sha — the card's line numbers predate it, so everything below was re-derived, not inherited). Final commit 42f6c1480; every gate result quoted here was run on that tree with git status --porcelain empty.

The card's "byte-identical" premise is FALSE — verified mechanically

The title and both PM comments say the two formatTimestamp copies are byte-identical. They are not:

md5 b36edffbf4d6719482f17e1743ac2a4a RecordComments::formatTimestamp (18 lines, 597 bytes)
md5 846a6b780ea858ee9650f7a0eadf6f6c PointInTimeRestore::formatTimestamp (16 lines, 495 bytes)
diff exit=1 — 3 lines differ

RecordComments has a fourth d ago branch and falls through to toLocaleDateString(); PointInTimeRestore stops at hours and falls through to toLocaleString(). A revision list wants the time of day; a comment list does not. (The card body said as much — "the same, minus the days branch"; only the title and the dispatch generalised it.)

This decides the sharing question by measurement, per the scope ruling. Sharing is declined on two independent grounds: the two helpers have genuinely different tails, so unifying them would silently restyle one surface; and a shared helper needs a new export, which is Clause ② with the quota exhausted. Reported rather than routed around. The duplication is now three-way (ActivityTimeline, RecordComments, and this near-copy) and is filed separately as a finding.

What shipped

RecordComments.tsx — pure lookup swap, no new key, no copy change. The file was already wired to the packs (11 t('detail.…') refs); only its helper was hardcoded. It now resolves detail.justNow / minutesAgo / hoursAgo / daysAgo. Each mapping was checked for exactness, not adopted on the card's word:

literalkeyen pack valueverdict
'just now'detail.justNowjust nowexact
`${diffMins}m ago`detail.minutesAgo{{count}}m agoexact
`${diffHours}h ago`detail.hoursAgo{{count}}h agoexact
`${diffDays}d ago`detail.daysAgo{{count}}d agoexact

PointInTimeRestore.tsx — swept WHOLE, not partially wired. It used no translation hook at all (0 refs), so per the ruling it was finished in one pass rather than becoming a third half-done component: card title, empty state, field-count line, preview heading, snapshot heading, both (empty) placeholders, the snapshot dash, the restore confirmation, and all three buttons. 17 static t() call sites; zero English literals remain (each removed anchor greps to 0).

Ten new keys land in all ten packs and in DETAIL_DEFAULT_TRANSLATIONS. Three existing keys are reused rather than forked: detail.cancel, detail.activityEmptyValue (the key ActivityTimeline already uses for an absent old/new value in a field-change diff — the same concept), and detail.emptyValue.

Two shape rules observed:

One deliberate copy change, called out rather than quietly adopted: the snapshot panel's null placeholder was an EN DASH (, U+2013) written inline; it now resolves detail.emptyValue, which is an EM DASH (, U+2014) in all ten packs — the glyph the rest of the detail package already uses.

Pack verification method, with both controls

Read out of the pack objects by walking the nested path (packages/i18n/src/locales/index.ts -> builtInLocales), never a dotted-key grep — a grep for detail.justNow returns a false zero against the en pack that defines it, and the detail namespace is one of five in these packs that carry a justNow row.

  • Loader liveness: locales loaded: en,zh,ja,ko,de,fr,es,pt,ru,ar (count=10)
  • Positive controls: detail.back 10/10, detail.noActivity 10/10
  • Negative control: detail.zzzAbsentControl71630/10
  • The four existing relative-time keys: 10/10 each
  • The ten new keys after the change: 10/10 each, controls re-run in the same invocation and unchanged

Reachability finding (this splits the card, as #7149's did)

PointInTimeRestore has zero in-repo consumers. Repo-wide, excluding node_modules and dist, it appears only in its own file and twice in the package barrel (index.tsx:126 export, :154 type export). Nothing renders it — not DetailView, not apps/console. RecordComments, by contrast, is rendered by DetailView at two call sites (:1479, :1705).

So the user-visible defect today is RecordComments; PointInTimeRestore is public API a downstream consumer can mount but nothing in this repo does. It was still swept whole rather than deferred: it is exported, so it is shippable surface, and finishing it now is what stops the #7142 shape from recurring.

Nothing pinned the old literals — attributed, not counted

Repo-wide grep -F on 'just now' and 'm ago' returns hits across five packages. Attributed rather than counted: every test assertion among them belongs to packages/collaboration (a different package and namespace, useCollaborationTranslation) or to ActivityTimeline.remainingLiterals.i18n.test.tsx (#7162's suite, asserting on a different component). The two plugin-detail hits in RecordMetaFooter.tsx are comments, not literals — that file is already a correct t() call site. The PointInTimeRestore copy has exactly one hit per literal: the file itself. No test pinned either file's English.

Evidence

Ablation — direction predicted before running, mutation proven on disk, restore proven by state.

Predicted: reverting only the two .tsx files to d8ec8d6d4 (packs, defaults map and the new suites kept) turns the zh/ar assertions red while every en and provider-less assertion stays green by construction, since each en pack value is byte-identical to the literal being restored. Predicted 9 red / 8 green.

Observed, exactly:

Test Files 2 failed (2)
Tests 9 failed | 8 passed (17)
  • Mutation proven by blob hash AND marker count: A(HEAD)=49c08e56… vs A(disk)=b5ce3ad9…, B(HEAD)=0db4d714… vs B(disk)=cac43cf7…; injected marker t('detail.justNow') 0 in both, restored marker return 'just now'; 1 in both, Revision History literal back to 1.
  • Restore proven by state, not exit code: git diff HEAD produced 0 lines, git status --porcelain0 lines, and both on-disk hashes match their HEAD blobs exactly. The script carried trap restore EXIT INT TERM with absolute paths resolved from git rev-parse --show-toplevel, and the restore leg names HEAD explicitly (a bare git checkout -- path restores from the index, which the mutation had already written).
  • The 8 greens are green-by-construction and are kept deliberately: they guard that this was a lookup swap and not a copy change. Naming them: the en and provider-less legs of both suites, the two {{count}}-interpolation guards, and the two raw-key guards (a reverted file renders literals, not keys, so those cannot discriminate).

Resolution path stated, since an ablation is only valid if it is measured: the suites resolve source, not dist/. The components are same-package relative imports, and the root vitest config aliases @object-ui/i18n to packages/i18n/src (vitest.config.mts:275). No rebuild leg is required for this ablation, and the green run was measured against source too. The dependency closure was built anyway before the first run (pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-detail^...' build, exit 0).

Green runs (vitest paths root-relative per #3378, canonical invocation through the root config):

packages/plugin-detail/src/RecordComments.i18n.test.tsx
packages/plugin-detail/src/PointInTimeRestore.i18n.test.tsx
Test Files 2 passed (2) Tests 17 passed (17)
packages/plugin-detail/ packages/i18n/ (affected packages, whole)
Test Files 182 passed (182) Tests 2071 passed (2071)
all-locales-key-parity + de-quote-pairing-3876 + defaults-maps-mirror-en-pack
Test Files 3 passed (3) Tests 53 passed (53)

Gates — exit codes captured by redirect-then-capture, never read across a pipe; each verdict is the gate's own printed line:

  • check:i18n-keysexit 0"Every in-scope call-site key resolves against the en pack (2865 keys), every literal inline defaultValue matches the value the pack serves, every call site passes exactly the arguments that value has holes for…"
  • check:i18n-driftexit 0"Compared the ten locale packs at d8ec8d6 (merge-base with origin/main) with the working tree: 0 en value(s) changed (10 key(s) added, 0 removed…)"
  • check:i18n-dead-keysexit 0 (report, not a gate)
  • changeset:checkexit 0"All workspace packages are in the changeset fixed group." / "No changeset declares a major bump."
  • check-changeset-presence.mjsexit 0"15 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)"
  • check-changeset-overwrite.mjsexit 0"No pre-existing changeset was modified or deleted."
  • check:control-bytesexit 0"OK (scanned 5947 tracked text file(s); skipped 85 binary)"; plus a direct grep -naP control-char scan over all 16 changed files: 0 hits
  • type-check (plugin-detail + i18n) exit 0"Scope: 2 of 47 workspace projects" with both script names echoed, so not a zero-match silent pass
  • lint (plugin-detail + i18n, plain per-package eslint ., no --no-inline-config) exit 0 — warnings only, all pre-existing; the three on PointInTimeRestore.tsx are the RevisionEntryany fields at lines 25/27, which this diff does not touch

Not NOT-MEASURED:type-check runs tsc -p tsconfig.test.json, and --listFiles confirms both new suites are actually in that program (1 hit each; control zzzNoSuchFile.tsx = 0) — so the green genuinely covers the new test files rather than excluding them.

The de quote-pairing census did not move. The ⚠ carried forward from #7149 (a pack value with a quoted span shifts de-quote-pairing-3876.test.ts, 55 -> 58) does not apply: the German values added here contain no quote characters by construction, the census stays at 58, and that file is untouched — verified by running it green rather than by assuming.

Scope

Lane held: packages/plugin-detail/src/{RecordComments,PointInTimeRestore}.tsx, their new suites, and the ten locale packs. ActivityTimeline.tsx was read-only reference and is unmodified. No fenced package was entered. content/docs/releases/ untouched.


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3156.6 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-DagPPWNY.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.32KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.14KB49.57KB
fields (index.js)244.25KB61.73KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)66.84KB18.86KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)250.65KB63.91KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)205.53KB55.50KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.21KB27.60KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

PM review — domain:ui seat (session session_012wwHa4aaFybxXrfmfHioDM), reviewer of record

Verdict: ACCEPT. Arming for the queue on green. Three things in the report are the PM's to rule on rather than the implementer's, so I am ruling them here rather than leaving them in the PR body as open observations.

⭐ The ablation is the best-formed one this seat has reviewed this week

Direction predicted before running — 9 red / 8 green — and observed exactly (Tests 9 failed | 8 passed (17)). What makes it worth calling out is not the match, it is the reason the split was predictable: every en pack value is byte-identical to the literal it replaced, so en and provider-less assertions stay green by construction while zh/ar go red. That turns the 8 passes into a real instrument — they are the guard proving this was a lookup swap and not a copy change, which is precisely the claim an i18n PR most needs to establish and most easily fakes. Keeping them deliberately, and saying why, is the discipline working as intended.

Two details I want on the record because they are traps this seat has actually fallen into:

  • git checkout -- <path> restores from the INDEX, not HEAD — and the mutation had already written the index. Naming HEAD explicitly is the difference between a restore and a no-op that reports success. This is the same failure family as this seat's own instrument failure [WIP] Update documentation for project #11 (a malformed control that printed empty and was read as a 0).
  • Restore proven by state (git diff HEAD 0 lines, git status --porcelain 0 lines, on-disk hashes equal to HEAD blobs), never by an editor's exit code; mutation proven by blob hash and marker count on both files. Correct on both legs.

The NOT-MEASURED check is also right: --listFiles confirms both new suites are in the type-check program (1 hit each, control zzzNoSuchFile.tsx = 0), so the green covers the new files rather than silently excluding them. And the rebuild leg was measured unnecessary (source aliasing at vitest.config.mts:275), not assumed — then the closure was built anyway. That is the right order.

Ruling 1 — the whole-file sweep of a component with ZERO consumers: upheld

PointInTimeRestore is barrel-exported and rendered by nothing — not DetailView, not apps/console. The implementer swept all 17 sites anyway and flagged it as the kind of measurement that legitimately re-splits a card. Correct to flag; and the call to sweep stands, for the reason given: it is exported public API, and a third #7142-shaped half-done component is a worse outcome than translating a few strings nobody currently renders.

⚠️ But the measurement is more consequential than a scope note, and it does not belong buried in this PR. A zero-consumer exported component is exactly the ADR-0049 enforce-or-remove question — and this PR has just executed the enforce half without anyone asking whether remove was the right answer. That is not a reason to hold this PR (the work is done, correct, and cleanly revertible as one file plus its keys), but it is a reason to file the question. Filing it now, and linking it back here.

Ruling 2 — the EN DASH → EM DASH copy change: accepted, and reporting it was the right move

PointInTimeRestore's snapshot null placeholder was an inline EN DASH (U+2013) and now resolves detail.emptyValue, an EM DASH (U+2014) across all ten packs. This is a user-visible copy change riding an i18n card, which is normally exactly what I would send back.

It stands because the alternative is worse: forking a detail.emptyValueEnDash key to preserve one glyph would put a second spelling of one concept into ten packs — the multi-spelling class this repo is actively paying down (#7021 found the record-title key in three spellings). Reusing the key converges on the glyph the rest of the package already uses. ⭐ Reporting it rather than quietly adopting it is what makes it acceptable; an unreported glyph swap inside a 15-file i18n diff is invisible to review.

Ruling 3 — premise_still_valid: false scoped to A3.2 only: correct handling

The "byte-identical copies" claim was false; A3.1, A3.3 and A3.5 confirmed; the implementable core was sound and shipped. Falsifying one PM assumption without stopping, and saying precisely which one, is the behaviour the three-zone order exists to produce. Zone 2 is there to be falsified.

Out-of-scope handling: correct on both

On the report's own accounting

mcp_calls reported as 9 with the note that the GitHub copy says 8 because the verifying read-back was necessarily the 9th. Trivial in itself; not trivial as a habit. A report that corrects its own count against its own artifact is a report whose other numbers I can spend less time re-deriving.


Nothing to change. Undrafting and arming auto-merge SQUASH once the four test shards, Type Check, Lint and Bundle Analysis report green — currently in_progress, nothing red.


Generated by Claude Code

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RecordComments and PointInTimeRestore carry the same untranslated formatTimestampActivityTimeline just had — keys already exist in all ten packs

1 participant

@os-warren