fix(components): page:header resolves declared action ids - #7180

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-6252-page-header-action-ids
Sep 1, 2026
Merged

fix(components): page:header resolves declared action ids#7180
os-warren merged 2 commits into
mainfrom
claude/issue-6252-page-header-action-ids

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#6252

Implements the objectstack#11592 ruling (maintainer, 2026-08-25, on recommendation B): the canonical page:header renderer resolves PageHeaderProps.actions as action ids.

The defect

@objectstack/spec declares actions: z.array(z.string()).optional().describe('Action IDs to show in header') (verified on the installed pin, @objectstack/spec@17.2.0, src/ui/component.zod.ts). The renderer read that array as ActionDefobjects and resolved nothing, so a header authored the way the published contract declares rendered zero buttons — satisfying the schema deleted the header.

Two sibling surfaces already read it as ids: record:quick_actions (packages/plugin-detail/src/renderers/record-quick-actions.tsx) resolves a string-valued actions out of the object's metadata, and layout:page-header reaches the same resolver by delegating its actions into a record:quick_actions node (packages/layout/src/PageHeader.tsx:196-208). So one authoring key meant two different things depending on which renderer drew the header.

What changed

packages/components/src/renderers/layout/containers.tsx:

  • Each element of actions is normalised at the top of the pipeline — a string is resolved against the object's own actions metadata, an object passes through. Resolution goes through the same useMetadataItem entryrecord:quick_actions uses; no second resolver, no second lookup path.
  • Because normalisation happens above the filters, one chain still runs: actionRendersAt placement (record_header / record_more), the requiredPermissions capability gate, visible / hidden, order, and the inline/overflow split are untouched by this change. That is what makes the equivalence claim testable at all.
  • An id that resolves to nothing renders nothing and warns once, naming the object's declared action names. Silently dropping it would reproduce the silent-loss class this repo keeps re-filing; it also makes a mistyped id indistinguishable from a correctly hidden one. The warning is suppressed while the metadata read is in flight, so a correctly-authored page does not warn on first paint.
  • The registered actions input description now says ids.

The one deliberate difference from record:quick_actions: that renderer switches on the whole array (rawActions.every(a => typeof a === 'string')); this one normalises per element, so a half-migrated ['convert', { … }] resolves the id and passes the object through. Same mechanism, wider arity — a page mid-migration is exactly the state this card creates.

Clause ② is not engaged. The declared type and its zod mirror are untouched — actions stays z.array(z.string()). The object shape survives as renderer tolerance, exactly as it does today, undeclared. No accept/reject behaviour of the contract moves.

Docs: content/docs/guide/slotted-pages.md now teaches the ids contract on the canonical header (it previously showed only the object form), with the inline-object form named as migration tolerance.

Acceptance criteria — evidence

Run at af40cf5c2 (working tree clean at that commit, so every run below read exactly this tree).

1. An id-authored header renders the same buttons as the object-shape authoring.

The same action metadata is authored twice and the two renders compared. The object-shape render is the live control: each equivalence case asserts it is non-empty first, so an id-side green can never come from two empty headers agreeing.

The population covers every filter the criterion names, so one equivalence assertion measures all of them: list_only (list_item — must not render), archive (record_more — must land in the ⋯ menu, not inline), gated (requiredPermissions the user lacks), closed_only (a CEL visible false for this record), and qualify (order: 1) rendering beforeconvert (order: 2), which is the reverse of the order both authorings list them in. Both the ordered button-name list and a normalised innerHTML projection are asserted equal.

2. The renderer's tests gain the id-shaped cases.packages/components/src/__tests__/page-header-action-ids.test.tsx — 8 cases (equivalence, the filter chain, record_more routing, unresolved-id warn-once with a resolvable sibling as control, no-warn-while-loading, mixed arrays, the properties.actions bridge spelling, and criterion 3).

pnpm exec vitest run --project dom-heavy packages/components/src/__tests__/page-header-action-ids.test.tsx
Test Files 1 passed (1) Tests 8 passed (8)

Ablation — direction predicted before running: red on the id path, green on the object-shape control. Mutation = feeding rawHeaderActions back into the filter chain (the minimal deletion of the fix). Proven on disk by marker count and blob hash, never by an editor's exit code:

PRE target-count=1 inject-count=0 PRE_HASH=3dafeb94… (== HEAD blob)
POST target-count=0 inject-count=1 POST_HASH=f87a37c9…
Tests 7 failed | 1 passed (8)

7 failed / 1 passed matched the prediction exactly: the only case that cannot move is "does not warn while the metadata lookup is still in flight", which is true with or without resolution. Restore proven by state, not exit code: restored blob 3dafeb94… equals the HEAD blob, and git diff HEAD, git diff --cached, git status --short are all empty. The mutation and restore legs need no rebuild here: the root vitest.config.mts aliases @object-ui/components to packages/components/src, so this suite resolves source, not dist.

3. The id path carries no body.source into the built artifact.

Measured two ways, and the built-artifact one is the load-bearing half.

