softr-vibe-coding 2.9.1 → 2.11.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.
@@ -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.** An `application_preview` link carries
501
+ `&version=<n>` in its URL and keeps serving that version, by design. After pushing a change, mint a
502
+ fresh preview link before you 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")`