Reorder Verfahrensablauf 'Browse a proceeding' so the user-input flow
matches the importance hierarchy: proceeding-type → side → appellant →
date / court / flags. Side was previously below the date input; it is
the most-defining input after proceeding-type, so it belongs above.
- frontend/src/verfahrensablauf.tsx: move .verfahrensablauf-perspective
block above .date-input-group inside step-2. Wrap the side radio
cluster in #side-radio-cluster and add a sibling #side-chip (hidden by
default) that the client swaps in when a project pre-fills the side.
Add a 1px divider between perspective and date-input groups. Update
step-2 heading from "Ausgangsdatum eingeben" → "Perspektive und Datum"
to honestly describe both controls now under the heading.
- frontend/src/client/verfahrensablauf.ts: read ?project=<id> on init,
fetch /api/projects/<id>, map our_side onto the side axis (mirrors
fristenrechner.ts ourSideToPerspective: claimant/applicant/appellant
→ claimant, defendant/respondent → defendant, else null) and render
the side row as a read-only chip + "Andere Seite wählen" override
link. The chip respects ?side= as an explicit user pick — URL wins
over project auto-fill, same precedence as fristenrechner. Override
swaps back to the radio cluster and drops ?project= from the URL.
Side-chip label is language-aware via onLangChange.
- frontend/src/styles/global.css: .verfahrensablauf-step2-divider
(1px hr between perspective and date blocks); .side-chip / -tag /
-value / -override styles mirror .proceeding-summary's chip look so
the two read as the same visual family.
- frontend/src/client/i18n.ts + i18n-keys.ts: 3 new keys
(deadlines.step2.perspective, deadlines.side.from_project,
deadlines.side.override) in DE + EN.
URL state stays backward-compatible: ?side= and ?appellant= survive
the reorder unchanged. Adding ?project= opts in to auto-fill; without
it the page behaves identically to before.
No backend / projection logic change.
Adds a firm-formatted Schriftsatz skeleton between the per-submission_code
template and the generic universal skeleton in the fallback chain. Carries
every HL paragraph + character style from the HL Patents Style .dotm
(HLpat-Heading-H1..H5, HLpat-Body-B0, HLpat-Header-Section,
HLpat-Table-Recitals-Party/Details/Roles/Sequencers, HLpat-Signature,
HLpat-Requests-Intro/Level1, HLpat-EvidenceOffering, …) and the firm
letterhead (header logo + firm-address footer), plus the full 48-key
SubmissionVarsService placeholder bag exercised in a real Schriftsatz
layout (rubrum → Betreff → Anträge → Sachverhalt → Rechtsausführungen →
Beweis → Schlussformel) with a locale-aware verification footer covering
every DE/EN alias and the rule.* legacy keys.
Resolved fallback chain after this CL:
1. per-firm per-submission_code template (submissionTemplateRegistry)
2. _firm-skeleton.docx — HL styles + placeholders (NEW)
3. universal _skeleton.docx — placeholders only
4. HL Patents Style.dotm — letterhead only
scripts/gen-hl-skeleton-template/main.go reads the source .dotm,
strips VBA macros + ribbon customizations + glossary parts, patches
[Content_Types].xml and the document rels, and replaces document.xml
with HL-styled paragraphs containing the placeholders. Keeps styles.xml,
theme/, header[12].xml, footer[12].xml, numbering.xml, settings.xml,
fontTable.xml, and media untouched so the firm typography survives.
Template uploaded to HL/mWorkRepo at
6 - material/Templates/Word/Paliad/HLC/_firm-skeleton.docx
(commit 0a41b45, blob SHA 07f7547d).
Verified end-to-end against the in-house renderer with a 48-key sample
project: every placeholder substitutes cleanly, no orphan {{ markers,
no VBA / glossary / customUI leftovers, header/footer rIds resolve.
Extension of #92 (m/paliad/issues/106). Two related polish fixes for the
submission draft editor's preview ↔ sidebar wiring.
Concern A — link persists after fill (regression coverage + UX visibility)
Audited the Go renderer: substituteInTextNodes / substituteAcrossRuns
already pass both filled and missing values through htmlPreviewWrapper,
so the <span class="draft-var" data-var="…"> wrapping is present for
every substituted placeholder regardless of source (resolved bag,
lawyer override, missing marker). What looked broken to m was a
visibility problem: the always-on rgba(198, 244, 28, 0.12) tint is
imperceptible against the serif preview prose, so a filled value
reads as plain text and the user concludes "the link is gone".
Added TestRenderHTML_WrapsOverriddenValueSameAsResolved that pins the
invariant explicitly — an override (project.case_number = "UPC_CFI_
42/2026") and a resolved value (firm.name = "HLC") both end up in
matching draft-var spans. Locks future refactors out of dropping the
wrap on either path.
CSS rewrite per m's "prose stays clean when not interacting" guidance
(issue body): drop the always-on background; on hover of a
--has-input span, layer a dotted-underline + brighter lime tint so
the click affordance reveals itself. Missing markers carry their own
[KEIN WERT: …] / [NO VALUE: …] gap-text and don't need extra visual.
Concern B — sidebar-field-focus → preview-occurrence highlight (new)
Reverse direction of the click-to-jump from #92. focusin on any
.submission-draft-var-input applies .draft-var--active to every
matching span in the preview; focusout (or focus shift via Tab)
clears them. Sticky-while-focused, not a one-shot flash — the lawyer
can scan "where does this variable land in my prose?" while the
field stays focused.
New CSS class .draft-var--active uses a brighter lime + box-shadow
ring so all occurrences pop at once. Handlers are wired in
paintVariables and re-applied at the end of both paintVariables AND
paintPreview because:
- paintVariables runs after autosave and re-creates inputs via
innerHTML, so the focusin listener attached to the old input is
gone; restoreVarFocus puts focus back programmatically without
firing focusin again. We re-apply explicitly to bridge.
- paintPreview blows away the preview HTML on every autosave, so
any prior --active class is gone too. Re-apply based on the
currently-focused sidebar input.
Files
internal/services/submission_merge_test.go — new regression test
frontend/src/client/submission-draft.ts — focus handlers + re-apply
frontend/src/styles/global.css — draft-var rewrite, --active
Hard rules
- .docx export path unchanged (Render passes nil wrap, covered by
existing TestRender_DocxOutputUnchangedByPreviewWrap).
- Both directions survive autosave-driven preview re-renders (see
paintPreview re-apply + paintVariables re-apply).
- go build ./... && go test ./internal/... && bun run build all clean.
Implements three Tier 3 primitives from curie's bulletproof completeness
audit (docs/research-deadlines-completeness-2026-05-25.md §10 T3.1, T3.2,
T3.5), per m's 2026-05-25 15:29 steer to build the full primitives
instead of documenting workarounds.
Primitive 1 — duration_unit='working_days':
Calculator walks day-by-day skipping weekends + court holidays via
HolidayService.IsNonWorkingDay. Event day is not counted; result is
always a working day for the (country, regime). Unlocks T1.8/T1.9
modeling and the R.198 / R.213 alt leg.
Primitive 2 — combine_op='max' (and 'min'):
When alt_duration_value + alt_duration_unit + combine_op are set, the
calculator evaluates both legs and picks the later (max) or earlier
(min) of the two adjusted end dates. The DB already had two rules
shaped this way ('31d OR 20wd, whichever is longer' — R.198 / R.213);
the calculator was silently dropping the alt leg.
Primitive 5 — timing='before' backward snap-to-working-day:
For backward rules (R.109.1: 1 month before oral hearing; R.109.4:
2 weeks before) the calculator now snaps to the PRECEDING working day
when the computed cut-off lands on a weekend/holiday. Forward snap
(the prior behavior) would push the cut-off past the statutory limit
and miss the deadline. Adds HolidayService.AdjustForNonWorkingDays-
Backward as the symmetric counterpart of AdjustForNonWorkingDays.
Migration 128 — DB schema:
Adds CHECK constraints on deadline_rules.duration_unit and
alt_duration_unit pinning the allowed set to days/weeks/months/
working_days. Live data audited and passes (no rows excluded).
Tests (12 new + 1 flipped):
- 5 working_days cases: forward over weekend, 20wd anchored on Fri,
across Karfreitag/Ostermontag, across year boundary, backward
from Friday, anchored on Saturday.
- 2 backward snap cases: Sun → preceding Fri; cluster Sun → Sat →
Karfreitag → Thu.
- 4 combine_op cases: max with primary winning, max with alt winning
over Christmas+Neujahr cluster, min with primary winning, NULL-alt
short-circuit.
- TestCalculateEndDate_BeforeTiming renamed and flipped from forward
(Sun → Mon, the prior wrong behavior) to backward (Sun → Fri).
No regression on existing rules: every pre-existing days/weeks/months
'after' rule still computes the same date. Frontend build + full
go test ./internal/... clean.
Slot 128 assigned per next-available convention (mig 127 = Wave 0
Tier-0 fixes, mig 128 = Wave 2 Tier-3 Slice A primitives).
Renames the procedural-event surface of paliad.deadline_rules from
"rule" wording to "procedural event" / "Verfahrensschritt" wording.
No DB change, no API change, no Go-type rename. Fully reversible.
m's locks via head (2026-05-25):
- Q1=C: cosmetic now, structural rework (Slice B) as planned t-paliad-273.
- Q2: umbrella term = procedural event / Verfahrensschritt.
- Q7: legacy {{rule.X}} placeholder aliases kept forever (@deprecated).
- Q9: Slice B filed as on-hold task immediately.
Changes:
- internal/services/submission_vars.go: emit procedural_event.* keys
alongside legacy rule.* keys with identical values. Package + function
comments updated. Function name kept (addRuleVars) to avoid coupling
Slice A to the Go-type rename which is Slice B (B.5).
- internal/services/submission_vars_aliases_test.go (new): regression
test asserts (a) every (canonical, legacy) key pair resolves to the
same string for both DE and EN; (b) NULL source columns still emit
both keys with "". Removing either guard surfaces here.
- frontend/src/client/submission-draft.ts: placeholder catalog now
shows canonical procedural_event.* labels first; legacy rule.*
entries kept as "(legacy)"-marked aliases.
- frontend/src/client/i18n.ts: admin labels updated in place
("Regeln verwalten" → "Verfahrensschritte verwalten", etc.) under
existing admin.rules.* keys; canonical admin.procedural_events.*
keys added with identical values so .tsx files can rebind in Slice B.
- frontend/src/i18n-keys.ts: auto-regenerated by build pipeline.
Design doc: docs/design-procedural-events-model-2026-05-25.md (shipped
on the inventor branch mai/cronus/inventor-procedural).
Slice B (planned, on-hold): t-paliad-273.
The Auto-mode resolved rule name was rendered as an inline-flex pill
that sat visually crammed next to the [Eigene Regel eingeben] toggle.
Promote .rule-mode-auto to a full-width block-level flex row (width:
100%, margin-top: 0.35rem) so it sits cleanly on its own line beneath
the toggle, and render the rule label via the canonical
formatRuleLabelHTML helper so the citation gets the muted-secondary
styling from rule-label.ts.
Applies to both /deadlines/new and /deadlines/:id edit form. Custom
mode (free-text input) is unaffected — the input already filled the
column.
Refs: m/paliad#98 (t-paliad-267), addendum to t-paliad-258 / m/paliad#89.
The FilterBar project_event_kind chip cluster (frontend/src/client/
filter-bar/axes.ts) renders one chip per KnownProjectEventKind via
tDyn(`event.title.${kind}`), which falls back to the raw key when the
catalog is missing the entry. Two kinds were uncovered:
- approval_decided → "Genehmigung entschieden" / "Approval decided"
- member_role_changed → "Teamrolle geändert" / "Team role changed"
Both are now present in DE + EN. i18n-keys.ts regenerated by the build.
Audit of KnownProjectEventKinds (filter_spec.go:200) vs. the catalog —
all 18 kinds now have DE + EN labels.
The Paliadin floating-button trigger was overlapping the PWA bottom-nav
on mobile because its lift rule was scoped to @media (max-width: 640px)
while .bottom-nav itself appears at @media (max-width: 767px). Phones in
landscape and small tablets between those breakpoints saw the desktop
bottom: 20px and got covered by the navbar.
Two changes:
- Widen the trigger lift breakpoint to 767px (matches .bottom-nav).
- Replace hardcoded 72px with calc(var(--bottom-nav-height) + 16px +
env(safe-area-inset-bottom, 0px)) so the math tracks the navbar
height variable already used elsewhere (e.g. dashboard-save-toast).
The drawer's full-screen rule (.paliadin-widget-drawer width: 100vw)
stays at <=640px — only the trigger lift moves.
Desktop layout (bottom: 20px) unchanged; widget open/close animation
unchanged.
Slice A Backup Mode (m/paliad#77) needs PALIAD_EXPORT_DIR set on the web
container, otherwise /admin/backups returns 503. Declare it via env
interpolation with a sensible compose-level default and mount a named
volume so backups persist across container restarts.
- env: PALIAD_EXPORT_DIR=${PALIAD_EXPORT_DIR:-/var/lib/paliad/exports}
- volume mount: paliad_exports:/var/lib/paliad/exports
- top-level: declare paliad_exports volume (default driver)
Verified: `docker compose config` resolves cleanly,
`go build ./... && go test ./internal/...` clean,
`cd frontend && bun run build` clean (no code change).
Closesm/paliad#105 once Dokploy auto-redeploys.
The /inbox surface drops "Genehmigungen" framing in favour of "Inbox"
and renders the unified feed.
- shape-list.ts: factor renderApprovalRow out of renderApprovalList so
it can be reused alongside renderProjectEventInboxRow inside the new
renderInboxList (row_action="inbox"). Project_event rows show a
compact stream layout with an Öffnen link pointing at the right
project tab (deadlines / appointments / notes).
- filter-bar gets two new axes: unread_only (binary chip cluster) +
inbox_focus (4-chip coarse cluster: Alles / Genehmigungen / +Termine
/ +Fristen). Both round-trip via url-codec; inbox_focus translates
to (sources, project_event.event_types, approval_request.entity_types)
at the bar's resolve step (applyInboxFocusOverlay).
- FilterSpec gains a top-level unread_only flag; the bar writes it
when the user toggles the chip; the server overlays the cursor.
- /inbox header: new "Alles als gelesen markieren" button POSTs
/api/inbox/mark-all-seen with up_to=<newest visible row> for
race-safety against a second tab.
- INBOX_AXES adds project + project_event_kind as advanced override
chips so power users can still narrow per kind.
- i18n: inbox.title.feed / inbox.heading.feed / inbox.action.mark_all_seen
/ inbox.action.open / inbox.empty.feed / views.bar.unread_only.* /
views.bar.inbox_focus.* (DE + EN).
- url-codec round-trip tests for the two new axes.
Substrate changes that turn /inbox from approvals-only into the
unified notification surface m asked for.
- Migration 126: paliad.users.inbox_seen_at (high-watermark read cursor;
pending approval_requests bypass it per design §3).
- KnownProjectEventKinds gains note_created, our_side_changed,
deadline_updated/deleted, deadlines_imported. New
InboxProjectEventKinds curated subset (head's Q1=A lock).
- InboxSystemView spans [approval_request, project_event]; defaults to
past 30 days, newest first, row_action="inbox".
- view_service.allowedProjectEventKinds drops *_approval_* audits when
ApprovalRequest is also in spec.Sources (no double-count).
- RunSpec resolves the caller's inbox_seen_at once and threads it
through viewSpecBounds; runProjectEvents excludes self-authored
events and rows older than the cursor when unread_only is set.
Decided approval_requests follow the cursor; pending always survives.
- ApprovalService.UnseenInboxCountForUser (unified badge count) +
MarkInboxSeen + InboxSeenAt service methods.
- GET /api/inbox/count returns the unified count; new
POST /api/inbox/mark-all-seen advances the cursor (optional up_to=).
Tests cover the InboxSystemView shape, the audit-dedup helper, the
isApprovalAuditKind matcher, and the no-narrow-no-approvals nil path.
Slice A complete. Builds on the additive backend constants (commit
34e3d71) by shipping the user-facing surface.
# Pure helpers (no DOM)
frontend/src/client/date-range-picker-pure.ts (190 LoC) — TimeSpec
shape, ALL_HORIZONS / PAST_HORIZONS / NEXT_HORIZONS registries,
horizonBounds (mirrors view_service.go), isValidHorizon, isValidISODate
(strict — rejects 2026-02-30 etc.), validateCustomRange, parseURL /
serializeURL (canonical ?horizon=...&horizon_from=...&horizon_to=...
with default-omission), isDefault.
frontend/src/client/date-range-picker-pure.test.ts (38 bun tests,
118 expect calls): registries, horizon bounds for all 14 values,
ISO-date validity rejects calendar-impossible dates, validateCustomRange
on every error branch, parseURL fallback to default, serializeURL
default-omission + key-override + custom-bounds, full round-trip.
# DOM mount
frontend/src/client/date-range-picker.ts (290 LoC) — mountDateRangePicker
returns {element, getValue, setValue, close, destroy}. Trigger button
in a .multi-anchor wrapper, popover panel reusing .multi-panel
positioning. Symmetric chip row: past fan (right-aligned) | ALLES
centre (target glyph U+2316) | next fan. 'Anpassen' chip toggles an
inline date-pair editor with Apply / Cancel + a live validation
message that surfaces only the meaningful 'inverted range' error
during typing (empty/format errors are visible via the disabled
Apply button). Outside-click + Esc close the popover, focus returns
to the trigger. setValue lets the host sync from URL changes.
# Filter-bar wiring
frontend/src/client/filter-bar/axes.ts:renderTimeAxis — the disabled
'Anpassen' stub (t-paliad-163 Phase 2 placeholder) is gone; the axis
mounts the picker instead. New default presets surface 6 chips +
ALLES centre + Anpassen, plus the per-surface timePresets override
filters down to whatever subset the surface declares. 'any' still
maps to BarState.time = undefined to keep the canonical URL short
and preserve the existing 'no overlay' semantics.
frontend/src/client/filter-bar/types.ts — TimeOverlay.horizon union
extended with next_1d / next_14d / next_all / past_1d / past_14d /
past_all.
frontend/src/client/filter-bar/url-codec.ts — parseHorizon accepts
the six new values; existing 9 values continue to round-trip.
frontend/src/client/filter-bar/url-codec.test.ts — round-trip
iteration extended to all 14 horizons.
frontend/src/client/views/types.ts — TimeHorizon TS mirror extended.
frontend/src/client/projects-detail.ts — horizonBounds covers the
six new values (open-ended for next_all/past_all so the upstream
filter treats nil bounds as 'no narrowing in that direction').
# i18n + retired legacy keys
frontend/src/client/i18n.ts — 30 new keys per language (date_range.*
namespace for the picker + 6 missing views.horizon.* labels for
existing dynamic-key composition in views.ts:317). Legacy
views.bar.time.* keys (10 per language) retired with a one-line
breadcrumb comment pointing at the date_range.* namespace.
frontend/src/i18n-keys.ts — regenerated by build.ts.
# CSS
frontend/src/styles/global.css — date-range-* class block (256 LoC).
Trigger button, popover panel, past/centre/next groups, custom-range
editor, mobile stack at <540px. Reuses --color-accent /
--color-accent-light / --color-bg-lime-tint / --color-border /
--color-text + .agenda-chip / .agenda-chip-active for chip styling
so every active state lights up with the same lime accent as every
other paliad filter chip — no new tokens, no fresh dark-mode
contrast risk (t-paliad-150 / fritz lesson held).
# Surfaces lit up by this single change
- /projects/:id Verlauf (filter-bar consumer)
- /views runtime
- /views/:id Custom-Views editor
- /inbox InboxFilterBar
All four pick up the picker on their next page load. Per-surface
presets (timePresets MountOpt) preserved exactly; Verlauf still
shows the past-only subset, /inbox the forward-leaning subset etc.
The custom chip that's been disabled-with-coming_soon since
t-paliad-163 now works.
# Tests + build hygiene
- go build ./... clean
- go test ./internal/services/ clean (filter_spec + new bounds test)
- bun test passes (150 tests, 8 files, 377 expect calls)
- bun run build clean (2848 i18n keys, data-i18n scan clean)
# What's NOT in this slice
- /agenda chip-row migration (Slice B).
- /admin/audit-log + /projects/:id/chart migration (Slice C).
- upckommentar-style range slicer for custom mode (Slice D, separate
task).
Replik and Duplik had parent_id = NULL with a 4-week placeholder
duration, so the projection anchored both off the proceeding's
trigger date (Klageerhebung) - both rows rendered at the same
calendar date AND before Klageerwiderung.
Migration 124 anchors Replik on Klageerwiderung
(de.inf.lg.erwidg) and Duplik on Replik, and marks both
is_court_set = true with legal_source DE.ZPO.273. The 4-week
placeholder duration is retained so the timeline gives a sane
notional date for each row; the lawyer overrides it with "Datum
setzen" once the court issues the actual period.
Each UPDATE is guarded by parent_id IS NULL so a re-apply against
a DB that already carries the fix no-ops cleanly (mig 095
convention). No new audit-log rows on idempotent re-runs.
Slot note: originally landed as 123 in an earlier iteration;
cronus's t-paliad-246 Backup-Mode migration won slot 123 in the
parallel merge race, so this migration shifted to slot 124.
ZPO citations in the migration comment per the t-paliad-264 brief:
- Klageerhebung - section 253 ZPO
- Anzeige Verteidigungsbereitschaft - section 276 Abs. 1 S. 1 ZPO
- Klageerwiderung - section 276 Abs. 1 S. 2 + section 277 ZPO
- Replik / Duplik - vom Gericht bestimmte Frist
(section 273 ZPO Anordnungskompetenz; section 282 ZPO
prozessuale Foerderungspflicht)
Verified ordering for trigger 2026-05-25:
Klage 2026-05-25 Mon
Anzeige 2026-06-08 Mon
Klageerwidg 2026-07-06 Mon
Replik 2026-08-03 Mon
Duplik 2026-08-31 Mon
Each row strictly later than the previous; Replik and Duplik no
longer collide on the same date and no longer precede the
Klageerwiderung.
Slice A backend, fully additive. Adds six new TimeHorizon constants
to make the past/future fan symmetric for the date-range picker:
next_1d, next_14d, next_all,
past_1d, past_14d, past_all
Each one-sided 'all' is distinct from the existing HorizonAll
(bidirectional unbounded, Q26-gated) and HorizonAny (no time filter
at all). next_all keeps from=today + to=nil; past_all keeps to=tomorrow
+ from=nil — half-open intervals, never crossing the boundary.
computeViewSpecBounds gets twelve explicit fan arms plus the
pre-existing any/all/custom paths. validate() accepts the six new
horizons against any scope (none of them is the unbounded substrate
scan that triggers Q26 on HorizonAll).
New tests:
- TestFilterSpec_NewSymmetricHorizonsValidate — round-trip
- TestComputeViewSpecBounds_Horizons — table of 14 cases
- TestComputeViewSpecBounds_NewHorizonsAreOneSided
- TestComputeViewSpecBounds_CustomRoundTrips
Cross-cutting Wiedereinsetzung sub-rows (PatG §123 / ZPO §233 /
EPC Art.122 / DPMA PatG §123 / UPC R.320) used to bypass the
forum-bucket chip selection by design — every chip combination
returned all five rows. m/paliad#97: chip the chips through
to triggers via legal_source inference.
- mig 123 backfills the missing deadline_rules row for trigger
207 (UPC R.320 Wiedereinsetzung, orphaned by mig 063 because
mig 092 dropped event_deadlines before that path was seeded)
and rebuilds paliad.deadline_search with a LEFT JOIN on
deadline_rules so cross-cutting trigger pills carry their
structured legal_source.
- DeadlineSearchService gains ForumToLegalSourcePrefixes (10
buckets → UPC. / DE.ZPO. / DE.PatG. / EU.EPC + EU.EPÜ)
paralleling ForumToProceedingCodes. Rule pills still narrow
by proceeding_code; trigger pills now narrow by legal_source
LIKE prefix. Multiple chips union the prefix allow-list as
expected.
- Live golden-table test gains a Wiedereinsetzung×forum matrix
plus a multi-chip union case, and the existing 4-pill assertion
is updated to the now-5-pill state (mig 063 added trigger 207).
Branch: mai/hermes/gitster-event-type-modal.
LOCKED design with head decisions (Q1=A) folded in §12. Slice plan
A/B/C reuses existing FilterSpec + RunSpec engine; no new aggregation
service. Slice A adds inbox_seen_at cursor + project_event source on
InboxSystemView + RowActionInbox dispatch in shape-list; Slice B adds
shape toggle (list/cards/calendar) + member_role_changed narrowing;
Slice C upgrades the badge + per-item dismiss.
Two related editor polish fixes.
(A) Autosave-refresh focus preservation
paintVariables() replaces every input via innerHTML, blowing away
the focused-input reference and dropping the cursor mid-edit. Fix:
capture the active variable input's data-var key + selectionStart/
End/Direction before the repaint, restore on the new element after
(by data-var lookup + setSelectionRange). Cursor stays put across
autosave, rename, and reset cycles. Works for <input> and
<textarea> via the shared selectionRange contract.
(B) Click variable in preview → jump to sidebar input
Go renderer wraps every substituted placeholder value in the HTML
preview with <span class="draft-var" data-var="key">…</span>.
Implemented via a valueWrapperFn plumbed through
substituteInDocumentXML → substituteInTextNodes /
substituteAcrossRuns → replacePlaceholders. RenderHTML passes
htmlPreviewWrapper which marks values with three PUA sentinels
(U+E100/U+E101/U+E102) that emitTextWithDraftVars converts to the
span pair inside docXMLToHTML. Missing-marker text is wrapped too
so a clicked [KEIN WERT: foo] jumps to the empty field.
Render() (.docx export) passes nil for wrap → output is byte-
identical to pre-261. New test
TestRender_DocxOutputUnchangedByPreviewWrap asserts the .docx never
carries draft-var/data-var markup or PUA sentinels.
Client wireDraftVars() adds .draft-var--has-input only to spans
whose key resolves to a sidebar input — derived variables (e.g.
today.iso) stay non-clickable. Click handler:
scrollIntoView(smooth, center) → focus + select after 50ms →
1.2s lime flash on the row.
Keyboard accessible (Enter / Space) with role=button + aria-label.
CSS adds a subtle lime tint to every .draft-var so the user sees
what was substituted; --has-input layers cursor: pointer + brighter
hover background. Flash animation respects prefers-reduced-motion
via a steps(1, end) fallback.
Tests: TestRenderHTML_ExtractsParagraphsAndFormatting updated to
assert the new span wrap. New tests for missing-marker wrap +
.docx-path-untouched. Go + frontend builds clean.
Approach A: stack vertically. At single-column widths the variable
editor was sticky + max-height: calc(100vh - 2rem), so it stayed
pinned at the top of the viewport while the user scrolled down to
read the preview, visually overlaying the preview pane.
Add a media-query override that switches the sidebar to position:
static, max-height: none, overflow-y: visible at the same ≤900px
breakpoint where the grid already collapses to one column. The
sidebar now reflows above the preview, takes its natural height,
and scrolls away as the user moves down — no overlay, no
horizontal scroll. Desktop (≥901px) layout unchanged: sidebar
keeps its sticky behavior side-by-side with the preview.
Verified at 375 / 414 / 768 / 1280 px in Playwright on the
populated editor body — same renderer serves both URL shapes
(/submissions/draft/{id} and
/projects/{id}/submissions/{code}/draft/{id}).
t-paliad-258. m's verdict on t-paliad-251's rule UI: "too many options"
(4 'Oral hearings' across courts, etc.). Replace the full deadline_rules
catalog dropdown + sort selector with a binary model and unify the rule
display contract across every surface that prints a rule label.
Binary Rule field on the deadline form
- Auto (default): rule_id is derived from the chosen Type. The resolved
rule renders read-only as 'Auto | <Name · Citation>' next to the
field. No catalog picker, no sort options.
- Custom: free-text input. Stored as deadlines.custom_rule_text (new
nullable column, migration 122). Mutually exclusive with rule_id at
the persistence boundary.
- Toggle link flips between modes. Re-toggling to Auto re-resolves from
the current Type — no stale state.
Schema + service (additive)
- migration 122 adds paliad.deadlines.custom_rule_text (nullable).
Existing rows: empty custom_rule_text + non-null rule_id = Auto-
equivalent. Both NULL = "keine Regel" (consistent with today).
- models.Deadline.CustomRuleText + service SELECTs include the column.
- CreateDeadlineInput accepts custom_rule_text; the service drops it
when rule_id is set (catalog wins; simple invariant at the boundary).
- UpdateDeadlineInput grows a {RuleSet, RuleID, CustomRuleText} triple.
RuleSet=true is the discriminator so absent fields don't overwrite
the row (PATCH semantics). RuleID and CustomRuleText are mutually
exclusive in one request; service rejects "both set".
- EventListItem (the /api/events union) carries CustomRuleText so list
surfaces can render it.
Frontend: deadlines-new
- Drop the rule <select>, the by_proceeding/by_court/alpha sort
dropdown, the override-warning slot, and the collapsed-by-Regel Typ
view. Strip the (Rule→Type) auto-fill machinery — direction is now
one-way (Type → Auto-resolved Rule).
- Keep Type→Rule resolution: resolveAutoRuleForType picks the canonical
rule by project's proceeding, then jurisdiction match, then first
candidate. Same logic, just re-aimed at the read-only display.
- Standardtitel preserves the chain (event type → Auto rule label →
Custom text → proceeding → fallback) so the recipe still produces a
sensible title even when Custom is used.
Frontend: deadlines-detail
- Read-only display: catalog rule → Name · Citation, else
custom_rule_text + Custom badge, else legacy rule_code, else "—".
- Edit mode: mirror the create form with the Auto/Custom toggle.
enterEdit initialises the mode from the persisted deadline; Save
PATCHes with rule_set:true + the chosen rule pointer.
Rule-label addendum (m's 14:31 follow-up)
- Canonical contract everywhere: Name primary, Citation muted secondary
("Notice of Appeal · UPC.RoP.220.1"). Custom rules render the text
with a "Custom" pill.
- New frontend/src/client/rule-label.ts exports formatRuleLabel /
formatRuleLabelHTML / formatCustomRuleLabelHTML — one helper per
shape (plain text vs muted-citation HTML).
- Wired into: deadlines-new Auto display, deadlines-detail read +
Standardtitel, events.ts ruleDisplay (REGEL column on /events),
projects-detail.ts Fristen table, views/shape-list.ts generic
rule column.
- Verfahrensablauf (views/verfahrensablauf-core.ts) already renders
name + citation chip separately and matches the canonical pattern;
no change needed. Schriftsätze table is column-shaped (name + code
in distinct columns) and out of scope per the addendum.
CSS
- New .rule-mode-auto / .rule-mode-custom / .rule-label-* family.
- Drop the dead .rule-sort-select rule and the .event-type-collapsed*
family (retired with the catalog dropdown).
i18n
- DE+EN. Remove 10 stale keys (rule.none, autofill, autofill_inline,
mismatch, override, override_warn, sort.*). Add 6 (auto_no_match,
auto_pick_type, custom_badge, custom_placeholder,
mode.toggle_to_auto, mode.toggle_to_custom).
Build hygiene
- go build + go test ./internal/... clean.
- frontend bun build clean (2803 keys, scan clean).
Out of scope (per issue)
- Promoting Custom entries back to the catalog ("save as new rule").
- Filtering/searching custom_rule_text in deadline lists.
- Touching the event-type browse modal (Part 1 of #82 — that stays).
Files
- internal/db/migrations/122_deadlines_custom_rule_text.{up,down}.sql
- internal/models/models.go
- internal/services/deadline_service.go (Create+Update+SELECT)
- internal/services/event_service.go (union projection)
- frontend/src/client/rule-label.ts (new helper)
- frontend/src/client/deadlines-new.ts (rewrite)
- frontend/src/client/deadlines-detail.ts (Auto/Custom editor + display)
- frontend/src/client/events.ts (REGEL column)
- frontend/src/client/projects-detail.ts (Fristen table cell)
- frontend/src/client/views/shape-list.ts (generic rule column)
- frontend/src/client/i18n.ts + i18n-keys.ts (DE+EN delta)
- frontend/src/deadlines-new.tsx (strip dropdown+sort, add toggle)
- frontend/src/deadlines-detail.tsx (Auto/Custom edit slots)
- frontend/src/styles/global.css (rule-mode + rule-label families)
Issue: m noticed the submission generator's preview still shows the raw
HL Patents Style .dotm letterhead for every submission_code that has no
per-firm template. Confirmed live: paliad.de's /healthz is green, the
preview path and /generate path both flow through resolveSubmissionTemplate,
and the only code wired in submissionTemplateRegistry is de.inf.lg.erwidg
(t-paliad-241). For every other code, the fallback was the bare letterhead
with zero placeholders — exactly what m observed.
Fix: slot a universal _skeleton.docx between the per-firm code-specific
template and the macro-only HL Patents Style:
per-firm/{code}.docx → _skeleton.docx → HL Patents Style.dotm
The skeleton carries every placeholder SubmissionVarsService resolves
(all 48 keys across firm.*, today.*, user.*, project.*, parties.*, rule.*,
deadline.*) without baking in submission_code-specific prose, so any
code lands with variables substituted instead of the bare letterhead.
Changes:
- scripts/gen-skeleton-submission-template/main.go: byte-reproducible
.docx generator mirroring gen-demo-submission-template but with a
code-agnostic body (no Klageerwiderung "I./II./III." structure, a
single [Schriftsatztext] block the lawyer replaces). One run per
placeholder so the renderer's pass-1 substitution catches every token.
- internal/handlers/files.go: register slug submission/_skeleton.docx +
fetchSubmissionSkeletonBytes helper (same stale-while-revalidate
semantics as the existing per-code and HL-Patents-Style fetchers).
- internal/handlers/submission_drafts.go: insert the skeleton lookup
between fetchSubmissionTemplateBytes (per-firm code) and
fetchHLPatentsStyleBytes (bare letterhead). HL Patents Style remains
the final fallback for resilience if mWorkRepo is unreachable.
The companion _skeleton.docx is committed to m/mWorkRepo at
6 - material/Templates/Word/Paliad/HLC/_skeleton.docx (commit f2659e4)
so the file proxy can fetch it on first request.
Build hygiene: go build ./... clean, go test ./internal/... clean,
bun run build clean.
Replaces the misleading Proaktiv/Reaktiv column pair with a static
"Unsere Seite" / "Gericht" / "Gegnerseite" axis ("WE always on the
left", per m's t-paliad-257 ask). The side toggle now drives row
PLACEMENT into the ours/opponent buckets — the column labels stay
truthful regardless of which physical party occupies them.
Old framing lied half the time: Klägerseite is sometimes proactive
(filing the claim) and sometimes reactive (responding to a CCR),
so "Proaktiv (Klägerseite)" was wrong whenever the user's perspective
flipped. New axis is purely positional with semantic labels.
Changes:
- frontend/src/client/views/verfahrensablauf-core.ts:
• ColumnsRow fields proactive/reactive → ours/opponent.
• renderColumnsBody picks static "Unsere Seite" / "Gegnerseite"
labels — no more variant-by-side label keys.
• bucketDeadlinesIntoColumns routes the user's party into `ours`
when opts.side ∈ {"defendant"}; default (null) keeps the legacy
"we are claimant" fallback so claimant-on-left layout survives.
- verfahrensablauf-core.test.ts: rewritten expectations on the new
ours/opponent fields. Added two new tests pinning the WE-on-left
semantics and the side+appellant interaction (side=defendant +
appellant=claimant → "both" collapses into opponent).
- fristenrechner.ts: wires currentPerspective into renderColumnsBody
as `side` so the columns honour the chip-strip perspective.
Without this, a defendant-perspective user would see claimant
filings under the "Unsere Seite" header — the old code didn't
need the wire-up because the labels weren't perspective-aware.
- i18n.ts: replaces deadlines.col.proactive(.defendant) +
deadlines.col.reactive(.claimant) with deadlines.col.ours +
deadlines.col.opponent ("Unsere Seite"/"Client Side",
"Gegnerseite"/"Opponent Side"). Court key unchanged.
- i18n-keys.ts: regenerated key union.
- global.css: .fr-col-proactive/.fr-col-reactive renamed to
.fr-col-ours/.fr-col-opponent.
Out of scope (kept intact):
- Side and appellant URL-state plumbing.
- Appellant selector for Appeal-type proceedings (separate axis).
- Project-default side-from-our_side wiring — /tools/verfahrensablauf
has no project context, and /tools/fristenrechner already does this
via applyOurSidePredefine().
Build: bun run build clean (2794 keys), go build ./... clean.
Tests: 112 frontend tests pass (was 110, +2 new); all Go tests
cached green.