From 809e071f146cb6454ba8d650ffb884221d80d063 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Fri, 31 Jul 2026 06:48:44 +0200 Subject: [PATCH] #207: print stylesheet with the classification on every printed sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First @media print support at all: page size/margins, navigation and interactive chrome suppressed, break behaviour for headings, tables, code blocks, figures and plugin blocks. The VS-NfD marking runs as header AND footer on every sheet via a real-table PrintFrame whose thead/tfoot browsers repeat per page — @page margin boxes are unimplemented and position:fixed places unreliably in both engines (verified empirically); on screen the table chain renders as plain blocks, so nothing changes visually. Verified as PDF-from-browser in Chromium 140 and Firefox 153 (2 markings on every page of a multi-page document); the repeatable procedure is documented in apps/web/e2e/README.md. Unclassified pages print without a marking. Co-Authored-By: Claude Fable 5 (1M context) --- apps/web/e2e/README.md | 30 +++++ .../src/components/ClassificationBanner.tsx | 47 ++++++- apps/web/src/main.tsx | 1 + apps/web/src/pages/PageEditorPage.tsx | 97 +++++++------- apps/web/src/pages/PublicPageView.tsx | 23 ++-- apps/web/src/styles/base.css | 17 +++ apps/web/src/styles/print.css | 124 ++++++++++++++++++ docs/vs-nfd/20-massnahmenplan.md | 2 +- 8 files changed, 280 insertions(+), 61 deletions(-) create mode 100644 apps/web/src/styles/print.css diff --git a/apps/web/e2e/README.md b/apps/web/e2e/README.md index 2ee1282..3efa43d 100644 --- a/apps/web/e2e/README.md +++ b/apps/web/e2e/README.md @@ -142,6 +142,36 @@ QA: - **Fixture Image** (`fixture-image`) — one real, servable uploaded image (the placeholder `fileId` inside the Markdown fixture above is not a real attachment; this page's image is). +- **Classified Note** (`classified-note`) — a VS-NfD-marked page + (issue #206, ADR 0022) so the a11y pack can assert the classification + banner (top and bottom, both themes). + +## Print marking check (`print.css`, issue #207) + +The VS-NfD marking must appear at the top AND bottom of **every printed +sheet** (ADR 0022). Mechanism: the page content sits in a real +`` whose `thead`/`tfoot` carry the banners — +the one construct Chromium and Gecko both repeat per page (`@page` margin +boxes are unimplemented; `position: fixed` places unreliably). The check, +repeatable against a running local stack (seeded, signed in as +`fixture-user`): + +1. Classify a multi-page document: + `UPDATE pages SET classification='VS_NFD' WHERE slug='every-element';` +2. **Chromium**: print `/p/content-fixtures/every-element` to PDF + (browser print dialog, or Playwright `page.pdf({ preferCSSPageSize: +true })` after logging in). +3. **Gecko (Firefox)**: open the same page (or grant it a public reader + and use `/public/content-fixtures/every-element`), Cmd+P → save as + PDF. Headless equivalent: geckodriver + WebDriver `POST +/session//print`. +4. Every page of both PDFs must show `VS – NUR FÜR DEN DIENSTGEBRAUCH` + once at the top and once at the bottom, with no navigation chrome and + no overlap with content (quick text check: + `pypdf` → `page.extract_text().count(...) == 2` per page). + +Last verified 2026-07-31: Chromium 140 (Playwright) and Firefox 153, +2 markings on every page of a 2-page PDF each. Regenerating after editing `content-page.md`: diff --git a/apps/web/src/components/ClassificationBanner.tsx b/apps/web/src/components/ClassificationBanner.tsx index 96bd2a5..e4edf73 100644 --- a/apps/web/src/components/ClassificationBanner.tsx +++ b/apps/web/src/components/ClassificationBanner.tsx @@ -11,16 +11,61 @@ import { useTranslation } from 'react-i18next'; */ export function ClassificationBanner({ classification, + edge, }: { classification: PageClassification | undefined; + /** Which end of the content this banner marks. */ + edge: 'top' | 'bottom'; }): React.JSX.Element | null { const { t } = useTranslation('common'); const marking = classification ? classificationMarking(classification) : null; if (!marking) return null; return ( -

+

