fix(usage): reformat the consent modal so the disclosure can be read

It was seven anonymous paragraphs in one scrolling block, with the title
and the buttons scrolling away with them. The scroll container is
keyboard-focusable, and unstyled it drew a default focus ring, so the
disclosure also looked like a giant textarea.

Now: a fixed header carrying the icon, title and purpose; a scroll
region with six labelled sections, each with a small heading and icon so
the thing can be scanned rather than only read; and a fixed footer with
the consent checkbox and the actions, which no longer scroll out of
reach on a short screen. "What is never included" is set apart as a
tinted block, since it is the part that answers the question an operator
actually has. The focus ring is now a deliberate inset ring on a
labelled region, which is correct for keyboard use instead of an
accident that looked like a form field.

Dark mode is fixed as part of this, and it was my own doing: the dialog
used `bg-theme-surface`, which does not follow dark mode, and the
section text I added carries dark: variants. Light surface plus
near-white text is unreadable. The surface is class-driven now —
neutral-800, which is what `.card` resolves to in dark and what the rest
of the admin UI uses. Verified in both themes through the app's own
theme toggle rather than by forcing the class, which is what produced
the misleading half-state the first time I looked.

Six section headings added in EN and DE.

Refs #1110
This commit is contained in:
Paul Nothaft
2026-09-05 22:43:20 +02:00
parent bb76ca5375
commit a9e51d8fd7
3 changed files with 132 additions and 50 deletions
@@ -1,13 +1,41 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState, type ComponentType } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
productUsageService as service,
type ProductFeedback
} from '../../../services/productUsage.service';
import {
Globe,
ListChecks,
MessageSquare,
Send,
ShieldOff,
Sparkles,
Trash2
} from 'lucide-react';
import { useConfirm } from '../../../components/common/ConfirmDialog';
import { Button, Card } from '../../../components/common';
/**
* Sections of the disclosure, in reading order. Each is a translated
* paragraph; the heading and icon give it a shape you can scan instead of
* seven identical blocks of prose.
*/
const DISCLOSURE: {
key: string;
heading: string;
Icon: ComponentType<{ className?: string }>;
tone?: 'positive';
}[] = [
{ key: 'fields', heading: 'sectionFields', Icon: ListChecks },
{ key: 'excluded', heading: 'sectionExcluded', Icon: ShieldOff, tone: 'positive' },
{ key: 'transport', heading: 'sectionTransport', Icon: Send },
{ key: 'visibility', heading: 'sectionVisibility', Icon: Globe },
{ key: 'deletion', heading: 'sectionDeletion', Icon: Trash2 },
{ key: 'feedbackDisclosure', heading: 'sectionFeedback', Icon: MessageSquare }
];
function ConsentDialog({
close,
enable,
@@ -30,27 +58,64 @@ function ConsentDialog({
ref={ref}
onCancel={close}
aria-labelledby="usage-consent-title"
className="w-full max-w-2xl rounded-xl p-6 text-theme bg-theme-surface backdrop:bg-black/50"
// Column layout with its own scroll region, so the title stays put and
// the actions never scroll out of reach on a short screen.
//
// Surface is class-driven rather than `bg-theme-surface`: that variable
// does not follow dark mode, so it stayed white while the dark: text
// variants below turned near-white. neutral-800 is what `.card`
// resolves to in dark, which is what the rest of the admin UI uses.
className="w-full max-w-2xl max-h-[85vh] flex flex-col overflow-hidden rounded-xl p-0 bg-white dark:bg-neutral-800 shadow-xl backdrop:bg-black/50"
>
<header className="flex items-start gap-3 px-6 pt-6 pb-4">
<span className="mt-0.5 flex h-9 w-9 flex-none items-center justify-center rounded-full bg-primary-50 dark:bg-primary-900/30">
<Sparkles className="h-5 w-5 text-primary-600 dark:text-primary-300" />
</span>
<div className="min-w-0">
<h2
id="usage-consent-title"
className="text-lg font-semibold text-neutral-900 dark:text-neutral-100"
>
<h2 id="usage-consent-title" className="text-xl font-semibold">
{t('productUsage.consentTitle')}
</h2>
<div className="my-4 max-h-[55vh] overflow-y-auto space-y-3">
{[
'purpose',
'fields',
'excluded',
'transport',
'visibility',
'deletion',
'feedbackDisclosure'
].map((key) => (
<p key={key}>{t(`productUsage.${key}`, { collector })}</p>
))}
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
{t('productUsage.purpose')}
</p>
</div>
<div className="mb-4 flex flex-wrap gap-x-6 gap-y-1 text-sm">
</header>
{/* A scrollable region is focusable, which is correct for keyboard use —
but unstyled it drew a default ring that made the disclosure look
like a textarea. Given a real label and ring so it reads as what it
is: a document you can scroll. */}
<div
tabIndex={0}
role="group"
aria-label={t('productUsage.consentTitle') as string}
className="flex-1 overflow-y-auto border-y border-neutral-200 dark:border-neutral-700 px-6 py-4 space-y-4 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary-500"
>
{DISCLOSURE.map(({ key, heading, Icon, tone }) => (
<section
key={key}
className={
tone === 'positive'
? 'rounded-lg border border-primary-200 dark:border-primary-800 bg-primary-50/60 dark:bg-primary-900/20 p-3'
: undefined
}
>
<h3 className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">
<Icon className="h-3.5 w-3.5" />
{t(`productUsage.${heading}`)}
</h3>
<p className="mt-1 text-sm text-neutral-700 dark:text-neutral-300">
{t(`productUsage.${key}`, { collector })}
</p>
</section>
))}
<div className="flex flex-wrap gap-x-6 gap-y-1 pt-1 text-sm">
<a
className={'text-primary-600 dark:text-primary-400 hover:underline'}
className="text-primary-600 dark:text-primary-400 hover:underline"
href={collector}
target="_blank"
rel="noreferrer"
@@ -58,7 +123,7 @@ function ConsentDialog({
{t('productUsage.linkCollector')}
</a>
<a
className={'text-primary-600 dark:text-primary-400 hover:underline'}
className="text-primary-600 dark:text-primary-400 hover:underline"
href={`${collector}/transparency`}
target="_blank"
rel="noreferrer"
@@ -66,13 +131,17 @@ function ConsentDialog({
{t('productUsage.transparency')}
</a>
</div>
<label className="flex items-start gap-2 mb-4">
</div>
<footer className="px-6 pt-4 pb-6 space-y-4">
<label className="flex items-start gap-2.5 text-sm text-neutral-800 dark:text-neutral-200">
<input
type="checkbox"
className="mt-0.5 h-4 w-4 flex-none"
checked={checked}
onChange={(e) => setChecked(e.target.checked)}
/>
{t('productUsage.consentCheck')}
<span>{t('productUsage.consentCheck')}</span>
</label>
<div className="flex justify-end gap-3">
<Button variant="outline" onClick={close} disabled={busy}>
@@ -82,6 +151,7 @@ function ConsentDialog({
{t('productUsage.enable')}
</Button>
</div>
</footer>
</dialog>
);
}
+6
View File
@@ -11,6 +11,12 @@
"failed": "Der Vorgang konnte nicht abgeschlossen werden. Prüfe den Status und versuche es erneut.",
"purpose": "Hilf bei der Priorisierung von PicPeak-Funktionen, Fehlerbehebungen und Wartung mit groben Informationen über teilnehmende Installationen.",
"consentTitle": "Produktnutzung freiwillig teilen",
"sectionFields": "Was ein Bericht enthält",
"sectionExcluded": "Was niemals enthalten ist",
"sectionTransport": "Wie er gesendet wird",
"sectionVisibility": "Wo er sichtbar ist",
"sectionDeletion": "Beenden und löschen",
"sectionFeedback": "Feedback ist getrennt",
"fields": "Berichte enthalten einen Installationsfingerabdruck, PicPeak-Version, Berichtstag, Schema- und Signaturmetadaten, Galerie-Layouts sowie Konfiguriert/Genutzt-Werte für CRM und Unterfunktionen, Buchhaltung, Workflows, Newsletter, Gesichtserkennung, eigenes CSS, OAuth, SMTP, WhatsApp, Backups, S3 und eingebundene Freigaben. „Genutzt“ bedeutet seit der Teilnahme beobachtet, nicht wie häufig.",
"excluded": "Automatische Berichte enthalten keine Galeriebesucher, Klickverläufe, Foto- oder Galerieanzahlen, Namen, E-Mail-Adressen, Domains, Dateinamen oder Zugangsdaten.",
"transport": "Dein PicPeak-Backend verwahrt den Signaturschlüssel und sendet signierte Berichte an {{collector}}, einmal pro UTC-Tag bei Admin-Nutzung. Du kannst Berichte vorab ansehen und alle angenommenen Rohpakete herunterladen.",
+6
View File
@@ -11,6 +11,12 @@
"failed": "The operation could not be completed. Check the status and try again.",
"purpose": "Help prioritize PicPeak features, fixes, and maintenance using coarse information about participating installations.",
"consentTitle": "Choose whether to share product usage",
"sectionFields": "What a report contains",
"sectionExcluded": "What is never included",
"sectionTransport": "How it is sent",
"sectionVisibility": "Where it is visible",
"sectionDeletion": "Leaving and deleting",
"sectionFeedback": "Feedback is separate",
"fields": "Reports contain an installation fingerprint, PicPeak version, report day, schema and signing metadata, gallery layout choices, and configured/used booleans for CRM and its subfeatures, accounting, workflows, newsletters, face recognition, custom CSS, OAuth, SMTP, WhatsApp, backups, S3, and share mounts. “Used” means observed since joining, not how often.",
"excluded": "No gallery visitors, clickstreams, photo or gallery counts, names, emails, domains, filenames, or configuration secrets are included in automatic usage reports.",
"transport": "Your PicPeak backend keeps the signing key and sends signed reports to {{collector}} once per UTC day when an admin uses the app. You can preview reports and download every accepted raw packet.",