Skip to content

docs(types): ui-action.ts no longer claims spec 17 narrowed I18nLabel to a plain string - #5617

Merged
os-sales merged 2 commits into
mainfrom
claude/issue-4611-i18nlabelschema-comment
Aug 21, 2026
Merged

os-sales merged 2 commits into
mainfrom
claude/issue-4611-i18nlabelschema-comment

Conversation

@os-sales

@os-sales os-sales commented Aug 21, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #4611

One paragraph of packages/types/src/ui-action.ts's ActionParam doc block asserted something about @objectstack/spec that was never true, sitting on the exact symbol the label vocabulary turns on. A seat reading it while implementing the label / description widening would have taken that work for a no-op.

The measurement, taken fresh against the installed GA

The card was filed against 17.0.0-rc.6; the pin has since moved to GA. Resolved from packages/types: @objectstack/spec@17.0.0, node_modules/.pnpm/@objectstack+spec@17.0.0_.../dist/ui/index.d.ts:614, quoted with the generic brackets flattened so GitHub's body sanitizer cannot eat them (the file holds real syntax):

declare const I18nLabelSchema: z.ZodUnion( readonly ( z.ZodString, z.ZodRecord( z.ZodString, z.ZodString ) ) );
type I18nLabel = z.input( typeof I18nLabelSchema );

Still a union; the comment is false against GA as it was against rc.6. Corroborated three ways:

  • The schema's own doc block (:571-613) says it in as many words: a display label in one of two authorized forms (objectstack#5728, maintainer ruling 2026-08-06), a plain default-language string or an inline locale map, closing with "Both are real; neither is deprecated by this schema."
  • Executed against dist/ui/index.mjs: plain string ACCEPTED, inline locale map ACCEPTED, { key, defaultValue } REJECTED.
  • The keys this paragraph is about carry that union: ActionParamSchema.label and options( ).label are both string | Record( string, string ) in the shipped declaration.

