Skip to content

feat(print): render a page that declares print — print CSS, printable blocks whole, record print action (objectui#11958) - #12356

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-11958-print-page-render
Oct 14, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-11958-print-page-render

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #11958

Clause-②: yes

Card ② of the ruling of record 6051470224 on objectstack#8346 (letter B′): a page that declares print now prints as a document, the printable blocks reach paper whole, and the record page offers a Print action (M1, zero server).

What changed

Area Where it landed What it does
Print stylesheet packages/components/src/renderers/layout/page-print.ts (new), page.tsx PageRenderer stamps data-print-page on a page whose print is an object, mounts a stylesheet built from the declaration, and publishes the declaration through PrintPageProvider. Every rule is inside @media print.
Running header / footer page.tsx (RunningRegions) With repeatHeader / repeatFooter, the page's own header / footer region goes in a real thead / tfoot. On screen the table, its sections, rows and cells are display: contents, so the regions stay the layout's flex children.
Print context packages/react/src/context/PrintPageContext.tsx (new) PrintPageProvider, usePrintPage() and PrintPageContext. usePrintPage() returns the page's print declaration, or null outside a print page.
record:line_items packages/plugin-form/src/LineItemsPanel.tsx On a print page the read asks for one probe row past the cap and $count: true. When the probe comes back, the panel draws the cap's rows and the shared NonGridRowCeilingNote under them. The note names both numbers when the server reports a total. Off a print page the wire and the panel are unchanged.
record:details packages/plugin-detail/src/DetailSection.tsx A collapsible section draws open on a print page, because Radix unmounts a closed section's rows.
Record page Print action packages/app-shell/src/views/RecordDetailView.tsx When the page bound to the record declares print, a Print button (record-print-button) calls window.print(). Other record pages get no button.
PDF sentence types/src/objectql.ts, ListView.tsx (two comments), ReportViewer.tsx, app-shell styles.css, app-shell DashboardView.tsx Rewritten the way card ① rewrote LIST_VIEW_EXPORT_PDF_RETIRED: a printable document is a page that declares print. DashboardView.tsx is a fifth site in app-shell that card ①'s measurement did not list.
Docs content/docs/guide/print-pages.md (new, plus meta.json), packages/react/README.md The print page, each key's print CSS, the blocks that print whole, and the Print action.

Landing point outside the claimed surface: the page renderer is PageRenderer in @object-ui/components. It renders every page's regions, for both PageView and RecordDetailView, so it owns the print stylesheet. The context had to live in @object-ui/react so that plugin-form and plugin-detail can read it. The line-items and detail-section edits are in their own packages because those packages own the two block renderers.

Per key: read now? (ledger rows in @objectstack/spec/liveness/page.json, all planned naming this card)

Key Read now Print CSS
print (the declaration) yes stamps the page, mounts the sheet, publishes the context
paperSize yes @page { size: SIZE ORIENTATION }, A4 when omitted
orientation yes same rule, portrait when omitted
margins.top / .right / .bottom / .left yes one @page margin-SIDE: Nmm per declared side. A side left out keeps the console's 12mm default from styles.css.
repeatHeader yes header region in a thead
repeatFooter yes footer region in a tfoot
pageNumbers yes @bottom-right { content: counter(page) " / " counter(pages) }
repeatTableHeaders yes thead { display: table-header-group } (on when omitted). false gives table-row-group.
avoidBreakInside yes break-inside: avoid on each block of a region

The ledger flip (planned to live, dropping authorWarn / authorHint) is an objectstack edit at the .objectui-sha pin that carries this PR. This PR does not make that edit.

Real-render measurement (Chromium 141.0.7390.37, one-off, not a repo file)

A replica of the RecordDetailView / PageRenderer DOM was printed to PDF. It had the shell's h-svh / overflow-hidden chain, the record view's own chrome and fallback panel, and a 120-line table in main. The stylesheet was the one buildPagePrintStylesheet emits for { paperSize: 'A5', repeatHeader: true, repeatFooter: true, pageNumbers: true, margins: { top: 12, bottom: 12, left: 10, right: 10 } }. Per-sheet text came from pdftotext:

  • Print page: 5 sheets, page size 420 x 594.96 pts (A5). The letterhead, the footer and the table's thead appear on all 5 sheets. The page numbers read 1/5 … 5/5. All 120 lines are present, and no console chrome string appears.
  • Control (same DOM, no print page): 1 sheet at the default Letter size. 3 chrome strings are present and only 38 of the 120 lines, because the viewport chain clips the rest.
  • First attempt, kept as a finding: the running regions were first drawn with display: table-header-group on the region divs. In Chromium that printed the letterhead on sheet 1 only and the footer on the last sheet only. A real thead / tfoot repeats on every sheet; a CSS-display table does not, and isolated experiments showed the same. That result is why the regions sit in a real table that is display: contents on screen.
  • Screen identity: with and without the display: contents table, the bounding boxes of the header, main and footer regions are identical (identical true).

Tests (head 8817cba)

New pins:

  • packages/components/src/__tests__/page-print-stylesheet-11958.test.tsx (15). One case per key, the injection boundary, the real render through SchemaRenderer, the running-region table, and controls. A print page with no running regions is DOM-identical to the same page without print, apart from the attribute and the style element.
  • packages/plugin-form/src/__tests__/lineItemsPrintRowCap-11958.test.tsx (6). The probe row and $count, the note with and without a reported total, and three controls.
  • packages/plugin-detail/src/__tests__/detailSectionPrintPage-11958.test.tsx (2).
  • packages/app-shell/src/views/RecordDetailView.printAction-11958.test.tsx (2). The action calls the print preview, and the page is a print page. Control: no print means no action and no print page.
  • packages/app-shell/src/__tests__/printable-blocks-registered-11958.test.ts. Every type in the spec's PRINTABLE_PAGE_COMPONENT_TYPES (read from @objectstack/spec/ui, with no second list kept) has a registered renderer, plus a control.

Runs, all from the repo root under the shared verify lock:

  • Line-items set, plugin-form files that mention LineItemsPanel / record:line_items: Test Files 23 passed (23), Tests 286 passed (286).
  • Detail and page set, plugin-detail files that mention DetailSection / detail-section / record:details, plus components files that mention the page renderer or regions: Test Files 75 passed | 1 skipped (76), Tests 830 passed | 8 skipped (838).
  • App-shell and touched surfaces: every RecordDetailView.*.test.tsx, the PageView tests, page-kind-writing-end-9718, print-stylesheet-4462, the product-neutral ratchet, the list / report print-button semantics, objectql.exportOptions and iconOnlyButtonCensus-12143: Test Files 72 passed (72), Tests 495 passed (495).
  • Declared narrowing: a whole-package run of plugin-form, plugin-detail and components did not finish on the shared box. It was stopped after 40 minutes with no output, and a plugin-form-only retry timed out at 8 minutes. The full farm is CI's. NOT MEASURED locally: full-package suites of plugin-form / plugin-detail / components / app-shell.

type-check exit codes (tsc --noEmit plus the test config): types 0, react 0, components 0, plugin-form 0, plugin-detail 0, plugin-list 0, plugin-report 0, app-shell 0. Components, plugin-form and app-shell were re-run after the last test edits.

Gates on 8817cba, each exit 0: check:phantom-deps, check:self-import, check:unreferenced-sources, check:doc-example-readers, check:handler-key-reads, check:side-effects-array, check:element-data-source-declaration, check:spec-symbols, check:action-forward-parity, check:designer-field-key-parity, check:icon-record-names, check:i18n-keys, check:i18n-drift, check:i18n-designer-parity, check:doc-json, check:doc-fences, check:doc-example-ids, check:doc-types, check:docs-route-closure, check:new-line-citations, check-changeset-fixed, check-changeset-presence, check-changeset-no-major.

  • check:doc-json first caught a real finding in the new guide: record:details columns must be a string enum. The example now carries none, and the gate judges 242 documents accepted and 0 refused.
  • NOT MEASURED: check:doc-examples / check:doc-snippets stopped at PRECONDITION NOT MET (exit 2) because seven packages outside this diff are unbuilt; the new guide has no TS fence. check:readme-exports exited 1 for the same unbuilt-package reason: 94 self-imports could not be judged. It reported 0 wrong-path and 0 fabricated, and the new @object-ui/react README snippet resolves against the rebuilt dist.

ESLint on the 16 changed TS/TSX files (--format json): 16 files, 0 errors, exit 0. This narrowing is complete because no parserOptions.project / projectService appears in any eslint config, so linting is not type-aware and this diff cannot move another file's verdict. The warnings on added lines are the moved Record of string to any cast in LineItemsPanel and the react-refresh/only-export-components pair on the new context file, which has the same shape as PreviewModeContext.

Clause ② — measured on the built declarations

  • @object-ui/react widens. dist/context/PrintPageContext.d.ts is re-exported by dist/context/index.d.ts, so PrintPageContext, PrintPageProvider and usePrintPage are new public exports. Control: usePreviewMode resolves in dist/context/PreviewModeContext.d.ts. At base b9814b3, git grep usePrintPage -- packages/react/src exits 1, and the control usePreviewMode exits 0. The changeset is minor.
  • @object-ui/types does not widen in this PR. PageNodeSchema already carried print by reference (Omit of the spec Page over PAGE_SPEC_EXCLUDED) since objectui#12030's @objectstack/spec 18.0.0-next.0 bump; type-check confirms schema.print is typed as the spec enum. This PR's diff under packages/types/src is comment lines only, so the mirror the claim anticipated was already in place.
  • @object-ui/components emits dist/renderers/layout/page-print.d.ts, but no .d.ts reachable from the package's only export . references it, so its public surface is unchanged. The behaviour change is minor.

Acceptance notes (not filed)

  • The binding fork (open question in the report). "The bound page" is read here as the record page usePageAssignment already picks for the object, which is the first type: 'record' page. Binding a SEPARATE print page next to a normal record page would need a selection rule this repo does not have. Today a second record page for the same object never renders. That needs a decision and is not done here.
  • The other objectstack#1301 "declined" sentences outside this card's surface: plugin-grid/src/ObjectGrid.tsx (export-format comment), and the headers of print-stylesheet-4462.test.ts, ListView.printButtonSemantics.test.tsx, ReportViewer.printButtonSemantics.test.tsx, exportGate.test.tsx and objectql.exportOptions.test.ts. They are comments, and no carrier is named.
  • record:highlights values use truncate (an ellipsis on a long value), so a very long highlight value is cut on paper as it is on screen. Not changed here.
  • A record:line_items panel on a print page is still the editable grid. On paper it prints its Save button and inputs as drawn.

Implemented by the dispatched os-dev run in session https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB (seat domain:ui#2).


Generated by Claude Code

…le blocks whole, record print action (objectui#11958)

PageRenderer mounts a print stylesheet built from `page.print` (@page size,
orientation, margins and page numbers; running header and footer regions;
repeated table heads; break-inside) and publishes the declaration through
PrintPageContext. record:line_items names its row cap on a print page instead
of cutting lines in silence; a collapsible record:details section draws open.
The record detail view offers a Print action when its page declares `print`.
The leftover "PDF export declined" sentences now point at print pages.

Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB
Co-authored-by: Claude <noreply@anthropic.com>
…ecord print action; docs and changeset (objectui#11958)

Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB
Co-authored-by: Claude <noreply@anthropic.com>
…play: contents on screen (objectui#11958)

Measured in Chromium 141: a div given display: table-header-group printed once,
on the first sheet; a real thead / tfoot repeated on every sheet. The running
regions now sit in a table whose boxes are display: contents on screen, so the
layout is unchanged there, and the print sheet turns it into a table on paper.

Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB
Co-authored-by: Claude <noreply@anthropic.com>
…e; test literal typed (objectui#11958)

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

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 72 chunks) 3053.8 KB 3119.4 KB
Main entry chunk (gzip) 73.8 KB 350 KB
Entry file index-B-23LG5z.js —
Status PASS —

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


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 20.69KB 7.62KB
app-shell (runtime-config.js) 22.59KB 7.89KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.11KB 3.87KB
auth (ActiveOrganizationStorage.js) 27.95KB 10.04KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.22KB 10.61KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.40KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.72KB 2.24KB
auth (SocialSignInButtons.js) 9.70KB 3.93KB
auth (UserMenu.js) 3.39KB 1.21KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 41.19KB 11.12KB
auth (createAuthenticatedFetch.js) 8.54KB 3.46KB
auth (index.js) 3.63KB 1.64KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 27.26KB 8.03KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.28KB 2.60KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (commentAuthorship.js) 1.39KB 0.78KB
collaboration (index.js) 1.93KB 0.85KB
collaboration (useCollaborationTranslation.js) 6.50KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 592.14KB 142.83KB
core (index.js) 10.18KB 4.04KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 248.41KB 69.48KB
fields (index.js) 279.74KB 71.33KB
i18n (LocalizationContext.js) 2.92KB 1.42KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 2.59KB 1.22KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.52KB 2.39KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 39.35KB 12.88KB
i18n (translateFn.js) 0.20KB 0.18KB
i18n (useDisplayLocale.js) 3.52KB 1.76KB
i18n (useObjectLabel.js) 36.87KB 9.88KB
i18n (useSafeTranslation.js) 7.14KB 2.92KB
layout (index.js) 40.26KB 11.71KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 6.62KB 2.45KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 5.52KB 2.10KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 15.35KB 5.51KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.82KB 2.38KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.33KB 3.07KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 16.04KB 3.92KB
plugin-calendar (index.js) 55.05KB 16.09KB
plugin-charts (index.js) 84.72KB 23.27KB
plugin-chatbot (index.js) 201.72KB 48.07KB
plugin-dashboard (index.js) 143.63KB 38.79KB
plugin-designer (index.js) 233.77KB 50.02KB
plugin-detail (index.js) 259.78KB 68.65KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 178.88KB 46.47KB
plugin-gantt (index.js) 178.63KB 45.19KB
plugin-grid (index.js) 258.97KB 72.23KB
plugin-kanban (index.js) 53.23KB 16.71KB
plugin-list (index.js) 123.64KB 31.63KB
plugin-map (index.js) 27.24KB 9.03KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 46.66KB 13.13KB
plugin-timeline (index.js) 39.21KB 11.85KB
plugin-tree (index.js) 15.07KB 5.33KB
plugin-view (index.js) 91.99KB 23.23KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 12.07KB 3.68KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 120.63KB 39.56KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.50KB 2.06KB
react (schema-input.js) 4.31KB 2.07KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (body-dialect.js) 4.50KB 1.99KB
sdui-parser (codegen.js) 9.45KB 3.76KB
sdui-parser (dashboard-widget-options.js) 3.07KB 1.30KB
sdui-parser (index.js) 7.30KB 3.12KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 25.28KB 7.81KB
sdui-parser (provenance.js) 3.84KB 1.90KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 23.87KB 7.83KB
types (ai.js) 4.39KB 2.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 4.12KB 1.61KB
types (authoring-nodes.js) 0.20KB 0.19KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (cloud.js) 0.20KB 0.18KB
types (complex.js) 4.44KB 2.07KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (dashboard-widget-layout.js) 2.06KB 0.96KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 1.13KB 0.65KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 5.78KB 2.70KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 5.00KB 2.39KB
types (navigation.js) 0.20KB 0.18KB
types (node-slots.js) 7.18KB 2.34KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 2.52KB 1.31KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 4.99KB 1.96KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 19.93KB 7.26KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.48KB 3.50KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 8817cba1d0927bc43ebc25d6637b4ccb3901ef47
Local-runs: none

Inputs read: card #11958 (body, restart 6159855155, claim 6161597014, os-dev-report 6163216257); PR #12356 body, its 21-file list and the net diff against main (base b9814b3); the 34 check-runs on this head; at the head, package.json exports and the entry barrel of @object-ui/react, @object-ui/components (plus renderers/layout/index.ts), @object-ui/types, @object-ui/plugin-form, @object-ui/plugin-detail, @object-ui/app-shell, @object-ui/plugin-list, @object-ui/plugin-report. The PR head is still 8817cba.

① Derived judgments

Gate verdicts on the head. 31 check-runs success, 3 skipped (dependabot, the coverage shards), 1 failure: Spec Main Shape Gate. Its annotations name packages/react/src/hooks/useViewData.ts (124–125, TS2367) and packages/core/src/adapters/resolveDataSource.ts (59–62, TS2678/TS2339), each "compiled against @objectstack/spec built from objectstack-ai/objectstack@b5b1bf7" — a dataSource kind union on objectstack main that no longer carries "api". Neither file is in this PR's file list, and nothing in the diff touches dataSource kinds; the failure is pre-existing drift between objectui main and the unreleased spec main, not this head's. The 12 test shards, the dist pins, Type Check, Lint, Repo Gates, Build Docs, Build & E2E and the four changeset gates all pass, which answers the dev's declared narrowing (full-package suites of plugin-form / plugin-detail / components / app-shell NOT MEASURED locally; check:doc-examples / check:doc-snippets / check:readme-exports stopped at a build precondition).

Public-surface changes the diff implies, package by package:

  • @object-ui/react — widens, right. packages/react/src/context/index.ts gains export * from './PrintPageContext.js', and the entry barrel re-exports ./context/index.js, so PrintPageContext, PrintPageProvider and usePrintPage are new public exports under the package's only export .. The value type is PagePrint from @objectstack/spec/ui, which the package already depends on (^18.0.0-next.0), so no phantom dependency. usePrintPage() is safe outside a provider (null default). Claim Clause-②: yes is the right declaration.
  • @object-ui/types — does not widen, right. The diff under packages/types/src is the ExportFormat doc comment only. print on PageNodeSchema rides the spec Page by reference (the Omit over PAGE_SPEC_EXCLUDED from objectui#12030), which is what page-print.ts reads as NonNullable of PageNodeSchema['print']. No mirror was needed and none was added.
  • @object-ui/components — public surface unchanged, right. renderers/layout/page-print.ts is new and exports buildPagePrintStylesheet, isPagePrintDeclaration, PRINT_PAGE_ATTRIBUTE, PRINT_RUNNING_ATTRIBUTE and the PagePrintDeclaration type, but the entry barrel exports ./ui, ./custom, ./notifications, ./debug, ./share and named symbols only; renderers/layout/index.ts side-effect-imports ./page and page.tsx only imports from ./page-print without re-exporting. The test file reaches the module by relative path. So the package publishes a behaviour change (the registered PageRenderer now stamps data-print-page, mounts a style[data-print-stylesheet] and wraps the page in PrintPageProvider when schema.print is an object) and no new symbol.
  • @object-ui/plugin-form, @object-ui/plugin-detail, @object-ui/app-shell — behaviour only, no new exports, right. LineItemsPanel, DetailSection and RecordDetailView change internally; the barrels are untouched. app-shell's RecordDetailView adds a Button with data-testid="record-print-button" only when the rendered page's print is a non-array object.
  • @object-ui/plugin-list, @object-ui/plugin-report — comment-only, right. Two comments in ListView.tsx, one in ReportViewer.tsx; no runtime text and no export moves.

Accept-set changes the diff implies: none on the authoring side. The renderer reads print as "any non-array object" (isPagePrintDeclaration) and clamps values at the stylesheet boundary (enum members for paperSize / orientation, finite non-negative numbers for margins, exact true for the booleans); every other value falls back to the spec default. That is an injection boundary below the spec's parse-time refusal, not a second contract, and the pin for it is in page-print-stylesheet-11958.test.tsx. On the wire, record:line_items on a print page asks for $top: cap + 1 and $count: true and slices the probe row off before drawing — the same construction applyNonGridRowCeiling already uses in core, so no new query shape reaches an adapter.

"The page renders on screen exactly as before" (card item 1), judged on the diff:

  • A page that does NOT declare print: PageRenderer computes print = null, mounts no style element, no attribute, no provider, and returns pageContent as before. RunningRegions returns a plain fragment of header, body, footer when neither repeatHeader nor repeatFooter is true, so each of the four templates (RegionLayout, HeaderSidebarMainTemplate, ThreeColumnTemplate, DashboardTemplate) renders the same flex children in the same order as the pre-diff markup (I compared the removed and added JSX of each: header, body wrapper, footer; HeaderSidebarMainTemplate had no footer before and passes none now). DetailSection's isCollapsed is collapsedState when usePrintPage() is null. LineItemsPanel's read is $top: top with no $count, and rowCap stays null; printing is in the load dependency list but is constant for a page, so it triggers no extra read. RecordDetailView draws no button. Right — unchanged. The components pin "a print page without running regions is DOM-identical to the plain page apart from the attribute and the style element" is the right control, and the app-shell / plugin-form / plugin-detail pins each carry a no-print control.
  • A page that declares print with repeatHeader / repeatFooter: the DOM gains a table[data-print-running] whose table, section, row and cell boxes carry class="contents" and role="presentation", so the regions stay the flex column's children on screen (the dev's Chromium bounding-box measurement says identical boxes; the pin checks every box has contents). This is the one screen-side DOM change, confined to print pages that opt into running regions, and it is the price of a real thead / tfoot, which the dev measured as the only construct Chromium repeats. Accepted as within the card's "print only adds the print stylesheet" reading: nothing visible moves.
  • Every emitted rule is inside @media print { (pinned as the first and last text of the sheet). The global hide rule (body :not(:has(page)):not(page):not(page *) { display: none }) and the ancestor-release rule only exist on a document that renders a print page, and only on paper.

Card items 2–4. (2) The printable subset is read from PRINTABLE_PAGE_COMPONENT_TYPES in @objectstack/spec/ui by printable-blocks-registered-11958.test.ts with no second list in the repo; the known gap from card ①'s review (record:line_items silent cap) is closed by the visible NonGridRowCeilingNote naming the cap and, when reported, the total; a collapsed record:details section draws open. (3) The Print action calls window.print() from the record view and is offered only where the bound page declares print. (4) The "declined NOT_PLANNED" sentence is rewritten at all four measured sites (types/src/objectql.ts, ListView.tsx twice, ReportViewer.tsx, app-shell styles.css) and the fifth the dev found (DashboardView.tsx); the ListView menu-format reasoning and the ReportViewer button semantics are unchanged in behaviour.

② Semver level

The changeset .changeset/11958-print-page-render.md declares minor for @object-ui/react, @object-ui/components, @object-ui/plugin-form, @object-ui/plugin-detail, @object-ui/app-shell and patch for @object-ui/types, @object-ui/plugin-list, @object-ui/plugin-report. Judged against what the diff publishes per package (① above): react adds three exports — minor, right; components / plugin-form / plugin-detail / app-shell add a feature with no symbol change and no removed behaviour — minor, right; types / plugin-list / plugin-report are comment-only — patch, right. No major: nothing is removed or renamed, and a page without print is byte-identical. The changeset body names the new react exports by name. Changeset Declaration, Bump Policy, Claim Re-read, Fixed Group Check and Overwrite Report are all success on this head.

Clause-②: yes on the claim and in the PR body matches the measured widening of @object-ui/react. The --pair exit code the dispatch asked for is retired (ruling 5770886272 on objectstack#19061, per clause2-line.mjs); the dev's "NOT MEASURED" for it is the right spelling.

③ Boundary flags

  • File-surface breach (claim named app-shell, types, and the PDF sentence sites; the diff lands in components, react, plugin-form, plugin-detail and content/docs). Answered: the producer of each change is the package that owns it — PageRenderer in components renders every page's regions for both PageView and RecordDetailView; the context must be readable from plugin-form and plugin-detail, hence react; the two block renderers live in their plugins. Explained in the report and the PR body, with no edit outside those owners. Accepted.
  • Fifth PDF-sentence site (DashboardView.tsx). Inside card item 4's instruction ("objectui's leftover sentence … is rewritten"); card ① listed four by measurement, the dev found one more. Accepted.
  • Declared narrowing of local test and gate runs; a 40-minute stalled vitest run stopped by PID; check:doc-examples / check:doc-snippets / check:readme-exports at precondition. Answered by this head's check-runs: 12 test shards, dist pins, Build Docs and Repo Gates are success. The unidentified stalling file is noted by the dev as unreproduced, not a filing class; nothing in this diff changes test infrastructure. Accepted.
  • Chromium probe run from scratchpad on repo source, no repo file. A measurement whose reading is in the PR body; not a repo artefact; no gate reads it. Accepted.
  • Harness PR footer replaced by the role file's session-URL footer. A reporting-form deviation with no effect on the diff. Noted.
  • Open question 1 — binding a SEPARATE print page to a record. Answered A for this head: the ruling's M1 clause is "a 'print' action on the record page that opens the browser's print preview of the bound page", and the only binding the spec and usePageAssignment know is the record page assigned to the object; no spec key names a second page, so B would be a spec contract-door card in objectstack, and C would be an undeclared selection rule. The PR reads A, documents it, and offers the action only on a bound page that declares print. Escalated as a note, not a defect: a domain:spec card is the right carrier if a business pull for a separate print document appears; none is measured today.
  • Open question 2 — needs:contract-review on PR feat(print): render a page that declares print — print CSS, printable blocks whole, record print action (objectui#11958) #12356. Answered per the seat's policy: the label is the waiting marker on the PR; the seat that adopts this PASS removes it. It stays until adoption. This record does not touch labels.
  • Out-of-scope findings. The page.json ledger rows print.* (planned, naming this card) flip to live in objectstack at the next .objectui-sha pin bump — carrier named (objectstack seat), the right place, since the ledger is an objectstack file. The remaining objectstack#1301 "declined" sentences in comments and test headers, the record:highlights ellipsis, and the editable line-items grid printing its inputs on a print page (an author sets readonly: true, as the guide's example does) are residuals with no carrier, noted and not filed. None blocks this card.
  • One residual for the adopting seat to carry forward, not a FAIL: the print sheet hides everything on the document that is not the print page or its ancestors, so a document that renders two print pages at once (none exists today — a record view renders one page) would need the rule scoped; no diff change is needed now.

Implemented-by: claude/issue-11958-print-page-render
Reviewed-by: session_01XVp19E1MYUTFM3dnkwz8gB

VERDICT: PASS


Generated by Claude Code

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

Projects

None yet

2 participants