softr-vibe-coding 2.9.1 → 2.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,10 @@ All notable changes to this skill are documented here. Versions follow [Semantic
4
4
 
5
5
  Entries from 1.3.1 onward are generated automatically from git commit subjects between version bumps (see `.github/workflows/publish.yml`). Entries before 1.3.1 were backfilled by hand from the existing commit history.
6
6
 
7
+ ## [2.10.0] - 2026-09-30
8
+ - Add the print-in-a-new-window rule and references/printing.md (verified live 2026-09-30)
9
+ - Add right-edge placement and the schema-less workspace-tool correction (2026-09-30)
10
+
7
11
  ## [2.9.1] - 2026-09-30
8
12
  - Add right-edge placement and the schema-less workspace-tool correction (2026-09-30)
9
13
  - Document dropdown clipping by overflow ancestors (verified live 2026-09-30)
package/README.md CHANGED
@@ -169,7 +169,7 @@ Create a contact form that creates records in our Airtable Contacts table
169
169
  softr-vibe-coding/
170
170
  ├── SKILL.md # Main skill
171
171
  │ # Workflow, code structure, visual baseline,
172
- │ # components, settings, 27 hard constraints
172
+ │ # components, settings, 28 hard constraints
173
173
  │
174
174
  ├── ui-ux-guidelines.md # Design reference
175
175
  │ # 26 sections: hierarchy, color, typography,
@@ -223,6 +223,13 @@ softr-vibe-coding/
223
223
  │ │ # MCP/CLI install (@latest npx + browser step),
224
224
  │ │ # extract → poll → findings → generate → write
225
225
  │ │ # flow, DESIGN.md anatomy, drift QA
226
+ │ ├── printing.md # Printing from a block: ALWAYS a new window
227
+ │ │ # with its own document (never window.print()
228
+ │ │ # on the page, never an in-page print view) —
229
+ │ │ # escaped HTML builder, pop-up-safe open from
230
+ │ │ # the click, print once stylesheets, fonts and
231
+ │ │ # images load, ?print=1 deep link, paper layout
232
+ │ │ # (Sep 30 2026)
226
233
  │ ├── quick-reference.md # Syntax cheat sheet
227
234
  │ │ # Imports, hook signatures, mutation shapes,
228
235
  │ │ # field mapping, component skeleton
package/SKILL.md CHANGED
@@ -91,6 +91,7 @@ You generate complete, production-ready Softr Vibe Coding blocks as TypeScript R
91
91
  - No hardcoded domains in links -- use relative paths (`/page?recordId=...`); same-page anchors written relative too (`/#section`)
92
92
  - **No `<select>` and no shadcn `<Select>`** — both break inside a block's shadow DOM (native hands the list to the OS; shadcn portals outside the shadow root and arrives unstyled). Use the `Combo` pattern in [references/searchable-dropdown.md](references/searchable-dropdown.md) — **searchable by default** for every framed filter or form field whatever the option count; `bare` inline editors are click-only; `searchable={false}` only on a short fixed enum the user is setting (a status, a location, a group-by)
93
93
  - No clipping class (`overflow-hidden`, `overflow-*-auto`, `truncate`, `line-clamp-*`) on any element that contains a `Combo` — its panel is absolutely positioned in local DOM, so a clipping `<td>` cuts the menu to the row's height; bound an over-wide chip at the chip (`min-w-0 truncate`), and never `scrollIntoView` inside the panel. See [references/searchable-dropdown.md](references/searchable-dropdown.md#the-four-things-that-will-bite-you), item 4
94
+ - Any **Print** control opens a **new window with its own document** — `window.open` straight from the click, an escaped standalone HTML printout written into it, `print()` once its stylesheets, fonts and images are in, the button disabled until the data has fully loaded. No `window.print()` on the Softr page, no in-page print view (Hard Constraint 28). See [references/printing.md](references/printing.md)
94
95
  - Static block: no hardcoded user-visible copy — every string/image/link is an editable setting (see [references/editable-settings.md](references/editable-settings.md#granularity-doctrine-settings-first-static-blocks))
95
96
  - Array-setting rows keyed by **index**, never by a builder-editable field value
96
97
  - Media settings that may start empty (`src: ""`) gated with a conditional render or placeholder — never an unconditional `<img src={setting.src}>`
@@ -175,6 +176,7 @@ For advanced patterns beyond data fetching, load the relevant reference when the
175
176
  | Debugging a broken block, checking patterns before delivery, full violation catalog | [references/anti-patterns.md](references/anti-patterns.md) |
176
177
  | Quick syntax check — import paths, hook signatures, mutation call shapes, field mapping | [references/quick-reference.md](references/quick-reference.md) |
177
178
  | Any **dropdown / picker / combobox** in a block — why shadcn `<Select>` and native `<select>` both fail inside the shadow DOM, the `composedPath()` click-outside, sorting A→Z inside the component, multi-token filtering, **searchable by default** regardless of option count (`bare` inline editors click-only; `searchable={false}` only for a short fixed enum being set), the `bare` inline-editor variant | [references/searchable-dropdown.md](references/searchable-dropdown.md) |
179
+ | **Printing** anything from a block — always a new window/tab holding its own document, never `window.print()` on the page or an in-page print view: the escaped HTML builder, pop-up-safe opening from the click, print-when-ready (stylesheets, fonts and images, capped), Print disabled until the data has loaded, the `?print=1` deep link from another page, paper layout (shared `<colgroup>`, `vertical-align: middle`, tick boxes) | [references/printing.md](references/printing.md) |
178
180
  | Small reusable patterns — `localStorage` cross-page state, clipboard copy button | [references/common-patterns.md](references/common-patterns.md) |
179
181
  | Writing Airtable Automation Scripts / Scripting Extension scripts / Airtable formulas — companion to Softr blocks for cross-table cascades and computed values | [references/airtable-automations.md](references/airtable-automations.md) |
180
182
  | The official **Softr MCP server** — Softr DB schema + full record/table/field/database CRUD (deletes included), field-level browsing of connected Airtable / Google Sheets / Notion / Supabase integrations, **creating, editing, versioning, and deploying Vibe Coding blocks directly** (`get_vibe_coding_docs`, `create_vibe_coding_block`, ...), app management/scaffolding, the **Softr Workflows** suite (26 tools, 418-node catalog), and **per-application MCP servers** | [references/softr-mcp.md](references/softr-mcp.md) |
@@ -604,6 +606,14 @@ Non-negotiable rules. Most are enforced by the Softr platform (compiler, validat
604
606
  one table merge into ONE UPDATE_RECORD action (field list = the union), filed under the table's
605
607
  FIRST connection even when a hook points at a second one. Point writes at the first connection.
606
608
  Verified live 2026-09-18. See [datasources/writing.md](datasources/writing.md#actions-register-per-table-not-per-hook-or-connection).
609
+ 28. **Print in a new window, never on the page [house]** -- A Print control opens a new window
610
+ (`window.open("", "_blank", …)`, synchronously in the click handler; a toast if it returns
611
+ `null`) and writes a standalone, escaped HTML printout into it, printed once its stylesheets,
612
+ fonts and images are in. Never `window.print()` on the Softr page: a block is page content in
613
+ a shadow root, so the page prints Softr's header, footer and every sibling block, and hiding
614
+ them takes global CSS across Softr's page structure as well as print CSS in the block. Never
615
+ an in-page "print view" either (Leo rejected it by name). Verified live 2026-09-30. See
616
+ [references/printing.md](references/printing.md).
607
617
 
608
618
  ## Style Conventions
609
619
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "softr-vibe-coding",
3
- "version": "2.9.1",
3
+ "version": "2.10.0",
4
4
  "description": "Claude Code skill for generating production-ready Softr Vibe Coding blocks (JSX). Installs into ~/.claude/skills/ and auto-updates on each Claude Code session.",
5
5
  "bin": {
6
6
  "softr-vibe-coding": "./bin/cli.js"
@@ -91,6 +91,12 @@ Run through this catalog before delivering any block. Every row is a violation o
91
91
  | Softr nav dropdown panel shows a tall blank gap below the items, and `height: auto` won't shrink it | The items sit in a CSS grid Softr sets to `grid-auto-flow: column` with pre-sized empty row tracks (`grid-template-rows: 60px 60px…`). Override the flow on `.softr-topbar [role="menu"] [role="group"]`: `grid-auto-flow: row !important; grid-template-rows: none !important; grid-auto-rows: auto !important` (leave `grid-template-columns` to preserve the menu width). Verified June 2026. See [native-chrome-styling.md](native-chrome-styling.md). |
92
92
  | Setting the page background on `body` (or any single element) — it appears to do nothing | Softr paints the SAME page fill on `html`, `body`, `#page-content`, AND a deeper class-less wrapper div, stacked — so styling one gets covered. Paint your backdrop on `html`, then clear the duplicates above it: `body`, `#page-content`, and `#page-content div` — but EXCLUDE the header subtree with `:not(.softr-topbar):not(.softr-topbar *)` (it renders inside `#page-content`, and `#page-content`'s id specificity would otherwise flatten the dropdown panel). Verified June 2026. See [native-chrome-styling.md](native-chrome-styling.md). |
93
93
 
94
+ ## Printing
95
+
96
+ | Anti-Pattern | Correct Approach |
97
+ |---|---|
98
+ | Printing the Softr page — `window.print()` from a block, with or without `@media print` CSS to hide the rest — or an in-page "print view": the block switches itself to a print layout under the app chrome, with "Print again" / "Exit print view" buttons | **Symptom:** the paper carries Softr's header and footer, every sibling block and the block's own controls; or the user lands in a second screen of the block that they have to find their way out of — "a weird UI", which Leo rejected by name on 2026-09-30. **Cause:** a block is page content in a shadow root. The app chrome and the sibling blocks are outside it, so the block's own print CSS cannot hide them; that takes global Custom Code CSS aimed at Softr's page structure, which you do not control. A print view changes nothing about that: it still ends in `window.print()` on the same page. **Fix:** Print opens a new window holding its own document — `window.open("", "_blank", …)` synchronously in the click (a toast if it returns `null`), a standalone, escaped HTML printout written into it, `print()` once its stylesheets, fonts and images are in. From another page, the linking page opens the target with `?print=1` in a window of its own, and the target block replaces its own document with the printout. Verified live 2026-09-30. See [printing.md](printing.md) |
99
+
94
100
  ## Permissions
95
101
 
96
102
  | Anti-Pattern | Correct Approach |
@@ -0,0 +1,533 @@
1
+ # Printing from a block — a window of its own
2
+
3
+ **Print opens a new window (or tab) holding its own printout document. Always.** Never
4
+ `window.print()` on the Softr page, and never an in-page "print view". This is the default for
5
+ every Print, "printable version" or save-as-PDF control in a block, whatever the data source.
6
+ Leo, 2026-09-30: "I don't want this print view, it needs a new tab opening", and as the default,
7
+ "so it doesn't create a weird UI".
8
+
9
+ A block is page *content*, rendered inside a shadow root, and that rules out both of the obvious
10
+ ways to print:
11
+
12
+ | Option | What ends up on paper |
13
+ |---|---|
14
+ | `window.print()` from the block | The whole Softr page: the app header, the footer, every sibling block, and the block's own buttons and filters. The block's CSS lives in its shadow root and cannot reach any of the rest. Hiding it takes global Custom Code CSS aimed at Softr's page structure, which you do not control ([native-chrome-styling.md](native-chrome-styling.md)), plus print CSS inside the block for its own controls: two stylesheets in two places. |
15
+ | An in-page "print view" (the block switches itself to a print layout) | A second screen of the block, still under the app chrome, with its own "Print again" / "Exit print view" buttons: a mode the user has to find their way out of. It still prints through `window.print()`, so it inherits the whole row above. Leo rejected it by name. |
16
+ | A new window holding its own document (this page) | The printout and nothing else. No chrome to hide, no print CSS, no siblings, and the same from any page. The one cost is a pop-up, and the click itself gets it past the blocker. |
17
+
18
+ Four parts make it work: a **builder** that turns the view into one HTML string (1), a **click**
19
+ that opens the window and writes the string into it (2), a **wait** so it prints only once fonts
20
+ and images are in (3), and a **Print button that waits for the data** (4). Printing from *another*
21
+ page adds the `?print=1` deep link (5). Then the paper layout (6), and gotchas and testing (7).
22
+
23
+ **Verified live 2026-09-30** in a Softr preview: the button opens a separate window holding only
24
+ the printout (no app header or footer, no controls), photos load before print fires, and the page
25
+ behind it is untouched; a `?print=1` link opens a window that shows "Preparing the printout…" and
26
+ then becomes the same printout; every table cell measured 0px off its row's middle. (The snippets
27
+ are modern TS, per SKILL.md's Style Conventions, generalised from the var-style original that was
28
+ verified.)
29
+
30
+ ## 1. The builder: one HTML string
31
+
32
+ The printout is a complete HTML document, built as one string by module-scope functions. Three
33
+ rules make it safe and self-sufficient.
34
+
35
+ **Escape every interpolated value, text and attribute values alike.** The window that
36
+ `window.open("")` returns is an `about:blank` page on the app's own origin, and a `?print=1`
37
+ printout (section 5) *is* the app's page. Record text written in unescaped is markup: an
38
+ `<img onerror=…>` in a vendor name runs as the signed-in user. Escape all five characters, `&`
39
+ first. The builder's attributes are single-quoted (`src='…'`), so `'` is not optional:
40
+
41
+ ```tsx
42
+ function escapeHtml(value: unknown): string {
43
+ return String(value ?? "")
44
+ .replace(/&/g, "&amp;") // first, or it re-escapes the four below
45
+ .replace(/</g, "&lt;")
46
+ .replace(/>/g, "&gt;")
47
+ .replace(/"/g, "&quot;")
48
+ .replace(/'/g, "&#39;");
49
+ }
50
+ ```
51
+
52
+ Then every `${…}` in the builder is one of three things: `escapeHtml(…)`, a number the code
53
+ computed, or a fragment built only from those two and literals (`printCellHtml(…)`, `colgroup`,
54
+ `headRow`, `PRINT_STYLES`). Nothing else.
55
+
56
+ **Bring your own styles and fonts.** The new document starts empty. Tailwind, shadcn, the lucide
57
+ React icons, the app's fonts and the Custom Code header CSS all stay behind in the app, so a class
58
+ like `text-sm` means nothing there. Write plain CSS into the printout's own `<style>`, load the
59
+ brand font with its own `<link>` (DESIGN.md's Font URLs), and draw anything iconic as text or an
60
+ inline SVG string.
61
+
62
+ **The `<title>` is the PDF's file name.** "Save as PDF" names the file after it, so give it the
63
+ context as well as the view: `Project name — List name`, not `Print`.
64
+
65
+ ```tsx
66
+ /* One cell on paper: the label the row shows ON SCREEN for that column (formatted dates, "—" for
67
+ empty), as escaped HTML. The name cell carries the photo at 24px, or an empty square of the
68
+ same size where there is none, so the names keep one straight edge. Adapt to your row shape. */
69
+ type PrintRow = { name: string; photoUrl: string; labels: Record<string, string> };
70
+
71
+ function printCellHtml(row: PrintRow, key: string): string {
72
+ if (key === "name") {
73
+ const photo = row.photoUrl
74
+ ? `<img class='ph' src='${escapeHtml(row.photoUrl)}' alt=''>`
75
+ : "<span class='ph none'></span>";
76
+ return `<span class='item'>${photo}<span>${escapeHtml(row.name)}</span></span>`;
77
+ }
78
+ return escapeHtml(row.labels[key] || "—");
79
+ }
80
+
81
+ /* Plain CSS for a document with no Tailwind in it. Black text on white prints best; put the
82
+ brand colour (DESIGN.md) on the eyebrow and the heading only. Section 6 explains each rule. */
83
+ const PRINT_STYLES = [
84
+ "body{font:12px Inter,Helvetica,Arial,sans-serif;color:#000;margin:24px}",
85
+ ".eyebrow{font-size:13px;font-weight:600;letter-spacing:0.75px;text-transform:uppercase;color:#111;margin:0 0 4px}",
86
+ "h1{font-size:18px;font-weight:600;color:#111;margin:0 0 2px}",
87
+ ".meta{font-size:11px;color:#333;margin:0 0 2px}",
88
+ ".meta.note{font-weight:600}",
89
+ ".grp{font-size:12px;font-weight:700;margin:14px 0 4px}",
90
+ "table{width:100%;table-layout:fixed;border-collapse:collapse;font-size:11px}",
91
+ "th{text-align:left;border-bottom:1px solid #DDD;padding:3px 4px;overflow-wrap:anywhere}",
92
+ "td{text-align:left;border-bottom:1px solid #DDD;padding:3px 4px;vertical-align:middle;overflow-wrap:anywhere}",
93
+ "tbody tr{break-inside:avoid}",
94
+ ".item{display:flex;align-items:center;gap:6px}",
95
+ ".ph{display:inline-block;flex:none;width:24px;height:24px;object-fit:cover;border:1px solid #DDD}",
96
+ ".ph.none{background:#F2F2F2;-webkit-print-color-adjust:exact;print-color-adjust:exact}",
97
+ ".chk{text-align:center}",
98
+ ".box{display:block;margin:0 auto;width:12px;height:12px;border:1px solid #000}",
99
+ ".notes{margin-top:18px;break-inside:avoid}",
100
+ ".notes-body{white-space:pre-wrap;font-size:12px}",
101
+ "@page{margin:14mm}",
102
+ ].join("");
103
+
104
+ type PrintColumn = { key: string; label: string };
105
+ type PrintContext = {
106
+ title: string; // the <title>: what "Save as PDF" names the file
107
+ eyebrow: string; // the context above the heading (the project, the client), or ""
108
+ heading: string;
109
+ meta: string[]; // plain-text lines under the heading, the print date first
110
+ filterNote: string; // "Filtered view — 12 of 40 items" while a search or filter is on, else ""
111
+ columns: PrintColumn[]; // the columns on show, in their on-screen order
112
+ widths: Record<string, number>; // on-screen widths by key; only the proportions matter
113
+ groups: { name: string; rows: PrintRow[] }[]; // one table per group; name "" when ungrouped
114
+ tickBox: boolean; // a title-less ☐ column at the end, to tick on paper
115
+ notes: string;
116
+ emptyText: string;
117
+ };
118
+
119
+ function buildPrintHtml(ctx: PrintContext): string {
120
+ const cols = ctx.columns;
121
+ const tick = ctx.tickBox ? 6 : 0; // the ☐ column's share of the width, in %
122
+ const total = cols.reduce((sum, c) => sum + (ctx.widths[c.key] || 100), 0);
123
+ const pct = (key: string) => (((ctx.widths[key] || 100) / total) * (100 - tick)).toFixed(2);
124
+ /* ONE <colgroup>, shared by every group's table. With table-layout:fixed, that is what lines
125
+ the columns up from one group to the next (section 6). */
126
+ const colgroup =
127
+ "<colgroup>" +
128
+ cols.map((c) => `<col style='width:${pct(c.key)}%'>`).join("") +
129
+ (tick ? `<col style='width:${tick}%'>` : "") +
130
+ "</colgroup>";
131
+ const headRow =
132
+ "<tr>" +
133
+ cols.map((c) => `<th>${escapeHtml(c.label)}</th>`).join("") +
134
+ (tick ? "<th aria-label='Done'></th>" : "") + // no title over the tick boxes
135
+ "</tr>";
136
+
137
+ const out: string[] = [];
138
+ out.push("<!doctype html><html><head><meta charset='utf-8'>");
139
+ out.push(`<title>${escapeHtml(ctx.title)}</title>`);
140
+ out.push(
141
+ "<link rel='stylesheet' href='https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap'>"
142
+ );
143
+ out.push(`<style>${PRINT_STYLES}</style></head><body>`);
144
+ if (ctx.eyebrow) out.push(`<p class='eyebrow'>${escapeHtml(ctx.eyebrow)}</p>`);
145
+ out.push(`<h1>${escapeHtml(ctx.heading)}</h1>`);
146
+ ctx.meta.forEach((line) => out.push(`<p class='meta'>${escapeHtml(line)}</p>`));
147
+ if (ctx.filterNote) out.push(`<p class='meta note'>${escapeHtml(ctx.filterNote)}</p>`);
148
+
149
+ ctx.groups.forEach((group) => {
150
+ if (group.name) {
151
+ out.push(`<div class='grp'>${escapeHtml(group.name)} &middot; ${group.rows.length}</div>`);
152
+ }
153
+ out.push(`<table>${colgroup}<thead>${headRow}</thead><tbody>`);
154
+ group.rows.forEach((row) => {
155
+ out.push(
156
+ "<tr>" +
157
+ cols.map((c) => `<td>${printCellHtml(row, c.key)}</td>`).join("") +
158
+ (tick ? "<td class='chk'><span class='box'></span></td>" : "") +
159
+ "</tr>"
160
+ );
161
+ });
162
+ out.push("</tbody></table>");
163
+ });
164
+ if (ctx.groups.length === 0) out.push(`<p class='meta'>${escapeHtml(ctx.emptyText)}</p>`);
165
+
166
+ if (ctx.notes) {
167
+ out.push(
168
+ `<div class='notes'><div class='grp'>Notes</div><div class='notes-body'>${escapeHtml(ctx.notes)}</div></div>`
169
+ );
170
+ }
171
+ out.push("</body></html>");
172
+ return out.join("");
173
+ }
174
+ ```
175
+
176
+ **What goes on paper: the view as it stands.** Build the context inside `Block()` from the same
177
+ derived values the screen renders (the columns on show, in their order, hidden ones left out; the
178
+ same groups, sort and filters), so paper and screen cannot disagree. Two meta lines earn their
179
+ place: the date, because a sheet outlives the day it was printed, and a line that says so when a
180
+ search or filter is on. (A printout that is a different document by design, such as a packing slip
181
+ or an invoice, builds its own content, but keeps every other rule on this page.)
182
+
183
+ ```tsx
184
+ /* Inside Block(), next to the values it reads. */
185
+ function printContext(): PrintContext {
186
+ return {
187
+ title: `${projectName} — ${listName}`,
188
+ eyebrow: projectName,
189
+ heading: listName,
190
+ meta: ["Printed " + format(new Date(), "MMM d, yyyy")],
191
+ filterNote: hasActiveFilter ? `Filtered view — ${shownRows.length} of ${rows.length} items` : "",
192
+ columns: visibleColumns,
193
+ widths: columnWidths,
194
+ groups: groups, // the grouped, sorted, filtered rows the table renders, as PrintRow
195
+ tickBox: true,
196
+ notes: notes.trim(),
197
+ emptyText: rows.length === 0 ? "Nothing on this list yet." : "No items match this filter.",
198
+ };
199
+ }
200
+ ```
201
+
202
+ ## 2. Open it from the click
203
+
204
+ ```tsx
205
+ function handlePrint() {
206
+ // FIRST, and synchronously: the click's user activation is what gets the window past the
207
+ // pop-up blocker. No await, .then() or setTimeout in front of this line.
208
+ const win = window.open("", "_blank", "width=900,height=700");
209
+ if (!win) {
210
+ toast.error("Your browser blocked the print window. Allow pop-ups for this site and try again.");
211
+ return;
212
+ }
213
+ writePrintout(win, buildPrintHtml(printContext()));
214
+ }
215
+ ```
216
+
217
+ - **Nothing asynchronous before `window.open`.** A browser lets a page open a window only while
218
+ the user's click is fresh. An `await` (fetching the data on click, say), a `.then()` or a
219
+ `setTimeout` in front of it spends that allowance, and the window is blocked some of the time,
220
+ in some browsers: the worst kind of bug to chase. Section 4 makes sure the data is already there
221
+ when the button can be pressed, so there is nothing to wait for.
222
+ - **`null` means blocked.** Say how to fix it (the toast above) instead of failing silently.
223
+ - **No `noopener` or `noreferrer` in the features.** With either one, `window.open` returns `null`
224
+ even though the window opened: there is no handle to write the printout into, and the code reads
225
+ the `null` as "blocked".
226
+ - **`width` / `height` make it a pop-up window.** A features string like the one above makes
227
+ Chromium and Firefox open a separate window at that size; with no features string it is a new
228
+ tab. Either meets the rule. Pick one and make every Print in the app the same call, so every
229
+ Print behaves the same; nothing but discipline keeps two blocks alike (SKILL.md Hard
230
+ Constraint 22).
231
+ - **The block that has the data writes the printout itself.** Do not route its own button through
232
+ `?print=1` (section 5): that reloads the whole app and every query in the new window to print
233
+ data this page already holds.
234
+
235
+ ## 3. Print only when it is ready
236
+
237
+ ```tsx
238
+ /* Writes the printout into `win` and prints it once it is ready. `win` is a window of its own or,
239
+ for a ?print=1 open (section 5), this very window, whose page the printout then replaces.
240
+ Ready means the font stylesheets and every image have loaded or failed, and then the web fonts
241
+ are in: a print fired earlier comes out with empty squares where the photos go, or in the
242
+ fallback font. It never waits more than 4s, then gives the layout a 250ms beat, because some
243
+ browsers otherwise print a blank first page. */
244
+ function writePrintout(win: Window, html: string) {
245
+ win.document.open();
246
+ win.document.write(html);
247
+ win.document.close();
248
+ win.focus();
249
+
250
+ let fired = false;
251
+ const fire = () => {
252
+ if (fired || win.closed) return;
253
+ fired = true;
254
+ window.setTimeout(() => {
255
+ if (!win.closed) win.print();
256
+ }, 250);
257
+ };
258
+ window.setTimeout(fire, 4000); // the cap: one slow or hung request never holds the print hostage
259
+
260
+ const settled = (el: HTMLElement) =>
261
+ new Promise((resolve) => {
262
+ el.addEventListener("load", resolve);
263
+ el.addEventListener("error", resolve); // a failed image or stylesheet must not hold it either
264
+ });
265
+ const waits: Promise<unknown>[] = [];
266
+ // The stylesheets too: document.fonts.ready does not wait for a font stylesheet still in flight.
267
+ win.document.querySelectorAll<HTMLLinkElement>("link[rel='stylesheet']").forEach((link) => {
268
+ if (!link.sheet) waits.push(settled(link));
269
+ });
270
+ Array.from(win.document.images).forEach((img) => {
271
+ if (!img.complete) waits.push(settled(img));
272
+ });
273
+ Promise.all(waits)
274
+ .then(() => win.document.fonts?.ready) // with the stylesheets in, this covers the font files
275
+ .then(fire, fire);
276
+ }
277
+ ```
278
+
279
+ - **Stylesheets and images first, then the fonts.** Those are what go missing on paper. The
280
+ stylesheet wait matters because `document.fonts.ready` only counts the fonts the document
281
+ already knows it needs. Measured in Chromium on 2026-09-30: with a font stylesheet taking 1.5s,
282
+ `fonts.ready` resolved at once and the print fired at 255ms; waiting for the `<link>` first held
283
+ the print until the font file had arrived. (The live-verified deployment waited on `fonts.ready`
284
+ and the images only. Photos usually arrive after the stylesheet, which hides the gap; without
285
+ photos, the brand font can miss the paper.)
286
+ - **`fired` makes it print once**, whichever comes first: the cap or the waits.
287
+ - **Guard `win.closed`.** The user can close the window during the wait.
288
+
289
+ ## 4. The Print button waits for the data
290
+
291
+ A printout built before the data is in is a skeleton on paper, or worse, the first page of
292
+ records, which looks complete. Keep Print disabled until every query has succeeded **and every
293
+ page is fetched** (`hasNextPage` false on each; the auto-load-all effect in
294
+ [../datasources/reading.md](../datasources/reading.md#loading-all-records-auto-pagination) is what
295
+ gets it there), and say why on hover:
296
+
297
+ ```tsx
298
+ const dataReady =
299
+ itemsResult.status === "success" &&
300
+ !itemsResult.hasNextPage &&
301
+ vendorsResult.status === "success" &&
302
+ !vendorsResult.hasNextPage;
303
+
304
+ <button
305
+ type="button"
306
+ onClick={handlePrint}
307
+ disabled={!dataReady}
308
+ title={dataReady ? "Opens the printout in a new window" : "Loading…"}
309
+ className="inline-flex items-center gap-1.5 … disabled:cursor-wait disabled:opacity-60"
310
+ >
311
+ <Printer className="h-4 w-4" />
312
+ Print
313
+ </button>
314
+ ```
315
+
316
+ If the printout reads anything else from state that arrives after the data, such as a saved
317
+ layout or view settings hydrated from a record, add its ready flag to the same condition
318
+ (`viewReady` in section 5).
319
+
320
+ ## 5. Printing from another page: `?print=1`
321
+
322
+ A Print control on a page that does not hold the data (a card on an index page, say) needs the
323
+ target page to build the printout. But a page that has just loaded cannot open a window (there is
324
+ no user gesture, so the pop-up blocker stops it), and it must not `window.print()` itself (the
325
+ rule at the top). So the two pages split the work.
326
+
327
+ **The linking page opens the window, straight from the click**, pointed at the target page with a
328
+ flag:
329
+
330
+ ```tsx
331
+ <a
332
+ href={`/list?recordId=${encodeURIComponent(record.id)}&print=1`}
333
+ onClick={(e) => {
334
+ // A window of its own, the same call as every other Print. preventDefault ONLY when it
335
+ // opened: if a pop-up blocker returns null, the link still works, in this tab.
336
+ const win = window.open(e.currentTarget.href, "_blank", "width=900,height=700");
337
+ if (win) e.preventDefault();
338
+ }}
339
+ className="…"
340
+ >
341
+ <Printer className="h-3.5 w-3.5" />
342
+ Print
343
+ </a>
344
+ ```
345
+
346
+ A real `<a href>` rather than a button, so the fallback costs nothing and the URL is a real one.
347
+ Keep `noopener` out here too: `window.open` would return `null` although the window opened, the
348
+ handler would not `preventDefault`, and the tab would navigate as well, giving two printouts.
349
+
350
+ **The target block turns its own window into the printout.** It reads the flag once, shows a
351
+ one-line holding state instead of its UI, and, once the data and anything else the printout reads
352
+ are in, replaces its own document with the printout and prints:
353
+
354
+ ```tsx
355
+ export default function Block() {
356
+ // Read once, at mount.
357
+ const [printOnLoad, setPrintOnLoad] = useState(() => {
358
+ try {
359
+ const p = new URLSearchParams(window.location.search).get("print");
360
+ return p === "1" || p === "true";
361
+ } catch (e) {
362
+ return false;
363
+ }
364
+ });
365
+
366
+ // … the data hooks, the auto-load-all effects, dataReady (section 4), anyQueryFailed (any of
367
+ // them in status "error"), and viewReady if a saved layout is hydrated into state (drop it
368
+ // from the effect below if there is none) …
369
+
370
+ /* Runs once: printOnLoad goes false first, so a later refetch cannot print a second time.
371
+ printContext is declared further down and reads values computed during the render; the
372
+ effect runs after the render, by which time they are all set. */
373
+ useEffect(() => {
374
+ if (!printOnLoad || !dataReady || !viewReady) return;
375
+ setPrintOnLoad(false);
376
+ writePrintout(window, buildPrintHtml(printContext()));
377
+ }, [printOnLoad, dataReady, viewReady]);
378
+
379
+ // … every other hook: ALL of them above the holding return below (Hard Constraint 19) …
380
+
381
+ if (printOnLoad) {
382
+ // This window exists only to become the printout: a holding line, never the full UI.
383
+ return (
384
+ <div className="container py-0">
385
+ <div className="content">
386
+ <div className="px-8 pt-10 pb-12 text-sm text-muted-foreground">
387
+ {anyQueryFailed
388
+ ? "Couldn't load this to print it. Reload this window to try again."
389
+ : "Preparing the printout…"}
390
+ </div>
391
+ </div>
392
+ </div>
393
+ );
394
+ }
395
+
396
+ // … the normal view …
397
+ }
398
+ ```
399
+
400
+ - **The ready flags are the effect's dependencies.** It has to run again each time one of them
401
+ flips; an empty dependency array runs it once, at mount, before any data exists.
402
+ - **A holding line, not the full UI.** Rendering the normal view meanwhile flashes the whole app
403
+ (filters, buttons, a table filling in) for a second before it turns into paper.
404
+ - **`writePrintout(window, …)` replaces the Softr page in that window.** `document.open()` clears
405
+ the app's DOM, header and footer included, and the printout is all that is left. Whatever React
406
+ renders after that goes into a root that is no longer in the document.
407
+ - **The fallback.** If the linking page's pop-up was blocked, the link opened this URL in the
408
+ user's own tab, and the printout replaces the app there. The link still works; it is just less
409
+ graceful.
410
+
411
+ ## 6. Layout on paper
412
+
413
+ The CSS in section 1 carries all of this; each rule is there for a reason.
414
+
415
+ - **One `<colgroup>`, shared by every group's table, with `table-layout: fixed`.** A grouped view
416
+ prints as one table per group. With automatic layout each table sizes its columns to its own
417
+ content, and the columns zig-zag from one group to the next. A fixed layout takes its widths
418
+ from the `<col>` elements, and one shared `<colgroup>` gives every table the same ones.
419
+ Percentages in proportion to the on-screen widths keep the paper recognisable as the screen.
420
+ - **`overflow-wrap: anywhere` on every cell**, so a long SKU or URL wraps inside its fixed column
421
+ instead of running into the next one.
422
+ - **`vertical-align: middle` on every `td`.** A room or category that wraps to two lines otherwise
423
+ leaves the one-line values in its row hanging from the top. (Verified: every cell 0px off its
424
+ row's middle.)
425
+ - **A tick-box column is a bordered block, centred:** `.box{display:block;margin:0 auto;…}` in a
426
+ `text-align:center` cell, under a title-less `<th>` that carries an `aria-label`. A block sits
427
+ on no text line, so no line-height can nudge it off the middle; an inline box sits on the text
428
+ baseline, and the line's descender space pushes it off-centre.
429
+ - **`tbody tr { break-inside: avoid }`**, so a row never splits across two pages. The same for the
430
+ notes.
431
+ - **Backgrounds do not print by default.** Browsers drop background colours and images unless the
432
+ user ticks "Background graphics". Where a background carries meaning (a placeholder square, a
433
+ status fill), set `-webkit-print-color-adjust: exact; print-color-adjust: exact` on it, and draw
434
+ photos as `<img>`, never as a CSS `background-image`.
435
+ - **Images at a fixed size with `object-fit: cover`**, and an empty square of the same size where
436
+ a row has none, so the text beside them keeps one straight edge.
437
+ - **`@page { margin: 14mm }`** is the margin on paper; the `body` margin is for the window on
438
+ screen.
439
+
440
+ ## 7. Gotchas and testing
441
+
442
+ **Attachment URLs work as they are.** Softr Database attachment URLs are pre-signed links that
443
+ carry their auth in the query string, so the new window loads them with no cookie or header
444
+ (verified 2026-09-30); any file URL that needs no cookie behaves the same. They expire after a
445
+ couple of hours and are re-signed on every fetch, so build the printout from what the block has
446
+ just fetched, never from a URL saved earlier.
447
+
448
+ **Capture the window with `page.waitForEvent("popup")`** (Playwright). The printout is a popup of
449
+ the page that was clicked, for the Print button (`window.open("")`) and for a `?print=1` link on
450
+ another page (`window.open(href)`) alike:
451
+
452
+ ```ts
453
+ const [popup] = await Promise.all([
454
+ page.waitForEvent("popup"),
455
+ page.getByRole("button", { name: /print/i }).click(),
456
+ ]);
457
+
458
+ // Replace print() before it fires, so no dialog blocks the run and the test sees when it fired.
459
+ // For the Print button the document is already written by the time you hold the popup, and
460
+ // print() comes at least 250ms later.
461
+ await popup.evaluate(() => {
462
+ window.print = () => {
463
+ (window as any).__printedWithImages = Array.from(document.images).every((img) => img.complete);
464
+ };
465
+ });
466
+ await expect
467
+ .poll(() => popup.evaluate(() => (window as any).__printedWithImages), { timeout: 10_000 })
468
+ .toBe(true); // the 4s cap plus the 250ms beat is too close to the 5s default
469
+
470
+ // A plain document: no shadow roots and no Softr chrome, so ordinary locators work.
471
+ await expect(popup.locator(".softr-topbar")).toHaveCount(0);
472
+ await expect(popup.locator("tbody tr")).toHaveCount(expectedRows);
473
+
474
+ // Each tick box's centre against its row's.
475
+ const offsets = await popup.evaluate(() =>
476
+ Array.from(document.querySelectorAll("tbody tr")).map((tr) => {
477
+ const row = tr.getBoundingClientRect();
478
+ const box = tr.querySelector(".box")!.getBoundingClientRect();
479
+ return Math.abs(box.top + box.height / 2 - (row.top + row.height / 2));
480
+ })
481
+ );
482
+ expect(Math.max(...offsets)).toBeLessThan(1);
483
+ ```
484
+
485
+ For a `?print=1` link, click the link instead. That window loads the Softr page first, so wait
486
+ for it to *become* the printout before asserting on it:
487
+ `await expect(popup).toHaveTitle("Project name — List name")`. Stub `print` there once the Softr
488
+ page has loaded (`await popup.waitForLoadState()`): `document.open()` keeps a `print` set on the
489
+ window before it (checked in Chromium, 2026-09-30). The printout has no print-only CSS, so the
490
+ window lays it out as the paper will; narrow it to about the paper's printable width (roughly
491
+ 700px for A4 or Letter at 14mm margins) to see the wrapping the paper gets.
492
+
493
+ **Do not mock a popup printout's images or fonts with `route()`.** The Print button writes the
494
+ printout in the same task that opens the window, so its first requests go out before Playwright
495
+ has attached to the popup. With Playwright attached over CDP (seen 2026-09-30) they hang: no
496
+ `request` event, the route handler never runs, the images stay pending, and the print falls
497
+ through to the 4s cap, which looks exactly like a broken wait. Use real URLs or `data:` URLs in
498
+ the popup, or mock on a page that loaded normally (the `?print=1` path), where routing works.
499
+
500
+ **Preview links pin the app version they were minted on.** A `preview_app` session keeps serving
501
+ the version it was opened on, so after pushing a change, mint a fresh preview link before you
502
+ verify anything; otherwise you are testing the old Print. (What else a preview link is, and why
503
+ it is never shared: [softr-mcp.md](softr-mcp.md#application-management-tools).)
504
+
505
+ **A browser that is not painting does not run the page.** A hidden browser pane or a background
506
+ tab fires no `requestAnimationFrame` and no IntersectionObserver callbacks, so anything that waits
507
+ on them (a reveal-on-scroll, lazy content, a check timed off a frame) stalls there, and a check
508
+ fails for reasons that have nothing to do with your code. Verify in a visible window or a
509
+ headless browser.
510
+
511
+ ## Checklist before shipping a Print control
512
+
513
+ - [ ] Print opens a window of its own: `window.open("", "_blank", …)`, first thing in the click
514
+ handler, nothing asynchronous before it. No `window.print()` on the Softr page, no in-page
515
+ print view
516
+ - [ ] `null` → a toast telling the user to allow pop-ups; no `noopener` / `noreferrer` in the
517
+ features
518
+ - [ ] The same `window.open` features string as every other Print in the app
519
+ - [ ] Every interpolated value through `escapeHtml` (`& < > " '`, attribute values included)
520
+ - [ ] The printout carries its own `<style>` (no Tailwind classes), its fonts' `<link>`, a
521
+ `<title>` with the context, and an `@page` margin
522
+ - [ ] It prints once the stylesheets, fonts and images are in (capped at ~4s), after a 250ms
523
+ beat, with `win.closed` guarded
524
+ - [ ] Print is disabled until every query has succeeded and every page is fetched (and any saved
525
+ layout is applied), with a `title` that says why
526
+ - [ ] Printing from another page: the linking page opens the window (`preventDefault` only when
527
+ it opened); the target block shows "Preparing the printout…", then replaces its own
528
+ document once the ready flags are set, once
529
+ - [ ] Paper: one shared `<colgroup>` + `table-layout: fixed`; `vertical-align: middle`;
530
+ `break-inside: avoid` on rows; `print-color-adjust: exact` where a background must print;
531
+ photos as fixed-size `<img>`
532
+ - [ ] Verified on a freshly minted preview link, with the window captured by
533
+ `waitForEvent("popup")`