Live rather than theoretical, measured in this tree: 26 files under packages/ contain a pickLocalized( call site (19 of them non-test), e.g. packages/components/src/renderers/layout/containers.tsx:739 resolving schema.title.

What the replacement says, and why it should not rot the same way

It leads with what I18nLabel admits and cites the spec's own doc block, rather than restating a zod expression a spec bump can falsify. Today's spelling is still recorded — a reader needs it — but scoped as "against @objectstack/spec@17.0.0 it is ... (dist/ui/index.d.ts:614)", a reading of one named version with its file and line, and it says so: that is the distinction the replaced sentence failed to make.

The decision is unchanged, and never structurally depended on the false premise. label flows in by reference through Omit( z.input( typeof ActionParamSchema ), 'type' ), so it is whatever the spec declares. Deleting the local override was right for a reason the union's contents do not affect: a plain string is one of the admitted forms, so a local string | I18nLabel collapses to I18nLabel either way — an exact restatement claiming to be wider than it is. The old text reached the right conclusion through a false premise; the correction keeps the conclusion and replaces the reasoning.

Diff shape: comment-only, proven rather than asserted

  • Source: one hunk, @@ -315,9 +315,38 @@, 47 changed lines, every one matching ^[+-] \*( |$) — JSDoc continuation lines, all inside the block spanning 226-355 that documents export interface ActionParam.
  • Emitted, using the package's real tsc build (removeComments: false per tsconfig.base.json, so comments are emitted), dist cleared and tsbuildinfo removed between legs, restore leg guarded by an EXIT trap: 108 files both sides, exactly one differs — dist/ui-action.d.ts, 29,176 to 31,026 bytes, and its diff is likewise one hunk of 47 lines all matching ^[+-] \*( |$).
  • dist/ui-action.js is byte-identical (3,480 bytes, sha256 1b24e8f5). Not because comments were stripped — they were not — but because this block documents an interface, which tsc erases along with its leading comment. No fragment of the block appears in the emitted JS on either side. Flagging this because the dispatch expected shipped JS to move: it does not here, and the reason is structural rather than a measurement artifact.

Changeset form follows that measurement: patch for @object-ui/types, not empty frontmatter. The JSDoc is on an exported declaration, so it ships in dist/*.d.ts and in consumers' hover text — consumer-visible API documentation, which is the criterion. No behaviour changes.

No test and no ablation is possible on this change — there is no executable behaviour to pin and nothing to ablate; a mutation of comment text cannot turn any assertion red. Stating that plainly rather than manufacturing a test that would only pin prose.

Gates — exit code captured before any pipe, each gate's own verdict quoted

All run at ec10aa217 (the final commit) with a clean tree, from the repo root:

gate exit verdict
pnpm --filter @object-ui/types type-check 0 tsc --noEmit && tsc -p tsconfig.examples.json && tsc -p tsconfig.test.json (script name echoed, so not a zero-match)
pnpm --filter @object-ui/types lint 0 ✖ 255 problems (0 errors, 255 warnings)
pnpm exec vitest run packages/types/ 0 Test Files 40 passed (40) · Tests 460 passed (460)
check-control-bytes 0 check-control-bytes: OK (scanned 4666 tracked text file(s); skipped 85 binary)
check-changeset-presence 0 1 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)
check-changeset-fixed 0 All workspace packages are in the changeset fixed group.
check-changeset-no-major 0 No changeset declares a major bump.
check-type-check-coverage 0 45/46 via type-check, 0 known-broken (0 errors outstanding)
check-lint-coverage 0 46/46 packages linted, 0 with outstanding errors (0 total)
check-spec-symbol-derivation 0 1290 files scanned against 4912 spec export names
check-action-forward-parity 0 5 surfaces checked against 40 runtime-read keys from 4 consumers

vitest was run from the repo root with a path filter, never pnpm --filter ... test (objectui#3378). Heavy runs went through the container's shared verify lock.

Declared narrowing. Repo-wide pnpm lint / pnpm type-check (turbo, 46 packages), the four test shards, and the import-shape gates (check:phantom-deps, check:self-import, check:esm-specifiers, check:i18n-keys, check:i18n-drift) were left to CI. Why the narrowing cannot hide a failure: ESLint here is not type-aware — eslint.config.js declares no parserOptions.project and no projectService — so every file's lint verdict is a function of that file's own source, and the only source file this PR touches was linted green. For everything type- or behaviour-shaped, the emitted-artifact measurement above is the argument: the sole changed byte range in the whole build output is JSDoc text inside one .d.ts, so no declaration, no type, and no runtime value that any other package compiles or executes against has moved. Imports are untouched, no locale strings or t() call sites are involved.

Two findings filed rather than fixed here

  • objectui#5612 — the parity test's self-described INVERTED PIN on this very decision cannot detect a re-widening. packages/types/src/__tests__/page-nav-misc-spec-parity.test.ts:350-366 asserts const label: SpecI18nLabel = 'Priority', which type-checks under both the narrow and the wide shape. The spec has already re-widened and the pin stayed green — the event it promised to catch happened and it observed nothing. Out of this card's scope (different file), so it is filed, not touched. Its it(...) title is now the last copy of the false belief in this package.
  • objectui#5613 — ui-action.ts:27 imports I18nLabel and never uses it (residue of the removed override). Confirmed by this PR's own lint run: 27:3 warning 'I18nLabel' is defined but never used. Nothing fails on it: packages/types/tsconfig.json extends the root tsconfig.json, which sets noUnusedLocals: false (tsconfig.base.json's true never reaches the package), and the ESLint rule is a warning with no --max-warnings cap. Left in place to keep this diff prose-only.

skills/objectui/guides/i18n.md carries the same false belief as a rule — that is #5081, needs-user-decision, on a governed human-merge-only surface. Not touched here. Notes for whoever picks it up are in the report to the dispatching seat.

Generated by Claude Code

claude added 2 commits August 21, 2026 18:00
…bel is a plain string

The `ActionParam` doc block justified deleting the local `label` /
`options[].label` override with a claim about `@objectstack/spec` that was never
true: "in spec 17 `I18nLabelSchema` is `z.ZodString` -- inline per-locale
objects were dropped in favour of translation files". Measured against the
installed GA pin, `@objectstack/spec@17.0.0`, `dist/ui/index.d.ts:614`:

  declare const I18nLabelSchema: z.ZodUnion(readonly (z.ZodString,
    z.ZodRecord(z.ZodString, z.ZodString)))   // real syntax at that line

Executed against `dist/ui/index.mjs`: plain string ACCEPTED, inline locale map
ACCEPTED, `{ key, defaultValue }` REJECTED. `ActionParamSchema.label` and
`options[].label` both carry that union, so the keys this paragraph is about
admit the map form today.

The correction describes what `I18nLabel` admits and quotes the schema's own doc
block (two authorized forms, "Both are real; neither is deprecated by this
schema") rather than restating a zod expression a spec bump can falsify. Where
today's spelling is named it is scoped as a measurement against 17.0.0 with its
file and line, which is the distinction the replaced sentence failed to make.

The decision the paragraph records is unchanged and was never structurally
dependent on the false premise: `label` flows in by reference through
`Omit(z.input(typeof ActionParamSchema), 'type')`, and deleting the override was
right for a reason the union's contents do not affect -- a plain string is one
of the admitted forms, so a local `string | I18nLabel` collapses to
`I18nLabel`.

Two things found while reading, filed rather than fixed here: the parity test's
self-described "INVERTED PIN" on this decision cannot detect a re-widening (it
assigns a plain string, which type-checks under either form -- so the widening
above went through it green), objectui#5612; and this file's now-unused
`I18nLabel` type import, objectui#5613.

Fixes #4611

Co-Authored-By: Claude <noreply@anthropic.com>
`dist/ui-action.d.ts` is the release-visible surface that moves: the corrected
paragraph is JSDoc on the exported `ActionParam` interface, so it ships in the
declaration file and in consumers' hover text. Measured with the package's real
`tsc` build (`removeComments: false`, per `tsconfig.base.json`), dist cleared and
tsbuildinfo removed between runs:

  dist/ui-action.d.ts  29,176 -> 31,026 bytes, sha256 7c438361 -> 5a4513cc
  dist/ui-action.js    3,480 bytes, sha256 1b24e8f5, BYTE-IDENTICAL
  108 emitted files both sides; ui-action.d.ts is the only one that moves

The JavaScript does not move here -- not because comments are stripped (they are
not), but because the comment documents an `interface`, which tsc erases together
with its leading comment. Confirmed: no fragment of the block appears in
`dist/ui-action.js` on either side.

Co-Authored-By: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 52 chunks) 3785.7 KB 3867.2 KB
Main entry chunk (gzip) 151.5 KB 350 KB
Entry file index-avY7FpUW.js —
Status PASS —

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


📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 10.04KB 3.72KB
app-shell (runtime-config.js) 8.91KB 2.99KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 29.34KB 7.05KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 6.35KB 2.43KB
auth (index.js) 2.77KB 1.22KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.02KB 0.89KB
auth (useIsWorkspaceAdmin.js) 3.04KB 1.45KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 506.99KB 113.73KB
core (index.js) 4.51KB 1.80KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 159.80KB 44.33KB
fields (index.js) 238.85KB 60.13KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.28KB 1.75KB
i18n (index.js) 3.44KB 1.39KB
i18n (pickLocalized.js) 7.22KB 3.08KB
i18n (provider.js) 23.13KB 7.63KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 33.40KB 8.71KB
i18n (useSafeTranslation.js) 7.77KB 3.13KB
layout (index.js) 38.95KB 10.97KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.55KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useResponsiveConfig.js) 1.37KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 9.35KB 3.31KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 4.42KB 1.42KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 1.81KB 0.83KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.62KB 12.83KB
plugin-charts (index.js) 64.72KB 18.35KB
plugin-chatbot (index.js) 181.41KB 43.22KB
plugin-dashboard (index.js) 128.36KB 32.95KB
plugin-designer (index.js) 212.30KB 42.80KB
plugin-detail (index.js) 242.15KB 60.89KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 125.07KB 30.43KB
plugin-gantt (index.js) 164.10KB 39.87KB
plugin-grid (index.js) 200.79KB 54.26KB
plugin-kanban (index.js) 52.93KB 14.60KB
plugin-list (index.js) 111.70KB 27.17KB
plugin-map (index.js) 20.06KB 6.62KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 43.49KB 11.93KB
plugin-timeline (index.js) 26.68KB 7.66KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.50KB 20.68KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 43.66KB 14.77KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.33KB 0.69KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 5.41KB 2.34KB
sdui-parser (index.js) 4.77KB 2.16KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 10.76KB 3.17KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 6.92KB 2.40KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 3.08KB 1.53KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

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

Copy link
Copy Markdown
Collaborator Author

PM review — ACCEPT (card #4611)

Gates. 22 named check runs read individually for completed + success at ec10aa217: 19 success, 3 skipped — Test (coverage), the unexpanded Test (coverage shard …/4) matrix placeholder, and dependabot. All four real shards, Type Check, Lint, Build & E2E, Build Docs, Bundle Analysis, Live E2E, both doc type checks, Control Byte Scan, Skill Guide Path Check, Internal Docs Link Check and all three changeset checks green.

The observation that makes this more than a text swap

The old comment reached a correct conclusion through a false premise. The recorded decision — inherit, do not override — was right, and right for a reason the union's contents cannot affect: label and options[].label arrive by reference through Omit(z.input(typeof SpecActionParamSchema), 'type'), and a plain string is one of the two admitted forms, so a local string | I18nLabel collapses to I18nLabel either way.

Keeping the conclusion and replacing only the reasoning is the right call and the harder one. The tempting move when you find a false premise is to assume the decision resting on it is also wrong; checking whether the conclusion survives on its own is what separates a repair from a regression.

Two of my dispatch's claims were falsified, both correctly

The emit premise. I told you a comment edit here moves dist/*.js. Measured with the package's real build: dist/ui-action.js is byte-identical, 3,480 bytes, sha256 1b24e8f5 both sides — and the reason is precise. Not that comments were stripped, but that this JSDoc documents an interface, which tsc erases along with its leading comment. Exactly one of 108 emitted files differs: dist/ui-action.d.ts, 29,176 → 31,026 bytes, its diff a single hunk of 47 lines all matching comment syntax.

The lane note is amended accordingly: comments on value declarations and inside function bodies move dist/*.js; comments on type-only declarations move only the .d.ts. Your scoped version is safer than my blanket one, because it never tells a seat to skip the measurement and it names the surface that actually decides the changeset form.

The pickLocalized figure. I passed "32 files" down from a card comment without measuring it. You could not reproduce it, measured your own — 38 files mentioning, 26 with a call site, 19 non-test — and cited yours with the method. That is the correct response to a number handed down by a PM, and I should not have quoted it unmeasured.

skills/ untouched, as required

Confirmed. The relay material for #5081 is in your report and goes to the maintainer through me; that card stays needs-user-decision and that surface stays human-merge-only.

Findings

#5612 is the valuable one, and it is a phantom check with a stale label attached. A test that calls itself an "INVERTED PIN" on this very decision asserts const label: SpecI18nLabel = 'Priority' — a plain string, which type-checks under both the narrow and the wide shape. The spec has already re-widened and the pin observed nothing; it can only fire if the plain-string form is removed, which is the opposite of the event it was written to catch. And its it(...) title is now, by grep, the last copy of the false belief in the package. Filed correctly rather than folded in.

#5613 carries a second-order observation worth more than the unused import it reports: packages/types/tsconfig.json extends the root tsconfig.json, which sets noUnusedLocals: false, so tsconfig.base.json's true never reaches the package. If that holds generally, a repo-wide setting is reaching no package at all — its own card, as you note.

Both arrived correctly labelled and queued.


Generated by Claude Code

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

Projects

None yet

2 participants