Skip to content

feat(app-shell): a module-internal record-preview card for any (object, id) pair (objectui#12029) - #12031

Merged
objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-12029-record-preview
Oct 9, 2026
Merged

objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-12029-record-preview

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #12029
Clause-②: no

What this adds

One module-internal component, RecordPreviewCard, at packages/app-shell/src/views/record-preview/RecordPreviewCard.tsx, with its tests beside it and a patch changeset. It takes objectName, recordId and a data source, and renders a compact card for that pair. This is the A2 child of objectui#2763. Nothing re-exports it from the package entry and it registers no component type: registering it as a type page metadata can reference is B1's step, where it is first consumed.

Premise check, on main 8f8f760f

  • What the approval surfaces show for a target today. The inbox's RecordCell shows record_title || formatIdentity(record_id): a link when the readability probe allows one, plain text when it does not, and the tombstone or the cause-free label in the two dead-reference cases. RecordApprovalsPanel sits on the record page itself, so its target is the page's own record and it draws no target card.
  • An existing compact rendering from (object, id): none. Read: LookupField (its popover is a picker, its chip a label), RecordDetailView in embedded mode (the whole record page inside a drawer), plugin-dashboard's RecordDetailDrawer (draws a row it already holds and reads nothing), the global-search hit cards (server hits, no definition read). What does exist is the record page header's three pieces: getRecordDisplayName (the title), deriveHighlightFields with detectStatusField (which fields), and HeaderHighlight (how they are drawn). The card composes those three instead of adding a fourth rendering.
  • useRecordReadability cannot be reused as code from app-shell. It lives in apps/console (pages/system/recordReadability.ts) and imports useAdapter from @object-ui/app-shell, so importing it back would be a dependency cycle. The card keeps its RULE instead: an unreadable target says nothing about why, and it shows the inbox's own cause-free sentence, read by its existing key approvalsInbox.recordUnresolvable (no new pack key).
  • "Absent, the record is gone" cannot be learned from the card's own read. ObjectStackAdapter.findOne resolves null on a 404, and the platform answers a by-id read of a record outside the viewer's row set with that same 404 on purpose. The objectui#8631 ruling forbids telling "deleted" from "not visible" (an existence oracle). So in this card absent means the REFERENCE is absent (no object name or no record id): nothing is read and the shared empty-value dash renders. A deletion the platform asserts (an approval's record_deleted) stays the caller's to render, as the inbox's tombstone does today.

The four states, each pinned

State When What renders
loading the definition or the record read is in flight a role="status" card with the shared common.loading text
readable findOne returned the record the object label, the title, the highlight fields
unreadable no definition, no data source, findOne resolved null, rejected (refused or failed) or threw one cause-free card: the same DOM for every one of those causes
absent the pair names no record the shared EmptyValue dash, and zero reads

Rules reused, not restated

  • Title: getRecordDisplayName from @object-ui/core, the one ADR-0079 ladder the record page's H1 and breadcrumb read.
  • Key fields: deriveHighlightFields(def, detectStatusField(def)), the call the synthesized record page makes for its highlights strip: the object's declared highlightFields first, else the same heuristic.
  • Field values: HeaderHighlight, the record page's own strip.
  • Field-level security: the served row goes through withoutDeniedFields before the title and the fields read it, and the $expand list is gated by the loaded policy the way RecordDetailView gates its own.
  • Host edit session: the strip runs inside its own read-only InlineEditProvider. Without it, a card mounted on a record page would join that page's edit session: the host's draft would be laid over this record's values.

Data path and cost

  • Definition: useMetadataItem('object', name), the console's existing hook. A cache hit when the provider already holds the object; otherwise one by-name read that MetadataProvider de-duplicates in flight and caches. N cards of one object share one definition read, pinned against the real MetadataProvider: three cards of one object and one of another make exactly one by-name read per object.
  • Record: one findOne per card, in the record page's request shape: no params, or $expand naming only the reference fields the card shows. N cards for N ids are N record reads, not batched; a list surface that renders many cards (B2) should weigh that.
  • Refresh: useDataInvalidation re-reads in place, and the card keeps what it shows until the answer lands.
  • First load: nothing imports the module, so it moves no first-load bytes.

Tests and reverse verification

All runs from the worktree root on HEAD b3fe66cb0 (the type-check ran on the same bytes just before that commit; nothing changed after it).

  • pnpm exec vitest run packages/app-shell/src/views/record-preview/: Test Files 1 passed (1), Tests 13 passed (13). The pins: absent (four ways a pair can name nothing, zero reads), loading until the read answers, the title by the declared nameField over a record that also carries name, $expand only for a shown reference field, refresh in place on invalidation, one identical unreadable rendering for a null read, a refused read, a failed read, a throwing data source and a missing definition (the last with zero record reads), one definition read for N cards under the real MetadataProvider, a denied field neither drawn nor expanded, and a host edit session kept out.
  • Reverse verification through objectstack scripts/ablation-replace.mjs in wrap mode: each mutation's anchor had to hit once, the blob had to change, and the restore was proven by the blob hash equal to HEAD's and an empty git diff HEAD. The test imports the module by relative path, so no build sits between a mutation and its run.
    • U, drop the unreadable branch (return null where the unreadable card is returned): Tests 1 failed | 12 passed, the unreadable pin. The first attempt was a no-op: its anchor was contained in its own replacement, so the tool refused it and ran nothing. It was redone with an anchor the replacement does not contain.
    • T, break the title rule (String(row.name ?? '') in place of getRecordDisplayName): Tests 2 failed | 11 passed, the title pin and the N-cards pin.
    • I, drop the read-only InlineEditProvider around the strip: Tests 1 failed | 12 passed, the host-isolation pin.
    • Expected direction for all three: red. Observed: red.

Gates

Command Exit The gate's own verdict
pnpm --filter @object-ui/app-shell type-check (tsc --noEmit && tsc -p tsconfig.test.json) 0 no output; --listFilesOnly on the test project lists both new files
pnpm --filter @object-ui/app-shell build 0 dist completeness: 1 package(s) complete
pnpm exec vitest run on the new test file and the 21 app-shell tests that enumerate files on disk 0 Test Files 22 passed (22), Tests 174 passed (174)
pnpm exec eslint on the two touched source files 0 2 files, 0 errors, 0 warnings
pnpm check:control-bytes 0 check-control-bytes: OK
pnpm check:test-path-roots 0 check-test-path-roots: OK
pnpm check:changeset-claims 0 No pending changeset names a file this change touches.
pnpm check:pending-changeset-literals 0 No test source names a pending changeset.
node scripts/check-changeset-presence.mjs 0 2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)
check-changeset-no-major / check-changeset-fixed 0 / 0 No changeset declares a major bump. / All workspace packages are in the changeset fixed group.
pnpm check:i18n-keys 0 Every in-scope call-site key resolves against the en pack
pnpm check:i18n-drift / check:i18n-dead-keys 0 / 0 no designer-table change; the dead-key pass is a report
pnpm check:new-line-citations 0 0 new citation(s)
pnpm check:phantom-deps / check:self-import 0 / 0 Every in-scope import is declared / No package names itself
pnpm check:published-dist / check:esm-specifiers / check:unreferenced-sources 0 / 0 / 0 no tooling in build output / no extensionless relative specifier / covered packages reachable

