From 1c774596e1ae4947bec05bd83abd343f2af6cd76 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Fri, 31 Jul 2026 18:33:27 +0200 Subject: [PATCH] =?UTF-8?q?#244:=20mode=20marked=20=E2=80=94=20flag=20prof?= =?UTF-8?q?ile-violating=20configuration=20in=20the=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every catalog-listed control on the admin surfaces carries an accessible deviation marking in mode marked: text + icon under the control (never colour alone), part of the control's accessible description (aria-describedby), i18n de+en. The check runs against the CURRENT control value, so a violating choice is marked before saving. Covered controls: registration mode, new-page classification, upload policy, SVG policy, the four master switches (api/mcp/feeds/plugins), the legal texts (violating while empty), and the Nextcloud backup toggle on the system panel. The profile card (#243) gains the warning summary and the hardening-guide reference. e2e: new vs-nfd-marking pack (marked half in CI — the e2e api now runs VS_NFD_MODE=marked, which also puts the marked state into the a11y admin scan; off half in local default runs; both halves verified live). hidden/enforced follow in #245/#246. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8 --- .gitea/workflows/ci.yml | 11 +- apps/web/e2e/README.md | 5 + apps/web/e2e/vs-nfd-marking.spec.ts | 60 +++++++++++ apps/web/src/components/forms.tsx | 24 ++++- apps/web/src/components/vs-nfd.tsx | 59 ++++++++++ apps/web/src/pages/AdminBackupSection.tsx | 5 + apps/web/src/pages/AdminSettingsPage.tsx | 126 +++++++++++++--------- apps/web/src/styles/base.css | 14 +++ packages/shared/i18n/de/settings.json | 4 +- packages/shared/i18n/en/settings.json | 4 +- 10 files changed, 254 insertions(+), 58 deletions(-) create mode 100644 apps/web/e2e/vs-nfd-marking.spec.ts create mode 100644 apps/web/src/components/vs-nfd.tsx diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 8b18654..c7ea086 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -192,7 +192,10 @@ jobs: - name: Start api, collab, and static web server run: | - (cd apps/api && PORT=3001 node dist/main.js > /tmp/api.log 2>&1 &) + # VS_NFD_MODE=marked: the marking pack and the a11y admin scan + # cover the marked state (issue #244); mode off is covered by + # local full runs and the marking pack's off-assertions there. + (cd apps/api && PORT=3001 VS_NFD_MODE=marked node dist/main.js > /tmp/api.log 2>&1 &) (cd apps/collab && PORT=3002 node dist/index.js > /tmp/collab.log 2>&1 &) (PORT=5173 COLLAB_TARGET=http://127.0.0.1:3002 node scripts/e2e-static-server.mjs > /tmp/web.log 2>&1 &) for i in $(seq 1 30); do @@ -611,6 +614,12 @@ jobs: E2E_BASE_URL=http://localhost:5173 \ pnpm --filter @dorfteich/web exec playwright test e2e/a11y.spec.ts + # VS-NfD-Markierungen im Modus `marked` (issue #244). + - name: Run VS-NfD marking pack + run: | + E2E_BASE_URL=http://localhost:5173 E2E_VS_NFD_MODE=marked \ + pnpm --filter @dorfteich/web exec playwright test e2e/vs-nfd-marking.spec.ts + # Hell/Dunkel/System-Umschalter (issue #180). - name: Run theme pack run: | diff --git a/apps/web/e2e/README.md b/apps/web/e2e/README.md index 3efa43d..b96b002 100644 --- a/apps/web/e2e/README.md +++ b/apps/web/e2e/README.md @@ -36,6 +36,11 @@ E2E_BASE_URL=http://localhost:5173 E2E_MAILPIT_URL=http://localhost:8025 pnpm -- `auth.spec.ts` skips itself when `E2E_MAILPIT_URL` is unset, so the CD smoke run never trips over it. +`vs-nfd-marking.spec.ts` (issue #244) has two halves selected by +`E2E_VS_NFD_MODE`: set it to `marked` **and** start the api with +`VS_NFD_MODE=marked` for the marking assertions (CI does this in the +auth-e2e job); leave both unset for the no-marking-in-`off` assertions. + ## Fixture matrix Seeded by `pnpm --filter @dorfteich/api db:seed` (idempotent — re-running diff --git a/apps/web/e2e/vs-nfd-marking.spec.ts b/apps/web/e2e/vs-nfd-marking.spec.ts new file mode 100644 index 0000000..cad53c2 --- /dev/null +++ b/apps/web/e2e/vs-nfd-marking.spec.ts @@ -0,0 +1,60 @@ +import { expect, test } from '@playwright/test'; + +import { contextForUser } from './helpers'; + +/** + * VS-NfD marking pack (issue #244): in mode `marked`, catalog-listed + * controls carry an accessible deviation marking that follows the CURRENT + * (unsaved) value; in mode `off` nothing is marked anywhere. The api's + * mode is deploy-level, so each half runs only against the matching stack: + * CI runs the marked half (VS_NFD_MODE=marked on the e2e api), local full + * runs against a default stack cover the off half. + */ +const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173'; +const MODE = process.env.E2E_VS_NFD_MODE ?? 'off'; + +test('mode marked: card, checkbox marking, and point-of-choice marking', async ({ browser }) => { + test.skip(MODE !== 'marked', 'needs an api started with VS_NFD_MODE=marked'); + const context = await contextForUser(browser, BASE_URL, 'fixture-admin'); + const page = await context.newPage(); + await page.goto('/admin'); + await page.waitForLoadState('networkidle'); + + // The profile card shows mode and violation count (issue #243). + await expect(page.locator('.vs-nfd-summary')).toBeVisible(); + + // feeds.enabled defaults to true = violating: its toggle row is marked, + // and the marking is part of the control's accessible description. + const feedsInput = page.locator('input[aria-describedby="vs-nfd-mark-feeds.enabled"]'); + await expect(feedsInput).toBeVisible(); + await expect(page.locator('#vs-nfd-mark-feeds\\.enabled')).toBeVisible(); + + // Point of choice: the registration-mode marking follows the UNSAVED + // select value — compliant choice clears it, violating choice brings it + // back, no save in between. + const regField = page.locator('label.field', { + has: page.locator('select[name="auth.registrationMode"]'), + }); + const regSelect = regField.locator('select'); + await regSelect.selectOption('open'); + await expect(regField.locator('.vs-nfd-mark')).toBeVisible(); + await regSelect.selectOption('closed'); + await expect(regField.locator('.vs-nfd-mark')).toHaveCount(0); + await regSelect.selectOption('open'); + await expect(regField.locator('.vs-nfd-mark')).toBeVisible(); + + await context.close(); +}); + +test('mode off: no card, no markings', async ({ browser }) => { + test.skip(MODE !== 'off', 'covers the default stack only'); + const context = await contextForUser(browser, BASE_URL, 'fixture-admin'); + const page = await context.newPage(); + await page.goto('/admin'); + await page.waitForLoadState('networkidle'); + + await expect(page.locator('.vs-nfd-summary')).toHaveCount(0); + await expect(page.locator('.vs-nfd-mark')).toHaveCount(0); + + await context.close(); +}); diff --git a/apps/web/src/components/forms.tsx b/apps/web/src/components/forms.tsx index c16a986..04ae315 100644 --- a/apps/web/src/components/forms.tsx +++ b/apps/web/src/components/forms.tsx @@ -13,22 +13,30 @@ export function Field({ label, error, hint, + marking, children, }: { label: string; error?: string; hint?: string; + /** VS-NfD deviation marking (issue #244): icon + text under the control, + * part of its accessible description like the hint. */ + marking?: string; children: React.ReactNode; }): React.JSX.Element { const { t } = useTranslation(); const noteId = useId(); - // Tie the hint/error text to the control itself (#168, WCAG 3.3.1): - // screen readers then repeat it when the field receives focus. Only a - // single element child can be wired; fragments render unchanged. + const markId = useId(); + // Tie the hint/error/marking text to the control itself (#168, WCAG + // 3.3.1): screen readers then repeat it when the field receives focus. + // Only a single element child can be wired; fragments render unchanged. + const describedBy = [error || hint ? noteId : null, marking ? markId : null] + .filter(Boolean) + .join(' '); const wired = - isValidElement(children) && (error || hint) + isValidElement(children) && describedBy ? cloneElement(children as React.ReactElement>, { - 'aria-describedby': noteId, + 'aria-describedby': describedBy, ...(error ? { 'aria-invalid': true } : {}), }) : children; @@ -46,6 +54,12 @@ export function Field({ {t(`errors:${error}`, t('errors:bad_request'))} )} + {marking && ( + + + {marking} + + )} ); } diff --git a/apps/web/src/components/vs-nfd.tsx b/apps/web/src/components/vs-nfd.tsx new file mode 100644 index 0000000..b5596e9 --- /dev/null +++ b/apps/web/src/components/vs-nfd.tsx @@ -0,0 +1,59 @@ +import { useQuery } from '@tanstack/react-query'; +import { + VS_NFD_PROFILE, + describeCompliance, + isVsNfdCompliant, + type VsNfdMode, + type VsNfdProfileView, +} from '@dorfteich/shared'; +import { useTranslation } from 'react-i18next'; + +import { apiGet } from '../lib/api'; + +/** + * VS-NfD profile marking (issue #244, ADR 0027): in mode `marked`, every + * catalog-listed control shows an accessible deviation marking. The check + * runs against the CURRENT control value (saved or not), so the operator + * is warned at the point of choice. Admin-only surfaces — the endpoint is + * Site-Admin-guarded, matching every caller. + */ +export function useVsNfdMarking(): { + mode: VsNfdMode; + view: VsNfdProfileView | undefined; + /** Translated marking text for a violating value, else undefined. */ + markingFor: (key: string, value: unknown) => string | undefined; +} { + const { t } = useTranslation('settings'); + const query = useQuery({ + queryKey: ['admin', 'vs-nfd-profile'], + queryFn: () => apiGet('/admin/system/vs-nfd-profile'), + staleTime: 60_000, + }); + const mode = query.data?.mode ?? 'off'; + return { + mode, + view: query.data, + markingFor(key, value) { + // Marking is the `marked` treatment; `hidden`/`enforced` get their + // own behaviour with #245/#246. + if (mode !== 'marked') return undefined; + const entry = VS_NFD_PROFILE.find((e) => e.scope === 'instance' && e.key === key); + if (!entry || isVsNfdCompliant(entry, value)) return undefined; + return t('admin.vsNfd.marking', { value: describeCompliance(entry.compliance) }); + }, + }; +} + +/** + * The marking element for controls not wrapped in (checkbox rows): + * icon + text, colour never alone; wire `id` into the control's + * aria-describedby so screen readers announce it with the control. + */ +export function VsNfdMark({ id, text }: { id?: string; text: string }): React.JSX.Element { + return ( + + + {text} + + ); +} diff --git a/apps/web/src/pages/AdminBackupSection.tsx b/apps/web/src/pages/AdminBackupSection.tsx index 11c41e8..d5b0072 100644 --- a/apps/web/src/pages/AdminBackupSection.tsx +++ b/apps/web/src/pages/AdminBackupSection.tsx @@ -10,6 +10,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { VsNfdMark, useVsNfdMarking } from '../components/vs-nfd'; import { formatBytes } from '../files/file-format'; import { ApiError, apiGet, apiPost, apiPut } from '../lib/api'; @@ -202,6 +203,7 @@ function BackupSettingsForm(): React.JSX.Element { const { t } = useTranslation('system'); const { t: tErrors } = useTranslation('errors'); const queryClient = useQueryClient(); + const vsNfd = useVsNfdMarking(); const [draft, setDraft] = useState(null); const [notice, setNotice] = useState<{ kind: 'ok' | 'error'; text: string } | null>(null); const [busy, setBusy] = useState<'test' | 'save' | null>(null); @@ -213,6 +215,7 @@ function BackupSettingsForm(): React.JSX.Element { const view = query.data; if (!view) return <>; const form = draft ?? toDraft(view); + const nextcloudMarking = vsNfd.markingFor('backup.nextcloud.enabled', form.enabled); const update = (patch: Partial): void => setDraft({ ...form, ...patch }); const describeError = (error: unknown): string => { @@ -309,10 +312,12 @@ function BackupSettingsForm(): React.JSX.Element { type="checkbox" checked={form.enabled} disabled={!view.remoteTargets.allowed} + aria-describedby={nextcloudMarking ? 'vs-nfd-mark-backup-nextcloud' : undefined} onChange={(e) => update({ enabled: e.target.checked })} /> {t('backup.settings.enabled')} + {nextcloudMarking && }
({ values: settings.data }); + const vsNfd = useVsNfdMarking(); const onSubmit = form.handleSubmit(async (input) => { setError(null); @@ -86,7 +88,13 @@ export function AdminSettingsPage(): React.JSX.Element { - +