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 thet()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.webmanifestnow takes itsname/short_nameand install colours from thecompany_namebrand setting and the Appearancetheme_bg, so an installed mobile app shows the tenant’s name and colour instead of “Ethica Field”. Commitf4d042f.
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=Nreturns 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 asgdpr.export. Commit005ddb5. - 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 stampsanonymised_at, is idempotent, and is irreversible by design. Commit17b8205.
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.csswithfont-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. Commitd7b4efe; a follow-up4152a8brestored 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. Addedscripts/check_contrast.py, wired into the smoke, so contrast can’t silently regress. Commit73464e7. - “Auto” theme follows the OS — selecting Auto now resolves light/dark via
prefers-color-schemebefore paint (no flash) and tracks the OS live; acolor-schememeta makes native controls/scrollbars match. Commite5c1faa. - Accessibility quick wins — a skip-to-content link, a visible
:focus-visiblering, aprefers-reduced-motionguard, andautocompletetokens on the login form (extended in a later commit to the password-change and mobile-profile forms,8c9f3dc). Commitae503ed. - 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-settingstab strip (?tab=appearance); the old/admin/appearanceURL now 302-redirects. Commit705d2ac. - 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_*_notifiedflags 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.