Skip to content

fix(fields): ImageField names a nameless image with the translated fields.image.imageAlt, not the literal 'Image' (objectui#10637) - #10660

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-10637-image-alt-translated
Sep 25, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-10637-image-alt-translated

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #10637

Clause-②: no

What changed

ImageField read its images with readFileValues(value, 'Image'). readFileValue puts that fallback name on every value that carries no name of its own (a bare sys_file id, a data: URI), so img.name was the truthy English word Image, and each img.name || t('fields.image.imageAlt', ...) in the widget stopped at it on every locale. The translated fallback never ran.

  • packages/fields/src/widgets/ImageField.tsx: the one readFileValues call now passes '' as the fallback name, as the image cell's displayableImagesOf has since objectui#10493, plus a comment saying why. No other line of the widget changed: every reader of the view's name already falls through on an empty name. file-value.ts is not touched, per the triage ruling.
  • packages/fields/src/widgets/ImageField.imageAlt-10637.test.tsx (new): the pin.
  • .changeset/10637-image-alt-translated.md (new): patch for @object-ui/fields.

Every reader of the name in ImageField (dispatch H3, measured)

For a value with no name of its own:

Reader Base After
read-only img alt Image translated fields.image.imageAlt, indexed by position
read-only enlarge button's aria-label fields.image.enlarge naming Image fields.image.enlarge embedding the translated alt
edit-mode thumbnail alt Image translated alt, indexed
lightbox dialog's accessible name (its title) Image translated fields.image.preview
lightbox img alt Image translated alt, indexed
cropper output file name (openCropper) Image image-0.png for the first image
a just-uploaded image stored as a bare id (withRecentUploads) its upload name its upload name, unchanged

The lightbox row follows the cell exactly: the lightbox gets no name and uses its own translated fallbacks, fields.image.imageAlt for the image and fields.image.preview for the dialog title. ImageLightbox is unchanged. The dispatch's pin shape said the lightbox name would be the fields.image.imageAlt text. Measured, the dialog's accessible name comes from its title, so the pin asserts the translated preview title for the dialog and the translated alt for its image.

The cropper row is a side effect of the fix, not its purpose. image-${index}.png was already the widget's fallback in openCropper, and it was unreachable for the same reason as the translated alt. It was measured with a throwaway probe, never committed, that mocked the cropper dialog to expose its outputName: base Image, after image-0.png. A value with its own name read cover.png on both trees. The name reaches original_name on the legacy-blob submit path and the oversize and failed-upload messages. It is not pinned.

Measured

Base = f99f9cd.

Run Tree Result Exit
the pin before the fix base plus the new pin file 6 failed, 4 passed 1
the pin with the fix adb443f 10 passed 0
the pin at the final head d139941 10 passed 0

Base readings from those failures: under zh, a bare id's alt was Image where the zh pack's fields.image.imageAlt for index 1 was expected, in read-only and in edit mode. The lightbox dialog's accessible name was Image. Two bare ids read Image, Image. A data URI read Image. Under en the alt was Image where the pack gives Image 1. The four rows that passed on the base are the premise row and three controls: a value with its own name, an https URL, and a just-uploaded image stored as a bare id.

Every expected string in the pin comes from the imported en or zh pack, interpolated in the test. None is typed by hand. The premise row asserts that the two packs differ and that the zh strings do not contain Image.

Tests and gates

The union was run after the final commit, at d139941. That head merges origin/main at 785d3a7, which brought objectui#10653's file-value.ts change. Its nameFromUrl edit does not interact with the fallback name.

  • pnpm exec vitest run --maxWorkers=2 packages/fields/: 210 files passed and 1 skipped, 3409 tests passed and 7 skipped, exit 0.
  • pnpm turbo run type-check --filter @object-ui/fields --concurrency=2: 12 of 12 tasks successful, exit 0. @object-ui/fields:type-check was a cache miss and executed. The package's type-check chains tsconfig.test.json, and tsc -p tsconfig.test.json --listFilesOnly lists the new pin once (control: ImageField.maxSize.test.tsx is also listed once).
  • eslint, plain form with --no-inline-config, on the two changed source and test files: exit 0, 0 errors, 7 warnings, all in ImageField.tsx. They are the same rule set as the base blob: 4 @typescript-eslint/no-explicit-any, 1 react-hooks/exhaustive-deps, 2 react-hooks/preserve-manual-memoization. Their messages differ from the base only by the line numbers they embed, which the 8 added comment lines shift. This narrowed run is a measurement, not a skipped check, on three counts. (1) The population comes from eslint.config.js, whose **/*.{ts,tsx} block covers both files. (2) The --format json output lists exactly 2 files. (3) Invariance: the config enables no type-aware parsing (0 hits for projectService|parserOptions|project:|TypeChecked, against 9 hits for files: as a control), and no rule under eslint-rules/ imports fs. So this diff cannot move the verdict on any untouched file.
  • Exit 0 on each of: pnpm check:control-bytes, check:test-path-roots, check:vi-mock-specifiers, check:vi-mock-inherit, check:vi-mock-override-shape, check:new-line-citations (0 new citations), check:changeset-claims, check:pending-changeset-literals, check:i18n-keys, check:i18n-dead-keys (a report; a grep of its output finds no fields.image.* key, and the same grep does find engine.quickfind.empty, a key it lists), check:unreferenced-sources, node scripts/check-changeset-presence.mjs, node scripts/check-changeset-no-major.mjs and node scripts/check-changeset-overwrite.mjs. The last five are added to the dispatch's list, derived from the diff: the i18n call sites it sits beside, a new test file, and the changeset workflow's two gates.

Round 2: merged origin/main at 7baede3 (objectui#10677, which carries the change for the objectui#10645 test) into the branch with no conflicts, giving head af4162e. At that head the pin (10 passed), pnpm exec vitest run packages/fields/ (211 files passed and 1 skipped, 3419 tests passed and 7 skipped) and pnpm turbo run type-check --filter @object-ui/fields (12 of 12) each exit 0 (os-dev-report round 2, comment 5836466253).

Acceptance notes

  • FileValueView.name's doc in file-value.ts gives the image cell as the caller that asks for an empty fallback. ImageField now asks too. The doc stays true, because it names the cell as an example and not as the only caller. It is left alone, because the ruling keeps file-value.ts out of this change.
  • uploadResultView names a result File when both the result's name and the original name are empty. ImageField never reaches that: its two upload paths always pass a name (the picked file's, or the cropper's).
  • No governed surface is touched.

Dispatched by the domain:ui seat 5 PM, session https://claude.ai/code/session_01KUxVUa7e39aNjhkKi1gsoy.


Generated by Claude Code

…elds.image.imageAlt, not the literal 'Image'

ImageField read its images with readFileValues(value, 'Image'), and
readFileValue puts that fallback name on every value that carries no name
of its own (a bare sys_file id, a data: URI). So the alt, the enlarge
button's label and the lightbox all stopped at the truthy English word on
every locale, and the translated fields.image.imageAlt fallback never ran.

Follow the image cell's displayableImagesOf: pass an empty fallback name,
so a nameless image reaches the translated alt. A value with its own name
keeps it.

Claude-Session: https://claude.ai/code/session_01KUxVUa7e39aNjhkKi1gsoy
Co-authored-by: Claude <noreply@anthropic.com>
…h for @object-ui/fields (objectui#10637)

Claude-Session: https://claude.ai/code/session_01KUxVUa7e39aNjhkKi1gsoy
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 329 chunks) 3051.5 KB 3104.5 KB
Main entry chunk (gzip) 147.9 KB 350 KB
Entry file index-DEfIr3Ur.js —
Status PASS —

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


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 16.57KB 6.15KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 27.95KB 10.04KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.17KB 10.58KB
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.39KB 1.21KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.52KB 3.45KB
auth (index.js) 3.63KB 1.64KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 27.13KB 7.95KB
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) 546.99KB 130.83KB
core (index.js) 9.22KB 3.71KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 223.91KB 62.28KB
fields (index.js) 260.14KB 65.89KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 2.59KB 1.22KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.24KB 2.27KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 39.40KB 12.91KB
i18n (useDisplayLocale.js) 3.52KB 1.76KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 39.28KB 11.09KB
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.99KB 0.87KB
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) 6.62KB 2.45KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 5.52KB 2.10KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.33KB 3.07KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 16.01KB 3.93KB
plugin-calendar (index.js) 51.49KB 14.64KB
plugin-charts (index.js) 74.94KB 20.89KB
plugin-chatbot (index.js) 198.36KB 47.20KB
plugin-dashboard (index.js) 133.60KB 35.41KB
plugin-designer (index.js) 216.25KB 44.39KB
plugin-detail (index.js) 232.85KB 61.58KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 152.20KB 39.05KB
plugin-gantt (index.js) 169.71KB 41.93KB
plugin-grid (index.js) 215.37KB 58.92KB
plugin-kanban (index.js) 48.35KB 15.08KB
plugin-list (index.js) 114.58KB 28.31KB
plugin-map (index.js) 22.42KB 7.38KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.55KB 11.99KB
plugin-timeline (index.js) 30.67KB 8.95KB
plugin-tree (index.js) 10.52KB 3.69KB
plugin-view (index.js) 87.77KB 21.99KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 116.21KB 38.14KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.03KB 1.86KB
react (schema-input.js) 4.25KB 2.04KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (body-dialect.js) 4.78KB 2.09KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.78KB 2.56KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (kanban-quick-add.js) 3.89KB 1.87KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 18.27KB 6.20KB
types (ai.js) 4.11KB 2.06KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 5.00KB 2.39KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 2.52KB 1.31KB
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 (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 17.15KB 6.32KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

CI red at d139941 on the objectui#10645 test only, so this PR waits for that fix

domain:ui seat 5, session_01KUxVUa7e39aNjhkKi1gsoy, 2026-09-25T15:46Z. The at-tier contract review of this head PASSed; its record follows. One required check is red:

  • The check: Test (shard 2/8) (job 108133385997), with a single failing file, apps/console/src/pages/developer/ApiConsolePage.requestPreset-10591.test.tsx:299. It is the cold-lazy timing race objectui#10645 records, and the same case is red today on PR objectui#10635, PR objectui#10651 and PR objectui#10644.
  • Why it is not this PR's: that file imports nothing from @object-ui/fields, and this PR touches only ImageField.tsx, its pin and its changeset.
  • What happens next: objectui#10645 is now graded p1, and this seat claims its test-only fix. This PR stays a draft until that fix is on main. It then takes one origin/main merge and a fresh ACCEPT. It does not widen.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: d139941957e36e462fd316608e2c94bf74ec01e7

Read: card objectui#10637 (body, triage 5833723730, claim 5834260043; the os-dev-report was not used as evidence), origin card objectui#10493, PR objectui#10660 body and file list, check-runs at the head, the diff refs/review/main..refs/review/pr-10660 (merge-base 785d3a7 = current origin/main), AGENTS.md 版本号策略, .changeset/config.json, and the installed @objectstack/spec@17.4.0 isFileIdToken. Read-only; no GitHub writes.

① Derived judgments

Diff vs merge-base: 3 files, +248 −1. packages/fields/src/widgets/ImageField.tsx (+9 −1), packages/fields/src/widgets/ImageField.imageAlt-10637.test.tsx (new, 222 lines), .changeset/10637-image-alt-translated.md (new, 17 lines). file-value.ts and ImageLightbox.tsx are untouched, as the boundary requires.

The one source change. ImageField.tsx:92 — readFileValues(value, 'Image') becomes readFileValues(value, ''), plus the comment at :84-91. RIGHT against the triage 「Pass no fallback name into readFileValues」 and 「follow the cell renderer」: the cell's displayableImagesOf at packages/fields/src/index.tsx:2261-2262 passes exactly ''. With an empty fallback, readFileValue (file-value.ts:150-186) yields name: '' for a bare id (:158), for a data: URI (:160, nameFromUrl gives undefined since objectui#10493), and for an object with no name/original_name/URL segment (:170-174); a value with its own name keeps it (:170-172). No other line of the widget changed, and no other line needed to: every reader below already has an || fallback.

Every reader of a view's name in ImageField, and what a nameless value now shows (checked one by one, as the card-specific boundary asks):

Reader Head line Nameless value, before Nameless value, after Judgment
read-only thumbnail img alt ImageField.tsx:167 Image t('fields.image.imageAlt', { index }) asked (「take the translated fields.image.imageAlt」) — right
read-only enlarge button aria-label :163 fields.image.enlarge embedding Image fields.image.enlarge embedding the translated alt asked (the card's second landing site) — right
edit-mode thumbnail img alt :273 Image translated alt, indexed asked (the card says alt in the widget; the triage says every reader) — right
lightbox img alt :149 builds name: v.name; ImageLightbox.tsx:77 Image t('fields.image.imageAlt', { index }) 「the lightbox takes the same name」 — right
lightbox dialog title (its accessible name) :149; ImageLightbox.tsx:73 Image t('fields.image.preview') see the lightbox judgment below — right
cropper output file name :144 img.name || \image-${index}.png`` Image image-0.png for the first image disclosed side effect — see below
just-uploaded bare id (withRecentUploads, file-value.ts:259-277) :92 its upload name its upload name unchanged; the recent view is keyed by id and overrides the empty name — right

The lightbox judgment. The triage binds 「the lightbox takes the same name」. At the head the lightbox's image alt is the same translated fields.image.imageAlt string the thumbnail carries (ImageLightbox.tsx:77), so the image's name IS the same name. The dialog container is named by its own DialogTitle, which falls to fields.image.preview (:73) when the image has no name — a translated string, not the literal. That is exactly what the cell produces (the cell passes name: undefined at index.tsx:2264, and the lightbox is shared), and the cell's own pin from objectui#10493 (packages/fields/src/__tests__/cellRenderers.dataUriAlt-10493.test.tsx:146-154) asserted the very same split: the dialog text contains zh.fields.image.preview, the image alt is the translated alt. The ruling's prescribed mechanism (no fallback name in; the lightbox left alone, since the claim's file surface is ImageField.tsx only) necessarily yields this. Verdict: satisfies 「the lightbox takes the same name」; not a gap. Under zh no reader in the lightbox reads the English word.

The cropper judgment. openCropper (ImageField.tsx:144) already carried its own fallback image-${index}.png, authored for a nameless image and unreachable for the same reason the translated alt was. Removing the literal from readFileValues makes it reachable: the crop of a nameless image is now named image-0.png (a PNG name for a cropper that re-encodes to PNG) instead of the extension-less Image. That name flows to maxSizeError (:107), upload/remember/fileValueForSubmit(result, name) (:117-119) and the failure message (:130); on the reference-form submit path the name is not stored (file-value.ts:221-222), on the legacy-blob path it lands in original_name (:223-229, the field at :225). Judgment: a behaviour change the ruling's mechanism requires (「Pass no fallback name」 plus the triage's own reading that the literal leaves the widget), not a regression — the previous value was the defect's literal surfacing as a file name. It is a side effect not named by the card. It IS disclosed: PR body (the H3 table row and the paragraph 「The cropper row is a side effect of the fix…」) and the changeset (.changeset/10637-image-alt-translated.md:16-17). It is NOT pinned, and the body says so. The ruling's pin ask (「Pin under zh with a bare-id value」) is about the accessible name, so the missing cropper pin is a thinness, not a missing ask. Recorded, no FAIL.

Asked and present: no fallback name into readFileValues (:92) ✓; translated fields.image.imageAlt for a nameless value in alt and enlarge label (:163, :167, :273) ✓; lightbox takes the same name (:149, ImageLightbox.tsx:77) ✓; pin under zh with a bare-id value (test :94-107, :109-116, :118-122) ✓; pins beside the existing ImageField tests (widgets/, next to ImageField.maxSize.test.tsx and ImageField.uploadFailure.test.tsx) ✓; one .changeset/10637-…md ✓; file-value.ts untouched ✓ (the doc at file-value.ts:39-43 that names the cell as an example caller stays true).

Done but not asked: only the 8-line explanatory comment at ImageField.tsx:84-91, in-scope. Two merge commits from origin/main (5511043, d139941) carry no diff of their own against the merge-base. Nothing else.

Existing pins edited or deleted: none. The diff deletes one source line only. I grepped every test on main that renders ImageField (readonly-host-plumbing-e2e, widget-aria-invalid-registry-e2e, widget-dom-leak-e2e, ImageField.maxSize, ImageField.uploadFailure, uploadingScope, uploadingScope.hold) and ImageLightbox.test.tsx for an assertion on the literal Image as an alt or name: zero hits, so no guard on main was weakened or silently broken.

Can the new pins fail? Yes on both legs.

  • Negative leg (base tree): with readFileValues(value, 'Image') the six DEFECT rows fail by construction — the read-only alt is Image not packAlt(zh, 1) (:100); getByRole('dialog', { name: zh.fields.image.preview }) throws because the base title is Image (:112); edit-mode alt Image (:121); data-URI alt Image (:128); the pair reads ['Image','Image'] (:135-138); the en leg reads Image where the pack gives Image 1 (:158). The premise row and the three controls pass on both trees, so the honest base count is 6 fail / 4 pass, as the body reports.
  • Lit control: :162-172 a value with name: 'cover.png' keeps it in alt, enlarge label and lightbox (dialog named cover.png); :174-177 an https URL is named by its last segment; :179-221 a just-uploaded bare id keeps its upload name through withRecentUploads. The caricature 「every image is now named by the translated string」 fails all three.
  • Premise row :85-92 guards the locale legs: zh and en differ, and neither the zh alt nor the zh enlarge string contains Image (zh.ts:238 图片 {{index}}, :241 放大 {{name}}; en.ts:271 Image {{index}}, :274 Enlarge {{name}}, :275 Image preview). Expected strings are pack values interpolated in the test (:52, :55), matching the i18n {{hole}} syntax with escapeValue: false (packages/i18n/src/i18n.ts:237).
  • The bare-id rows depend on isFileIdToken('file_a'): the installed spec 17.4.0 defines it as /^[A-Za-z0-9_-]{1,64}$/, so file_a, file_b, file_new are ids and the src assertion /api/v1/storage/files/file_a (file-value.ts:106-108) holds.
  • Import @object-ui/i18n/locales is a published subpath (packages/i18n/package.json exports ./locales) with five precedent test imports in packages/fields, including the 10493 pin.

② Semver level

.changeset/10637-image-alt-translated.md:1-3 declares '@object-ui/fields': patch. @object-ui/fields is a published package in the fixed group (.changeset/config.json), so a changeset is owed and this is not the docs-only case. Against AGENTS.md 版本号策略: no major (the rule's only hard prohibition) ✓; minor is reserved for objectui's own breaking changes — this diff moves no export, prop, schema or accepted value, only the accessible-name text of a nameless image and the reachable file-name fallback, so patch is the right level ✓.

Changeset prose, sentence by sentence against the diff:

  1. Title :5 「the image field widget names an image that carries no name of its own with the translated fields.image.imageAlt, not the literal Image」 — TRUE (ImageField.tsx:92, :163, :167, :273).
  2. :7-9 「ImageField read its images with the untranslated literal 'Image' as the fallback name, so a value with no name of its own (a bare sys_file id, or a data: URI) was named Image on every locale.」 — TRUE (base :84; file-value.ts:158, :160).
  3. :9-10 「Its img alt, the read-mode enlarge button's accessible name and the lightbox it opens all read that word, and the translated fields.image.imageAlt fallback never ran.」 — TRUE (:163, :167, :273; ImageLightbox.tsx:73, :77).
  4. :12-13 「ImageField now reads its images with no fallback name, as the image cell has since objectui#10493.」 — TRUE (:92; index.tsx:2262).
  5. :13-15 「A nameless image gets the translated alt ("Image 1" in en, numbered by position) in read mode and edit mode, the enlarge button's name embeds that alt, and the lightbox gets the translated alt and the translated preview title.」 — TRUE (en.ts:271; :163, :167, :273; ImageLightbox.tsx:73, :77).
  6. :15 「A value that carries a name keeps it.」 — TRUE (file-value.ts:170-174).
  7. :16-17 「The cropper's output file name for a nameless image is now the widget's own fallback (image-0.png for the first image), where it was Image.」 — TRUE (:144).
  8. :17 「Stored values do not change.」 — TRUE as written: the change is on the read path and rewrites no stored value. One precision note, not a falsity: a NEW crop of a nameless image submitted on the legacy-blob path now stores original_name: 'image-0.png' where it stored Image (:119; file-value.ts:222-225); the PR body discloses that path explicitly, the changeset does not spell it out.

Docs/README prose added by the diff: none (no content/docs/**, no README). The code comment at ImageField.tsx:84-91 is true sentence by sentence (the lightbox mention refers to its image alt; the title's preview fallback is not mentioned, which is an omission, not an error). The test docblock :9-30 is true against the diff.

③ Boundary flags

  • PR body flags answered. (a) The lightbox deviation from the claim's pin shape: judged above — the dialog is named by its title, the image by the alt; both translated, both as the cell and the 10493 cell pin; not a gap. (b) The cropper row: a disclosed, unpinned side effect the ruling's mechanism produces; not a regression; stated in body and changeset. (c) 「file-value.ts is not touched」 — TRUE (diff stat). (d) 「No other line of the widget changed」 — TRUE (+9 comment lines, one call edited). (e) 「uploadResultView names a result File when both names are empty; ImageField never reaches that」 — TRUE: both upload paths pass file.name (:223-224) or the cropper's name (:118-119). (f) 「Every expected string in the pin comes from the imported en or zh pack… None is typed by hand」 — TRUE for every translated string (:52, :55, :112, :144); the lit-control rows compare against the test's own input names (cover.png :166-171, photo.png :176) and URLs, which are inputs echoed back, not pack strings — a precision note, not a falsity. (g) The body's Measured table and gate list are the author's runs; I did not re-run them and judge on the diff plus CI. No open questions are posed by the body.
  • CI at the head d139941 (42 check-runs). Poll 1, 15:27:59Z: 28 success, 3 skipped (Test (coverage), dependabot, the coverage-shard matrix placeholder), 11 in progress, 0 failure. Poll 2, 15:30:17Z: 29 success, 3 skipped, 10 in progress (Type Check, Spec Main Shape Gate, Test (shard 1-8/8)), 0 failure. Poll 3, 15:34:43Z (the second and last permitted re-poll): 36 success, 3 skipped, 2 in progress (Spec Main Shape Gate, Test (shard 4/8)), 1 failure — Test (shard 2/8), job 108133385997, step 8 Run tests (shard 2/8). Its only test-failure annotation is apps/console/src/pages/developer/ApiConsolePage.requestPreset-10591.test.tsx:299 (「the standalone developer/api-console route honours the preset」: findByPlaceholderText('/api/v1/...') timed out while the console layout was still on its loading splash). That file is outside the diff, imports nothing from @object-ui/fields (its imports are @object-ui/app-shell, ../../AppContent, ./ApiConsolePage), and landed on main at 13:27Z by objectui#10632 (eca2760), before this PR's merge-base — a timing-dependent test of another surface, not a reading on this diff. No annotation names ImageField or the new pin, and no shard other than 2 failed. So CI at the head is NOT converged: one unrelated red, two still running at my last poll. That is the dispatching seat's convergence work (re-run or a main fix), not a defect in this PR; it does not move the verdict, and it is recorded here so the PM does not read this review as CI-green. Green so far include Changeset Declaration, Changeset Bump Policy, Changeset Fixed Group Check, Changeset Claim Re-read, Governed Surface Queue Guard, Line Citation Gate, Lint, Build & E2E, Test (dist pins).
  • Draft? Yes — draft: true, mergeable_state: blocked, which is the dispatch contract's terminal state for a dispatched PR (AGENTS.md: a claimed, dispatched PR stops at draft for the PM seat).
  • Clause-②: no holds. No accept set moves (no schema, prop, or value shape changes; readFileValues' signature and file-value.ts are untouched), and no published export moves (packages/fields/src/index.tsx untouched; the only new file is a test). What moves is runtime text: the accessible name of a nameless image and the reachable cropper file-name fallback — a patch-level behaviour fix, which is exactly what the claim's Clause-② note says.
  • Did main move under the PR's files since its merge-base? No. Merge-base 785d3a731056db1173d54f6b560b2ad17b8d0360 equals the current origin/main tip fetched into refs/review/main, so there is nothing on main after the base; the head already merges 785d3a7 (which carried objectui#10653's file-value.ts change, and the PR's diff against it does not touch that file).
  • Boundary items. file-value.ts untouched ✓. Branch claude/issue-10637-image-alt-translated matches the PR head ref ✓. Zero GitHub writes by this review.

Implemented-by: claude/issue-10637-image-alt-translated
Reviewed-by: session_01KUxVUa7e39aNjhkKi1gsoy

VERDICT: PASS

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 329 chunks) 3055.8 KB 3104.5 KB
Main entry chunk (gzip) 147.9 KB 350 KB
Entry file index-CzVpAdro.js —
Status PASS —

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


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 16.57KB 6.15KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 27.95KB 10.04KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.17KB 10.58KB
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.39KB 1.21KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.52KB 3.45KB
auth (index.js) 3.63KB 1.64KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 27.13KB 7.95KB
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) 547.22KB 130.96KB
core (index.js) 9.52KB 3.79KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 223.91KB 62.28KB
fields (index.js) 259.18KB 65.61KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 2.59KB 1.22KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.24KB 2.27KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 39.40KB 12.91KB
i18n (useDisplayLocale.js) 3.52KB 1.76KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 39.28KB 11.09KB
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.99KB 0.87KB
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) 6.62KB 2.45KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 5.52KB 2.10KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.33KB 3.07KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 16.01KB 3.93KB
plugin-calendar (index.js) 51.49KB 14.64KB
plugin-charts (index.js) 76.80KB 21.35KB
plugin-chatbot (index.js) 198.36KB 47.20KB
plugin-dashboard (index.js) 133.60KB 35.41KB
plugin-designer (index.js) 216.25KB 44.39KB
plugin-detail (index.js) 232.85KB 61.58KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 152.55KB 39.16KB
plugin-gantt (index.js) 169.71KB 41.93KB
plugin-grid (index.js) 216.65KB 59.28KB
plugin-kanban (index.js) 48.35KB 15.08KB
plugin-list (index.js) 115.77KB 28.75KB
plugin-map (index.js) 23.01KB 7.60KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.55KB 11.99KB
plugin-timeline (index.js) 30.75KB 8.98KB
plugin-tree (index.js) 10.52KB 3.69KB
plugin-view (index.js) 87.77KB 21.99KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 116.21KB 38.14KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.03KB 1.86KB
react (schema-input.js) 4.25KB 2.04KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (body-dialect.js) 4.78KB 2.09KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.78KB 2.56KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (kanban-quick-add.js) 3.89KB 1.87KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 18.27KB 6.20KB
types (ai.js) 4.11KB 2.06KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 5.00KB 2.39KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 2.52KB 1.31KB
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 (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 17.15KB 6.32KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: af4162ebdfec1f609706d9d8b314b4ab183cc576

Re-review at the PR's new head. Read: card objectui#10637 (body, triage 5833723730, claim 5834260043; the two os-dev-report comments 5834937061 and 5836466253 were not used as evidence), PR objectui#10660 body and file list, the check-runs at the head, the prior at-tier review 5835241998 (PASS at d139941), the diff refs/review/main..refs/review/pr-10660 (merge-base 7baede3 = the current origin/main tip), main's 785d3a7..7baede3 range, AGENTS.md 版本号策略 and its dispatched-PR draft rule, .changeset/config.json, and the installed @objectstack/spec@17.4.0 isFileIdToken. The shared checkout at /home/user/objectui carries no node_modules, so the spec symbol was read from the lockfile-pinned 17.4.0 install under a sibling worktree's node_modules/.pnpm (a read of the published package bytes; no worktree file was touched). Read-only throughout; zero GitHub writes.

① Derived judgments

The diff is unchanged. git diff 7baede3 af4162e (272 lines: 3 files, +248 −1) is byte-identical (cmp) to git diff 785d3a7 d139941, the diff the prior review PASSed. Same three files, same hunks: packages/fields/src/widgets/ImageField.tsx (+9 −1), packages/fields/src/widgets/ImageField.imageAlt-10637.test.tsx (new, 222 lines), .changeset/10637-image-alt-translated.md (new, 17 lines). file-value.ts and ImageLightbox.tsx are untouched (diff stat against refs/review/main on both paths is empty). The merge commit af4162e (parents d139941 and 7baede3) has no diff of its own on any of the three PR files (git diff d139941 af4162e restricted to them is empty), and main touched none of them between the two bases (git diff 785d3a7 7baede3 restricted to them is empty). Every judgment below was re-derived from the head, not inherited.

The one source change. ImageField.tsx:92 — readFileValues(value, 'Image') becomes readFileValues(value, ''), with the explanatory comment at :84-91. RIGHT against the triage ruling 「follow the cell renderer. Pass no fallback name into readFileValues」: the cell's displayableImagesOf at packages/fields/src/index.tsx:2261-2262 passes exactly ''. With an empty fallback, readFileValue (file-value.ts:150-187) yields name: '' for a bare id (:158), for a data: URI (:160, nameFromUrl gives undefined, ?? ''), and for an object carrying no name, original_name or URL segment (:170-174); a value with its own name keeps it (:171-173). readFileValues (:190-194) only maps readFileValue over the items, so nothing else moves.

Every reader of a view's name in ImageField at the head (grep of .name in the file: eleven hits, seven of them readers of a view's name; the other four are the picked file's file.name at :223-224, :226 and the cropper target's :349):

Reader Head line Nameless value, before Nameless value, after Judgment
read-only thumbnail img alt ImageField.tsx:167 Image t('fields.image.imageAlt', { index }) asked 「take the translated fields.image.imageAlt」 — right
read-only enlarge button aria-label :163 fields.image.enlarge embedding Image fields.image.enlarge embedding the translated alt asked (the card's second landing site) — right
edit-mode thumbnail img alt :273 Image translated alt, indexed asked (the card names the widget's alt; the triage says the widget follows the cell) — right
lightbox img alt :149 builds name: v.name; ImageLightbox.tsx:77 Image t('fields.image.imageAlt', { index }) 「the lightbox takes the same name」 — right
lightbox dialog title (its accessible name) :149; ImageLightbox.tsx:73 Image t('fields.image.preview') see the lightbox judgment — right
cropper output file name :144 img.name || \image-${index}.png`` Image image-0.png for the first image disclosed side effect — see below
just-uploaded bare id via withRecentUploads :92; file-value.ts:259-278 its upload name its upload name unchanged: the recent view is keyed by id and replaces the nameless view (:264-276) — right

The lightbox judgment. The triage binds 「the lightbox takes the same name」. At the head the lightbox image's alt is the same translated fields.image.imageAlt string the thumbnail carries (ImageLightbox.tsx:77). The dialog container is named by its DialogTitle, which falls to fields.image.preview (:73) when the image has no name — a translated string, not the literal. That is exactly what the cell produces (index.tsx:2264 passes name: v.name || undefined; the lightbox component is shared and unchanged), and the cell's own pin from objectui#10493 (packages/fields/src/__tests__/cellRenderers.dataUriAlt-10493.test.tsx:146-155) asserts the very same split: image alt is the translated alt, dialog text contains zh.fields.image.preview. The ruling's mechanism (empty fallback in; the lightbox left alone, since the claim's file surface is ImageField.tsx only) necessarily yields this. Satisfies the ruling; not a gap. Under zh no reader in the lightbox reads the English word.

The cropper judgment. openCropper (ImageField.tsx:144) already carried its own fallback image-${index}.png for a nameless image, unreachable for the same reason the translated alt was. Removing the literal makes it reachable: a crop of a nameless image is now named image-0.png (a PNG name for a cropper that re-encodes to PNG, :102-104) instead of the extension-less Image. That name flows to maxSizeError (:107), upload/remember/fileValueForSubmit(result, name) (:117-119), the failure message (:130) and the dialog's outputName (:349). On the reference-form submit path the name is not stored (file-value.ts:221-222 returns the bare id); on the legacy-blob path it lands in original_name (:223-229). Judgment: a behaviour change the ruling's mechanism requires, not a regression — the previous value was the defect's literal surfacing as a file name. Not named by the card; disclosed in the PR body (the H3 row and the paragraph 「The cropper row is a side effect of the fix…」) and in the changeset (.changeset/10637-image-alt-translated.md:16-17). Not pinned, and the body says so; the ruling's pin ask is the accessible name under zh, so this is a thinness, not a missing ask.

Asked and present. No fallback name into readFileValues (:92) ✓; translated fields.image.imageAlt for a nameless value in alt and enlarge label (:163, :167, :273) ✓; lightbox takes the same name (:149; ImageLightbox.tsx:77) ✓; pin under zh with a bare-id value (test :94-107, :109-116, :118-122) ✓; pins beside the existing ImageField tests (packages/fields/src/widgets/, next to ImageField.maxSize.test.tsx and ImageField.uploadFailure.test.tsx) ✓; one .changeset/10637-…md ✓; file-value.ts untouched, per the claim's file surface ✓ (its FileValueView.name doc at file-value.ts:38-43 names the cell as an example caller and stays true).

Done but not asked. Only the 8-line comment at ImageField.tsx:84-91, in scope. Three merge commits from origin/main (5511043, d139941, af4162e) carry no diff of their own against the merge-base. Nothing else.

Existing pins edited or deleted. None; the diff deletes one source line. I re-grepped, on the NEW main 7baede3, every test that imports ImageField or ImageLightbox (ten files: ActionParamDialog.uploading, readonly-host-plumbing-e2e, widget-aria-invalid-registry-e2e, widget-dom-leak-e2e, ImageField.maxSize, ImageField.uploadFailure, ImageLightbox.test, uploadingScope, uploadingScope.hold, WizardForm.nextMidUpload) for an assertion on the literal Image as an alt or name: zero hits. A wider grep of packages/** and apps/** for alt="Image", alt='Image' or getByAltText('Image'): zero hits. No guard on main is weakened or silently broken.

Can the new pins fail? Yes on both legs, by construction.

  • Negative leg: with the base's 'Image' fallback, the six DEFECT rows fail — read-only alt Image is not packAlt(zh, 1) = 图片 1 (test :100); getByRole('dialog', { name: zh.fields.image.preview }) throws because the base title is Image (:112); edit-mode alt Image (:121); data-URI alt Image (:128); the pair reads Image, Image (:135-138); the en leg reads Image where the pack gives Image 1 (:158). The premise row and the three controls pass on both trees, so the honest base count is 6 fail / 4 pass, as the body's Measured table says.
  • Lit control: :162-172 a value with name: 'cover.png' keeps it in alt, enlarge label and dialog name; :174-177 an https URL is named by its last segment; :179-221 a just-uploaded bare id keeps its upload name through withRecentUploads. The caricature 「every image is now named by the translated string」 fails all three.
  • Premise row :85-92: zh and en differ, and neither the zh alt nor the zh enlarge string contains Image (zh.ts:238 图片 {{index}}, :241 放大 {{name}}, :242 图片预览; en.ts:271 Image {{index}}, :274 Enlarge {{name}}, :275 Image preview). Expected strings are pack values interpolated in the test (:52, :55); the runtime interpolation is verbatim because escapeValue: false (packages/i18n/src/i18n.ts:237).
  • The bare-id rows depend on isFileIdToken('file_a'): spec 17.4.0 dist/data/index.js:1609 defines it as typeof value === "string" && /^[A-Za-z0-9_-]{1,64}$/.test(value), so file_a, file_b, file_new are ids and the src assertion /api/v1/storage/files/file_a (file-value.ts:103-108) holds.
  • @object-ui/i18n/locales is a published subpath (packages/i18n/package.json exports["./locales"]).
  • CI's eight Test (shard N/8) runs are green at the head; the pin lives in packages/fields, inside their population.

② Semver level

.changeset/10637-image-alt-translated.md:1-3 declares '@object-ui/fields': patch. @object-ui/fields is a published package in the single fixed group of .changeset/config.json (40 packages listed; AGENTS.md's prose says 39 — a stale count in the doc, not this PR's), so a changeset is owed and this is not the docs-only case. Against AGENTS.md 版本号策略: no major (the rule's hard prohibition, CI-enforced by check-changeset-no-major.mjs; Changeset Bump Policy is green) ✓; minor is reserved for objectui's own breaking changes — this diff moves no export, prop, schema or accepted value, only the accessible-name text of a nameless image and the reachable file-name fallback, so patch is the right level ✓.

Changeset prose, sentence by sentence against the diff:

  1. :5 title 「the image field widget names an image that carries no name of its own with the translated fields.image.imageAlt, not the literal Image」 — TRUE (ImageField.tsx:92, :163, :167, :273).
  2. :7-9 「ImageField read its images with the untranslated literal 'Image' as the fallback name, so a value with no name of its own (a bare sys_file id, or a data: URI) was named Image on every locale.」 — TRUE (base :84; file-value.ts:158, :160).
  3. :9-10 「Its img alt, the read-mode enlarge button's accessible name and the lightbox it opens all read that word, and the translated fields.image.imageAlt fallback never ran.」 — TRUE (:163, :167, :273; ImageLightbox.tsx:73, :77).
  4. :12-13 「ImageField now reads its images with no fallback name, as the image cell has since objectui#10493.」 — TRUE (:92; index.tsx:2262).
  5. :13-15 「A nameless image gets the translated alt ("Image 1" in en, numbered by position) in read mode and edit mode, the enlarge button's name embeds that alt, and the lightbox gets the translated alt and the translated preview title.」 — TRUE (en.ts:271; :163, :167, :273; ImageLightbox.tsx:73, :77).
  6. :15 「A value that carries a name keeps it.」 — TRUE (file-value.ts:170-174).
  7. :16-17 「The cropper's output file name for a nameless image is now the widget's own fallback (image-0.png for the first image), where it was Image.」 — TRUE (:144).
  8. :17 「Stored values do not change.」 — TRUE on its natural reading: the change sits on the read path and rewrites no existing stored value. Precision note, not a falsity: a NEW crop of a nameless image submitted through the legacy-blob path (an adapter surfacing no fileId) now stores original_name: 'image-0.png' where it stored Image (:119; file-value.ts:223-229); the reference-form path stores the bare id with no name (:221-222). The PR body spells that path out (「The name reaches original_name on the legacy-blob submit path」); the changeset does not.

Docs/README prose added by the diff: none (no content/docs/**, no README). The code comment at ImageField.tsx:84-91 is true sentence by sentence (its 「lightbox」 refers to the lightbox image's alt; the title's preview fallback is not mentioned — an omission, not an error). The test docblock :9-30 is true against the diff.

③ Boundary flags

  • PR body flags answered. (a) The lightbox deviation from the claim's pin shape (dialog named by fields.image.preview, image by the alt): judged above — both translated, both as the cell and the 10493 cell pin; not a gap. (b) The cropper row: a disclosed, unpinned side effect the ruling's mechanism produces; not a regression. (c) 「file-value.ts is not touched, per the triage ruling」 — TRUE (diff stat), though the ruling itself never mentions file-value.ts; the boundary comes from the claim's file surface. (d) 「No other line of the widget changed」 — TRUE (+9 comment lines, one call edited). (e) 「uploadResultView names a result File when both names are empty; ImageField never reaches that」 — TRUE: both upload paths pass a name (:223-224 file.name; :118-119 the cropper's name), and uploadResultView (file-value.ts:246) only falls to File when both are empty. (f) 「Every expected string in the pin comes from the imported en or zh pack… None is typed by hand」 — TRUE for every translated string (:52, :55, :112, :144); the lit-control rows compare against the test's own input names (cover.png, photo.png) and URLs, inputs echoed back rather than pack strings — a precision note, not a falsity. (g) The Measured table and the gate list are the author's runs; I did not re-run them and judge on the diff plus CI. No open questions are posed by the body.
  • The body's Round 2 paragraph, sentence by sentence. 「merged origin/main at 7baede3」 — TRUE (af4162e parents d139941 and 7baede3). 「(objectui#10677, which carries the change for the objectui#10645 test)」 — TRUE: 7baede3 is test(console): wait for the lazy route to settle in the API console route-table cases (objectui#10645) (#10677), touching apps/console/src/pages/developer/ApiConsolePage.requestPreset-10591.test.tsx (+45 −1) and an empty-frontmatter changeset — the very test the prior review's one red shard failed on. 「into the branch with no conflicts」 — TRUE: main touched none of the three PR files between the bases, and the head's diff against 7baede3 is byte-identical to d139941's against 785d3a7, so the merge added nothing beyond the three files. 「giving head af4162e」 — TRUE. 「At that head the pin (10 passed), pnpm exec vitest run packages/fields/ (211 files passed and 1 skipped, 3419 tests passed and 7 skipped) and pnpm turbo run type-check --filter @object-ui/fields (12 of 12) each exit 0」 — the author's measurements, not re-run here. Consistency against the tree: main's 785d3a7..7baede3 range changed exactly three packages/fields files — PasswordField.tsx, nativeDateValue.ts and one NEW test file packages/fields/src/__tests__/PasswordField.readonlyEmpty-10639.test.tsx, whose cases are 3 loop-generated EMPTY rows, 4 loop-generated PRESENT rows, 1 RULE and 2 CONTROL = 10 tests — so 210 to 211 files and 3409 to 3419 tests reconcile exactly, and the pin has 10 it blocks. CI's Test shards and Type Check are green at this head. 「(os-dev-report round 2, comment 5836466253)」 — the comment exists on the card; it was not used as evidence.
  • CI at the head af4162e, read once at 2026-09-25T17:18:04Z: 43 check-runs, all completed — 40 success, 3 skipped (Test (coverage), the Test (coverage shard …/4) matrix placeholder, dependabot), 0 failure, 0 in progress; no re-poll needed. Green include Lint, Type Check, Build & E2E, Build Docs, Test, Test (dist pins), Test (shard 1/8) through Test (shard 8/8), Changeset Declaration, Changeset Bump Policy, Changeset Fixed Group Check, Changeset Claim Re-read, Changeset Overwrite Report, Governed Surface Queue Guard, Line Citation Gate, Control Byte Scan, Inert vi.mock Specifier Check. The Test (shard 2/8) that was red at d139941 on the objectui#10645 test is success here, consistent with 7baede3 carrying that fix. CI is converged and green.
  • Draft? Yes — draft: true; mergeable: true, mergeable_state: clean (was blocked at d139941). Per AGENTS.md's dispatched-PR rule (a PR with a Claim: on its card stops at draft; readying and enqueueing are the dispatching seat's actions), draft is the correct terminal state for this PR.
  • Clause-②: no holds. No accept set moves (no schema, prop or value shape changes; readFileValues' signature and file-value.ts are untouched) and no published export moves (packages/fields/src/index.tsx untouched; the only new files are a test and a changeset). What moves is runtime text: the accessible name of a nameless image and the reachable cropper file-name fallback — a patch-level behaviour fix, as the claim's Clause-② note says.
  • Did main move under the PR's files since its merge-base? No. The merge-base 7baede3745904d929d4afd80bcd5e2314b466072 equals the current origin/main tip fetched into refs/review/main, so nothing on main follows the base. Between the previous base 785d3a7 and the new base 7baede3, main's eleven commits (67 files, +5086 −562) touched none of the three PR files.
  • Card-specific re-review items. (1) The PR's own diff against its new merge-base is unchanged: same three files, same hunks, byte-identical patch. (2) The merge touched none of the PR's files. (3) The Round 2 paragraph is true in every verifiable sentence, and its measurements reconcile with the tree. Head af4162e is a plain merge commit with no trailers, reached by a fast-forward push d139941..af4162e — the sync route AGENTS.md prescribes (merge, not rebase; no force push), and merge commits are squashed away on landing.
  • Boundary items. file-value.ts and ImageLightbox.tsx untouched ✓. Branch claude/issue-10637-image-alt-translated is the PR head ref ✓. Zero GitHub writes by this review.

Implemented-by: claude/issue-10637-image-alt-translated
Reviewed-by: session_01KUxVUa7e39aNjhkKi1gsoy

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 25, 2026 17:28
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 25, 2026
Merged via the queue into main with commit 3cd6c5e Sep 25, 2026
45 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-10637-image-alt-translated branch September 25, 2026 17:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants