#206: VS-NfD marking in the web view (top+bottom) #265

Merged
fable-5 merged 1 commits from issue-206-web-classification-marking into main 2026-07-31 06:49:22 +02:00
10 changed files with 131 additions and 13 deletions

View File

@ -311,6 +311,36 @@ async function seedContentFixtures(ownerId: string): Promise<void> {
deriveContentOf(everyElementDoc), 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 Image": one real, servable uploaded image (the Markdown
// fixture above only carries a placeholder fileId for round-trip // fixture above only carries a placeholder fileId for round-trip
// testing — this is the one that actually resolves via /media/:fileId). // testing — this is the one that actually resolves via /media/:fileId).

View File

@ -1,6 +1,6 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import type { PageCommentsView } from '@dorfteich/shared'; import type { PageClassification, PageCommentsView } from '@dorfteich/shared';
import { Pond, User } from '@prisma/client'; import { Page, Pond, User } from '@prisma/client';
import { CommentsService } from '../comments/comments.service'; import { CommentsService } from '../comments/comments.service';
import { TasksService } from '../pages/tasks.service'; import { TasksService } from '../pages/tasks.service';
@ -18,12 +18,21 @@ export interface PublicPageContent {
slug: string; slug: string;
/** Pre-rendered body HTML from the content cache (issue #24). */ /** Pre-rendered body HTML from the content cache (issue #24). */
html: string; html: string;
/** VS-NfD marking level (ADR 0022, issue #206) the read view renders it
* above and below the content. */
classification: PageClassification;
updatedAt: string; updatedAt: string;
} }
interface ResolvedPage { interface ResolvedPage {
pond: Pond; 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(); if (!pond) throw new NotFoundException();
const page = await this.prisma.page.findFirst({ const page = await this.prisma.page.findFirst({
where: { pondId: pond.id, slug: pageSlug, deletedAt: null }, 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. // Hide existence: no read access (incl. anonymous without a public grant) → 404.
if (!page || !(await this.permissions.canAccessPage(user, page, 'read'))) { if (!page || !(await this.permissions.canAccessPage(user, page, 'read'))) {
@ -80,6 +89,7 @@ export class PublicService {
title: page.title, title: page.title,
slug: page.slug, slug: page.slug,
html: styleTag + resolveMediaUrls(body), html: styleTag + resolveMediaUrls(body),
classification: page.classification.toLowerCase() as PageClassification,
updatedAt: (cache?.updatedAt ?? new Date()).toISOString(), updatedAt: (cache?.updatedAt ?? new Date()).toISOString(),
}; };
} }

View File

@ -63,6 +63,24 @@ for (const scheme of SCHEMES) {
await context.close(); 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 }) => { test(`user settings pass the axe WCAG A/AA scan (${scheme})`, async ({ browser }) => {
const context = await contextForUser(browser, BASE, 'fixture-user'); const context = await contextForUser(browser, BASE, 'fixture-user');
const page = await context.newPage(); const page = await context.newPage();

View File

@ -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 (
<p className="classification-banner">
<span className="visually-hidden">{t('classification.label')}: </span>
{marking}
</p>
);
}

View File

@ -12,6 +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 { 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';
@ -563,6 +564,9 @@ export function PageEditorPage(): React.JSX.Element {
actionsSlot.element, actionsSlot.element,
)} )}
<div className="editor-page"> <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"> <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). */}
@ -610,6 +614,7 @@ export function PageEditorPage(): React.JSX.Element {
</div> </div>
)} )}
</div> </div>
<ClassificationBanner classification={page.data?.classification} />
{/* "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

@ -3,7 +3,10 @@ import { useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import type { PageClassification } from '@dorfteich/shared';
import { PublicComments } from '../comments/CommentsSection'; import { PublicComments } from '../comments/CommentsSection';
import { ClassificationBanner } 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';
@ -16,6 +19,7 @@ interface PublicPageContent {
title: string; title: string;
slug: string; slug: string;
html: string; html: string;
classification: PageClassification;
updatedAt: string; updatedAt: string;
} }
@ -51,6 +55,8 @@ 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). */}
<ClassificationBanner 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>
@ -58,6 +64,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} />
{/* 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

@ -1476,6 +1476,22 @@ button {
color: var(--color-danger); 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 { .editor-page__body {
display: flex; display: flex;
gap: var(--space-4); gap: var(--space-4);

View File

@ -52,13 +52,13 @@ _Meilenstein: `M26 — VS-NfD: classification metadata`_
Instance-Setting · 2 AT · #204 Instance-Setting · 2 AT · #204
- [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
- 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 - [ ] **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
- Atom-Feeds, Public-API, Suchergebnisse, No-JS-Shell · 23 AT · #211 - [ ] Atom-Feeds, Public-API, Suchergebnisse, No-JS-Shell · 23 AT · #211
- Attachment-Download (Dateiname-Präfix + Begleitdatei) · 12 AT · #212 - [ ] Attachment-Download (Dateiname-Präfix + Begleitdatei) · 12 AT · #212
- [ ] Warnung/Sperre beim Anhängen an eingestufte Seiten · 1 AT · #213 - [ ] Warnung/Sperre beim Anhängen an eingestufte Seiten · 1 AT · #213
### P1-3 Verifizierter Offline-/Airgap-Pfad · 810 AT ⟵ neu aus Roadmap ### P1-3 Verifizierter Offline-/Airgap-Pfad · 810 AT ⟵ neu aus Roadmap

View File

@ -97,5 +97,8 @@
"settingsNav": { "settingsNav": {
"label": "Abschnitte" "label": "Abschnitte"
}, },
"tableActions": "Aktionen" "tableActions": "Aktionen",
"classification": {
"label": "Einstufung"
}
} }

View File

@ -97,5 +97,8 @@
"settingsNav": { "settingsNav": {
"label": "Sections" "label": "Sections"
}, },
"tableActions": "Actions" "tableActions": "Actions",
"classification": {
"label": "Classification"
}
} }