Against the built artifact.pnpm --filter @object-ui/components build, then a run that mounts the builtpackages/components/dist/index.js (an explicit relative import — the workspace alias would otherwise redirect to src) with an id-authored header whose resolved action carries body: { language: 'js', source: '…OS6252_DIST_BODY_MARKER…' }. The id resolved through the built renderer, the marker appears nowhere in the rendered DOM, and the authored node's JSON.stringify is byte-identical before and after render. Live control: with that one import removed the same file fails expected undefined to be truthypage:header is unregistered in the light dom project, which proves the passing run measured the built bundle and nothing else. This measurement is not committed: turbo's test task is dependsOn: ["^build"] (dependencies only, not the package's own build), so a committed dist-importing test would be NOT MEASURED in CI rather than a pin. Filed as a follow-up.

As a committed pin.never writes a resolved def — and so no body.source — back onto the authored node asserts the authored node is unchanged by rendering and the marker is absent from the DOM, with the object-shape authoring as the live control: expect(JSON.stringify(objectAuthored)).toContain(BODY_MARKER) — the inline form does drag the handler body into what it serializes, which is what proves this assertion can fail at all.

Grep of the built bundle, with controls: did not resolve = 1 and page:header = 7 (present), OS6252_HANDLER_BODY_MARKER = 0.

Other verification

RunResult
vitest run packages/components scripts/__tests__/vitest-invocation-guard.test.ts224 files, 2070 passed
all page-header* + RecordDetailView.header* / predicate suites16 files, 184 passed
console registry/contract parity (5 files incl. registry-inputs-spec-parity, public-contract)156 passed
pnpm --filter @object-ui/components run type-checkexit 0 — and it covers the new test file (tsc -p tsconfig.test.json reported errors in it before they were fixed)
pnpm check:control-bytes✅ 5945 tracked text files
pnpm check:doc-types✅ every documented component type registered
pnpm check:doc-fences
pnpm check:doc-snippets✅ 272/272 blocks judged, 0 failed (after building its declared closure; its own resolution/sentinel/positive controls printed)
pnpm check:sdui-registration-pins✅ all 16 registrations present (after building the console + its closure)

eslint on all three changed source files: 0 errors (5 pre-existing-style no-explicit-any warnings in the new test, matching its sibling page-header tests; --max-warnings is deliberately unset in this repo). The lint run is narrowed and the narrowing is measured, not assumed: the population is the diff itself (git diff --name-only), the count comes from --format json (3 files linted), and the config is not type-aware — eslint.config.js contains zero projectService / parserOptions / project: occurrences against a live control of 10 rules hits in the same file — so no untouched file's verdict can move under this diff, which touches no config. The repo-wide farm is CI's run either way.

Not in this PR

hotcrm's migration of its 16 inline record_header objects to id references is downstream of this landing, as the card records. Nothing here touches it.


Generated by Claude Code

os-warrenand others added 2 commits September 1, 2026 09:24
`@objectstack/spec`'s `PageHeaderProps.actions` is `z.array(z.string())` —
"Action IDs to show in header" — but the canonical `page:header` renderer read
the array as ActionDef objects and resolved nothing, so metadata satisfying the
published contract rendered zero header buttons.
Resolve ids against the object's own metadata at the top of the actions
pipeline, through the same `useMetadataItem` entry `record:quick_actions` uses,
so the existing placement / capability / visible / order chain runs unchanged
over uniformly-shaped defs. Inline objects keep working as renderer tolerance
for the transition, per element, so a half-migrated array resolves.
An id that resolves to nothing warns once and renders nothing, naming the
object's declared action names.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
Document the contract the renderer now honours: the header names actions by id
and resolves them against the object's own metadata, with the inline-object
form named as migration tolerance rather than a second declared shape.
Adds the changeset and types the console-warning assertions in the new pin.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

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

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


📦 Bundle Size Report

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

Size Limits

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

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

os-dev-report

Same report as #6252 (comment 5492213936); posted here too because the dispatch asked for it on the PR.

{
"issue": 6252,
"status": "done",
"branch": "claude/issue-6252-page-header-action-ids",
"pr": "https://github.com/objectstack-ai/objectui/pull/7180",
"premise_still_valid": true,
"summary": "The canonical page:header renderer (packages/components/src/renderers/layout/containers.tsx) now resolves each element of `actions` as a declared action id against the object's own metadata, through the same useMetadataItem entry record:quick_actions uses — no second resolver. Normalisation sits at the TOP of the actions pipeline, so the existing single filter chain (actionRendersAt placement, requiredPermissions capability gate, visible/hidden, order, inline/overflow split) runs unchanged over uniformly-shaped defs; that is what makes the equivalence claim measurable. Inline ActionDef objects keep working per element, so a half-migrated ['convert', {def}] array resolves the id and passes the object through — renderer tolerance for the migration, still undeclared. An id that resolves to nothing renders nothing and warns ONCE naming the object's declared action names (suppressed while the metadata read is in flight, so a correct page never warns on first paint); silently dropping it would reproduce the silent-loss class this seat keeps filing. Clause 2 fence honoured: the spec type and its zod mirror are untouched, actions stays z.array(z.string()). Docs updated (content/docs/guide/slotted-pages.md taught only the object form) plus a minor changeset. ZONE 2 VERDICTS, all re-measured on this repo's head: A2.1 CONFIRMED (the pre-fix chain read .locations/.requiredPermissions/.visible/.order straight off each array element via actionRendersAt, no lookup anywhere). A2.2 CONFIRMED — exactly ONE canonical page:header registration, ComponentRegistry.register('header', PageHeaderRenderer, {namespace:'page', skipFallback:true}) in containers.tsx; packages/layout/src/index.ts deliberately does NOT re-register it and says so, and the legacy `page-header` alias already resolves ids by delegating to record:quick_actions — so the multi-spelling hazard the PM flagged does not fire here, and the sibling that already implements the ruling is the alias, not a second canonical mount. A2.3 CONFIRMED, cheap. A2.4 CONFIRMED — nothing pinned the rendered result of a string-authored page:header; the only two id-shaped fixtures in the repo pin VALIDATION only (examples/schema-catalog pins that actions:['export'] validates clean, which supports ids), so nothing had to be updated or deleted. A2.5 CONFIRMED with no seam: @object-ui/components already depends on @object-ui/react and already imports eight hooks from it, so useMetadataItem was reachable with no new dependency and no second context scope.",
"tests": "All runs at final commit af40cf5c2 with a clean working tree (git status --short empty), so every figure below read exactly that tree. Heavy runs went through the container's shared verify lock; verdicts read from its VERDICT line, exit codes captured before any pipe. (1) NEW PIN packages/components/src/__tests__/page-header-action-ids.test.tsx, registered in vitest.config.mts heavyDomTests (required — it renders through the ComponentRegistry): 'pnpm exec vitest run --project dom-heavy ...' gives Test Files 1 passed (1), Tests 8 passed (8). Shape of the proof: the SAME action metadata authored twice, once as ids and once as inline objects, both renders compared on the ordered button-name list AND a normalised innerHTML projection; the object-shape render is the LIVE CONTROL and is asserted non-empty first, so an id-side green cannot come from two empty headers agreeing. The population makes one equivalence assertion cover every filter the acceptance criterion names: list_item-only must not render, record_more must route to the overflow menu and never inline, a requiredPermissions the user lacks is denied, a CEL visible false for the record hides, and qualify (order 1) renders BEFORE convert (order 2) — the reverse of the order both authorings list them in. (2) ABLATION, direction predicted before running (red on the id path, green on the object-shape control). Mutation = feeding rawHeaderActions back into the filter chain. Proven on disk by BOTH marker count and blob hash, never by an editor's exit code: PRE target-count=1 inject-count=0 PRE_HASH=3dafeb94 equal to the HEAD blob; POST target-count=0 inject-count=1 POST_HASH=f87a37c9. Result: Tests 7 failed | 1 passed (8) — passing count asserted alongside the red, and 7/1 matched the prediction exactly (the one immovable case is 'does not warn while the metadata lookup is still in flight', true with or without resolution, so this is a targeted break and not a collapsed population). Restore proven by STATE not exit code: restored blob 3dafeb94 equals the HEAD blob, and git diff HEAD / git diff --cached / git status --short are all empty; the script carried an EXIT/INT/TERM trap with an absolute path. No rebuild needed on either leg and this was measured, not assumed: the root vitest.config.mts aliases @object-ui/components to packages/components/src, so the suite resolves SOURCE, never dist. (3) ACCEPTANCE CRITERION 3, measured against the BUILT ARTIFACT as the card demands. pnpm --filter @object-ui/components build, then a run mounting the built packages/components/dist/index.js by explicit relative import (the workspace alias would otherwise redirect to src) with an id-authored header whose resolved action carries body:{language:'js',source:'...OS6252_DIST_BODY_MARKER...'}: the id resolved through the BUILT renderer, the marker appears nowhere in the rendered DOM, and the authored node's JSON.stringify is byte-identical before and after render. LIVE CONTROL: with that one import removed the same file fails 'expected undefined to be truthy' (page:header is unregistered in the light dom project), which proves the passing run measured the built bundle and nothing else. That measurement is deliberately NOT COMMITTED — turbo's test task is dependsOn ['^build'], dependencies only, so a committed dist-importing test would be NOT MEASURED in CI rather than a pin; filed as objectui#7183. The committed half is the pin 'never writes a resolved def — and so no body.source — back onto the authored node', with the object-shape authoring as its live control: expect(JSON.stringify(objectAuthored)).toContain(BODY_MARKER) — the inline form DOES drag the handler body into what it serializes, which is what proves the id-side assertion can fail at all. Built-bundle greps with controls: 'did not resolve'=1 and 'page:header'=7 present, 'OS6252_HANDLER_BODY_MARKER'=0. (4) REGRESSION SURFACE: 'vitest run packages/components scripts/__tests__/vitest-invocation-guard.test.ts' gives 224 files, 2070 passed; all page-header* plus RecordDetailView.header*/predicate suites give 16 files, 184 passed; console registry/contract parity (registry-inputs-spec-parity, public-contract, ga-honoured-inputs-author-reach, component-input-union-specimens, record-block-record-reach) gives 156 passed. (5) TYPES: 'pnpm --filter @object-ui/components run type-check' exit 0 — note the script is spelled type-check (hyphen) in this repo — and it demonstrably COVERS the new test file: tsc -p tsconfig.test.json reported four TS7006 errors inside it before they were fixed, so this is not a typecheck that excludes tests. (6) GATES: check:control-bytes OK (5945 tracked text files); check:doc-types OK; check:doc-fences OK; check:doc-snippets OK, 272/272 blocks judged 0 failed, after building the closure it names (its first run was PRECONDITION NOT MET exit 2 — recorded as NOT MEASURED, then re-run, never as a pass); check:sdui-registration-pins OK, all 16 registrations present, after building the console plus its dependency closure (its first two runs were exit 2 'no console build to weigh' and a TS2307 unbuilt-closure error — both NOT MEASURED, not results). (7) LINT: eslint over all three changed source files, 0 errors (5 no-explicit-any WARNINGS in the new test matching its sibling page-header tests; lint.yml deliberately sets no --max-warnings). The narrowing is measured, not assumed, all three pieces: population is the diff itself via git diff --name-only; the count comes from --format json (3 files linted); and the config is not type-aware — eslint.config.js has zero projectService / parserOptions / project: occurrences against a live control of 10 'rules' hits in the same file — so with no config file in the diff no untouched file's verdict can move. The repo-wide farm is CI's run either way. CI convergence not awaited, per the standing dispatch contract.",
"mcp_calls": "12 — issue_read get + get_comments, create_pull_request, pull_request_read (body read-back), search_issues x2 (the second was the required control and it FAILED to hit, see open_questions), issue_write create x2, and this report's comments plus one read-back. Bulk reads went to git and to the zero-quota web payload channel instead.",
"open_questions": [
{
"question": "MECHANISM, not a code decision — the MCP `search_issues` channel appears blind in this container, and this seat's dedupe had to route around it. A targeted search for this card's own near-verbatim title returned total_count 0, i.e. the known-must-hit CONTROL failed, so its earlier empty result for my dedupe query was not a reading. Unauthenticated REST is also 403 here (both /repos/.../issues and /search/issues). Dedupe was completed on the zero-quota GitHub web payload channel with a live control that DID hit (query 'action-id lookup' returned 8 issue numbers including 6252; the two dedupe queries returned disjoint populations with no duplicate of either finding), so both findings are filed properly — but the next seat should know that search_issues answering 0 here means nothing.",
"options": [
"A — treat MCP search_issues as unavailable for this seat/container and make the web payload channel the documented dedupe route, control word mandatory",
"B — investigate why search_issues returns 0 for a query matching an issue it can read directly via issue_read (indexing lag vs. a scope/permission difference between the read and search paths)",
"C — do nothing; each dev re-discovers it per run"
],
"recommendation": "A plus B. A is cheap and immediate — the web channel is measured working here, costs no GraphQL quota, and the control discipline the dispatch already mandates is exactly what caught this. B matters because a silently-empty search is the worst failure shape available to a dedupe step: it looks like 'no duplicate exists' and it is unfalsifiable without a control, so a seat that skips the control files duplicates and never learns."
}
],
"out_of_scope_findings": [
"filed as objectui#7182: the action-id lookup now exists twice (record-quick-actions.tsx and containers.tsx) and the two copies DISAGREE on mixed arrays — quick_actions switches on the whole array (every element a string), page:header normalises per element — so during the migration this card opens, one authored array means two things depending on which renderer draws it. Not fixed here: unifying them changes record:quick_actions behaviour on a renderer this card does not own, and wants a ruling on whether a mixed array is a supported migration state.",
"filed as objectui#7183: no committed test can observe its own package's BUILT artifact — the root vitest alias sends every workspace specifier to src, and turbo's test task is dependsOn ['^build'] (dependencies only), so a dist-importing test is MODULE_NOT_FOUND / NOT MEASURED in CI. This card's acceptance criterion 3 explicitly asks for a built-artifact measurement; it was run by hand with a live control and passes, but it cannot be left behind as a standing pin."
]
}

Generated by Claude Code

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

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

Verdict: ACCEPT. Arming on green. Two judgement calls are ruled below, and one finding in this report is bigger than this card.

⭐ The finding that outranks the PR: search_issues is blind in this container, and its zero is not a reading

"A targeted search for this card's own near-verbatim title returned total_count 0, i.e. the known-must-hit CONTROL failed, so its earlier empty result for my dedupe query was not a reading."

This is the most valuable thing in the report and it is not about page:header at all. A silently-empty search is the worst failure shape available to a dedupe step: it renders as "no duplicate exists", it is indistinguishable from a real negative, and it is unfalsifiable without a control. A seat that skips the control files duplicates and never finds out.

It is also the seat's own instrument rule — a control that returns zero is no control; an empty output is not a 0 — applied one level up, to a channel rather than a grep. That generalisation is worth more than the rule it came from, and I am adopting it: the control belongs on the channel, not only on the query. Recording it as its own card rather than leaving it in a PR body, because it applies to every dev in this container and nothing in the repo currently says so. Dedupe here was completed on the zero-quota web payload channel with a control that did hit (action-id lookup → 8 issue numbers including #6252), so both out-of-scope findings are properly filed.

⚠️ Noting for the record that this does not impugn search_code, which this seat used successfully an hour ago (13 hits on 13855, with the returned paths independently confirmed against a local checkout). The blindness is specific to the issue-search path, which is exactly why a per-channel control is the answer rather than a blanket distrust.

A2.2 was my low-confidence assumption, and confirming it was worth the cost

I flagged a possible second page:header mount because #7021 found the record-title key in three spellings. Measured: exactly one canonical registration (ComponentRegistry.register('header', PageHeaderRenderer, {namespace:'page', skipFallback:true})), packages/layout/src/index.ts deliberately does not re-register it and says so, and the legacy page-header alias already resolves ids by delegating to record:quick_actions.

⭐ That last clause reframes the card: the sibling that already implements this ruling is the alias, not a second canonical mount. The acceptance criterion was not under-specified, and the hazard did not fire. A confirmed assumption that closes a named risk is a result, not a formality.

Ruling 1 — the built-artifact measurement was run and deliberately NOT committed: upheld

Acceptance criterion 3 ("the id path carries no body.source into the built artifact") was measured properly — the built packages/components/dist/index.js mounted by explicit relative import, an id-authored header whose resolved action carries an OS6252_DIST_BODY_MARKER body, marker absent from the rendered DOM, authored node byte-identical before and after render, and a live control proving the passing run measured the bundle (removing that one import fails expected undefined to be truthy, because page:header is unregistered in the light dom project).

And then it was not committed, because turbo's test task is dependsOn ['^build']dependencies only — so a committed dist-importing test would be MODULE_NOT_FOUND / NOT MEASURED in CI.

That is the right call and I want the reasoning preserved: a test that reports NOT MEASURED in CI is worse than no test, because it renders as coverage. Shipping it would have put a permanently-void assertion into the suite under the name of this card's hardest acceptance criterion. Filed as #7183 instead, with the committed half being the pin that can fail — "never writes a resolved def back onto the authored node" — carrying the object-shape authoring as its live control, since the inline form genuinely does drag the handler body into what it serializes. That control is what makes the id-side assertion falsifiable at all.

Ruling 2 — #7182, the divergence this card created: file, do not unify. Grading raised.

The action-id lookup now exists in two places and they disagree on mixed arrays: record:quick_actions switches on the whole array (every element a string), page:header normalises per element. So during the migration this card opens, one authored array means two things depending on which renderer draws it.

I asked for "no second resolver" and that fence held — the metadata read goes through the same useMetadataItem entry. What diverges is the array normalisation policy, which is a different thing and was not covered. ⚠️ But it is a real new dialect in a repo actively paying down dialects (#6771 is retiring one this hour), and unlike most findings it was introduced here rather than discovered. Declining to unify was still correct: it would change record:quick_actions behaviour on a renderer this card does not own, and whether a mixed array is a supported migration state is a ruling, not an implementation detail.

⇒ I am treating #7182 as the completion condition of this migration, not a loose end, and will grade it accordingly rather than letting it sit as an ordinary finding.

Also correct

  • Clause ② fence honoured — spec type and zod mirror untouched, actions stays z.array(z.string()). The renderer catches up to a declaration that was already public; nothing widens.
  • Unresolvable id renders nothing and warns ONCE, naming the object's declared action names, suppressed while the metadata read is in flight so a correct page never warns on first paint. That is the loud-over-silent option, and the in-flight suppression is the detail that makes it usable rather than noisy.
  • Equivalence proved by the same metadata authored twice — ids and inline objects — compared on the ordered button-name list and a normalised innerHTML projection, with the object-shape render asserted non-empty first, so an id-side green cannot come from two empty headers agreeing. One assertion covers every filter the acceptance criterion names, including qualify (order 1) rendering beforeconvert (order 2) — the reverse of the order both authorings list them in.
  • Ablation: Tests 7 failed | 1 passed (8), matching the 7/1 prediction exactly, with the single immovable case identified ("does not warn while the metadata lookup is still in flight" is true with or without resolution) — which is what distinguishes a targeted break from a collapsed population.
  • Two gates correctly reported NOT MEASURED on first run (check:doc-snippets exit 2 PRECONDITION NOT MET; check:sdui-registration-pins exit 2 and a TS2307 unbuilt closure) and re-run to real verdicts rather than scored as passes.
  • Docs updated at content/docs/guide/slotted-pages.md, which taught only the object form. ⛔ content/docs/releases/ untouched, correctly.

Nothing to change.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 10:05
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 8ec11e1Sep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-6252-page-header-action-ids branch September 1, 2026 10:19
Sign up for freeto 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.

page:header implements the declared action-id lookup — resolve actions: string[] like record:quick_actions does (objectstack#11592 ruling)

1 participant

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(components): page:header resolves declared action ids - #7180

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-6252-page-header-action-ids
Sep 1, 2026
Merged

fix(components): page:header resolves declared action ids#7180
os-warren merged 2 commits into
mainfrom
claude/issue-6252-page-header-action-ids

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#6252

Implements the objectstack#11592 ruling (maintainer, 2026-08-25, on recommendation B): the canonical page:header renderer resolves PageHeaderProps.actions as action ids.

The defect

@objectstack/spec declares actions: z.array(z.string()).optional().describe('Action IDs to show in header') (verified on the installed pin, @objectstack/spec@17.2.0, src/ui/component.zod.ts). The renderer read that array as ActionDefobjects and resolved nothing, so a header authored the way the published contract declares rendered zero buttons — satisfying the schema deleted the header.

Two sibling surfaces already read it as ids: record:quick_actions (packages/plugin-detail/src/renderers/record-quick-actions.tsx) resolves a string-valued actions out of the object's metadata, and layout:page-header reaches the same resolver by delegating its actions into a record:quick_actions node (packages/layout/src/PageHeader.tsx:196-208). So one authoring key meant two different things depending on which renderer drew the header.

What changed

packages/components/src/renderers/layout/containers.tsx:

  • Each element of actions is normalised at the top of the pipeline — a string is resolved against the object's own actions metadata, an object passes through. Resolution goes through the same useMetadataItem entryrecord:quick_actions uses; no second resolver, no second lookup path.
  • Because normalisation happens above the filters, one chain still runs: actionRendersAt placement (record_header / record_more), the requiredPermissions capability gate, visible / hidden, order, and the inline/overflow split are untouched by this change. That is what makes the equivalence claim testable at all.
  • An id that resolves to nothing renders nothing and warns once, naming the object's declared action names. Silently dropping it would reproduce the silent-loss class this repo keeps re-filing; it also makes a mistyped id indistinguishable from a correctly hidden one. The warning is suppressed while the metadata read is in flight, so a correctly-authored page does not warn on first paint.
  • The registered actions input description now says ids.

The one deliberate difference from record:quick_actions: that renderer switches on the whole array (rawActions.every(a =&gt; typeof a === 'string')); this one normalises per element, so a half-migrated ['convert', { … }] resolves the id and passes the object through. Same mechanism, wider arity — a page mid-migration is exactly the state this card creates.

Clause ② is not engaged. The declared type and its zod mirror are untouched — actions stays z.array(z.string()). The object shape survives as renderer tolerance, exactly as it does today, undeclared. No accept/reject behaviour of the contract moves.

Docs: content/docs/guide/slotted-pages.md now teaches the ids contract on the canonical header (it previously showed only the object form), with the inline-object form named as migration tolerance.

Acceptance criteria — evidence

Run at af40cf5c2 (working tree clean at that commit, so every run below read exactly this tree).

1. An id-authored header renders the same buttons as the object-shape authoring.

The same action metadata is authored twice and the two renders compared. The object-shape render is the live control: each equivalence case asserts it is non-empty first, so an id-side green can never come from two empty headers agreeing.

The population covers every filter the criterion names, so one equivalence assertion measures all of them: list_only (list_item — must not render), archive (record_more — must land in the ⋯ menu, not inline), gated (requiredPermissions the user lacks), closed_only (a CEL visible false for this record), and qualify (order: 1) rendering beforeconvert (order: 2), which is the reverse of the order both authorings list them in. Both the ordered button-name list and a normalised innerHTML projection are asserted equal.

2. The renderer's tests gain the id-shaped cases.packages/components/src/__tests__/page-header-action-ids.test.tsx — 8 cases (equivalence, the filter chain, record_more routing, unresolved-id warn-once with a resolvable sibling as control, no-warn-while-loading, mixed arrays, the properties.actions bridge spelling, and criterion 3).

pnpm exec vitest run --project dom-heavy packages/components/src/__tests__/page-header-action-ids.test.tsx
Test Files 1 passed (1) Tests 8 passed (8)

Ablation — direction predicted before running: red on the id path, green on the object-shape control. Mutation = feeding rawHeaderActions back into the filter chain (the minimal deletion of the fix). Proven on disk by marker count and blob hash, never by an editor's exit code:

PRE target-count=1 inject-count=0 PRE_HASH=3dafeb94… (== HEAD blob)
POST target-count=0 inject-count=1 POST_HASH=f87a37c9…
Tests 7 failed | 1 passed (8)

7 failed / 1 passed matched the prediction exactly: the only case that cannot move is "does not warn while the metadata lookup is still in flight", which is true with or without resolution. Restore proven by state, not exit code: restored blob 3dafeb94… equals the HEAD blob, and git diff HEAD, git diff --cached, git status --short are all empty. The mutation and restore legs need no rebuild here: the root vitest.config.mts aliases @object-ui/components to packages/components/src, so this suite resolves source, not dist.

3. The id path carries no body.source into the built artifact.

Measured two ways, and the built-artifact one is the load-bearing half.

Against the built artifact.pnpm --filter @object-ui/components build, then a run that mounts the builtpackages/components/dist/index.js (an explicit relative import — the workspace alias would otherwise redirect to src) with an id-authored header whose resolved action carries body: { language: 'js', source: '…OS6252_DIST_BODY_MARKER…' }. The id resolved through the built renderer, the marker appears nowhere in the rendered DOM, and the authored node's JSON.stringify is byte-identical before and after render. Live control: with that one import removed the same file fails expected undefined to be truthypage:header is unregistered in the light dom project, which proves the passing run measured the built bundle and nothing else. This measurement is not committed: turbo's test task is dependsOn: ["^build"] (dependencies only, not the package's own build), so a committed dist-importing test would be NOT MEASURED in CI rather than a pin. Filed as a follow-up.

As a committed pin.never writes a resolved def — and so no body.source — back onto the authored node asserts the authored node is unchanged by rendering and the marker is absent from the DOM, with the object-shape authoring as the live control: expect(JSON.stringify(objectAuthored)).toContain(BODY_MARKER) — the inline form does drag the handler body into what it serializes, which is what proves this assertion can fail at all.

Grep of the built bundle, with controls: did not resolve = 1 and page:header = 7 (present), OS6252_HANDLER_BODY_MARKER = 0.

Other verification

RunResult
vitest run packages/components scripts/__tests__/vitest-invocation-guard.test.ts224 files, 2070 passed
all page-header* + RecordDetailView.header* / predicate suites16 files, 184 passed
console registry/contract parity (5 files incl. registry-inputs-spec-parity, public-contract)156 passed
pnpm --filter @object-ui/components run type-checkexit 0 — and it covers the new test file (tsc -p tsconfig.test.json reported errors in it before they were fixed)
pnpm check:control-bytes✅ 5945 tracked text files
pnpm check:doc-types✅ every documented component type registered
pnpm check:doc-fences
pnpm check:doc-snippets✅ 272/272 blocks judged, 0 failed (after building its declared closure; its own resolution/sentinel/positive controls printed)
pnpm check:sdui-registration-pins✅ all 16 registrations present (after building the console + its closure)

eslint on all three changed source files: 0 errors (5 pre-existing-style no-explicit-any warnings in the new test, matching its sibling page-header tests; --max-warnings is deliberately unset in this repo). The lint run is narrowed and the narrowing is measured, not assumed: the population is the diff itself (git diff --name-only), the count comes from --format json (3 files linted), and the config is not type-aware — eslint.config.js contains zero projectService / parserOptions / project: occurrences against a live control of 10 rules hits in the same file — so no untouched file's verdict can move under this diff, which touches no config. The repo-wide farm is CI's run either way.

Not in this PR

hotcrm's migration of its 16 inline record_header objects to id references is downstream of this landing, as the card records. Nothing here touches it.


Generated by Claude Code

os-warrenand others added 2 commits September 1, 2026 09:24
`@objectstack/spec`'s `PageHeaderProps.actions` is `z.array(z.string())` —
"Action IDs to show in header" — but the canonical `page:header` renderer read
the array as ActionDef objects and resolved nothing, so metadata satisfying the
published contract rendered zero header buttons.
Resolve ids against the object's own metadata at the top of the actions
pipeline, through the same `useMetadataItem` entry `record:quick_actions` uses,
so the existing placement / capability / visible / order chain runs unchanged
over uniformly-shaped defs. Inline objects keep working as renderer tolerance
for the transition, per element, so a half-migrated array resolves.
An id that resolves to nothing warns once and renders nothing, naming the
object's declared action names.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
Document the contract the renderer now honours: the header names actions by id
and resolves them against the object's own metadata, with the inline-object
form named as migration tolerance rather than a second declared shape.
Adds the changeset and types the console-warning assertions in the new pin.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

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

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


📦 Bundle Size Report

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

Size Limits

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

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

os-dev-report

Same report as #6252 (comment 5492213936); posted here too because the dispatch asked for it on the PR.

{
"issue": 6252,
"status": "done",
"branch": "claude/issue-6252-page-header-action-ids",
"pr": "https://github.com/objectstack-ai/objectui/pull/7180",
"premise_still_valid": true,
"summary": "The canonical page:header renderer (packages/components/src/renderers/layout/containers.tsx) now resolves each element of `actions` as a declared action id against the object's own metadata, through the same useMetadataItem entry record:quick_actions uses — no second resolver. Normalisation sits at the TOP of the actions pipeline, so the existing single filter chain (actionRendersAt placement, requiredPermissions capability gate, visible/hidden, order, inline/overflow split) runs unchanged over uniformly-shaped defs; that is what makes the equivalence claim measurable. Inline ActionDef objects keep working per element, so a half-migrated ['convert', {def}] array resolves the id and passes the object through — renderer tolerance for the migration, still undeclared. An id that resolves to nothing renders nothing and warns ONCE naming the object's declared action names (suppressed while the metadata read is in flight, so a correct page never warns on first paint); silently dropping it would reproduce the silent-loss class this seat keeps filing. Clause 2 fence honoured: the spec type and its zod mirror are untouched, actions stays z.array(z.string()). Docs updated (content/docs/guide/slotted-pages.md taught only the object form) plus a minor changeset. ZONE 2 VERDICTS, all re-measured on this repo's head: A2.1 CONFIRMED (the pre-fix chain read .locations/.requiredPermissions/.visible/.order straight off each array element via actionRendersAt, no lookup anywhere). A2.2 CONFIRMED — exactly ONE canonical page:header registration, ComponentRegistry.register('header', PageHeaderRenderer, {namespace:'page', skipFallback:true}) in containers.tsx; packages/layout/src/index.ts deliberately does NOT re-register it and says so, and the legacy `page-header` alias already resolves ids by delegating to record:quick_actions — so the multi-spelling hazard the PM flagged does not fire here, and the sibling that already implements the ruling is the alias, not a second canonical mount. A2.3 CONFIRMED, cheap. A2.4 CONFIRMED — nothing pinned the rendered result of a string-authored page:header; the only two id-shaped fixtures in the repo pin VALIDATION only (examples/schema-catalog pins that actions:['export'] validates clean, which supports ids), so nothing had to be updated or deleted. A2.5 CONFIRMED with no seam: @object-ui/components already depends on @object-ui/react and already imports eight hooks from it, so useMetadataItem was reachable with no new dependency and no second context scope.",
"tests": "All runs at final commit af40cf5c2 with a clean working tree (git status --short empty), so every figure below read exactly that tree. Heavy runs went through the container's shared verify lock; verdicts read from its VERDICT line, exit codes captured before any pipe. (1) NEW PIN packages/components/src/__tests__/page-header-action-ids.test.tsx, registered in vitest.config.mts heavyDomTests (required — it renders through the ComponentRegistry): 'pnpm exec vitest run --project dom-heavy ...' gives Test Files 1 passed (1), Tests 8 passed (8). Shape of the proof: the SAME action metadata authored twice, once as ids and once as inline objects, both renders compared on the ordered button-name list AND a normalised innerHTML projection; the object-shape render is the LIVE CONTROL and is asserted non-empty first, so an id-side green cannot come from two empty headers agreeing. The population makes one equivalence assertion cover every filter the acceptance criterion names: list_item-only must not render, record_more must route to the overflow menu and never inline, a requiredPermissions the user lacks is denied, a CEL visible false for the record hides, and qualify (order 1) renders BEFORE convert (order 2) — the reverse of the order both authorings list them in. (2) ABLATION, direction predicted before running (red on the id path, green on the object-shape control). Mutation = feeding rawHeaderActions back into the filter chain. Proven on disk by BOTH marker count and blob hash, never by an editor's exit code: PRE target-count=1 inject-count=0 PRE_HASH=3dafeb94 equal to the HEAD blob; POST target-count=0 inject-count=1 POST_HASH=f87a37c9. Result: Tests 7 failed | 1 passed (8) — passing count asserted alongside the red, and 7/1 matched the prediction exactly (the one immovable case is 'does not warn while the metadata lookup is still in flight', true with or without resolution, so this is a targeted break and not a collapsed population). Restore proven by STATE not exit code: restored blob 3dafeb94 equals the HEAD blob, and git diff HEAD / git diff --cached / git status --short are all empty; the script carried an EXIT/INT/TERM trap with an absolute path. No rebuild needed on either leg and this was measured, not assumed: the root vitest.config.mts aliases @object-ui/components to packages/components/src, so the suite resolves SOURCE, never dist. (3) ACCEPTANCE CRITERION 3, measured against the BUILT ARTIFACT as the card demands. pnpm --filter @object-ui/components build, then a run mounting the built packages/components/dist/index.js by explicit relative import (the workspace alias would otherwise redirect to src) with an id-authored header whose resolved action carries body:{language:'js',source:'...OS6252_DIST_BODY_MARKER...'}: the id resolved through the BUILT renderer, the marker appears nowhere in the rendered DOM, and the authored node's JSON.stringify is byte-identical before and after render. LIVE CONTROL: with that one import removed the same file fails 'expected undefined to be truthy' (page:header is unregistered in the light dom project), which proves the passing run measured the built bundle and nothing else. That measurement is deliberately NOT COMMITTED — turbo's test task is dependsOn ['^build'], dependencies only, so a committed dist-importing test would be NOT MEASURED in CI rather than a pin; filed as objectui#7183. The committed half is the pin 'never writes a resolved def — and so no body.source — back onto the authored node', with the object-shape authoring as its live control: expect(JSON.stringify(objectAuthored)).toContain(BODY_MARKER) — the inline form DOES drag the handler body into what it serializes, which is what proves the id-side assertion can fail at all. Built-bundle greps with controls: 'did not resolve'=1 and 'page:header'=7 present, 'OS6252_HANDLER_BODY_MARKER'=0. (4) REGRESSION SURFACE: 'vitest run packages/components scripts/__tests__/vitest-invocation-guard.test.ts' gives 224 files, 2070 passed; all page-header* plus RecordDetailView.header*/predicate suites give 16 files, 184 passed; console registry/contract parity (registry-inputs-spec-parity, public-contract, ga-honoured-inputs-author-reach, component-input-union-specimens, record-block-record-reach) gives 156 passed. (5) TYPES: 'pnpm --filter @object-ui/components run type-check' exit 0 — note the script is spelled type-check (hyphen) in this repo — and it demonstrably COVERS the new test file: tsc -p tsconfig.test.json reported four TS7006 errors inside it before they were fixed, so this is not a typecheck that excludes tests. (6) GATES: check:control-bytes OK (5945 tracked text files); check:doc-types OK; check:doc-fences OK; check:doc-snippets OK, 272/272 blocks judged 0 failed, after building the closure it names (its first run was PRECONDITION NOT MET exit 2 — recorded as NOT MEASURED, then re-run, never as a pass); check:sdui-registration-pins OK, all 16 registrations present, after building the console plus its dependency closure (its first two runs were exit 2 'no console build to weigh' and a TS2307 unbuilt-closure error — both NOT MEASURED, not results). (7) LINT: eslint over all three changed source files, 0 errors (5 no-explicit-any WARNINGS in the new test matching its sibling page-header tests; lint.yml deliberately sets no --max-warnings). The narrowing is measured, not assumed, all three pieces: population is the diff itself via git diff --name-only; the count comes from --format json (3 files linted); and the config is not type-aware — eslint.config.js has zero projectService / parserOptions / project: occurrences against a live control of 10 'rules' hits in the same file — so with no config file in the diff no untouched file's verdict can move. The repo-wide farm is CI's run either way. CI convergence not awaited, per the standing dispatch contract.",
"mcp_calls": "12 — issue_read get + get_comments, create_pull_request, pull_request_read (body read-back), search_issues x2 (the second was the required control and it FAILED to hit, see open_questions), issue_write create x2, and this report's comments plus one read-back. Bulk reads went to git and to the zero-quota web payload channel instead.",
"open_questions": [
{
"question": "MECHANISM, not a code decision — the MCP `search_issues` channel appears blind in this container, and this seat's dedupe had to route around it. A targeted search for this card's own near-verbatim title returned total_count 0, i.e. the known-must-hit CONTROL failed, so its earlier empty result for my dedupe query was not a reading. Unauthenticated REST is also 403 here (both /repos/.../issues and /search/issues). Dedupe was completed on the zero-quota GitHub web payload channel with a live control that DID hit (query 'action-id lookup' returned 8 issue numbers including 6252; the two dedupe queries returned disjoint populations with no duplicate of either finding), so both findings are filed properly — but the next seat should know that search_issues answering 0 here means nothing.",
"options": [
"A — treat MCP search_issues as unavailable for this seat/container and make the web payload channel the documented dedupe route, control word mandatory",
"B — investigate why search_issues returns 0 for a query matching an issue it can read directly via issue_read (indexing lag vs. a scope/permission difference between the read and search paths)",
"C — do nothing; each dev re-discovers it per run"
],
"recommendation": "A plus B. A is cheap and immediate — the web channel is measured working here, costs no GraphQL quota, and the control discipline the dispatch already mandates is exactly what caught this. B matters because a silently-empty search is the worst failure shape available to a dedupe step: it looks like 'no duplicate exists' and it is unfalsifiable without a control, so a seat that skips the control files duplicates and never learns."
}
],
"out_of_scope_findings": [
"filed as objectui#7182: the action-id lookup now exists twice (record-quick-actions.tsx and containers.tsx) and the two copies DISAGREE on mixed arrays — quick_actions switches on the whole array (every element a string), page:header normalises per element — so during the migration this card opens, one authored array means two things depending on which renderer draws it. Not fixed here: unifying them changes record:quick_actions behaviour on a renderer this card does not own, and wants a ruling on whether a mixed array is a supported migration state.",
"filed as objectui#7183: no committed test can observe its own package's BUILT artifact — the root vitest alias sends every workspace specifier to src, and turbo's test task is dependsOn ['^build'] (dependencies only), so a dist-importing test is MODULE_NOT_FOUND / NOT MEASURED in CI. This card's acceptance criterion 3 explicitly asks for a built-artifact measurement; it was run by hand with a live control and passes, but it cannot be left behind as a standing pin."
]
}

Generated by Claude Code

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

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

Verdict: ACCEPT. Arming on green. Two judgement calls are ruled below, and one finding in this report is bigger than this card.

⭐ The finding that outranks the PR: search_issues is blind in this container, and its zero is not a reading

"A targeted search for this card's own near-verbatim title returned total_count 0, i.e. the known-must-hit CONTROL failed, so its earlier empty result for my dedupe query was not a reading."

This is the most valuable thing in the report and it is not about page:header at all. A silently-empty search is the worst failure shape available to a dedupe step: it renders as "no duplicate exists", it is indistinguishable from a real negative, and it is unfalsifiable without a control. A seat that skips the control files duplicates and never finds out.

It is also the seat's own instrument rule — a control that returns zero is no control; an empty output is not a 0 — applied one level up, to a channel rather than a grep. That generalisation is worth more than the rule it came from, and I am adopting it: the control belongs on the channel, not only on the query. Recording it as its own card rather than leaving it in a PR body, because it applies to every dev in this container and nothing in the repo currently says so. Dedupe here was completed on the zero-quota web payload channel with a control that did hit (action-id lookup → 8 issue numbers including #6252), so both out-of-scope findings are properly filed.

⚠️ Noting for the record that this does not impugn search_code, which this seat used successfully an hour ago (13 hits on 13855, with the returned paths independently confirmed against a local checkout). The blindness is specific to the issue-search path, which is exactly why a per-channel control is the answer rather than a blanket distrust.

A2.2 was my low-confidence assumption, and confirming it was worth the cost

I flagged a possible second page:header mount because #7021 found the record-title key in three spellings. Measured: exactly one canonical registration (ComponentRegistry.register('header', PageHeaderRenderer, {namespace:'page', skipFallback:true})), packages/layout/src/index.ts deliberately does not re-register it and says so, and the legacy page-header alias already resolves ids by delegating to record:quick_actions.

⭐ That last clause reframes the card: the sibling that already implements this ruling is the alias, not a second canonical mount. The acceptance criterion was not under-specified, and the hazard did not fire. A confirmed assumption that closes a named risk is a result, not a formality.

Ruling 1 — the built-artifact measurement was run and deliberately NOT committed: upheld

Acceptance criterion 3 ("the id path carries no body.source into the built artifact") was measured properly — the built packages/components/dist/index.js mounted by explicit relative import, an id-authored header whose resolved action carries an OS6252_DIST_BODY_MARKER body, marker absent from the rendered DOM, authored node byte-identical before and after render, and a live control proving the passing run measured the bundle (removing that one import fails expected undefined to be truthy, because page:header is unregistered in the light dom project).

And then it was not committed, because turbo's test task is dependsOn ['^build']dependencies only — so a committed dist-importing test would be MODULE_NOT_FOUND / NOT MEASURED in CI.

That is the right call and I want the reasoning preserved: a test that reports NOT MEASURED in CI is worse than no test, because it renders as coverage. Shipping it would have put a permanently-void assertion into the suite under the name of this card's hardest acceptance criterion. Filed as #7183 instead, with the committed half being the pin that can fail — "never writes a resolved def back onto the authored node" — carrying the object-shape authoring as its live control, since the inline form genuinely does drag the handler body into what it serializes. That control is what makes the id-side assertion falsifiable at all.

Ruling 2 — #7182, the divergence this card created: file, do not unify. Grading raised.

The action-id lookup now exists in two places and they disagree on mixed arrays: record:quick_actions switches on the whole array (every element a string), page:header normalises per element. So during the migration this card opens, one authored array means two things depending on which renderer draws it.

I asked for "no second resolver" and that fence held — the metadata read goes through the same useMetadataItem entry. What diverges is the array normalisation policy, which is a different thing and was not covered. ⚠️ But it is a real new dialect in a repo actively paying down dialects (#6771 is retiring one this hour), and unlike most findings it was introduced here rather than discovered. Declining to unify was still correct: it would change record:quick_actions behaviour on a renderer this card does not own, and whether a mixed array is a supported migration state is a ruling, not an implementation detail.

⇒ I am treating #7182 as the completion condition of this migration, not a loose end, and will grade it accordingly rather than letting it sit as an ordinary finding.

Also correct

  • Clause ② fence honoured — spec type and zod mirror untouched, actions stays z.array(z.string()). The renderer catches up to a declaration that was already public; nothing widens.
  • Unresolvable id renders nothing and warns ONCE, naming the object's declared action names, suppressed while the metadata read is in flight so a correct page never warns on first paint. That is the loud-over-silent option, and the in-flight suppression is the detail that makes it usable rather than noisy.
  • Equivalence proved by the same metadata authored twice — ids and inline objects — compared on the ordered button-name list and a normalised innerHTML projection, with the object-shape render asserted non-empty first, so an id-side green cannot come from two empty headers agreeing. One assertion covers every filter the acceptance criterion names, including qualify (order 1) rendering beforeconvert (order 2) — the reverse of the order both authorings list them in.
  • Ablation: Tests 7 failed | 1 passed (8), matching the 7/1 prediction exactly, with the single immovable case identified ("does not warn while the metadata lookup is still in flight" is true with or without resolution) — which is what distinguishes a targeted break from a collapsed population.
  • Two gates correctly reported NOT MEASURED on first run (check:doc-snippets exit 2 PRECONDITION NOT MET; check:sdui-registration-pins exit 2 and a TS2307 unbuilt closure) and re-run to real verdicts rather than scored as passes.
  • Docs updated at content/docs/guide/slotted-pages.md, which taught only the object form. ⛔ content/docs/releases/ untouched, correctly.

Nothing to change.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 10:05
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 8ec11e1Sep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-6252-page-header-action-ids branch September 1, 2026 10:19
Sign up for freeto 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.

page:header implements the declared action-id lookup — resolve actions: string[] like record:quick_actions does (objectstack#11592 ruling)

1 participant

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(components): page:header resolves declared action ids - #7180

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-6252-page-header-action-ids
Sep 1, 2026
Merged

fix(components): page:header resolves declared action ids#7180
os-warren merged 2 commits into
mainfrom
claude/issue-6252-page-header-action-ids

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#6252

Implements the objectstack#11592 ruling (maintainer, 2026-08-25, on recommendation B): the canonical page:header renderer resolves PageHeaderProps.actions as action ids.

The defect

@objectstack/spec declares actions: z.array(z.string()).optional().describe('Action IDs to show in header') (verified on the installed pin, @objectstack/spec@17.2.0, src/ui/component.zod.ts). The renderer read that array as ActionDefobjects and resolved nothing, so a header authored the way the published contract declares rendered zero buttons — satisfying the schema deleted the header.

Two sibling surfaces already read it as ids: record:quick_actions (packages/plugin-detail/src/renderers/record-quick-actions.tsx) resolves a string-valued actions out of the object's metadata, and layout:page-header reaches the same resolver by delegating its actions into a record:quick_actions node (packages/layout/src/PageHeader.tsx:196-208). So one authoring key meant two different things depending on which renderer drew the header.

What changed

packages/components/src/renderers/layout/containers.tsx:

  • Each element of actions is normalised at the top of the pipeline — a string is resolved against the object's own actions metadata, an object passes through. Resolution goes through the same useMetadataItem entryrecord:quick_actions uses; no second resolver, no second lookup path.
  • Because normalisation happens above the filters, one chain still runs: actionRendersAt placement (record_header / record_more), the requiredPermissions capability gate, visible / hidden, order, and the inline/overflow split are untouched by this change. That is what makes the equivalence claim testable at all.
  • An id that resolves to nothing renders nothing and warns once, naming the object's declared action names. Silently dropping it would reproduce the silent-loss class this repo keeps re-filing; it also makes a mistyped id indistinguishable from a correctly hidden one. The warning is suppressed while the metadata read is in flight, so a correctly-authored page does not warn on first paint.
  • The registered actions input description now says ids.

The one deliberate difference from record:quick_actions: that renderer switches on the whole array (rawActions.every(a =&gt; typeof a === 'string')); this one normalises per element, so a half-migrated ['convert', { … }] resolves the id and passes the object through. Same mechanism, wider arity — a page mid-migration is exactly the state this card creates.

Clause ② is not engaged. The declared type and its zod mirror are untouched — actions stays z.array(z.string()). The object shape survives as renderer tolerance, exactly as it does today, undeclared. No accept/reject behaviour of the contract moves.

Docs: content/docs/guide/slotted-pages.md now teaches the ids contract on the canonical header (it previously showed only the object form), with the inline-object form named as migration tolerance.

Acceptance criteria — evidence

Run at af40cf5c2 (working tree clean at that commit, so every run below read exactly this tree).

1. An id-authored header renders the same buttons as the object-shape authoring.

The same action metadata is authored twice and the two renders compared. The object-shape render is the live control: each equivalence case asserts it is non-empty first, so an id-side green can never come from two empty headers agreeing.

The population covers every filter the criterion names, so one equivalence assertion measures all of them: list_only (list_item — must not render), archive (record_more — must land in the ⋯ menu, not inline), gated (requiredPermissions the user lacks), closed_only (a CEL visible false for this record), and qualify (order: 1) rendering beforeconvert (order: 2), which is the reverse of the order both authorings list them in. Both the ordered button-name list and a normalised innerHTML projection are asserted equal.

2. The renderer's tests gain the id-shaped cases.packages/components/src/__tests__/page-header-action-ids.test.tsx — 8 cases (equivalence, the filter chain, record_more routing, unresolved-id warn-once with a resolvable sibling as control, no-warn-while-loading, mixed arrays, the properties.actions bridge spelling, and criterion 3).

pnpm exec vitest run --project dom-heavy packages/components/src/__tests__/page-header-action-ids.test.tsx
Test Files 1 passed (1) Tests 8 passed (8)

Ablation — direction predicted before running: red on the id path, green on the object-shape control. Mutation = feeding rawHeaderActions back into the filter chain (the minimal deletion of the fix). Proven on disk by marker count and blob hash, never by an editor's exit code:

PRE target-count=1 inject-count=0 PRE_HASH=3dafeb94… (== HEAD blob)
POST target-count=0 inject-count=1 POST_HASH=f87a37c9…
Tests 7 failed | 1 passed (8)

7 failed / 1 passed matched the prediction exactly: the only case that cannot move is "does not warn while the metadata lookup is still in flight", which is true with or without resolution. Restore proven by state, not exit code: restored blob 3dafeb94… equals the HEAD blob, and git diff HEAD, git diff --cached, git status --short are all empty. The mutation and restore legs need no rebuild here: the root vitest.config.mts aliases @object-ui/components to packages/components/src, so this suite resolves source, not dist.

3. The id path carries no body.source into the built artifact.

Measured two ways, and the built-artifact one is the load-bearing half.

Against the built artifact.pnpm --filter @object-ui/components build, then a run that mounts the builtpackages/components/dist/index.js (an explicit relative import — the workspace alias would otherwise redirect to src) with an id-authored header whose resolved action carries body: { language: 'js', source: '…OS6252_DIST_BODY_MARKER…' }. The id resolved through the built renderer, the marker appears nowhere in the rendered DOM, and the authored node's JSON.stringify is byte-identical before and after render. Live control: with that one import removed the same file fails expected undefined to be truthypage:header is unregistered in the light dom project, which proves the passing run measured the built bundle and nothing else. This measurement is not committed: turbo's test task is dependsOn: ["^build"] (dependencies only, not the package's own build), so a committed dist-importing test would be NOT MEASURED in CI rather than a pin. Filed as a follow-up.

As a committed pin.never writes a resolved def — and so no body.source — back onto the authored node asserts the authored node is unchanged by rendering and the marker is absent from the DOM, with the object-shape authoring as the live control: expect(JSON.stringify(objectAuthored)).toContain(BODY_MARKER) — the inline form does drag the handler body into what it serializes, which is what proves this assertion can fail at all.

Grep of the built bundle, with controls: did not resolve = 1 and page:header = 7 (present), OS6252_HANDLER_BODY_MARKER = 0.

Other verification

RunResult
vitest run packages/components scripts/__tests__/vitest-invocation-guard.test.ts224 files, 2070 passed
all page-header* + RecordDetailView.header* / predicate suites16 files, 184 passed
console registry/contract parity (5 files incl. registry-inputs-spec-parity, public-contract)156 passed
pnpm --filter @object-ui/components run type-checkexit 0 — and it covers the new test file (tsc -p tsconfig.test.json reported errors in it before they were fixed)
pnpm check:control-bytes✅ 5945 tracked text files
pnpm check:doc-types✅ every documented component type registered
pnpm check:doc-fences
pnpm check:doc-snippets✅ 272/272 blocks judged, 0 failed (after building its declared closure; its own resolution/sentinel/positive controls printed)
pnpm check:sdui-registration-pins✅ all 16 registrations present (after building the console + its closure)

eslint on all three changed source files: 0 errors (5 pre-existing-style no-explicit-any warnings in the new test, matching its sibling page-header tests; --max-warnings is deliberately unset in this repo). The lint run is narrowed and the narrowing is measured, not assumed: the population is the diff itself (git diff --name-only), the count comes from --format json (3 files linted), and the config is not type-aware — eslint.config.js contains zero projectService / parserOptions / project: occurrences against a live control of 10 rules hits in the same file — so no untouched file's verdict can move under this diff, which touches no config. The repo-wide farm is CI's run either way.

Not in this PR

hotcrm's migration of its 16 inline record_header objects to id references is downstream of this landing, as the card records. Nothing here touches it.


Generated by Claude Code

os-warrenand others added 2 commits September 1, 2026 09:24
`@objectstack/spec`'s `PageHeaderProps.actions` is `z.array(z.string())` —
"Action IDs to show in header" — but the canonical `page:header` renderer read
the array as ActionDef objects and resolved nothing, so metadata satisfying the
published contract rendered zero header buttons.
Resolve ids against the object's own metadata at the top of the actions
pipeline, through the same `useMetadataItem` entry `record:quick_actions` uses,
so the existing placement / capability / visible / order chain runs unchanged
over uniformly-shaped defs. Inline objects keep working as renderer tolerance
for the transition, per element, so a half-migrated array resolves.
An id that resolves to nothing warns once and renders nothing, naming the
object's declared action names.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
Document the contract the renderer now honours: the header names actions by id
and resolves them against the object's own metadata, with the inline-object
form named as migration tolerance rather than a second declared shape.
Adds the changeset and types the console-warning assertions in the new pin.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

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

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


📦 Bundle Size Report

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

Size Limits

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

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

os-dev-report

Same report as #6252 (comment 5492213936); posted here too because the dispatch asked for it on the PR.

{
"issue": 6252,
"status": "done",
"branch": "claude/issue-6252-page-header-action-ids",
"pr": "https://github.com/objectstack-ai/objectui/pull/7180",
"premise_still_valid": true,
"summary": "The canonical page:header renderer (packages/components/src/renderers/layout/containers.tsx) now resolves each element of `actions` as a declared action id against the object's own metadata, through the same useMetadataItem entry record:quick_actions uses — no second resolver. Normalisation sits at the TOP of the actions pipeline, so the existing single filter chain (actionRendersAt placement, requiredPermissions capability gate, visible/hidden, order, inline/overflow split) runs unchanged over uniformly-shaped defs; that is what makes the equivalence claim measurable. Inline ActionDef objects keep working per element, so a half-migrated ['convert', {def}] array resolves the id and passes the object through — renderer tolerance for the migration, still undeclared. An id that resolves to nothing renders nothing and warns ONCE naming the object's declared action names (suppressed while the metadata read is in flight, so a correct page never warns on first paint); silently dropping it would reproduce the silent-loss class this seat keeps filing. Clause 2 fence honoured: the spec type and its zod mirror are untouched, actions stays z.array(z.string()). Docs updated (content/docs/guide/slotted-pages.md taught only the object form) plus a minor changeset. ZONE 2 VERDICTS, all re-measured on this repo's head: A2.1 CONFIRMED (the pre-fix chain read .locations/.requiredPermissions/.visible/.order straight off each array element via actionRendersAt, no lookup anywhere). A2.2 CONFIRMED — exactly ONE canonical page:header registration, ComponentRegistry.register('header', PageHeaderRenderer, {namespace:'page', skipFallback:true}) in containers.tsx; packages/layout/src/index.ts deliberately does NOT re-register it and says so, and the legacy `page-header` alias already resolves ids by delegating to record:quick_actions — so the multi-spelling hazard the PM flagged does not fire here, and the sibling that already implements the ruling is the alias, not a second canonical mount. A2.3 CONFIRMED, cheap. A2.4 CONFIRMED — nothing pinned the rendered result of a string-authored page:header; the only two id-shaped fixtures in the repo pin VALIDATION only (examples/schema-catalog pins that actions:['export'] validates clean, which supports ids), so nothing had to be updated or deleted. A2.5 CONFIRMED with no seam: @object-ui/components already depends on @object-ui/react and already imports eight hooks from it, so useMetadataItem was reachable with no new dependency and no second context scope.",
"tests": "All runs at final commit af40cf5c2 with a clean working tree (git status --short empty), so every figure below read exactly that tree. Heavy runs went through the container's shared verify lock; verdicts read from its VERDICT line, exit codes captured before any pipe. (1) NEW PIN packages/components/src/__tests__/page-header-action-ids.test.tsx, registered in vitest.config.mts heavyDomTests (required — it renders through the ComponentRegistry): 'pnpm exec vitest run --project dom-heavy ...' gives Test Files 1 passed (1), Tests 8 passed (8). Shape of the proof: the SAME action metadata authored twice, once as ids and once as inline objects, both renders compared on the ordered button-name list AND a normalised innerHTML projection; the object-shape render is the LIVE CONTROL and is asserted non-empty first, so an id-side green cannot come from two empty headers agreeing. The population makes one equivalence assertion cover every filter the acceptance criterion names: list_item-only must not render, record_more must route to the overflow menu and never inline, a requiredPermissions the user lacks is denied, a CEL visible false for the record hides, and qualify (order 1) renders BEFORE convert (order 2) — the reverse of the order both authorings list them in. (2) ABLATION, direction predicted before running (red on the id path, green on the object-shape control). Mutation = feeding rawHeaderActions back into the filter chain. Proven on disk by BOTH marker count and blob hash, never by an editor's exit code: PRE target-count=1 inject-count=0 PRE_HASH=3dafeb94 equal to the HEAD blob; POST target-count=0 inject-count=1 POST_HASH=f87a37c9. Result: Tests 7 failed | 1 passed (8) — passing count asserted alongside the red, and 7/1 matched the prediction exactly (the one immovable case is 'does not warn while the metadata lookup is still in flight', true with or without resolution, so this is a targeted break and not a collapsed population). Restore proven by STATE not exit code: restored blob 3dafeb94 equals the HEAD blob, and git diff HEAD / git diff --cached / git status --short are all empty; the script carried an EXIT/INT/TERM trap with an absolute path. No rebuild needed on either leg and this was measured, not assumed: the root vitest.config.mts aliases @object-ui/components to packages/components/src, so the suite resolves SOURCE, never dist. (3) ACCEPTANCE CRITERION 3, measured against the BUILT ARTIFACT as the card demands. pnpm --filter @object-ui/components build, then a run mounting the built packages/components/dist/index.js by explicit relative import (the workspace alias would otherwise redirect to src) with an id-authored header whose resolved action carries body:{language:'js',source:'...OS6252_DIST_BODY_MARKER...'}: the id resolved through the BUILT renderer, the marker appears nowhere in the rendered DOM, and the authored node's JSON.stringify is byte-identical before and after render. LIVE CONTROL: with that one import removed the same file fails 'expected undefined to be truthy' (page:header is unregistered in the light dom project), which proves the passing run measured the built bundle and nothing else. That measurement is deliberately NOT COMMITTED — turbo's test task is dependsOn ['^build'], dependencies only, so a committed dist-importing test would be NOT MEASURED in CI rather than a pin; filed as objectui#7183. The committed half is the pin 'never writes a resolved def — and so no body.source — back onto the authored node', with the object-shape authoring as its live control: expect(JSON.stringify(objectAuthored)).toContain(BODY_MARKER) — the inline form DOES drag the handler body into what it serializes, which is what proves the id-side assertion can fail at all. Built-bundle greps with controls: 'did not resolve'=1 and 'page:header'=7 present, 'OS6252_HANDLER_BODY_MARKER'=0. (4) REGRESSION SURFACE: 'vitest run packages/components scripts/__tests__/vitest-invocation-guard.test.ts' gives 224 files, 2070 passed; all page-header* plus RecordDetailView.header*/predicate suites give 16 files, 184 passed; console registry/contract parity (registry-inputs-spec-parity, public-contract, ga-honoured-inputs-author-reach, component-input-union-specimens, record-block-record-reach) gives 156 passed. (5) TYPES: 'pnpm --filter @object-ui/components run type-check' exit 0 — note the script is spelled type-check (hyphen) in this repo — and it demonstrably COVERS the new test file: tsc -p tsconfig.test.json reported four TS7006 errors inside it before they were fixed, so this is not a typecheck that excludes tests. (6) GATES: check:control-bytes OK (5945 tracked text files); check:doc-types OK; check:doc-fences OK; check:doc-snippets OK, 272/272 blocks judged 0 failed, after building the closure it names (its first run was PRECONDITION NOT MET exit 2 — recorded as NOT MEASURED, then re-run, never as a pass); check:sdui-registration-pins OK, all 16 registrations present, after building the console plus its dependency closure (its first two runs were exit 2 'no console build to weigh' and a TS2307 unbuilt-closure error — both NOT MEASURED, not results). (7) LINT: eslint over all three changed source files, 0 errors (5 no-explicit-any WARNINGS in the new test matching its sibling page-header tests; lint.yml deliberately sets no --max-warnings). The narrowing is measured, not assumed, all three pieces: population is the diff itself via git diff --name-only; the count comes from --format json (3 files linted); and the config is not type-aware — eslint.config.js has zero projectService / parserOptions / project: occurrences against a live control of 10 'rules' hits in the same file — so with no config file in the diff no untouched file's verdict can move. The repo-wide farm is CI's run either way. CI convergence not awaited, per the standing dispatch contract.",
"mcp_calls": "12 — issue_read get + get_comments, create_pull_request, pull_request_read (body read-back), search_issues x2 (the second was the required control and it FAILED to hit, see open_questions), issue_write create x2, and this report's comments plus one read-back. Bulk reads went to git and to the zero-quota web payload channel instead.",
"open_questions": [
{
"question": "MECHANISM, not a code decision — the MCP `search_issues` channel appears blind in this container, and this seat's dedupe had to route around it. A targeted search for this card's own near-verbatim title returned total_count 0, i.e. the known-must-hit CONTROL failed, so its earlier empty result for my dedupe query was not a reading. Unauthenticated REST is also 403 here (both /repos/.../issues and /search/issues). Dedupe was completed on the zero-quota GitHub web payload channel with a live control that DID hit (query 'action-id lookup' returned 8 issue numbers including 6252; the two dedupe queries returned disjoint populations with no duplicate of either finding), so both findings are filed properly — but the next seat should know that search_issues answering 0 here means nothing.",
"options": [
"A — treat MCP search_issues as unavailable for this seat/container and make the web payload channel the documented dedupe route, control word mandatory",
"B — investigate why search_issues returns 0 for a query matching an issue it can read directly via issue_read (indexing lag vs. a scope/permission difference between the read and search paths)",
"C — do nothing; each dev re-discovers it per run"
],
"recommendation": "A plus B. A is cheap and immediate — the web channel is measured working here, costs no GraphQL quota, and the control discipline the dispatch already mandates is exactly what caught this. B matters because a silently-empty search is the worst failure shape available to a dedupe step: it looks like 'no duplicate exists' and it is unfalsifiable without a control, so a seat that skips the control files duplicates and never learns."
}
],
"out_of_scope_findings": [
"filed as objectui#7182: the action-id lookup now exists twice (record-quick-actions.tsx and containers.tsx) and the two copies DISAGREE on mixed arrays — quick_actions switches on the whole array (every element a string), page:header normalises per element — so during the migration this card opens, one authored array means two things depending on which renderer draws it. Not fixed here: unifying them changes record:quick_actions behaviour on a renderer this card does not own, and wants a ruling on whether a mixed array is a supported migration state.",
"filed as objectui#7183: no committed test can observe its own package's BUILT artifact — the root vitest alias sends every workspace specifier to src, and turbo's test task is dependsOn ['^build'] (dependencies only), so a dist-importing test is MODULE_NOT_FOUND / NOT MEASURED in CI. This card's acceptance criterion 3 explicitly asks for a built-artifact measurement; it was run by hand with a live control and passes, but it cannot be left behind as a standing pin."
]
}

Generated by Claude Code

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

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

Verdict: ACCEPT. Arming on green. Two judgement calls are ruled below, and one finding in this report is bigger than this card.

⭐ The finding that outranks the PR: search_issues is blind in this container, and its zero is not a reading

"A targeted search for this card's own near-verbatim title returned total_count 0, i.e. the known-must-hit CONTROL failed, so its earlier empty result for my dedupe query was not a reading."

This is the most valuable thing in the report and it is not about page:header at all. A silently-empty search is the worst failure shape available to a dedupe step: it renders as "no duplicate exists", it is indistinguishable from a real negative, and it is unfalsifiable without a control. A seat that skips the control files duplicates and never finds out.

It is also the seat's own instrument rule — a control that returns zero is no control; an empty output is not a 0 — applied one level up, to a channel rather than a grep. That generalisation is worth more than the rule it came from, and I am adopting it: the control belongs on the channel, not only on the query. Recording it as its own card rather than leaving it in a PR body, because it applies to every dev in this container and nothing in the repo currently says so. Dedupe here was completed on the zero-quota web payload channel with a control that did hit (action-id lookup → 8 issue numbers including #6252), so both out-of-scope findings are properly filed.

⚠️ Noting for the record that this does not impugn search_code, which this seat used successfully an hour ago (13 hits on 13855, with the returned paths independently confirmed against a local checkout). The blindness is specific to the issue-search path, which is exactly why a per-channel control is the answer rather than a blanket distrust.

A2.2 was my low-confidence assumption, and confirming it was worth the cost

I flagged a possible second page:header mount because #7021 found the record-title key in three spellings. Measured: exactly one canonical registration (ComponentRegistry.register('header', PageHeaderRenderer, {namespace:'page', skipFallback:true})), packages/layout/src/index.ts deliberately does not re-register it and says so, and the legacy page-header alias already resolves ids by delegating to record:quick_actions.

⭐ That last clause reframes the card: the sibling that already implements this ruling is the alias, not a second canonical mount. The acceptance criterion was not under-specified, and the hazard did not fire. A confirmed assumption that closes a named risk is a result, not a formality.

Ruling 1 — the built-artifact measurement was run and deliberately NOT committed: upheld

Acceptance criterion 3 ("the id path carries no body.source into the built artifact") was measured properly — the built packages/components/dist/index.js mounted by explicit relative import, an id-authored header whose resolved action carries an OS6252_DIST_BODY_MARKER body, marker absent from the rendered DOM, authored node byte-identical before and after render, and a live control proving the passing run measured the bundle (removing that one import fails expected undefined to be truthy, because page:header is unregistered in the light dom project).

And then it was not committed, because turbo's test task is dependsOn ['^build']dependencies only — so a committed dist-importing test would be MODULE_NOT_FOUND / NOT MEASURED in CI.

That is the right call and I want the reasoning preserved: a test that reports NOT MEASURED in CI is worse than no test, because it renders as coverage. Shipping it would have put a permanently-void assertion into the suite under the name of this card's hardest acceptance criterion. Filed as #7183 instead, with the committed half being the pin that can fail — "never writes a resolved def back onto the authored node" — carrying the object-shape authoring as its live control, since the inline form genuinely does drag the handler body into what it serializes. That control is what makes the id-side assertion falsifiable at all.

Ruling 2 — #7182, the divergence this card created: file, do not unify. Grading raised.

The action-id lookup now exists in two places and they disagree on mixed arrays: record:quick_actions switches on the whole array (every element a string), page:header normalises per element. So during the migration this card opens, one authored array means two things depending on which renderer draws it.

I asked for "no second resolver" and that fence held — the metadata read goes through the same useMetadataItem entry. What diverges is the array normalisation policy, which is a different thing and was not covered. ⚠️ But it is a real new dialect in a repo actively paying down dialects (#6771 is retiring one this hour), and unlike most findings it was introduced here rather than discovered. Declining to unify was still correct: it would change record:quick_actions behaviour on a renderer this card does not own, and whether a mixed array is a supported migration state is a ruling, not an implementation detail.

⇒ I am treating #7182 as the completion condition of this migration, not a loose end, and will grade it accordingly rather than letting it sit as an ordinary finding.

Also correct

  • Clause ② fence honoured — spec type and zod mirror untouched, actions stays z.array(z.string()). The renderer catches up to a declaration that was already public; nothing widens.
  • Unresolvable id renders nothing and warns ONCE, naming the object's declared action names, suppressed while the metadata read is in flight so a correct page never warns on first paint. That is the loud-over-silent option, and the in-flight suppression is the detail that makes it usable rather than noisy.
  • Equivalence proved by the same metadata authored twice — ids and inline objects — compared on the ordered button-name list and a normalised innerHTML projection, with the object-shape render asserted non-empty first, so an id-side green cannot come from two empty headers agreeing. One assertion covers every filter the acceptance criterion names, including qualify (order 1) rendering beforeconvert (order 2) — the reverse of the order both authorings list them in.
  • Ablation: Tests 7 failed | 1 passed (8), matching the 7/1 prediction exactly, with the single immovable case identified ("does not warn while the metadata lookup is still in flight" is true with or without resolution) — which is what distinguishes a targeted break from a collapsed population.
  • Two gates correctly reported NOT MEASURED on first run (check:doc-snippets exit 2 PRECONDITION NOT MET; check:sdui-registration-pins exit 2 and a TS2307 unbuilt closure) and re-run to real verdicts rather than scored as passes.
  • Docs updated at content/docs/guide/slotted-pages.md, which taught only the object form. ⛔ content/docs/releases/ untouched, correctly.

Nothing to change.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 10:05
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 8ec11e1Sep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-6252-page-header-action-ids branch September 1, 2026 10:19
Sign up for freeto 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.

page:header implements the declared action-id lookup — resolve actions: string[] like record:quick_actions does (objectstack#11592 ruling)

1 participant

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(components): page:header resolves declared action ids - #7180

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-6252-page-header-action-ids
Sep 1, 2026
Merged

fix(components): page:header resolves declared action ids#7180
os-warren merged 2 commits into
mainfrom
claude/issue-6252-page-header-action-ids

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#6252

Implements the objectstack#11592 ruling (maintainer, 2026-08-25, on recommendation B): the canonical page:header renderer resolves PageHeaderProps.actions as action ids.

The defect

@objectstack/spec declares actions: z.array(z.string()).optional().describe('Action IDs to show in header') (verified on the installed pin, @objectstack/spec@17.2.0, src/ui/component.zod.ts). The renderer read that array as ActionDefobjects and resolved nothing, so a header authored the way the published contract declares rendered zero buttons — satisfying the schema deleted the header.

Two sibling surfaces already read it as ids: record:quick_actions (packages/plugin-detail/src/renderers/record-quick-actions.tsx) resolves a string-valued actions out of the object's metadata, and layout:page-header reaches the same resolver by delegating its actions into a record:quick_actions node (packages/layout/src/PageHeader.tsx:196-208). So one authoring key meant two different things depending on which renderer drew the header.

What changed

packages/components/src/renderers/layout/containers.tsx:

  • Each element of actions is normalised at the top of the pipeline — a string is resolved against the object's own actions metadata, an object passes through. Resolution goes through the same useMetadataItem entryrecord:quick_actions uses; no second resolver, no second lookup path.
  • Because normalisation happens above the filters, one chain still runs: actionRendersAt placement (record_header / record_more), the requiredPermissions capability gate, visible / hidden, order, and the inline/overflow split are untouched by this change. That is what makes the equivalence claim testable at all.
  • An id that resolves to nothing renders nothing and warns once, naming the object's declared action names. Silently dropping it would reproduce the silent-loss class this repo keeps re-filing; it also makes a mistyped id indistinguishable from a correctly hidden one. The warning is suppressed while the metadata read is in flight, so a correctly-authored page does not warn on first paint.
  • The registered actions input description now says ids.

The one deliberate difference from record:quick_actions: that renderer switches on the whole array (rawActions.every(a =&gt; typeof a === 'string')); this one normalises per element, so a half-migrated ['convert', { … }] resolves the id and passes the object through. Same mechanism, wider arity — a page mid-migration is exactly the state this card creates.

Clause ② is not engaged. The declared type and its zod mirror are untouched — actions stays z.array(z.string()). The object shape survives as renderer tolerance, exactly as it does today, undeclared. No accept/reject behaviour of the contract moves.

Docs: content/docs/guide/slotted-pages.md now teaches the ids contract on the canonical header (it previously showed only the object form), with the inline-object form named as migration tolerance.

Acceptance criteria — evidence

Run at af40cf5c2 (working tree clean at that commit, so every run below read exactly this tree).

1. An id-authored header renders the same buttons as the object-shape authoring.

The same action metadata is authored twice and the two renders compared. The object-shape render is the live control: each equivalence case asserts it is non-empty first, so an id-side green can never come from two empty headers agreeing.

The population covers every filter the criterion names, so one equivalence assertion measures all of them: list_only (list_item — must not render), archive (record_more — must land in the ⋯ menu, not inline), gated (requiredPermissions the user lacks), closed_only (a CEL visible false for this record), and qualify (order: 1) rendering beforeconvert (order: 2), which is the reverse of the order both authorings list them in. Both the ordered button-name list and a normalised innerHTML projection are asserted equal.

2. The renderer's tests gain the id-shaped cases.packages/components/src/__tests__/page-header-action-ids.test.tsx — 8 cases (equivalence, the filter chain, record_more routing, unresolved-id warn-once with a resolvable sibling as control, no-warn-while-loading, mixed arrays, the properties.actions bridge spelling, and criterion 3).

pnpm exec vitest run --project dom-heavy packages/components/src/__tests__/page-header-action-ids.test.tsx
Test Files 1 passed (1) Tests 8 passed (8)

Ablation — direction predicted before running: red on the id path, green on the object-shape control. Mutation = feeding rawHeaderActions back into the filter chain (the minimal deletion of the fix). Proven on disk by marker count and blob hash, never by an editor's exit code:

PRE target-count=1 inject-count=0 PRE_HASH=3dafeb94… (== HEAD blob)
POST target-count=0 inject-count=1 POST_HASH=f87a37c9…
Tests 7 failed | 1 passed (8)

7 failed / 1 passed matched the prediction exactly: the only case that cannot move is "does not warn while the metadata lookup is still in flight", which is true with or without resolution. Restore proven by state, not exit code: restored blob 3dafeb94… equals the HEAD blob, and git diff HEAD, git diff --cached, git status --short are all empty. The mutation and restore legs need no rebuild here: the root vitest.config.mts aliases @object-ui/components to packages/components/src, so this suite resolves source, not dist.

3. The id path carries no body.source into the built artifact.

Measured two ways, and the built-artifact one is the load-bearing half.

Against the built artifact.pnpm --filter @object-ui/components build, then a run that mounts the builtpackages/components/dist/index.js (an explicit relative import — the workspace alias would otherwise redirect to src) with an id-authored header whose resolved action carries body: { language: 'js', source: '…OS6252_DIST_BODY_MARKER…' }. The id resolved through the built renderer, the marker appears nowhere in the rendered DOM, and the authored node's JSON.stringify is byte-identical before and after render. Live control: with that one import removed the same file fails expected undefined to be truthypage:header is unregistered in the light dom project, which proves the passing run measured the built bundle and nothing else. This measurement is not committed: turbo's test task is dependsOn: ["^build"] (dependencies only, not the package's own build), so a committed dist-importing test would be NOT MEASURED in CI rather than a pin. Filed as a follow-up.

As a committed pin.never writes a resolved def — and so no body.source — back onto the authored node asserts the authored node is unchanged by rendering and the marker is absent from the DOM, with the object-shape authoring as the live control: expect(JSON.stringify(objectAuthored)).toContain(BODY_MARKER) — the inline form does drag the handler body into what it serializes, which is what proves this assertion can fail at all.

Grep of the built bundle, with controls: did not resolve = 1 and page:header = 7 (present), OS6252_HANDLER_BODY_MARKER = 0.

Other verification

RunResult
vitest run packages/components scripts/__tests__/vitest-invocation-guard.test.ts224 files, 2070 passed
all page-header* + RecordDetailView.header* / predicate suites16 files, 184 passed
console registry/contract parity (5 files incl. registry-inputs-spec-parity, public-contract)156 passed
pnpm --filter @object-ui/components run type-checkexit 0 — and it covers the new test file (tsc -p tsconfig.test.json reported errors in it before they were fixed)
pnpm check:control-bytes✅ 5945 tracked text files
pnpm check:doc-types✅ every documented component type registered
pnpm check:doc-fences
pnpm check:doc-snippets✅ 272/272 blocks judged, 0 failed (after building its declared closure; its own resolution/sentinel/positive controls printed)
pnpm check:sdui-registration-pins✅ all 16 registrations present (after building the console + its closure)

eslint on all three changed source files: 0 errors (5 pre-existing-style no-explicit-any warnings in the new test, matching its sibling page-header tests; --max-warnings is deliberately unset in this repo). The lint run is narrowed and the narrowing is measured, not assumed: the population is the diff itself (git diff --name-only), the count comes from --format json (3 files linted), and the config is not type-aware — eslint.config.js contains zero projectService / parserOptions / project: occurrences against a live control of 10 rules hits in the same file — so no untouched file's verdict can move under this diff, which touches no config. The repo-wide farm is CI's run either way.

Not in this PR

hotcrm's migration of its 16 inline record_header objects to id references is downstream of this landing, as the card records. Nothing here touches it.


Generated by Claude Code

os-warrenand others added 2 commits September 1, 2026 09:24
`@objectstack/spec`'s `PageHeaderProps.actions` is `z.array(z.string())` —
"Action IDs to show in header" — but the canonical `page:header` renderer read
the array as ActionDef objects and resolved nothing, so metadata satisfying the
published contract rendered zero header buttons.
Resolve ids against the object's own metadata at the top of the actions
pipeline, through the same `useMetadataItem` entry `record:quick_actions` uses,
so the existing placement / capability / visible / order chain runs unchanged
over uniformly-shaped defs. Inline objects keep working as renderer tolerance
for the transition, per element, so a half-migrated array resolves.
An id that resolves to nothing warns once and renders nothing, naming the
object's declared action names.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
Document the contract the renderer now honours: the header names actions by id
and resolves them against the object's own metadata, with the inline-object
form named as migration tolerance rather than a second declared shape.
Adds the changeset and types the console-warning assertions in the new pin.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

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

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


📦 Bundle Size Report

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

Size Limits

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

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

os-dev-report

Same report as #6252 (comment 5492213936); posted here too because the dispatch asked for it on the PR.

{
"issue": 6252,
"status": "done",
"branch": "claude/issue-6252-page-header-action-ids",
"pr": "https://github.com/objectstack-ai/objectui/pull/7180",
"premise_still_valid": true,
"summary": "The canonical page:header renderer (packages/components/src/renderers/layout/containers.tsx) now resolves each element of `actions` as a declared action id against the object's own metadata, through the same useMetadataItem entry record:quick_actions uses — no second resolver. Normalisation sits at the TOP of the actions pipeline, so the existing single filter chain (actionRendersAt placement, requiredPermissions capability gate, visible/hidden, order, inline/overflow split) runs unchanged over uniformly-shaped defs; that is what makes the equivalence claim measurable. Inline ActionDef objects keep working per element, so a half-migrated ['convert', {def}] array resolves the id and passes the object through — renderer tolerance for the migration, still undeclared. An id that resolves to nothing renders nothing and warns ONCE naming the object's declared action names (suppressed while the metadata read is in flight, so a correct page never warns on first paint); silently dropping it would reproduce the silent-loss class this seat keeps filing. Clause 2 fence honoured: the spec type and its zod mirror are untouched, actions stays z.array(z.string()). Docs updated (content/docs/guide/slotted-pages.md taught only the object form) plus a minor changeset. ZONE 2 VERDICTS, all re-measured on this repo's head: A2.1 CONFIRMED (the pre-fix chain read .locations/.requiredPermissions/.visible/.order straight off each array element via actionRendersAt, no lookup anywhere). A2.2 CONFIRMED — exactly ONE canonical page:header registration, ComponentRegistry.register('header', PageHeaderRenderer, {namespace:'page', skipFallback:true}) in containers.tsx; packages/layout/src/index.ts deliberately does NOT re-register it and says so, and the legacy `page-header` alias already resolves ids by delegating to record:quick_actions — so the multi-spelling hazard the PM flagged does not fire here, and the sibling that already implements the ruling is the alias, not a second canonical mount. A2.3 CONFIRMED, cheap. A2.4 CONFIRMED — nothing pinned the rendered result of a string-authored page:header; the only two id-shaped fixtures in the repo pin VALIDATION only (examples/schema-catalog pins that actions:['export'] validates clean, which supports ids), so nothing had to be updated or deleted. A2.5 CONFIRMED with no seam: @object-ui/components already depends on @object-ui/react and already imports eight hooks from it, so useMetadataItem was reachable with no new dependency and no second context scope.",
"tests": "All runs at final commit af40cf5c2 with a clean working tree (git status --short empty), so every figure below read exactly that tree. Heavy runs went through the container's shared verify lock; verdicts read from its VERDICT line, exit codes captured before any pipe. (1) NEW PIN packages/components/src/__tests__/page-header-action-ids.test.tsx, registered in vitest.config.mts heavyDomTests (required — it renders through the ComponentRegistry): 'pnpm exec vitest run --project dom-heavy ...' gives Test Files 1 passed (1), Tests 8 passed (8). Shape of the proof: the SAME action metadata authored twice, once as ids and once as inline objects, both renders compared on the ordered button-name list AND a normalised innerHTML projection; the object-shape render is the LIVE CONTROL and is asserted non-empty first, so an id-side green cannot come from two empty headers agreeing. The population makes one equivalence assertion cover every filter the acceptance criterion names: list_item-only must not render, record_more must route to the overflow menu and never inline, a requiredPermissions the user lacks is denied, a CEL visible false for the record hides, and qualify (order 1) renders BEFORE convert (order 2) — the reverse of the order both authorings list them in. (2) ABLATION, direction predicted before running (red on the id path, green on the object-shape control). Mutation = feeding rawHeaderActions back into the filter chain. Proven on disk by BOTH marker count and blob hash, never by an editor's exit code: PRE target-count=1 inject-count=0 PRE_HASH=3dafeb94 equal to the HEAD blob; POST target-count=0 inject-count=1 POST_HASH=f87a37c9. Result: Tests 7 failed | 1 passed (8) — passing count asserted alongside the red, and 7/1 matched the prediction exactly (the one immovable case is 'does not warn while the metadata lookup is still in flight', true with or without resolution, so this is a targeted break and not a collapsed population). Restore proven by STATE not exit code: restored blob 3dafeb94 equals the HEAD blob, and git diff HEAD / git diff --cached / git status --short are all empty; the script carried an EXIT/INT/TERM trap with an absolute path. No rebuild needed on either leg and this was measured, not assumed: the root vitest.config.mts aliases @object-ui/components to packages/components/src, so the suite resolves SOURCE, never dist. (3) ACCEPTANCE CRITERION 3, measured against the BUILT ARTIFACT as the card demands. pnpm --filter @object-ui/components build, then a run mounting the built packages/components/dist/index.js by explicit relative import (the workspace alias would otherwise redirect to src) with an id-authored header whose resolved action carries body:{language:'js',source:'...OS6252_DIST_BODY_MARKER...'}: the id resolved through the BUILT renderer, the marker appears nowhere in the rendered DOM, and the authored node's JSON.stringify is byte-identical before and after render. LIVE CONTROL: with that one import removed the same file fails 'expected undefined to be truthy' (page:header is unregistered in the light dom project), which proves the passing run measured the built bundle and nothing else. That measurement is deliberately NOT COMMITTED — turbo's test task is dependsOn ['^build'], dependencies only, so a committed dist-importing test would be NOT MEASURED in CI rather than a pin; filed as objectui#7183. The committed half is the pin 'never writes a resolved def — and so no body.source — back onto the authored node', with the object-shape authoring as its live control: expect(JSON.stringify(objectAuthored)).toContain(BODY_MARKER) — the inline form DOES drag the handler body into what it serializes, which is what proves the id-side assertion can fail at all. Built-bundle greps with controls: 'did not resolve'=1 and 'page:header'=7 present, 'OS6252_HANDLER_BODY_MARKER'=0. (4) REGRESSION SURFACE: 'vitest run packages/components scripts/__tests__/vitest-invocation-guard.test.ts' gives 224 files, 2070 passed; all page-header* plus RecordDetailView.header*/predicate suites give 16 files, 184 passed; console registry/contract parity (registry-inputs-spec-parity, public-contract, ga-honoured-inputs-author-reach, component-input-union-specimens, record-block-record-reach) gives 156 passed. (5) TYPES: 'pnpm --filter @object-ui/components run type-check' exit 0 — note the script is spelled type-check (hyphen) in this repo — and it demonstrably COVERS the new test file: tsc -p tsconfig.test.json reported four TS7006 errors inside it before they were fixed, so this is not a typecheck that excludes tests. (6) GATES: check:control-bytes OK (5945 tracked text files); check:doc-types OK; check:doc-fences OK; check:doc-snippets OK, 272/272 blocks judged 0 failed, after building the closure it names (its first run was PRECONDITION NOT MET exit 2 — recorded as NOT MEASURED, then re-run, never as a pass); check:sdui-registration-pins OK, all 16 registrations present, after building the console plus its dependency closure (its first two runs were exit 2 'no console build to weigh' and a TS2307 unbuilt-closure error — both NOT MEASURED, not results). (7) LINT: eslint over all three changed source files, 0 errors (5 no-explicit-any WARNINGS in the new test matching its sibling page-header tests; lint.yml deliberately sets no --max-warnings). The narrowing is measured, not assumed, all three pieces: population is the diff itself via git diff --name-only; the count comes from --format json (3 files linted); and the config is not type-aware — eslint.config.js has zero projectService / parserOptions / project: occurrences against a live control of 10 'rules' hits in the same file — so with no config file in the diff no untouched file's verdict can move. The repo-wide farm is CI's run either way. CI convergence not awaited, per the standing dispatch contract.",
"mcp_calls": "12 — issue_read get + get_comments, create_pull_request, pull_request_read (body read-back), search_issues x2 (the second was the required control and it FAILED to hit, see open_questions), issue_write create x2, and this report's comments plus one read-back. Bulk reads went to git and to the zero-quota web payload channel instead.",
"open_questions": [
{
"question": "MECHANISM, not a code decision — the MCP `search_issues` channel appears blind in this container, and this seat's dedupe had to route around it. A targeted search for this card's own near-verbatim title returned total_count 0, i.e. the known-must-hit CONTROL failed, so its earlier empty result for my dedupe query was not a reading. Unauthenticated REST is also 403 here (both /repos/.../issues and /search/issues). Dedupe was completed on the zero-quota GitHub web payload channel with a live control that DID hit (query 'action-id lookup' returned 8 issue numbers including 6252; the two dedupe queries returned disjoint populations with no duplicate of either finding), so both findings are filed properly — but the next seat should know that search_issues answering 0 here means nothing.",
"options": [
"A — treat MCP search_issues as unavailable for this seat/container and make the web payload channel the documented dedupe route, control word mandatory",
"B — investigate why search_issues returns 0 for a query matching an issue it can read directly via issue_read (indexing lag vs. a scope/permission difference between the read and search paths)",
"C — do nothing; each dev re-discovers it per run"
],
"recommendation": "A plus B. A is cheap and immediate — the web channel is measured working here, costs no GraphQL quota, and the control discipline the dispatch already mandates is exactly what caught this. B matters because a silently-empty search is the worst failure shape available to a dedupe step: it looks like 'no duplicate exists' and it is unfalsifiable without a control, so a seat that skips the control files duplicates and never learns."
}
],
"out_of_scope_findings": [
"filed as objectui#7182: the action-id lookup now exists twice (record-quick-actions.tsx and containers.tsx) and the two copies DISAGREE on mixed arrays — quick_actions switches on the whole array (every element a string), page:header normalises per element — so during the migration this card opens, one authored array means two things depending on which renderer draws it. Not fixed here: unifying them changes record:quick_actions behaviour on a renderer this card does not own, and wants a ruling on whether a mixed array is a supported migration state.",
"filed as objectui#7183: no committed test can observe its own package's BUILT artifact — the root vitest alias sends every workspace specifier to src, and turbo's test task is dependsOn ['^build'] (dependencies only), so a dist-importing test is MODULE_NOT_FOUND / NOT MEASURED in CI. This card's acceptance criterion 3 explicitly asks for a built-artifact measurement; it was run by hand with a live control and passes, but it cannot be left behind as a standing pin."
]
}

Generated by Claude Code

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

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

Verdict: ACCEPT. Arming on green. Two judgement calls are ruled below, and one finding in this report is bigger than this card.

⭐ The finding that outranks the PR: search_issues is blind in this container, and its zero is not a reading

"A targeted search for this card's own near-verbatim title returned total_count 0, i.e. the known-must-hit CONTROL failed, so its earlier empty result for my dedupe query was not a reading."

This is the most valuable thing in the report and it is not about page:header at all. A silently-empty search is the worst failure shape available to a dedupe step: it renders as "no duplicate exists", it is indistinguishable from a real negative, and it is unfalsifiable without a control. A seat that skips the control files duplicates and never finds out.

It is also the seat's own instrument rule — a control that returns zero is no control; an empty output is not a 0 — applied one level up, to a channel rather than a grep. That generalisation is worth more than the rule it came from, and I am adopting it: the control belongs on the channel, not only on the query. Recording it as its own card rather than leaving it in a PR body, because it applies to every dev in this container and nothing in the repo currently says so. Dedupe here was completed on the zero-quota web payload channel with a control that did hit (action-id lookup → 8 issue numbers including #6252), so both out-of-scope findings are properly filed.

⚠️ Noting for the record that this does not impugn search_code, which this seat used successfully an hour ago (13 hits on 13855, with the returned paths independently confirmed against a local checkout). The blindness is specific to the issue-search path, which is exactly why a per-channel control is the answer rather than a blanket distrust.

A2.2 was my low-confidence assumption, and confirming it was worth the cost

I flagged a possible second page:header mount because #7021 found the record-title key in three spellings. Measured: exactly one canonical registration (ComponentRegistry.register('header', PageHeaderRenderer, {namespace:'page', skipFallback:true})), packages/layout/src/index.ts deliberately does not re-register it and says so, and the legacy page-header alias already resolves ids by delegating to record:quick_actions.

⭐ That last clause reframes the card: the sibling that already implements this ruling is the alias, not a second canonical mount. The acceptance criterion was not under-specified, and the hazard did not fire. A confirmed assumption that closes a named risk is a result, not a formality.

Ruling 1 — the built-artifact measurement was run and deliberately NOT committed: upheld

Acceptance criterion 3 ("the id path carries no body.source into the built artifact") was measured properly — the built packages/components/dist/index.js mounted by explicit relative import, an id-authored header whose resolved action carries an OS6252_DIST_BODY_MARKER body, marker absent from the rendered DOM, authored node byte-identical before and after render, and a live control proving the passing run measured the bundle (removing that one import fails expected undefined to be truthy, because page:header is unregistered in the light dom project).

And then it was not committed, because turbo's test task is dependsOn ['^build']dependencies only — so a committed dist-importing test would be MODULE_NOT_FOUND / NOT MEASURED in CI.

That is the right call and I want the reasoning preserved: a test that reports NOT MEASURED in CI is worse than no test, because it renders as coverage. Shipping it would have put a permanently-void assertion into the suite under the name of this card's hardest acceptance criterion. Filed as #7183 instead, with the committed half being the pin that can fail — "never writes a resolved def back onto the authored node" — carrying the object-shape authoring as its live control, since the inline form genuinely does drag the handler body into what it serializes. That control is what makes the id-side assertion falsifiable at all.

Ruling 2 — #7182, the divergence this card created: file, do not unify. Grading raised.

The action-id lookup now exists in two places and they disagree on mixed arrays: record:quick_actions switches on the whole array (every element a string), page:header normalises per element. So during the migration this card opens, one authored array means two things depending on which renderer draws it.

I asked for "no second resolver" and that fence held — the metadata read goes through the same useMetadataItem entry. What diverges is the array normalisation policy, which is a different thing and was not covered. ⚠️ But it is a real new dialect in a repo actively paying down dialects (#6771 is retiring one this hour), and unlike most findings it was introduced here rather than discovered. Declining to unify was still correct: it would change record:quick_actions behaviour on a renderer this card does not own, and whether a mixed array is a supported migration state is a ruling, not an implementation detail.

⇒ I am treating #7182 as the completion condition of this migration, not a loose end, and will grade it accordingly rather than letting it sit as an ordinary finding.

Also correct

  • Clause ② fence honoured — spec type and zod mirror untouched, actions stays z.array(z.string()). The renderer catches up to a declaration that was already public; nothing widens.
  • Unresolvable id renders nothing and warns ONCE, naming the object's declared action names, suppressed while the metadata read is in flight so a correct page never warns on first paint. That is the loud-over-silent option, and the in-flight suppression is the detail that makes it usable rather than noisy.
  • Equivalence proved by the same metadata authored twice — ids and inline objects — compared on the ordered button-name list and a normalised innerHTML projection, with the object-shape render asserted non-empty first, so an id-side green cannot come from two empty headers agreeing. One assertion covers every filter the acceptance criterion names, including qualify (order 1) rendering beforeconvert (order 2) — the reverse of the order both authorings list them in.
  • Ablation: Tests 7 failed | 1 passed (8), matching the 7/1 prediction exactly, with the single immovable case identified ("does not warn while the metadata lookup is still in flight" is true with or without resolution) — which is what distinguishes a targeted break from a collapsed population.
  • Two gates correctly reported NOT MEASURED on first run (check:doc-snippets exit 2 PRECONDITION NOT MET; check:sdui-registration-pins exit 2 and a TS2307 unbuilt closure) and re-run to real verdicts rather than scored as passes.
  • Docs updated at content/docs/guide/slotted-pages.md, which taught only the object form. ⛔ content/docs/releases/ untouched, correctly.

Nothing to change.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 10:05
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 8ec11e1Sep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-6252-page-header-action-ids branch September 1, 2026 10:19
Sign up for freeto 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.

page:header implements the declared action-id lookup — resolve actions: string[] like record:quick_actions does (objectstack#11592 ruling)

1 participant

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(components): page:header resolves declared action ids - #7180

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-6252-page-header-action-ids
Sep 1, 2026
Merged

fix(components): page:header resolves declared action ids#7180
os-warren merged 2 commits into
mainfrom
claude/issue-6252-page-header-action-ids

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#6252

Implements the objectstack#11592 ruling (maintainer, 2026-08-25, on recommendation B): the canonical page:header renderer resolves PageHeaderProps.actions as action ids.

The defect

@objectstack/spec declares actions: z.array(z.string()).optional().describe('Action IDs to show in header') (verified on the installed pin, @objectstack/spec@17.2.0, src/ui/component.zod.ts). The renderer read that array as ActionDefobjects and resolved nothing, so a header authored the way the published contract declares rendered zero buttons — satisfying the schema deleted the header.

Two sibling surfaces already read it as ids: record:quick_actions (packages/plugin-detail/src/renderers/record-quick-actions.tsx) resolves a string-valued actions out of the object's metadata, and layout:page-header reaches the same resolver by delegating its actions into a record:quick_actions node (packages/layout/src/PageHeader.tsx:196-208). So one authoring key meant two different things depending on which renderer drew the header.

What changed

packages/components/src/renderers/layout/containers.tsx:

  • Each element of actions is normalised at the top of the pipeline — a string is resolved against the object's own actions metadata, an object passes through. Resolution goes through the same useMetadataItem entryrecord:quick_actions uses; no second resolver, no second lookup path.
  • Because normalisation happens above the filters, one chain still runs: actionRendersAt placement (record_header / record_more), the requiredPermissions capability gate, visible / hidden, order, and the inline/overflow split are untouched by this change. That is what makes the equivalence claim testable at all.
  • An id that resolves to nothing renders nothing and warns once, naming the object's declared action names. Silently dropping it would reproduce the silent-loss class this repo keeps re-filing; it also makes a mistyped id indistinguishable from a correctly hidden one. The warning is suppressed while the metadata read is in flight, so a correctly-authored page does not warn on first paint.
  • The registered actions input description now says ids.

The one deliberate difference from record:quick_actions: that renderer switches on the whole array (rawActions.every(a =&gt; typeof a === 'string')); this one normalises per element, so a half-migrated ['convert', { … }] resolves the id and passes the object through. Same mechanism, wider arity — a page mid-migration is exactly the state this card creates.

Clause ② is not engaged. The declared type and its zod mirror are untouched — actions stays z.array(z.string()). The object shape survives as renderer tolerance, exactly as it does today, undeclared. No accept/reject behaviour of the contract moves.

Docs: content/docs/guide/slotted-pages.md now teaches the ids contract on the canonical header (it previously showed only the object form), with the inline-object form named as migration tolerance.

Acceptance criteria — evidence

Run at af40cf5c2 (working tree clean at that commit, so every run below read exactly this tree).

1. An id-authored header renders the same buttons as the object-shape authoring.

The same action metadata is authored twice and the two renders compared. The object-shape render is the live control: each equivalence case asserts it is non-empty first, so an id-side green can never come from two empty headers agreeing.

The population covers every filter the criterion names, so one equivalence assertion measures all of them: list_only (list_item — must not render), archive (record_more — must land in the ⋯ menu, not inline), gated (requiredPermissions the user lacks), closed_only (a CEL visible false for this record), and qualify (order: 1) rendering beforeconvert (order: 2), which is the reverse of the order both authorings list them in. Both the ordered button-name list and a normalised innerHTML projection are asserted equal.

2. The renderer's tests gain the id-shaped cases.packages/components/src/__tests__/page-header-action-ids.test.tsx — 8 cases (equivalence, the filter chain, record_more routing, unresolved-id warn-once with a resolvable sibling as control, no-warn-while-loading, mixed arrays, the properties.actions bridge spelling, and criterion 3).

pnpm exec vitest run --project dom-heavy packages/components/src/__tests__/page-header-action-ids.test.tsx
Test Files 1 passed (1) Tests 8 passed (8)

Ablation — direction predicted before running: red on the id path, green on the object-shape control. Mutation = feeding rawHeaderActions back into the filter chain (the minimal deletion of the fix). Proven on disk by marker count and blob hash, never by an editor's exit code:

PRE target-count=1 inject-count=0 PRE_HASH=3dafeb94… (== HEAD blob)
POST target-count=0 inject-count=1 POST_HASH=f87a37c9…
Tests 7 failed | 1 passed (8)

7 failed / 1 passed matched the prediction exactly: the only case that cannot move is "does not warn while the metadata lookup is still in flight", which is true with or without resolution. Restore proven by state, not exit code: restored blob 3dafeb94… equals the HEAD blob, and git diff HEAD, git diff --cached, git status --short are all empty. The mutation and restore legs need no rebuild here: the root vitest.config.mts aliases @object-ui/components to packages/components/src, so this suite resolves source, not dist.

3. The id path carries no body.source into the built artifact.

Measured two ways, and the built-artifact one is the load-bearing half.

Against the built artifact.pnpm --filter @object-ui/components build, then a run that mounts the builtpackages/components/dist/index.js (an explicit relative import — the workspace alias would otherwise redirect to src) with an id-authored header whose resolved action carries body: { language: 'js', source: '…OS6252_DIST_BODY_MARKER…' }. The id resolved through the built renderer, the marker appears nowhere in the rendered DOM, and the authored node's JSON.stringify is byte-identical before and after render. Live control: with that one import removed the same file fails expected undefined to be truthypage:header is unregistered in the light dom project, which proves the passing run measured the built bundle and nothing else. This measurement is not committed: turbo's test task is dependsOn: ["^build"] (dependencies only, not the package's own build), so a committed dist-importing test would be NOT MEASURED in CI rather than a pin. Filed as a follow-up.

As a committed pin.never writes a resolved def — and so no body.source — back onto the authored node asserts the authored node is unchanged by rendering and the marker is absent from the DOM, with the object-shape authoring as the live control: expect(JSON.stringify(objectAuthored)).toContain(BODY_MARKER) — the inline form does drag the handler body into what it serializes, which is what proves this assertion can fail at all.

Grep of the built bundle, with controls: did not resolve = 1 and page:header = 7 (present), OS6252_HANDLER_BODY_MARKER = 0.

Other verification

RunResult
vitest run packages/components scripts/__tests__/vitest-invocation-guard.test.ts224 files, 2070 passed
all page-header* + RecordDetailView.header* / predicate suites16 files, 184 passed
console registry/contract parity (5 files incl. registry-inputs-spec-parity, public-contract)156 passed
pnpm --filter @object-ui/components run type-checkexit 0 — and it covers the new test file (tsc -p tsconfig.test.json reported errors in it before they were fixed)
pnpm check:control-bytes✅ 5945 tracked text files
pnpm check:doc-types✅ every documented component type registered
pnpm check:doc-fences
pnpm check:doc-snippets✅ 272/272 blocks judged, 0 failed (after building its declared closure; its own resolution/sentinel/positive controls printed)
pnpm check:sdui-registration-pins✅ all 16 registrations present (after building the console + its closure)

eslint on all three changed source files: 0 errors (5 pre-existing-style no-explicit-any warnings in the new test, matching its sibling page-header tests; --max-warnings is deliberately unset in this repo). The lint run is narrowed and the narrowing is measured, not assumed: the population is the diff itself (git diff --name-only), the count comes from --format json (3 files linted), and the config is not type-aware — eslint.config.js contains zero projectService / parserOptions / project: occurrences against a live control of 10 rules hits in the same file — so no untouched file's verdict can move under this diff, which touches no config. The repo-wide farm is CI's run either way.

Not in this PR

hotcrm's migration of its 16 inline record_header objects to id references is downstream of this landing, as the card records. Nothing here touches it.


Generated by Claude Code

os-warrenand others added 2 commits September 1, 2026 09:24
`@objectstack/spec`'s `PageHeaderProps.actions` is `z.array(z.string())` —
"Action IDs to show in header" — but the canonical `page:header` renderer read
the array as ActionDef objects and resolved nothing, so metadata satisfying the
published contract rendered zero header buttons.
Resolve ids against the object's own metadata at the top of the actions
pipeline, through the same `useMetadataItem` entry `record:quick_actions` uses,
so the existing placement / capability / visible / order chain runs unchanged
over uniformly-shaped defs. Inline objects keep working as renderer tolerance
for the transition, per element, so a half-migrated array resolves.
An id that resolves to nothing warns once and renders nothing, naming the
object's declared action names.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
Document the contract the renderer now honours: the header names actions by id
and resolves them against the object's own metadata, with the inline-object
form named as migration tolerance rather than a second declared shape.
Adds the changeset and types the console-warning assertions in the new pin.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

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

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


📦 Bundle Size Report

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

Size Limits

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

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

os-dev-report

Same report as #6252 (comment 5492213936); posted here too because the dispatch asked for it on the PR.

{
"issue": 6252,
"status": "done",
"branch": "claude/issue-6252-page-header-action-ids",
"pr": "https://github.com/objectstack-ai/objectui/pull/7180",
"premise_still_valid": true,
"summary": "The canonical page:header renderer (packages/components/src/renderers/layout/containers.tsx) now resolves each element of `actions` as a declared action id against the object's own metadata, through the same useMetadataItem entry record:quick_actions uses — no second resolver. Normalisation sits at the TOP of the actions pipeline, so the existing single filter chain (actionRendersAt placement, requiredPermissions capability gate, visible/hidden, order, inline/overflow split) runs unchanged over uniformly-shaped defs; that is what makes the equivalence claim measurable. Inline ActionDef objects keep working per element, so a half-migrated ['convert', {def}] array resolves the id and passes the object through — renderer tolerance for the migration, still undeclared. An id that resolves to nothing renders nothing and warns ONCE naming the object's declared action names (suppressed while the metadata read is in flight, so a correct page never warns on first paint); silently dropping it would reproduce the silent-loss class this seat keeps filing. Clause 2 fence honoured: the spec type and its zod mirror are untouched, actions stays z.array(z.string()). Docs updated (content/docs/guide/slotted-pages.md taught only the object form) plus a minor changeset. ZONE 2 VERDICTS, all re-measured on this repo's head: A2.1 CONFIRMED (the pre-fix chain read .locations/.requiredPermissions/.visible/.order straight off each array element via actionRendersAt, no lookup anywhere). A2.2 CONFIRMED — exactly ONE canonical page:header registration, ComponentRegistry.register('header', PageHeaderRenderer, {namespace:'page', skipFallback:true}) in containers.tsx; packages/layout/src/index.ts deliberately does NOT re-register it and says so, and the legacy `page-header` alias already resolves ids by delegating to record:quick_actions — so the multi-spelling hazard the PM flagged does not fire here, and the sibling that already implements the ruling is the alias, not a second canonical mount. A2.3 CONFIRMED, cheap. A2.4 CONFIRMED — nothing pinned the rendered result of a string-authored page:header; the only two id-shaped fixtures in the repo pin VALIDATION only (examples/schema-catalog pins that actions:['export'] validates clean, which supports ids), so nothing had to be updated or deleted. A2.5 CONFIRMED with no seam: @object-ui/components already depends on @object-ui/react and already imports eight hooks from it, so useMetadataItem was reachable with no new dependency and no second context scope.",
"tests": "All runs at final commit af40cf5c2 with a clean working tree (git status --short empty), so every figure below read exactly that tree. Heavy runs went through the container's shared verify lock; verdicts read from its VERDICT line, exit codes captured before any pipe. (1) NEW PIN packages/components/src/__tests__/page-header-action-ids.test.tsx, registered in vitest.config.mts heavyDomTests (required — it renders through the ComponentRegistry): 'pnpm exec vitest run --project dom-heavy ...' gives Test Files 1 passed (1), Tests 8 passed (8). Shape of the proof: the SAME action metadata authored twice, once as ids and once as inline objects, both renders compared on the ordered button-name list AND a normalised innerHTML projection; the object-shape render is the LIVE CONTROL and is asserted non-empty first, so an id-side green cannot come from two empty headers agreeing. The population makes one equivalence assertion cover every filter the acceptance criterion names: list_item-only must not render, record_more must route to the overflow menu and never inline, a requiredPermissions the user lacks is denied, a CEL visible false for the record hides, and qualify (order 1) renders BEFORE convert (order 2) — the reverse of the order both authorings list them in. (2) ABLATION, direction predicted before running (red on the id path, green on the object-shape control). Mutation = feeding rawHeaderActions back into the filter chain. Proven on disk by BOTH marker count and blob hash, never by an editor's exit code: PRE target-count=1 inject-count=0 PRE_HASH=3dafeb94 equal to the HEAD blob; POST target-count=0 inject-count=1 POST_HASH=f87a37c9. Result: Tests 7 failed | 1 passed (8) — passing count asserted alongside the red, and 7/1 matched the prediction exactly (the one immovable case is 'does not warn while the metadata lookup is still in flight', true with or without resolution, so this is a targeted break and not a collapsed population). Restore proven by STATE not exit code: restored blob 3dafeb94 equals the HEAD blob, and git diff HEAD / git diff --cached / git status --short are all empty; the script carried an EXIT/INT/TERM trap with an absolute path. No rebuild needed on either leg and this was measured, not assumed: the root vitest.config.mts aliases @object-ui/components to packages/components/src, so the suite resolves SOURCE, never dist. (3) ACCEPTANCE CRITERION 3, measured against the BUILT ARTIFACT as the card demands. pnpm --filter @object-ui/components build, then a run mounting the built packages/components/dist/index.js by explicit relative import (the workspace alias would otherwise redirect to src) with an id-authored header whose resolved action carries body:{language:'js',source:'...OS6252_DIST_BODY_MARKER...'}: the id resolved through the BUILT renderer, the marker appears nowhere in the rendered DOM, and the authored node's JSON.stringify is byte-identical before and after render. LIVE CONTROL: with that one import removed the same file fails 'expected undefined to be truthy' (page:header is unregistered in the light dom project), which proves the passing run measured the built bundle and nothing else. That measurement is deliberately NOT COMMITTED — turbo's test task is dependsOn ['^build'], dependencies only, so a committed dist-importing test would be NOT MEASURED in CI rather than a pin; filed as objectui#7183. The committed half is the pin 'never writes a resolved def — and so no body.source — back onto the authored node', with the object-shape authoring as its live control: expect(JSON.stringify(objectAuthored)).toContain(BODY_MARKER) — the inline form DOES drag the handler body into what it serializes, which is what proves the id-side assertion can fail at all. Built-bundle greps with controls: 'did not resolve'=1 and 'page:header'=7 present, 'OS6252_HANDLER_BODY_MARKER'=0. (4) REGRESSION SURFACE: 'vitest run packages/components scripts/__tests__/vitest-invocation-guard.test.ts' gives 224 files, 2070 passed; all page-header* plus RecordDetailView.header*/predicate suites give 16 files, 184 passed; console registry/contract parity (registry-inputs-spec-parity, public-contract, ga-honoured-inputs-author-reach, component-input-union-specimens, record-block-record-reach) gives 156 passed. (5) TYPES: 'pnpm --filter @object-ui/components run type-check' exit 0 — note the script is spelled type-check (hyphen) in this repo — and it demonstrably COVERS the new test file: tsc -p tsconfig.test.json reported four TS7006 errors inside it before they were fixed, so this is not a typecheck that excludes tests. (6) GATES: check:control-bytes OK (5945 tracked text files); check:doc-types OK; check:doc-fences OK; check:doc-snippets OK, 272/272 blocks judged 0 failed, after building the closure it names (its first run was PRECONDITION NOT MET exit 2 — recorded as NOT MEASURED, then re-run, never as a pass); check:sdui-registration-pins OK, all 16 registrations present, after building the console plus its dependency closure (its first two runs were exit 2 'no console build to weigh' and a TS2307 unbuilt-closure error — both NOT MEASURED, not results). (7) LINT: eslint over all three changed source files, 0 errors (5 no-explicit-any WARNINGS in the new test matching its sibling page-header tests; lint.yml deliberately sets no --max-warnings). The narrowing is measured, not assumed, all three pieces: population is the diff itself via git diff --name-only; the count comes from --format json (3 files linted); and the config is not type-aware — eslint.config.js has zero projectService / parserOptions / project: occurrences against a live control of 10 'rules' hits in the same file — so with no config file in the diff no untouched file's verdict can move. The repo-wide farm is CI's run either way. CI convergence not awaited, per the standing dispatch contract.",
"mcp_calls": "12 — issue_read get + get_comments, create_pull_request, pull_request_read (body read-back), search_issues x2 (the second was the required control and it FAILED to hit, see open_questions), issue_write create x2, and this report's comments plus one read-back. Bulk reads went to git and to the zero-quota web payload channel instead.",
"open_questions": [
{
"question": "MECHANISM, not a code decision — the MCP `search_issues` channel appears blind in this container, and this seat's dedupe had to route around it. A targeted search for this card's own near-verbatim title returned total_count 0, i.e. the known-must-hit CONTROL failed, so its earlier empty result for my dedupe query was not a reading. Unauthenticated REST is also 403 here (both /repos/.../issues and /search/issues). Dedupe was completed on the zero-quota GitHub web payload channel with a live control that DID hit (query 'action-id lookup' returned 8 issue numbers including 6252; the two dedupe queries returned disjoint populations with no duplicate of either finding), so both findings are filed properly — but the next seat should know that search_issues answering 0 here means nothing.",
"options": [
"A — treat MCP search_issues as unavailable for this seat/container and make the web payload channel the documented dedupe route, control word mandatory",
"B — investigate why search_issues returns 0 for a query matching an issue it can read directly via issue_read (indexing lag vs. a scope/permission difference between the read and search paths)",
"C — do nothing; each dev re-discovers it per run"
],
"recommendation": "A plus B. A is cheap and immediate — the web channel is measured working here, costs no GraphQL quota, and the control discipline the dispatch already mandates is exactly what caught this. B matters because a silently-empty search is the worst failure shape available to a dedupe step: it looks like 'no duplicate exists' and it is unfalsifiable without a control, so a seat that skips the control files duplicates and never learns."
}
],
"out_of_scope_findings": [
"filed as objectui#7182: the action-id lookup now exists twice (record-quick-actions.tsx and containers.tsx) and the two copies DISAGREE on mixed arrays — quick_actions switches on the whole array (every element a string), page:header normalises per element — so during the migration this card opens, one authored array means two things depending on which renderer draws it. Not fixed here: unifying them changes record:quick_actions behaviour on a renderer this card does not own, and wants a ruling on whether a mixed array is a supported migration state.",
"filed as objectui#7183: no committed test can observe its own package's BUILT artifact — the root vitest alias sends every workspace specifier to src, and turbo's test task is dependsOn ['^build'] (dependencies only), so a dist-importing test is MODULE_NOT_FOUND / NOT MEASURED in CI. This card's acceptance criterion 3 explicitly asks for a built-artifact measurement; it was run by hand with a live control and passes, but it cannot be left behind as a standing pin."
]
}

Generated by Claude Code

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

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

Verdict: ACCEPT. Arming on green. Two judgement calls are ruled below, and one finding in this report is bigger than this card.

⭐ The finding that outranks the PR: search_issues is blind in this container, and its zero is not a reading

"A targeted search for this card's own near-verbatim title returned total_count 0, i.e. the known-must-hit CONTROL failed, so its earlier empty result for my dedupe query was not a reading."

This is the most valuable thing in the report and it is not about page:header at all. A silently-empty search is the worst failure shape available to a dedupe step: it renders as "no duplicate exists", it is indistinguishable from a real negative, and it is unfalsifiable without a control. A seat that skips the control files duplicates and never finds out.

It is also the seat's own instrument rule — a control that returns zero is no control; an empty output is not a 0 — applied one level up, to a channel rather than a grep. That generalisation is worth more than the rule it came from, and I am adopting it: the control belongs on the channel, not only on the query. Recording it as its own card rather than leaving it in a PR body, because it applies to every dev in this container and nothing in the repo currently says so. Dedupe here was completed on the zero-quota web payload channel with a control that did hit (action-id lookup → 8 issue numbers including #6252), so both out-of-scope findings are properly filed.

⚠️ Noting for the record that this does not impugn search_code, which this seat used successfully an hour ago (13 hits on 13855, with the returned paths independently confirmed against a local checkout). The blindness is specific to the issue-search path, which is exactly why a per-channel control is the answer rather than a blanket distrust.

A2.2 was my low-confidence assumption, and confirming it was worth the cost

I flagged a possible second page:header mount because #7021 found the record-title key in three spellings. Measured: exactly one canonical registration (ComponentRegistry.register('header', PageHeaderRenderer, {namespace:'page', skipFallback:true})), packages/layout/src/index.ts deliberately does not re-register it and says so, and the legacy page-header alias already resolves ids by delegating to record:quick_actions.

⭐ That last clause reframes the card: the sibling that already implements this ruling is the alias, not a second canonical mount. The acceptance criterion was not under-specified, and the hazard did not fire. A confirmed assumption that closes a named risk is a result, not a formality.

Ruling 1 — the built-artifact measurement was run and deliberately NOT committed: upheld

Acceptance criterion 3 ("the id path carries no body.source into the built artifact") was measured properly — the built packages/components/dist/index.js mounted by explicit relative import, an id-authored header whose resolved action carries an OS6252_DIST_BODY_MARKER body, marker absent from the rendered DOM, authored node byte-identical before and after render, and a live control proving the passing run measured the bundle (removing that one import fails expected undefined to be truthy, because page:header is unregistered in the light dom project).

And then it was not committed, because turbo's test task is dependsOn ['^build']dependencies only — so a committed dist-importing test would be MODULE_NOT_FOUND / NOT MEASURED in CI.

That is the right call and I want the reasoning preserved: a test that reports NOT MEASURED in CI is worse than no test, because it renders as coverage. Shipping it would have put a permanently-void assertion into the suite under the name of this card's hardest acceptance criterion. Filed as #7183 instead, with the committed half being the pin that can fail — "never writes a resolved def back onto the authored node" — carrying the object-shape authoring as its live control, since the inline form genuinely does drag the handler body into what it serializes. That control is what makes the id-side assertion falsifiable at all.

Ruling 2 — #7182, the divergence this card created: file, do not unify. Grading raised.

The action-id lookup now exists in two places and they disagree on mixed arrays: record:quick_actions switches on the whole array (every element a string), page:header normalises per element. So during the migration this card opens, one authored array means two things depending on which renderer draws it.

I asked for "no second resolver" and that fence held — the metadata read goes through the same useMetadataItem entry. What diverges is the array normalisation policy, which is a different thing and was not covered. ⚠️ But it is a real new dialect in a repo actively paying down dialects (#6771 is retiring one this hour), and unlike most findings it was introduced here rather than discovered. Declining to unify was still correct: it would change record:quick_actions behaviour on a renderer this card does not own, and whether a mixed array is a supported migration state is a ruling, not an implementation detail.

⇒ I am treating #7182 as the completion condition of this migration, not a loose end, and will grade it accordingly rather than letting it sit as an ordinary finding.

Also correct

  • Clause ② fence honoured — spec type and zod mirror untouched, actions stays z.array(z.string()). The renderer catches up to a declaration that was already public; nothing widens.
  • Unresolvable id renders nothing and warns ONCE, naming the object's declared action names, suppressed while the metadata read is in flight so a correct page never warns on first paint. That is the loud-over-silent option, and the in-flight suppression is the detail that makes it usable rather than noisy.
  • Equivalence proved by the same metadata authored twice — ids and inline objects — compared on the ordered button-name list and a normalised innerHTML projection, with the object-shape render asserted non-empty first, so an id-side green cannot come from two empty headers agreeing. One assertion covers every filter the acceptance criterion names, including qualify (order 1) rendering beforeconvert (order 2) — the reverse of the order both authorings list them in.
  • Ablation: Tests 7 failed | 1 passed (8), matching the 7/1 prediction exactly, with the single immovable case identified ("does not warn while the metadata lookup is still in flight" is true with or without resolution) — which is what distinguishes a targeted break from a collapsed population.
  • Two gates correctly reported NOT MEASURED on first run (check:doc-snippets exit 2 PRECONDITION NOT MET; check:sdui-registration-pins exit 2 and a TS2307 unbuilt closure) and re-run to real verdicts rather than scored as passes.
  • Docs updated at content/docs/guide/slotted-pages.md, which taught only the object form. ⛔ content/docs/releases/ untouched, correctly.

Nothing to change.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 10:05
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 8ec11e1Sep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-6252-page-header-action-ids branch September 1, 2026 10:19
Sign up for freeto 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.

page:header implements the declared action-id lookup — resolve actions: string[] like record:quick_actions does (objectstack#11592 ruling)

1 participant

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(components): page:header resolves declared action ids - #7180

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-6252-page-header-action-ids
Sep 1, 2026
Merged

fix(components): page:header resolves declared action ids#7180
os-warren merged 2 commits into
mainfrom
claude/issue-6252-page-header-action-ids

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#6252

Implements the objectstack#11592 ruling (maintainer, 2026-08-25, on recommendation B): the canonical page:header renderer resolves PageHeaderProps.actions as action ids.

The defect

@objectstack/spec declares actions: z.array(z.string()).optional().describe('Action IDs to show in header') (verified on the installed pin, @objectstack/spec@17.2.0, src/ui/component.zod.ts). The renderer read that array as ActionDefobjects and resolved nothing, so a header authored the way the published contract declares rendered zero buttons — satisfying the schema deleted the header.

Two sibling surfaces already read it as ids: record:quick_actions (packages/plugin-detail/src/renderers/record-quick-actions.tsx) resolves a string-valued actions out of the object's metadata, and layout:page-header reaches the same resolver by delegating its actions into a record:quick_actions node (packages/layout/src/PageHeader.tsx:196-208). So one authoring key meant two different things depending on which renderer drew the header.

What changed

packages/components/src/renderers/layout/containers.tsx:

  • Each element of actions is normalised at the top of the pipeline — a string is resolved against the object's own actions metadata, an object passes through. Resolution goes through the same useMetadataItem entryrecord:quick_actions uses; no second resolver, no second lookup path.
  • Because normalisation happens above the filters, one chain still runs: actionRendersAt placement (record_header / record_more), the requiredPermissions capability gate, visible / hidden, order, and the inline/overflow split are untouched by this change. That is what makes the equivalence claim testable at all.
  • An id that resolves to nothing renders nothing and warns once, naming the object's declared action names. Silently dropping it would reproduce the silent-loss class this repo keeps re-filing; it also makes a mistyped id indistinguishable from a correctly hidden one. The warning is suppressed while the metadata read is in flight, so a correctly-authored page does not warn on first paint.
  • The registered actions input description now says ids.

The one deliberate difference from record:quick_actions: that renderer switches on the whole array (rawActions.every(a =&gt; typeof a === 'string')); this one normalises per element, so a half-migrated ['convert', { … }] resolves the id and passes the object through. Same mechanism, wider arity — a page mid-migration is exactly the state this card creates.

Clause ② is not engaged. The declared type and its zod mirror are untouched — actions stays z.array(z.string()). The object shape survives as renderer tolerance, exactly as it does today, undeclared. No accept/reject behaviour of the contract moves.

Docs: content/docs/guide/slotted-pages.md now teaches the ids contract on the canonical header (it previously showed only the object form), with the inline-object form named as migration tolerance.

Acceptance criteria — evidence

Run at af40cf5c2 (working tree clean at that commit, so every run below read exactly this tree).

1. An id-authored header renders the same buttons as the object-shape authoring.

The same action metadata is authored twice and the two renders compared. The object-shape render is the live control: each equivalence case asserts it is non-empty first, so an id-side green can never come from two empty headers agreeing.

The population covers every filter the criterion names, so one equivalence assertion measures all of them: list_only (list_item — must not render), archive (record_more — must land in the ⋯ menu, not inline), gated (requiredPermissions the user lacks), closed_only (a CEL visible false for this record), and qualify (order: 1) rendering beforeconvert (order: 2), which is the reverse of the order both authorings list them in. Both the ordered button-name list and a normalised innerHTML projection are asserted equal.

2. The renderer's tests gain the id-shaped cases.packages/components/src/__tests__/page-header-action-ids.test.tsx — 8 cases (equivalence, the filter chain, record_more routing, unresolved-id warn-once with a resolvable sibling as control, no-warn-while-loading, mixed arrays, the properties.actions bridge spelling, and criterion 3).

pnpm exec vitest run --project dom-heavy packages/components/src/__tests__/page-header-action-ids.test.tsx
Test Files 1 passed (1) Tests 8 passed (8)

Ablation — direction predicted before running: red on the id path, green on the object-shape control. Mutation = feeding rawHeaderActions back into the filter chain (the minimal deletion of the fix). Proven on disk by marker count and blob hash, never by an editor's exit code:

PRE target-count=1 inject-count=0 PRE_HASH=3dafeb94… (== HEAD blob)
POST target-count=0 inject-count=1 POST_HASH=f87a37c9…
Tests 7 failed | 1 passed (8)

7 failed / 1 passed matched the prediction exactly: the only case that cannot move is "does not warn while the metadata lookup is still in flight", which is true with or without resolution. Restore proven by state, not exit code: restored blob 3dafeb94… equals the HEAD blob, and git diff HEAD, git diff --cached, git status --short are all empty. The mutation and restore legs need no rebuild here: the root vitest.config.mts aliases @object-ui/components to packages/components/src, so this suite resolves source, not dist.

3. The id path carries no body.source into the built artifact.

Measured two ways, and the built-artifact one is the load-bearing half.

Against the built artifact.pnpm --filter @object-ui/components build, then a run that mounts the builtpackages/components/dist/index.js (an explicit relative import — the workspace alias would otherwise redirect to src) with an id-authored header whose resolved action carries body: { language: 'js', source: '…OS6252_DIST_BODY_MARKER…' }. The id resolved through the built renderer, the marker appears nowhere in the rendered DOM, and the authored node's JSON.stringify is byte-identical before and after render. Live control: with that one import removed the same file fails expected undefined to be truthypage:header is unregistered in the light dom project, which proves the passing run measured the built bundle and nothing else. This measurement is not committed: turbo's test task is dependsOn: ["^build"] (dependencies only, not the package's own build), so a committed dist-importing test would be NOT MEASURED in CI rather than a pin. Filed as a follow-up.

As a committed pin.never writes a resolved def — and so no body.source — back onto the authored node asserts the authored node is unchanged by rendering and the marker is absent from the DOM, with the object-shape authoring as the live control: expect(JSON.stringify(objectAuthored)).toContain(BODY_MARKER) — the inline form does drag the handler body into what it serializes, which is what proves this assertion can fail at all.

Grep of the built bundle, with controls: did not resolve = 1 and page:header = 7 (present), OS6252_HANDLER_BODY_MARKER = 0.

Other verification

RunResult
vitest run packages/components scripts/__tests__/vitest-invocation-guard.test.ts224 files, 2070 passed
all page-header* + RecordDetailView.header* / predicate suites16 files, 184 passed
console registry/contract parity (5 files incl. registry-inputs-spec-parity, public-contract)156 passed
pnpm --filter @object-ui/components run type-checkexit 0 — and it covers the new test file (tsc -p tsconfig.test.json reported errors in it before they were fixed)
pnpm check:control-bytes✅ 5945 tracked text files
pnpm check:doc-types✅ every documented component type registered
pnpm check:doc-fences
pnpm check:doc-snippets✅ 272/272 blocks judged, 0 failed (after building its declared closure; its own resolution/sentinel/positive controls printed)
pnpm check:sdui-registration-pins✅ all 16 registrations present (after building the console + its closure)

eslint on all three changed source files: 0 errors (5 pre-existing-style no-explicit-any warnings in the new test, matching its sibling page-header tests; --max-warnings is deliberately unset in this repo). The lint run is narrowed and the narrowing is measured, not assumed: the population is the diff itself (git diff --name-only), the count comes from --format json (3 files linted), and the config is not type-aware — eslint.config.js contains zero projectService / parserOptions / project: occurrences against a live control of 10 rules hits in the same file — so no untouched file's verdict can move under this diff, which touches no config. The repo-wide farm is CI's run either way.

Not in this PR

hotcrm's migration of its 16 inline record_header objects to id references is downstream of this landing, as the card records. Nothing here touches it.


Generated by Claude Code

os-warrenand others added 2 commits September 1, 2026 09:24
`@objectstack/spec`'s `PageHeaderProps.actions` is `z.array(z.string())` —
"Action IDs to show in header" — but the canonical `page:header` renderer read
the array as ActionDef objects and resolved nothing, so metadata satisfying the
published contract rendered zero header buttons.
Resolve ids against the object's own metadata at the top of the actions
pipeline, through the same `useMetadataItem` entry `record:quick_actions` uses,
so the existing placement / capability / visible / order chain runs unchanged
over uniformly-shaped defs. Inline objects keep working as renderer tolerance
for the transition, per element, so a half-migrated array resolves.
An id that resolves to nothing warns once and renders nothing, naming the
object's declared action names.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
Document the contract the renderer now honours: the header names actions by id
and resolves them against the object's own metadata, with the inline-object
form named as migration tolerance rather than a second declared shape.
Adds the changeset and types the console-warning assertions in the new pin.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

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

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


📦 Bundle Size Report

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

Size Limits

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

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

os-dev-report

Same report as #6252 (comment 5492213936); posted here too because the dispatch asked for it on the PR.

{
"issue": 6252,
"status": "done",
"branch": "claude/issue-6252-page-header-action-ids",
"pr": "https://github.com/objectstack-ai/objectui/pull/7180",
"premise_still_valid": true,
"summary": "The canonical page:header renderer (packages/components/src/renderers/layout/containers.tsx) now resolves each element of `actions` as a declared action id against the object's own metadata, through the same useMetadataItem entry record:quick_actions uses — no second resolver. Normalisation sits at the TOP of the actions pipeline, so the existing single filter chain (actionRendersAt placement, requiredPermissions capability gate, visible/hidden, order, inline/overflow split) runs unchanged over uniformly-shaped defs; that is what makes the equivalence claim measurable. Inline ActionDef objects keep working per element, so a half-migrated ['convert', {def}] array resolves the id and passes the object through — renderer tolerance for the migration, still undeclared. An id that resolves to nothing renders nothing and warns ONCE naming the object's declared action names (suppressed while the metadata read is in flight, so a correct page never warns on first paint); silently dropping it would reproduce the silent-loss class this seat keeps filing. Clause 2 fence honoured: the spec type and its zod mirror are untouched, actions stays z.array(z.string()). Docs updated (content/docs/guide/slotted-pages.md taught only the object form) plus a minor changeset. ZONE 2 VERDICTS, all re-measured on this repo's head: A2.1 CONFIRMED (the pre-fix chain read .locations/.requiredPermissions/.visible/.order straight off each array element via actionRendersAt, no lookup anywhere). A2.2 CONFIRMED — exactly ONE canonical page:header registration, ComponentRegistry.register('header', PageHeaderRenderer, {namespace:'page', skipFallback:true}) in containers.tsx; packages/layout/src/index.ts deliberately does NOT re-register it and says so, and the legacy `page-header` alias already resolves ids by delegating to record:quick_actions — so the multi-spelling hazard the PM flagged does not fire here, and the sibling that already implements the ruling is the alias, not a second canonical mount. A2.3 CONFIRMED, cheap. A2.4 CONFIRMED — nothing pinned the rendered result of a string-authored page:header; the only two id-shaped fixtures in the repo pin VALIDATION only (examples/schema-catalog pins that actions:['export'] validates clean, which supports ids), so nothing had to be updated or deleted. A2.5 CONFIRMED with no seam: @object-ui/components already depends on @object-ui/react and already imports eight hooks from it, so useMetadataItem was reachable with no new dependency and no second context scope.",
"tests": "All runs at final commit af40cf5c2 with a clean working tree (git status --short empty), so every figure below read exactly that tree. Heavy runs went through the container's shared verify lock; verdicts read from its VERDICT line, exit codes captured before any pipe. (1) NEW PIN packages/components/src/__tests__/page-header-action-ids.test.tsx, registered in vitest.config.mts heavyDomTests (required — it renders through the ComponentRegistry): 'pnpm exec vitest run --project dom-heavy ...' gives Test Files 1 passed (1), Tests 8 passed (8). Shape of the proof: the SAME action metadata authored twice, once as ids and once as inline objects, both renders compared on the ordered button-name list AND a normalised innerHTML projection; the object-shape render is the LIVE CONTROL and is asserted non-empty first, so an id-side green cannot come from two empty headers agreeing. The population makes one equivalence assertion cover every filter the acceptance criterion names: list_item-only must not render, record_more must route to the overflow menu and never inline, a requiredPermissions the user lacks is denied, a CEL visible false for the record hides, and qualify (order 1) renders BEFORE convert (order 2) — the reverse of the order both authorings list them in. (2) ABLATION, direction predicted before running (red on the id path, green on the object-shape control). Mutation = feeding rawHeaderActions back into the filter chain. Proven on disk by BOTH marker count and blob hash, never by an editor's exit code: PRE target-count=1 inject-count=0 PRE_HASH=3dafeb94 equal to the HEAD blob; POST target-count=0 inject-count=1 POST_HASH=f87a37c9. Result: Tests 7 failed | 1 passed (8) — passing count asserted alongside the red, and 7/1 matched the prediction exactly (the one immovable case is 'does not warn while the metadata lookup is still in flight', true with or without resolution, so this is a targeted break and not a collapsed population). Restore proven by STATE not exit code: restored blob 3dafeb94 equals the HEAD blob, and git diff HEAD / git diff --cached / git status --short are all empty; the script carried an EXIT/INT/TERM trap with an absolute path. No rebuild needed on either leg and this was measured, not assumed: the root vitest.config.mts aliases @object-ui/components to packages/components/src, so the suite resolves SOURCE, never dist. (3) ACCEPTANCE CRITERION 3, measured against the BUILT ARTIFACT as the card demands. pnpm --filter @object-ui/components build, then a run mounting the built packages/components/dist/index.js by explicit relative import (the workspace alias would otherwise redirect to src) with an id-authored header whose resolved action carries body:{language:'js',source:'...OS6252_DIST_BODY_MARKER...'}: the id resolved through the BUILT renderer, the marker appears nowhere in the rendered DOM, and the authored node's JSON.stringify is byte-identical before and after render. LIVE CONTROL: with that one import removed the same file fails 'expected undefined to be truthy' (page:header is unregistered in the light dom project), which proves the passing run measured the built bundle and nothing else. That measurement is deliberately NOT COMMITTED — turbo's test task is dependsOn ['^build'], dependencies only, so a committed dist-importing test would be NOT MEASURED in CI rather than a pin; filed as objectui#7183. The committed half is the pin 'never writes a resolved def — and so no body.source — back onto the authored node', with the object-shape authoring as its live control: expect(JSON.stringify(objectAuthored)).toContain(BODY_MARKER) — the inline form DOES drag the handler body into what it serializes, which is what proves the id-side assertion can fail at all. Built-bundle greps with controls: 'did not resolve'=1 and 'page:header'=7 present, 'OS6252_HANDLER_BODY_MARKER'=0. (4) REGRESSION SURFACE: 'vitest run packages/components scripts/__tests__/vitest-invocation-guard.test.ts' gives 224 files, 2070 passed; all page-header* plus RecordDetailView.header*/predicate suites give 16 files, 184 passed; console registry/contract parity (registry-inputs-spec-parity, public-contract, ga-honoured-inputs-author-reach, component-input-union-specimens, record-block-record-reach) gives 156 passed. (5) TYPES: 'pnpm --filter @object-ui/components run type-check' exit 0 — note the script is spelled type-check (hyphen) in this repo — and it demonstrably COVERS the new test file: tsc -p tsconfig.test.json reported four TS7006 errors inside it before they were fixed, so this is not a typecheck that excludes tests. (6) GATES: check:control-bytes OK (5945 tracked text files); check:doc-types OK; check:doc-fences OK; check:doc-snippets OK, 272/272 blocks judged 0 failed, after building the closure it names (its first run was PRECONDITION NOT MET exit 2 — recorded as NOT MEASURED, then re-run, never as a pass); check:sdui-registration-pins OK, all 16 registrations present, after building the console plus its dependency closure (its first two runs were exit 2 'no console build to weigh' and a TS2307 unbuilt-closure error — both NOT MEASURED, not results). (7) LINT: eslint over all three changed source files, 0 errors (5 no-explicit-any WARNINGS in the new test matching its sibling page-header tests; lint.yml deliberately sets no --max-warnings). The narrowing is measured, not assumed, all three pieces: population is the diff itself via git diff --name-only; the count comes from --format json (3 files linted); and the config is not type-aware — eslint.config.js has zero projectService / parserOptions / project: occurrences against a live control of 10 'rules' hits in the same file — so with no config file in the diff no untouched file's verdict can move. The repo-wide farm is CI's run either way. CI convergence not awaited, per the standing dispatch contract.",
"mcp_calls": "12 — issue_read get + get_comments, create_pull_request, pull_request_read (body read-back), search_issues x2 (the second was the required control and it FAILED to hit, see open_questions), issue_write create x2, and this report's comments plus one read-back. Bulk reads went to git and to the zero-quota web payload channel instead.",
"open_questions": [
{
"question": "MECHANISM, not a code decision — the MCP `search_issues` channel appears blind in this container, and this seat's dedupe had to route around it. A targeted search for this card's own near-verbatim title returned total_count 0, i.e. the known-must-hit CONTROL failed, so its earlier empty result for my dedupe query was not a reading. Unauthenticated REST is also 403 here (both /repos/.../issues and /search/issues). Dedupe was completed on the zero-quota GitHub web payload channel with a live control that DID hit (query 'action-id lookup' returned 8 issue numbers including 6252; the two dedupe queries returned disjoint populations with no duplicate of either finding), so both findings are filed properly — but the next seat should know that search_issues answering 0 here means nothing.",
"options": [
"A — treat MCP search_issues as unavailable for this seat/container and make the web payload channel the documented dedupe route, control word mandatory",
"B — investigate why search_issues returns 0 for a query matching an issue it can read directly via issue_read (indexing lag vs. a scope/permission difference between the read and search paths)",
"C — do nothing; each dev re-discovers it per run"
],
"recommendation": "A plus B. A is cheap and immediate — the web channel is measured working here, costs no GraphQL quota, and the control discipline the dispatch already mandates is exactly what caught this. B matters because a silently-empty search is the worst failure shape available to a dedupe step: it looks like 'no duplicate exists' and it is unfalsifiable without a control, so a seat that skips the control files duplicates and never learns."
}
],
"out_of_scope_findings": [
"filed as objectui#7182: the action-id lookup now exists twice (record-quick-actions.tsx and containers.tsx) and the two copies DISAGREE on mixed arrays — quick_actions switches on the whole array (every element a string), page:header normalises per element — so during the migration this card opens, one authored array means two things depending on which renderer draws it. Not fixed here: unifying them changes record:quick_actions behaviour on a renderer this card does not own, and wants a ruling on whether a mixed array is a supported migration state.",
"filed as objectui#7183: no committed test can observe its own package's BUILT artifact — the root vitest alias sends every workspace specifier to src, and turbo's test task is dependsOn ['^build'] (dependencies only), so a dist-importing test is MODULE_NOT_FOUND / NOT MEASURED in CI. This card's acceptance criterion 3 explicitly asks for a built-artifact measurement; it was run by hand with a live control and passes, but it cannot be left behind as a standing pin."
]
}

Generated by Claude Code

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

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

Verdict: ACCEPT. Arming on green. Two judgement calls are ruled below, and one finding in this report is bigger than this card.

⭐ The finding that outranks the PR: search_issues is blind in this container, and its zero is not a reading

"A targeted search for this card's own near-verbatim title returned total_count 0, i.e. the known-must-hit CONTROL failed, so its earlier empty result for my dedupe query was not a reading."

This is the most valuable thing in the report and it is not about page:header at all. A silently-empty search is the worst failure shape available to a dedupe step: it renders as "no duplicate exists", it is indistinguishable from a real negative, and it is unfalsifiable without a control. A seat that skips the control files duplicates and never finds out.

It is also the seat's own instrument rule — a control that returns zero is no control; an empty output is not a 0 — applied one level up, to a channel rather than a grep. That generalisation is worth more than the rule it came from, and I am adopting it: the control belongs on the channel, not only on the query. Recording it as its own card rather than leaving it in a PR body, because it applies to every dev in this container and nothing in the repo currently says so. Dedupe here was completed on the zero-quota web payload channel with a control that did hit (action-id lookup → 8 issue numbers including #6252), so both out-of-scope findings are properly filed.

⚠️ Noting for the record that this does not impugn search_code, which this seat used successfully an hour ago (13 hits on 13855, with the returned paths independently confirmed against a local checkout). The blindness is specific to the issue-search path, which is exactly why a per-channel control is the answer rather than a blanket distrust.

A2.2 was my low-confidence assumption, and confirming it was worth the cost

I flagged a possible second page:header mount because #7021 found the record-title key in three spellings. Measured: exactly one canonical registration (ComponentRegistry.register('header', PageHeaderRenderer, {namespace:'page', skipFallback:true})), packages/layout/src/index.ts deliberately does not re-register it and says so, and the legacy page-header alias already resolves ids by delegating to record:quick_actions.

⭐ That last clause reframes the card: the sibling that already implements this ruling is the alias, not a second canonical mount. The acceptance criterion was not under-specified, and the hazard did not fire. A confirmed assumption that closes a named risk is a result, not a formality.

Ruling 1 — the built-artifact measurement was run and deliberately NOT committed: upheld

Acceptance criterion 3 ("the id path carries no body.source into the built artifact") was measured properly — the built packages/components/dist/index.js mounted by explicit relative import, an id-authored header whose resolved action carries an OS6252_DIST_BODY_MARKER body, marker absent from the rendered DOM, authored node byte-identical before and after render, and a live control proving the passing run measured the bundle (removing that one import fails expected undefined to be truthy, because page:header is unregistered in the light dom project).

And then it was not committed, because turbo's test task is dependsOn ['^build']dependencies only — so a committed dist-importing test would be MODULE_NOT_FOUND / NOT MEASURED in CI.

That is the right call and I want the reasoning preserved: a test that reports NOT MEASURED in CI is worse than no test, because it renders as coverage. Shipping it would have put a permanently-void assertion into the suite under the name of this card's hardest acceptance criterion. Filed as #7183 instead, with the committed half being the pin that can fail — "never writes a resolved def back onto the authored node" — carrying the object-shape authoring as its live control, since the inline form genuinely does drag the handler body into what it serializes. That control is what makes the id-side assertion falsifiable at all.

Ruling 2 — #7182, the divergence this card created: file, do not unify. Grading raised.

The action-id lookup now exists in two places and they disagree on mixed arrays: record:quick_actions switches on the whole array (every element a string), page:header normalises per element. So during the migration this card opens, one authored array means two things depending on which renderer draws it.

I asked for "no second resolver" and that fence held — the metadata read goes through the same useMetadataItem entry. What diverges is the array normalisation policy, which is a different thing and was not covered. ⚠️ But it is a real new dialect in a repo actively paying down dialects (#6771 is retiring one this hour), and unlike most findings it was introduced here rather than discovered. Declining to unify was still correct: it would change record:quick_actions behaviour on a renderer this card does not own, and whether a mixed array is a supported migration state is a ruling, not an implementation detail.

⇒ I am treating #7182 as the completion condition of this migration, not a loose end, and will grade it accordingly rather than letting it sit as an ordinary finding.

Also correct

  • Clause ② fence honoured — spec type and zod mirror untouched, actions stays z.array(z.string()). The renderer catches up to a declaration that was already public; nothing widens.
  • Unresolvable id renders nothing and warns ONCE, naming the object's declared action names, suppressed while the metadata read is in flight so a correct page never warns on first paint. That is the loud-over-silent option, and the in-flight suppression is the detail that makes it usable rather than noisy.
  • Equivalence proved by the same metadata authored twice — ids and inline objects — compared on the ordered button-name list and a normalised innerHTML projection, with the object-shape render asserted non-empty first, so an id-side green cannot come from two empty headers agreeing. One assertion covers every filter the acceptance criterion names, including qualify (order 1) rendering beforeconvert (order 2) — the reverse of the order both authorings list them in.
  • Ablation: Tests 7 failed | 1 passed (8), matching the 7/1 prediction exactly, with the single immovable case identified ("does not warn while the metadata lookup is still in flight" is true with or without resolution) — which is what distinguishes a targeted break from a collapsed population.
  • Two gates correctly reported NOT MEASURED on first run (check:doc-snippets exit 2 PRECONDITION NOT MET; check:sdui-registration-pins exit 2 and a TS2307 unbuilt closure) and re-run to real verdicts rather than scored as passes.
  • Docs updated at content/docs/guide/slotted-pages.md, which taught only the object form. ⛔ content/docs/releases/ untouched, correctly.

Nothing to change.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 10:05
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 8ec11e1Sep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-6252-page-header-action-ids branch September 1, 2026 10:19
Sign up for freeto 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.

page:header implements the declared action-id lookup — resolve actions: string[] like record:quick_actions does (objectstack#11592 ruling)

1 participant

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(components): page:header resolves declared action ids - #7180

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-6252-page-header-action-ids
Sep 1, 2026
Merged

fix(components): page:header resolves declared action ids#7180
os-warren merged 2 commits into
mainfrom
claude/issue-6252-page-header-action-ids

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#6252

Implements the objectstack#11592 ruling (maintainer, 2026-08-25, on recommendation B): the canonical page:header renderer resolves PageHeaderProps.actions as action ids.

The defect

@objectstack/spec declares actions: z.array(z.string()).optional().describe('Action IDs to show in header') (verified on the installed pin, @objectstack/spec@17.2.0, src/ui/component.zod.ts). The renderer read that array as ActionDefobjects and resolved nothing, so a header authored the way the published contract declares rendered zero buttons — satisfying the schema deleted the header.

Two sibling surfaces already read it as ids: record:quick_actions (packages/plugin-detail/src/renderers/record-quick-actions.tsx) resolves a string-valued actions out of the object's metadata, and layout:page-header reaches the same resolver by delegating its actions into a record:quick_actions node (packages/layout/src/PageHeader.tsx:196-208). So one authoring key meant two different things depending on which renderer drew the header.

What changed

packages/components/src/renderers/layout/containers.tsx:

  • Each element of actions is normalised at the top of the pipeline — a string is resolved against the object's own actions metadata, an object passes through. Resolution goes through the same useMetadataItem entryrecord:quick_actions uses; no second resolver, no second lookup path.
  • Because normalisation happens above the filters, one chain still runs: actionRendersAt placement (record_header / record_more), the requiredPermissions capability gate, visible / hidden, order, and the inline/overflow split are untouched by this change. That is what makes the equivalence claim testable at all.
  • An id that resolves to nothing renders nothing and warns once, naming the object's declared action names. Silently dropping it would reproduce the silent-loss class this repo keeps re-filing; it also makes a mistyped id indistinguishable from a correctly hidden one. The warning is suppressed while the metadata read is in flight, so a correctly-authored page does not warn on first paint.
  • The registered actions input description now says ids.

The one deliberate difference from record:quick_actions: that renderer switches on the whole array (rawActions.every(a =&gt; typeof a === 'string')); this one normalises per element, so a half-migrated ['convert', { … }] resolves the id and passes the object through. Same mechanism, wider arity — a page mid-migration is exactly the state this card creates.

Clause ② is not engaged. The declared type and its zod mirror are untouched — actions stays z.array(z.string()). The object shape survives as renderer tolerance, exactly as it does today, undeclared. No accept/reject behaviour of the contract moves.

Docs: content/docs/guide/slotted-pages.md now teaches the ids contract on the canonical header (it previously showed only the object form), with the inline-object form named as migration tolerance.

Acceptance criteria — evidence

Run at af40cf5c2 (working tree clean at that commit, so every run below read exactly this tree).

1. An id-authored header renders the same buttons as the object-shape authoring.

The same action metadata is authored twice and the two renders compared. The object-shape render is the live control: each equivalence case asserts it is non-empty first, so an id-side green can never come from two empty headers agreeing.

The population covers every filter the criterion names, so one equivalence assertion measures all of them: list_only (list_item — must not render), archive (record_more — must land in the ⋯ menu, not inline), gated (requiredPermissions the user lacks), closed_only (a CEL visible false for this record), and qualify (order: 1) rendering beforeconvert (order: 2), which is the reverse of the order both authorings list them in. Both the ordered button-name list and a normalised innerHTML projection are asserted equal.

2. The renderer's tests gain the id-shaped cases.packages/components/src/__tests__/page-header-action-ids.test.tsx — 8 cases (equivalence, the filter chain, record_more routing, unresolved-id warn-once with a resolvable sibling as control, no-warn-while-loading, mixed arrays, the properties.actions bridge spelling, and criterion 3).

pnpm exec vitest run --project dom-heavy packages/components/src/__tests__/page-header-action-ids.test.tsx
Test Files 1 passed (1) Tests 8 passed (8)

Ablation — direction predicted before running: red on the id path, green on the object-shape control. Mutation = feeding rawHeaderActions back into the filter chain (the minimal deletion of the fix). Proven on disk by marker count and blob hash, never by an editor's exit code:

PRE target-count=1 inject-count=0 PRE_HASH=3dafeb94… (== HEAD blob)
POST target-count=0 inject-count=1 POST_HASH=f87a37c9…
Tests 7 failed | 1 passed (8)

7 failed / 1 passed matched the prediction exactly: the only case that cannot move is "does not warn while the metadata lookup is still in flight", which is true with or without resolution. Restore proven by state, not exit code: restored blob 3dafeb94… equals the HEAD blob, and git diff HEAD, git diff --cached, git status --short are all empty. The mutation and restore legs need no rebuild here: the root vitest.config.mts aliases @object-ui/components to packages/components/src, so this suite resolves source, not dist.

3. The id path carries no body.source into the built artifact.

Measured two ways, and the built-artifact one is the load-bearing half.

Against the built artifact.pnpm --filter @object-ui/components build, then a run that mounts the builtpackages/components/dist/index.js (an explicit relative import — the workspace alias would otherwise redirect to src) with an id-authored header whose resolved action carries body: { language: 'js', source: '…OS6252_DIST_BODY_MARKER…' }. The id resolved through the built renderer, the marker appears nowhere in the rendered DOM, and the authored node's JSON.stringify is byte-identical before and after render. Live control: with that one import removed the same file fails expected undefined to be truthypage:header is unregistered in the light dom project, which proves the passing run measured the built bundle and nothing else. This measurement is not committed: turbo's test task is dependsOn: ["^build"] (dependencies only, not the package's own build), so a committed dist-importing test would be NOT MEASURED in CI rather than a pin. Filed as a follow-up.

As a committed pin.never writes a resolved def — and so no body.source — back onto the authored node asserts the authored node is unchanged by rendering and the marker is absent from the DOM, with the object-shape authoring as the live control: expect(JSON.stringify(objectAuthored)).toContain(BODY_MARKER) — the inline form does drag the handler body into what it serializes, which is what proves this assertion can fail at all.

Grep of the built bundle, with controls: did not resolve = 1 and page:header = 7 (present), OS6252_HANDLER_BODY_MARKER = 0.

Other verification

RunResult
vitest run packages/components scripts/__tests__/vitest-invocation-guard.test.ts224 files, 2070 passed
all page-header* + RecordDetailView.header* / predicate suites16 files, 184 passed
console registry/contract parity (5 files incl. registry-inputs-spec-parity, public-contract)156 passed
pnpm --filter @object-ui/components run type-checkexit 0 — and it covers the new test file (tsc -p tsconfig.test.json reported errors in it before they were fixed)
pnpm check:control-bytes✅ 5945 tracked text files
pnpm check:doc-types✅ every documented component type registered
pnpm check:doc-fences
pnpm check:doc-snippets✅ 272/272 blocks judged, 0 failed (after building its declared closure; its own resolution/sentinel/positive controls printed)
pnpm check:sdui-registration-pins✅ all 16 registrations present (after building the console + its closure)

eslint on all three changed source files: 0 errors (5 pre-existing-style no-explicit-any warnings in the new test, matching its sibling page-header tests; --max-warnings is deliberately unset in this repo). The lint run is narrowed and the narrowing is measured, not assumed: the population is the diff itself (git diff --name-only), the count comes from --format json (3 files linted), and the config is not type-aware — eslint.config.js contains zero projectService / parserOptions / project: occurrences against a live control of 10 rules hits in the same file — so no untouched file's verdict can move under this diff, which touches no config. The repo-wide farm is CI's run either way.

Not in this PR

hotcrm's migration of its 16 inline record_header objects to id references is downstream of this landing, as the card records. Nothing here touches it.


Generated by Claude Code

os-warrenand others added 2 commits September 1, 2026 09:24
`@objectstack/spec`'s `PageHeaderProps.actions` is `z.array(z.string())` —
"Action IDs to show in header" — but the canonical `page:header` renderer read
the array as ActionDef objects and resolved nothing, so metadata satisfying the
published contract rendered zero header buttons.
Resolve ids against the object's own metadata at the top of the actions
pipeline, through the same `useMetadataItem` entry `record:quick_actions` uses,
so the existing placement / capability / visible / order chain runs unchanged
over uniformly-shaped defs. Inline objects keep working as renderer tolerance
for the transition, per element, so a half-migrated array resolves.
An id that resolves to nothing warns once and renders nothing, naming the
object's declared action names.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
Document the contract the renderer now honours: the header names actions by id
and resolves them against the object's own metadata, with the inline-object
form named as migration tolerance rather than a second declared shape.
Adds the changeset and types the console-warning assertions in the new pin.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

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

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


📦 Bundle Size Report

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

Size Limits

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

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

os-dev-report

Same report as #6252 (comment 5492213936); posted here too because the dispatch asked for it on the PR.

{
"issue": 6252,
"status": "done",
"branch": "claude/issue-6252-page-header-action-ids",
"pr": "https://github.com/objectstack-ai/objectui/pull/7180",
"premise_still_valid": true,
"summary": "The canonical page:header renderer (packages/components/src/renderers/layout/containers.tsx) now resolves each element of `actions` as a declared action id against the object's own metadata, through the same useMetadataItem entry record:quick_actions uses — no second resolver. Normalisation sits at the TOP of the actions pipeline, so the existing single filter chain (actionRendersAt placement, requiredPermissions capability gate, visible/hidden, order, inline/overflow split) runs unchanged over uniformly-shaped defs; that is what makes the equivalence claim measurable. Inline ActionDef objects keep working per element, so a half-migrated ['convert', {def}] array resolves the id and passes the object through — renderer tolerance for the migration, still undeclared. An id that resolves to nothing renders nothing and warns ONCE naming the object's declared action names (suppressed while the metadata read is in flight, so a correct page never warns on first paint); silently dropping it would reproduce the silent-loss class this seat keeps filing. Clause 2 fence honoured: the spec type and its zod mirror are untouched, actions stays z.array(z.string()). Docs updated (content/docs/guide/slotted-pages.md taught only the object form) plus a minor changeset. ZONE 2 VERDICTS, all re-measured on this repo's head: A2.1 CONFIRMED (the pre-fix chain read .locations/.requiredPermissions/.visible/.order straight off each array element via actionRendersAt, no lookup anywhere). A2.2 CONFIRMED — exactly ONE canonical page:header registration, ComponentRegistry.register('header', PageHeaderRenderer, {namespace:'page', skipFallback:true}) in containers.tsx; packages/layout/src/index.ts deliberately does NOT re-register it and says so, and the legacy `page-header` alias already resolves ids by delegating to record:quick_actions — so the multi-spelling hazard the PM flagged does not fire here, and the sibling that already implements the ruling is the alias, not a second canonical mount. A2.3 CONFIRMED, cheap. A2.4 CONFIRMED — nothing pinned the rendered result of a string-authored page:header; the only two id-shaped fixtures in the repo pin VALIDATION only (examples/schema-catalog pins that actions:['export'] validates clean, which supports ids), so nothing had to be updated or deleted. A2.5 CONFIRMED with no seam: @object-ui/components already depends on @object-ui/react and already imports eight hooks from it, so useMetadataItem was reachable with no new dependency and no second context scope.",
"tests": "All runs at final commit af40cf5c2 with a clean working tree (git status --short empty), so every figure below read exactly that tree. Heavy runs went through the container's shared verify lock; verdicts read from its VERDICT line, exit codes captured before any pipe. (1) NEW PIN packages/components/src/__tests__/page-header-action-ids.test.tsx, registered in vitest.config.mts heavyDomTests (required — it renders through the ComponentRegistry): 'pnpm exec vitest run --project dom-heavy ...' gives Test Files 1 passed (1), Tests 8 passed (8). Shape of the proof: the SAME action metadata authored twice, once as ids and once as inline objects, both renders compared on the ordered button-name list AND a normalised innerHTML projection; the object-shape render is the LIVE CONTROL and is asserted non-empty first, so an id-side green cannot come from two empty headers agreeing. The population makes one equivalence assertion cover every filter the acceptance criterion names: list_item-only must not render, record_more must route to the overflow menu and never inline, a requiredPermissions the user lacks is denied, a CEL visible false for the record hides, and qualify (order 1) renders BEFORE convert (order 2) — the reverse of the order both authorings list them in. (2) ABLATION, direction predicted before running (red on the id path, green on the object-shape control). Mutation = feeding rawHeaderActions back into the filter chain. Proven on disk by BOTH marker count and blob hash, never by an editor's exit code: PRE target-count=1 inject-count=0 PRE_HASH=3dafeb94 equal to the HEAD blob; POST target-count=0 inject-count=1 POST_HASH=f87a37c9. Result: Tests 7 failed | 1 passed (8) — passing count asserted alongside the red, and 7/1 matched the prediction exactly (the one immovable case is 'does not warn while the metadata lookup is still in flight', true with or without resolution, so this is a targeted break and not a collapsed population). Restore proven by STATE not exit code: restored blob 3dafeb94 equals the HEAD blob, and git diff HEAD / git diff --cached / git status --short are all empty; the script carried an EXIT/INT/TERM trap with an absolute path. No rebuild needed on either leg and this was measured, not assumed: the root vitest.config.mts aliases @object-ui/components to packages/components/src, so the suite resolves SOURCE, never dist. (3) ACCEPTANCE CRITERION 3, measured against the BUILT ARTIFACT as the card demands. pnpm --filter @object-ui/components build, then a run mounting the built packages/components/dist/index.js by explicit relative import (the workspace alias would otherwise redirect to src) with an id-authored header whose resolved action carries body:{language:'js',source:'...OS6252_DIST_BODY_MARKER...'}: the id resolved through the BUILT renderer, the marker appears nowhere in the rendered DOM, and the authored node's JSON.stringify is byte-identical before and after render. LIVE CONTROL: with that one import removed the same file fails 'expected undefined to be truthy' (page:header is unregistered in the light dom project), which proves the passing run measured the built bundle and nothing else. That measurement is deliberately NOT COMMITTED — turbo's test task is dependsOn ['^build'], dependencies only, so a committed dist-importing test would be NOT MEASURED in CI rather than a pin; filed as objectui#7183. The committed half is the pin 'never writes a resolved def — and so no body.source — back onto the authored node', with the object-shape authoring as its live control: expect(JSON.stringify(objectAuthored)).toContain(BODY_MARKER) — the inline form DOES drag the handler body into what it serializes, which is what proves the id-side assertion can fail at all. Built-bundle greps with controls: 'did not resolve'=1 and 'page:header'=7 present, 'OS6252_HANDLER_BODY_MARKER'=0. (4) REGRESSION SURFACE: 'vitest run packages/components scripts/__tests__/vitest-invocation-guard.test.ts' gives 224 files, 2070 passed; all page-header* plus RecordDetailView.header*/predicate suites give 16 files, 184 passed; console registry/contract parity (registry-inputs-spec-parity, public-contract, ga-honoured-inputs-author-reach, component-input-union-specimens, record-block-record-reach) gives 156 passed. (5) TYPES: 'pnpm --filter @object-ui/components run type-check' exit 0 — note the script is spelled type-check (hyphen) in this repo — and it demonstrably COVERS the new test file: tsc -p tsconfig.test.json reported four TS7006 errors inside it before they were fixed, so this is not a typecheck that excludes tests. (6) GATES: check:control-bytes OK (5945 tracked text files); check:doc-types OK; check:doc-fences OK; check:doc-snippets OK, 272/272 blocks judged 0 failed, after building the closure it names (its first run was PRECONDITION NOT MET exit 2 — recorded as NOT MEASURED, then re-run, never as a pass); check:sdui-registration-pins OK, all 16 registrations present, after building the console plus its dependency closure (its first two runs were exit 2 'no console build to weigh' and a TS2307 unbuilt-closure error — both NOT MEASURED, not results). (7) LINT: eslint over all three changed source files, 0 errors (5 no-explicit-any WARNINGS in the new test matching its sibling page-header tests; lint.yml deliberately sets no --max-warnings). The narrowing is measured, not assumed, all three pieces: population is the diff itself via git diff --name-only; the count comes from --format json (3 files linted); and the config is not type-aware — eslint.config.js has zero projectService / parserOptions / project: occurrences against a live control of 10 'rules' hits in the same file — so with no config file in the diff no untouched file's verdict can move. The repo-wide farm is CI's run either way. CI convergence not awaited, per the standing dispatch contract.",
"mcp_calls": "12 — issue_read get + get_comments, create_pull_request, pull_request_read (body read-back), search_issues x2 (the second was the required control and it FAILED to hit, see open_questions), issue_write create x2, and this report's comments plus one read-back. Bulk reads went to git and to the zero-quota web payload channel instead.",
"open_questions": [
{
"question": "MECHANISM, not a code decision — the MCP `search_issues` channel appears blind in this container, and this seat's dedupe had to route around it. A targeted search for this card's own near-verbatim title returned total_count 0, i.e. the known-must-hit CONTROL failed, so its earlier empty result for my dedupe query was not a reading. Unauthenticated REST is also 403 here (both /repos/.../issues and /search/issues). Dedupe was completed on the zero-quota GitHub web payload channel with a live control that DID hit (query 'action-id lookup' returned 8 issue numbers including 6252; the two dedupe queries returned disjoint populations with no duplicate of either finding), so both findings are filed properly — but the next seat should know that search_issues answering 0 here means nothing.",
"options": [
"A — treat MCP search_issues as unavailable for this seat/container and make the web payload channel the documented dedupe route, control word mandatory",
"B — investigate why search_issues returns 0 for a query matching an issue it can read directly via issue_read (indexing lag vs. a scope/permission difference between the read and search paths)",
"C — do nothing; each dev re-discovers it per run"
],
"recommendation": "A plus B. A is cheap and immediate — the web channel is measured working here, costs no GraphQL quota, and the control discipline the dispatch already mandates is exactly what caught this. B matters because a silently-empty search is the worst failure shape available to a dedupe step: it looks like 'no duplicate exists' and it is unfalsifiable without a control, so a seat that skips the control files duplicates and never learns."
}
],
"out_of_scope_findings": [
"filed as objectui#7182: the action-id lookup now exists twice (record-quick-actions.tsx and containers.tsx) and the two copies DISAGREE on mixed arrays — quick_actions switches on the whole array (every element a string), page:header normalises per element — so during the migration this card opens, one authored array means two things depending on which renderer draws it. Not fixed here: unifying them changes record:quick_actions behaviour on a renderer this card does not own, and wants a ruling on whether a mixed array is a supported migration state.",
"filed as objectui#7183: no committed test can observe its own package's BUILT artifact — the root vitest alias sends every workspace specifier to src, and turbo's test task is dependsOn ['^build'] (dependencies only), so a dist-importing test is MODULE_NOT_FOUND / NOT MEASURED in CI. This card's acceptance criterion 3 explicitly asks for a built-artifact measurement; it was run by hand with a live control and passes, but it cannot be left behind as a standing pin."
]
}

Generated by Claude Code

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

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

Verdict: ACCEPT. Arming on green. Two judgement calls are ruled below, and one finding in this report is bigger than this card.

⭐ The finding that outranks the PR: search_issues is blind in this container, and its zero is not a reading

"A targeted search for this card's own near-verbatim title returned total_count 0, i.e. the known-must-hit CONTROL failed, so its earlier empty result for my dedupe query was not a reading."

This is the most valuable thing in the report and it is not about page:header at all. A silently-empty search is the worst failure shape available to a dedupe step: it renders as "no duplicate exists", it is indistinguishable from a real negative, and it is unfalsifiable without a control. A seat that skips the control files duplicates and never finds out.

It is also the seat's own instrument rule — a control that returns zero is no control; an empty output is not a 0 — applied one level up, to a channel rather than a grep. That generalisation is worth more than the rule it came from, and I am adopting it: the control belongs on the channel, not only on the query. Recording it as its own card rather than leaving it in a PR body, because it applies to every dev in this container and nothing in the repo currently says so. Dedupe here was completed on the zero-quota web payload channel with a control that did hit (action-id lookup → 8 issue numbers including #6252), so both out-of-scope findings are properly filed.

⚠️ Noting for the record that this does not impugn search_code, which this seat used successfully an hour ago (13 hits on 13855, with the returned paths independently confirmed against a local checkout). The blindness is specific to the issue-search path, which is exactly why a per-channel control is the answer rather than a blanket distrust.

A2.2 was my low-confidence assumption, and confirming it was worth the cost

I flagged a possible second page:header mount because #7021 found the record-title key in three spellings. Measured: exactly one canonical registration (ComponentRegistry.register('header', PageHeaderRenderer, {namespace:'page', skipFallback:true})), packages/layout/src/index.ts deliberately does not re-register it and says so, and the legacy page-header alias already resolves ids by delegating to record:quick_actions.

⭐ That last clause reframes the card: the sibling that already implements this ruling is the alias, not a second canonical mount. The acceptance criterion was not under-specified, and the hazard did not fire. A confirmed assumption that closes a named risk is a result, not a formality.

Ruling 1 — the built-artifact measurement was run and deliberately NOT committed: upheld

Acceptance criterion 3 ("the id path carries no body.source into the built artifact") was measured properly — the built packages/components/dist/index.js mounted by explicit relative import, an id-authored header whose resolved action carries an OS6252_DIST_BODY_MARKER body, marker absent from the rendered DOM, authored node byte-identical before and after render, and a live control proving the passing run measured the bundle (removing that one import fails expected undefined to be truthy, because page:header is unregistered in the light dom project).

And then it was not committed, because turbo's test task is dependsOn ['^build']dependencies only — so a committed dist-importing test would be MODULE_NOT_FOUND / NOT MEASURED in CI.

That is the right call and I want the reasoning preserved: a test that reports NOT MEASURED in CI is worse than no test, because it renders as coverage. Shipping it would have put a permanently-void assertion into the suite under the name of this card's hardest acceptance criterion. Filed as #7183 instead, with the committed half being the pin that can fail — "never writes a resolved def back onto the authored node" — carrying the object-shape authoring as its live control, since the inline form genuinely does drag the handler body into what it serializes. That control is what makes the id-side assertion falsifiable at all.

Ruling 2 — #7182, the divergence this card created: file, do not unify. Grading raised.

The action-id lookup now exists in two places and they disagree on mixed arrays: record:quick_actions switches on the whole array (every element a string), page:header normalises per element. So during the migration this card opens, one authored array means two things depending on which renderer draws it.

I asked for "no second resolver" and that fence held — the metadata read goes through the same useMetadataItem entry. What diverges is the array normalisation policy, which is a different thing and was not covered. ⚠️ But it is a real new dialect in a repo actively paying down dialects (#6771 is retiring one this hour), and unlike most findings it was introduced here rather than discovered. Declining to unify was still correct: it would change record:quick_actions behaviour on a renderer this card does not own, and whether a mixed array is a supported migration state is a ruling, not an implementation detail.

⇒ I am treating #7182 as the completion condition of this migration, not a loose end, and will grade it accordingly rather than letting it sit as an ordinary finding.

Also correct

  • Clause ② fence honoured — spec type and zod mirror untouched, actions stays z.array(z.string()). The renderer catches up to a declaration that was already public; nothing widens.
  • Unresolvable id renders nothing and warns ONCE, naming the object's declared action names, suppressed while the metadata read is in flight so a correct page never warns on first paint. That is the loud-over-silent option, and the in-flight suppression is the detail that makes it usable rather than noisy.
  • Equivalence proved by the same metadata authored twice — ids and inline objects — compared on the ordered button-name list and a normalised innerHTML projection, with the object-shape render asserted non-empty first, so an id-side green cannot come from two empty headers agreeing. One assertion covers every filter the acceptance criterion names, including qualify (order 1) rendering beforeconvert (order 2) — the reverse of the order both authorings list them in.
  • Ablation: Tests 7 failed | 1 passed (8), matching the 7/1 prediction exactly, with the single immovable case identified ("does not warn while the metadata lookup is still in flight" is true with or without resolution) — which is what distinguishes a targeted break from a collapsed population.
  • Two gates correctly reported NOT MEASURED on first run (check:doc-snippets exit 2 PRECONDITION NOT MET; check:sdui-registration-pins exit 2 and a TS2307 unbuilt closure) and re-run to real verdicts rather than scored as passes.
  • Docs updated at content/docs/guide/slotted-pages.md, which taught only the object form. ⛔ content/docs/releases/ untouched, correctly.

Nothing to change.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 10:05
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 8ec11e1Sep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-6252-page-header-action-ids branch September 1, 2026 10:19
Sign up for freeto 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.

page:header implements the declared action-id lookup — resolve actions: string[] like record:quick_actions does (objectstack#11592 ruling)

1 participant

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(components): page:header resolves declared action ids - #7180

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-6252-page-header-action-ids
Sep 1, 2026
Merged

fix(components): page:header resolves declared action ids#7180
os-warren merged 2 commits into
mainfrom
claude/issue-6252-page-header-action-ids

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#6252

Implements the objectstack#11592 ruling (maintainer, 2026-08-25, on recommendation B): the canonical page:header renderer resolves PageHeaderProps.actions as action ids.

The defect

@objectstack/spec declares actions: z.array(z.string()).optional().describe('Action IDs to show in header') (verified on the installed pin, @objectstack/spec@17.2.0, src/ui/component.zod.ts). The renderer read that array as ActionDefobjects and resolved nothing, so a header authored the way the published contract declares rendered zero buttons — satisfying the schema deleted the header.

Two sibling surfaces already read it as ids: record:quick_actions (packages/plugin-detail/src/renderers/record-quick-actions.tsx) resolves a string-valued actions out of the object's metadata, and layout:page-header reaches the same resolver by delegating its actions into a record:quick_actions node (packages/layout/src/PageHeader.tsx:196-208). So one authoring key meant two different things depending on which renderer drew the header.

What changed

packages/components/src/renderers/layout/containers.tsx:

  • Each element of actions is normalised at the top of the pipeline — a string is resolved against the object's own actions metadata, an object passes through. Resolution goes through the same useMetadataItem entryrecord:quick_actions uses; no second resolver, no second lookup path.
  • Because normalisation happens above the filters, one chain still runs: actionRendersAt placement (record_header / record_more), the requiredPermissions capability gate, visible / hidden, order, and the inline/overflow split are untouched by this change. That is what makes the equivalence claim testable at all.
  • An id that resolves to nothing renders nothing and warns once, naming the object's declared action names. Silently dropping it would reproduce the silent-loss class this repo keeps re-filing; it also makes a mistyped id indistinguishable from a correctly hidden one. The warning is suppressed while the metadata read is in flight, so a correctly-authored page does not warn on first paint.
  • The registered actions input description now says ids.

The one deliberate difference from record:quick_actions: that renderer switches on the whole array (rawActions.every(a =&gt; typeof a === 'string')); this one normalises per element, so a half-migrated ['convert', { … }] resolves the id and passes the object through. Same mechanism, wider arity — a page mid-migration is exactly the state this card creates.

Clause ② is not engaged. The declared type and its zod mirror are untouched — actions stays z.array(z.string()). The object shape survives as renderer tolerance, exactly as it does today, undeclared. No accept/reject behaviour of the contract moves.

Docs: content/docs/guide/slotted-pages.md now teaches the ids contract on the canonical header (it previously showed only the object form), with the inline-object form named as migration tolerance.

Acceptance criteria — evidence

Run at af40cf5c2 (working tree clean at that commit, so every run below read exactly this tree).

1. An id-authored header renders the same buttons as the object-shape authoring.

The same action metadata is authored twice and the two renders compared. The object-shape render is the live control: each equivalence case asserts it is non-empty first, so an id-side green can never come from two empty headers agreeing.

The population covers every filter the criterion names, so one equivalence assertion measures all of them: list_only (list_item — must not render), archive (record_more — must land in the ⋯ menu, not inline), gated (requiredPermissions the user lacks), closed_only (a CEL visible false for this record), and qualify (order: 1) rendering beforeconvert (order: 2), which is the reverse of the order both authorings list them in. Both the ordered button-name list and a normalised innerHTML projection are asserted equal.

2. The renderer's tests gain the id-shaped cases.packages/components/src/__tests__/page-header-action-ids.test.tsx — 8 cases (equivalence, the filter chain, record_more routing, unresolved-id warn-once with a resolvable sibling as control, no-warn-while-loading, mixed arrays, the properties.actions bridge spelling, and criterion 3).

pnpm exec vitest run --project dom-heavy packages/components/src/__tests__/page-header-action-ids.test.tsx
Test Files 1 passed (1) Tests 8 passed (8)

Ablation — direction predicted before running: red on the id path, green on the object-shape control. Mutation = feeding rawHeaderActions back into the filter chain (the minimal deletion of the fix). Proven on disk by marker count and blob hash, never by an editor's exit code:

PRE target-count=1 inject-count=0 PRE_HASH=3dafeb94… (== HEAD blob)
POST target-count=0 inject-count=1 POST_HASH=f87a37c9…
Tests 7 failed | 1 passed (8)

7 failed / 1 passed matched the prediction exactly: the only case that cannot move is "does not warn while the metadata lookup is still in flight", which is true with or without resolution. Restore proven by state, not exit code: restored blob 3dafeb94… equals the HEAD blob, and git diff HEAD, git diff --cached, git status --short are all empty. The mutation and restore legs need no rebuild here: the root vitest.config.mts aliases @object-ui/components to packages/components/src, so this suite resolves source, not dist.

3. The id path carries no body.source into the built artifact.

Measured two ways, and the built-artifact one is the load-bearing half.

Against the built artifact.pnpm --filter @object-ui/components build, then a run that mounts the builtpackages/components/dist/index.js (an explicit relative import — the workspace alias would otherwise redirect to src) with an id-authored header whose resolved action carries body: { language: 'js', source: '…OS6252_DIST_BODY_MARKER…' }. The id resolved through the built renderer, the marker appears nowhere in the rendered DOM, and the authored node's JSON.stringify is byte-identical before and after render. Live control: with that one import removed the same file fails expected undefined to be truthypage:header is unregistered in the light dom project, which proves the passing run measured the built bundle and nothing else. This measurement is not committed: turbo's test task is dependsOn: ["^build"] (dependencies only, not the package's own build), so a committed dist-importing test would be NOT MEASURED in CI rather than a pin. Filed as a follow-up.

As a committed pin.never writes a resolved def — and so no body.source — back onto the authored node asserts the authored node is unchanged by rendering and the marker is absent from the DOM, with the object-shape authoring as the live control: expect(JSON.stringify(objectAuthored)).toContain(BODY_MARKER) — the inline form does drag the handler body into what it serializes, which is what proves this assertion can fail at all.

Grep of the built bundle, with controls: did not resolve = 1 and page:header = 7 (present), OS6252_HANDLER_BODY_MARKER = 0.

Other verification

RunResult
vitest run packages/components scripts/__tests__/vitest-invocation-guard.test.ts224 files, 2070 passed
all page-header* + RecordDetailView.header* / predicate suites16 files, 184 passed
console registry/contract parity (5 files incl. registry-inputs-spec-parity, public-contract)156 passed
pnpm --filter @object-ui/components run type-checkexit 0 — and it covers the new test file (tsc -p tsconfig.test.json reported errors in it before they were fixed)
pnpm check:control-bytes✅ 5945 tracked text files
pnpm check:doc-types✅ every documented component type registered
pnpm check:doc-fences
pnpm check:doc-snippets✅ 272/272 blocks judged, 0 failed (after building its declared closure; its own resolution/sentinel/positive controls printed)
pnpm check:sdui-registration-pins✅ all 16 registrations present (after building the console + its closure)

eslint on all three changed source files: 0 errors (5 pre-existing-style no-explicit-any warnings in the new test, matching its sibling page-header tests; --max-warnings is deliberately unset in this repo). The lint run is narrowed and the narrowing is measured, not assumed: the population is the diff itself (git diff --name-only), the count comes from --format json (3 files linted), and the config is not type-aware — eslint.config.js contains zero projectService / parserOptions / project: occurrences against a live control of 10 rules hits in the same file — so no untouched file's verdict can move under this diff, which touches no config. The repo-wide farm is CI's run either way.

Not in this PR

hotcrm's migration of its 16 inline record_header objects to id references is downstream of this landing, as the card records. Nothing here touches it.


Generated by Claude Code

os-warrenand others added 2 commits September 1, 2026 09:24
`@objectstack/spec`'s `PageHeaderProps.actions` is `z.array(z.string())` —
"Action IDs to show in header" — but the canonical `page:header` renderer read
the array as ActionDef objects and resolved nothing, so metadata satisfying the
published contract rendered zero header buttons.
Resolve ids against the object's own metadata at the top of the actions
pipeline, through the same `useMetadataItem` entry `record:quick_actions` uses,
so the existing placement / capability / visible / order chain runs unchanged
over uniformly-shaped defs. Inline objects keep working as renderer tolerance
for the transition, per element, so a half-migrated array resolves.
An id that resolves to nothing warns once and renders nothing, naming the
object's declared action names.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
Document the contract the renderer now honours: the header names actions by id
and resolves them against the object's own metadata, with the inline-object
form named as migration tolerance rather than a second declared shape.
Adds the changeset and types the console-warning assertions in the new pin.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

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

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


📦 Bundle Size Report

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

Size Limits

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

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

os-dev-report

Same report as #6252 (comment 5492213936); posted here too because the dispatch asked for it on the PR.

{
"issue": 6252,
"status": "done",
"branch": "claude/issue-6252-page-header-action-ids",
"pr": "https://github.com/objectstack-ai/objectui/pull/7180",
"premise_still_valid": true,
"summary": "The canonical page:header renderer (packages/components/src/renderers/layout/containers.tsx) now resolves each element of `actions` as a declared action id against the object's own metadata, through the same useMetadataItem entry record:quick_actions uses — no second resolver. Normalisation sits at the TOP of the actions pipeline, so the existing single filter chain (actionRendersAt placement, requiredPermissions capability gate, visible/hidden, order, inline/overflow split) runs unchanged over uniformly-shaped defs; that is what makes the equivalence claim measurable. Inline ActionDef objects keep working per element, so a half-migrated ['convert', {def}] array resolves the id and passes the object through — renderer tolerance for the migration, still undeclared. An id that resolves to nothing renders nothing and warns ONCE naming the object's declared action names (suppressed while the metadata read is in flight, so a correct page never warns on first paint); silently dropping it would reproduce the silent-loss class this seat keeps filing. Clause 2 fence honoured: the spec type and its zod mirror are untouched, actions stays z.array(z.string()). Docs updated (content/docs/guide/slotted-pages.md taught only the object form) plus a minor changeset. ZONE 2 VERDICTS, all re-measured on this repo's head: A2.1 CONFIRMED (the pre-fix chain read .locations/.requiredPermissions/.visible/.order straight off each array element via actionRendersAt, no lookup anywhere). A2.2 CONFIRMED — exactly ONE canonical page:header registration, ComponentRegistry.register('header', PageHeaderRenderer, {namespace:'page', skipFallback:true}) in containers.tsx; packages/layout/src/index.ts deliberately does NOT re-register it and says so, and the legacy `page-header` alias already resolves ids by delegating to record:quick_actions — so the multi-spelling hazard the PM flagged does not fire here, and the sibling that already implements the ruling is the alias, not a second canonical mount. A2.3 CONFIRMED, cheap. A2.4 CONFIRMED — nothing pinned the rendered result of a string-authored page:header; the only two id-shaped fixtures in the repo pin VALIDATION only (examples/schema-catalog pins that actions:['export'] validates clean, which supports ids), so nothing had to be updated or deleted. A2.5 CONFIRMED with no seam: @object-ui/components already depends on @object-ui/react and already imports eight hooks from it, so useMetadataItem was reachable with no new dependency and no second context scope.",
"tests": "All runs at final commit af40cf5c2 with a clean working tree (git status --short empty), so every figure below read exactly that tree. Heavy runs went through the container's shared verify lock; verdicts read from its VERDICT line, exit codes captured before any pipe. (1) NEW PIN packages/components/src/__tests__/page-header-action-ids.test.tsx, registered in vitest.config.mts heavyDomTests (required — it renders through the ComponentRegistry): 'pnpm exec vitest run --project dom-heavy ...' gives Test Files 1 passed (1), Tests 8 passed (8). Shape of the proof: the SAME action metadata authored twice, once as ids and once as inline objects, both renders compared on the ordered button-name list AND a normalised innerHTML projection; the object-shape render is the LIVE CONTROL and is asserted non-empty first, so an id-side green cannot come from two empty headers agreeing. The population makes one equivalence assertion cover every filter the acceptance criterion names: list_item-only must not render, record_more must route to the overflow menu and never inline, a requiredPermissions the user lacks is denied, a CEL visible false for the record hides, and qualify (order 1) renders BEFORE convert (order 2) — the reverse of the order both authorings list them in. (2) ABLATION, direction predicted before running (red on the id path, green on the object-shape control). Mutation = feeding rawHeaderActions back into the filter chain. Proven on disk by BOTH marker count and blob hash, never by an editor's exit code: PRE target-count=1 inject-count=0 PRE_HASH=3dafeb94 equal to the HEAD blob; POST target-count=0 inject-count=1 POST_HASH=f87a37c9. Result: Tests 7 failed | 1 passed (8) — passing count asserted alongside the red, and 7/1 matched the prediction exactly (the one immovable case is 'does not warn while the metadata lookup is still in flight', true with or without resolution, so this is a targeted break and not a collapsed population). Restore proven by STATE not exit code: restored blob 3dafeb94 equals the HEAD blob, and git diff HEAD / git diff --cached / git status --short are all empty; the script carried an EXIT/INT/TERM trap with an absolute path. No rebuild needed on either leg and this was measured, not assumed: the root vitest.config.mts aliases @object-ui/components to packages/components/src, so the suite resolves SOURCE, never dist. (3) ACCEPTANCE CRITERION 3, measured against the BUILT ARTIFACT as the card demands. pnpm --filter @object-ui/components build, then a run mounting the built packages/components/dist/index.js by explicit relative import (the workspace alias would otherwise redirect to src) with an id-authored header whose resolved action carries body:{language:'js',source:'...OS6252_DIST_BODY_MARKER...'}: the id resolved through the BUILT renderer, the marker appears nowhere in the rendered DOM, and the authored node's JSON.stringify is byte-identical before and after render. LIVE CONTROL: with that one import removed the same file fails 'expected undefined to be truthy' (page:header is unregistered in the light dom project), which proves the passing run measured the built bundle and nothing else. That measurement is deliberately NOT COMMITTED — turbo's test task is dependsOn ['^build'], dependencies only, so a committed dist-importing test would be NOT MEASURED in CI rather than a pin; filed as objectui#7183. The committed half is the pin 'never writes a resolved def — and so no body.source — back onto the authored node', with the object-shape authoring as its live control: expect(JSON.stringify(objectAuthored)).toContain(BODY_MARKER) — the inline form DOES drag the handler body into what it serializes, which is what proves the id-side assertion can fail at all. Built-bundle greps with controls: 'did not resolve'=1 and 'page:header'=7 present, 'OS6252_HANDLER_BODY_MARKER'=0. (4) REGRESSION SURFACE: 'vitest run packages/components scripts/__tests__/vitest-invocation-guard.test.ts' gives 224 files, 2070 passed; all page-header* plus RecordDetailView.header*/predicate suites give 16 files, 184 passed; console registry/contract parity (registry-inputs-spec-parity, public-contract, ga-honoured-inputs-author-reach, component-input-union-specimens, record-block-record-reach) gives 156 passed. (5) TYPES: 'pnpm --filter @object-ui/components run type-check' exit 0 — note the script is spelled type-check (hyphen) in this repo — and it demonstrably COVERS the new test file: tsc -p tsconfig.test.json reported four TS7006 errors inside it before they were fixed, so this is not a typecheck that excludes tests. (6) GATES: check:control-bytes OK (5945 tracked text files); check:doc-types OK; check:doc-fences OK; check:doc-snippets OK, 272/272 blocks judged 0 failed, after building the closure it names (its first run was PRECONDITION NOT MET exit 2 — recorded as NOT MEASURED, then re-run, never as a pass); check:sdui-registration-pins OK, all 16 registrations present, after building the console plus its dependency closure (its first two runs were exit 2 'no console build to weigh' and a TS2307 unbuilt-closure error — both NOT MEASURED, not results). (7) LINT: eslint over all three changed source files, 0 errors (5 no-explicit-any WARNINGS in the new test matching its sibling page-header tests; lint.yml deliberately sets no --max-warnings). The narrowing is measured, not assumed, all three pieces: population is the diff itself via git diff --name-only; the count comes from --format json (3 files linted); and the config is not type-aware — eslint.config.js has zero projectService / parserOptions / project: occurrences against a live control of 10 'rules' hits in the same file — so with no config file in the diff no untouched file's verdict can move. The repo-wide farm is CI's run either way. CI convergence not awaited, per the standing dispatch contract.",
"mcp_calls": "12 — issue_read get + get_comments, create_pull_request, pull_request_read (body read-back), search_issues x2 (the second was the required control and it FAILED to hit, see open_questions), issue_write create x2, and this report's comments plus one read-back. Bulk reads went to git and to the zero-quota web payload channel instead.",
"open_questions": [
{
"question": "MECHANISM, not a code decision — the MCP `search_issues` channel appears blind in this container, and this seat's dedupe had to route around it. A targeted search for this card's own near-verbatim title returned total_count 0, i.e. the known-must-hit CONTROL failed, so its earlier empty result for my dedupe query was not a reading. Unauthenticated REST is also 403 here (both /repos/.../issues and /search/issues). Dedupe was completed on the zero-quota GitHub web payload channel with a live control that DID hit (query 'action-id lookup' returned 8 issue numbers including 6252; the two dedupe queries returned disjoint populations with no duplicate of either finding), so both findings are filed properly — but the next seat should know that search_issues answering 0 here means nothing.",
"options": [
"A — treat MCP search_issues as unavailable for this seat/container and make the web payload channel the documented dedupe route, control word mandatory",
"B — investigate why search_issues returns 0 for a query matching an issue it can read directly via issue_read (indexing lag vs. a scope/permission difference between the read and search paths)",
"C — do nothing; each dev re-discovers it per run"
],
"recommendation": "A plus B. A is cheap and immediate — the web channel is measured working here, costs no GraphQL quota, and the control discipline the dispatch already mandates is exactly what caught this. B matters because a silently-empty search is the worst failure shape available to a dedupe step: it looks like 'no duplicate exists' and it is unfalsifiable without a control, so a seat that skips the control files duplicates and never learns."
}
],
"out_of_scope_findings": [
"filed as objectui#7182: the action-id lookup now exists twice (record-quick-actions.tsx and containers.tsx) and the two copies DISAGREE on mixed arrays — quick_actions switches on the whole array (every element a string), page:header normalises per element — so during the migration this card opens, one authored array means two things depending on which renderer draws it. Not fixed here: unifying them changes record:quick_actions behaviour on a renderer this card does not own, and wants a ruling on whether a mixed array is a supported migration state.",
"filed as objectui#7183: no committed test can observe its own package's BUILT artifact — the root vitest alias sends every workspace specifier to src, and turbo's test task is dependsOn ['^build'] (dependencies only), so a dist-importing test is MODULE_NOT_FOUND / NOT MEASURED in CI. This card's acceptance criterion 3 explicitly asks for a built-artifact measurement; it was run by hand with a live control and passes, but it cannot be left behind as a standing pin."
]
}

Generated by Claude Code

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

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

Verdict: ACCEPT. Arming on green. Two judgement calls are ruled below, and one finding in this report is bigger than this card.

⭐ The finding that outranks the PR: search_issues is blind in this container, and its zero is not a reading

"A targeted search for this card's own near-verbatim title returned total_count 0, i.e. the known-must-hit CONTROL failed, so its earlier empty result for my dedupe query was not a reading."

This is the most valuable thing in the report and it is not about page:header at all. A silently-empty search is the worst failure shape available to a dedupe step: it renders as "no duplicate exists", it is indistinguishable from a real negative, and it is unfalsifiable without a control. A seat that skips the control files duplicates and never finds out.

It is also the seat's own instrument rule — a control that returns zero is no control; an empty output is not a 0 — applied one level up, to a channel rather than a grep. That generalisation is worth more than the rule it came from, and I am adopting it: the control belongs on the channel, not only on the query. Recording it as its own card rather than leaving it in a PR body, because it applies to every dev in this container and nothing in the repo currently says so. Dedupe here was completed on the zero-quota web payload channel with a control that did hit (action-id lookup → 8 issue numbers including #6252), so both out-of-scope findings are properly filed.

⚠️ Noting for the record that this does not impugn search_code, which this seat used successfully an hour ago (13 hits on 13855, with the returned paths independently confirmed against a local checkout). The blindness is specific to the issue-search path, which is exactly why a per-channel control is the answer rather than a blanket distrust.

A2.2 was my low-confidence assumption, and confirming it was worth the cost

I flagged a possible second page:header mount because #7021 found the record-title key in three spellings. Measured: exactly one canonical registration (ComponentRegistry.register('header', PageHeaderRenderer, {namespace:'page', skipFallback:true})), packages/layout/src/index.ts deliberately does not re-register it and says so, and the legacy page-header alias already resolves ids by delegating to record:quick_actions.

⭐ That last clause reframes the card: the sibling that already implements this ruling is the alias, not a second canonical mount. The acceptance criterion was not under-specified, and the hazard did not fire. A confirmed assumption that closes a named risk is a result, not a formality.

Ruling 1 — the built-artifact measurement was run and deliberately NOT committed: upheld

Acceptance criterion 3 ("the id path carries no body.source into the built artifact") was measured properly — the built packages/components/dist/index.js mounted by explicit relative import, an id-authored header whose resolved action carries an OS6252_DIST_BODY_MARKER body, marker absent from the rendered DOM, authored node byte-identical before and after render, and a live control proving the passing run measured the bundle (removing that one import fails expected undefined to be truthy, because page:header is unregistered in the light dom project).

And then it was not committed, because turbo's test task is dependsOn ['^build']dependencies only — so a committed dist-importing test would be MODULE_NOT_FOUND / NOT MEASURED in CI.

That is the right call and I want the reasoning preserved: a test that reports NOT MEASURED in CI is worse than no test, because it renders as coverage. Shipping it would have put a permanently-void assertion into the suite under the name of this card's hardest acceptance criterion. Filed as #7183 instead, with the committed half being the pin that can fail — "never writes a resolved def back onto the authored node" — carrying the object-shape authoring as its live control, since the inline form genuinely does drag the handler body into what it serializes. That control is what makes the id-side assertion falsifiable at all.

Ruling 2 — #7182, the divergence this card created: file, do not unify. Grading raised.

The action-id lookup now exists in two places and they disagree on mixed arrays: record:quick_actions switches on the whole array (every element a string), page:header normalises per element. So during the migration this card opens, one authored array means two things depending on which renderer draws it.

I asked for "no second resolver" and that fence held — the metadata read goes through the same useMetadataItem entry. What diverges is the array normalisation policy, which is a different thing and was not covered. ⚠️ But it is a real new dialect in a repo actively paying down dialects (#6771 is retiring one this hour), and unlike most findings it was introduced here rather than discovered. Declining to unify was still correct: it would change record:quick_actions behaviour on a renderer this card does not own, and whether a mixed array is a supported migration state is a ruling, not an implementation detail.

⇒ I am treating #7182 as the completion condition of this migration, not a loose end, and will grade it accordingly rather than letting it sit as an ordinary finding.

Also correct

  • Clause ② fence honoured — spec type and zod mirror untouched, actions stays z.array(z.string()). The renderer catches up to a declaration that was already public; nothing widens.
  • Unresolvable id renders nothing and warns ONCE, naming the object's declared action names, suppressed while the metadata read is in flight so a correct page never warns on first paint. That is the loud-over-silent option, and the in-flight suppression is the detail that makes it usable rather than noisy.
  • Equivalence proved by the same metadata authored twice — ids and inline objects — compared on the ordered button-name list and a normalised innerHTML projection, with the object-shape render asserted non-empty first, so an id-side green cannot come from two empty headers agreeing. One assertion covers every filter the acceptance criterion names, including qualify (order 1) rendering beforeconvert (order 2) — the reverse of the order both authorings list them in.
  • Ablation: Tests 7 failed | 1 passed (8), matching the 7/1 prediction exactly, with the single immovable case identified ("does not warn while the metadata lookup is still in flight" is true with or without resolution) — which is what distinguishes a targeted break from a collapsed population.
  • Two gates correctly reported NOT MEASURED on first run (check:doc-snippets exit 2 PRECONDITION NOT MET; check:sdui-registration-pins exit 2 and a TS2307 unbuilt closure) and re-run to real verdicts rather than scored as passes.
  • Docs updated at content/docs/guide/slotted-pages.md, which taught only the object form. ⛔ content/docs/releases/ untouched, correctly.

Nothing to change.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 10:05
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 8ec11e1Sep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-6252-page-header-action-ids branch September 1, 2026 10:19
Sign up for freeto 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.

page:header implements the declared action-id lookup — resolve actions: string[] like record:quick_actions does (objectstack#11592 ruling)

1 participant

@os-warren