#207: print stylesheet with the classification on every printed sheet
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 5m56s
CI / Build container images (pull_request) Successful in 1m27s
CI / Auth e2e pack (pull_request) Successful in 9m18s
CI / Import/export fidelity gate (pull_request) Successful in 1m6s
CI / Import/export fidelity gate (push) Blocked by required conditions
CD / Build and push images (push) Successful in 20s
CD / Deploy to Test (push) Successful in 11s
CD / Smoke tests against Test (push) Successful in 1m25s
CD / Promote to Int (push) Successful in 12s
CI / Lint, typecheck, test (push) Successful in 6m5s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Has been cancelled

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) <noreply@anthropic.com>
This commit is contained in:
Claude Fable 5 2026-07-31 06:48:44 +02:00
parent adceca7358
commit 809e071f14
8 changed files with 280 additions and 61 deletions

View File

@ -142,6 +142,36 @@ QA:
- **Fixture Image** (`fixture-image`) — one real, servable uploaded image - **Fixture Image** (`fixture-image`) — one real, servable uploaded image
(the placeholder `fileId` inside the Markdown fixture above is not a (the placeholder `fileId` inside the Markdown fixture above is not a
real attachment; this page's image is). 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
`<table class="print-frame">` 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/<id>/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`: Regenerating after editing `content-page.md`:

View File

@ -11,16 +11,61 @@ import { useTranslation } from 'react-i18next';
*/ */
export function ClassificationBanner({ export function ClassificationBanner({
classification, classification,
edge,
}: { }: {
classification: PageClassification | undefined; classification: PageClassification | undefined;
/** Which end of the content this banner marks. */
edge: 'top' | 'bottom';
}): React.JSX.Element | null { }): React.JSX.Element | null {
const { t } = useTranslation('common'); const { t } = useTranslation('common');
const marking = classification ? classificationMarking(classification) : null; const marking = classification ? classificationMarking(classification) : null;
if (!marking) return null; if (!marking) return null;
return ( return (
<p className="classification-banner"> <p className={`classification-banner classification-banner--${edge}`}>
<span className="visually-hidden">{t('classification.label')}: </span> <span className="visually-hidden">{t('classification.label')}: </span>
{marking} {marking}
</p> </p>
); );
} }
/**
* Print frame (issue #207): a REAL `<table>` around the page content whose
* `<thead>`/`<tfoot>` 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 (
<table className="print-frame" role="presentation">
<thead>
<tr>
<td>
<ClassificationBanner classification={classification} edge="top" />
</td>
</tr>
</thead>
<tbody>
<tr>
<td>{children}</td>
</tr>
</tbody>
<tfoot>
<tr>
<td>
<ClassificationBanner classification={classification} edge="bottom" />
</td>
</tr>
</tfoot>
</table>
);
}

View File

@ -12,6 +12,7 @@ import { initUserTheme } from './theme/apply-theme';
import { applyTheme, initSystemThemeListener, readStoredThemeMode } from './theme/theme'; import { applyTheme, initSystemThemeListener, readStoredThemeMode } from './theme/theme';
import './styles/tokens.css'; import './styles/tokens.css';
import './styles/base.css'; import './styles/base.css';
import './styles/print.css';
// theme-init.js already themed the document pre-paint; re-applying here is // 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 // a no-op safety net for contexts serving index.html without it, and the

View File

@ -12,7 +12,7 @@ import * as Y from 'yjs';
import { useAuth } from '../auth/auth-context'; import { useAuth } from '../auth/auth-context';
import { CommentsSection } from '../comments/CommentsSection'; import { CommentsSection } from '../comments/CommentsSection';
import { ClassificationBanner } from '../components/ClassificationBanner'; import { PrintFrame } from '../components/ClassificationBanner';
import { FormError } from '../components/forms'; import { FormError } from '../components/forms';
import { useToast } from '../components/Toast'; import { useToast } from '../components/Toast';
import { AccessRevokedDialog } from '../editor/AccessRevokedDialog'; import { AccessRevokedDialog } from '../editor/AccessRevokedDialog';
@ -565,8 +565,9 @@ export function PageEditorPage(): React.JSX.Element {
)} )}
<div className="editor-page"> <div className="editor-page">
{/* VS-NfD marking above and below the content (issue #206, ADR {/* VS-NfD marking above and below the content (issue #206, ADR
0022) in reading AND edit mode; unclassified pages show none. */} 0022) in reading AND edit mode; unclassified pages show none.
<ClassificationBanner classification={page.data?.classification} /> The PrintFrame repeats the pair on every printed sheet (#207). */}
<PrintFrame classification={page.data?.classification}>
<div className="editor-page__header"> <div className="editor-page__header">
{/* The visible title is an input; give assistive tech the page {/* The visible title is an input; give assistive tech the page
heading it expects on an article view (#166). */} heading it expects on an article view (#166). */}
@ -614,7 +615,7 @@ export function PageEditorPage(): React.JSX.Element {
</div> </div>
)} )}
</div> </div>
<ClassificationBanner classification={page.data?.classification} /> </PrintFrame>
{/* "Linked from" appears below the content in read mode (issue #48); {/* "Linked from" appears below the content in read mode (issue #48);
the inline discussion (issue #133) and the local neighborhood graph the inline discussion (issue #133) and the local neighborhood graph
(issue #113) follow it, in that order. */} (issue #113) follow it, in that order. */}

View File

@ -6,7 +6,7 @@ import { useParams } from 'react-router-dom';
import type { PageClassification } from '@dorfteich/shared'; import type { PageClassification } from '@dorfteich/shared';
import { PublicComments } from '../comments/CommentsSection'; import { PublicComments } from '../comments/CommentsSection';
import { ClassificationBanner } from '../components/ClassificationBanner'; import { PrintFrame } from '../components/ClassificationBanner';
import { ApiError, apiGet } from '../lib/api'; import { ApiError, apiGet } from '../lib/api';
import { useDocumentTitle } from '../lib/use-document-title'; import { useDocumentTitle } from '../lib/use-document-title';
import { countWords, htmlToText } from '../lib/word-count'; import { countWords, htmlToText } from '../lib/word-count';
@ -55,8 +55,9 @@ export function PublicPageView(): React.JSX.Element {
const page = query.data; const page = query.data;
return ( return (
<article className="public-page"> <article className="public-page">
{/* VS-NfD marking above and below the content (issue #206, ADR 0022). */} {/* VS-NfD marking above and below the content (issue #206, ADR 0022);
<ClassificationBanner classification={page.classification} /> the PrintFrame repeats the pair on every printed sheet (#207). */}
<PrintFrame classification={page.classification}>
<p className="public-page__badge">{t('readOnlyBadge')}</p> <p className="public-page__badge">{t('readOnlyBadge')}</p>
<p className="public-page__pond">{page.pondName}</p> <p className="public-page__pond">{page.pondName}</p>
<h1 className="public-page__title">{page.title}</h1> <h1 className="public-page__title">{page.title}</h1>
@ -64,7 +65,7 @@ export function PublicPageView(): React.JSX.Element {
{/* The HTML comes from the server's content cache (issue #24), derived {/* The HTML comes from the server's content cache (issue #24), derived
from the sanitized editor schema safe to render. */} from the sanitized editor schema safe to render. */}
<div className="public-page__body" dangerouslySetInnerHTML={{ __html: page.html }} /> <div className="public-page__body" dangerouslySetInnerHTML={{ __html: page.html }} />
<ClassificationBanner classification={page.classification} /> </PrintFrame>
{/* Existing comments, read-only for anonymous visitors (issue #133). */} {/* Existing comments, read-only for anonymous visitors (issue #133). */}
<PublicComments pondSlug={pondSlug} pageSlug={pageSlug} /> <PublicComments pondSlug={pondSlug} pageSlug={pageSlug} />
</article> </article>

View File

@ -1492,6 +1492,23 @@ button {
font-size: 0.9rem; 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 { .editor-page__body {
display: flex; display: flex;
gap: var(--space-4); gap: var(--space-4);

View File

@ -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 <table>, 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;
}
}

View File

@ -53,7 +53,7 @@ _Meilenstein: `M26 — VS-NfD: classification metadata`_
- [x] Vererbung im Seitenbaum, Herabstufung nur mit eigenem Recht + Audit · 3 AT · #205 - [x] Vererbung im Seitenbaum, Herabstufung nur mit eigenem Recht + Audit · 3 AT · #205
- [ ] Durchreichen in alle Ausgabekanäle · 812 AT · #206#212 - [ ] Durchreichen in alle Ausgabekanäle · 812 AT · #206#212
- [x] Web-Ansicht (Kopf/Fuß) · 1 AT · #206 - [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 - [ ] PDF via gotenberg (`pdf-html.ts` Header/Footer-Template) · 1 AT · #208
- [ ] DOCX/ODT via pandoc (Reference-Doc mit Kopf-/Fußzeile) · 23 AT · #209 - [ ] DOCX/ODT via pandoc (Reference-Doc mit Kopf-/Fußzeile) · 23 AT · #209
- [ ] Markdown-ZIP (Frontmatter + Aufdruck) · 1 AT · #210 - [ ] Markdown-ZIP (Frontmatter + Aufdruck) · 1 AT · #210