{t('classification.label')}: {marking}

); } + +/** + * Print frame (issue #207): a REAL `
` around the page content whose + * ``/`` carry the classification banners — the one mechanism + * Chromium and Gecko both repeat on every printed page (`@page` margin + * boxes are unimplemented, `position: fixed` places unreliably; verified + * empirically). On screen the table chain is neutralized to `display: + * block` (base.css), so layout and DOM order (banner, content, banner) + * look exactly like before; `role="presentation"` keeps assistive tech + * from announcing a data table. + */ +export function PrintFrame({ + classification, + children, +}: { + classification: PageClassification | undefined; + children: React.ReactNode; +}): React.JSX.Element { + return ( +
+ + + + + + + + + + + + + + + +
+ +
{children}
+ +
+ ); +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 9ec6b27..dbba67d 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -12,6 +12,7 @@ import { initUserTheme } from './theme/apply-theme'; import { applyTheme, initSystemThemeListener, readStoredThemeMode } from './theme/theme'; import './styles/tokens.css'; import './styles/base.css'; +import './styles/print.css'; // theme-init.js already themed the document pre-paint; re-applying here is // a no-op safety net for contexts serving index.html without it, and the diff --git a/apps/web/src/pages/PageEditorPage.tsx b/apps/web/src/pages/PageEditorPage.tsx index 3137244..573e7e2 100644 --- a/apps/web/src/pages/PageEditorPage.tsx +++ b/apps/web/src/pages/PageEditorPage.tsx @@ -12,7 +12,7 @@ import * as Y from 'yjs'; import { useAuth } from '../auth/auth-context'; import { CommentsSection } from '../comments/CommentsSection'; -import { ClassificationBanner } from '../components/ClassificationBanner'; +import { PrintFrame } from '../components/ClassificationBanner'; import { FormError } from '../components/forms'; import { useToast } from '../components/Toast'; import { AccessRevokedDialog } from '../editor/AccessRevokedDialog'; @@ -565,56 +565,57 @@ export function PageEditorPage(): React.JSX.Element { )}
{/* VS-NfD marking above and below the content (issue #206, ADR - 0022) — in reading AND edit mode; unclassified pages show none. */} - -
- {/* The visible title is an input; give assistive tech the page + 0022) — in reading AND edit mode; unclassified pages show none. + The PrintFrame repeats the pair on every printed sheet (#207). */} + +
+ {/* The visible title is an input; give assistive tech the page heading it expects on an article view (#166). */} -

{title || t('title.placeholder')}

- setTitle(event.target.value)} - onBlur={() => void saveTitle()} - /> -
- {/* Status line between the header and the article (#134): last update, +

{title || t('title.placeholder')}

+ setTitle(event.target.value)} + onBlur={() => void saveTitle()} + /> +
+ {/* Status line between the header and the article (#134): last update, word count, reading time — reading mode only. */} - {mode === 'view' && page.data && ( - - )} -
- setShowAttachments(false)} - onWriteAccess={setCanWrite} - /> - {/* Side panels stack vertically in one column (M10 follow-up). */} - {(showLabels || showHistory) && ( -
- {showLabels && ( - setShowLabels(false)} - /> - )} - {showHistory && ( - setShowHistory(false)} /> - )} -
+ {mode === 'view' && page.data && ( + )} -
- +
+ setShowAttachments(false)} + onWriteAccess={setCanWrite} + /> + {/* Side panels stack vertically in one column (M10 follow-up). */} + {(showLabels || showHistory) && ( +
+ {showLabels && ( + setShowLabels(false)} + /> + )} + {showHistory && ( + setShowHistory(false)} /> + )} +
+ )} +
+ {/* "Linked from" appears below the content in read mode (issue #48); the inline discussion (issue #133) and the local neighborhood graph (issue #113) follow it, in that order. */} diff --git a/apps/web/src/pages/PublicPageView.tsx b/apps/web/src/pages/PublicPageView.tsx index 2eaf7a5..6786160 100644 --- a/apps/web/src/pages/PublicPageView.tsx +++ b/apps/web/src/pages/PublicPageView.tsx @@ -6,7 +6,7 @@ import { useParams } from 'react-router-dom'; import type { PageClassification } from '@dorfteich/shared'; import { PublicComments } from '../comments/CommentsSection'; -import { ClassificationBanner } from '../components/ClassificationBanner'; +import { PrintFrame } from '../components/ClassificationBanner'; import { ApiError, apiGet } from '../lib/api'; import { useDocumentTitle } from '../lib/use-document-title'; import { countWords, htmlToText } from '../lib/word-count'; @@ -55,16 +55,17 @@ export function PublicPageView(): React.JSX.Element { const page = query.data; return (
- {/* VS-NfD marking above and below the content (issue #206, ADR 0022). */} - -

{t('readOnlyBadge')}

-

{page.pondName}

-

{page.title}

- - {/* The HTML comes from the server's content cache (issue #24), derived - from the sanitized editor schema — safe to render. */} -
- + {/* VS-NfD marking above and below the content (issue #206, ADR 0022); + the PrintFrame repeats the pair on every printed sheet (#207). */} + +

{t('readOnlyBadge')}

+

{page.pondName}

+

{page.title}

+ + {/* The HTML comes from the server's content cache (issue #24), derived + from the sanitized editor schema — safe to render. */} +
+ {/* Existing comments, read-only for anonymous visitors (issue #133). */}
diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index bfed238..7ee68f2 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -1492,6 +1492,23 @@ button { font-size: 0.9rem; } +/* The print frame (issue #207) is a REAL table so browsers repeat its + thead/tfoot (the classification banners) on every printed page. On + screen the whole chain renders as plain blocks — layout and visual + order are exactly as without the table. */ +.print-frame, +.print-frame > thead, +.print-frame > tbody, +.print-frame > tfoot, +.print-frame > * > tr, +.print-frame > * > tr > td { + display: block; + width: 100%; + border: none; + padding: 0; + margin: 0; +} + .editor-page__body { display: flex; gap: var(--space-4); diff --git a/apps/web/src/styles/print.css b/apps/web/src/styles/print.css new file mode 100644 index 0000000..0f5cd99 --- /dev/null +++ b/apps/web/src/styles/print.css @@ -0,0 +1,124 @@ +/* + * Print stylesheet (issue #207, ADR 0022). Browser printing must produce + * paper that carries the VS-NfD marking on EVERY sheet — this file gives + * the app a print layout at all (there was none) and turns the two on-page + * classification banners (#206) into running header/footer boxes inside + * the page margins. + * + * Mechanism for the per-sheet marking: `@page` margin boxes are not + * implemented by Chromium/Gecko, so the equivalent is `position: fixed`, + * which paged media repeat on every page. The fixed banners are pulled + * into the top/bottom `@page` margin with negative offsets so they never + * overlap flowing content. Verified as PDF-from-browser in Chromium and + * Firefox (procedure: apps/web/e2e/README.md §Print). + */ + +@page { + size: A4; + margin: 22mm 15mm; +} + +@media print { + /* Navigation and interactive chrome disappear; the content is the page. */ + .topbar, + .sidebar, + .sidebar-resizer, + .skip-link, + .app-footer, + .editor-toolbar, + .editor-connection, + .editor-banner, + .editor-page__panels, + .page-statusbar, + .backlinks, + .local-graph, + .comments-section, + .public-page__badge, + .toast-region { + display: none !important; + } + + /* The app shell must not clip or scroll — printing needs one long flow. */ + html, + body, + #root { + height: auto !important; + overflow: visible !important; + } + + .app, + .app-body, + .main, + .main-column, + .editor-shell, + .editor-content { + display: block !important; + overflow: visible !important; + height: auto !important; + max-height: none !important; + padding: 0 !important; + margin: 0 !important; + } + + /* Break behaviour: headings stay with what follows; tables, code blocks, + figures and plugin blocks are kept intact where possible. */ + h1, + h2, + h3, + h4 { + break-after: avoid; + } + + table, + pre, + figure, + img, + .plugin-block { + break-inside: avoid; + } + + p, + li { + orphans: 2; + widows: 2; + } + + /* The VS-NfD marking as a running header AND footer on every sheet + (ADR 0022): in print the PrintFrame (a real , see + ClassificationBanner.tsx) gets its table display chain back — real + thead/tfoot are the one mechanism Chromium AND Gecko repeat on every + page of a fragmented table (`@page` margin boxes are unimplemented, + `position: fixed` places unreliably; verified empirically). + Unclassified pages have no banner in the DOM, so they print without + a marking. */ + .print-frame { + display: table !important; + width: 100%; + border-collapse: collapse; + } + + .print-frame > thead { + display: table-header-group !important; + } + + .print-frame > tbody { + display: table-row-group !important; + } + + .print-frame > tfoot { + display: table-footer-group !important; + } + + .print-frame > * > tr { + display: table-row !important; + } + + .print-frame > * > tr > td { + display: table-cell !important; + } + + .classification-banner { + color: #000; + background: none; + } +} diff --git a/docs/vs-nfd/20-massnahmenplan.md b/docs/vs-nfd/20-massnahmenplan.md index a6c01ac..7b5991b 100644 --- a/docs/vs-nfd/20-massnahmenplan.md +++ b/docs/vs-nfd/20-massnahmenplan.md @@ -53,7 +53,7 @@ _Meilenstein: `M26 — VS-NfD: classification metadata`_ - [x] Vererbung im Seitenbaum, Herabstufung nur mit eigenem Recht + Audit · 3 AT · #205 - [ ] Durchreichen in alle Ausgabekanäle · 8–12 AT · #206–#212 - [x] Web-Ansicht (Kopf/Fuß) · 1 AT · #206 - - [ ] **Print-CSS** (`@media print`, Kopf/Fuß je Seite) — fehlt komplett · 1 AT · #207 + - [x] **Print-CSS** (`@media print`, Kopf/Fuß je Seite) — fehlt komplett · 1 AT · #207 - [ ] PDF via gotenberg (`pdf-html.ts` Header/Footer-Template) · 1 AT · #208 - [ ] DOCX/ODT via pandoc (Reference-Doc mit Kopf-/Fußzeile) · 2–3 AT · #209 - [ ] Markdown-ZIP (Frontmatter + Aufdruck) · 1 AT · #210