From 0d95e1304e33222e00129ed9fa351a838d5b70f1 Mon Sep 17 00:00:00 2001
From: Claude Fable 5
Date: Fri, 31 Jul 2026 19:10:51 +0200
Subject: [PATCH] =?UTF-8?q?#245:=20mode=20hidden=20=E2=80=94=20hide=20prof?=
=?UTF-8?q?ile-violating=20options,=20mark=20the=20hiding?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
In hidden (and later enforced) mode, catalog-listed controls whose only
purpose is enabling a violation are not rendered while their saved value
is compliant (the four master switches, the Nextcloud backup block);
value-listed selects keep only their compliant choices (registration
mode, new-page classification, upload policy, SVG policy). Every
affected section shows one accessible policy note (i18n de+en) so
policy is distinguishable from missing features. A value that was
already violating is surfaced exactly like in marked — never silently
hidden. The API stays unchanged; enforcement is #246. e2e: hidden half
of the marking pack (rows disappear, note visible, already-violating
row stays marked, axe WCAG A/AA clean) — verified live locally; CI runs
it against a second api (VS_NFD_MODE=hidden, same database) behind its
own static server.
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
---
.gitea/workflows/ci.yml | 16 ++
apps/web/e2e/README.md | 9 +-
apps/web/e2e/vs-nfd-marking.spec.ts | 40 +++++
apps/web/src/components/vs-nfd.tsx | 34 ++++-
apps/web/src/pages/AdminBackupSection.tsx | 176 +++++++++++-----------
apps/web/src/pages/AdminSettingsPage.tsx | 40 ++++-
packages/shared/i18n/de/settings.json | 3 +-
packages/shared/i18n/en/settings.json | 3 +-
8 files changed, 220 insertions(+), 101 deletions(-)
diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
index b64ab40..fc5e25f 100644
--- a/.gitea/workflows/ci.yml
+++ b/.gitea/workflows/ci.yml
@@ -196,6 +196,12 @@ jobs:
# 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 &)
+ # Second api on the SAME database with VS_NFD_MODE=hidden: the
+ # marking pack's hidden half runs against it via its own static
+ # server (issue #245); the mode is env-only, so sharing the db is
+ # exactly the deploy semantics.
+ (cd apps/api && PORT=3006 VS_NFD_MODE=hidden MIGRATE_ON_START=false node dist/main.js > /tmp/api-hidden.log 2>&1 &)
+ (PORT=5176 API_TARGET=http://127.0.0.1:3006 node scripts/e2e-static-server.mjs > /tmp/web-hidden.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
@@ -620,6 +626,16 @@ jobs:
E2E_BASE_URL=http://localhost:5173 E2E_VS_NFD_MODE=marked \
pnpm --filter @dorfteich/web exec playwright test e2e/vs-nfd-marking.spec.ts
+ # Ausblendung + Policy-Hinweis im Modus `hidden` (issue #245).
+ - name: Run VS-NfD hidden pack
+ run: |
+ for i in $(seq 1 30); do
+ curl -sf http://localhost:3006/api/v1/readyz >/dev/null && break
+ sleep 2
+ done
+ E2E_BASE_URL=http://localhost:5176 E2E_VS_NFD_MODE=hidden \
+ pnpm --filter @dorfteich/web exec playwright test e2e/vs-nfd-marking.spec.ts
+
# The marking pack's extra login on top of the six a11y logins pushes
# the theme pack over the 10/min login limit — reset again (#244).
- name: Reset login rate limit before theme pack
diff --git a/apps/web/e2e/README.md b/apps/web/e2e/README.md
index b96b002..4540de9 100644
--- a/apps/web/e2e/README.md
+++ b/apps/web/e2e/README.md
@@ -36,10 +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.
+`vs-nfd-marking.spec.ts` (issues #244/#245) has three parts selected by
+`E2E_VS_NFD_MODE` (`marked`, `hidden`, unset = `off`); each needs an api
+started with the matching `VS_NFD_MODE`. CI runs the marked half against
+the main e2e stack and the hidden half against a second api (port 3006)
+on the same database; the off assertions run in local default stacks.
## Fixture matrix
diff --git a/apps/web/e2e/vs-nfd-marking.spec.ts b/apps/web/e2e/vs-nfd-marking.spec.ts
index cad53c2..820947c 100644
--- a/apps/web/e2e/vs-nfd-marking.spec.ts
+++ b/apps/web/e2e/vs-nfd-marking.spec.ts
@@ -1,3 +1,4 @@
+import AxeBuilder from '@axe-core/playwright';
import { expect, test } from '@playwright/test';
import { contextForUser } from './helpers';
@@ -46,6 +47,45 @@ test('mode marked: card, checkbox marking, and point-of-choice marking', async (
await context.close();
});
+test('mode hidden: rows disappear, notes mark the hiding, a11y clean', async ({ browser }) => {
+ test.skip(MODE !== 'hidden', 'needs an api started with VS_NFD_MODE=hidden');
+ const context = await contextForUser(browser, BASE_URL, 'fixture-admin');
+ const page = await context.newPage();
+ await page.goto('/admin');
+ await page.waitForLoadState('networkidle');
+
+ // feeds.enabled defaults to true = ALREADY violating: never silently
+ // hidden — the row stays visible with its marking.
+ await expect(page.locator('#vs-nfd-mark-feeds\\.enabled')).toBeVisible();
+
+ // Master switches: compliant (false) rows disappear, violating (true)
+ // rows stay — derive the expectation from the actual instance settings,
+ // the seed state differs between local and CI databases.
+ const settings = (await (
+ await context.request.get(`${BASE_URL}/api/v1/admin/settings`)
+ ).json()) as Record;
+ const masterKeys = ['api.enabled', 'mcp.enabled', 'feeds.enabled', 'plugins.enabled'] as const;
+ const visible = masterKeys.filter((key) => settings[key] === true).length;
+ expect(visible).toBeLessThan(masterKeys.length); // at least one row is hidden
+ await expect(page.locator('.api-opt-in__label')).toHaveCount(visible);
+ await expect(page.locator('.vs-nfd-hidden-note').first()).toBeVisible();
+
+ // Value-listed control: the compliant registration mode keeps only its
+ // compliant choice (seed leaves it open = violating? then all options).
+ const regSelect = page.locator('select[name="auth.registrationMode"]');
+ const regField = page.locator('label.field', { has: regSelect });
+ const optionCount = await regSelect.locator('option').count();
+ const marked = await regField.locator('.vs-nfd-mark').count();
+ // Exactly one of the two treatments applies, never a silent third state.
+ expect(optionCount === 1 || marked === 1).toBe(true);
+
+ // The hidden state and the policy note pass the axe WCAG A/AA scan.
+ const results = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']).analyze();
+ expect(results.violations).toEqual([]);
+
+ 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');
diff --git a/apps/web/src/components/vs-nfd.tsx b/apps/web/src/components/vs-nfd.tsx
index b5596e9..4e25134 100644
--- a/apps/web/src/components/vs-nfd.tsx
+++ b/apps/web/src/components/vs-nfd.tsx
@@ -22,6 +22,9 @@ export function useVsNfdMarking(): {
view: VsNfdProfileView | undefined;
/** Translated marking text for a violating value, else undefined. */
markingFor: (key: string, value: unknown) => string | undefined;
+ /** True when the control must not be rendered (#245): mode hidden/
+ * enforced and the SAVED value is compliant. */
+ hides: (key: string, savedValue: unknown) => boolean;
} {
const { t } = useTranslation('settings');
const query = useQuery({
@@ -34,16 +37,41 @@ export function useVsNfdMarking(): {
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;
+ // Marking applies in `marked`, and in `hidden`/`enforced` to values
+ // that are ALREADY violating — an existing violation is never
+ // silently hidden (#245).
+ if (mode === 'off') 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) });
},
+ hides(key, savedValue) {
+ // The `hidden` treatment (#245, `enforced` renders the same, #246):
+ // a compliant control whose only purpose would be enabling a
+ // violation disappears; a violating one stays visible WITH its
+ // marking so the deviation is surfaced, never buried.
+ if (mode !== 'hidden' && mode !== 'enforced') return false;
+ const entry = VS_NFD_PROFILE.find((e) => e.scope === 'instance' && e.key === key);
+ return entry !== undefined && isVsNfdCompliant(entry, savedValue);
+ },
};
}
+/**
+ * The one accessible per-section note that options are hidden by
+ * deployment policy (#245) — policy must be distinguishable from missing
+ * features for anyone reading the settings page.
+ */
+export function VsNfdHiddenNote(): React.JSX.Element {
+ const { t } = useTranslation('settings');
+ return (
+
+ 🛈
+ {t('admin.vsNfd.hiddenNote')}
+
+ );
+}
+
/**
* The marking element for controls not wrapped in (checkbox rows):
* icon + text, colour never alone; wire `id` into the control's
diff --git a/apps/web/src/pages/AdminBackupSection.tsx b/apps/web/src/pages/AdminBackupSection.tsx
index d5b0072..937835d 100644
--- a/apps/web/src/pages/AdminBackupSection.tsx
+++ b/apps/web/src/pages/AdminBackupSection.tsx
@@ -10,7 +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 { VsNfdHiddenNote, VsNfdMark, useVsNfdMarking } from '../components/vs-nfd';
import { formatBytes } from '../files/file-format';
import { ApiError, apiGet, apiPost, apiPut } from '../lib/api';
@@ -216,6 +216,7 @@ function BackupSettingsForm(): React.JSX.Element {
if (!view) return <>>;
const form = draft ?? toDraft(view);
const nextcloudMarking = vsNfd.markingFor('backup.nextcloud.enabled', form.enabled);
+ const nextcloudHidden = vsNfd.hides('backup.nextcloud.enabled', view.nextcloud.enabled);
const update = (patch: Partial): void => setDraft({ ...form, ...patch });
const describeError = (error: unknown): string => {
@@ -307,92 +308,97 @@ function BackupSettingsForm(): React.JSX.Element {
})}