Adds a Role filter row alongside the existing Office row on /team. Pills
are rendered from the distinct paliad.users.job_title values present in
the loaded users; "Alle" + Partner / Counsel / Senior Associate /
Associate / … / PA / Paralegal in seniority order, anything unrecognised
sorted alphabetically after.
The interface field name was previously `role: string`, left over from
before t-paliad-051 split paliad.users.role into job_title +
global_role. The API has been returning `job_title` since then, so the
role line on every card was silently empty. Updated User /
DepartmentMember interfaces to `job_title?: string | null`, and
renderUserCard now displays it via roleLabel(). Search now matches
job_title too.
Role values are normalised case-insensitively (DB still has both
"Associate" and "associate" today — separate cleanup), and a roleLabel()
helper looks up team.role.<slug> with the raw job_title as fallback so
new titles render even before the i18n entry exists.
Files
- frontend/src/team.tsx — second team-filter-row
- frontend/src/client/team.ts — User.job_title, ROLE_ORDER,
presentRoles, buildRoleFilters, userMatchesRole, roleLabel; render()
intersects office × role × search
- frontend/src/client/i18n.ts — team.filter.role + 10 team.role.* keys
(DE/EN)
loadAkten() fetched /api/projects but assigned the result to a stray
'akten' identifier (implicit global) while renderAkteOptions() iterated
over the declared 'projects' variable. Result: the project select on the
new-checklist-instance modal was always empty.
Two-line typo from the akten→projects rename sweep.
Two-palette swap at :root and :root[data-theme="dark"]; FOUC-prevention
inline <script> in PWAHead reads paliad-theme + paliad-sidebar-pinned +
paliad-sidebar-width from localStorage before the stylesheet loads, so
the page paints in the persisted state from frame one. New theme.ts
client owns the runtime side: cycles auto → light → dark → auto, listens
to prefers-color-scheme while pref="auto", broadcasts change events to
the sidebar toggle so the sun/moon/auto icon stays in sync (incl. on
OS-level theme flips). Sidebar gains a sun/moon toggle below the lang
item with localized aria-label/tooltip describing the next click action.
Surface tokens introduced (--color-surface-{2,muted}, --color-input-bg,
--color-overlay-{faint,subtle,strong,modal}, --color-border-strong,
--shadow-{lg,xl}, --status-{red,amber,green,blue,neutral}-{bg,fg,...},
--tree-icon-{client,litigation,patent,case,project},
--sidebar-scrollbar-{thumb,...,width}); status pills, dashboard cards,
agenda urgency markers, frist-due-chip, akten-status-chip, termin-type
badges all read tokens or get a class-level dark override at the bottom
of global.css. Form inputs render on white in light mode (m: 2026-04-30)
and on a value below --color-surface in dark mode so the well still
reads as depressed below the card panel.
Sidebar scrollbar themed thin + cream-channel alpha with
scrollbar-gutter: stable so the collapsed icon column doesn't shift
when the nav overflows on tall (admin) layouts; .sidebar-icon width
shrinks by var(--sidebar-scrollbar-width) to keep icons centered in
the visible content area.
The pre-paint script also fixes the sidebar-pinned FOUC (maria's add):
sets <html class="sidebar-pinned"> from localStorage before paint, with
sidebar.ts mirroring the class on <html> on every pin toggle so the
new selector :root.sidebar-pinned .has-sidebar tracks the existing
.has-sidebar.sidebar-pinned (body) selector. width is also pre-applied
when within clamp.
Build: bun run build clean (1224 i18n keys, 36 pages).
Smoke: Playwright on /login in both modes — body bg/fg/cards/inputs
read from the right tokens, FOUC script lands in <head> before the
stylesheet, dark→light→auto cycle toggles via the sidebar button.
F-9 from t-paliad-074. Aligns the i18n key namespace with the codebase's
English-language convention; no UI text changes.
Renames in frontend/src/client/i18n.ts (the source-of-truth file):
akten.* -> projects.* (merged with projekte.*; projekte wins on collision,
the more-recently-edited value, per brief)
fristen.* -> deadlines.*
termine.* -> appointments.*
projekte.* -> projects.*
notizen.* -> notes.*
Scope of changes:
- 760 key lines renamed in i18n.ts (380 unique keys × 2 langs)
- 70 akten/projekte suffix collisions resolved by dropping akten.* lines
(140 lines dropped total — projekte values preserved)
- 19 inner-segment fixes (e.g. projects.detail.fristen.add ->
projects.detail.deadlines.add, and template-literal sites like
`tDyn(`fristen.${x}`)` whose suffix begins with ${...})
- 476 caller-side replacements across 27 *.ts/*.tsx files
(literal t() / tDyn() args, template-literal prefixes,
"prefix." string concatenations, data-i18n attributes)
- i18n-keys.ts (generated) regenerated by build.ts: 1218 keys total
t-paliad-078's typed registry + build-time data-i18n scanner caught this
rename was complete: `bun run build` reports "i18n scan: data-i18n
attributes clean", meaning every literal data-i18n attribute in TSX/TS
sources references a key that exists in i18n.ts post-rename.
Out of scope (per brief): backend Go service rename (t-paliad-080 F-4),
URL paths (/akten, /projekte routes still server-side), CSS class names
(akten-table, akten-form, etc.), and German sub-tokens like .akte (label
"Akte:") or .no_akten (the modal hint when no project is linked).
Lime text on the cream/white BG fails WCAG AA. Adds a foreground token that
is midnight by default and lime inside the .sidebar scope (which lives on
midnight). Rewires every text-color use of --color-accent to the new token,
including the double-fallback typo variants. Decoration uses (border, BG,
border-bottom) keep --color-accent (= lime).
mAi/paliad#2 (full dark mode) flips --color-accent-fg back to lime in one
place — no need to revisit every rule.
F-8 from the t-paliad-074 audit. Replaces silent `?? key` fallback with a
typed key surface so drift caught at compile/build time, not in prod.
- New `frontend/src/i18n-keys.ts` (generated): `I18nKey` literal union of
all 1288 keys in `i18n.ts`. Regenerated by `frontend/build.ts` on every
build; written only when content changes (no spurious diffs).
- `t(key: I18nKey)` is now strict — `t("fristn.detail.title")` fails
`tsc --noEmit`. New `tDyn(key: string)` is the explicit escape hatch
for runtime-composed keys (`tDyn(\`fristen.status.${x}\`)`); 27 dynamic
call sites migrated.
- Build-time scan in `build.ts` walks `src/**/*.{ts,tsx}` for literal
`data-i18n` / `data-i18n-placeholder` / `data-i18n-title` attributes
and aborts the build on any value not in the key set. Skips `${...}`
interpolations (can't resolve statically). Applied before bundling so
no artefact ships when an unknown literal is present.
Surfaced and fixed during migration:
- `data-i18n="fristen.save.modal.project"` (fristenrechner.ts:145) →
`fristen.save.modal.akte` — F-04-class bug, would render the raw key.
- `t("termine.field.project.none")` (appointments-new.ts:30) →
`termine.field.akte.none` — same class.
- `t("checklisten.instance.project.open")` (checklists-instance.ts:155)
→ `checklisten.instance.akte.open` — same class.
- 4 duplicate-key entries in `i18n.ts` removed (TS1117): `nav.termine`
and `akten.detail.tab.termine` each appeared twice in DE and twice in
EN with identical values.
Out of scope (per brief): the German-vs-English i18n-key namespace split
flagged as F-9, JSX intrinsic typing, and the `akten` → `projects`
half-rename in checklists-detail.ts. Those stay tsc-noisy until separate
tasks land.
Six findings from docs/audit-polish-2-2026-04-29.md DEFER list:
- F-23: hide STATUS column on /deadlines + /projects when every visible
row shares the same status. Toggled at render time via a CSS class on
the table; the column re-appears the moment filters re-introduce
variety.
- F-32: agenda urgency pill now renders only when it disagrees with
the day-bucket heading (e.g. an Überfällig deadline that lands in
HEUTE through a filter quirk). Common case drops the redundant tag.
- F-38: bottom-nav agenda badge already counted overdue+today (the
brief's option (b)); added a localized title + aria-label so the
count's semantics ("X überfällig + Y heute fällig") is no longer
ambiguous.
- F-40: glossary filter chips no longer mix EN+DE — DE shows
"Streitsachen / Erteilungsverfahren / Allgemein", EN keeps
"Litigation / Prosecution / General". Same i18n keys cover the
Suggest-modal category dropdown.
- F-48: /projects/{id}/sub-projects now 301-redirects to the canonical
/children URL via the existing redirects.go mechanism. Added a small
redirects_test.go to lock the alias in.
- F-49: dropped the meta-circular 2026-04-22 "Neuigkeiten / What's New"
changelog entry that referenced "this changelog" itself.
go build/vet/test clean, bun run build clean.
F-25 (mobile tables → card layout) is redesign-class and is scoped at
the bottom of the PR description as t-paliad-074, not implemented here.
Frontend half of the rename:
- New /admin/partner-units page (admin-partner-units.tsx + .ts) with
full CRUD + member management. Mirrors /admin/team's aesthetic and
uses the same modal pattern. Card on /admin flips from "Geplant"
to "Verfügbar" with ICON_BUILDING and a /admin/partner-units link.
- Sidebar gains a "Partner Units" admin nav item between Team and Audit.
- Onboarding form replaces the free-text Dezernat input with a select
populated from /api/partner-units; submits partner_unit_id which the
backend uses to insert a membership row in the user-create tx.
- Settings: dezernat tab removed entirely (TabName drops to 3). The
read-only "Meine Partner Units" view now lives as a card on the
profile tab. Free-text dezernat input removed from the profile form.
~250 lines of admin-CRUD removed; replaced by ~70 lines of read-only
partner-units summary.
- /admin/team: Dezernat column dropped from the table and the inline
edit row; "Onboard existing account" modal no longer asks for one.
Column count drops from 10 to 9.
- /team directory: groups by structured partner_unit_members only;
drops the free-text fallback grouping and the "Ohne Dezernat" loose
bucket. Single "Ohne Partner Unit" orphan group catches users in no
unit.
- i18n: ~30 dezernat.* + onboarding.dezernat + admin.team.col.dezernat
+ admin.card.departments + team.* keys removed; ~30 partner_unit.*
keys added in DE+EN. "Partner Unit" / "Partner Units" used as a
loanword in DE.
- /api/departments?include=members → /api/partner-units?include=members
in team.ts (the only frontend-side fetch URL referencing the old
endpoints).
go build / vet / test clean. cd frontend && bun run build clean.
DB-backed email-template editor for global_admins, replacing the
"Kommt bald" placeholder. Admins can edit invitation, deadline_digest,
and the shared base wrapper for both DE and EN, preview against sample
data, save with versions, and reset to the embedded default.
Backend:
- Migration 026 adds paliad.email_templates (active row per (key, lang))
and paliad.email_template_versions (append-only, retained 20 deep).
- EmailTemplateService — GetActive falls through to the embedded per-
language file when no DB row, Save validates parse + structural
invariants and writes a version, Reset deletes the active row, Restore
copies a version back. Mutations require DB; reads work without.
- MailService now consults the service for body and subject and falls
back to the embedded default if the active row is malformed at parse
time — a corrupt admin save can never wedge the send path.
- Subjects move from Go (buildDigestSubject + inviteSubject) to
text/template strings stored in the (key, lang) row. Default subjects
ship with a {{/* keep this phrasing */}} comment pointing at the
reminder-redesign doc so the SLO framing rationale survives edits.
- Bilingual templates split into per-language files (invitation.de.html
+ .en.html, deadline_digest.de.html + .en.html, base.de.html + .en.html).
No more {{if eq .Lang}} branching inside templates.
- Handlers under /api/admin/email-templates/* gated by the existing
RequireAdminFunc(users) admin middleware, same shape as /admin/team.
Frontend:
- /admin/email-templates list page — three cards (one per template),
each linking to DE + EN editors with their last-modified status.
- /admin/email-templates/{key}?lang=de three-pane editor — subject + body
textarea + variable docs + actions on the left, sandboxed iframe
preview + version log on the right. 500 ms debounced live preview;
save validates server-side (422 on parse error, surfaced inline).
- admin.tsx flips the Email-Templates card from PLANNED to verfügbar.
- 50 new i18n keys (DE + EN) for the editor surface.
Tests: GetActive fallback path, ValidateTemplate happy + sad paths,
SaveRequiresStore on no-DB service, RenderTemplate body + subject
goldens, full SYSTEMAUSFALL/SYSTEM FAILURE subject matrix.
Smoke (knowledge-platform-only run, no DB/auth):
- GET /admin/email-templates → 302 to /login
- GET /api/admin/email-templates → 401
- go build/vet/test clean, bun run build clean
Design: docs/design-email-templates-2026-04-29.md.
Replaces the "Geplant: Audit-Log" placeholder on /admin with a working
viewer that unions paliad.project_events + caldav_sync_log + reminder_log
into a single keyset-paginated timeline.
- AuditService.ListEntries (internal/services/audit_service.go) does one
UNION ALL across the three sources, projecting each into a unified
AuditEntry shape and ordering by (timestamp, id) DESC. Cursor is
(BeforeTS, BeforeID) — matches the project-event Verlauf pattern. ILIKE
search escapes %/_ so "100%" doesn't act as a wildcard.
- GET /api/audit-log (internal/handlers/audit.go) accepts
source/from/to/q/before_ts/before_id/limit, validates the cursor halves
are paired, and returns { entries, next_cursor }. Both API and the
GET /admin/audit-log SPA shell are wrapped in auth.RequireAdminFunc, so
non-admins get 403 (API) / 302 (browser) via the same gate /admin/team
uses.
- Frontend (admin-audit-log.tsx + client/admin-audit-log.ts) renders the
table with source dropdown, range presets (24h / 7d / 30d / custom /
all), free-text search (debounced 250ms), and "Weitere laden" cursor
pagination. project_events rows reuse translateEvent (t-paliad-067 PR-1)
for DE/EN narrative parity with the dashboard activity feed; caldav and
reminder rows have their own per-event-type i18n keys.
- /admin landing card moved from PLANNED to AVAILABLE; sidebar admin
group gains a third entry.
Per docs/audit-polish-2-2026-04-29.md PR-3. Greenlit by m 2026-04-29 15:22.
- F-16 type-pill saturated colours collapsed to one neutral midnight-tint
chip. The five .akten-type-chip.akten-type-{client,litigation,patent,
case,project} per-type backgrounds (lavender/pink-red/cyan/salmon/
neutral-green) made /projects feel alarming for a routine type label
and the colours carried no semantic ranking. Replaced with a single
rgb(var(--hlc-midnight-rgb) / 0.06) bg + var(--color-text) fg; the type
label inside the chip carries the differentiation. The per-type
modifier classes are kept on the markup so a future signal-use
(highlighting Mandant roots, etc.) can re-introduce a colour for one
specific type without re-adding the random palette. Same neutralisation
applied to .akten-office-chip on /admin/team STANDORT — the audit
flagged it as the same class of issue.
- F-20 .login-tab.active and .gebuehren-tab.active flipped to the
canonical pattern from .akten-tab.active — lime underline + midnight
text + 600 weight. Active tabs now read identically across /login,
/tools/gebuehrentabellen, project detail, deadline detail, appointment
detail, settings, and admin.
- F-37 Notiz textarea now ships a small footer hint reading
"Strg+Enter (oder ⌘+Enter) zum Speichern" / "Ctrl+Enter (or ⌘+Enter)
to save". The keyboard-shortcut listener at notes.ts:426 was already
wired; this is purely the visible affordance. New i18n key
notizen.shortcut.hint (DE+EN); new .notiz-form-hint CSS rule sized
0.75rem muted-text below the actions row.
Verified
- bun run build clean.
- go build/vet/test ./... all green.
- Live smoke pending Dokploy redeploy.
Per docs/audit-polish-2-2026-04-29.md PR-2. Local visual cleanups across
deadlines, appointments, projects, project detail, dashboard, settings,
onboarding.
- F-13 + F-42 the .frist-akte-title CSS rule was renamed to
.frist-project-title (matching the markup that the rename sweep already
produced) plus text-overflow:ellipsis and a max-width gutter, and the
client renderers now stamp title= on the project-title span so the full
ref+title is reachable on hover. Fixes the
"L-2026-001Siemens AG ./." collision and trims the deadline rows that
were ballooning to 2 lines.
- F-15 "Projekt archivieren" demoted from .btn-danger to .btn-secondary
(neutral outline). Confirm-modal action stays red.
- F-24 the /projects filter row groups label+select pairs into
.akten-filter-group divs and stacks each as a full-width labelled block
at <480px instead of wrapping each label/select onto its own line.
- F-27 single-element breadcrumbs hide on root projects — the lone crumb
used to echo the H1 below it.
- F-28 empty REFERENZ + CLIENTMATTER cells on /projects and ORT on
/appointments render an em-dash so the placeholder convention matches
/admin/team and /projects/{id}/deadlines.
- F-33 truncated project refs on the dashboard upcoming-deadlines and
upcoming-appointments lists carry a title= attribute with the full
"REF · Title" string, so hover reveals the truncated tail.
- F-36 /projects/new no longer defaults to Mandant — a "Bitte wählen…"
placeholder is the initial selection (required attr blocks submit).
New projekte.field.type.choose i18n key in DE+EN.
- F-39 the /projects search counter renders "X / Y" in tree view too
(was bare "X"), matching the flat-view format.
- F-43 /projects/{id}/parties empty state is now an .akten-empty-card
with a "Partei hinzufügen" CTA underneath the message, wired to the
same form-open handler as the toolbar button.
- F-47 onboarding + settings job_title placeholder swaps the EN-DE-EN
mix "z.B. Associate, Partner, PA" for "z.B. Associate, Partner,
Patentanwalt" / "e.g. Associate, Partner, Patent Attorney". Three
named titles, no abbrev, EN-jargon convention kept consistent.
- F-50 mobile bottom-nav clearance bumped from 1rem to 1.75rem on
body.has-sidebar main so the centre FAB (margin-top: -10px above the
56px bottom-nav) clears the last list item with a real gutter.
Verified
- go build/vet/test ./... all green.
- bun run build clean.
- Live smoke pending deploy.
Per docs/audit-polish-2-2026-04-29.md PR-1. Single concern: text rendered
to a German narrative that was still English or raw-keyed.
- F-04 deadlines-new.ts now references the existing fristen.field.akte.*
keys (the SSR template already used them) instead of the non-existent
fristen.field.project.* keys, so the picker no longer renders the raw
i18n key.
- F-07 + F-21 dashboard activity log + project Verlauf:
• i18n.ts gains the missing dashboard.action.short.project_type_changed
plus a parallel event.title.* key set (full noun-phrase form for
Verlauf, complementing the dashboard's verb form) and
event.description.* templates with {title}/{count}/{parent}
placeholders.
• New translateEvent(eventType, title, description) helper localizes a
stored project_events row for display; parses both new value-only
descriptions and legacy English+DE-mix shapes ("Deadline „Foo"
geändert", "Type case → litigation", "Note zu deadline hinzugefügt").
Wired into dashboard.ts and projects-detail.ts renderers.
• Go services now write descriptions as value-only payloads (the title,
the count, the parent slug, or "old → new") so future rows are
locale-clean. Affected services: deadline_service.go (5 sites),
appointment_service.go (3 sites), note_service.go (1 site),
project_service.go (2 sites: status_changed, project_type_changed).
• Translation covers historical project_events rows too — the
legacy-format parsers in translateEventDescription strip the English
"Type"/"Status" prefix and pull the quoted title out of "Deadline
„Foo" geändert" so DE/EN renders correctly without DB migration.
• Renamed dashboard.action.short.project_* DE labels from "...Akte" to
"...Projekt" to match the project-rename direction.
- F-10 deadlines list REGEL column now resolves rule_name/rule_name_en
via a JOIN-side alias on deadline_service.ListWithProjects (added
RuleName/RuleNameEN to DeadlineWithProject). New ruleDisplay() helper
prefers the localized rule name and falls back to em-dash; never
renders the raw rule_code slug ("inf.rejoin").
- F-12 fristen.col.akte and termine.col.akte DE values flip "Akte" →
"Projekt"; matching SSR placeholder text on deadlines.tsx and
appointments.tsx column headers (EN already said "Matter").
- F-29 the checklists empty-state hint on /projects/{id}/checklists is
split into prefix/link/suffix spans so the <a href="/checklists"> stays
intact after applyTranslations() runs (the previous single-string i18n
value collapsed the anchor on first paint).
- F-35 projekte.subtitle DE flips "Fälle" → "Verfahren" (matches the
actual type taxonomy: Mandant/Streitsache/Patent/Verfahren/Projekt).
Same fix on projekte.empty.hint. EN keeps "cases" since EN labels the
case type as "case".
- F-46 dashboard.greeting.prefix EN flips "Good day" → "Hello".
Verified
- go build ./... + go vet ./... + go test ./... all green.
- bun run build clean.
- Dashboard activity widget + project Verlauf renderer verified by
reading the translated paths; live smoke pending deploy.
Exposes paliad.users.escalation_contact_id (added in migration 025) via
the Benachrichtigungen tab so users can route DRINGEND/overdue
escalation to a specific colleague instead of the global_admins
fallback.
Service:
- UpdateProfileInput.EscalationContactID *string (empty = clear, matches
Dezernat tri-state pattern). Server-side validation rejects self-
pointer (also enforced by CHECK in migration 025) and unknown UUIDs.
Reminder read path:
- digestRow now carries owner.escalation_contact_id and the audience
predicate adds the override. visibleForCategory's "global admin"
branch suppresses when an override is set, so escalation does not
fan out to the whole admin team. Test table extended with override
cases (escalation contact sees overdue / DRINGEND, admin suppressed).
UI / client:
- New "Eskalations-Kontakt" section under Benachrichtigungen with a
select populated from /api/users (excluding self, sorted by name).
First option is the default-fallback marker; selecting it clears.
- savePrefs PATCHes escalation_contact_id alongside the existing
reminder fields.
i18n: einstellungen.prefs.escalation.{heading,hint,default_option}
in DE + EN.
docs/project-status.md: flips the open follow-up to "shipped".
Paliad ships firm-agnostic per CLAUDE.md ("survives firm renames") but
landing copy, email templates, page titles, and form placeholders still
hard-coded "Hogan Lovells" / "HL Patents". Replaces every user-facing
firm reference with a single source of truth: internal/branding.Name on
the server and frontend/src/branding.ts in the bundle, both reading
FIRM_NAME at startup/build time and defaulting to "HLC".
Server: branding package + boot log; auth, invite, admin_users error
strings; courts/offices/models comments; mail templates thread
{{.Firm}} via injected payload default. Files handler keeps the
upstream "HL Patents Style.dotm" path (must match mWorkRepo's blob
name) but renders the user-visible DownloadName from branding.Name.
Frontend: branding.ts read via Bun.build define so process.env.FIRM_NAME
is statically substituted into client bundles (no runtime process
reference); index/login/downloads/kostenrechner/Sidebar/ProjectFormFields
and every i18n.ts string templated against ${FIRM}.
ALLOWED_EMAIL_DOMAINS whitelist intentionally untouched — email
domains and display name rotate independently.
Verified: go build/vet/test clean; bun run build clean; FIRM_NAME=Acme
override produces "Acme" in HTML and JS bundles end-to-end.
Replaces the per-deadline reminder model (overdue / tomorrow /
due_today_evening / weekly templates and four per-kind send paths) with
one bundled digest per (user, slot, local-date) — owner + project leads +
global_admins as audience tiers, three category sections per email.
Service rewrite (internal/services/reminder_service.go):
- RunOnce iterates users, evaluates morning/evening slot per user's tz,
calls runSlotForUser for each match.
- runSlotForUser checks the slot+date dedup (migration 025), fetches the
three pending-deadline categories visible to u (overdue / due_today /
due_warning at u.reminder_warning_offset_days), composes a digest, and
inserts the dedup row only on successful send.
- Audience filter applied per row in Go: due_warning to owner/lead,
due_today to owner/lead (+global_admin in evening), overdue to
owner/global_admin (NOT lead — system failure escalates past the team).
- Subject ladder: ÜBERFÄLLIG / SYSTEMAUSFALL when overdues are in the
bundle; DRINGEND on evening when due_today still pending; "Frist-
Erinnerung: N offen" otherwise. EN equivalents.
- Retired sendPerFrist, sendWeekly, deliverFristReminder, deliverWeekly,
buildSubject, slotForKind, matchesLocalDueDate.
Templates:
- Added deadline_digest.html with three category sections (red/amber/
neutral), DRINGEND wording on evening, IsOtherOwner attribution row.
- Removed deadline_reminder.html, deadline_due_today.html, deadline_weekly.html.
User schema (Go side):
- models.User gains ReminderWarningOffsetDays (int, default 7) and
EscalationContactID (*uuid.UUID, nullable).
- userColumns SELECT updated; UpdateProfileInput accepts the new offset
with 1..30 validation.
Settings → Notifications UI (PR-4):
- New reminder categories: overdue / due_today / due_warning. Legacy
toggles (tomorrow, due_today_evening, weekly) removed and the legacy
pref keys are explicitly deleted from the email_preferences object on
next save so they don't linger.
- New "Vorwarnung (Tage vorher)" input (1..30, required), wired into the
PATCH /api/me payload as reminder_warning_offset_days.
- Times-section copy refreshed: "Morgen-Slot" / "Abend-Slot (Eskalation)"
with new hint text reflecting the bundled-digest model.
- DE + EN i18n strings added/updated.
Tests:
- TestCategorize, TestVisibleForCategory, TestBuildDigestSubject lock
the boundary, recipient-rule, and subject-ladder logic.
- TestRunSlotForUser (live DB, skipped without TEST_DATABASE_URL) covers
the morning/evening flow, slot+date dedup, and off-slot tick.
- TestRunSlotForUser_EmptyDigest enforces the no-spam rule.
- TestDeliverDigest_RendersTemplate runs the new template on the
digestRow shape so a typo would fail before any SMTP I/O.
- TestRenderTemplateDeadlineDigest replaces the deleted reminder/weekly
template tests.
go build/vet/test + bun run build all clean.
Replace ad-hoc lime/forest-green system with the official 4-color HLC
palette. Lime + midnight are the primary pair; cyan + cream supporting.
Tokens
- :root now exposes --hlc-lime, --hlc-midnight, --hlc-cyan, --hlc-cream
plus channel-token siblings (--hlc-*-rgb) so tints can be expressed as
rgb(var(--hlc-*-rgb) / a) without hex literals.
- --color-bg → cream, --color-text/--color-hero-bg → midnight,
--color-accent → lime, --color-accent-dark → midnight (foreground on
lime; passes WCAG AA where #fff failed).
- New --sidebar-* tokens for the dark sidebar surface.
Sweep (frontend/src/styles/global.css)
- Replaced every hard-coded #c6f41c / #65a30d / #84cc16 / #b8e616 /
#4d7c0f / #1a2e1a / #1a1a2e / #1a2e05 with the matching var(...).
- rgba(101,163,13,a) and rgba(198,244,28,a) collapsed to
rgb(var(--hlc-lime-rgb) / a).
- text-on-lime now uses var(--color-accent-dark) instead of #fff;
btn-danger keeps white on red.
Sidebar reskin (cronus's audit, F-30)
- Background: midnight; text: cream (muted via cream-channel alpha);
active/hover: lime. Border + hover use cream-channel alphas so no
rgba hex creep on the dark surface.
Brand assets
- manifest.json theme_color → lime, background_color → cream.
- icon.svg / icon-maskable.svg base recoloured to lime + midnight glyph.
- 32× <meta name="theme-color"> across pages updated to #BFF355.
- Email templates (base.html, invitation.html) lime accent updated;
mail_service_test.go expectation tracks the new hex.
Deferred / out of scope
- PNG icons under public/icons/ are baked artefacts; regen left to the
next deploy.
- Categorical chip colours (office tints, traffic-light red/amber/green,
termin-type hues) are functional, not brand, and deliberately
untouched.
- Dark mode is not in scope.
Verified
- bun run build clean.
- go build ./... clean; mail render tests pass.
- Visual sweep at 1280×900 against frontend/dist via Playwright on
/, /login, /dashboard, /projects, /agenda, /team, /fristenrechner,
/glossary — sidebar midnight + lime active, cream page bg, white
cards, midnight text on lime CTAs.
Supersedes audit findings F-14, F-30, F-31.
Four standalone bugs from the 2026-04-27 polish audit (PR-E batch).
F-02 — /admin/team search input: long placeholder ("Nach Name oder
E-Mail suchen…") visually overlapped the absolutely-positioned count
badge ("31 / 31") because .glossar-search reserved only 0.75rem of
right padding. Bumped padding-right to 4.5rem so the badge sits in its
own gutter — same fix protects every other use of the .glossar-search
shell (admin team, glossary, etc.) without touching individual pages.
F-03 — /api/departments?include=members 500 regression. Migration 020
renamed paliad.dezernat_mitglieder → department_members but missed the
dezernat_id column on prod youpc. Application code (DepartmentService.
ListWithMembers / ListMembers / AddMember / RemoveMember) selects
department_id, which doesn't exist there → "column does not exist"
500. New migration 024 renames the column idempotently, plus the
indexes/constraints/policies that postgres did not auto-rename when
their table was renamed (departments_pkey, departments_office_idx,
departments_lead_idx, departments_lead_user_id_fkey,
departments_office_check, department_members_pkey,
department_members_user_idx, department_members_department_id_fkey,
department_members_user_id_fkey, departments_select / _write,
department_members_select / _write). Every rename uses a DO block that
swallows undefined_object / undefined_column so the migration is a
no-op on dev DBs that already had English names from migration 018.
Down step puts the German names back symmetrically.
F-08 — Project detail tabs (/projects/{id}/Verlauf|Team|…) used
href="#", so middle-click and "open in new tab" were broken even
though the SPA already mirrored the canonical path via
history.replaceState. initTabs() now sets each tab anchor's href to
/projects/{id}/{tab} (id resolved from the URL when the project hasn't
loaded yet) and only intercepts plain left-clicks — middle/ctrl/meta/
shift/alt fall through to the browser. Backend gains the previously-
missing /projects/{id}/history and /projects/{id}/children server
routes (both bound to handleProjectsDetailPage like every other tab),
so opening the URL in a fresh tab no longer 404s.
F-09 — /projects?view=tree was silently ignored: viewMode was hard-
coded to "flat" and the URL was never read. parseInitialView() now
seeds viewMode from ?view=, initFilters() syncs the dropdown to the
parsed value before binding the change handler, and changing the
dropdown rewrites the query string via history.replaceState (default
"flat" stays implicit to keep the canonical path clean). Bookmarks,
dashboard links, and copy-shared URLs round-trip correctly.
Verification:
- /api/departments?include=members live-tested after applying 024 to
youpc: returns 200 with members enriched.
- go build ./... + go vet ./... + go test ./... clean.
- bun run build clean.
Per docs/audit-polish-2026-04-27.md PR-D batch:
- F-11 office labels on /projects/{id}/team — use t("office."+key) so
"duesseldorf"/"munich" render as "Düsseldorf"/"München"
- F-17 "Lead" → "Leitung" in DE on the Rolle column and /projects/new
subtitle (EN keeps "Lead")
- F-18 admin.team.permission.global_admin → "Globaler Admin" (DE) plus
matching "globaler Admin" in last_admin error
- F-19 rename DOM IDs: projekt-type → project-type, projekt-view →
project-view, akten-status → project-status (markup + all
getElementById/$ callsites in client modules)
- F-26 Akte filter dropdown on /deadlines + /appointments → "Projekt"
/ "Alle Projekte" in DE (column headers stay for PR-A/F-12)
- F-44 admin card "Departments / Dezernate" → "Dezernate"
- F-45 "Dezernat / Partner" → "Dezernat oder Partner" on settings +
onboarding profile fields
go build/vet/test clean; frontend bun run build clean.
Native <input type="date|time|datetime-local"> follows the browser
locale unless lang is set on the element itself — Chrome and Safari
ignore the document-root lang attribute for date-picker formatting.
initI18n and setLang now sweep every date/time/datetime-local input
and stamp the current locale, so DE users see dd.mm.yyyy and 24h on
every form (Termine, Fristen, Settings reminder times, Project
filing/grant dates, Fristenrechner trigger date, Appointments
filters). EN users keep their browser-locale defaults via lang="en".
The sweep runs on every page that calls initI18n (all of them) and
again on the DE/EN toggle, so live-switching language updates the
input formatting too. Inputs are static in the SSR templates today;
dynamically-injected date inputs would need an explicit re-sweep.
Extract the bundle format into a single BUILD_FORMAT constant and add a
post-build inspection that aborts if any emitted dist/assets/*.js bundle
is missing the IIFE prologue. Catches the three regression vectors that
would re-introduce t-paliad-043:
- BUILD_FORMAT changed to "esm" / "cjs"
- `format` option dropped from the Bun.build call
- a future Bun version emitting a non-IIFE wrapper despite the option
Without IIFE wrapping, top-level `var`/`function` declarations in each
per-page bundle leak to `window` and the minifier collisions (`var d`
vs `function d()`) take down the whole authenticated surface — that is
what crashed every page on Apr 26.
Server-startup inspection of dist/assets is deferred — the build-time
guard alone makes the bug class impossible to ship.
Each "Letzte Aktivität" entry now renders as two visually distinct lines:
Line 1 (bold): actor + short verb — e.g. "Matthias änderte Frist"
Line 2 (muted): project ref link + German description — e.g. "C-2024-001 Deadline „ok" geändert"
Previously the row collapsed both into one running line, producing redundant
text like "Matthias änderte Frist Deadline „ok" geändert".
- i18n keys moved to dashboard.action.short.<kind> namespace (DE + EN);
legacy German action kinds (frist_*, notiz_*, …) kept for historical rows.
- renderActivity in dashboard.ts emits .dashboard-activity-summary +
.dashboard-activity-detail paragraphs inside the body container.
- CSS: replaced inline actor/action/details styling with stacked summary
(default size, bold actor) + detail (smaller, muted) rules; project-ref
link kept as small mono accent.
Enables the type dropdown in /projects/{id} edit modal. Switching to a
new type clears the old type's specific columns server-side and emits a
project_type_changed audit event. The frontend surfaces an inline
warning naming the fields that will be NULL'd before the user saves.
Field map (kept in sync with services.typeSpecificColumns):
client → industry, country, client_number
patent → patent_number, filing_date, grant_date
case → court, case_number, proceeding_type_id
litigation/project → none
Server: PATCH /api/projects/{id} now accepts `type`. ProjectService.Update
collects the obsolete columns up-front and force-NULLs them at the end of
the SET list; per-field appendSet calls for those columns are skipped so
Postgres' "no duplicate column in UPDATE" rule isn't tripped (and the
clear wins regardless of what the client sent). Audit event description
records old → new type slug.
Frontend: openEditModal no longer disables projekt-type. A new
renderTypeChangeWarning() computes the lost-fields list from the loaded
project record and shows it above Save when the selection diverges from
the current type. Empty when nothing would be cleared.
No DB hierarchy CHECK constraint exists on parent/child types, so type
changes don't risk schema violations on existing children. Tree
inheritance rules are not enforced on edit (matching create behaviour).
The Kommende Fristen / Termine cards forced the page wider than the
viewport on mobile. Root cause: .dashboard-col is a CSS Grid item with
the default min-width: auto, so its track sized to the min-content of
the deeply nested .dashboard-list-link content (nowrap title + ref +
badge), expanding the single-column grid track to ~641px even when the
viewport is 375px.
Set min-width: 0 on .dashboard-col so the grid track can shrink to its
column width and the existing text-overflow: ellipsis on the title/ref
spans does the rest.
Verified at 375x900: no horizontal page scroll, columns 327px each,
long titles truncate with ellipsis. Desktop >=768px unchanged
(1fr 1fr -> 504px each at 1280 wide).
`/admin` was 404 — only `/admin/team` existed. Add a browseable index so
the admin area has a root, with the existing Team-Verwaltung tile alongside
greyed-out roadmap placeholders (Departments, Audit-Log, Email-Templates,
Feature-Flags) so admins see what's coming.
- internal/handlers/admin_users.go: handleAdminIndexPage serves
dist/admin.html. Same RequireAdminFunc gate as /admin/team — non-admins
get the standard 302 to /dashboard?forbidden=admin.
- internal/handlers/handlers.go: register GET /admin under the existing
admin-conditional block.
- frontend/src/admin.tsx + client/admin.ts: card grid built from the
shared .grid + .card landing-page pattern. .admin-card-soon dims the
placeholders + adds a "Kommt bald" badge so they read as roadmap, not
broken links.
- frontend/src/components/Sidebar.tsx: add Admin-Bereich (/admin) above
Team-Verwaltung in the existing admin group. Both items live in the
same display:none group that sidebar.ts reveals after /api/me confirms
global_role='global_admin'.
- frontend/src/client/i18n.ts: nav.admin.bereich + admin.title /
.heading / .subtitle / .section.{available,planned} / .coming_soon
plus per-card title+desc, DE+EN.
- frontend/src/styles/global.css: .admin-section-planned spacing,
.admin-card-soon dimming, .admin-soon-badge pill.
- frontend/build.ts: register the renderAdmin entrypoint and admin.ts
client bundle.
Conflation: paliad.users.role was simultaneously job title (display only)
and global permission ('role=admin' checks across Go/SQL/JS). m wanted
to set his real job title ('Counsel Knowledge Lawyer') without losing
admin access — the t-paliad-050 admin-team UI even rejected role='admin'
on edit, so any UI-driven update silently demoted m.
Per m's three-axis principle ("firm roles are not project roles are not
tool roles"), this lands TWO orthogonal columns:
* paliad.users.job_title — free text, NULL allowed, display only.
NEVER gates anything in code or SQL.
* paliad.users.global_role — CHECK ('standard'|'global_admin'),
default 'standard'. The only thing that gates ops.
Migration 023:
* Drops NOT NULL + 'associate' default off the legacy role column
* Promotes role='admin' rows to global_role='global_admin'; clears
their role text; sets m's job_title='Counsel Knowledge Lawyer'
* Renames role -> job_title with CHECK (job_title IS NULL OR <> '')
* Replaces can_see_project body with global_role='global_admin'
* CASCADE-rebuilds every RLS policy under canonical English names —
with the historic u.role IN ('partner','admin') gates simplified
to u.global_role='global_admin' only (job_title NEVER gates)
Code surface:
* internal/models/models.go: User.Role -> User.JobTitle (*string) +
User.GlobalRole (string)
* internal/services/user_service.go: bootstrap (first row promoted to
global_admin via pg_advisory_xact_lock(7346298141), unchanged constant);
UpdateProfile drops role, accepts job_title only; AdminUpdateUser adds
global_role with last-admin demotion guard (ErrLastGlobalAdmin);
IsAdmin reads global_role
* Other services (dashboard/agenda/appointment/project/deadline/
department/party/note/checklist_instance): pass user.GlobalRole into
visibility predicates; partner-or-admin gates simplified to
global_admin only
* Handlers: drop now-impossible ErrAdminBootstrapOnly cases;
admin_users handles ErrLastGlobalAdmin -> 409
* department_service: SQL u.role -> u.job_title, DepartmentMember.Role
-> JobTitle (*string)
Frontend:
* /api/me + Me interfaces ship {job_title, global_role}
* Onboarding form: 'Berufsbezeichnung / Job title' (job_title)
* Settings + admin-team forms: same renames + i18n updates
* Admin-team: new 'Berechtigung / Permission' column with
'Standard'|'Global Admin' badge + dropdown editor; last-admin
demotion guard at the UI layer
* Sidebar admin-section reveal: me.global_role==='global_admin'
* deadlines/deadlines-detail/projects-detail/notes: partner-as-permission
gates dropped, only global_admin grants those operations
Tests:
* user_service_test: bootstrap promotes first user to global_admin,
subsequent default to standard; AdminUpdateUser refuses to demote
the last global_admin; IsAdmin reads global_role
Migration applied to ydb 2026-04-27. Live state verified:
* m: job_title='Counsel Knowledge Lawyer', global_role='global_admin'
* tester: job_title=NULL, global_role='global_admin'
* 29 stub colleagues: job_title='associate', global_role='standard'
- New auth.RequireAdmin middleware (gates by paliad.users.role='admin')
with API/browser-aware reject paths and a fail-closed lookup-error 500.
- Service: AdminCreateUser (onboard from existing auth.users), AdminUpdate
(full profile fields incl. additional_offices), AdminDeleteUser (also
removes project_teams + department_members memberships and clears any
led-Dezernat seat — auth.users is left intact), ListUnonboardedAuthUsers,
IsAdmin (implements auth.AdminLookup).
- Handlers: GET/POST /api/admin/users, GET /api/admin/users/unonboarded,
PATCH/DELETE /api/admin/users/{id}, plus GET /admin/team for the page.
All registered through RequireAdminFunc so non-admins get 403/302.
- Refuses to delete the last remaining admin and rejects role='admin'
assignment via the admin UI (still SQL-only) — same rules as PATCH /api/me.
- /admin/team page: full users table with inline edit (display_name, office,
role, dezernat, additional_offices, lang), trash with confirm, search +
office filters, "Onboard existing account" modal driven by
/api/admin/users/unonboarded, and an Invite button that re-opens the
shared sidebar invite modal.
- Sidebar gains a hidden Admin section that sidebar.ts reveals after a
successful /api/me lookup confirms role='admin' (fails closed on error).
- DE+EN i18n strings for the page, modal and table.
- Tests: require_admin_test.go covers admin-allowed, non-admin 403/302,
unauthenticated 401 and lookup-error fail-closed paths.
- Edit pencil on /projects/{id} now opens a modal with the same form as
/projects/new, pre-filled from the project. Type and parent are
intentionally read-only — re-typing/reparenting are structural ops not
exposed via PATCH today.
- Form body extracted into <ProjectFormFields/> + shared
client/project-form.ts so create and edit share the same fields,
visibility logic, parent picker, and payload builder.
- Inline title/description edit removed; one edit path is clearer than two.
- Breadcrumb rewritten as pill chips with type icons (matching the project
tree), chevron separators, hover lime accent, ellipsis truncation, and
horizontal-scroll fallback on mobile.
- Tab toolbar action buttons standardised — same height, padding, font
weight across Verlauf/Team/Untergeordnet/Parteien/Fristen/Termine plus
the "Mehr laden" secondary so they no longer drift visually.
Reminders used to fire whenever the hourly ticker happened to scan after
a user's first eligible event — m got mail at 02:28. We now gate delivery
to a user-chosen hour-of-day in their local timezone.
* Migration 022 adds reminder_morning_time / reminder_evening_time /
reminder_timezone (defaults 09:00, 16:00, Europe/Berlin).
* New "due_today_evening" reminder kind with its own template — fires only
for due_date = today AND status = pending, in the evening slot.
* Reminder service computes user-local hour each tick and skips users
outside their slot. SQL widens to a 3-day band; in-process filter
narrows to per-user local date.
* Settings → Notifications gains time inputs and a timezone field.
* Tests: pure (inSlot, slotForKind, matchesLocalDueDate) plus a live-DB
TestReminderSlots covering morning, evening, outside-slot, and the
completed-deadline case.
Adds a 6px col-resize strip on the right edge of the desktop sidebar.
Drag updates --sidebar-width on document.documentElement (clamped
180-480px). Mouse + touch handlers; double-click resets to the 240px
default. Width persists via localStorage["paliad-sidebar-width"], read
on every page load before first paint so layout is stable from frame 1.
The handle is opacity-faded on the icon-rail (collapsed) state and
hidden entirely under the mobile breakpoint, since the mobile sidebar
is an overlay drawer that always uses the fixed --sidebar-expanded
width.
Pin/unpin behaviour is preserved: pinned state keeps the user's chosen
width; unpinning drops to the icon rail; hover-expand restores the
chosen width. The hover-collapse mouseleave handler ignores transitions
during an active drag so the sidebar doesn't snap shut mid-resize.
Implements the design from docs/design-command-palette.md. Adds a fzf-style
command palette on top of the existing global search overlay:
- Cmd+K (Mac) / Ctrl+K (Win/Lin) opens the palette in discoverability mode
(all 20 actions visible, no entity fetch). Existing "/" shortcut preserved.
- preventDefault + stopPropagation suppress browser-native Ctrl+K behavior
(Firefox URL-bar focus). Cmd+K explicitly ignores the in-text-input skip
rule so power users can open the palette from anywhere.
- Action catalog (frontend/src/client/palette-actions.ts) — 12 navigate +
3 create + 4 toggle/app actions. Substring filter on DE+EN labels (no
fuzzy lib). runAction dispatcher reuses existing DOM handlers (lang
toggle button, sidebar pin, invite modal) — no duplicated state.
- Filtered state shows actions on top, entity search results below.
- First item auto-selected so ↵ works without an arrow press first.
- Footer shows ↑↓ / ↵ / Esc kbd hints (hidden on <480px viewports).
- 25 i18n keys (DE + EN) under palette.action.* / palette.section.* /
palette.footer.*.
- Mobile: BottomNav stays as-is (5 slots full); palette accessed via the
drawer search input. Documented decision in design doc.
Build / vet / test all clean. Smoke verified on local: login page loads
with no console errors, palette code is bundled into authenticated page
JS bundles. Production verification via Playwright after Dokploy
auto-deploy.
The global search palette emits /links?q=<title> when a user clicks a link
result, but /links had no search input — only category filter pills — so
the deep link silently landed on the unfiltered catalog.
Added a search input matching the glossary/courts pattern: live keystroke
filtering across title + DE/EN description + URL, combined with the
existing category filter, and ?q= URL prefill on init. Result count chip
("2 / 47") added next to the input for parity with the other catalogs.
i18n: links.search.placeholder added in DE + EN.
Completed deadlines were irreversible — accidental completions could not be
undone. Adds a symmetric reopen path for global admins and project leads.
Server:
- PATCH /api/deadlines/{id}/reopen flips status back to pending and clears
completed_at, audit-logged as project_event kind 'deadline_reopened'.
- DeadlineService.Reopen mirrors Complete shape; new
assertCanAdminProject helper gates on global users.role='admin' OR
paliad.project_teams.role IN ('admin','lead') walking the project path.
- Service test (skipped without TEST_DATABASE_URL) covers admin + non-admin
paths and idempotent no-op.
UI:
- /deadlines/{id} detail: Wieder öffnen / Reopen button replaces the
disabled completed-state Erledigt button (admin/partner only).
- /deadlines list: per-row ↻ icon for completed rows (admin/partner only;
project-lead-only users use the detail page).
- i18n: fristen.detail.reopen, fristen.action.reopen,
dashboard.action.deadline_reopened (DE + EN).
The global search palette emits /courts?q=... and /glossary?q=... but the
client bundles only wired the search input to live keystrokes — the URL
parameter was ignored on load. Clicking a court or glossary result in the
palette landed on the right page but showed all 41 courts / all glossary
terms instead of the expected single match.
Fix: in each page's initSearch(), read URLSearchParams.get('q') and prefill
the input value + module-level searchQuery before the data loads. The
existing render() call after fetch resolves already reads searchQuery.
Out of scope: /links has no search input today (palette also emits
/links?q=...). Flagged for a follow-up — adding a search input is a feature
addition, not a bug fix.
Cache-Control: no-cache on /assets/* (step 3) only applies to NEW
responses — cached entries from before the deploy are still served
without revalidation under heuristic freshness, which is exactly the
window that kept users stuck on the broken bundle.
The robust fix is to change the cache key on every deploy:
- frontend/build.ts now post-processes every dist/*.html and appends
`?v=<buildVersion>` to every /assets/*.js and /assets/*.css URL.
Same buildVersion the SW already uses, so the SW cache, the asset
URL, and the HTML reference all rotate together.
- internal/handlers/handlers.go wraps the protected mux (and the
public /login, /logout, /{$} pages) in a noCachePages middleware.
HTML pages now revalidate on every navigation; combined with the
versioned asset URLs, a deploy reaches users on their next request:
new HTML → new ?v= → fresh script load, every time.
After step 2 deployed the IIFE-wrapped bundles, m's browser still saw
the broken page because /assets/projects.js was being served from the
local HTTP cache (no Cache-Control, just heuristic freshness from
Last-Modified). Even after the new SW activated and cleared its own
caches, its cacheFirst handler did `fetch(req)` which goes through the
browser HTTP cache — re-fed the SW cache from the stale bundle and the
loop perpetuated forever.
Three mutually reinforcing fixes:
1. SW cacheFirst now does `fetch(req, { cache: "reload" })` for the
network leg. Forces the network fetch to bypass the browser's HTTP
cache, so the SW always seeds its own cache from a true network read.
2. Go static handlers for /assets/* and /icons/* set
`Cache-Control: no-cache, must-revalidate`. Combined with the
Last-Modified that http.FileServer already emits, browsers send
If-Modified-Since and the server replies 304 when unchanged — fast
for repeat loads, fresh on every deploy. Users without a SW (or after
the kill-switch unregistered theirs) now also pick up new bundles
immediately.
3. pwa-install.ts gates the install banner on
`(min-width: 768px)` — same breakpoint the BottomNav and other
mobile-shell elements use. Desktop partners no longer get an install
prompt covering their work area.
ROOT CAUSE of /projects empty state: the per-page bundles (app.js,
projects.js, dashboard.js, …) were emitted by bun build without an IIFE
wrapper, and loaded as classic <script> tags. Every top-level `var`,
`let`, `const`, and `function` declaration therefore became a property
of the global object.
After t-paliad-042 added app.js to every page (loaded with defer, before
DOMContentLoaded), the minified `var d = "patholo-sidebar-pinned"`
inside app.js (the legacy sidebar-pinned localStorage key constant)
clobbered projects.js's minified `function d() { … }` (the
`applyTranslations` helper). When projects.js's DOMContentLoaded handler
called initI18n → applyTranslations → `d()`, `d` was now the string
"patholo-sidebar-pinned" → "TypeError: d is not a function" → the
fetch to /api/projects never even fired → table stayed empty → empty
state showed.
Fix: pass `format: "iife"` to Bun.build so every entry is wrapped in
`(()=>{ … })()`. Top-level identifiers are now scoped per bundle and
cannot collide on `window`. Verified locally: window.d, window.r,
window.K all `undefined` after both app.js and projects.js execute.
While here, replace the t-paliad-043 step 1 kill-switch SW with the
proper versioned cache pattern the brief asked for:
- frontend/public/sw.js carries `__PALIAD_BUILD_VERSION__` placeholder
- frontend/build.ts substitutes `v<Date.now()>` after copying public/
into dist/, so every deploy opens a fresh `<version>-static` cache
- activate handler deletes any cache whose name doesn't match current,
which evicts both the old paliad-v1-static cache and any kill-switch
survivors the moment a user lands on the new deploy
- skipWaiting + clients.claim so the new SW takes over on the next
navigation rather than waiting for every tab to close
Emergency: t-paliad-042 shipped a service worker that cached a broken
/assets/projects.js (crashes on init with "d is not a function"), making
/projects show the empty state. Mobile Safari users have no devtools to
manually unregister the SW.
Replace sw.js with a self-destructing variant: on activate, delete every
cache, unregister itself, and force every open client to navigate to a
fresh page. /sw.js is served with no-cache headers so browsers refetch
on the next navigation and propagate the kill-switch automatically.
Step 2 (separate commit): fix the projects.js bundle bug, then ship a
properly versioned SW that evicts stale caches on every deploy.