#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
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:
parent
adceca7358
commit
809e071f14
@ -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
|
||||
`<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`:
|
||||
|
||||
|
||||
@ -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 (
|
||||
<p className="classification-banner">
|
||||
<p className={`classification-banner classification-banner--${edge}`}>
|
||||
<span className="visually-hidden">{t('classification.label')}: </span>
|
||||
{marking}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
)}
|
||||
<div className="editor-page">
|
||||
{/* VS-NfD marking above and below the content (issue #206, ADR
|
||||
0022) — in reading AND edit mode; unclassified pages show none. */}
|
||||
<ClassificationBanner classification={page.data?.classification} />
|
||||
<div className="editor-page__header">
|
||||
{/* 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). */}
|
||||
<PrintFrame classification={page.data?.classification}>
|
||||
<div className="editor-page__header">
|
||||
{/* The visible title is an input; give assistive tech the page
|
||||
heading it expects on an article view (#166). */}
|
||||
<h1 className="visually-hidden">{title || t('title.placeholder')}</h1>
|
||||
<input
|
||||
type="text"
|
||||
className="editor-page__title"
|
||||
value={title}
|
||||
placeholder={t('title.placeholder')}
|
||||
aria-label={t('title.label')}
|
||||
disabled={mode !== 'edit'}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
onBlur={() => void saveTitle()}
|
||||
/>
|
||||
</div>
|
||||
{/* Status line between the header and the article (#134): last update,
|
||||
<h1 className="visually-hidden">{title || t('title.placeholder')}</h1>
|
||||
<input
|
||||
type="text"
|
||||
className="editor-page__title"
|
||||
value={title}
|
||||
placeholder={t('title.placeholder')}
|
||||
aria-label={t('title.label')}
|
||||
disabled={mode !== 'edit'}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
onBlur={() => void saveTitle()}
|
||||
/>
|
||||
</div>
|
||||
{/* Status line between the header and the article (#134): last update,
|
||||
word count, reading time — reading mode only. */}
|
||||
{mode === 'view' && page.data && (
|
||||
<PageStatusBar updatedAt={page.data.updatedAt} wordCount={wordCount} />
|
||||
)}
|
||||
<div className="editor-page__body">
|
||||
<PageEditor
|
||||
page={resolved}
|
||||
mode={mode}
|
||||
pondSlug={pondSlug}
|
||||
showAttachments={showAttachments}
|
||||
showPageTools={showPageTools}
|
||||
onCloseAttachments={() => setShowAttachments(false)}
|
||||
onWriteAccess={setCanWrite}
|
||||
/>
|
||||
{/* Side panels stack vertically in one column (M10 follow-up). */}
|
||||
{(showLabels || showHistory) && (
|
||||
<div className="editor-page__panels">
|
||||
{showLabels && (
|
||||
<LabelPicker
|
||||
pageId={resolved.id}
|
||||
pondId={resolved.pondId}
|
||||
pondSlug={pondSlug}
|
||||
onClose={() => setShowLabels(false)}
|
||||
/>
|
||||
)}
|
||||
{showHistory && (
|
||||
<HistoryPanel pageId={resolved.id} onClose={() => setShowHistory(false)} />
|
||||
)}
|
||||
</div>
|
||||
{mode === 'view' && page.data && (
|
||||
<PageStatusBar updatedAt={page.data.updatedAt} wordCount={wordCount} />
|
||||
)}
|
||||
</div>
|
||||
<ClassificationBanner classification={page.data?.classification} />
|
||||
<div className="editor-page__body">
|
||||
<PageEditor
|
||||
page={resolved}
|
||||
mode={mode}
|
||||
pondSlug={pondSlug}
|
||||
showAttachments={showAttachments}
|
||||
showPageTools={showPageTools}
|
||||
onCloseAttachments={() => setShowAttachments(false)}
|
||||
onWriteAccess={setCanWrite}
|
||||
/>
|
||||
{/* Side panels stack vertically in one column (M10 follow-up). */}
|
||||
{(showLabels || showHistory) && (
|
||||
<div className="editor-page__panels">
|
||||
{showLabels && (
|
||||
<LabelPicker
|
||||
pageId={resolved.id}
|
||||
pondId={resolved.pondId}
|
||||
pondSlug={pondSlug}
|
||||
onClose={() => setShowLabels(false)}
|
||||
/>
|
||||
)}
|
||||
{showHistory && (
|
||||
<HistoryPanel pageId={resolved.id} onClose={() => setShowHistory(false)} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PrintFrame>
|
||||
{/* "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. */}
|
||||
|
||||
@ -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 (
|
||||
<article className="public-page">
|
||||
{/* VS-NfD marking above and below the content (issue #206, ADR 0022). */}
|
||||
<ClassificationBanner classification={page.classification} />
|
||||
<p className="public-page__badge">{t('readOnlyBadge')}</p>
|
||||
<p className="public-page__pond">{page.pondName}</p>
|
||||
<h1 className="public-page__title">{page.title}</h1>
|
||||
<PageStatusBar updatedAt={page.updatedAt} wordCount={wordCount} />
|
||||
{/* The HTML comes from the server's content cache (issue #24), derived
|
||||
from the sanitized editor schema — safe to render. */}
|
||||
<div className="public-page__body" dangerouslySetInnerHTML={{ __html: page.html }} />
|
||||
<ClassificationBanner classification={page.classification} />
|
||||
{/* VS-NfD marking above and below the content (issue #206, ADR 0022);
|
||||
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__pond">{page.pondName}</p>
|
||||
<h1 className="public-page__title">{page.title}</h1>
|
||||
<PageStatusBar updatedAt={page.updatedAt} wordCount={wordCount} />
|
||||
{/* The HTML comes from the server's content cache (issue #24), derived
|
||||
from the sanitized editor schema — safe to render. */}
|
||||
<div className="public-page__body" dangerouslySetInnerHTML={{ __html: page.html }} />
|
||||
</PrintFrame>
|
||||
{/* Existing comments, read-only for anonymous visitors (issue #133). */}
|
||||
<PublicComments pondSlug={pondSlug} pageSlug={pageSlug} />
|
||||
</article>
|
||||
|
||||
@ -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);
|
||||
|
||||
124
apps/web/src/styles/print.css
Normal file
124
apps/web/src/styles/print.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user