#206: VS-NfD marking in the web view (top+bottom) #265
@ -311,6 +311,36 @@ async function seedContentFixtures(ownerId: string): Promise<void> {
|
||||
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).
|
||||
|
||||
@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
26
apps/web/src/components/ClassificationBanner.tsx
Normal file
26
apps/web/src/components/ClassificationBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
)}
|
||||
<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
|
||||
heading it expects on an article view (#166). */}
|
||||
@ -610,6 +614,7 @@ export function PageEditorPage(): React.JSX.Element {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ClassificationBanner classification={page.data?.classification} />
|
||||
{/* "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. */}
|
||||
|
||||
@ -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 (
|
||||
<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>
|
||||
@ -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. */}
|
||||
<div className="public-page__body" dangerouslySetInnerHTML={{ __html: page.html }} />
|
||||
<ClassificationBanner classification={page.classification} />
|
||||
{/* Existing comments, read-only for anonymous visitors (issue #133). */}
|
||||
<PublicComments pondSlug={pondSlug} pageSlug={pageSlug} />
|
||||
</article>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -97,5 +97,8 @@
|
||||
"settingsNav": {
|
||||
"label": "Abschnitte"
|
||||
},
|
||||
"tableActions": "Aktionen"
|
||||
"tableActions": "Aktionen",
|
||||
"classification": {
|
||||
"label": "Einstufung"
|
||||
}
|
||||
}
|
||||
|
||||
@ -97,5 +97,8 @@
|
||||
"settingsNav": {
|
||||
"label": "Sections"
|
||||
},
|
||||
"tableActions": "Actions"
|
||||
"tableActions": "Actions",
|
||||
"classification": {
|
||||
"label": "Classification"
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user