Skip to content

feat(empty-state): draw the declared empty-state link through the action runner, and let a list page's own emptyState win (#12270) - #12350

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-12270-empty-state-link
Oct 14, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-12270-empty-state-link

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #12270
Clause-②: yes. The renderers start honouring two keys the spec declares (emptyState.link, and a list page's own interfaceConfig.emptyState), so what they draw widens. A contract review is owed before the queue.

What this does

This is the renderer half of objectstack-ai/objectstack#23004 and objectstack-ai/objectstack#23005 (spec PR objectstack-ai/objectstack#23098, merge 55828c5145). It is unblocked because main c5406c2 installs @objectstack/spec 18.0.0-next.0. The change covers three doors, which share one piece:

  • The shared piece. EmptyStateLink, in @object-ui/components beside DataEmptyState, draws the spec's EmptyStateSchema.link ({ label, url }). It follows the link as a type: 'url' action through the ActionRunner, so what a click opens is classifyNavigationTarget's answer. It has no scheme list or URL check of its own. The label is a string or an inline locale map, resolved against the caller's display locale. A label that resolves to no text draws nothing.
  • Door 1: ListView's empty branch (grid and gallery). ListEmptyState takes link and draws it beside the add-record action. The spec keeps the two apart, so either, both or neither may be drawn. With no link, the DOM is unchanged.
  • Door 2: InterfaceListPage. The relay is now emptyState: cfg.emptyState ?? view.emptyState. The page's own block wins, whole, on every visualization, because the precedence is set before the visualization is chosen. The source view's block is the fallback, as its appearance and userFilters already are.
  • Door 3: ObjectGrid's authored empty state. It draws the link in DataEmptyState's action slot. The registry inputs description for object-grid.emptyState now names link.

Docs: the emptyState passages in the plugin-grid docs, README and schema reference now cover link. The plugin-list README gains an "Empty state" section; its typed example is checked below. Changeset: minor for the four packages touched.

For the spec seat (scope addition 6132635356): the renderer half has landed with this PR. The follow-up is the spec seat's to file: dropping the two [PLANNED — not yet rendered] markers, rewriting the two ledger notes, offering interfaceConfig.emptyState in the page form and deleting its reconciliation omit row.

How a declared link becomes a runner navigation (measured with the real runner)

target under the console's provider (onNavigate + onToast) no provider
in-app /... or ./... onNavigate(url, { external: false, newTab: false }), in place the runner hands back redirect; followed with location.assign
https://... onNavigate(url, { external: true, newTab: true }) window.open(url, '_blank', 'noopener,noreferrer')
mailto: location.href = url, in place; router and new tab untouched the same
refused (//host, thanks, javascript:) the runner's own refusal is toasted as an error; no navigation sink is touched the refusal is logged; no navigation

A followed link raises no success toast. The html-tier anchor and the dashboard header link raise none either.

Deviation from the suggested route: a refused value is refused at the click, not hidden at render

The suggested route said "a refused value draws no link". Here the link is drawn, and the runner refuses it at the click and reports its refusal. Reason, measured: the spec admits at parse a url that starts with a ${...} template, and its describe says such a url "is judged when it is clicked, after interpolation". EmptyStateSchema.safeParse of a link whose url is ${ctx.origin}/docs succeeds on 18.0.0-next.0. But classifyNavigationTarget answers null for that string before interpolation. Classifying at render would therefore hide a spec-valid link, and it would also be a second judgement beside the runner's. The pin judges nothing at render holds this.

Mechanism assumptions, measured

  1. Installed shapes. EmptyStateSchema.link is strict { label: I18nLabel, url: string }: url has min(1) and navigationTargetRefusal at parse. InterfacePageConfigSchema.emptyState is EmptyStateSchema.optional(). The hold's restart probe exits 0 from apps/console.
  2. At c5406c2. ListView handed ListEmptyState title, message and icon only, and ObjectGrid read the same three. InterfaceListPage relayed the source view's whole block (emptyState: view.emptyState) and read no cfg.emptyState; it did not read individual members. classifyNavigationTarget is the runner's one classifier (navigateTo, navigateOnSuccess).
  3. @object-ui/types mirrors: none needed. ListViewSchema['emptyState'] and ObjectGridSchema.emptyState both take the spec's EmptyState by reference. The README example (a ListViewSchema literal with link: { label, url }) compiles against the built @object-ui/types. The same literal with an undeclared href inside the link is refused with TS2353.

Tests (real renders, real runner)

  • packages/components/src/__tests__/empty-state-link-12270.test.tsx (14). Under a provider: in-app, https, mailto, three refused values (each compared with what a bare runner answers for the same target), a ${...} template drawn, the label map, and absent or empty label. With no provider: redirect followed, https in a new tab, refusals logged.
  • packages/plugin-list/src/__tests__/ListView.emptyStateLink-12270.test.tsx (4). Grid: link beside add-record, followed in place. Gallery: https. A refused value. A control with no link.
  • packages/plugin-grid/src/__tests__/ObjectGrid.emptyStateLink-12270.test.tsx (4). Through the registry and SchemaRenderer: in-app, https, refused, and a control with no link.
  • packages/app-shell/src/views/InterfaceListPage.emptyStatePrecedence-12270.test.tsx (6). The real page renders the real ListView. The page's own sentence and link win on grid and on gallery, whole (the view's message does not fill the gap). The page's link is followed in place. With no page block, the view's is kept, on grid and on gallery. A control proves the gallery page mounts the gallery (cards, no table).

Ablation (three legs)

Each leg went through ablation-replace.mjs: the anchor hit exactly once, the blob changed, and the restore was proven by blob == HEAD and an empty git diff HEAD. The direction was red, as predicted.

  • InterfaceListPage: cfg.emptyState ?? view.emptyState became view.emptyState. 3 failed, 3 passed: the three page-precedence rows failed; the two fallback rows and the gallery control passed.
  • ListView: link={schema.emptyState?.link} became link={undefined}. 3 failed, 1 passed: the control passed.
  • ObjectGrid: the link's action slot was emptied. 3 failed, 1 passed: the control passed.

Local verification (HEAD f20667b0e)

  • Closure build: turbo run build --filter=@object-ui/plugin-list^... --filter=@object-ui/plugin-grid^... --filter=@object-ui/app-shell^..., plus the two plugins: 28/28 tasks.
  • Suites (run from the repo root, path-filtered, through the verify lock):
    • packages/plugin-list/: 131 files, 1419 tests passed.
    • packages/plugin-grid/: 211 files, 1920 tests passed.
    • packages/components/: 382 files passed, 1 skipped; 3875 tests passed, 24 skipped.
    • The 27 app-shell files that name InterfaceListPage or emptyState: 273 tests passed.
  • type-check (tsc --noEmit && tsc -p tsconfig.test.json; each tsconfig.test.json includes src/**/*.test.tsx): components, plugin-list and plugin-grid all exit 0.
  • app-shell type-check, whole package: NOT MEASURED. Reason: the verify-lock queue had not reached it after about 30 minutes, behind another seat's long locked run (issue-11773 final2.sh). It is declared here as a narrowing, and CI runs the whole package. The narrowed check: tsc with the package's own tsconfig.test.json, whose files are the three touched app-shell files (the new test, InterfaceListPage.tsx and the census test). That program spans 629 workspace files, 79 of them under app-shell/src, and exits 0.
  • eslint on the 12 touched TS files, with the repo config. The population comes from eslint's own JSON output: 12 files, 0 errors. Warning counts are unchanged on every touched source file, base vs head. The 5 warnings in the new app-shell test are the no-explicit-any mock pattern its siblings use. The narrowing is sound: the config enables no type-aware linting (no project / projectService), and no local rule reads another file, so this diff cannot move the verdict on an untouched file.
  • Repo checks, all exit 0: check-changeset-presence, check:changeset-claims, check:pending-changeset-literals, check:new-line-citations, check:control-bytes, check:test-path-roots, check:vi-mock-specifiers, check:vi-mock-inherit, check:vi-mock-override-shape, check-doc-links, check:doc-json, check:doc-fences, check:doc-example-ids, check:doc-types, check:unreferenced-sources, check:phantom-deps, check:self-import, check:action-forward-parity, check:handler-key-reads, check:spec-symbols, check:esm-specifiers, check:i18n-keys, check-changeset-no-major, check-changeset-fixed and check-changeset-overwrite. No string or i18n key was added, so the rest of the check:i18n-* family is not owed.
  • NOT MEASURED: check:doc-snippets and check:readme-exports. Reason: PRECONDITION NOT MET, because packages outside this closure are unbuilt (plugin-gantt, plugin-map, plugin-markdown, plugin-timeline and others). CI builds them. The judged rows of readme-exports show 0 fabricated names. The new README snippet was compiled on its own (item 3 above).

Acceptance notes


Generated by Claude Code

…ion runner; a list page's own emptyState wins

One shared `EmptyStateLink` (@object-ui/components) follows the spec's
`EmptyStateSchema.link` ({ label, url }) as a `type: 'url'` action through the
ActionRunner, so `classifyNavigationTarget` is the one answer to what the
click opens. ListView's empty branch (grid and gallery) and ObjectGrid's
authored empty state draw it; InterfaceListPage prefers the page's own
`interfaceConfig.emptyState` over its source view's.

Claude-Session: https://claude.ai/code/session_01CsyMRsFJBKNEMa4oPY1b5z
Co-authored-by: Claude <noreply@anthropic.com>
…ge's own emptyState precedence

Real renders: the shared EmptyStateLink with and without an ActionProvider;
ListView (grid, gallery), object-grid through the registry, and
InterfaceListPage with the real ListView (grid, gallery, plus a gallery-mount
control). Refusals are read off a bare runner for the same target.

Claude-Session: https://claude.ai/code/session_01CsyMRsFJBKNEMa4oPY1b5z
Co-authored-by: Claude <noreply@anthropic.com>
…t page's own emptyState; changeset

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

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 72 chunks) 3050.6 KB 3119.4 KB
Main entry chunk (gzip) 73.8 KB 350 KB
Entry file index-d8o9uTlb.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) 20.69KB 7.62KB
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.26KB 8.03KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.28KB 2.60KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (commentAuthorship.js) 1.39KB 0.78KB
collaboration (index.js) 1.93KB 0.85KB
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) 588.68KB 141.75KB
core (index.js) 10.18KB 4.04KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 248.06KB 69.39KB
fields (index.js) 279.74KB 71.33KB
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) 40.26KB 11.71KB
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) 15.35KB 5.51KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.82KB 2.38KB
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) 55.05KB 16.09KB
plugin-charts (index.js) 84.72KB 23.27KB
plugin-chatbot (index.js) 201.72KB 48.07KB
plugin-dashboard (index.js) 143.63KB 38.79KB
plugin-designer (index.js) 233.77KB 50.02KB
plugin-detail (index.js) 259.74KB 68.60KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 178.42KB 46.26KB
plugin-gantt (index.js) 178.63KB 45.19KB
plugin-grid (index.js) 258.97KB 72.23KB
plugin-kanban (index.js) 53.23KB 16.71KB
plugin-list (index.js) 123.64KB 31.63KB
plugin-map (index.js) 27.24KB 9.03KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 46.66KB 13.13KB
plugin-timeline (index.js) 39.21KB 11.85KB
plugin-tree (index.js) 15.07KB 5.33KB
plugin-view (index.js) 91.99KB 23.23KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 12.07KB 3.68KB
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.81KB
sdui-parser (provenance.js) 3.84KB 1.90KB
sdui-parser (types.js) 0.29KB 0.24KB
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.26KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.48KB 3.50KB
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: opus — maintainer waiver 6160996883, in place of CONTRACT_REVIEW_TIER
Head-sha: f20667b0e36ba2dd67a6af4971d33f7c312bffed
Local-runs: none

