← Back

Patch notes — platform improvements: i18n, white-label, GDPR, a11y (2026-06-19)

A large feature batch on top of the same-day hardening release: the admin UI is now fully translatable (English / Norwegian Bokmål), the platform took its first real white-label steps (one-colour theming, branded PWA), two GDPR features landed (data export + right-to-erasure), and a broad performance / privacy / accessibility pass shipped — self-hosted fonts, WCAG AA contrast, an OS-following theme, and a scanner-safe unsubscribe. All deployed to main (73464e7). Docker smoke 164/0 throughout. 23 commits; every one was smoke-gated before landing.

The schema change in this batch (one additive column) is applied automatically on container start, so the deploy needed no manual migration.

1 — Internationalization: English + Norwegian Bokmål ✅

The entire admin surface — Settings, Newsletter, Billing, CRM and Service Desk — is now translatable, switched by the existing site_language setting. The mechanism is a tiny t("<English>", lang) lookup using the English string as the key: an unknown/untranslated string falls back to English and never raises, so a missing translation degrades to English rather than a broken page. ~650 Norwegian Bokmål strings were added; English output is byte-identical to before (the i18n table is strictly additive). Customer data (ticket/company names, campaign subjects, product names) is intentionally not translated. Commits f1666b4 (Settings), 697007b (Newsletter), 812a484 (Billing), 9427cc3 (CRM), 66952dc (Service Desk).

Each of those commits also fixed a latent bug class: several handlers had a loop variable literally named t (for t in teams: etc.) that shadowed the t() translation function and would have raised once a label was rendered in that scope. All statement-level shadows were renamed.

2 — White-label: one-colour theming, presets, branded PWA ✅

First real white-label steps, all built on the existing token + /css/theme-overrides.css plumbing (no rebuild needed to rebrand):

  • Palette from one colour — pick a single brand colour and the server derives a matching accent and hover shade (stdlib HLS maths, no dependency), so a non-designer gets a coherent palette from one input. Commit 795d605.
  • High-contrast preset — a first-class appearance preset (black / white / amber) reusing the same override mechanism. Commit 4b4d2b7.
  • Branded PWA manifest/manifest.webmanifest now takes its name / short_name and install colours from the company_name brand setting and the Appearance theme_bg, so an installed mobile app shows the tenant’s name and colour instead of “Ethica Field”. Commit f4d042f.

3 — GDPR: data export + right-to-erasure ✅

Two admin-only tools on the company profile, for the Norway/EU compliance angle:

  • Data export (Art. 15/20)GET /admin/company/export?id=N returns a JSON attachment bundling everything stored about a company (company, users, contacts, deals, tickets + comments, projects + tasks, assets, invoices + lines, time entries, file metadata, doc grants, events and the relevant audit rows). Secrets, tokens, password material and blob bytes are stripped; the export itself is audit-logged as gdpr.export. Commit 005ddb5.
  • Right-to-erasure (Art. 17)POST /admin/company/anonymise (CSRF-protected) redacts a customer’s personal data — contacts, company PII, login accounts and support-ticket/comment text — while retaining invoices, time entries and the audit log (financial/legal records must survive). It only redacts, never hard-deletes; it stamps anonymised_at, is idempotent, and is irreversible by design. Commit 17b8205.

The one schema change in this release — crm_companies.anonymised_at (additive, IF NOT EXISTS) — applies automatically on container start.

4 — Performance, privacy & accessibility ✅

  • Self-hosted fonts — the Google Fonts <link> (cross-origin request + render-blocking stylesheet) is gone; the three typefaces are now served as latin-subset WOFF2 from /css/fonts.css with font-display: swap. Removes a third-party request on every page (a GDPR plus in the EU — Google Fonts hotlinking has been ruled a data transfer) and one render-blocking dependency. Commit d7b4efe; a follow-up 4152a8b restored the Source Serif 500/700 weights and added a smoke guard so a future subset can’t silently drop a weight.
  • WCAG AA contrast — audited the design-token palette; the dark default passed, three light-theme tokens (--color-text-soft, --color-success, --color-warning) fell below 4.5:1 for normal text and were darkened to pass. Hue and the soft/muted hierarchy are preserved. Added scripts/check_contrast.py, wired into the smoke, so contrast can’t silently regress. Commit 73464e7.
  • “Auto” theme follows the OS — selecting Auto now resolves light/dark via prefers-color-scheme before paint (no flash) and tracks the OS live; a color-scheme meta makes native controls/scrollbars match. Commit e5c1faa.
  • Accessibility quick wins — a skip-to-content link, a visible :focus-visible ring, a prefers-reduced-motion guard, and autocomplete tokens on the login form (extended in a later commit to the password-change and mobile-profile forms, 8c9f3dc). Commit ae503ed.
  • Lighter 404 — the branded 404 image went from an 878 KB PNG to a 105 KB JPEG. Commit 3bec68a.

5 — Scanner-safe newsletter unsubscribe (RFC 8058) ✅

The List-Unsubscribe URL previously unsubscribed on a plain GET, so an automated email link-scanner or prefetcher (Outlook SafeLinks, antivirus gateways, inbox link checks) following it could silently unsubscribe a real subscriber. The two methods are now split per RFC 8058: a GET renders a confirm button and changes no state; a POST (the mail client’s one-click List-Unsubscribe=One-Click, or the confirm button) performs the unsubscribe and returns 200. Mail-client one-click is unaffected. Commit d401d02.

6 — UX & layout ✅

  • Liquid admin layout — the main content now fills to a balanced max width and centres, dashboards/boards no longer trigger horizontal scrollbars on wide cells, and the sidebar divider is stable. CSS-only, on existing tokens. Commit b703d3e.
  • First-run empty states — empty CRM lists show a friendly message with a primary “Add …” button that opens, scrolls to and focuses the inline form, instead of a blank table. Commit f8da24a.
  • Settings consolidation — Appearance and Language moved into the /admin/site-settings tab strip (?tab=appearance); the old /admin/appearance URL now 302-redirects. Commit 705d2ac.
  • CRM Key-contacts overflow fix — long contact emails in the fixed-width company-overview side panel wrapped out of the box; the rows now wrap and break cleanly inside it. Commit eb4f765.

7 — Service desk: a global default SLA (the “48h never fired” fix) ✅

SLAs were company-only — the breach scanner and dashboard joined on crm_companies WHERE sla_enabled=1, so a ticket on a company without its own SLA was never evaluated, and there was no blanket knob. A ticket “set to 48h” therefore never triggered. There is now a global default SLA (first-reply + resolution, in hours) under Settings → Service desk. The scanner, dashboard and per-ticket panel compute an effective SLA per ticket: a company’s own SLA still wins, otherwise the global default applies, and company-less tickets are covered too. Breaches email + push as before. Commit c12bf0d.

Rollout note: with a global default set, any existing open ticket already past the threshold breaches on the first sweep — expect a one-time notification burst on a large backlog (the sla_*_notified flags prevent re-notification thereafter).

Verification

Docker smoke 164/0 at the release commit (73464e7), green on every step; py_compile clean; the production deploy was confirmed live (self-hosted fonts serving, Google Fonts request gone, color-scheme meta present). The single additive column migrates automatically on container start; rollback is a prior dated image tag.