#245: mode hidden — hide profile-violating options, mark the hiding
All checks were successful
CI / Lint, typecheck, test (push) Successful in 6m24s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 8m34s
CI / Import/export fidelity gate (push) Successful in 1m1s
CI / Build container images (pull_request) Successful in 1m13s
CI / Lint, typecheck, test (pull_request) Successful in 6m14s
CI / Auth e2e pack (pull_request) Successful in 8m31s
CI / Import/export fidelity gate (pull_request) Successful in 58s
CD / Build and push images (push) Successful in 19s
CD / Deploy to Test (push) Successful in 14s
CD / Smoke tests against Test (push) Successful in 1m18s
CD / Promote to Int (push) Successful in 11s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
This commit is contained in:
Claude Fable 5 2026-07-31 19:10:51 +02:00
parent 5fdef95f67
commit 0d95e1304e
8 changed files with 220 additions and 101 deletions

View File

@ -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

View File

@ -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

View File

@ -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<string, boolean>;
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');

View File

@ -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 (
<p className="field__hint vs-nfd-hidden-note">
<span aria-hidden="true">🛈 </span>
{t('admin.vsNfd.hiddenNote')}
</p>
);
}
/**
* The marking element for controls not wrapped in <Field> (checkbox rows):
* icon + text, colour never alone; wire `id` into the control's

View File

@ -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<SettingsDraft>): void => setDraft({ ...form, ...patch });
const describeError = (error: unknown): string => {
@ -307,6 +308,8 @@ function BackupSettingsForm(): React.JSX.Element {
})}
</p>
)}
{nextcloudHidden && <VsNfdHiddenNote />}
{!nextcloudHidden && (
<label className="system-backup__checkbox">
<input
type="checkbox"
@ -317,7 +320,9 @@ function BackupSettingsForm(): React.JSX.Element {
/>
{t('backup.settings.enabled')}
</label>
)}
{nextcloudMarking && <VsNfdMark id="vs-nfd-mark-backup-nextcloud" text={nextcloudMarking} />}
{!nextcloudHidden && (
<fieldset
disabled={!form.enabled || !view.remoteTargets.allowed}
className="system-backup__nextcloud"
@ -393,6 +398,7 @@ function BackupSettingsForm(): React.JSX.Element {
{busy === 'test' ? t('backup.settings.testPending') : t('backup.settings.test')}
</button>
</fieldset>
)}
<button type="submit" className="button system-backup__save" disabled={busy !== null}>
{busy === 'save' ? t('backup.settings.savePending') : t('backup.settings.save')}
</button>

View File

@ -7,7 +7,7 @@ import { Link } from 'react-router-dom';
import { Field, FormError, FormSuccess } from '../components/forms';
import { SettingsLayout } from '../components/SettingsLayout';
import { VsNfdMark, useVsNfdMarking } from '../components/vs-nfd';
import { VsNfdHiddenNote, VsNfdMark, useVsNfdMarking } from '../components/vs-nfd';
import { apiGet, apiPatch } from '../lib/api';
import { PluginManager } from './PluginManager';
import { QuotaManager } from './QuotaManager';
@ -76,6 +76,15 @@ export function AdminSettingsPage(): React.JSX.Element {
<VsNfdProfileSection />
<section className="settings-section">
<h2>{t('settings:admin.general')}</h2>
{(vsNfd.hides('auth.registrationMode', settings.data['auth.registrationMode']) ||
vsNfd.hides(
'classification.newPageDefault',
settings.data['classification.newPageDefault'],
) ||
vsNfd.hides(
'classification.uploadPolicy',
settings.data['classification.uploadPolicy'],
)) && <VsNfdHiddenNote />}
<form onSubmit={onSubmit} noValidate>
<FormError error={error} />
<FormSuccess message={saved ? t('settings:admin.saved') : null} />
@ -96,7 +105,9 @@ export function AdminSettingsPage(): React.JSX.Element {
)}
>
<select {...form.register('auth.registrationMode')}>
{!vsNfd.hides('auth.registrationMode', settings.data['auth.registrationMode']) && (
<option value="open">{t('settings:admin.registrationOpen')}</option>
)}
<option value="closed">{t('settings:admin.registrationClosed')}</option>
</select>
</Field>
@ -109,9 +120,14 @@ export function AdminSettingsPage(): React.JSX.Element {
)}
>
<select {...form.register('classification.newPageDefault')}>
{!vsNfd.hides(
'classification.newPageDefault',
settings.data['classification.newPageDefault'],
) && (
<option value="unclassified">
{t('settings:admin.classificationUnclassified')}
</option>
)}
<option value="vs_nfd">{t('settings:admin.classificationVsNfd')}</option>
</select>
</Field>
@ -124,7 +140,10 @@ export function AdminSettingsPage(): React.JSX.Element {
)}
>
<select {...form.register('classification.uploadPolicy')}>
<option value="warn">{t('settings:admin.uploadPolicyWarn')}</option>
{!vsNfd.hides(
'classification.uploadPolicy',
settings.data['classification.uploadPolicy'],
) && <option value="warn">{t('settings:admin.uploadPolicyWarn')}</option>}
<option value="block">{t('settings:admin.uploadPolicyBlock')}</option>
</select>
</Field>
@ -266,6 +285,7 @@ function UploadSettingsForm({ settings }: { settings: InstanceSettings }): React
onChange={(event) => setExtensions(event.target.value)}
/>
</Field>
{vsNfd.hides('upload.svgPolicy', settings['upload.svgPolicy']) && <VsNfdHiddenNote />}
<Field
label={t('settings.svgPolicy')}
marking={vsNfd.markingFor('upload.svgPolicy', svgPolicy)}
@ -274,7 +294,9 @@ function UploadSettingsForm({ settings }: { settings: InstanceSettings }): React
value={svgPolicy}
onChange={(event) => setSvgPolicy(event.target.value as 'reject' | 'sanitize')}
>
{!vsNfd.hides('upload.svgPolicy', settings['upload.svgPolicy']) && (
<option value="sanitize">{t('settings.svgSanitize')}</option>
)}
<option value="reject">{t('settings.svgReject')}</option>
</select>
</Field>
@ -314,6 +336,9 @@ function PublicApiSettingsForm({ settings }: { settings: InstanceSettings }): Re
<h2>{t('admin.title')}</h2>
<FormError error={error} />
<FormSuccess message={saved ? t('admin.saved') : null} />
{(['api.enabled', 'mcp.enabled', 'feeds.enabled', 'plugins.enabled'] as const).some((key) =>
vsNfd.hides(key, settings[key]),
) && <VsNfdHiddenNote />}
{(
[
{ key: 'api.enabled', label: t('admin.label'), hint: t('admin.hint') },
@ -322,6 +347,7 @@ function PublicApiSettingsForm({ settings }: { settings: InstanceSettings }): Re
{ key: 'plugins.enabled', label: t('admin.pluginsLabel'), hint: t('admin.pluginsHint') },
] as const
).map((row) => {
if (vsNfd.hides(row.key, settings[row.key])) return null;
const marking = vsNfd.markingFor(row.key, settings[row.key]);
return (
<div key={row.key}>

View File

@ -72,7 +72,8 @@
"referenceValue": "Referenzwert: {{value}}",
"guideRef": "Härtungsleitfaden §{{section}}",
"marking": "Weicht von der VS-NfD-Referenzkonfiguration ab (Referenzwert: {{value}})",
"guideNote": "Referenzkonfiguration und Begründungen:"
"guideNote": "Referenzkonfiguration und Begründungen:",
"hiddenNote": "Einzelne Optionen sind durch die Deployment-Policy (VS-NfD-Modus) ausgeblendet — das ist Absicht, kein fehlendes Feature."
}
},
"landing": {

View File

@ -72,7 +72,8 @@
"referenceValue": "Reference value: {{value}}",
"guideRef": "Hardening guide §{{section}}",
"marking": "Deviates from the VS-NfD reference configuration (reference value: {{value}})",
"guideNote": "Reference configuration and rationale:"
"guideNote": "Reference configuration and rationale:",
"hiddenNote": "Some options are hidden by deployment policy (VS-NfD mode) — that is intentional, not a missing feature."
}
},
"landing": {