The built entry declarations do not reach the module: dist/index.d.ts and dist/index.js have zero hits for it (control: RecordDetailView hits dist/index.d.ts), and no other emitted file imports it. No locale pack and no export changed, so the pack-parity and check:readme-exports gates are not owed.

Lint is a declared narrowing. The population is what eslint's own config applies to these files (--print-config on the component lists 118 rules, react-hooks/exhaustive-deps among them); the file count, 2, is read from --format json; and the root config enables no type-aware linting (no parserOptions.project or projectService), so this diff cannot move any untouched file's verdict. The repo-wide pnpm lint is CI's.

Acceptance notes

  • A declared highlightFields list longer than four is capped at four by deriveHighlightFields' default. The record page's own path hands the declared list on uncapped. Same fields, same order; the card shows the first four.
  • The card passes each highlight field's definition label as the label fallback. The record page's synthesized strip passes none. Whether that strip draws an empty label when no translation bundle names the field was not measured here.
  • RecordDetailView also reads { name } descriptors out of an object's highlightFields, which the spec declares as an array of strings. The card reads only through deriveHighlightFields, which keeps strings.

Not measured

  • NOT MEASURED: the full packages/app-shell/ vitest run. Started under the shared lock with --maxWorkers=2 and stopped by me after 23m27s (exit 143, no results printed) because two other agents were queued behind it. Replaced by a declared narrowing: the diff adds only new files that nothing imports, so an existing app-shell test can reach them only by enumerating files on disk. The 21 tests that do were found by grepping the package's tests for readdirSync, globSync, readdir(, walk(, listSourceFiles, import.meta.glob, fs.promises, opendir, tinyglobby and fdir, and all of them ran green above. Blind spot: a scan through a helper none of those words names. Repo-wide census tests in other packages are CI's.
  • NOT MEASURED: browser rendering. No surface mounts the card yet (B1 and B2 will), so there is no console route to drive.

