m's 2026-05-08 18:09 spec — Slice 3c. Adds a Klägerseite / Beklagtenseite
chip strip at the top of the B1 cascade panel; cascade leaves tagged
with a contradictory party get hidden. Klägerseite never files
Klageerwiderung; Beklagtenseite never files Klageschrift.
Migration 071 adds `paliad.event_categories.party text[]` (CHECK on
{claimant, defendant, both, court}) plus a partial GIN index. Backfill
is conservative — only the obvious leaves get tagged on this pass:
- claimant ich-moechte-einreichen.klage.* (9 leaves)
ich-moechte-einreichen.spaetere-schriftsaetze.replik-*
- defendant ich-moechte-einreichen.widerklage.*
ich-moechte-einreichen.spaetere-schriftsaetze.duplik-*
cms-eingang.* (incoming) and frist-verpasst.* (anyone misses a
deadline) stay NULL because the user can be on either side and still
receive the same court communication. Cross-appeal / Anschluss-
berufung / Reply-to-cross-appeal also stay NULL — the role flips
depending on who appealed first; the cascade doesn't have that
context yet. Tag in a follow-up once dogfood validates the chip.
Backend: EventCategoryNode JSON gains optional `party` array;
EventCategoryService.Tree SELECT picks it up via pq.StringArray.
Frontend: new Perspective type + URL state (?role=claimant|defendant)
+ perspective chip strip styled identically to the inbox-channel chip
strip. perspectiveAllowsParty(party) gates each cascade child;
"both"/"court" tagged nodes always pass; neutral nodes always pass.
Persistence is URL-only — dogfood will tell us whether to add a saved
default later.
Migration applied to live Supabase; tracker at v71.
Refs t-paliad-157 / m/paliad#15.
Sidebar:
- Paliadin lifted out of Übersicht to a top-level entry directly under
Home (owner-only reveal logic unchanged — same id reused).
- Agenda removed from sidebar; the standalone /agenda route stays for
direct-link compatibility but the dashboard hosts its content inline.
- Projekte moved into Übersicht; Fristen + Termine moved into a new
Ansichten group; the Arbeit group is gone.
- Werkzeuge / Wissen / Ressourcen collapsed into one Werkzeuge group
per m's brief order (calculators → reference → content).
- BottomNav agenda slot repointed to /events?type=deadline so the
overdue+today badge still has a sensible target on mobile.
Dashboard:
- Agenda renders inline as a new collapsible section between the
upcoming-rails grid and Letzte Aktivität, with a "Vollständige Agenda
öffnen →" link to the standalone page.
- Letzte Aktivität moved under Agenda per m's design call.
- Sections (summary, deadlines, appointments, agenda, activity) become
collapsible via a chevron toggle; state persists in
localStorage[paliad:dashboard:collapse:<section>]. Matters card stays
whole-card-tappable, so it's intentionally left non-collapsible.
- Inline agenda fetches /api/agenda directly with a 30-day window and
refreshes on the existing 60s dashboard poll.
Render primitives:
- New client/agenda-render.ts hosts renderAgendaTimeline + AgendaItem
type, shared by client/agenda.ts and client/dashboard.ts. Standalone
agenda.ts shrinks accordingly; behaviour is identical.
i18n:
- Added nav.group.ansichten + dashboard.agenda.* + dashboard.section.*
keys (DE/EN). Removed nav.group.{arbeit,wissen,ressourcen} (no other
callers; i18n-keys.ts auto-regenerated).
m's 2026-05-08 18:09 spec: "if we have the project type defined, we
should only have events available that match the type of project /
type of case." Slice 3b wires the project's proceeding_type into the
cascade narrowing alongside the inbox chip and ad-hoc context.
Three inputs feed the cascade now, in priority order:
1. Inbox chip (cms / bea / posteingang) — user override.
2. Ad-hoc Step 1 chip (upc / de / epa / dpma).
3. Project's proceeding (Step 1 picked Akte → proceeding_type_id →
proceeding_types.code → forum prefix).
activeForumOnPage() returns the first non-null value. The B1
cascade's inboxFilterAllowsForums consults this so a user landing on
/tools/fristenrechner?project=<uuid>&path=b&mode=tree gets the
narrowed cascade automatically — no chip clicks required. The chip
can still override at the top of the panel.
Pieces:
- ProjectOption gains optional proceeding_type_id (already on the
JSON; just declared so TypeScript can read it).
- cachedProceedingTypes Map<int, string> is populated once on init
via /api/proceeding-types-db and cached for the page lifetime.
- forumFromProceedingCode() maps "UPC_INF" / "DE_NULL" / "EPA_OPP"
/ "EP_GRANT" / "DPMA_OPP" → upc / de / epa / dpma. EP_ and EPA_
both hit the EPA branch since EP_GRANT belongs to the EPA forum.
- triggerCascadeRefresh() is called from selectProject /
selectAdhoc / clearStep1Context + after the async load completes
so the cascade re-renders when the context changes.
The role variants (Klägerseite vs Beklagtenseite, Berufungskläger vs
-beklagte) are Slice 3c — they require fetching the user's
project_teams.responsibility for the selected project. Project's
forum lands first; role layers on after.
Refs t-paliad-157 / m/paliad#15. Folds in part of #18 (Item A
rule-vs-event collapse) — when the project context narrows the cascade
to one jurisdiction, the rule-vs-event mismatch surface shrinks.
m's request 2026-05-08 20:12: alongside Paliad's per-recipient
"E-Mail an Auswahl" broadcast (which sends individual envelopes from
the server), users want a one-click way to compose a single multi-
recipient email in their own mail client. Common use case: writing
to a specific team where the response thread should stay client-side
and be visible to every recipient (unlike the privacy-preserving
broadcast where each recipient sees only themselves).
Adds a "Im Mail-Client öffnen" / "Open in mail client" link to the
broadcast modal's recipient summary, alongside the existing
"Alle anzeigen" toggle. Clicking it opens a `mailto:` URL with every
selected recipient comma-separated in the To: line per RFC 6068.
`buildMailtoHref` is exported so it can be unit-tested independently
and reused by other selection surfaces (admin team table, project
team tab) without a refactor.
The existing server-driven broadcast path is unchanged — both options
coexist.
Six commits from mai/dirac/inventor-inline-paliadin (all sliced per
the design's §10 phasing):
142edca docs(paliadin): t-paliad-161 inventor design
282e0bb feat(paliadin/migration-070): Slice A — schema + relay seam
0d1a7ba feat(paliadin/context): Slice B — structured page-context payload
ba2408e feat(paliadin/inline-widget): Slice C — floating button + drawer
a3052eb feat(paliadin/suggest): Slice D — agent-suggested write path
4ecea7a feat(paliadin/agent-glyph): Slice E — ✨ alongside 👀
What ships:
- Floating Paliadin trigger bottom-right + Cmd/Ctrl-K shortcut, opening
a 420px right slide-out drawer (full-screen on mobile). Visible on
every authenticated page except /paliadin, /login, /onboarding.
Same PaliadinOwnerEmail gate as today — no scope expansion.
- Per-route starter-prompt registry in client/paliadin-starters.ts —
context-aware empty-state nudges users into useful first prompts.
- Structured PaliadinContext payload (route_name + primary_entity_type
+ primary_entity_id + user_selection_text + view hints) flowing from
the widget through Go into the tmux envelope. SKILL.md gains [ctx …]
parsing so the persona can use it.
- Agent-suggested write path: paliad__suggest_deadline +
paliad__suggest_appointment + paliad__suggest_note tools that draft
rows straight into the existing approval pipeline. Suggestions land
as approval_requests with requester_kind='agent' and an
agent_turn_id pointer back to the originating turn.
- Visual provenance: ✨ glyph alongside 👀 on pending-approval rows
whose request was agent-drafted; persistent ✨ on approved-from-agent
rows in the audit log. Lives in events.ts/agenda.ts/inbox.ts.
Migration 070 is idempotent (every ALTER guarded by IF NOT EXISTS,
constraints/index inside DO blocks). Live tracker is at v69; deploy
will apply 070 cleanly. Adds:
paliad.approval_requests.requester_kind text + xor-check
paliad.approval_requests.agent_turn_id uuid
paliad.paliadin_turns.context jsonb
m greenlit all 5 inventor decisions (a-a-a-a-a) on 2026-05-08 19:39:
owner-only gate, tmux relay v1, create-only suggestion verbs,
✨-alongside-👀 visual, selection-text default-on.
Refs m/paliad#20, design doc docs/design-paliadin-inline-2026-05-08.md.
When a pending row was drafted by Paliadin (requester_kind='agent' on
its in-flight approval_request), surface a sparkle ✨ next to the
existing eye-pill 👀. The two glyphs are orthogonal: 👀 = "needs
approval", ✨ = "Paliadin drafted this". Either can change without the
other, so the visual taxonomy stays decomposable for any future
autopilot mode where 👀 disappears but ✨ stays.
Read-path:
- DeadlineService.ListVisibleForUser + AppointmentService.ListVisibleForUser
LEFT JOIN paliad.approval_requests on pending_request_id and project
ar.requester_kind into the row. NULL when no request is pending.
- models.DeadlineWithProject + AppointmentWithProject grow
RequesterKind *string. The list-projection helpers
(projectDeadline / projectAppointment in event_service.go) carry it
into EventListItem.
- /api/events response now includes requester_kind on every pending
row; /api/inbox already does (Slice D extended approvalRequestViewColumns).
Render-path:
- frontend/src/client/events.ts — new AGENT_PILL_GLYPH constant ("✨"),
agentPill rendered into the title cell next to the existing
pendingPill when item.approval_status='pending' AND
item.requester_kind='agent'. EventListItem TS shape gains
`requester_kind?: "user" | "agent"`.
- frontend/src/client/agenda.ts — same pattern, agendaItem TS shape
+ agentPill rendered next to pendingPill in the headline span.
- frontend/src/client/inbox.ts — ApprovalRequestView gains
requester_kind + agent_turn_id; the meta line replaces the
requester's plain name with "Anna ✨ Paliadin" when the request was
drafted by the agent.
CSS: new .approval-pill--agent modifier in global.css using only
existing tokens (--color-bg-lime-tint / --color-surface-2 /
--color-text), mirroring the .approval-pill--icon shape so the two
glyphs sit side-by-side at the same baseline.
i18n: 3 new keys × 2 langs (approvals.agent.label /
approvals.agent.byline / approvals.agent.suggestion_pending) — total
1966 → 1969.
Build clean (frontend + go), tests green.
Refs: docs/design-paliadin-inline-2026-05-08.md §8.
m's 2026-05-08 18:09 spec: Step 3a is itself a 3-option fan-out. When
the user picks "Etwas einreichen" on Step 2 we no longer drop straight
into the Pathway A wizard; we ask "what kind of einreichen?" first.
Three cards:
- **File** (Schriftsatz einreichen) → navigates to Pathway A — the
existing wizard with proceeding picker, trigger date, flags,
timeline, save modal. The rule-library entry point.
- **Draft** (Schriftsatz entwerfen) → v1 placeholder. Disabled
button with a "kommt bald" pill in the corner. m specced this
as a link to a future drafting surface; for now we show the
intent without doing anything so the surface exists in the IA.
- **Enter** (Frist manuell erfassen) → routes to
`/projects/{id}/deadlines/new` (or `/deadlines/new` in ad-hoc
mode where there's no project to anchor against).
Pathway type extends to include "outgoing"; readPathwayFromURL +
showPathway both handle it. The Step 3a panel reuses .fristen-step2-
card visuals so File / Draft / Enter look consistent with the parent
Step 2 cards but distinct from Pathway A's proceeding picker.
Back-button policy:
- Step 3a back → Step 2 (the new "fork" state).
- Pathway A back → Step 3a (since that's where the user came from
in the new flow). Two clicks back to the fork.
- Pathway B back → fork directly (Step 2 happened-card jumps
straight to Pathway B; no intermediate chooser).
Out of scope for this slice:
- Step 3b's project-type-scoped event picker (Slice 3b).
- Klägerseite/Beklagtenseite role variants (Slice 3c).
- Real /drafts route — Draft stays a soft placeholder.
Refs t-paliad-157 / m/paliad#15.
The inline Paliadin chat surface — reachable from every authenticated
page, replacing the standalone /paliadin route as the primary entry
point. The standalone page survives as the dedicated full-screen mode
(the drawer's "↗ fullscreen" action links to it).
Components:
- frontend/src/components/PaliadinWidget.tsx — emits the floating
trigger button (bottom-right, lime ✨, owner-revealed by JS), a
scrim, and the right-edge slide-out drawer with header (reset /
fullscreen / close), context chip, message stream, empty-state
starter list, and textarea+send form. Loads /assets/paliadin-widget.js.
- frontend/src/client/paliadin-widget.ts — runtime. /api/me probe
reveals the trigger when caller matches PaliadinOwnerEmail (with
optional is_paliadin_owner flag fast-path); Cmd+J / Ctrl+J shortcut
toggles open/close (Cmd+K stays reserved for global search per
client/search.ts). Uses computePaliadinContext() (Slice B) per send
so route + entity + selection flow into every turn. SSE consumer
writes assistant bubbles; localStorage persists per-session history.
- frontend/src/client/paliadin-starters.ts — per-route starter prompt
registry. 14 routes covered (dashboard, projects.*, deadlines.*,
appointments.*, agenda, events, inbox, tools.*, glossary, courts) +
a _default fallback. Bilingual (DE/EN); prompts ending in `: ` seed
the textarea for the user to finish; fully-formed prompts auto-send.
- 39 authenticated TSX pages get a `<PaliadinWidget />` element after
`<Footer />` via a mechanical pass. paliadin.tsx (the standalone)
is intentionally excluded — its dedicated UI is the widget's
fullscreen escape hatch, not a place to overlay another widget.
- frontend/build.ts registers the new bundle.
- frontend/src/styles/global.css gains ~280 lines of widget CSS
(trigger / scrim / drawer / header / context-chip / messages /
bubbles / starters / form / send-btn) using only existing tokens.
Mobile (≤640px): drawer goes full-screen; trigger lifts above
bottom-nav slots.
- 11 new i18n keys × 2 langs = 22 entries under paliadin.widget.*.
Visibility predicate (paliadin-context.shouldSendContext) hides the
widget on /paliadin, /login, /onboarding. Owner-only gate stays on
PaliadinOwnerEmail.
Build clean: i18n 1955 → 1966 keys, IIFE-wrapped 218KB bundle, go test
green.
Refs: docs/design-paliadin-inline-2026-05-08.md §3, §5.
m's 2026-05-08 Slice 2: "Neue Akte anlegen" on the Fristenrechner now
round-trips cleanly. The Step 1 link sends `?return=/tools/fristenrechner`
on the way out; projects-new.ts honours the param after a successful
POST and redirects back with `?project=<new_uuid>` appended so the
just-created Akte preselects itself in Step 1.
Two pieces:
- frontend/src/client/projects-new.ts — new sanitizeReturnUrl()
rejects anything that could escape to a different origin
(protocol-relative `//foo`, absolute `https://...`, non-rooted
relative paths). On submit success, if a sanitized return URL
exists, build the destination via URL() so existing query params
on the return path stay intact and ?project= is set without
clobbering, then redirect there. Falls back to /projects/{id}
when no return param is present (existing behaviour preserved).
- frontend/src/fristenrechner.tsx — Step 1 link gets the
?return=/tools/fristenrechner query string so the bounce-back
knows where to land.
Step 1 hydration from Slice 1 already handles `?project=<uuid>` —
fetchProjects() repopulates cachedAkten, the projectId looks up its
ProjectOption record, renderStep1Summary() renders the collapsed
state, Step 2 cards become visible. No client-side state coordination
needed; the URL is the contract.
Refs t-paliad-157 / m/paliad#15.
df04e50 — feat(fristenrechner/determinator): the legacy "Was möchten
Sie tun?" landing fork is replaced by:
Step 1: filtered Akte picker + "Neue Akte anlegen" link (bare; the
bounce-back to the wizard after creation is Slice 2 scope) +
4 ad-hoc chips driving ?ad_hoc=upc|de|epa|dpma.
Step 2: "Etwas einreichen" / "Etwas ist passiert" cards driving
showPathway('a' | 'b'). Quick-pick chips moved here from the old
fork. Pathway A/B back buttons return to Step 2.
Save CTA on Pathway A's wizard disables in ad-hoc mode with hint
"Ad-hoc — kein Projekt, kein Speichern" (DE+EN). The locked context
collapses to a one-line summary; Reselect re-expands.
URL contract:
?project=<uuid> | ?ad_hoc=upc|de|epa|dpma — Step 1 result
?path=a|b — Step 2 result (back-compat)
?mode=tree|filter — Pathway B sub-mode
Pathway A/B sub-routing primitives (showPathway, showBMode) unchanged
— Step 2 cards just drive the same hooks.
Still open:
Slice 2 — /projects/new return-bounce on save.
Slice 3+ — scoping the picker / cascade by project's proceeding-type
+ role; replacing the wizard with the Step 3a File/Draft/Enter
chooser.
m's 2026-05-08 18:08 Determinator redesign Slice 1. Replaces the
legacy "Was möchten Sie tun?" fork (Pathway A vs B) with a two-step
funnel that puts the project (Akte) at the foundation:
Step 1 — Welche Akte?
- Filtered list of visible projects, search-as-you-type.
- "Neue Akte anlegen" link → /projects/new (bare; the bounce-back
with auto-preselect lands as Slice 2 per Maria's gating).
- Four ad-hoc explore-mode chips (Custom UPC / DE / EPA / DPMA
proceeding) for users who just want to look up a rule. No DB
write; URL becomes ?ad_hoc=upc|de|epa|dpma.
Step 2 — Was möchten Sie tun?
- Two cards: "Etwas einreichen" → Pathway A (Verfahrensablauf
wizard) and "Etwas ist passiert" → Pathway B (cascade, mode=tree).
- Quick-pick chips moved here from the old fork's shortcut row.
Once Step 1 picks a context, the picker collapses to a one-line
summary "Akte: X · [Andere Akte]" mirroring the proceeding-summary
collapse pattern (097e21c). Reselect re-expands and clears downstream
state.
State on URL:
?project=<uuid> project context
?ad_hoc=upc|... ad-hoc explore-mode
?path=a|b Step 2 outcome (kept for back-compat)
?mode=tree|filter Pathway B sub-mode (kept)
The legacy back-from-Pathway buttons now return to Step 2 (the new
"fork" state). showPathway() / showBMode() unchanged — Step 2 cards
just drive the same primitive.
Save-to-project CTA on Pathway A's wizard detects ad-hoc mode and
disables itself with the hint "Ad-hoc — kein Projekt, kein Speichern"
(EN: "Ad-hoc — no matter, no save"). Hiding the CTA would leave the
user wondering where the action went; disabling makes the constraint
legible (per m's lock #2).
Frontend pieces:
- fristenrechner.tsx — Step 1 + Step 2 markup; legacy
fristen-pathway-fork removed wholesale.
- client/fristenrechner.ts — new Step1Context type + URL hydration
+ render helpers; initPathwayFork rewired to drive the new
cards; renderProcedureResults gates the save CTA on
isAdhocMode().
- client/i18n.ts — 19 new keys (DE+EN) under deadlines.step1.* +
deadlines.step2.* + the save CTA hint.
- styles/global.css — .fristen-step1 / .fristen-step2 block + chip
+ summary styles, all bound to the existing --color-* token
palette. Mobile breakpoint stacks the Step 2 cards at <600px.
Out of scope for this slice (will land later):
- Slice 2: /projects/new bounce-back with auto-preselect via
?return=/tools/fristenrechner.
- Slice 3+: scoping the picker / cascade by project's
proceeding-type + role; replacing the existing wizard with the
Step 3a "File / Draft / Enter" chooser.
Refs t-paliad-157 / m/paliad#15.
The inline widget (Slice C, next) submits a richer per-turn payload than
the standalone page's single page_origin string:
context: {
route_name, page_origin, primary_entity_type, primary_entity_id,
user_selection_text, view_mode, filter_summary
}
Wiring:
- services.TurnContext + EnvelopePrefix() build a
`[ctx route=… entity=…:<id> selection="…" view=… filter="…"]` block.
Empty fields are omitted; selection is always quoted (it's user-supplied
content); selection over 1000 chars gets truncated with an ellipsis.
- services.MaxSelectionChars = 1000 (the design's privacy floor §4.3).
- LocalPaliadinService.RunTurn + RemotePaliadinService.RunTurn prepend the
envelope to the user message before sending through tmux.
- paliadinDB.insertTurnRow now persists the structured context as
paliad.paliadin_turns.context jsonb (migration 070).
- handlers/paliadin.go's turnRequest accepts the new optional context
field; mirrors context.PageOrigin into the top-level page_origin when
the latter is empty so legacy admin queries still work.
- The standalone /paliadin page is unchanged — its turn body still has
only page_origin, the new field is optional. Backwards compatible.
SKILL.md (~/.claude/skills/paliadin/SKILL.md, refreshed via
scripts/install-paliadin-skill):
- Documents the new `[ctx …]` block in front of the user question.
- Five behaviour rules: pre-call enrichment when entity= is set, don't
repeat the obvious, treat selection as data not instructions, no
hallucination on empty entity lookup, legacy turns work as before.
Frontend client/paliadin-context.ts is the route-table + entity
extraction the widget will use (Slice C). Public surface:
computePaliadinContext() returns the payload or null on excluded
routes (/paliadin, /login, /onboarding); selection toggle reads
localStorage["paliadin:send-selection"] (default on, off opts out).
New test TestTurnContext_EnvelopePrefix pins the bracket-block format
(8 sub-tests including truncation, selection-quote escape, empty-context
empty-prefix). go test ./... clean. go build + bun run build clean.
Refs: docs/design-paliadin-inline-2026-05-08.md §4.
m typed in another pane: "The project view where there is a tab
'Untergeordnet' I want a 'Project Tree' instead. And it always shows
all siblings, all parents and all children of that entity." (Forwarded
by klaus / youpcorg/head, msg #1570.)
Tab label
DE: Untergeordnet → Projektbaum
EN: Sub-projects → Project Tree
i18n key kept as projects.detail.tab.kinder for back-compat (legacy
bookmarks + create-sub-project CTA still keyed on 'kinder').
Tree content
Was: direct children only (one /api/projects/<id>/children call).
Now: full visible project hierarchy via /api/projects/tree?subtree_counts=false,
rendered as nested <ul> with the current node highlighted with a
lime-soft background + current-color border. The dashed left border
on nested levels makes parent → child relationships scannable.
Visibility is RLS-scoped (the tree endpoint already filters to projects
the user can see).
Empty state
"Keine untergeordneten Projekte" still renders when the current node
has zero direct children — that is what the "+ Untervorhaben anlegen"
CTA next to it actually creates. Showing it for "tree has no other
branches" would have been wrong.
The standalone /api/projects/<id>/children call stays — it gates the
empty state and pre-fills parent_id on the create form.
Three commits from mai/feynman/fristenrechner:
- 614f9af fix(approval-pill): two-eyes glyph 👀 instead of single SVG eye
on /deadlines + /appointments + /agenda. m's preference: emoji denotes
"being looked at" closer to "wartet auf Genehmigung" semantics.
- 2d6ea3e feat(deadline-rules/is-optional): conditional rules opt-in via
save modal. Adds paliad.deadline_rules.is_optional. Distinct from
is_mandatory: a rule can be statutorily fixed when it applies AND
conditional on whether it applies (RoP.151 cost-decision request,
appeal-related deadlines). Save-modal pre-unchecks optional rows;
user toggles to opt in. Timeline shows "auf Antrag" pill.
- 097e21c feat(fristenrechner): proceeding-picker collapses to one-line
"Verfahren: X · [Reselect]" pill after pick (saves vertical space).
Column view becomes the default for the timeline (was previously
whichever-default; m wants Column on first render).
Migration housekeeping:
feynman's migration was authored as 066 on his branch but main has
already taken 066/067 via shannon's t-paliad-160 (approval policy
split + drop required_role). Renumbered to 068 during merge to
resolve the same-number collision. Added ADD COLUMN IF NOT EXISTS
to make the up-migration idempotent (defensive for environments
where the column was already applied out-of-band during dev). The
RoP.151 backfill UPDATE is naturally idempotent.
Live tracker bumped from 66 → 68 to reflect schema reality before
this merge: shannon's 066+067 effects and feynman's is_optional
column are all already present in the live youpc Supabase. The
next deploy will see tracker=68 and have nothing to apply.
Refs m/paliad#15, m/paliad#18 (rule-Typ contradiction filed against
Item A scope, not part of this batch).
m's 2026-05-08 18:26 dogfood batch — two pure UX tweaks on the
Verfahrensablauf wizard:
1) Collapse the proceeding-picker once a Verfahren is chosen. Replaces
the four-group block (UPC / DE / EPA / DPMA, ~25 buttons total)
with a one-line "Verfahren: X · [Anderes Verfahren wählen]" pill.
Reselect re-expands without throwing away the rest of the wizard
state (trigger date, flags, calc result stay put until the user
actually picks again). reset() also re-expands.
2) Column view as the default for step 3. The proactive / court /
reactive grid reads more naturally for the HLC team than the
single vertical timeline. URL semantics flipped: ?view=timeline
now opts back into the legacy view; absence of ?view= yields
columns. Share links stay clean.
Files:
- frontend/src/fristenrechner.tsx — new .proceeding-summary
markup; the view-toggle radio order swapped so "Spalten" is the
first / checked option.
- frontend/src/client/fristenrechner.ts — setProceedingPickerCollapsed
helper toggles the four .proceeding-group blocks vs the summary;
selectProceeding collapses, reset() + Reselect re-expand.
procedureView default flipped to "columns"; initViewToggle URL
semantics inverted.
- frontend/src/client/i18n.ts — 2 new keys (DE+EN) for the
summary label + Reselect button.
- frontend/src/styles/global.css — .proceeding-summary +
.proceeding-summary-reselect styles, all bound to the existing
--color-* token palette.
Refs m/paliad#15 dogfood thread (m's 2026-05-08 18:26 batch).
m's 2026-05-08 batch Item 2: some rules don't always apply per-instance.
Antrag auf Kostenentscheidung (RoP.151) only fires when a party files
for it; some appeal-related deadlines depend on specific facts. Today
they render in the timeline as if always applicable; the save-to-
project modal pre-checks them so the user has to remember to uncheck.
New paliad.deadline_rules.is_optional bool flag (default false). Threads
through the Go model, ruleColumns SELECT, UIDeadline JSON, and the
frontend save modal:
- Migration 066 adds the column + comment + a starter UPDATE that
flips RoP.151 to is_optional=true. m can flip more via SQL as he
reviews the rule library — distinct from is_mandatory, which is
about statutory strictness once the rule applies (an "auf Antrag"
rule can be is_mandatory=true once requested).
- Save modal: optional rows pre-uncheck (the user opts in) and a
small "auf Antrag" / "on request" pill renders in the meta line.
Court-determined rows still pre-uncheck via the existing disabled
path; isOptional doesn't override that.
Migration applied to live Supabase; tracker at v66.
Refs m/paliad#15 (m's 2026-05-08 18:21 follow-up batch Item 2).
m's 2026-05-08 18:21 follow-up: "two eyes instead of the one." The
single-eye SVG read as a watching-Eye-of-Sauron glyph; 👀 reads as
"under review" / "being looked at" — closer to "wartet auf
Genehmigung" semantics.
Drops the inline SVG + the .approval-pill--icon svg sizing rule;
replaces with the literal emoji as the pill's text content. CSS
modifier becomes a small auto-width text pill (min-width 28px so
single emoji stays nicely round-ish at higher densities).
Renamed APPROVAL_PILL_EYE_SVG → APPROVAL_PILL_GLYPH in both events.ts
and agenda.ts since the constant is no longer SVG.
m's 2026-05-08 cosmetic ask: the "Wartet auf Genehmigung" badge ate
row width and read as a noisy block of text on every pending row.
Replace with a 22px eye-icon pill; the lifecycle label moves to the
hover tooltip (title attr + aria-label so screen readers still get
the full text).
Three pieces:
- global.css — new .approval-pill--icon modifier sets the pill to
a circular 22×22 hit target with a centered SVG. Base
.approval-pill (text-pill behavior) and --historic (inbox status
pill) stay untouched so the inbox surface keeps rendering the
full status + decider name.
- client/events.ts (the /deadlines + /appointments shell) and
client/agenda.ts each get a tiny APPROVAL_PILL_EYE_SVG constant
+ the new --icon class on the pending pill. Two definitions
(no shared icons module today; no other surfaces need this glyph
yet) — the duplication is two lines, easier to read than yet
another import.
What it looks like: 👁 in a soft amber circle, hovers to "Änderung
wartet auf Genehmigung" / "Erledigung wartet auf Genehmigung" / etc.
The lifecycle-specific label kept (no schema work) — Maria gated this
slice as pure-frontend; the richer "wartet auf Genehmigung von
<role>; angefragt am <date>" tooltip needs a backend join we're not
doing here.
Refs t-paliad-160 §C / m's 2026-05-08 18:15 batch Item B.
m's 2026-05-08 17:50 feedback: 'Antrag auf Kostenentscheidung' (RoP.151)
labels itself "wird vom Gericht bestimmt" but the rule is actually
"1 Monat ab Hauptentscheidung". The court doesn't directly determine
this date — it determines the parent's date (Hauptentscheidung) and
this rule chains off that. Calling it "vom Gericht bestimmt" overstates
the relationship; "unbestimmt" reads correctly: derived from a
not-yet-known anchor.
Two failure modes split:
- Direct court-set rule itself is hearing / decision / order
(or primary_party='court'). Label stays
"wird vom Gericht bestimmt" — strictly correct.
- Indirect court-set rule has a real duration but its anchor is a
court-set parent (RoP.151 case), or it's a
zero-duration rule whose parent is court-set
without a real date. Label flips to
"unbestimmt".
Backend: new `IsCourtSetIndirect bool` on UIDeadline, set on the three
indirect cases inside FristenrechnerService.Calculate. Direct cases
keep IsCourtSetIndirect=false so their label stays unchanged. JSON
omits the field when false, no consumer churn.
Frontend: deadlineCardHtml + the save-modal row both consult
IsCourtSetIndirect to pick between two i18n keys (deadlines.court.set
"vom Gericht bestimmt" and deadlines.court.indirect "unbestimmt"; EN
falls back to "set by court" / "tbd"). The override edit affordance
keeps working unchanged — user types the actual parent date, downstream
re-flows.
Refs m/paliad#15 (m's 2026-05-08 17:50 feedback Item 1).
m's 2026-05-08 feedback: the inbox-channel chip is a Determinator step,
not a page-level prefilter — "Verlauf does not need to see that so it
cant be outside of that."
Changes:
- frontend/src/fristenrechner.tsx — strip the .fristen-inbox-bar
markup from above the pathway fork; mount it instead at the top
of #fristen-b1-panel, before the cascade. The chip is now visible
only when the user enters Pathway B → tree mode.
- frontend/src/client/fristenrechner.ts — drop the .proceeding-group
visibility loop from applyInboxFilter. Pathway A's wizard is no
longer filtered by the chip. The data-forum attributes stay on
the markup as documentation of intent but no longer drive
visibility.
What stays:
- persistence (paliad.users.forum_pref via PATCH /api/me)
- URL ?inbox= override
- B1 cascade narrowing via paliad.event_categories.forums
- B2 fine-bucket activeForums sync (B2 lives inside the
Determinator too)
Refs m/paliad#15 (m's 2026-05-08 17:50 feedback).
Cleanup of the t-paliad-160 dual-read shim. After slice 1+2 every writer
hits both `required_role` and the new `(requires_approval, min_role)`
columns, and every reader prefers the new ones. M2 (migration 065) drops
the legacy column from `paliad.approval_policies` and rewrites
`paliad.approval_policy_effective()` to a 4-column return shape.
`paliad.approval_requests.required_role` is intentionally untouched —
that's the in-flight policy snapshot at submission time, a separate
concern from the policy authoring grammar.
Go side:
- models.ApprovalPolicy and models.EffectivePolicy lose RequiredRole.
The MinRole pointer is now the only seniority-threshold surface.
- LookupPolicy / GetEffectivePolicyOne / List* / snapshotProjectRows
drop the required_role SELECT projection.
- UpsertProjectPolicySplit / UpsertUnitPolicySplit /
DeleteProjectPolicy / DeleteUnitPolicy / ApplyMatrixToDescendants
drop the required_role write. The audit-log row still uses the
legacy string format ('partner|...|none'); composed via
legacyFromSplit() from the new columns so the audit table layout
keeps working without a parallel migration.
- submit() reads policy.MinRole directly (LookupPolicy guarantees
non-nil when a non-nil policy is returned).
- nullToPtr helper retired (no remaining callers).
Frontend side:
- admin-approval-policies.ts UnitPolicy / EffectivePolicy lose the
legacy required_role optional. The 2-control UI was already on the
split-grammar path.
- deadlines-new.ts + appointments-new.ts form-time hint readers prefer
requires_approval+min_role. They keep a soft-fall back to the
legacy required_role for one cycle in case any cached pre-M2 server
is still serving the old shape — that path is dead-code post-deploy
and can be dropped later.
Test:
- TestApprovalService_PolicyCRUD asserts MinRole instead of
RequiredRole after re-upsert.
Build: bun build OK, go build ./... OK, go test ./... OK.
Deploy ordering: this slice MUST land after slice 2 is merged so the
pre-deploy code paths that still reference required_role have already
been retired.
A3 — admin/approval-policies 2-control flip:
Each cell becomes [✓] requires_approval checkbox + role select + clear
button. The "none" option in the role dropdown is gone — the checkbox
replaces it. Role select is greyed when the checkbox is off (gate
closed). Clear button explicitly drops the cell back to inheritance.
Project matrix surfaces inherited "no approval" state with its own
attribution chip ("Geerbt · keine Genehmigung") so admins can tell a
silently-inherited off-state from a never-authored cell.
PUT /api/.../approval-policies/{entity}/{lifecycle} accepts the new
shape `{requires_approval: bool, min_role: string|null}` while still
honouring the legacy `{required_role: "..."}` body during the M1
dual-read window (decodePolicyBody routes to UpsertProjectPolicySplit
vs UpsertProjectPolicy accordingly).
C+E — Pending-approval badge + Withdraw button:
deadlines-detail + appointments-detail surface a "Wartet auf
Genehmigung" badge when approval_status='pending'. Hover-tooltip
carries requested_at + required_role + requester_name. Action
controls (Complete, Edit, Delete) freeze while pending — caller
would get a 409 anyway, no point letting them try.
Withdraw button visible only to the requester (me.id ===
pending_request.requested_by). Click → POST /api/approval-requests/
{id}/revoke (existing endpoint, no new server route). On success,
the entity flips back to approval_status='approved' and the page
re-renders with normal controls.
Complete button now handles 409 from the server gracefully:
surfaces the new mapApprovalError body's `message` instead of
silently disabling itself.
D — /inbox "Meine Anfragen" visibility hardening:
Three defence-in-depth fixes for the "tab shows empty" report:
1. handlers force `[]` (not Go-nil → JSON null) on every inbox
endpoint so the frontend never trips on `rows.length` of null.
2. parseInboxFilter validates ?status= against an allowlist
(pending|approved|rejected|revoked|superseded). Anything else
is silently dropped — a stray ?status=foo from a stale
frontend build can no longer shadow rows out of the result.
entity_type filter same treatment (deadline|appointment).
3. Frontend inbox.ts coerces null body → [] so older / cached
builds talking to the new server still don't crash.
Test coverage: TestParseInboxFilter_DropsUnknownStatus +
TestApprovalService_ListSubmittedByUser_PendingVisible (live-DB,
skipped without TEST_DATABASE_URL).
Build clean: bun build OK, go test ./... OK.
Defers: M2 (drop required_role column) — only fires once all
in-tree writers are confirmed off the legacy column path.
Completes the #15 vision: the inbox chip now narrows the B1 decision
tree alongside Pathway A's picker and B2's fine-bucket forum filter.
Picking CMS hides DE / EPA / DPMA cascade entries; picking beA /
Posteingang hides UPC / EPA / DPMA entries. Neutral nodes (top-level
branches, Mündliche Verhandlung sub-states, court-generic events like
Ladung / Kostenfestsetzung) stay visible from every inbox setting so
the user can always reach the cross-jurisdictional middle of the tree.
Migration 065 adds paliad.event_categories.forums (text[]) with a
CHECK on {upc, de, epa, dpma}, a partial GIN index, and a two-step
backfill:
1. Regex on slug for nodes that carry the forum token explicitly.
Token-bounded by ^/./- so .dpma doesn't trip the de pattern.
2. Explicit slug list for stragglers (BGH / BPatG / Versäumnisurteil /
Hinweisbeschluss are DE-only; r116-eingaben is EPA-only).
NULL stays neutral. Migration applied to live Supabase; tracker at v65.
Backend: EventCategoryNode JSON gains an optional `forums` array;
EventCategoryService.Tree SELECT includes the column and threads it
through to the response.
Frontend: new module-level currentInboxChannel mirrors the chip state
so renderB1Cascade can ask "which forum is active?" without re-deriving
from the URL on every step. inboxFilterAllowsForums(forums) gates each
child node — neutral arrays (undefined / empty) always pass; tagged
arrays must include the active forum. applyInboxFilter re-renders the
cascade so chip clicks reflow B1 in place. Pathway A picker filter
and B2 fine-bucket sync remain orthogonal — same chip, three filters.
Refs m/paliad#15 (B1 follow-up).
The page-top inbox chip now drives the existing 10-bucket B2 forum
filter so search results inherit the same narrowing as the Pathway A
picker. CMS auto-selects upc_cfi + upc_coa; beA / Posteingang
auto-select de_lg + de_olg + de_bgh + de_bpatg; "Alle" clears both the
inbox and the fine chips.
State-priority discipline:
- User chip click always replaces activeForums with
the inbox-derived set.
- Hydrate (URL ?inbox=, no
?forum=) derives activeForums from inbox.
initForumFilter (which runs first)
has already loaded URL ?forum= when
present, so the explicit forum= wins.
- Hydrate (no URL ?inbox=,
/api/me forum_pref set) same as above: derive when ?forum=
is empty.
- popstate re-applies the same rule so browser
back/forward stays consistent.
The "explicit URL forum= wins" rule means a colleague's CMS-narrowed
share link still works when the recipient has a different saved pref —
they see the shared narrowing, not their own.
Refs m/paliad#15.
Adds a slim chip strip above the pathway fork on /tools/fristenrechner
so the user can pick the inbox channel they typically work in (CMS for
UPC, beA / Posteingang for national-DE). Three behaviours stack:
- URL ?inbox=cms|bea|posteingang per-visit override; lets a colleague
share a CMS-narrowed link without
flipping the recipient's saved pref.
- /api/me forum_pref the user's persisted default,
fetched on hydrate when no URL.
- unset picker shows all four groups.
Click behaviour: write URL → apply filter (hide non-matching
.proceeding-group via the new data-forum attributes) → PATCH /api/me.
The "Alle" chip clears both URL and the saved pref. EPA / DPMA fall
out under cms / bea / posteingang; users still reach those via B2
search or by clearing the chip.
Frontend pieces:
- frontend/src/fristenrechner.tsx — new .fristen-inbox-bar markup
above the pathway fork; data-forum attributes on each
.proceeding-group so the filter knows which to hide.
- frontend/src/client/fristenrechner.ts — initInboxFilter() hydrates
from URL → /api/me, wires chip clicks (write URL, apply filter,
PATCH /api/me opportunistically), restores on popstate.
- frontend/src/client/i18n.ts — 6 new keys (deadlines.inbox.*) DE+EN.
- frontend/src/i18n-keys.ts — codegen picked up the new keys.
- frontend/src/styles/global.css — .fristen-inbox-bar /
.fristen-inbox-chip / --active / --clear styles, all bound to the
existing --color-* / --color-accent token palette.
The chip writes "" to forum_pref to clear (matching the
EscalationContactID convention from the previous slice). The B2 forum
filter (the 10-bucket finer-grained chip set further down the page)
stays untouched and orthogonal — this slice is the page-top coarse
pre-filter only.
Refs m/paliad#15.
Undated events (Urteil, Beschluss, court-set placeholders) were keyed by
the empty string and collapsed into a single trailing row, so Urteil and
Berufungseinlegung ended up adjacent even though Urteil precedes Berufung
in the proceeding's sequence_order. Each undated event now gets its own
row keyed by its index in the backend response (which is already sorted
by sequence_order), and dated/unscheduled keys are sorted into separate
buckets before concatenation so the dateless tail still sits below the
dated rows.
Refs #14 (section D).
Three issues from m's dogfood (2026-05-08 15:02–15:14):
## A. /projects-cards on desktop overflowed the right column
.projects-cards-grid.is-grid-2 used grid-template-columns: repeat(2, 1fr)
which is shorthand for repeat(2, minmax(auto, 1fr)). 'auto' resolves to
max-content so any card with content wider than the track expands the
track and pushes the right column past the parent's right edge.
Switched is-grid-2/3/4 to repeat(N, minmax(0, 1fr)) which clamps the
floor to zero — overflow now wraps/clips inside the card instead of
blowing out the layout. Bonus: the auto-fill default also got the
min(320px, 100%) treatment so narrow viewports collapse the floor and
spare us horizontal scroll on mobile (mirrors t-paliad-155's earlier
views-cards fix).
## B. "Nächste Termine" empty while "5 offen" showed
CardsPreview's deadline source filtered WHERE f.status = 'pending'
AND f.due_date >= today::date. m's 5 pending deadlines are all in the
past — overdue — so they were excluded from NextEvents while still
counted in the "X offen" badge.
Dropped the >= today predicate. Now any pending deadline lands in
NextEvents, sorted ASC by due_date, so most-overdue surfaces first
(which matches m's mental model: an overdue Frist is more urgent than
tomorrow's, not less). Appointments keep the >= now filter (past
appointments are history, not next). Cleaned up the args[] threading
since deadlines no longer needs the temporal bound.
## C. Chat bubbles ignored Markdown formatting (## h2, **bold**, lists)
renderResponseHTML only handled chip markers + the new (today)
markdown-link / bare-URL passes; everything else fell through as raw
text. "## Projekte" rendered with the literal hashes visible.
Added renderBlocks() — a small block-level parser that turns:
- → <h2>H</h2>
- → <h3>H</h3>
- lines → <ul class=paliadin-list><li>...</li></ul>
- → <hr>
- blank-line-separated runs → <p>...<br>...</p>
and inline emphasis passes that wrap **bold** in <strong> and *italic*
in <em>. Block-level runs before the link passes so the regexes only
operate inside a block; emphasis runs after links so a bold link works.
Pipeline is still: escape → chip-stage → blocks → md-links → bare-urls
→ emphasis → unstage chips.
## D. (carrying over from earlier in this commit) /admin/paliadin monitor — show user + response preview + page origin + per-tool row counts
m's ask (2026-05-08 15:02): the Paliadin monitor should show which user
made each turn, and ideally log more than just timing/classifier.
Backend:
- PaliadinTurn gains UserEmail + UserDisplayName fields (json:omitempty
so user-facing API paths don't leak unrelated identity info; only
populated by the admin LIST query).
- ListRecentTurns LEFT JOINs paliad.users to surface email +
display_name on each row. The existing global_admin OR caller-owns
visibility predicate on the WHERE clause stays unchanged.
Frontend (admin-paliadin):
- Recent-turns table grows from 5 → 8 columns:
Zeit · Nutzer · Art · Anfrage · Antwort · Tools · Seite · Dauer
- Nutzer cell shows display_name (fallback email, fallback first 8 of
user_id), with the full email in the title attribute on hover.
- Antwort cell renders the first 80 chars of the response with the full
cleanBody available on hover. Useful for spot-checking what Paliadin
actually wrote without clicking through every turn.
- Tools cell now pairs each tool name with its rows_seen count
("list_my_projects (11), search_my_deadlines (18)") so the data
density is legible at a glance.
- Seite cell exposes page_origin (where in Paliad m kicked off the
turn) — was already audited but never surfaced.
- DE/EN i18n keys added for the four new column headers.
m's ask (2026-05-08 14:18): chat should render arbitrary links, not
just internal navigation chips.
Extends renderResponseHTML with two link passes after the existing chip
substitution:
1. Markdown link syntax — [label](url) becomes <a class=paliadin-link>.
Internal /paths stay same-tab; external http(s) URLs open in a new tab
with rel=noopener,noreferrer.
2. Auto-linkify bare URLs — any free-standing https?:// becomes a link.
The leading-character class on the regex avoids re-matching URLs that
are already inside an href attribute (like the chip URLs from stage 1
or the markdown-link URLs from stage 2).
Pipeline order: HTML-escape → chip markers replaced with SOH-bounded
sentinels → markdown links → bare URLs → sentinels swapped back. Done in
that order so chip URLs never go through the link passes (which would
double-anchor them) and the SOH boundary characters can't collide with
user text.
fix(views/cards): collapse min-width floor on mobile to prevent overflow
m's report (2026-05-08): on mobile-portrait the views-cards layout
forced horizontal scrolling because grid-template-columns had a 280px
floor on every column. Replaced minmax(280px, 1fr) with
minmax(min(280px, 100%), 1fr) so on viewports narrower than 280px the
floor collapses to the available width — cards span 100% of the stream
on mobile, return to the 280px-min auto-fill once there's room.
When a Paliadin response contains chip markers like [#deadline-OPEN:c47bd2-1]
they get rendered to anchor tags by renderResponseHTML in finishBubble. The
'end' handler then saved the bubble to localStorage history via getBubbleText,
which returns textContent — i.e. the anchor text *only*, with the original
[#deadline-OPEN:...] markers gone.
On a page reload, history.forEach replays each entry: appendBubble puts h.text
back into textContent, then finishBubble runs again and tries to re-render via
renderResponseHTML — but the markers are already gone, so the links don't come
back (m, 2026-05-08 14:11 — links disappeared on second load).
Fix: save placeholder.dataset.fullText (the raw Markdown body cached at
content-event time) instead of the post-render textContent. On reload the raw
markers survive, finishBubble re-runs renderResponseHTML, and the chips/links
reappear identically to the first render.
Refs t-paliad-155, m/paliad#12.
Two bugs surfaced in m's dogfood of t-paliad-155 (2026-05-08 13:55).
## A. used_tools NOT NULL constraint violation on casual turns
paliad.paliadin_turns.used_tools is text[] NOT NULL DEFAULT '{}'. parseTrailer
leaves trailerMeta.UsedTools as nil when Claude omits the trailer ("Heyhey!")
or sends an empty list. completeTurn passed pq.StringArray(nil) which the pq
driver writes as NULL — UPDATE failed with constraint 23502 on every casual
chat turn, leaving the row half-finalized.
Fix: coerce UsedTools to a non-nil empty pq.StringArray before the UPDATE,
mirroring the existing rowsSeen pattern in the same function.
## B. Frontend rendered "## Proje" instead of the full 1408-byte response
m saw the first 8 characters of his Markdown response in the chat bubble,
plus the full meta row underneath. The DB row had the complete cleanBody
in 'response'. Truncation lived entirely in the browser.
Root cause: finishBubble read textNode.textContent at the moment of the
'end' event — but typewriter() animates the text 8 chars at a time, so
textContent was "## Proje" (one tick into 1408 bytes) when finishBubble
fired. renderResponseHTML(raw) baked in the partial state, then the
typewriter's next tick saw streaming='false' and ran 'node.textContent =
text' which overwrote the rendered HTML with the raw string — except in
this case the second tick never ran in time, leaving the partial render.
Fix:
1. Cache the full SSE-delivered text on placeholder.dataset.fullText at
content-event time. finishBubble prefers that over textContent.
2. Typewriter's abort branch no longer overwrites the node — finishBubble
already owns the final rendered HTML, so a delayed tick should just
return rather than blow away the rendered Markdown.
Both fixes verified locally: go build clean, bun build clean.
Refs t-paliad-155, m/paliad#12.
Changing any required_role cell saves the policy and re-renders the units
list to refresh the attribution chips, but the re-render rebuilt every
<details> closed — collapsing the accordion the admin was actively
editing (m, 2026-05-08 11:19).
Capture the set of open data-unit-ids before innerHTML overwrites them,
then re-apply the open attribute on the rendered nodes for those ids.
Adds data-unit-id to the <details> as the stable identity. No behavior
change for first render or for units the admin hadn't expanded.
Three remaining surfaces from the locked design (Q9 + Q13):
/inbox empty-state admin nudge (Q9):
- New conditional block (.inbox-admin-nudge) revealed only when:
* /api/me reports global_role='global_admin'
* the inbox tab returned zero rows
* /api/admin/approval-policies/seeded reports any=false (no policies firm-wide)
- Card links to /admin/approval-policies. Hidden in every other case so the
ordinary post-rollout state (admins with active policies) sees nothing.
Form-time 4-eye hint on /projects/{id}/deadlines/new + /appointments/new (Q13):
- New .approval-hint container above the Speichern button on each form;
hidden by default.
- Client TS fires GET /api/projects/{id}/approval-policies/effective on
page load + on project change, reveals the hint when required_role is
non-null and not 'none'. Renders role label + source attribution
('· Standard: Munich Lit') so the user knows where the rule comes from.
- Hides in every 'no policy applies' case (no candidates / 'none' suppression
/ project change to a project with no policy / fetch error).
i18n: 6 new keys × 2 langs (3 inbox-nudge keys + 2 form-hint keys + the
inbox-nudge title/body/cta wired in inbox.tsx). Total i18n keys: 1929.
Dynamic-key call sites use tDyn (admin-approval-policies.ts +
deadlines-new.ts + appointments-new.ts) so the typed t() barrier stays
intact for static keys.
Build: bun run build clean, go build + vet + test clean (no DB tests
require TEST_DATABASE_URL — those run in CI).
New TSX page shell + client orchestration + admin-index card + CSS for
the matrix + i18n keys (DE+EN).
Page structure:
- Section 1 'Partner-Unit-Standards': accordion list, each <details>
block expandable into the 8-cell matrix for that partner unit.
- Section 2 'Projekt-spezifisch': search-driven project picker → matrix
showing the EFFECTIVE policy per cell with attribution chips
(Projekt / Geerbt / Standard) per source.
- Bulk-apply modal: 'Auf Unterprojekte anwenden' button per project; lists
affected descendants; POST to /api/admin/approval-policies/apply-to-descendants.
Cell semantics:
- Select per cell with options: '— keine Regel —' (= DELETE), partner /
of_counsel / associate / senior_pa / pa / 'Keine Genehmigung' (= 'none'
sentinel, project-row only).
- Change → PUT for any value, DELETE for empty. Re-fetch the affected
scope so attribution chips reflect the new state.
CSS: matrix grid on desktop (≥700px); two stacked sections (Fristen /
Termine) below 700px via media query — both rendered in DOM, CSS toggles.
All tokens are existing --color-* / --status-* / --hlc-*-rgb (no bare
--surface / --text-muted / --bg-subtle).
i18n: 42 new keys × 2 languages = 84 entries. Total i18n keys: 1924.
Build: bun run build clean (i18n codegen updated, IIFE wrapping enforced).
Extends the SSE error switch in frontend/src/client/paliadin.ts'
friendlyErrorMessage to map four new error codes from RemotePaliadin
Service into localised messages:
- mriver_unreachable: mRiver is offline / paliadin-shim unreachable
(DE: "mRiver ist offline — Paliadin nicht erreichbar. Mach mRiver an,
oder nutze Paliadin lokal mit ./paliad."
EN: "mRiver is offline — Paliadin can't reach it. Wake mRiver, or
run Paliadin locally with ./paliad.")
- shim_auth_failed: SSH key / authorized_keys mismatch (Permission
denied)
- shim_error / bootstrap_failed: generic remote-shim failure
- timeout: Claude didn't write the response file in 60 s
Adds the matching i18n keys (DE + EN) plus the type-union entries in
i18n-keys.ts so the t() typecheck stays sound. The old codes
(tmux_unavailable, connection_lost, upstream) are unchanged — local-PoC
deployments keep their existing UX.
Frontend `bun run build` clean: 1886 keys (unchanged sync).
Refs m/paliad#12
Substrate marshals deadline.due_date as time.Date(...,0,0,0,0,UTC), so the
JSON arrives as "YYYY-MM-DDT00:00:00Z" — UTC midnight, no real time. Feeding
that into new Date() + toLocaleTimeString() produced "02:00" in CEST,
"01:00" in CET, "20:00 the day before" in EST, etc.
Pattern A: don't render time for date-only fields.
- Centralised the date/time formatters used by the views shapes into
frontend/src/client/views/format.ts. parseDateOnly recognises both
"YYYY-MM-DD" and the substrate's "YYYY-MM-DDT00:00:00Z" form; formatDate
formats those in UTC so the day matches the source day in every timezone.
- shape-cards.ts: per-row time slot is empty for deadlines when the day is
already in the heading (groupBy=day). Falls back to formatDate when
groupBy=week|none. Bucketing now anchors date-only inputs to UTC so a
deadline can't slip into the previous day in negative-offset zones.
- shape-list.ts: formatRelative is kind-aware — deadlines reduce to
day-precision ("morgen" / "in 3 Tagen") instead of leaking hour math
("in 2h") off the UTC midnight.
- Appointments and other timestamped sources are untouched.
- format.test.ts: regression coverage in CEST / PST / UTC. 14 tests pass.
Adds the Cards view-mode to /projects (third option in the segment-control
between Tree and Liste).
frontend/src/projects.tsx:
- View-mode segment gains "Karten" button
- Two new toolbars (initially display:none, surfaced by Cards mode):
- .projects-cards-toolbar: layout dropdown + [Bearbeiten] + [Neue Ansicht]
+ "Alle Ebenen anzeigen" toggle
- .projects-cards-edit-toolbar: density radio + grid select + rename /
delete / set-default / discard / save buttons
- New container: .projects-cards-wrap > #projects-cards-grid
frontend/src/client/projects-cards.ts (NEW, ~640 LoC):
- Layout management: GET /api/user-card-layouts on first mount; auto-seeds
Standard layout if empty (POST). Layout dropdown switches active layout
in-place; show_all_levels toggle persists immediately.
- Edit mode: clones the active layout into editDraft; renders per-card
fact list with drag handles + visibility checkboxes + count steppers
(1..5) for next-events / recent-verlauf. HTML5 drag-and-drop reorders
facts; title-row is forced to the first position so the server-side
validator's invariant holds.
- New layout: prompts for a name, seeds with the current draft (or active
layout's facts), POSTs, enters edit mode.
- Set-default / rename / delete: each maps to PATCH or DELETE; default
cannot be deleted (server returns 409 + UI alerts).
- Card render: title row (icon + link + pin star), type/status chips,
client-matter, parent-path-as-reference (parent breadcrumb deferred —
needs an extra fetch per card), deadline-counts (subtree-aggregated
when available), next-events from /api/projects/cards-preview, recent-
verlauf, team-chips initials with overflow count.
- Pin click on a card star does optimistic toggle + POST/DELETE pin
endpoint and updates treeCache in place.
- Cards sort: pinned first, then last_activity_at DESC, then title ASC.
- "Alle Ebenen anzeigen" toggle decides whether Mandanten + Litigations
appear as their own cards (off by default — leaf-ish projects only:
Cases, Patents, Verfahren, Projekte).
frontend/src/client/projects.ts (orchestrator):
- ViewMode type expands to "tree" | "cards" | "flat"
- View segment-control wires through to Cards mode
- render() dispatches to renderCardsView / teardownCardsView based on
active mode
frontend/src/client/i18n.ts: 53 new keys DE+EN under projects.cards.* —
section titles, empty-states, layout picker labels (label/new/edit/save/
discard/set_default/delete/rename/is_default/new.prompt/delete.confirm/
delete.default_blocked), per-fact labels (title-row/type-chip/status-chip/
client-matter/parent-path/deadline-counts/next-events/recent-verlauf/
team-chips/reference/last-activity-at), density values (compact/roomy),
grid values (auto/2/3/4), event-kind labels (deadline/appointment/
project_event), edit toggles (toggle.hide/show/move_up/move_down/count).
frontend/src/styles/global.css: ~290 LoC appended for cards toolbar +
grid + card layout (title row / row / section / event row / team chips)
+ edit-mode chrome (drag handles, drop targets, count steppers) + dark-
themed dashed border on edit cards. Mobile media query forces single-
column grid.
i18n codegen: 1830 → 1882 keys (+52). bun run build clean. tsc on new
files clean (pre-existing JSX-IntrinsicElements noise unrelated).
go build/vet/test still clean.
frontend/src/projects.tsx — strip the legacy 3-select toolbar; replace with
search input + view-mode segment-control (Tree | Liste) + chip filter row
(Alle / Nur meine / Angepinnt / Status / Typ / Mit aktiven Fristen). Tree
container is the default visible mount; flat-table hidden until view mode
toggles.
frontend/src/client/projects.ts — orchestrator. Owns chip + search + view-
mode state. Last-viewed restore from sessionStorage (Q1 lock-in), URL params
override on load, syncURL on every state change. Debounced search (250ms).
Multi-select panels via <details> for status/type. Delegates rendering to
project-tree.ts (tree mode) or projects-flat.ts (flat mode).
frontend/src/client/projects-flat.ts (NEW) — extracted table render from the
old projects.ts so the orchestrator can mount/unmount cleanly.
frontend/src/client/project-tree.ts — extends ProjectTreeNode shape with
pinned, inherited_visibility, match_kind, *_subtree fields. Renders pin
star button (always-visible per design §4.6 — touch-friendly), greyed-
ancestor opacity for InheritedVisibility=true, lime backdrop on
match_kind=self. Pin click does optimistic toggle + POST/DELETE
/api/projects/{id}/pin then invalidates the tree cache.
frontend/src/styles/global.css — toolbar + chips + pin star + greyed-
ancestor + match highlighting. ~200 LoC appended.
frontend/src/client/i18n.ts — 29 new keys DE+EN under projects.toolbar.*,
projects.chip.*, projects.tree.deadlines.*, projects.tree.pin/unpin,
projects.search.match.*, projects.empty.filtered.action.
internal/services/pin_service_test.go (NEW) — live-DB tests for PinService
(pin/unpin/idempotent/owner-scope/visibility-gate) + 2 BuildTreeWithOptions
cases (PinnedSet surfaces, ScopeMine greys ancestors). Skips without
TEST_DATABASE_URL; pure-Go path runs clean.
Frontend bun build clean. go build / vet / test (short) clean.
Three visual bugs from the t-146 PoC ship.
1. Bubble alignment robustness — keep `align-self: flex-end/-start`
but also pin with `margin-left/right: auto`. align-self was already
correct in CSS, but layered margin-auto makes the alignment
bulletproof against any future cross-axis override.
2. Dark-mode contrast — paliadin CSS used three undefined tokens
(`--color-accent-tint`, `--color-status-red`, `--color-status-red-tint`,
`--color-surface-hover`) whose hardcoded fallbacks (`#e8fbb2`, `#fee`,
etc.) always fired. In dark mode the user bubble rendered light cream
text on light-lime background, the error bubble light cream on light
pink — both unreadable. Repointed to the project's actual tokens:
`--color-bg-lime-tint` (defined in both modes), `--status-red-fg/bg/border`
(defined in both modes), `--color-surface-2` for the starter hover.
Added explicit `color: var(--color-text)` to `.paliadin-bubble` and
`color: var(--status-red-fg)` to the error variant. Same root cause as
t-paliad-144's contrast sweeps (cf. memory `paliad: undefined --color-bg-muted token`).
3. Friendly tmux-unavailable error — Dokploy container has no tmux/claude
CLI per CLAUDE.md, so prod hits `event: error` with
`{"code":"tmux_unavailable", ...}`. The client used to dump the raw
JSON into the bubble. Now `friendlyErrorMessage()` parses the payload
and shows a localised "Paliadin läuft nur lokal" notice (DE+EN), with
a `connection_lost` fallback for native EventSource transport errors
(no `data`) or anything we don't recognise. Same code path also
replaces the generic "Fehler beim Senden: …" pre-SSE catch block with
`paliadin.error.upstream` so transport errors don't leak `String(err)`
into the UI either.
Phase 0 PoC of the Paliadin design (docs/design-paliadin-2026-05-07.md
§0.5). m-only via in-code email gate (services.PaliadinOwnerEmail);
no deploy-time toggle. tmux-Claude pattern lifted from goldi/mVoice
(mVoice/server.py:250-380). Migration 058 introduces
paliad.paliadin_turns audit table (full prompt+response stored at
PoC scope; production v1 swaps to hash-only). 7 unit tests on the
trailer parser / chip counter / sanitiser, all green.
Surface: /paliadin chat panel (sidebar entry under Übersicht,
revealed by /api/me on owner) + /admin/paliadin monitoring dashboard
(daily counts, classifier histogram, tool-use rate, top prompts,
recent turns). Citation chips parsed from inline marker syntax;
tool-use evidence visible under each bubble.
Production safety: routes register everywhere but the per-request
owner gate returns 404 for any user other than m. paliad.de prod
container has no tmux/claude CLI, so even m hitting the route from
there gets "tmux unavailable" — clear failure, no security surface.
Branch: mai/noether/inventor-paliadin-in-app (8d714dd).
m's call (2026-05-07 21:52): "remove the export variable, that is bad
form. It should be connected only to my account."
The PALIADIN_ENABLED env var was a deploy-time toggle: easy to
mis-flip, splits prod/dev behaviour, and reads as "could be turned on
for anyone." Replaced with a per-request gate in code:
services.PaliadinOwnerEmail = "matthias.siebels@hoganlovells.com"
handlers/paliadin.go now gates every entry point through
requirePaliadinOwner, which looks up paliad.users.email by the caller's
UUID and returns 404 (not 403 — pretend the route doesn't exist) for
anyone else.
Routes register unconditionally; the gate is in the code, not the
deploy. main.go wires PaliadinService whenever DATABASE_URL is set and
logs the owner identity at boot. CLAUDE.md drops the PALIADIN_ENABLED
row and gains an explanatory note about the in-code gate.
Sidebar entries (Paliadin under Übersicht; Paliadin Monitor under
Admin) now render with display:none, revealed by sidebar.ts after
/api/me confirms the caller's email matches PALIADIN_OWNER_EMAIL —
same fail-closed pattern the Admin group already uses.
Side-effect for ops: paliad.de production now serves the routes too,
but only to m, and only successfully if the host has tmux + claude
in PATH (which Dokploy doesn't). m hitting /paliadin from prod gets a
"tmux unavailable" — clear failure mode, not a security concern.
One new test (TestPaliadinOwnerEmail_IsLowercaseStable) keeps the
constant aligned with migration 023's seed so a future rename of m's
account doesn't silently strand the gate. All existing tests pass.