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.
- package/CHANGELOG.md +7 -0
- package/README.md +12 -3
- package/SKILL.md +31 -12
- package/datasources/multi-datasource.md +1 -1
- package/datasources/softr-database.md +2 -2
- package/datasources/writing.md +7 -5
- package/package.json +1 -1
- package/references/anti-patterns.md +9 -1
- package/references/editable-settings.md +1 -1
- package/references/printing.md +533 -0
- package/references/softr-mcp.md +365 -128
|
@@ -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, "&") // first, or it re-escapes the four below
|
|
45
|
+
.replace(/</g, "<")
|
|
46
|
+
.replace(/>/g, ">")
|
|
47
|
+
.replace(/"/g, """)
|
|
48
|
+
.replace(/'/g, "'");
|
|
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)} · ${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")`
|