fix(ui): stop branding-theme text colour rendering headings invisible

Components that render headings with no explicit text-colour class inherit
`body { color: var(--color-text) }`, and the branding theme sets --color-text
on <html> app-wide -- so on a dark-toned theme they render near-invisible
(#f5f5f5 on #fff), including inside the admin panel in light mode.
Compliance-adjacent: /impressum and /datenschutz are two of the surfaces.

Convention copied from AccountingTab, the QA control that is visually
identical but not affected: h2 -> text-neutral-900 dark:text-neutral-100,
labels -> neutral-700/300, checkbox labels -> neutral-800/200, hints ->
neutral-500/400.

Fixed beyond the reported lines, after sweeping each file:
- LegalPage: the CMS prose wrapper and the single-segment 404 heading.
- CMSContentBlock: the multi-segment CMS 404 and the admin unknown-route 404
  turn out to be the same component (App.tsx path="*"; there is no admin-level
  catch-all). Its text already used var(--color-text); the actual defect was
  .card hardcoding bg-white under themed text, so the surface was fixed, not
  the text.
- SettingsBusinessProfilePage (11), CrmSettingsPage (15, incl. both shared
  checkbox-label helpers covering ~20 rendered rows), ReminderTemplatesPage (7,
  incl. text-theme/text-muted-theme on an admin page where they are wrong).
- The <select> elements on those tabs: Tailwind preflight sets color:inherit
  on form controls, so they picked up the near-white body colour on a white
  background. Same root cause, not previously reported.

Plus one line of defence-in-depth on the admin shell (AdminLayout): an
explicit text colour there stops the whole admin panel inheriting the themed
body colour. Components with their own class, including text-theme, still win.

Interpretation -- the robust fix was evaluated and rejected. Scoping the theme
tokens to gallery contexts is not feasible: the leak is deliberate product
behaviour (GlobalThemeProvider applies branding on every non-gallery page),
40 files read var(--color-*) with only 9 under components/gallery, and it
would break the customer portal, the public token pages, AdminLoginPage and
the Branding live preview. It also cannot be done at container level without
moving `body { color: ... }` and the whole .text-theme/.bg-surface/.card-themed
utility family, which are global by construction.

Known remaining instances, not converted: SystemHealthPage, CrmOverviewSection
and HoursSection use text-theme explicitly on admin surfaces, so they keep the
themed colour and stay affected. Outside the reported surfaces.

Refs testplan REPORT.md #14 (Part 8, S3/S4/S13).
This commit is contained in:
Paul Nothaft
2026-09-01 16:41:13 +02:00
parent ac50f0b48b
commit da9ceb14ca
7 changed files with 129 additions and 44 deletions
@@ -72,7 +72,13 @@ interface AdminLayoutInnerProps {
const AdminLayoutInner: React.FC<AdminLayoutInnerProps> = ({ sidebarOpen, setSidebarOpen, sidebarCollapsed, setSidebarCollapsed, mustChangePassword }) => {
return (
<div className="h-screen bg-neutral-50 dark:bg-neutral-950 flex overflow-hidden">
// Explicit text colour on the admin shell: the branding theme sets
// --color-text on <html> app-wide (GlobalThemeProvider applies it on every
// non-gallery page, by design), so any admin component that forgot its own
// colour class inherited it through `body { color: var(--color-text) }` and
// rendered near-invisible on a dark-toned theme. Components with an
// explicit class or `text-theme` still win over this.
<div className="h-screen bg-neutral-50 dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100 flex overflow-hidden">
{/* Mandatory Password Change Modal */}
{mustChangePassword && <MandatoryPasswordChangeModal />}
@@ -83,7 +83,18 @@ export const CMSContentBlock: React.FC<CMSContentBlockProps> = ({ slug, fallback
<main className="flex-1 flex items-start justify-center px-4">
<div className="max-w-2xl w-full">
<Card padding="lg">
{/*
* The card surface has to follow the theme too: `.card` hardcodes
* bg-white, so a dark-toned branding theme paired with the themed
* text below rendered near-white text on a white card (QA S3/S4).
*/}
<Card
padding="lg"
style={{
backgroundColor: 'var(--color-surface)',
borderColor: 'var(--color-surface-border)',
}}
>
{/*
* Heading + body now read from theme tokens so dark themes
* (and force-dark mode) render correctly without dark: variants
@@ -0,0 +1,59 @@
/**
* ThemeContext.applyTheme() writes the branding theme's `--color-text` as an
* inline style on <html>, so `body { color: var(--color-text) }` applies
* everywhere — including the light admin chrome and the light-chromed public
* legal pages. Any heading that ships without an explicit text-color class
* therefore renders near-white on white as soon as the install picks a
* dark-toned branding theme (QA S3 / S4 / S13).
*
* Source-inspection guard: every heading on the surfaces that were fixed must
* declare its own colour rather than inheriting the themed body colour.
*/
import fs from 'fs';
import path from 'path';
import { describe, it, expect } from 'vitest';
const SRC = path.resolve(__dirname, '../../..');
const read = (rel: string) => fs.readFileSync(path.join(SRC, rel), 'utf8');
// Headings must set a colour explicitly. `text-theme` / `text-muted-theme` are
// deliberately NOT accepted — they resolve to the same leaking variables.
const EXPLICIT_COLOR = /\btext-(neutral|white|amber|blue|red|green|primary|accent)\b|\btext-(neutral|amber|blue|red|green|primary)-\d/;
const HEADING_TAG = /<(h[1-4])(\s[^>]*?)?>/gs;
const HEADING_FILES = [
'pages/admin/settings/SettingsBusinessProfilePage.tsx',
'pages/admin/settings/CrmSettingsPage.tsx',
'pages/admin/settings/ReminderTemplatesPage.tsx',
'pages/public/LegalPage.tsx',
];
describe('branding-theme text colour leak (QA S3 / S4 / S13)', () => {
it.each(HEADING_FILES)('every heading in %s declares an explicit text colour', (rel) => {
const source = read(rel);
const offenders: string[] = [];
for (const match of source.matchAll(HEADING_TAG)) {
const attrs = match[2] || '';
const className = /className="([^"]*)"/.exec(attrs)?.[1] ?? '';
if (!EXPLICIT_COLOR.test(className)) offenders.push(match[0]);
}
expect(offenders).toEqual([]);
});
it('gives the LegalPage CMS body an explicit colour instead of the themed body colour', () => {
const source = read('pages/public/LegalPage.tsx');
expect(source).toMatch(/className="prose prose-neutral max-w-none text-neutral-\d00"/);
});
it('keeps the CMS 404 card surface on the same theme tokens as its text', () => {
// CMSContentBlock intentionally renders themed text (var(--color-text));
// the card surface has to follow, because `.card` hardcodes bg-white.
const source = read('components/common/CMSContentBlock.tsx');
expect(source).toContain("backgroundColor: 'var(--color-surface)'");
expect(source).toContain("color: 'var(--color-text)'");
});
});
@@ -136,7 +136,7 @@ export const CrmSettingsPage: React.FC = () => {
const setVal = (k: string, v: any) => setValues((s) => ({ ...s, [k]: v }));
const checkbox = (k: string, label: string) => (
<label className="flex items-center gap-2 text-sm py-1">
<label className="flex items-center gap-2 text-sm text-neutral-800 dark:text-neutral-200 py-1">
<input type="checkbox" checked={!!values[k]} onChange={(e) => setVal(k, e.target.checked)} />
<span>{t(`crmSettings.${k}.label`, label)}</span>
</label>
@@ -158,7 +158,7 @@ export const CrmSettingsPage: React.FC = () => {
const stored = values[k];
const effective = stored === undefined || stored === null ? true : !!stored;
return (
<label className="flex items-center gap-2 text-sm py-1">
<label className="flex items-center gap-2 text-sm text-neutral-800 dark:text-neutral-200 py-1">
<input
type="checkbox"
checked={effective}
@@ -173,7 +173,7 @@ export const CrmSettingsPage: React.FC = () => {
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold">{t('crmSettings.title', 'CRM settings')}</h2>
<h2 className="text-xl font-bold text-neutral-900 dark:text-neutral-100">{t('crmSettings.title', 'CRM settings')}</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
{t('crmSettings.subtitle', 'Fine-tune quote and invoice behaviour.')}
</p>
@@ -194,7 +194,7 @@ export const CrmSettingsPage: React.FC = () => {
{showQuotes && (
<Card>
<h3 className="font-semibold mb-3">{t('crmSettings.section.quotes', 'Quotes')}</h3>
<h3 className="font-semibold text-neutral-900 dark:text-neutral-100 mb-3">{t('crmSettings.section.quotes', 'Quotes')}</h3>
{checkbox('crm_quotes_pdf_attachment_enabled', 'Attach quote PDF to email')}
{checkbox('crm_quotes_skonto_enabled', 'Allow early-payment discount (Skonto) on quotes')}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
@@ -217,7 +217,7 @@ export const CrmSettingsPage: React.FC = () => {
must tick before Accept fires. The text snapshot is
recorded on the quote at acceptance time for audit. */}
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
<h4 className="font-semibold mb-2 text-sm">
<h4 className="font-semibold text-neutral-900 dark:text-neutral-100 mb-2 text-sm">
{t('crmSettings.section.quotesTos', 'Terms of Service / AGB step')}
</h4>
{checkbox('crm_quotes_tos_required', 'Require customers to tick "I accept the Terms of Service" before accepting')}
@@ -229,7 +229,7 @@ export const CrmSettingsPage: React.FC = () => {
onChange={(e) => setVal('crm_quotes_tos_url', e.target.value)} />
</div>
<div className="mt-3">
<label className="block text-sm font-medium mb-1">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('crmSettings.crm_quotes_tos_text.label', 'Inline Terms text shown on the quote page')}
</label>
<textarea
@@ -247,7 +247,7 @@ export const CrmSettingsPage: React.FC = () => {
{showInvoices && (
<Card>
<h3 className="font-semibold mb-3">{t('crmSettings.section.invoices', 'Invoices')}</h3>
<h3 className="font-semibold text-neutral-900 dark:text-neutral-100 mb-3">{t('crmSettings.section.invoices', 'Invoices')}</h3>
{checkbox('crm_invoices_qr_enabled', 'Render payment QR on invoice PDFs')}
{checkbox('crm_invoice_round_total', 'Reconcile sub-cent rounding to a clean total (adds a "Rundung" row when per-line rounding drifts from qty × rate)')}
@@ -356,7 +356,7 @@ export const CrmSettingsPage: React.FC = () => {
on every new multi-installment plan. Per-document edits
still override. */}
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
<h4 className="font-semibold mb-2 text-sm">
<h4 className="font-semibold text-neutral-900 dark:text-neutral-100 mb-2 text-sm">
{t('crmSettings.section.installmentDefaults', 'Default installment triggers')}
</h4>
<p className="text-xs text-neutral-500 mb-3">
@@ -365,13 +365,13 @@ export const CrmSettingsPage: React.FC = () => {
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div>
<label className="block text-sm font-medium mb-1">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('crmSettings.crm_invoices_installment_trigger_first.label', 'First installment trigger')}
</label>
<select
value={values.crm_invoices_installment_trigger_first ?? 'quote_accepted'}
onChange={(e) => setVal('crm_invoices_installment_trigger_first', e.target.value)}
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm"
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 text-sm"
>
<option value="quote_accepted">{t('crmSettings.installmentDefaults.trigger.quote_accepted', 'At signing / creation')}</option>
<option value="before_event">{t('crmSettings.installmentDefaults.trigger.before_event', 'Before event')}</option>
@@ -401,7 +401,7 @@ export const CrmSettingsPage: React.FC = () => {
pickers — admin can override per document — but new
drafts auto-prefill from these two settings. */}
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
<h4 className="font-semibold mb-2 text-sm">
<h4 className="font-semibold text-neutral-900 dark:text-neutral-100 mb-2 text-sm">
{t('crmSettings.section.paymentDefaults', 'Default payment conditions')}
</h4>
<p className="text-xs text-neutral-500 mb-3">
@@ -410,11 +410,11 @@ export const CrmSettingsPage: React.FC = () => {
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium mb-1">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('crmSettings.crm_invoices_default_payment_net_days_template_id.label', 'Default net days')}
</label>
<select
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm"
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 text-sm"
value={values.crm_invoices_default_payment_net_days_template_id ?? ''}
onChange={(e) => setVal('crm_invoices_default_payment_net_days_template_id', e.target.value ? Number(e.target.value) : null)}
>
@@ -425,11 +425,11 @@ export const CrmSettingsPage: React.FC = () => {
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('crmSettings.crm_invoices_default_payment_timing_template_id.label', 'Default payment schedule')}
</label>
<select
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm"
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 text-sm"
value={values.crm_invoices_default_payment_timing_template_id ?? ''}
onChange={(e) => setVal('crm_invoices_default_payment_timing_template_id', e.target.value ? Number(e.target.value) : null)}
>
@@ -450,7 +450,7 @@ export const CrmSettingsPage: React.FC = () => {
shape: 3 behaviour toggles, then a 2-column input grid, then
the number-format input with helper text. */
<Card>
<h3 className="font-semibold mb-3">{t('crmSettings.section.contracts', 'Contracts')}</h3>
<h3 className="font-semibold text-neutral-900 dark:text-neutral-100 mb-3">{t('crmSettings.section.contracts', 'Contracts')}</h3>
{/* Three of these four toggles use `!== false` semantics on
the backend — a missing app_settings row behaves as if
checked. `checkboxDefaultOn` mirrors that so the UI tells
@@ -491,7 +491,7 @@ export const CrmSettingsPage: React.FC = () => {
component reads these via /public/settings and hides the
matching tile when the value is explicit false. */
<Card>
<h3 className="font-semibold mb-1">
<h3 className="font-semibold text-neutral-900 dark:text-neutral-100 mb-1">
{t('crmSettings.section.dashboardOverview', 'Dashboard CRM overview')}
</h3>
<p className="text-xs text-neutral-500 mb-3">
@@ -250,7 +250,11 @@ export const ReminderTemplatesPage: React.FC = () => {
<Link to="/admin/settings/crm" className="p-2 -ml-2 rounded hover:bg-neutral-100 dark:hover:bg-neutral-700">
<ArrowLeft className="w-4 h-4" />
</Link>
<h1 className="text-2xl font-bold text-theme">
{/* Explicit neutral colours (not `text-theme` / `text-muted-theme`):
those resolve to the gallery branding theme's --color-text, which
is applied globally on <html> and renders near-white inside the
light admin chrome (QA S13). */}
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">
{t('reminderTemplates.title', 'Pre-event reminder emails')}
</h1>
</div>
@@ -263,7 +267,7 @@ export const ReminderTemplatesPage: React.FC = () => {
<WorkflowIcon className="w-4 h-4 mt-0.5 shrink-0" />
<div>
<p className="font-medium">{t('reminderTemplates.scheduleMoved.title', 'The reminder schedule is now in Workflows')}</p>
<p className="mt-1 text-muted-theme">
<p className="mt-1 text-neutral-600 dark:text-neutral-400">
{t('reminderTemplates.scheduleMoved.body', 'Whether pre-event reminders are sent, and how many days before the event, is configured in the “Pre-event reminder” workflow. This page edits the email templates; per-event overrides stay on each events detail page.')}{' '}
<Link to="/admin/workflows" className="underline font-medium">{t('reminderTemplates.scheduleMoved.link', 'Open Workflows')}</Link>
</p>
@@ -271,20 +275,20 @@ export const ReminderTemplatesPage: React.FC = () => {
</div>
) : (
<>
<h3 className="font-semibold text-sm mb-2">
<h3 className="font-semibold text-sm text-neutral-900 dark:text-neutral-100 mb-2">
{t('reminderTemplates.globalSection', 'Global behaviour')}
</h3>
<p className="text-xs text-muted-theme mb-3">
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-3">
{t('reminderTemplates.globalHelp',
'Off by default — turn on to start sending pre-event reminders. The offset below is the default; each event can override on its detail page.')}
</p>
<div className="flex items-center gap-6 flex-wrap">
<label className="inline-flex items-center gap-2 text-sm cursor-pointer">
<label className="inline-flex items-center gap-2 text-sm text-neutral-800 dark:text-neutral-200 cursor-pointer">
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
{t('reminderTemplates.enableLabel', 'Send pre-event reminder emails')}
</label>
<div className="flex items-center gap-2">
<label htmlFor="reminder-days-before" className="text-sm">
<label htmlFor="reminder-days-before" className="text-sm text-neutral-700 dark:text-neutral-300">
{t('reminderTemplates.daysBeforeLabel', 'Days before the event')}
</label>
<Input id="reminder-days-before" type="number" min={0} max={365}
@@ -452,7 +456,7 @@ export const ReminderTemplatesPage: React.FC = () => {
/>
</div>
<p className="text-xs text-muted-theme">
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{t('reminderTemplates.variablesHint',
'Available variables: {{customer_name}}, {{event_name}}, {{event_date}}, {{event_type}}, {{days_before}}, {{business_name}} — substituted when the email is rendered.')}
</p>
@@ -66,7 +66,7 @@ export const SettingsBusinessProfilePage: React.FC = () => {
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold">{t('businessProfile.title', 'Business profile')}</h2>
<h2 className="text-xl font-bold text-neutral-900 dark:text-neutral-100">{t('businessProfile.title', 'Business profile')}</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
{t('businessProfile.subtitle', 'Issuer block shown on every quote and invoice PDF.')}
</p>
@@ -82,7 +82,7 @@ export const SettingsBusinessProfilePage: React.FC = () => {
</div>
<Card>
<h3 className="font-semibold mb-3">{t('businessProfile.section.company', 'Company')}</h3>
<h3 className="font-semibold text-neutral-900 dark:text-neutral-100 mb-3">{t('businessProfile.section.company', 'Company')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Input label={t('businessProfile.field.companyName', 'Company name') as string} value={profile.companyName}
onChange={(e) => setProfile({ ...profile, companyName: e.target.value })} />
@@ -116,7 +116,7 @@ export const SettingsBusinessProfilePage: React.FC = () => {
</Card>
<Card>
<h3 className="font-semibold mb-3">{t('businessProfile.section.contact', 'Contact')}</h3>
<h3 className="font-semibold text-neutral-900 dark:text-neutral-100 mb-3">{t('businessProfile.section.contact', 'Contact')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Input label={t('businessProfile.field.phone', 'Phone') as string} value={profile.phone}
onChange={(e) => setProfile({ ...profile, phone: e.target.value })} />
@@ -130,7 +130,7 @@ export const SettingsBusinessProfilePage: React.FC = () => {
</Card>
<Card>
<h3 className="font-semibold mb-3">{t('businessProfile.section.defaults', 'Defaults')}</h3>
<h3 className="font-semibold text-neutral-900 dark:text-neutral-100 mb-3">{t('businessProfile.section.defaults', 'Defaults')}</h3>
{/* Pointer so admins who look for the old VAT/hourly-rate fields here
know where they went. */}
<p className="mb-3 rounded-md border border-blue-200 dark:border-blue-900/50 bg-blue-50 dark:bg-blue-900/20 px-3 py-2 text-xs text-blue-800 dark:text-blue-300">
@@ -147,7 +147,7 @@ export const SettingsBusinessProfilePage: React.FC = () => {
<select
value={normalizeCurrency(profile.defaultCurrency)}
onChange={(e) => setProfile({ ...profile, defaultCurrency: e.target.value })}
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
>
{currencyOptions(profile.defaultCurrency).map((c) => (
<option key={c} value={c}>{c}</option>
@@ -166,7 +166,7 @@ export const SettingsBusinessProfilePage: React.FC = () => {
<select
value={profile.timezone || ''}
onChange={(e) => setProfile({ ...profile, timezone: e.target.value || null })}
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<option value="">
{t('businessProfile.field.timezoneSystemDefault', 'System default')} ({Intl.DateTimeFormat().resolvedOptions().timeZone})
@@ -180,9 +180,9 @@ export const SettingsBusinessProfilePage: React.FC = () => {
Settings → Accounting (so all financial/VAT config lives in one
place). See the callout above. */}
<div>
<label className="block text-sm font-medium mb-1">{t('businessProfile.field.defaultQrFormat', 'Default invoice QR')}</label>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">{t('businessProfile.field.defaultQrFormat', 'Default invoice QR')}</label>
<select value={profile.defaultQrFormat} onChange={(e) => setProfile({ ...profile, defaultQrFormat: e.target.value as QrFormat })}
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm">
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 text-sm">
<option value="none">{t('businessProfile.qrFormat.none', 'None')}</option>
<option value="swiss">{t('businessProfile.qrFormat.swiss', 'Swiss QR-bill (CH / LI)')}</option>
<option value="epc">{t('businessProfile.qrFormat.epc', 'EPC QR (SEPA / EUR)')}</option>
@@ -204,12 +204,12 @@ export const SettingsBusinessProfilePage: React.FC = () => {
onChange={(e) => setProfile({ ...profile, pdfLogoHeight: Number(e.target.value) })} />
{/* Folding marks dropdown. */}
<div>
<label className="block text-sm font-medium mb-1">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('businessProfile.field.pdfFoldingMarks', 'Folding marks on PDF page edge')}
</label>
<select value={profile.pdfFoldingMarks || 'none'}
onChange={(e) => setProfile({ ...profile, pdfFoldingMarks: e.target.value as any })}
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm">
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 text-sm">
<option value="none">{t('businessProfile.foldingMarks.none', 'None')}</option>
<option value="half">{t('businessProfile.foldingMarks.half', 'Half (148.5mm) — for C5 envelopes')}</option>
<option value="third">{t('businessProfile.foldingMarks.third', 'Thirds (105 + 210mm) — for DL / DIN long envelopes')}</option>
@@ -280,7 +280,7 @@ export const SettingsBusinessProfilePage: React.FC = () => {
<Card>
<div className="flex items-center gap-2 mb-1">
<Clock className="w-5 h-5 text-neutral-500" />
<h3 className="font-semibold">{t('businessProfile.businessHours.title', 'Business hours')}</h3>
<h3 className="font-semibold text-neutral-900 dark:text-neutral-100">{t('businessProfile.businessHours.title', 'Business hours')}</h3>
</div>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
{t('businessProfile.businessHours.subtitle',
@@ -546,7 +546,7 @@ const PdfLogoUploader: React.FC<PdfLogoUploaderProps> = ({ profile, setProfile }
return (
<div>
<label className="block text-sm font-medium mb-1">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('businessProfile.field.pdfLogoUpload', 'PDF letterhead logo (PNG, JPEG, or SVG)')}
</label>
<div className="flex items-center gap-3 flex-wrap">
@@ -655,7 +655,7 @@ const BankAccountsSection: React.FC<BankAccountsSectionProps> = ({ accounts }) =
onChange={(e) => setDraft({ ...draft, bic: e.target.value })} />
<Input label={t('businessProfile.bank.currency', 'Currency') as string} value={draft.currency}
maxLength={3} onChange={(e) => setDraft({ ...draft, currency: e.target.value.toUpperCase() })} />
<label className="flex items-center gap-2 text-sm pt-6">
<label className="flex items-center gap-2 text-sm text-neutral-800 dark:text-neutral-200 pt-6">
<input type="checkbox" checked={draft.isDefault}
onChange={(e) => setDraft({ ...draft, isDefault: e.target.checked })} />
{t('businessProfile.bank.isDefault', 'Default for this currency')}
@@ -679,7 +679,7 @@ const BankAccountsSection: React.FC<BankAccountsSectionProps> = ({ accounts }) =
return (
<Card>
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold">{t('businessProfile.section.banks', 'Bank accounts')}</h3>
<h3 className="font-semibold text-neutral-900 dark:text-neutral-100">{t('businessProfile.section.banks', 'Bank accounts')}</h3>
<Button size="sm" onClick={() => {
if (openForm === 'new') closeForm();
else { setDraft(EMPTY_DRAFT); setOpenForm('new'); }
@@ -698,7 +698,7 @@ const BankAccountsSection: React.FC<BankAccountsSectionProps> = ({ accounts }) =
<React.Fragment key={b.id}>
<li className="py-2 flex items-center justify-between">
<div>
<div className="font-medium text-sm">{b.label || b.iban}
<div className="font-medium text-sm text-neutral-900 dark:text-neutral-100">{b.label || b.iban}
{b.isDefault && <Star className="inline w-4 h-4 ml-1 text-amber-500" />}
</div>
<div className="text-xs text-neutral-500 font-mono">{b.iban.replace(/(.{4})/g, '$1 ').trim()}{b.currency ? ` · ${b.currency}` : ''}</div>
+8 -3
View File
@@ -94,7 +94,7 @@ export const LegalPage: React.FC = () => {
<div className="min-h-screen bg-neutral-50 flex items-center justify-center">
<Card className="max-w-md w-full mx-4">
<div className="text-center py-12 px-6">
<h2 className="text-xl font-semibold mb-2">Page Not Found</h2>
<h2 className="text-xl font-semibold text-neutral-900 mb-2">Page Not Found</h2>
<p className="text-neutral-600 mb-6">
The page you're looking for doesn't exist.
</p>
@@ -132,8 +132,13 @@ export const LegalPage: React.FC = () => {
<Card padding="lg">
<h1 className="text-3xl font-bold text-neutral-900 mb-8">{page.title}</h1>
<div
className="prose prose-neutral max-w-none"
{/* This page's chrome is hardcoded light (bg-neutral-50 wrapper,
white card). Without an explicit text color the CMS body
inherits `body { color: var(--color-text) }`, which a
dark-toned branding theme sets to near-white — leaving the
Impressum / Datenschutz text invisible (QA S3). */}
<div
className="prose prose-neutral max-w-none text-neutral-800"
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(page.content, {
ALLOWED_TAGS: [