From adceca735898ba3294ca4d1a2bd56b3c322051a0 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Fri, 31 Jul 2026 06:18:47 +0200 Subject: [PATCH] #206: show the VS-NfD marking in web view header and footer ClassificationBanner renders the fixed ADR-0022 wording above and below the content in reading view, editor and public page view; unclassified pages show nothing. Announced to assistive tech via a localized hidden prefix (de+en); styled from the plain text token only, so contrast holds in both themes and under every accent with no new color pair. Public content endpoint now carries the classification. New seed fixture classified-note; a11y pack asserts banner top+bottom and axe-clean in light and dark. Co-Authored-By: Claude Fable 5 (1M context) --- apps/api/prisma/seed.ts | 30 +++++++++++++++++++ apps/api/src/public/public.service.ts | 18 ++++++++--- apps/web/e2e/a11y.spec.ts | 18 +++++++++++ .../src/components/ClassificationBanner.tsx | 26 ++++++++++++++++ apps/web/src/pages/PageEditorPage.tsx | 5 ++++ apps/web/src/pages/PublicPageView.tsx | 7 +++++ apps/web/src/styles/base.css | 16 ++++++++++ docs/vs-nfd/20-massnahmenplan.md | 14 ++++----- packages/shared/i18n/de/common.json | 5 +++- packages/shared/i18n/en/common.json | 5 +++- 10 files changed, 131 insertions(+), 13 deletions(-) create mode 100644 apps/web/src/components/ClassificationBanner.tsx diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index 0aabe27..4f8d105 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -311,6 +311,36 @@ async function seedContentFixtures(ownerId: string): Promise { deriveContentOf(everyElementDoc), ); + // "Classified Note" (issue #206, ADR 0022): a VS-NfD-marked page so e2e + // (a11y pack) can assert the marking banner in both themes. Kept simple — + // the marking, not the content, is what the fixture exists for. + const classifiedDoc = editorSchema.node('doc', null, [ + editorSchema.node('heading', { level: 1 }, [editorSchema.text('Classified Note')]), + editorSchema.node('paragraph', null, [ + editorSchema.text('This fixture page carries the VS-NfD marking.'), + ]), + ]); + const classifiedYdoc = new Y.Doc(); + prosemirrorJSONToYXmlFragment( + editorSchema, + classifiedDoc.toJSON(), + classifiedYdoc.getXmlFragment('default'), + ); + const classifiedState = new Uint8Array(Y.encodeStateAsUpdate(classifiedYdoc)); + classifiedYdoc.destroy(); + const classifiedPageId = await upsertFixturePage( + pond.id, + 'classified-note', + 'Classified Note', + ownerId, + classifiedState, + deriveContentOf(classifiedDoc), + ); + await prisma.page.update({ + where: { id: classifiedPageId }, + data: { classification: 'VS_NFD' }, + }); + // "Fixture Image": one real, servable uploaded image (the Markdown // fixture above only carries a placeholder fileId for round-trip // testing — this is the one that actually resolves via /media/:fileId). diff --git a/apps/api/src/public/public.service.ts b/apps/api/src/public/public.service.ts index 1c596b3..e1aea15 100644 --- a/apps/api/src/public/public.service.ts +++ b/apps/api/src/public/public.service.ts @@ -1,6 +1,6 @@ import { Injectable, NotFoundException } from '@nestjs/common'; -import type { PageCommentsView } from '@dorfteich/shared'; -import { Pond, User } from '@prisma/client'; +import type { PageClassification, PageCommentsView } from '@dorfteich/shared'; +import { Page, Pond, User } from '@prisma/client'; import { CommentsService } from '../comments/comments.service'; import { TasksService } from '../pages/tasks.service'; @@ -18,12 +18,21 @@ export interface PublicPageContent { slug: string; /** Pre-rendered body HTML from the content cache (issue #24). */ html: string; + /** VS-NfD marking level (ADR 0022, issue #206) — the read view renders it + * above and below the content. */ + classification: PageClassification; updatedAt: string; } interface ResolvedPage { pond: Pond; - page: { id: string; pondId: string; slug: string; title: string }; + page: { + id: string; + pondId: string; + slug: string; + title: string; + classification: Page['classification']; + }; } /** @@ -54,7 +63,7 @@ export class PublicService { if (!pond) throw new NotFoundException(); const page = await this.prisma.page.findFirst({ where: { pondId: pond.id, slug: pageSlug, deletedAt: null }, - select: { id: true, pondId: true, slug: true, title: true }, + select: { id: true, pondId: true, slug: true, title: true, classification: true }, }); // Hide existence: no read access (incl. anonymous without a public grant) → 404. if (!page || !(await this.permissions.canAccessPage(user, page, 'read'))) { @@ -80,6 +89,7 @@ export class PublicService { title: page.title, slug: page.slug, html: styleTag + resolveMediaUrls(body), + classification: page.classification.toLowerCase() as PageClassification, updatedAt: (cache?.updatedAt ?? new Date()).toISOString(), }; } diff --git a/apps/web/e2e/a11y.spec.ts b/apps/web/e2e/a11y.spec.ts index 97f4e7c..9f08551 100644 --- a/apps/web/e2e/a11y.spec.ts +++ b/apps/web/e2e/a11y.spec.ts @@ -63,6 +63,24 @@ for (const scheme of SCHEMES) { await context.close(); }); + test(`a classified page shows the marking top+bottom and passes axe (${scheme})`, async ({ + browser, + }) => { + const context = await contextForUser(browser, BASE, 'fixture-user'); + const page = await context.newPage(); + await page.emulateMedia({ colorScheme: scheme }); + await page.goto('/p/content-fixtures/classified-note'); + await page.waitForLoadState('networkidle'); + // Kennzeichnung oben UND unten (issue #206, ADR 0022) — fester + // Wortlaut, nicht lokalisiert. + const banners = page.locator('.classification-banner'); + await expect(banners).toHaveCount(2); + await expect(banners.first()).toContainText('VS – NUR FÜR DEN DIENSTGEBRAUCH'); + await expect(banners.last()).toContainText('VS – NUR FÜR DEN DIENSTGEBRAUCH'); + await expectClean(page, `Eingestufte Seite classified-note (${scheme})`); + await context.close(); + }); + test(`user settings pass the axe WCAG A/AA scan (${scheme})`, async ({ browser }) => { const context = await contextForUser(browser, BASE, 'fixture-user'); const page = await context.newPage(); diff --git a/apps/web/src/components/ClassificationBanner.tsx b/apps/web/src/components/ClassificationBanner.tsx new file mode 100644 index 0000000..96bd2a5 --- /dev/null +++ b/apps/web/src/components/ClassificationBanner.tsx @@ -0,0 +1,26 @@ +import { PageClassification, classificationMarking } from '@dorfteich/shared'; +import { useTranslation } from 'react-i18next'; + +/** + * The VS-NfD marking of a page (issue #206, ADR 0022), rendered at the top + * AND bottom of every page representation. The wording comes solely from + * `classificationMarking()` and is deliberately not translated (a marking + * is a fixed formula); only the assistive-tech prefix naming the element is + * localized. Unclassified pages render nothing — marking everything trains + * users to ignore markings (ADR 0022). + */ +export function ClassificationBanner({ + classification, +}: { + classification: PageClassification | undefined; +}): React.JSX.Element | null { + const { t } = useTranslation('common'); + const marking = classification ? classificationMarking(classification) : null; + if (!marking) return null; + return ( +

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

