Skip to content

fix(print): window.print() produces a usable page and the Print buttons say what they do (#4462) - #4488

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-4462-print-usable-page
Aug 12, 2026
Merged

fix(print): window.print() produces a usable page and the Print buttons say what they do (#4462)#4488
yinlianghui merged 1 commit into
mainfrom
claude/issue-4462-print-usable-page

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Closes #4462

The defect

Three surfaces expose a Print control backed by a bare window.print()plugin-list/src/ListView.tsx, plugin-report/src/ReportViewer.tsx and app-shell/src/views/DashboardView.tsx. With no print stylesheet the browser printed the whole console (sidebar, top bar, chat rail, toasts) with the data table clipped to a single viewport, because the shell is a viewport-height flex chain: h-svh on the SidebarProvider, then flex-1 min-h-0 overflow-hidden all the way down to the grid's scroller. Nothing said what the control was, so it was being accepted at the reporting project against an "export to PDF" requirement.

The ruling this implements

Per the ruling on #4462 (option 1 + option 2 as a rider; no removal, no headless detection). A real print/PDF primitive stays out of scope — objectstack-ai/objectstack#1301, closed NOT_PLANNED — and is not re-litigated here.

1. A shared print stylesheet at the app-shell level. packages/app-shell/src/styles.css — the centralized styles entry host apps import (apps/console/src/index.css does exactly that) — gains one @media print block serving list, report and dashboard alike. It hides the shell chrome, prints the active content area full-width, releases the viewport-height chain so long tables paginate instead of clipping, repeats table header rows on every sheet, keeps a row from splitting across a page break, and neutralises dark mode (which otherwise prints white-on-white, since browsers drop background fills but keep foreground colours).

The un-clipping rules match on Tailwind class substrings (main [class*='overflow-'] and friends). That is not a heuristic: AGENTS.md section 2 bans inline style={{}} and CSS modules, so every overflow-* / h-full / max-h-* constraint in the render tree is a utility class. This is why plugin-list needs no print rules of its own — its root, its view container and the grid's scroller are all reached from the shared sheet (measured below).

2. The Print controls say what they do. New common.printDialogHint — "Opens your browser's print dialog (not a PDF export)" — in all ten locale packs, on the title and the accessible name of the list and report Print buttons, following the density button's existing two-attribute shape. The list reads it through LIST_DEFAULT_TRANSLATIONS (mirror row added, byte-identical to the en pack, per the #4409 map-mirror gate); the report reads it through useSafeTranslate, which is that package's existing i18n channel (already used by DatasetReportRenderer.tsx) and needs no defaults map.

3. No removal, no headless detection. Every call site still calls window.print(), pinned by a test on each of the three.

One deviation from the ruling's premise, reported rather than papered over

The ruling names DashboardView.tsx:101 as the third Print button. It is not one. That line is the export_dashboard_pdf script action handler, and its toast read Preparing PDF export… — no PDF was ever produced. There is no tooltip target there, so the honest equivalent of the ruling's item 2 on that surface is the copy itself: dashboardActions.pdfPreparing is replaced by dashboardActions.printDialogOpening, "Opening your browser's print dialog (not a PDF export)", in all ten packs. That string was the single most literal instance of the misreading the issue reports. The action ID stays export_dashboard_pdf — it is the identifier server-driven dashboard metadata declares, so renaming it is a spec-side change, not a copy fix.

Red-first

Reverse verification with the fix committed, then removed with git checkout origin/main -- ... (never git stash) and restored:

Test Files  3 failed (3)
     Tests  34 failed | 5 passed (39)

Ordinary red direction, as predicted in each file's header. The 5 that stay green are the deliberate "no removal" controls — the three window.print() call-site pins plus the two facts this change does not touch (the console imports the styles entry; the package exports it). They were true before and must stay true.

What CANNOT go red in vitest — stated in the test files, not only here

@media print never applies under happy-dom or jsdom: neither implements a layout engine or media-type emulation, so window.matchMedia('print') is a stub and getComputedStyle() returns the screen cascade whatever the sheet says. So no vitest assertion in this PR observes print rendering. What is pinned is structural — the sheet ships, the @media print block exists, and each load-bearing rule is present in it, so deleting a rule reds. A rule that merely stops matching (because AppShell or the shadcn sidebar primitive changed its markup) cannot be caught from a stylesheet and would stay green; that failure mode is written into the stylesheet's own header.

Real-browser verification (Playwright, print-media emulation)

Because the above cannot be tested in vitest, it was measured once for real. Method: the console's own Vite dev server compiled apps/console/src/index.css (436 KB, the whole Tailwind v4 / Lightning CSS pipeline) — confirming every rule survives compilation, including the .group\/sidebar-wrapper escape. That compiled CSS was paired with a real rendered shell DOM (React-rendered AppShell + shadcn Sidebar + ListView, dumped from a throwaway render), loaded in Chromium, and measured in two arms: the compiled CSS as-is, and the same CSS with the @media print block stripped out.

Under page.emulateMedia({ media: 'print' }):

probe without the fix with the fix
shell top bar (.group\/sidebar-wrapper > header) flex, 1280x56, visible none
sidebar panel ([data-sidebar="sidebar"]) flex, 255x800, visible none
list tool cluster ([data-print-hide]) flex, 407x34, visible none
content area (main element) 1280x744 — one viewport 1280x4948 — full content
ListView root overflow: hidden overflow: visible
grid scroller / Table wrapper overflow: auto overflow: visible
table row break-inside auto avoid

And the decisive one, page.pdf({ format: 'A4' }):

without-fix   1 page(s)
with-fix      6 page(s)

One clipped page becomes six paginated ones, with no console chrome and no toolbar buttons on any of them. Evidence files (screenshots, PDFs, JSON measurements) were kept out of the repo per AGENTS.md section 9 housekeeping.

Caveat, stated because the numbers deserve it: the harness renders the real AppShell, Sidebar and ListView, but its 120-row table is a synthetic Table built from the shadcn primitive's verbatim classes, since the registry-backed grid does not register under the light vitest setup. So the chrome-hiding measurements are of real product markup; the pagination measurement is of real product class shapes carrying synthetic rows.

Scope

Touched: the app-shell styles entry, the three named call sites, the ten locale packs, three test files, one changeset. Not touched: ObjectView.tsx, the app-surface/landing components, console/organizations/manage/**, plugin-calendar/**, the DOM-leak ledger, content/docs/releases/.

Verification

  • pnpm exec vitest run packages/i18n/ packages/plugin-list/ packages/plugin-report/ packages/app-shell/444 files, 4864 passed, 1 skipped
  • type-check (both tsc --noEmit and tsc -p tsconfig.test.json) on app-shell, plugin-list, plugin-report, i18n — clean
  • eslint on every touched file — 0 errors
  • check:i18n-keys, check:i18n-drift, check:control-bytes, check-changeset-presence — all pass
  • .d.ts diff against a build of origin/main: 124 lines, all of them the i18n pack literal types (the added printDialogHint, the renamed printDialogOpening). No .d.ts change in plugin-list or plugin-report. The one other hunk is app-shell/chrome/ErrorBoundary.d.ts, a render() return union whose 9 members are set-identical and only reordered — tsc declaration-emit nondeterminism, unrelated to this change. Patch grading throughout; no API surface moved.

Generated by Claude Code

@vercel

vercel Bot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectui Ignored Ignored Aug 12, 2026 7:56pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 24.7 KB 350 KB
Entry file index-BA6vFmoY.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 9.56KB 3.59KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 8.92KB 3.41KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 22.10KB 4.37KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.13KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.64KB 2.21KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 35.76KB 9.11KB
auth (createAuthenticatedFetch.js) 4.37KB 1.69KB
auth (index.js) 2.35KB 1.07KB
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) 4.91KB 0.87KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 26.07KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.65KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 489.32KB 108.45KB
core (index.js) 3.37KB 1.34KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 153.42KB 41.19KB
fields (index.js) 228.84KB 56.77KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 3.35KB 1.38KB
i18n (pickLocalized.js) 3.69KB 1.73KB
i18n (provider.js) 23.12KB 7.62KB
i18n (useDisplayLocale.js) 2.33KB 1.20KB
i18n (useObjectLabel.js) 27.59KB 6.63KB
i18n (useSafeTranslation.js) 7.77KB 3.13KB
layout (index.js) 38.98KB 10.85KB
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.74KB
mobile (index.js) 1.50KB 0.62KB
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) 2.53KB 0.85KB
mobile (useResponsive.js) 0.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 8.75KB 3.06KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 3.67KB 1.12KB
permissions (evaluator.js) 4.41KB 1.44KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.55KB 0.71KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 45.46KB 12.52KB
plugin-charts (index.js) 62.01KB 17.63KB
plugin-chatbot (index.js) 181.21KB 43.14KB
plugin-dashboard (index.js) 120.75KB 31.38KB
plugin-designer (index.js) 212.58KB 42.83KB
plugin-detail (index.js) 239.03KB 59.77KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 114.58KB 27.68KB
plugin-gantt (index.js) 164.14KB 39.98KB
plugin-grid (index.js) 188.13KB 50.00KB
plugin-kanban (index.js) 48.62KB 13.42KB
plugin-list (index.js) 110.47KB 26.90KB
plugin-map (index.js) 18.16KB 5.81KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 41.16KB 10.96KB
plugin-timeline (index.js) 26.21KB 7.52KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.08KB 20.55KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.71KB 3.53KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.67KB 2.37KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 23.73KB 7.96KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.23KB 0.66KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 4.47KB 2.03KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 3.05KB 1.52KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
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 (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
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

Copy link
Copy Markdown
Collaborator Author

ACCEPT — step-7 复核 by PM session session_017Qqyix2QcnpUC9XeYVDzx3 (focused review).

  • The Chromium print-media verification is the decisive evidence and exactly what the ruling hoped for: 1 printed page without the block vs 6 with it, chrome elements measured display:none, overflow chains released — through the REAL compiled Tailwind pipeline, with the synthetic-rows caveat honestly scoped (chrome-hiding on real markup, pagination on real class shapes).
  • Red-first: 34 red / 5 controls green, with the controls being the no-removal pins the ruling mandated and the reach facts — correct that they stay green in both states; the >400-byte non-vacuity guard is among the reds. Per-selector cases so one deleted rule reds one named case.
  • Premise contradiction handled by intent, accepted: DashboardView:101 is a script-handler toast, not a button — and Preparing PDF export… was the most literal instance of the misreading this card exists to kill. The key rename with honest copy in ten packs IS item 2 applied to what the surface actually is; leaving the action ID export_dashboard_pdf alone was right (spec-side identifier, flagged in code). One-key revert available under the veto window if the maintainer prefers the toast untouched.
  • i18n shape by measurement throughout: one key for one semantic, per-pack conventions, borrowed-common.* map row byte-identical, plugin-report's channel measured as useSafeTranslate (no map to mirror). All i18n gates green.
  • data-print-hide markers accepted as markers-not-rules, with the right editorial calls: filter chips still print (they say which slice of data is on the page); modals deliberately NOT hidden (the overlay is the content) and pinned as a negative case.
  • .d.ts: locale literals + one set-identical union reorder (emit nondeterminism, verified programmatically) → patch ×4 stands. The runner/components reach limit is a documented pre-existing architecture note, correctly not filed. CI 18/18 green.

Flipping ready + arming auto-merge. #4464 (ListView surface) unlocks when this lands.


Generated by Claude Code


Generated by Claude Code

@yinlianghui
yinlianghui marked this pull request as ready for review August 12, 2026 20:09
@yinlianghui
yinlianghui added this pull request to the merge queue Aug 12, 2026
Merged via the queue into main with commit 31ab1ac Aug 12, 2026
21 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-4462-print-usable-page branch August 12, 2026 20:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

plugin-list: the list toolbar Print button is a bare window.print() — no preview, no output, silent no-op in headless/WebView

2 participants