added print, download as pdf. no puppeteer, we'll see

This commit is contained in:
2026-09-22 22:06:06 +03:00
parent 122480cd46
commit 6f614270e7
37 changed files with 3955 additions and 473 deletions
+451
View File
@@ -0,0 +1,451 @@
# Finalize-on-print, History view, and unified report preview modal
## Context
Today, the moment a vehicle's second weighing lands, the backend silently
computes `net` and assigns a `doc_number` (`_maybe_finalize`,
[api/views.py:134-141](backend/api/views.py#L134-L141)) — but nothing in the
UI reflects this as an event. The vehicle just sits in the active list
forever, indistinguishable from an in-progress one except that its action
column now shows Print/Design instead of Measure.
The user's actual workflow (matching their existing Delphi weighing software,
and the problem it has that they want to avoid repeating): once a truck is
weighed twice, the operator opens a report preview, prints or downloads the
notice, and the truck should then leave the active list — because it's done,
and a busy station's list should only show trucks still being processed. If a
print physically fails (paper jam, printer offline — a real, frequent
occurrence per the user, and one the browser's `window.print()` can never
report back on, confirmed via `afterprint`'s fundamental limitation), the
operator needs to get back to that vehicle's report without digging through a
separate/hidden screen. A **History** view listing finalized vehicles, with
the same print/download controls available there, is exactly that recovery
path — and it becomes the *only* path back once a vehicle leaves the active
list, so it must be trivial to reach, not buried.
This session's exploration surfaced two things that must be fixed as part of
this work, not deferred:
1. **`driver_name` is dead on arrival.** The live vehicle API is
`api/views.py` + `api/serializers.py` (registered in
`api/urls.py`) — NOT `vehicles/views.py`/`vehicles/serializers.py`, which
are orphaned (not routed) and contain the `driver_name`-required work from
the previous session. `driver_name` is missing entirely from
`VehicleSerializer.Meta.fields` in the real, live
`api/serializers.py:165`. The frontend's create-vehicle form already sends
`driver_name`, but the live backend silently drops it on the floor.
2. **The frontend never removes anything from `vehicles`, and SSE has no
working delete path.** `NomenclatureContext.handleSSEUpdate`
([NomenclatureContext.jsx:407-432](frontend/src/contexts/NomenclatureContext.jsx#L407-L432))
only handles `insert`/`update`; a `delete`/`archive` operation is silently
dropped even though the backend's `operation_map` already produces
`'delete'` for a `'deleted'` action. Any "vehicle leaves the list in real
time, on every open tab" behavior needs this added — it is not
pre-existing plumbing we can just call.
## Decisions made this session (do not re-litigate)
- **driver_name**: nullable at the DB level (already migrated), **required on
create** at the serializer level — fix belongs in `api/serializers.py`
(the live one), same validation approach already proven correct in the
orphaned `vehicles/serializers.py` (object-level `validate()`, gated on
`self.instance is None`, because a per-field `validate_driver_name` hook
was tested and found to skip validation when the key is omitted entirely).
- **When a vehicle leaves the active list**: on the operator's explicit
**Print or Download PDF click** inside the new preview modal — not
automatically the moment `net`/`doc_number` are assigned server-side. The
user was explicit: "when press it - the truck is given a document number,
disappears from list, the preview opens with print/download buttons."
Concretely: the modal opens as soon as Print Notice is clicked (same
trigger as today), and the archive+doc_number-visible transition happens
when the operator acts inside that modal (Print or Download PDF), not on
the initial open. Opening the modal must not, by itself, remove the truck —
an operator who opens it and then closes it without printing must find the
truck still there, still able to re-measure if needed.
- **Preview modal layout** (replaces `ReportPreviewModal`'s current bare
preview-only body): top bar with a matrix/A4 kind switcher on the left and
Download PDF on the right; body renders the selected kind's preview; footer
bar with centered Print and Close buttons. **The switcher is real, not a
placeholder**: `notice_report` can have two actual saved `Report` rows
(one `kind='matrix'`, one `kind='a4'`) side by side — confirmed the model
already allows this (`name` has no uniqueness constraint) — and the
switcher toggles between whichever of the two the operator has designed.
If only one kind has been saved, the other option shows a disabled/empty
state pointing at Design Notice for that kind. Default kind shown on open =
the kind that was open/used last, or the first one that exists.
- **Download PDF: REVERSED from an earlier draft of this plan** — no
server-side rendering. Originally scoped as Playwright (Python) driving
headless Chromium; abandoned once an existing plan
([a4-report-designer.md](.claude/plans/a4-report-designer.md), §"Why
Puppeteer is deferred (not rejected)") surfaced two concrete costs specific
to this deployment that the earlier draft hadn't weighed:
1. **Fonts.** A4 notices print with whatever font is installed on the
*operator's station* — that's the whole point of using
`window.print()`/`@page` CSS instead of a PDF library (see that same
plan's "Why browser printing, not a PDF library"). Server-side
rendering uses the *server's* fonts instead. At this project's actual
reference deployment (one server, ten stations, per
[[deployment-windows-installer]]), that's the wrong machine — a
server-rendered PDF could visibly mismatch what prints at the station,
forever, unless fonts are duplicated and kept in sync server-side.
2. **Puppeteer/Playwright earns its cost when a PDF must be produced with
no human in the loop** (auto-archiving, emailed tickets, batch jobs).
Download PDF has an operator clicking a button and waiting — exactly
the case `window.print()` → "Save as PDF" already covers, today, with
zero new services and zero font-mismatch risk.
**Decided approach**: Download PDF triggers the SAME `window.print()` flow
already used for Print (`printA4Report` for a4, the new print-root-append
flow from Phase C for matrix), just labelled/framed toward the "Save as
PDF" destination rather than physical printing. No Playwright, no
Puppeteer, no new backend endpoint, no offline-installer packaging cost.
This also means Phase D as originally scoped (a new PDF microservice) is
**not needed** — see the rewritten Architecture section below.
- **Print button** in the modal (and Download PDF, per the reversal above)
both use today's `window.print()`/`printA4Report()` flow for A4, and the
Phase C print-root-append flow for matrix. No further backend work.
- **History view**: a new page/route, reachable from the main nav (not
hidden), listing finalized vehicles (those with `doc_number` set) — most
recent first. Each row opens the same unified preview modal used from the
main weighing screen, so reprint/redownload is the identical component and
code path, not a second implementation.
- **Nothing is ever deleted.** Explicit, standing rule for this feature and
beyond: a vehicle record is never destroyed. There are three real states —
**in progress** (active list), **completed** (History, has `doc_number`),
and **voided** (History, flagged but still fully visible/queryable) — and
moving between them is always a flag/field change, never a `DELETE`. This
also means the orphaned `vehicles/views.py`'s `perform_destroy`
([confirmed at api/views.py's pattern](backend/api/views.py) — check
whether `VehicleViewSet` exposes a working DELETE at all; if it does, it is
now out of step with this rule and should be disabled, flagged to the user
rather than silently left reachable).
- **Void ("сторно"/reversal)**: an operator-triggered action available **only
from History, only on an already-completed record** — not on an
in-progress vehicle, and not a bulk/automatic thing. Marks a completed
weighing as voided when it turns out to be wrong (wrong truck, bad
measurement, mistake caught after printing) — the record, its doc_number,
and its full weighing history stay intact and visible in History,
permanently flagged, distinguishable from a normal completed record at a
glance (e.g. a visual badge, per how `.vehicle-status--*` already
distinguishes states in the hero bar,
[Main.css:189-203](frontend/src/components/Main.css#L189-L203)).
- **`vehicle_number` uniqueness must change.** Today it's a plain
`unique=True` column ([vehicles/models.py:11](backend/vehicles/models.py#L11))
— global, forever. Combined with "nothing is ever deleted," this breaks the
very next time a real truck (plate reused constantly) returns to the scale
after its first weighing completes: creating the new active record would
fail on a uniqueness violation. Decided fix: **unique only among
in-progress vehicles** — a truck can't be on the scale twice at once, but
its plate can appear any number of times across History. Implemented as a
Django partial `UniqueConstraint`, not a plain column constraint:
```python
class Meta:
constraints = [
models.UniqueConstraint(
fields=['vehicle_number'],
condition=models.Q(doc_number__isnull=True),
name='unique_active_vehicle_number',
),
]
```
(Django 6.0, confirmed installed — `condition=` on `UniqueConstraint` has
been supported since Django 2.2, compiles to a PostgreSQL partial unique
index, which this project's Postgres backend fully supports.) Requires
dropping the existing `unique=True` on the field itself and adding this
constraint instead — a real migration, not a no-op. Confirm during
implementation whether "in-progress" should be defined as
`doc_number__isnull=True` alone, or also exclude voided-but-somehow-
doc_number-less edge cases — should not arise if void is only ever
triggered on already-completed (doc_number-bearing) records, per the
Void scope decision above, but worth a sanity check against real data.
## Architecture
### Backend
**1. Fix the live serializer** — `backend/api/serializers.py`:
- Add `'driver_name'` to `VehicleSerializer.Meta.fields` (currently missing
at line 165).
- Add the required-on-create `validate()` override, ported from the pattern
already proven in the orphaned `vehicles/serializers.py`:
```python
def validate(self, attrs):
if self.instance is None and not attrs.get('driver_name'):
raise serializers.ValidationError({'driver_name': 'This field is required.'})
return attrs
```
(If `VehicleSerializer` already defines a `validate()`, merge into it
rather than adding a second one.)
- Leave the orphaned `vehicles/` app alone — it's unrouted dead code, not
this task's problem to clean up (flag to the user separately; don't delete
without being asked, per project convention).
**2. No PDF endpoint needed — REVERSED, see the Download PDF decision above.**
No new backend endpoint, no Playwright, no server-side rendering. Phase D on
the backend side is a no-op; everything below is frontend-only.
**3. Print-intent signal for both the modal's Print AND Download PDF
buttons**: both now trigger the *existing* client flow
(`window.print()`/`printA4Report` for a4, the Phase C print-root-append flow
for matrix — Download PDF is the same call, just aimed at the "Save as PDF"
destination the operator picks in that same dialog), which the backend can't
observe directly.
Add a lightweight endpoint, e.g. `POST /api/vehicles/{id}/mark-notice-printed/`,
called by the frontend right when the operator clicks Print in the modal (not
waiting on `afterprint`, since that signal is untrustworthy per this
session's earlier discussion) — its only job is to broadcast the SSE removal
event (below). It does not need to change any DB field; `doc_number` already
being set is the source of truth for "this vehicle belongs in History, not
the active list." This keeps "print succeeded" and "should leave the active
list" cleanly decoupled, exactly as discussed: a failed physical print still
correctly moves the truck to History (where it's one click to reprint), it
does not silently strand it in a state nobody can find.
**4. SSE removal broadcast**: wherever the "moved to history" signal fires
(the `mark-notice-printed` endpoint, and/or the new PDF endpoint on
successful generation), call
`sse_broadcast_update('vehicle', 'archived', serializer.data)` — a new,
distinct action string (not reusing `'deleted'`, since the vehicle record
itself isn't deleted, only its presence in the *active* list changes) — see
frontend section for how this action is consumed. Using
[sse_broadcast_update](backend/scalesapp/sse.py#L26-L58), same call shape
already used by `perform_create`/`perform_update`.
**5. Filter the active-list endpoint** (optional but recommended for
correctness/scale): today `VehicleViewSet.queryset` returns all vehicles
unconditionally. Once History exists, the main screen's vehicle list should
probably query only non-finalized vehicles server-side too (e.g.
`?doc_number__isnull=true`), not rely purely on client-side SSE-driven
removal — SSE gives real-time updates for tabs already open, but a **fresh
page load** should not have to fetch every historical vehicle ever weighed
just to filter them out client-side. Add a query param the frontend's main
list fetch already passes (`NomenclatureContext`'s vehicle endpoint config,
[NomenclatureContext.jsx:9](frontend/src/contexts/NomenclatureContext.jsx#L9)),
and a separate History fetch that explicitly requests
`?doc_number__isnull=false`, ordered newest-first (`-net_date` or similar).
**6. `is_void` field + partial-uniqueness migration** — the `Vehicle` model
itself lives at `backend/vehicles/models.py` regardless of which app's
views/serializers are live (there is only ONE `Vehicle` model — `vehicles/`
being "orphaned" refers only to that app's OWN `views.py`/`serializers.py`,
not the model, which `api/views.py` imports and uses directly). So this
migration correctly targets `backend/vehicles/models.py`, but the
`@action`/serializer changes below belong in `api/views.py`/`api/serializers.py`
(the live ones), matching how `driver_name` and everything else in this plan
is split: model in `vehicles/`, live API surface in `api/`.
- Add `is_void = models.BooleanField(default=False)`, plus
`void_date`/`void_user`/`void_reason` following the same
date/user/note provenance pattern already used for `tare`/`gross`/manual
entries elsewhere on this model (e.g. `tare_manual_user`,
`tare_manual_date`, `tare_manual_note`,
[vehicles/models.py:19-22](backend/vehicles/models.py#L19-L22)) — reuse
that established shape rather than inventing a different one for void.
- Replace `vehicle_number = models.CharField(max_length=15, unique=True)`
with the plain field (drop `unique=True`) plus the partial
`UniqueConstraint` from the decision above, in one migration.
- New `@action` on `VehicleViewSet`, e.g.
`POST /api/vehicles/{id}/void/` (mirroring `measure`'s permission-gated
pattern) — sets `is_void`, `void_date`, `void_user`, accepts a required
`reason` in the request body (store it; an operator voiding a printed
document should always leave a reason, same spirit as
`gross_manual_note`/`tare_manual_note` already require context for a
manual override). Rejects (400) if `doc_number` is not set — void only
applies to completed records, per the scope decision above. This does NOT
set `doc_number` to null or touch `net`/`tare`/`gross` — everything about
the original weighing stays exactly as it was, only `is_void` changes.
Broadcasts `sse_broadcast_update('vehicle', 'voided', serializer.data)` so
any open History view updates live too.
- `VehicleSerializer` needs `is_void`/`void_date`/`void_reason` added to
`Meta.fields` (read-only except through the dedicated void action).
### Frontend
**1. `NomenclatureContext.jsx`** — add a `delete`/`archive` branch to
`handleSSEUpdate`'s switch
([NomenclatureContext.jsx:407-432](frontend/src/contexts/NomenclatureContext.jsx#L407-L432)),
handling the new `'archived'` action (mapped through `operation_map` on the
backend, or handled as a raw fallback string if not added to that map — check
which is simpler when implementing) by removing the vehicle from the
in-memory `vehicles` array. This is the one piece of "real-time removal"
plumbing that doesn't exist anywhere today and is the crux of making
multi-station "truck vanishes everywhere at once" actually work.
**2. Unified `ReportPreviewModal`** — restructure
[ReportPreviewModal.jsx](frontend/src/components/ReportPreviewModal.jsx) (and
its `.css`) into three regions instead of the current bare header+body:
- **Top bar**: left = matrix/A4 toggle, switching between two genuinely
separate stored `Report` rows sharing the same `name='notice_report'` but
different `kind`. Confirmed this needs no schema change: `Report.name` has
**no uniqueness constraint**
([api/models.py:45-75](backend/api/models.py#L45-L75)), so a matrix row and
an a4 row can already coexist under the same name — nothing stops it today.
The gap is entirely on the fetch/lookup side:
- `fetchNoticeReport()` ([Main.jsx:357-360](frontend/src/components/Main.jsx#L357-L360))
currently does `.find(r => r.name === 'notice_report')`, returning
whichever row the API lists first — it has no way to ask for "the matrix
one" vs "the a4 one." Change it to accept a `kind` argument and filter
accordingly (or add a backend query param, `?name=notice_report&kind=a4`,
since `ReportViewSet` already has `filterset_fields = ['name', 'kind']`
— the backend filter already exists, this is a client-side fix only).
- The modal fetches BOTH kind-variants on open (two lookups, or one list
call filtered by name only, then split client-side by `kind`), so the
switcher can toggle without a re-fetch. If one kind doesn't exist yet
(operator never designed an A4 version), that switcher option is
disabled/shows an empty state with a link to Design Notice for that kind.
- `handleDesignNotice`/`handleDesignAs`
([Main.jsx:403-449](frontend/src/components/Main.jsx#L403-L449)) already
let an operator explicitly open either editor regardless of what's saved
(`handleDesignAs('matrix'|'a4')`) — this already supports creating the
*other* kind's report under the same name; the Report Editor's own save
path (`handleSave`,
[ReportEditor.jsx:276-297](frontend/src/components/ReportEditor/ReportEditor.jsx#L276-L297))
does a plain `POST` when `reportId` is unset, so saving a second,
different-kind `notice_report` is already a supported write path with no
backend changes needed there either.
Right = Download PDF button — **REVERSED, see the top-level decision
above**: no backend endpoint, no blob download. Triggers the same
`window.print()`/`printA4Report` (a4) or print-root-append (matrix) flow
as the Print button, so the operator reaches PDF via "Save as PDF" in that
same dialog, using the station's own installed fonts. (Phase C shipped
this button as a disabled placeholder before this reversal was decided —
Phase D's remaining frontend work is wiring it to the real print flow
instead of leaving it disabled, plus the `MatrixPrintDocument` work above.)
- **Body**: existing `EditorCanvas`/`DataProvider`/`BandProvider` preview for
matrix; for A4, mount `PrintDocument` (confirmed stateless/reusable,
[PrintDocument.jsx](frontend/src/components/ReportEditor/a4/print/PrintDocument.jsx))
scaled into the modal body instead of only appending it to `document.body`
for `window.print()` — needs a scaled/contained rendering mode, not the
full-bleed print-root mounting `printReport.jsx` uses today.
- **Footer bar**: centered Print + Close. Print triggers the kind-appropriate
existing print flow (`printA4Report` for a4; for matrix, there is currently
no equivalent "just print this" path — `ReportPreviewModal` today is
preview-only with no print button at all, so a matrix print path needs to
be added, likely also via `window.print()` on the rendered preview DOM, or
reusing the same print-root-append pattern `printReport.jsx` established).
Then calls the new `mark-notice-printed` endpoint.
- Replace `Main.jsx`'s current branching logic
([handlePrintNotice, lines 375-398](frontend/src/components/Main.jsx#L375-L398))
so BOTH kinds open this one modal (today only matrix does; a4 skips the
modal entirely and goes straight to `window.print()`) — this is the biggest
behavioral change to `handlePrintNotice`: it becomes "always open the
modal," full stop, with the modal itself deciding what Print/Download do
based on the loaded report's kind.
**3. New History page** — new route (e.g. `/history`) added alongside the
existing routes in
[App.jsx](frontend/src/App.jsx) (inside the same provider nest, following the
existing route-gating pattern used for admin-only pages). Fetches finalized
vehicles (`?doc_number__isnull=false`, newest first) independently of the
live `vehicles` array (History should NOT depend on `NomenclatureContext`'s
in-memory list, since that list is specifically the *active* set now).
List each with vehicle number, doc number, net weight, date, and a
completed/voided badge (`is_void`); clicking a row opens the same unified
`ReportPreviewModal`, passing that vehicle's data exactly as `Main.jsx` does
today via `vehicleReportData`
([Main.jsx:313-334](frontend/src/components/Main.jsx#L313-L334) — this
transform logic is reusable, likely worth extracting to a shared helper
rather than duplicating in the new History component). Print/Download PDF
stay available on a voided row too — voiding never removes the ability to
see or reproduce the original document, it only flags that the weighing
itself shouldn't be treated as valid.
- **Void button**: on a completed (non-voided) row, a distinct action from
opening the preview — requires a reason (matches the backend's required
`reason` field), then calls the new `/void/` endpoint. On success, the row
updates in place to show the voided badge; it does not disappear or move
anywhere (History is a flat list of everything that ever completed,
voided or not). Confirm with a dialog before voiding — this is meant to be
rare and deliberate, matching the "сторно/reversal" concept's own
seriousness (it's correcting the record, not casually undoable further).
- A nav entry for History (in `Header.jsx`, alongside whatever nav already
exists) — must be easy to find, per the whole point of this feature being
"don't bury the recovery path."
## Open questions to resolve during implementation
Phase D, resolved: no Playwright/Puppeteer, no server-side PDF endpoint (see
the Download PDF decision above). The matrix print path question is already
resolved too — Phase C built it (print-root-append, following
`printReport.jsx`'s pattern, confirmed working).
**Remaining open item, genuinely new scope surfaced while designing Phase D**:
1. **Matrix has no `PrintDocument`-equivalent.** A4's `PrintDocument.jsx`
([print/PrintDocument.jsx](frontend/src/components/ReportEditor/a4/print/PrintDocument.jsx))
is a small (107-line), stateless, non-interactive component that reads
`layout()`'s pure `pages[]` output and renders plain positioned HTML — no
hooks, no drag/select/delete callbacks. Its own doc comment already
anticipated exactly this kind of reuse: *"DELIBERATELY DUMB... that is
what lets a future Puppeteer service consume this exact markup."* Matrix
has no equivalent: its preview goes through `EditorCanvas`
([EditorCanvas.jsx](frontend/src/components/ReportEditor/EditorCanvas.jsx),
682 lines), the SAME interactive editor component used for actual
editing, gated by a `previewMode` prop — confirmed by reading it that
`previewMode` is a flag threaded through the interactive tree, not a
separate rendering path; every element still gets full
`onSelect`/`onUpdate`/`onDelete`/`onDragStart` callback props even in
preview mode. Phase C's matrix print-root flow works today only because
it reuses this same `EditorCanvas`-in-`previewMode` machinery — it works,
but it's borrowing an editor component for a job a pure renderer should
do.
- **Decided**: build a `MatrixPrintDocument`-equivalent as part of this
phase — a pure, non-interactive component mirroring what
`PrintDocument.jsx` already does for A4, reading matrix `elements` +
resolved band/data output and emitting plain markup. This is real,
bounded new work (not a redesign of anything existing), and it's a
prerequisite for the matrix print/preview path to reach the same
quality bar A4 already has — it does NOT unlock server-side PDF
generation (that's still explicitly out of scope, per the reversal
above); it's purely about correctness/simplicity of the existing
client-side matrix print flow, replacing "borrow the editor" with "use
a real preview renderer," same shape as A4.
## Verification
1. Create a vehicle without `driver_name` via the API directly (curl/Postman)
— confirm it's now rejected with a 400 and the field-specific error,
where today it would silently succeed with `driver_name` dropped.
2. Weigh a vehicle twice (tare + gross) in the browser — confirm it still
shows in the active list (finalization alone must not remove it).
3. Click Print Notice — confirm the unified modal opens (for BOTH a matrix-
and an a4-kind `notice_report`, test each), with top bar (kind
switcher + Download PDF), body preview, footer (Print + Close centered).
4. Click Close without printing — confirm the vehicle is still in the active
list (opening the modal alone must not archive).
5. Click Print — confirm `window.print()`/`printA4Report` still works exactly
as before, AND the vehicle disappears from the active list within ~1s on
the SAME tab and on a SECOND open tab (real-time SSE removal across
stations).
6. Click Download PDF instead — confirm it opens the same `window.print()`
dialog as Print (with "Save as PDF" available as a destination, per the
reversed decision — no direct blob download, no bypass of the OS dialog),
and the vehicle is similarly removed from the active list once triggered.
7. Open History — confirm the just-archived vehicle appears, newest first,
with correct doc_number/net/date.
8. Click that History row — confirm the same modal opens and Print/Download
both work again (the reprint path this whole feature exists to guarantee).
9. Confirm a vehicle still mid-weighing (no `gross` yet) never appears in
History.
10. **Reuse a plate**: complete a weighing for `B7898TP`, confirm it archives
to History, then create a brand-new active vehicle also named `B7898TP`
— confirm this succeeds (no uniqueness error), proving the partial
constraint is scoped correctly. Then try creating a SECOND simultaneous
active `B7898TP` while the first is still in progress — confirm this is
rejected (proving uniqueness is still enforced among active records).
11. From History, void a completed record without providing a reason —
confirm it's rejected. Void it with a reason — confirm the row updates
in place with a voided badge, doc_number/net/tare/gross are unchanged,
and Print/Download still work on it afterward.
12. Attempt to void an active (non-completed) vehicle directly via the API —
confirm it's rejected (void is completed-records-only, per scope).
13. Confirm nothing in this feature ever issues a DELETE against a vehicle.
**Done, not just checked**: `VehicleViewSet.destroy()` (the LIVE one in
`api/views.py` — found reachable via `IsAuthenticated` alone, worse than
the orphaned `vehicles/views.py` this item originally worried about) now
unconditionally returns `405` pointing at `/void/` instead. Verify with a
direct `DELETE /api/vehicles/{id}/` call that it's rejected and the
vehicle still exists afterward.
14. **Matrix print quality**: with the new `MatrixPrintDocument`, print (and
Download-as-PDF-via-print) a matrix `notice_report` — confirm the output
matches what the old `EditorCanvas`-in-`previewMode` flow produced
(no regression), and confirm the print/preview path no longer carries
editor-only affordances (no residual selection/drag artifacts, since a
non-interactive component can't have any).