+ ); +} diff --git a/apps/web/src/pages/PageEditorPage.tsx b/apps/web/src/pages/PageEditorPage.tsx index bbc3d76..3137244 100644 --- a/apps/web/src/pages/PageEditorPage.tsx +++ b/apps/web/src/pages/PageEditorPage.tsx @@ -12,6 +12,7 @@ import * as Y from 'yjs'; import { useAuth } from '../auth/auth-context'; import { CommentsSection } from '../comments/CommentsSection'; +import { ClassificationBanner } from '../components/ClassificationBanner'; import { FormError } from '../components/forms'; import { useToast } from '../components/Toast'; import { AccessRevokedDialog } from '../editor/AccessRevokedDialog'; @@ -563,6 +564,9 @@ export function PageEditorPage(): React.JSX.Element { actionsSlot.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 heading it expects on an article view (#166). */} @@ -610,6 +614,7 @@ export function PageEditorPage(): React.JSX.Element {
)}
+ {/* "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 c5f3cb6..2eaf7a5 100644 --- a/apps/web/src/pages/PublicPageView.tsx +++ b/apps/web/src/pages/PublicPageView.tsx @@ -3,7 +3,10 @@ import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; +import type { PageClassification } from '@dorfteich/shared'; + import { PublicComments } from '../comments/CommentsSection'; +import { ClassificationBanner } from '../components/ClassificationBanner'; import { ApiError, apiGet } from '../lib/api'; import { useDocumentTitle } from '../lib/use-document-title'; import { countWords, htmlToText } from '../lib/word-count'; @@ -16,6 +19,7 @@ interface PublicPageContent { title: string; slug: string; html: string; + classification: PageClassification; updatedAt: string; } @@ -51,6 +55,8 @@ 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}

@@ -58,6 +64,7 @@ export function PublicPageView(): React.JSX.Element { {/* 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 ea01521..bfed238 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -1476,6 +1476,22 @@ button { color: var(--color-danger); } +/* VS-NfD classification marking (issue #206, ADR 0022): rendered at the top + and bottom of every page representation. Deliberately built from the + normal text token only — full contrast in both themes and under every + accent, with no new color pair for the contrast fence. */ +.classification-banner { + margin: var(--space-2) 0; + padding: var(--space-1) var(--space-2); + border-top: 2px solid currentColor; + border-bottom: 2px solid currentColor; + color: var(--color-text); + font-weight: 700; + letter-spacing: 0.08em; + text-align: center; + font-size: 0.9rem; +} + .editor-page__body { display: flex; gap: var(--space-4); diff --git a/docs/vs-nfd/20-massnahmenplan.md b/docs/vs-nfd/20-massnahmenplan.md index f4078ae..a6c01ac 100644 --- a/docs/vs-nfd/20-massnahmenplan.md +++ b/docs/vs-nfd/20-massnahmenplan.md @@ -52,13 +52,13 @@ _Meilenstein: `M26 — VS-NfD: classification metadata`_ Instance-Setting · 2 AT · #204 - [x] Vererbung im Seitenbaum, Herabstufung nur mit eigenem Recht + Audit · 3 AT · #205 - [ ] Durchreichen in alle Ausgabekanäle · 8–12 AT · #206–#212 - - Web-Ansicht (Kopf/Fuß) · 1 AT · #206 - - **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 - - Atom-Feeds, Public-API, Suchergebnisse, No-JS-Shell · 2–3 AT · #211 - - Attachment-Download (Dateiname-Präfix + Begleitdatei) · 1–2 AT · #212 + - [x] Web-Ansicht (Kopf/Fuß) · 1 AT · #206 + - [ ] **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 + - [ ] Atom-Feeds, Public-API, Suchergebnisse, No-JS-Shell · 2–3 AT · #211 + - [ ] Attachment-Download (Dateiname-Präfix + Begleitdatei) · 1–2 AT · #212 - [ ] Warnung/Sperre beim Anhängen an eingestufte Seiten · 1 AT · #213 ### P1-3 Verifizierter Offline-/Airgap-Pfad · 8–10 AT ⟵ neu aus Roadmap diff --git a/packages/shared/i18n/de/common.json b/packages/shared/i18n/de/common.json index a5b9465..9da66f5 100644 --- a/packages/shared/i18n/de/common.json +++ b/packages/shared/i18n/de/common.json @@ -97,5 +97,8 @@ "settingsNav": { "label": "Abschnitte" }, - "tableActions": "Aktionen" + "tableActions": "Aktionen", + "classification": { + "label": "Einstufung" + } } diff --git a/packages/shared/i18n/en/common.json b/packages/shared/i18n/en/common.json index b1a7f48..4e4e2bb 100644 --- a/packages/shared/i18n/en/common.json +++ b/packages/shared/i18n/en/common.json @@ -97,5 +97,8 @@ "settingsNav": { "label": "Sections" }, - "tableActions": "Actions" + "tableActions": "Actions", + "classification": { + "label": "Classification" + } } -- 2.45.2