Dispatched from objectui#9800 (seat domain:ui#3), session https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8.


Generated by Claude Code

…t, id) pair (objectui#12029)

RecordPreviewCard resolves the target object's definition through
useMetadataItem and reads the record through the given data source, then
draws it with the record page's own rules: the ADR-0079 title ladder
(getRecordDisplayName), the object label, and the highlight fields
(deriveHighlightFields with detectStatusField, drawn by HeaderHighlight).
Denied fields are removed before anything reads the row, and the $expand
list is gated the way the record page gates its own.

Four states: loading, readable, unreadable (one cause-free rendering for
a missing record, a refused read, a failed read and a missing definition)
and absent (the pair names no record; nothing is read). The strip runs in
its own read-only InlineEditProvider so a host page's edit session cannot
reach it.

Not exported from the package entry and not registered as a component type.

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

github-actions Bot commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 289 chunks) 3162.5 KB 3204.6 KB
Main entry chunk (gzip) 71.6 KB 350 KB
Entry file index-Dwnuf-wp.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) 19.52KB 7.19KB
app-shell (runtime-config.js) 22.59KB 7.89KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.11KB 3.87KB
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.22KB 10.61KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.40KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.72KB 2.24KB
auth (SocialSignInButtons.js) 9.70KB 3.93KB
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) 41.19KB 11.12KB
auth (createAuthenticatedFetch.js) 8.54KB 3.46KB
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.11KB 7.97KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.28KB 2.60KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.50KB 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) 587.83KB 141.44KB
core (index.js) 10.00KB 3.96KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 240.39KB 67.09KB
fields (index.js) 268.74KB 68.04KB
i18n (LocalizationContext.js) 2.92KB 1.42KB
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.52KB 2.39KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 39.35KB 12.88KB
i18n (translateFn.js) 0.20KB 0.18KB
i18n (useDisplayLocale.js) 3.52KB 1.76KB
i18n (useObjectLabel.js) 36.87KB 9.88KB
i18n (useSafeTranslation.js) 7.14KB 2.92KB
layout (index.js) 41.50KB 11.82KB
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.86KB 5.00KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.52KB 2.26KB
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.04KB 3.92KB
plugin-calendar (index.js) 53.43KB 15.54KB
plugin-charts (index.js) 84.71KB 23.25KB
plugin-chatbot (index.js) 201.52KB 47.99KB
plugin-dashboard (index.js) 144.20KB 38.95KB
plugin-designer (index.js) 233.53KB 49.80KB
plugin-detail (index.js) 248.57KB 65.47KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 177.11KB 45.88KB
plugin-gantt (index.js) 179.17KB 45.07KB
plugin-grid (index.js) 249.62KB 69.16KB
plugin-kanban (index.js) 52.77KB 16.56KB
plugin-list (index.js) 120.25KB 30.26KB
plugin-map (index.js) 27.24KB 9.03KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 44.12KB 12.29KB
plugin-timeline (index.js) 39.10KB 11.81KB
plugin-tree (index.js) 15.07KB 5.33KB
plugin-view (index.js) 91.20KB 23.04KB
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.81KB 3.58KB
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) 120.63KB 39.56KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.50KB 2.06KB
react (schema-input.js) 4.31KB 2.07KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (body-dialect.js) 4.50KB 1.99KB
sdui-parser (codegen.js) 9.45KB 3.76KB
sdui-parser (dashboard-widget-options.js) 3.07KB 1.30KB
sdui-parser (index.js) 7.30KB 3.12KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.84KB 1.90KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 23.87KB 7.83KB
types (ai.js) 4.39KB 2.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 4.12KB 1.61KB
types (authoring-nodes.js) 0.20KB 0.19KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (cloud.js) 0.20KB 0.18KB
types (complex.js) 4.44KB 2.07KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (dashboard-widget-layout.js) 2.06KB 0.96KB
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) 1.13KB 0.65KB
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) 5.78KB 2.70KB
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 (node-slots.js) 7.18KB 2.34KB
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) 4.99KB 1.96KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 19.93KB 7.25KB
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
objectstack-fleet Bot marked this pull request as ready for review October 9, 2026 07:05
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 9, 2026
Merged via the queue into main with commit 049012b Oct 9, 2026
45 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-12029-record-preview branch October 9, 2026 07:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

approvals A2 (child of objectui#2763): a reusable record-preview component that renders a compact card for any (object_name, record_id) pair

2 participants