Read: card #12270 body and all 5 comments (triage 6130578798, scope addition 6132635356, hold 6135494657, claim 6159702345, dev report 6160908856); PR #12350 body, its 17-file list, and the net diff merge-base c5406c2 → head. main has since gained one commit, c08c9332a6, which touches only plugin-detail and types files, disjoint from these 17.

Checks on this head, re-read at signing: 29 success, 3 skipped (the two coverage jobs, dependabot), 0 pending, 1 failure. The failure is Spec Main Shape Gate. Its failed step is "Report which objectui file fails against which objectstack commit", and its first error line is packages/react/src/hooks/useViewData.ts:125 TS2367. Both merge-base c5406c2 runs (115671845822, 115655524133) fail at the same step on the same first line. The head's failure-annotation set equals the merge-base's line for line (useViewData.ts 124/125, resolveDataSource.ts 59/61/62), and none of those files is in this diff. The red comes from the merge-base, not from this change.

① Derived judgments

  1. ListView empty branch (grid, gallery): RIGHT. It passes schema.emptyState?.link to ListEmptyState, which draws EmptyStateLink beside the add-record action. With no link, the action slot holds the same add-record element as before, so the DOM is unchanged. What is drawn widens by one spec-declared member. ListEmptyState is module-internal (not in the plugin-list barrel), so its new link prop is not public surface.
  2. ObjectGrid authored empty state: RIGHT. DataEmptyState's action slot carries the link only when authoredEmptyState.link is present, under the existing drawsAuthoredEmptyState gate. That gate still excludes the case where the grid's own search emptied it. The registry inputs description for object-grid.emptyState now names link. That is prose and changes no accept set.
  3. InterfaceListPage: RIGHT. The relay is now emptyState: cfg.emptyState ?? view.emptyState. cfg is page?.interfaceConfig || {} and was already in the memo deps. The page's whole block takes precedence and the source view's is the fallback. The relay is written before the visualization is chosen, which is exactly card item 2. The relay census still counts emptyState as relayed, because the value still reads view; its header edit is prose only.
  4. Navigation: RIGHT, and it meets the card's ⛔ (no second URL check, no scheme list). EmptyStateLink calls runner.execute({ type: 'url', target: link.url, toast: { showOnSuccess: false } }). At head, executeUrl → navigateTo interpolates the target, asks classifyNavigationTarget, and refuses with NAVIGATION_TARGET_REFUSAL. showOnError keeps its default, so a refusal is toasted under a provider. The component's only logic of its own is the no-provider path: it follows redirect with location.assign and logs a refusal.
  5. Public surface of @object-ui/components: RIGHT, additive. The root barrel gains three exports: EmptyStateLink, EmptyStateLinkProps and EmptyStateLinkSpec. The path is custom/index.ts → index.ts export * from './custom', which the . export reaches. @object-ui/react was already a dependency of components, with many existing importers, so there is no new package edge.
  6. @object-ui/types: RIGHT that no mirror is owed. Three members take the spec's EmptyStateSchema by reference: ObjectGridSchema.emptyState (objectql.ts L1501), its Zod twin stripImportedDefaults(SpecEmptyStateSchema) (objectql.zod.ts L781) and ListViewSchema['emptyState']. So the installed spec admits link, not this diff. One note, not a defect of this diff: that Zod twin's .describe() (L783) still reads { title, message, icon }. See ③ F1.
  7. Docs: RIGHT. schema-reference, the plugin-grid mdx and README, and the plugin-list README match the code. The new typed README snippet is compiled in CI: the Repo Gates step "Doc Snippet Type Check — Compile documentation snippets against the built types" succeeded.
  8. No packages/spec/** or governed rule text in the diff. Governed Surface Queue Guard: success.

② Semver level

Changeset: RIGHT. .changeset/12270-empty-state-link.md declares minor for @object-ui/components, @object-ui/plugin-list, @object-ui/plugin-grid and @object-ui/app-shell. Those are exactly the four packages whose src this diff changes. The change is additive: one new export, two declared keys now honoured, nothing removed or narrowed. That makes it minor under AGENTS.md §版本号策略, which allows no major and moves the fixed group together. The changeset checks all succeeded: Bump Policy, Fixed Group, Declaration, Claim Re-read and Overwrite Report.

Clause-②: yes. RIGHT, and consistent across claim 6159702345, the PR body and the changeset (yes (widening)). The rendered accept set widens by emptyState.link on three doors and interfaceConfig.emptyState on the list page, plus one public export. No key narrows. Before spec 18, the spec refused interfaceConfig.emptyState at parse (hold 6135494657, negative control on 17.7.0). So the new precedence overrides no page that validated before.

③ Boundary flags

open_questions: none.

Dev deviations:

  • D1. A refused value is drawn and then refused at the click, not hidden at render. ANSWERED, accepted. The runner interpolates ${…} before it classifies (navigateTo at head). A classifyNavigationTarget call at render would therefore hide a template link the spec accepts, and it would be the second judgement the card forbids. Static values the runner would refuse are already refused at spec parse. Whatever is left is reported (toast or log), and the pin "judges nothing at render" plus the three refused-value rows hold this.
  • D2. The file surface is wider than the claim's list. ANSWERED, accepted. Each extra file has a reason:
    • @object-ui/components is the one package both plugins already import DataEmptyState from.
    • ListEmptyState is what ListView's empty branch draws.
    • plugin-grid index.tsx holds the registry description.
    • The census edit is header prose.
      The one public-surface addition is counted in ①5 and ②.
  • D3. The whole app-shell type-check was not measured locally. ANSWERED by CI: Type Check succeeded on this head.
  • D4. check:doc-snippets and check:readme-exports were not measured locally. ANSWERED by CI: on this head, the Repo Gates steps "Doc Snippet Type Check — Compile documentation snippets against the built types" and "README Export Check — Check every README self-import against the real export surface" both succeeded.

Out-of-scope findings:

  • F1. apps/console/src/__tests__/registry-inputs-spec-parity.test.ts, row MEMBER_PINS['object-grid.emptyState']. CONFIRMED at head, non-blocking. The row still says "All three members" and cites only the 11227 test. It is mechanically green, and link is pinned by ObjectGrid.emptyStateLink-12270.test.tsx.
    • Drift of the same kind sits in packages/types/src/zod/objectql.zod.ts L783 (the object-grid emptyState .describe(), which lists three members).
    • ESCALATED, for a carrier only. "Whoever next edits that ledger row, for example the spec seat's follow-up" names no one in this repository, because the spec seat's follow-up lands in objectstack. The adopting seat should file one objectui card covering both lines, or name an existing one.
    • Not a FAIL reason.
  • F2. The link draws on grid and gallery only. ANSWERED, accepted. That is the empty branch card item 1 names. The page precedence (item 2) is set at the relay, so it holds on every visualization.
  • PR acceptance note on Spec Main Shape Gate. CONFIRMED by comparing the failure signature with the merge-base's runs through the API (see above), not on the PR's word. All Test shards are green, so the named flake (test(console): OAuthConsentPage.public-client-12301 races its own lookup — line 171 counts fetch calls before the effect has called fetch, so the in-flight case flakes red on a loaded runner #12344) did not fire.

Implemented-by: claude/issue-12270-empty-state-link
Reviewed-by: session_01CsyMRsFJBKNEMa4oPY1b5z

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Seat ACCEPT: objectui PR #12350 at f20667b · domain:ui seat 1 · session_01CsyMRsFJBKNEMa4oPY1b5z · 2026-10-14T04:45Z

Verdict: ACCEPT. Reviewed on GitHub against the claim 6159702345 (the hold's three doors 6135494657, the triage 6130578798, the scope addition 6132635356) and the dev's report 6160908856.

  • The contract review of record on this head is 6161068462, PASS, served at opus under the maintainer's waiver 6160996883 (objectui#5560).

  • The PR lands in this act.

  • PR form.

    • Draft, base main, assignee zhuangjianguo.
    • Line 1 is Fixes #12270, and no other closing verb appears in the body.
    • Line 2 is Clause-②: yes: two declared keys (emptyState.link, a list page's own interfaceConfig.emptyState) start being drawn.
  • The three doors, item by item.

    • ListView's empty branch (grid and gallery) hands emptyState.link to ListEmptyState, which draws it beside the add-record action. With no link the action slot is the same element as before.
    • InterfaceListPage relays cfg.emptyState ?? view.emptyState. The page's block wins whole, before the visualization is chosen.
    • ObjectGrid draws the link in DataEmptyState's action slot, only when one is declared.
    • One shared piece, not three copies: EmptyStateLink in @object-ui/components, which already depends on @object-ui/react. There is no new dependency edge and no cycle.
    • The click is the runner's: a type: 'url' action through useAction().runner. It carries no scheme list and no URL check of its own, as triage 6130578798 rules.
    • Key names come from the installed spec: EmptyState['link'] is taken by reference, and no @object-ui/types mirror moves.
  • Deviation 1, accepted: a refused value is drawn and refused at the click, not hidden at render.

    • Zone 3 was a suggestion. The ruling is "⛔ No second URL check".
    • The spec admits a ${…} url at parse and judges it after interpolation. Classifying at render would hide a spec-valid link and add a second judgement.
    • The contract review agrees.
  • Deviation 2, accepted: the file surface is wider than the claim's list.

    • It adds @object-ui/components (the shared piece), ListEmptyState.tsx, the grid registry description, one census-test sentence and four docs.
    • The seat's dispatch order asked for one shared piece rather than three copies, and allowed a fix where the real producer lives, named in the report and the PR body.
    • Each extra file is named there. The one public addition, EmptyStateLink with its two types, is counted in the changeset.
  • Changeset, checked sentence by sentence. It is minor for @object-ui/components, @object-ui/plugin-list, @object-ui/plugin-grid and @object-ui/app-shell, exactly the four packages whose source moved. Each sentence matches the diff:

    • the two keys drawn, with the visualizations named;
    • the runner's handling of an in-app path, http(s):// and mailto:;
    • a refusal at the click;
    • the label as a string or locale map;
    • the new export.
  • Docs: the grid docs, the grid README, the schema reference and the plugin-list README all say what the code does. The README example compiles, and CI's doc-snippet step passed.

  • Tests.

    • New pins are real renders through the real runner: components 14, plugin-list 4, plugin-grid 4, app-shell 6.
    • Three ablation legs each went red as predicted, and each restore is blob-proven.
    • Whole suites, at f20667b0e: packages/plugin-list/ (1419 tests), packages/plugin-grid/ (1920), packages/components/ (3875), and the 27 app-shell suites naming InterfaceListPage or emptyState (273).
    • The whole app-shell type-check was not measured locally. CI's Type Check on this head is success.
  • CI on this head: 33 check-runs, every one completed: 29 success, 3 expected skips and 1 red by design. Lint, Type Check, Repo Gates, Test, all 12 shards, Build & E2E, Build Docs and Test (dist pins) are success, read off check-runs whose head_sha is f20667b0e36ba2dd67a6af4971d33f7c312bffed.

    • Red by design: Spec Main Shape Gate. All three conditions hold:
      1. It is advisory and not in the required set.
      2. It runs no merge_group.
      3. The cause is objectstack PR #23160, and its consumer card is objectui#12325.
    • Its 21 annotations on this head (check-run 115680619484) are identical to main's run on 72594f29ec (115425173959).
  • check-governed-merges --pr: NOT governed. 950 changed lines, under 3000.

  • Out of scope, not filed: two sentences still list the empty state as three members. The carrier is objectui#12348, the open card that aligns @object-ui/types with spec 18's members. It gets one comment from this seat.

    • packages/types/src/zod/objectql.zod.ts:783, the object-grid emptyState describe: { title, message, icon }.
    • The MEMBER_PINS row object-grid.emptyState in apps/console/src/__tests__/registry-inputs-spec-parity.test.ts: "All three members".
  • For the spec seat: the renderer half of [finding] EmptyStateSchema declares no action, so an empty list cannot name its next step (a docs link, a how-to); the console's only empty-state button is add-record objectstack#23004 / #23005 lands with this PR. The [PLANNED — not yet rendered] markers become false once objectstack's console pin carries this merge. That pin hop is chore(objectui): move the console pin from 1fb1ced36243 to objectui main once objectui#11773 and objectui#12270 land (the hop carries objectui's v18 dependency move) objectstack#23392, which this seat dispatches next and which carries the markers.

  • Landing: pr_ready, then automerge_enable (the merge queue), in this act.

Implemented-by: claude/issue-12270-empty-state-link (mode:subagent dev of this seat)
Reviewed-by: session_01CsyMRsFJBKNEMa4oPY1b5z


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