Compare commits

..

8 Commits

Author SHA1 Message Date
mAi
001542a3ce mAi: #113 - fix admin rules list 'undefined' proceeding name
ProceedingType TS interface in admin-rules-list.ts and admin-rules-edit.ts
declared `name_de` but the Go ProceedingType model serialises `db:"name"`
as JSON key `name` (DE is the primary on the wire). Result: `pt.name_de`
was undefined for every row, so `${pt.code} · ${pt.name_de}` produced the
literal "upc.apl.cost · undefined" in the list (and the same in proceeding
selects of the edit page).

Frontend-only fix:
- Rename the field to `name` to match the API contract.
- Guard the label builder: if the active-language name is missing, fall
  back to just the proceeding code rather than rendering "code · " (or
  worse, the original "code · undefined" string).

Other admin pages that fetch /api/proceeding-types-db (deadlines-new,
deadlines-detail, project-form, fristenrechner) already read `pt.name`
correctly, so the bug was scoped to these two files. TriggerEvent's
`name_de` field is real and stays untouched.
2026-05-25 16:52:07 +02:00
mAi
b1340e2be4 Merge: t-paliad-278 — date-range picker 3-column layout Past/NOW/Future (m/paliad#110) 2026-05-25 16:46:59 +02:00
mAi
1292aa575d Merge: t-paliad-265 — per-event-card choices Slice A+B (popover + CCR + projection engine, mig 129) (m/paliad#96) 2026-05-25 16:46:15 +02:00
mAi
4f910e31ea mAi: #110 - t-paliad-278 — 3-column date-range picker (Past/NOW/Future, closeness-to-NOW sort)
Restructures atlas's #79 horizontal row into 3 vertical columns: Past
(left), NOW (middle), Future (right). Each column sorts by closeness
to NOW (closest at top, farthest at bottom) — the picker now reads as
a spatial map of time around the current moment instead of a flat
horizontal fan.

Layout

  Vergangenheit          ⌖              Zukunft
  Letzte 7 Tage          Heute          Nächste 7 Tage
  Letzte 30 Tage         Alles          Nächste 30 Tage
  Letzte 90 Tage                        Nächste 90 Tage
  Ganze Vergangenheit                   Ganze Zukunft

Changes

- date-range-picker.ts — renderPanel builds .date-range-grid with
  three vertical .date-range-col children. Past column iterates
  PAST_HORIZONS reversed (past_1d → past_all top-to-bottom). NOW
  column hosts next_1d ("Heute") + any ("Alles") plus a ⌖ glyph
  header. Future column iterates NEXT_HORIZONS minus next_1d (which
  moved to NOW). Legacy "all" horizon still lights up the Alles chip
  for saved-Custom-View back-compat.
- global.css — replace .date-range-row/.date-range-fan/.date-range-
  center{,-btn,-glyph,-label} with .date-range-grid + .date-range-col
  + .date-range-col-heading. Chips stretch to 100% column width for a
  clean vertical stack. Panel widened from 32rem to 34rem so "Ganze
  Vergangenheit" never wraps. Mobile (max-width 540px) collapses the
  grid to a single column, preserving in-column sort.
- i18n.ts — next_1d label fixed from "Morgen"/"Tomorrow" to "Heute"/
  "Today". next_1d's bounds are [today, tomorrow) = single-day today,
  so the prior label was semantically wrong; renaming aligns the
  label with the bounds and matches m's "Heute" spec for the NOW
  column.
- axes.ts — DEFAULT_TIME_PRESETS updated to match m's spec (4 past +
  Heute + Alles + 4 future + custom). projects-detail.ts continues
  to override via timePresets for its past-only Verlauf surface.

12 horizon values in the union remain unchanged — PAST_HORIZONS /
NEXT_HORIZONS registries and parseURL still accept past_1d / past_14d
/ next_14d for back-compat with saved URLs; the default picker UI
just no longer surfaces chips for them. Surfaces that want the
finer granularity can opt back in via timePresets.

Verification

- bun test src/client/date-range-picker-pure.test.ts — 38 pass
- bun run build — i18n + branding + bundle clean
- go build ./... — clean
- go test ./internal/... — pass
2026-05-25 16:45:30 +02:00
mAi
930771a898 Merge: t-paliad-275 — HL-formatted skeleton template with placeholders (m/paliad#107) 2026-05-25 16:37:34 +02:00
mAi
f2fbf93adf feat(submissions): HL-formatted skeleton template with placeholders (t-paliad-275)
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.
2026-05-25 16:35:38 +02:00
mAi
7368e7012b Merge: t-paliad-274 — bidirectional draft editor link + click-field-highlights (m/paliad#106) 2026-05-25 16:34:28 +02:00
mAi
d4df81e374 mAi: #106 - t-paliad-274 — bidirectional draft editor link + click-field-highlights
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.
2026-05-25 16:32:45 +02:00
11 changed files with 789 additions and 160 deletions

View File

@@ -51,7 +51,10 @@ interface Rule {
interface ProceedingType {
id: number;
code: string;
name_de: string;
// `name` is the German display name on the wire; the Go `ProceedingType`
// model serialises `db:"name"` as JSON key `name`. Don't reach for
// `name_de` — that field does not exist in this payload (m/paliad#113).
name: string;
name_en: string;
}
@@ -169,7 +172,8 @@ function fillProceedingSelect(selectId: string, list: ProceedingType[]) {
for (const pt of list) {
const opt = document.createElement("option");
opt.value = String(pt.id);
opt.textContent = `${pt.code} · ${getLang() === "en" ? pt.name_en : pt.name_de}`;
const name = getLang() === "en" ? pt.name_en : pt.name;
opt.textContent = name ? `${pt.code} · ${name}` : pt.code;
sel.appendChild(opt);
}
}

View File

@@ -29,7 +29,11 @@ interface Rule {
interface ProceedingType {
id: number;
code: string;
name_de: string;
// `name` is the German display name on the wire; the Go `ProceedingType`
// model serialises `db:"name"` as JSON key `name` (the schema treats DE
// as primary). EN lives in `name_en`. Don't reach for `name_de` — that
// field does not exist in this payload (cf. m/paliad#113).
name: string;
name_en: string;
category: string;
}
@@ -125,7 +129,12 @@ function proceedingLabel(id: number | null | undefined): string {
if (id == null) return "—";
const pt = proceedings.find((p) => p.id === id);
if (!pt) return `#${id}`;
const name = getLang() === "en" ? pt.name_en : pt.name_de;
const name = getLang() === "en" ? pt.name_en : pt.name;
// Guard against a proceeding row that's missing the active-language
// name (or against a stale field-name mismatch slipping back in).
// Show the code on its own rather than "code · undefined" — that
// literal string is the smell that surfaced this bug (m/paliad#113).
if (!name) return pt.code;
return `${pt.code} · ${name}`;
}
@@ -153,7 +162,8 @@ async function loadProceedings(): Promise<void> {
for (const pt of proceedings) {
const opt = document.createElement("option");
opt.value = String(pt.id);
opt.textContent = `${pt.code} · ${getLang() === "en" ? pt.name_en : pt.name_de}`;
const name = getLang() === "en" ? pt.name_en : pt.name;
opt.textContent = name ? `${pt.code} · ${name}` : pt.code;
sel.appendChild(opt);
}
}

View File

@@ -191,25 +191,37 @@ export function mountDateRangePicker(opts: MountOpts): PickerHandle {
function renderPanel(): void {
panel.replaceChildren();
// Three groups in a single row: past fan / ALLES centre / next fan.
const row = document.createElement("div");
row.className = "date-range-row";
// Three vertical columns: Past (closest→farthest top→bottom),
// NOW (Heute + Alles), Future (closest→farthest). The grid
// visualises time as space around NOW — each column's top is
// closest to the current moment, bottom is furthest away.
const grid = document.createElement("div");
grid.className = "date-range-grid";
const pastGroup = renderFan(
PAST_HORIZONS.filter((h) => presets.includes(h)),
// Past column: PAST_HORIZONS registry is outermost→innermost
// (past_all → past_1d); reverse for closeness-to-NOW ordering
// (past_1d at top, past_all at bottom).
const pastCol = renderColumn(
"past",
t("date_range.fan.past.label"),
[...PAST_HORIZONS].reverse().filter((h) => presets.includes(h)),
);
const centerGroup = renderCenter();
const nextGroup = renderFan(
NEXT_HORIZONS.filter((h) => presets.includes(h)),
"next",
const nowCol = renderNowColumn();
// Future column: NEXT_HORIZONS registry is already in closeness
// order (next_1d → next_all). next_1d moves to the NOW column as
// "Heute" (semantically just-today, single-day window), so the
// future column skips it.
const futureCol = renderColumn(
"future",
t("date_range.fan.future.label"),
NEXT_HORIZONS.filter((h) => h !== "next_1d" && presets.includes(h)),
);
if (pastGroup) row.appendChild(pastGroup);
if (centerGroup) row.appendChild(centerGroup);
if (nextGroup) row.appendChild(nextGroup);
if (pastCol) grid.appendChild(pastCol);
if (nowCol) grid.appendChild(nowCol);
if (futureCol) grid.appendChild(futureCol);
panel.appendChild(row);
panel.appendChild(grid);
// Custom-range section ("Anpassen"). Toggle button + collapsible
// date-pair editor below.
@@ -218,49 +230,57 @@ export function mountDateRangePicker(opts: MountOpts): PickerHandle {
}
}
function renderFan(horizons: readonly TimeHorizon[], side: "past" | "next"): HTMLElement | null {
function renderColumn(
side: "past" | "future",
heading: string,
horizons: readonly TimeHorizon[],
): HTMLElement | null {
if (horizons.length === 0) return null;
const group = document.createElement("div");
group.className = `date-range-fan date-range-fan--${side}`;
group.setAttribute("role", "group");
group.setAttribute("aria-label", side === "past"
? t("date_range.fan.past.label")
: t("date_range.fan.future.label"));
const col = document.createElement("div");
col.className = `date-range-col date-range-col--${side}`;
col.setAttribute("role", "group");
col.setAttribute("aria-label", heading);
const head = document.createElement("div");
head.className = "date-range-col-heading";
head.textContent = heading;
col.appendChild(head);
for (const h of horizons) {
group.appendChild(makeChip(h));
col.appendChild(makeChip(h));
}
return group;
return col;
}
function renderCenter(): HTMLElement | null {
if (!presets.includes("any")) return null;
const wrap = document.createElement("div");
wrap.className = "date-range-center";
const btn = document.createElement("button");
btn.type = "button";
btn.className = "date-range-center-btn";
if (value.horizon === "any" || value.horizon === "all") {
btn.classList.add("date-range-center-btn--active");
}
btn.setAttribute("aria-pressed", String(value.horizon === "any" || value.horizon === "all"));
btn.dataset.testid = `${opts.surface}.date-range-chip.any`;
function renderNowColumn(): HTMLElement | null {
const showHeute = presets.includes("next_1d");
const showAlles = presets.includes("any");
if (!showHeute && !showAlles) return null;
const glyph = document.createElement("span");
glyph.className = "date-range-center-glyph";
const col = document.createElement("div");
col.className = "date-range-col date-range-col--now";
col.setAttribute("role", "group");
col.setAttribute("aria-label", t("date_range.center.label"));
const glyph = document.createElement("div");
glyph.className = "date-range-col-heading date-range-col-heading--glyph";
glyph.setAttribute("aria-hidden", "true");
glyph.textContent = "⌖"; // ⌖ POSITION INDICATOR
const label = document.createElement("span");
label.className = "date-range-center-label";
label.textContent = t("date_range.center.label");
btn.appendChild(glyph);
btn.appendChild(label);
col.appendChild(glyph);
btn.addEventListener("click", () => {
commit({ horizon: "any" }, /*closeAfter*/ true);
});
wrap.appendChild(btn);
return wrap;
if (showHeute) col.appendChild(makeChip("next_1d"));
if (showAlles) {
const allesChip = makeChip("any");
// Legacy "all" horizon also lights up Alles for back-compat
// with saved Custom Views that store the bidirectional-unbounded
// value (Q26 — parser preserves it, picker surfaces it here).
if (value.horizon === "all") {
allesChip.classList.add("agenda-chip-active");
allesChip.setAttribute("aria-pressed", "true");
}
col.appendChild(allesChip);
}
return col;
}
function makeChip(h: TimeHorizon): HTMLButtonElement {

View File

@@ -73,13 +73,16 @@ export function renderAxis(axis: AxisKey, ctx: AxisCtx, opts?: RenderAxisOpts):
type TimeHorizonValue = NonNullable<BarState["time"]>["horizon"];
// Default chip set when the surface doesn't override. Matches the
// forward-leaning bias of the legacy filter-bar default (the universal
// substrate is more often used for "what's coming up" than "what just
// happened") but now covers the full symmetric fan plus past_30d for
// quick recent-history lookups.
// Default chip set when the surface doesn't override. Mirrors m's
// 3-column picker spec (t-paliad-278): symmetric 7d/30d/90d/all fan
// per side, plus Heute (next_1d) + Alles (any) in the centre column,
// plus Anpassen. Surfaces with a tighter scope (project history is
// past-only) keep overriding via `timePresets`.
const DEFAULT_TIME_PRESETS: TimeHorizonValue[] = [
"past_30d", "past_7d", "any", "next_7d", "next_30d", "next_90d", "custom",
"past_7d", "past_30d", "past_90d", "past_all",
"next_1d", "any",
"next_7d", "next_30d", "next_90d", "next_all",
"custom",
];
function renderTimeAxis(ctx: AxisCtx, presetOverride?: TimeHorizonValue[]): HTMLElement {

View File

@@ -3062,7 +3062,7 @@ const translations: Record<Lang, Record<string, string>> = {
// /admin/audit-log to the same component.
"date_range.button.label": "Zeitraum",
"date_range.button.label.custom_range": "Von {from} bis {to}",
"date_range.horizon.next_1d": "Morgen",
"date_range.horizon.next_1d": "Heute",
"date_range.horizon.next_7d": "Nächste 7 Tage",
"date_range.horizon.next_14d": "Nächste 14 Tage",
"date_range.horizon.next_30d": "Nächste 30 Tage",
@@ -6110,7 +6110,7 @@ const translations: Record<Lang, Record<string, string>> = {
// Date-range picker (t-paliad-248). See DE block above for details.
"date_range.button.label": "Time range",
"date_range.button.label.custom_range": "From {from} to {to}",
"date_range.horizon.next_1d": "Tomorrow",
"date_range.horizon.next_1d": "Today",
"date_range.horizon.next_7d": "Next 7 days",
"date_range.horizon.next_14d": "Next 14 days",
"date_range.horizon.next_30d": "Next 30 days",

View File

@@ -610,10 +610,25 @@ function paintVariables(): void {
host.querySelectorAll<HTMLInputElement>(".submission-draft-var-input").forEach((inp) => {
inp.addEventListener("input", () => onVarChange(inp));
// t-paliad-274 (B) — focus into a sidebar field highlights every
// matching .draft-var span in the preview (sticky while focused,
// clears on blur). Survives autosave repaints because paintVariables
// is called by flushAutosave and we re-bind every render.
inp.addEventListener("focusin", () => onVarFocusEnter(inp.dataset.var ?? ""));
inp.addEventListener("focusout", () => onVarFocusLeave(inp.dataset.var ?? ""));
});
host.querySelectorAll<HTMLButtonElement>(".submission-draft-var-reset").forEach((btn) => {
btn.addEventListener("click", () => onVarReset(btn.dataset.resetKey ?? ""));
});
// After repaint, re-apply the active highlight if a field is still
// focused (paintVariables runs after autosave; the same input regains
// focus via restoreVarFocus and would otherwise emit focusin too
// late for our handler — re-apply explicitly).
const active = document.activeElement;
if (isVarField(active)) {
const key = active.dataset.var;
if (key) applyPreviewActiveHighlight(key);
}
}
function paintPreview(): void {
@@ -621,6 +636,16 @@ function paintPreview(): void {
if (!host || !state.view) return;
host.innerHTML = state.view.preview_html ?? "";
wireDraftVars(host);
// t-paliad-274 (B) — preview HTML was just blown away by innerHTML,
// so any prior --active classes are gone. Re-apply for whichever
// sidebar field is currently focused (typing in a field triggers an
// autosave round-trip that ends in paintPreview, and the user should
// see the highlight stay put across that cycle).
const active = document.activeElement;
if (isVarField(active)) {
const key = active.dataset.var;
if (key) applyPreviewActiveHighlight(key);
}
}
// t-paliad-261 (B) — click a substituted variable in the preview to
@@ -695,6 +720,48 @@ function onDraftVarClick(key: string, ev: Event): void {
flashVarRow(input);
}
// t-paliad-274 (B) — sidebar-field-focus → preview-occurrence highlight.
// Reverse direction of the click-to-jump from #92: when the user focuses
// any .submission-draft-var-input, every matching .draft-var span in the
// preview gets the --active modifier; on blur (or focus shift to a
// different field), the previous key's highlights clear and the new
// key's apply. Sticky-while-focused, not a one-shot flash — the lawyer
// can scan the preview for "where does this variable land in my prose?"
// while the field stays focused.
function onVarFocusEnter(key: string): void {
if (!key) return;
// Clear any leftover highlight before applying the new one — covers
// the focus-shift-without-blur case (Tab between fields).
clearPreviewActiveHighlight();
applyPreviewActiveHighlight(key);
}
function onVarFocusLeave(_key: string): void {
// We don't need the key here — if focus moves to a different sidebar
// input, that input's focusin will re-call apply with the new key
// (after our clearPreviewActiveHighlight). If focus leaves the sidebar
// entirely, this clears.
clearPreviewActiveHighlight();
}
function applyPreviewActiveHighlight(key: string): void {
const host = document.getElementById("submission-draft-preview");
if (!host) return;
host.querySelectorAll<HTMLElement>(
`.draft-var[data-var="${cssEscape(key)}"]`,
).forEach((el) => {
el.classList.add("draft-var--active");
});
}
function clearPreviewActiveHighlight(): void {
const host = document.getElementById("submission-draft-preview");
if (!host) return;
host.querySelectorAll<HTMLElement>(".draft-var--active").forEach((el) => {
el.classList.remove("draft-var--active");
});
}
function flashVarRow(input: HTMLElement): void {
const row = input.closest<HTMLElement>(".submission-draft-var-row");
if (!row) return;

View File

@@ -6007,16 +6007,27 @@ dialog.modal::backdrop {
font-style: italic;
}
/* t-paliad-261 (B) — substituted variables in the preview are wrapped
in <span class="draft-var" data-var="…"> by the Go HTML renderer.
.draft-var by itself shows a subtle dotted underline so the lawyer
can SEE which text was filled in from a variable. .draft-var--has-input
(added client-side when a matching sidebar input exists) layers on
the clickable affordance — pointer cursor + brighter hover background.
Non-matching draft-vars (derived variables not exposed in the
sidebar) stay visually distinct but non-interactive. */
/* t-paliad-261 / t-paliad-274 — substituted variables in the preview
are wrapped in <span class="draft-var" data-var="…"> by the Go HTML
renderer for BOTH filled values and missing-marker text. The lawyer
can click any wrapped span and jump to the matching sidebar input;
conversely, focusing a sidebar input lights up every matching span in
the preview via .draft-var--active.
Visual contract:
.draft-var — invisible by default (prose stays clean
per t-paliad-274 m's request).
.draft-var--has-input — pointer cursor; dotted underline on
hover so the click affordance reveals
itself, plus a brighter lime tint.
.draft-var--active — sticky lime highlight applied while the
matching sidebar input is focused
(t-paliad-274 reverse direction).
[KEIN WERT: …] / [NO VALUE: …] markers carry their own warning
style via .submission-draft-var-marker on the sidebar hint; in the
preview they read as obvious gap text, so .draft-var itself doesn't
need an always-on visual to flag them. */
.draft-var {
background-color: rgba(198, 244, 28, 0.12);
border-radius: 2px;
padding: 0 2px;
box-decoration-break: clone;
@@ -6031,9 +6042,21 @@ dialog.modal::backdrop {
.draft-var--has-input:hover,
.draft-var--has-input:focus-visible {
background-color: rgba(198, 244, 28, 0.45);
text-decoration: underline dotted rgba(198, 244, 28, 0.85);
text-underline-offset: 2px;
outline: none;
}
/* t-paliad-274 (B) — sticky highlight while the matching sidebar input
is focused. Brighter than the hover tint so the user's eye lands on
every occurrence at once when they click into a field. Applies to ALL
.draft-var spans for that data-var, not just one. */
.draft-var--active,
.draft-var--has-input.draft-var--active {
background-color: rgba(198, 244, 28, 0.55);
box-shadow: 0 0 0 1px rgba(198, 244, 28, 0.85);
}
/* t-paliad-261 (B) — brief lime flash on the sidebar row after a
click-jump from the preview, so the user's eye lands on the right
input even after the smooth-scroll motion. Animation restarts on
@@ -17737,9 +17760,10 @@ dialog.quick-add-sheet::backdrop {
}
.date-range-panel {
/* Inherits .multi-panel positioning + border + shadow. Widen it so
the symmetric fan + the custom editor have room to breathe. */
width: 32rem;
/* Inherits .multi-panel positioning + border + shadow. Sized so the
3-column grid holds the widest chip text ("Ganze Vergangenheit")
without wrapping while staying within the viewport on tablets. */
width: 34rem;
max-width: calc(100vw - 1rem);
top: 100%;
left: 0;
@@ -17747,88 +17771,54 @@ dialog.quick-add-sheet::backdrop {
gap: 0.75rem;
}
.date-range-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: stretch;
.date-range-grid {
/* Past / NOW / Future as three equal vertical columns. Each column
is a top-aligned chip stack so closeness-to-NOW (closest at top,
farthest at bottom) reads spatially. */
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 0.75rem;
align-items: start;
}
.date-range-fan {
.date-range-col {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
align-content: flex-start;
flex: 1 1 12rem;
flex-direction: column;
gap: 0.35rem;
min-width: 0;
}
.date-range-fan--past {
/* Past fan: outermost chip (Ganze Vergangenheit) leftmost. */
justify-content: flex-end;
.date-range-col--now {
align-items: stretch;
}
.date-range-fan--next {
/* Future fan: innermost chip (Morgen / next_1d) leftmost. */
justify-content: flex-start;
}
.date-range-center {
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
padding: 0 0.25rem;
}
.date-range-center-btn {
appearance: none;
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.1rem;
background: var(--color-surface-muted);
border: 1px solid var(--color-border);
border-radius: 0.6rem;
min-width: 4.5rem;
padding: 0.55rem 0.75rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text);
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.date-range-center-btn:hover {
background: var(--color-overlay-subtle);
border-color: var(--color-accent-light);
}
.date-range-center-btn:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
.date-range-center-btn--active {
background: var(--color-accent);
border-color: var(--color-accent);
color: var(--color-accent-dark);
}
.date-range-center-glyph {
font-size: 1.4rem;
line-height: 1;
}
.date-range-center-label {
.date-range-col-heading {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted, #71717a);
text-align: center;
padding-bottom: 0.15rem;
}
.date-range-col-heading--glyph {
font-size: 1.3rem;
line-height: 1;
letter-spacing: 0;
text-transform: none;
color: var(--color-text-muted, #71717a);
}
.date-range-chip {
/* .agenda-chip provides bg/border/radius/typography; this modifier
only tightens horizontal padding so more chips fit per row. */
padding: 0.3rem 0.65rem;
/* .agenda-chip provides bg/border/radius/typography; in the
3-column stack each chip fills its column so the closeness-to-NOW
ordering reads as a single vertical column rather than a ragged
row. */
padding: 0.35rem 0.65rem;
font-size: 0.8rem;
width: 100%;
text-align: center;
}
.date-range-chip--custom {
@@ -17915,17 +17905,14 @@ dialog.quick-add-sheet::backdrop {
color: var(--status-red-fg, #b91c1c);
}
/* Mobile: stack past / centre / next vertically so each fan gets
the full popover width. */
/* Mobile: stack the 3 columns vertically (one column per row),
preserving the closeness-to-NOW sort within each column. */
@media (max-width: 540px) {
.date-range-panel {
width: calc(100vw - 1rem);
}
.date-range-row {
flex-direction: column;
}
.date-range-fan--past,
.date-range-fan--next {
justify-content: flex-start;
.date-range-grid {
grid-template-columns: 1fr;
gap: 0.5rem;
}
}

View File

@@ -79,6 +79,24 @@ var fileRegistry = map[string]fileEntry{
RepoName: "mWorkRepo",
FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/_skeleton.docx",
},
// Firm-formatted skeleton (t-paliad-275). Carries the same 48-key
// placeholder bag as the universal _skeleton.docx, but additionally
// preserves every HL paragraph + character style from the HL Patents
// Style .dotm (HLpat-Heading-H1..H5, HLpat-Body-B0, HLpat-Header-Section,
// HLpat-Table-Recitals-*, HLpat-Signature, …) and the firm letterhead
// (header logo + firm-address footer). Slotted ahead of the universal
// skeleton in the fallback chain so any submission_code without a
// dedicated per-code template still renders as a real firm-branded
// Schriftsatz with variables substituted, rather than a plain skeleton.
// Generated via scripts/gen-hl-skeleton-template against the .dotm.
firmSkeletonSubmissionSlug: {
RawURL: "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/Paliad/" + branding.Name + "/_firm-skeleton.docx",
DownloadName: branding.Name + " — Firm Schriftsatz-Skelett.docx",
ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
RepoOwner: "m",
RepoName: "mWorkRepo",
FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/_firm-skeleton.docx",
},
}
// skeletonSubmissionSlug names the universal skeleton template inside
@@ -87,6 +105,14 @@ var fileRegistry = map[string]fileEntry{
// the same string the registry uses.
const skeletonSubmissionSlug = "submission/_skeleton.docx"
// firmSkeletonSubmissionSlug names the firm-formatted skeleton template
// inside the shared fileRegistry cache (t-paliad-275). Same placeholder
// surface as skeletonSubmissionSlug; carries HL paragraph + character
// styles from the source .dotm on top. Sits between the per-code
// template and the generic universal skeleton in the fallback chain so
// codes without a dedicated template still render with firm branding.
const firmSkeletonSubmissionSlug = "submission/_firm-skeleton.docx"
// submissionTemplateRegistry maps a deadline-rule submission_code to a
// fileRegistry slug. Lookup order matches the cronus design fallback
// chain §8: per-firm `templates/{FIRM_NAME}/{code}.docx` first, then
@@ -219,11 +245,28 @@ func handleFileRefresh(w http.ResponseWriter, r *http.Request) {
// call warms the cache synchronously from mWorkRepo via Gitea; later
// calls return immediately while a background refresh runs.
func fetchSubmissionSkeletonBytes(ctx context.Context) ([]byte, string, error) {
entry, ok := fileRegistry[skeletonSubmissionSlug]
return fetchSubmissionTemplateSlug(ctx, skeletonSubmissionSlug)
}
// fetchFirmSkeletonBytes returns the cached firm-formatted skeleton
// template bytes (HL paragraph/character styles + 48-key placeholder
// bag) plus its provenance SHA. Sits between the per-code template and
// the generic universal skeleton in resolveSubmissionTemplate's
// fallback chain (t-paliad-275). Same stale-while-revalidate caching
// as the other Gitea-backed template parts.
func fetchFirmSkeletonBytes(ctx context.Context) ([]byte, string, error) {
return fetchSubmissionTemplateSlug(ctx, firmSkeletonSubmissionSlug)
}
// fetchSubmissionTemplateSlug is the shared cache-aware fetcher used by
// the firm-skeleton and universal-skeleton accessors. Factored out so
// the two paths can't drift apart on caching semantics.
func fetchSubmissionTemplateSlug(ctx context.Context, slug string) ([]byte, string, error) {
entry, ok := fileRegistry[slug]
if !ok {
return nil, "", fmt.Errorf("file proxy: %s not registered", skeletonSubmissionSlug)
return nil, "", fmt.Errorf("file proxy: %s not registered", slug)
}
ce := getCacheEntry(skeletonSubmissionSlug)
ce := getCacheEntry(slug)
ce.mu.RLock()
hasData := len(ce.data) > 0
@@ -241,7 +284,7 @@ func fetchSubmissionSkeletonBytes(ctx context.Context) ([]byte, string, error) {
ce.mu.RLock()
defer ce.mu.RUnlock()
if len(ce.data) == 0 {
return nil, "", fmt.Errorf("file proxy: %s cache empty after fetch", skeletonSubmissionSlug)
return nil, "", fmt.Errorf("file proxy: %s cache empty after fetch", slug)
}
out := make([]byte, len(ce.data))
copy(out, ce.data)

View File

@@ -904,19 +904,25 @@ func buildSubmissionDraftView(ctx context.Context, d *services.SubmissionDraft,
// resolveSubmissionTemplate returns the .docx bytes for the given
// submission code. Lookup order matches the cronus design fallback chain
// §8 plus the t-paliad-259 universal-skeleton slot:
// §8 plus the t-paliad-259 universal-skeleton slot and the t-paliad-275
// firm-skeleton slot:
//
// 1. per-firm per-submission_code template registered in
// submissionTemplateRegistry (e.g. de.inf.lg.erwidg.docx) — code-
// specific structure plus the full variable bag.
// 2. universal _skeleton.docx — same variable bag, no submission_code-
// specific prose. Catches every code without a dedicated template
// so the editor preview / generate flow still has variables to
// substitute instead of falling through to the bare letterhead.
// 3. universal HL Patents Style .dotm — macro-only letterhead, no
// placeholders. Final fallback when even the skeleton is unreachable
// (mWorkRepo outage etc.). Preserves the pre-t-paliad-259 behaviour
// for resilience.
// 2. firm-formatted _firm-skeleton.docx — full HL paragraph + character
// styles (HLpat-Heading-H1..H5, HLpat-Body-B0, HLpat-Header-Section,
// HLpat-Table-Recitals-*, HLpat-Signature, …) preserved from the
// source .dotm, the firm letterhead header/footer, plus the full
// 48-key placeholder bag. Catches every code without a dedicated
// template so the editor still renders firm-branded output.
// 3. universal _skeleton.docx — same variable bag, no firm formatting.
// Backstop for when the firm skeleton is unreachable (e.g. a future
// firm hasn't authored one yet).
// 4. universal HL Patents Style .dotm — macro-only letterhead, no
// placeholders. Final fallback when even both skeletons are
// unreachable (mWorkRepo outage etc.). Preserves the
// pre-t-paliad-259 behaviour for resilience.
//
// The returned SHA is the cache entry's commit SHA so the export audit
// row can record provenance.
@@ -926,6 +932,11 @@ func resolveSubmissionTemplate(ctx context.Context, submissionCode string) ([]by
} else if found {
return data, sha, nil
}
if data, sha, err := fetchFirmSkeletonBytes(ctx); err == nil {
return data, sha, nil
} else {
log.Printf("submission_drafts: firm-skeleton fetch failed for code=%s, falling back to universal skeleton: %v", submissionCode, err)
}
if data, sha, err := fetchSubmissionSkeletonBytes(ctx); err == nil {
return data, sha, nil
} else {

View File

@@ -327,6 +327,40 @@ func TestRenderHTML_WrapsMissingMarker(t *testing.T) {
}
}
// TestRenderHTML_WrapsOverriddenValueSameAsResolved is the t-paliad-274
// regression: m's report on m/paliad#106 was that "When filled, the link
// disappears". The preview HTML must wrap an override value with the
// same <span class="draft-var"> as it would an unfilled placeholder, so
// the click-jump from preview→sidebar persists after the user types a
// value. There is no distinction at the renderer level between a value
// that came from the resolved bag (project / parties / deadline lookups)
// and a value the lawyer typed into the sidebar — both arrive in the
// same PlaceholderMap and both must be wrapped.
func TestRenderHTML_WrapsOverriddenValueSameAsResolved(t *testing.T) {
doc := `<w:document><w:body>` +
`<w:p><w:r><w:t>{{project.case_number}} / {{firm.name}}</w:t></w:r></w:p>` +
`</w:body></w:document>`
tmpl := minimalMergeDOCX(t, doc)
r := NewSubmissionRenderer()
// project.case_number is the typed-by-lawyer override.
// firm.name is the always-resolved value from the firm bag.
html, err := r.RenderHTML(tmpl, PlaceholderMap{
"project.case_number": "UPC_CFI_42/2026",
"firm.name": "HLC",
}, nil)
if err != nil {
t.Fatalf("render html: %v", err)
}
wantOverride := `<span class="draft-var" data-var="project.case_number">UPC_CFI_42/2026</span>`
if !strings.Contains(html, wantOverride) {
t.Errorf("expected overridden value wrapped in draft-var span (click-jump must persist after fill, t-paliad-274), got %q", html)
}
wantResolved := `<span class="draft-var" data-var="firm.name">HLC</span>`
if !strings.Contains(html, wantResolved) {
t.Errorf("expected resolved value still wrapped, got %q", html)
}
}
// TestRender_DocxOutputUnchangedByPreviewWrap asserts the hard rule from
// t-paliad-261: the .docx export path must NOT carry the preview-only
// draft-var sentinels or any draft-var span markup. Renders the same

View File

@@ -0,0 +1,450 @@
// HL-firm skeleton submission template generator (t-paliad-275).
//
// Reads HLC's "HL Patents Style" .dotm letterhead, strips its VBA
// macros and template-only artifacts, then emits a clean .docx that:
//
// 1. Preserves every HL paragraph + character style (HLpat-Heading-H1,
// HLpat-Body-B0, HLpat-Signature, HLpat-Table-Recitals-*, …) by
// keeping word/styles.xml, word/theme/*, word/numbering.xml,
// word/fontTable.xml, settings.xml, footnotes/endnotes from the
// source .dotm untouched.
// 2. Preserves the firm letterhead (logo header + firm-address footer)
// by keeping word/header[12].xml + word/footer[12].xml and the
// sectPr that references them.
// 3. Replaces word/document.xml with a Schriftsatz-shaped body that
// exercises every SubmissionVarsService placeholder (firm.*,
// today.*, user.*, project.*, parties.*, procedural_event.*, rule.*,
// deadline.*) — applying HL paragraph/character styles to each
// section so the rendered output reads as a real HL submission with
// variables substituted.
//
// Drop the output into HL/mWorkRepo at
//
// 6 - material/Templates/Word/Paliad/HLC/_firm-skeleton.docx
//
// so paliad's submission generator picks it up via the fallback chain.
// Lookup order after this CL: per-firm per-code → _firm-skeleton.docx
// (THIS file — HL formatting + placeholders) → universal _skeleton.docx
// (generic skeleton from t-paliad-259) → bare HL Patents Style .dotm
// (no placeholders). See internal/handlers/submission_drafts.go
// resolveSubmissionTemplate.
//
// Why this is firm-specific: the .dotm carries HL-licensed fonts,
// HL-branded logo media, and HLpat-prefixed style IDs. The output lives
// under the firm-namespaced directory in mWorkRepo so a future firm gets
// its own equivalent file generated against its own .dotm.
//
// Run:
//
// go run ./scripts/gen-hl-skeleton-template \
// -in /tmp/hl-patents-style.dotm \
// -out /tmp/_firm-skeleton.docx
//
// Output is byte-stable across runs for a given input (zip mtimes
// pinned).
package main
import (
"archive/zip"
"bytes"
"flag"
"fmt"
"io"
"os"
"strings"
"time"
)
func main() {
in := flag.String("in", "", "path to source HL Patents Style .dotm (required)")
out := flag.String("out", "_firm-skeleton.docx", "output .docx path")
flag.Parse()
if *in == "" {
fmt.Fprintln(os.Stderr, "gen-hl-skeleton-template: -in is required (path to HL Patents Style .dotm)")
os.Exit(2)
}
srcBytes, err := os.ReadFile(*in)
if err != nil {
fmt.Fprintln(os.Stderr, "gen-hl-skeleton-template: read source:", err)
os.Exit(1)
}
docx, err := buildDocx(srcBytes)
if err != nil {
fmt.Fprintln(os.Stderr, "gen-hl-skeleton-template:", err)
os.Exit(1)
}
if err := os.WriteFile(*out, docx, 0o644); err != nil {
fmt.Fprintln(os.Stderr, "gen-hl-skeleton-template: write:", err)
os.Exit(1)
}
fmt.Printf("wrote %s (%d bytes)\n", *out, len(docx))
}
// fixedTime pins every zip entry's mtime so successive runs over the
// same .dotm produce byte-stable output. Useful for diffing the
// generated file in PR review.
var fixedTime = time.Date(2026, 5, 25, 0, 0, 0, 0, time.UTC)
// dropPaths lists zip entries removed during the .dotm → .docx
// conversion. VBA macros + their keymap binding + the template-only
// glossary parts and ribbon customizations are all dead weight (and
// some actively trigger Word's macro-security warning) — none of them
// add anything to a placeholder-rich Schriftsatz starter.
var dropPaths = map[string]bool{
"word/vbaProject.bin": true,
"word/vbaData.xml": true,
"word/customizations.xml": true,
"userCustomization/customUI.xml": true,
"customUI/customUI14.xml": true,
"word/glossary/document.xml": true,
"word/glossary/_rels/document.xml.rels": true,
"word/glossary/fontTable.xml": true,
"word/glossary/numbering.xml": true,
"word/glossary/settings.xml": true,
"word/glossary/styles.xml": true,
"word/glossary/webSettings.xml": true,
}
// rIdsToDrop names the document-rel ids whose targets are stripped
// from the package (vbaProject, customizations.xml, glossary). They
// must vanish from word/_rels/document.xml.rels so Word doesn't choke
// on a dangling reference.
var rIdsToDrop = map[string]bool{
"rId1": true, // vbaProject.bin
"rId2": true, // customizations.xml (keymap to VBA)
"rId21": true, // glossary/document.xml
}
func buildDocx(src []byte) ([]byte, error) {
zr, err := zip.NewReader(bytes.NewReader(src), int64(len(src)))
if err != nil {
return nil, fmt.Errorf("open source zip: %w", err)
}
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for _, f := range zr.File {
name := f.Name
if dropPaths[name] {
continue
}
body, err := readZipEntry(f)
if err != nil {
return nil, fmt.Errorf("read %s: %w", name, err)
}
switch name {
case "[Content_Types].xml":
body = []byte(patchContentTypes(string(body)))
case "_rels/.rels":
body = []byte(patchRootRels(string(body)))
case "word/_rels/document.xml.rels":
body = []byte(patchDocumentRels(string(body)))
case "word/document.xml":
body = []byte(buildDocumentXML())
}
hdr := &zip.FileHeader{
Name: name,
Method: zip.Deflate,
Modified: fixedTime,
}
w, err := zw.CreateHeader(hdr)
if err != nil {
return nil, fmt.Errorf("create %s: %w", name, err)
}
if _, err := w.Write(body); err != nil {
return nil, fmt.Errorf("write %s: %w", name, err)
}
}
if err := zw.Close(); err != nil {
return nil, fmt.Errorf("finalise zip: %w", err)
}
return buf.Bytes(), nil
}
func readZipEntry(f *zip.File) ([]byte, error) {
rc, err := f.Open()
if err != nil {
return nil, err
}
defer rc.Close()
return io.ReadAll(rc)
}
// patchContentTypes rewrites the macroEnabledTemplate part type to the
// regular wordprocessingml.document type (a .dotm carries the macro
// part type even on the body part), and removes Default/Override
// entries that target now-deleted parts (vba binary, customizations,
// glossary).
func patchContentTypes(in string) string {
out := in
out = strings.ReplaceAll(out,
`<Override PartName="/word/document.xml" ContentType="application/vnd.ms-word.template.macroEnabledTemplate.main+xml"/>`,
`<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>`)
removals := []string{
`<Default Extension="bin" ContentType="application/vnd.ms-office.vbaProject"/>`,
`<Override PartName="/word/vbaData.xml" ContentType="application/vnd.ms-word.vbaData+xml"/>`,
`<Override PartName="/word/customizations.xml" ContentType="application/vnd.ms-word.keyMapCustomizations+xml"/>`,
`<Override PartName="/word/glossary/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.glossary+xml"/>`,
`<Override PartName="/word/glossary/numbering.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml"/>`,
`<Override PartName="/word/glossary/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>`,
`<Override PartName="/word/glossary/settings.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml"/>`,
`<Override PartName="/word/glossary/webSettings.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.webSettings+xml"/>`,
`<Override PartName="/word/glossary/fontTable.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.fontTable+xml"/>`,
}
for _, r := range removals {
out = strings.ReplaceAll(out, r, "")
}
return out
}
// patchRootRels drops the userCustomization (ribbon mini-tab) and the
// customUI14 extensibility relationships — both reference VBA-backed
// UI we don't ship.
func patchRootRels(in string) string {
out := in
out = stripRelByPrefix(out, `<Relationship Id="rId2" Type="http://schemas.microsoft.com/office/2006/relationships/ui/userCustomization"`)
out = stripRelByPrefix(out, `<Relationship Id="Rf8f70ab1afd0469a" Type="http://schemas.microsoft.com/office/2007/relationships/ui/extensibility"`)
return out
}
// patchDocumentRels drops the document-level rels whose targets we
// stripped (vbaProject, customizations.xml, glossaryDocument).
func patchDocumentRels(in string) string {
out := in
for rid := range rIdsToDrop {
needle := `<Relationship Id="` + rid + `" `
out = stripRelByPrefix(out, needle)
}
return out
}
// stripRelByPrefix removes the full <Relationship .../> element whose
// open tag starts with the given prefix. Tolerates either a regular
// closing tag (</Relationship>) or the more common self-closing form.
func stripRelByPrefix(s, prefix string) string {
for {
start := strings.Index(s, prefix)
if start < 0 {
return s
}
// Find end of this element (next "/>"). The .dotm always uses the
// self-closing form for Relationship elements.
end := strings.Index(s[start:], "/>")
if end < 0 {
return s
}
s = s[:start] + s[start+end+2:]
}
}
// buildDocumentXML emits a Schriftsatz skeleton that exercises every
// SubmissionVarsService placeholder (the canonical 48-key v1 contract
// + the procedural_event.* canonical names + their rule.* legacy
// aliases). The structure mirrors a real DE/UPC submission — title
// block → court → rubrum → patent reference → submission title →
// legal grounds → Sachverhalt/Anträge/Rechtsausführungen/Beweis →
// signature → locale-variant verification footer.
//
// Each placeholder lives in its own <w:r> run so the renderer's pass-1
// (format-preserving single-run replace) catches every key. HL
// paragraph styles (HLpat-Heading-H1, HLpat-Header-Section, etc.) are
// applied via pStyle, character styles via rStyle.
//
// The sectPr at the bottom is copied verbatim from the source .dotm
// so the firm header/footer references (rId16=header1, rId17=footer1,
// rId18=header2 first-page, rId19=footer2 first-page) keep resolving
// after we replace the body. pgSz/pgMar/cols/docGrid match the .dotm
// exactly — a lawyer printing this gets the same A4 layout the .dotm
// produces.
func buildDocumentXML() string {
var b strings.Builder
b.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`)
b.WriteString(`<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">`)
b.WriteString(`<w:body>`)
skeletonBanner(&b)
heading(&b, "HLpat-Heading-H1", "{{firm.name}}")
body0(&b, "Bearbeiter: {{user.display_name}}")
body0(&b, "E-Mail: {{user.email}} · Büro: {{user.office}}")
body0(&b, "Datum: {{today.long_de}} ({{today.iso}})")
body0(&b, "{{firm.signature_block}}")
headerSection(&b, "{{project.court}}")
body0(&b, "Aktenzeichen: {{project.case_number}}")
body0(&b, "Verfahrensart: {{project.proceeding.name}} ({{project.proceeding.code}})")
body0(&b, "Instanz: {{project.instance_level}}")
headerSubsection(&b, "In der Sache")
recitalsParty(&b, "{{parties.claimant.name}}")
recitalsPartyDetails(&b, "vertreten durch {{parties.claimant.representative}}")
recitalsRoles(&b, "— Klägerin / Patentinhaberin / Anmelderin —")
recitalsSequencer(&b, "gegen")
recitalsParty(&b, "{{parties.defendant.name}}")
recitalsPartyDetails(&b, "vertreten durch {{parties.defendant.representative}}")
recitalsRoles(&b, "— Beklagte / Einsprechende / Beschwerdegegnerin —")
recitalsSequencer(&b, "sowie")
recitalsParty(&b, "{{parties.other.name}}")
recitalsPartyDetails(&b, "vertreten durch {{parties.other.representative}}")
recitalsRoles(&b, "— Weitere Beteiligte —")
headerSubsection(&b, "Betreff")
body0(&b, "Streitpatent: {{project.patent_number}} (UPC-Schreibweise: {{project.patent_number_upc}})")
body0(&b, "Anmeldung: {{project.filing_date}} · Erteilung: {{project.grant_date}}")
body0(&b, "Projekttitel: {{project.title}}")
body0(&b, "Unsere Seite: {{project.our_side_de}} ({{project.our_side}})")
body0(&b, "Mandant: {{project.client_number}} · Matter: {{project.matter_number}}")
body0(&b, "Internes Aktenzeichen: {{project.reference}}")
heading(&b, "HLpat-Heading-H1", "{{procedural_event.name}}")
body0(&b, "(Schriftsatz-Code: {{procedural_event.code}})")
body0(&b, "Rechtsgrundlage: {{procedural_event.legal_source_pretty}} ({{procedural_event.legal_source}})")
body0(&b, "Typische Partei: {{procedural_event.primary_party}} · Schriftsatz-Typ: {{procedural_event.event_kind}}")
headerSubsection(&b, "Frist")
body0(&b, "Frist-Bezeichnung: {{deadline.title}}")
body0(&b, "Fälligkeit: {{deadline.due_date_long_de}} ({{deadline.due_date}})")
body0(&b, "Ursprüngliche Frist: {{deadline.original_due_date}}")
body0(&b, "Berechnet aus: {{deadline.computed_from}} · Quelle: {{deadline.source}}")
heading(&b, "HLpat-Heading-H2", "I. Sachverhalt")
body0(&b, "[Hier folgt der Sachverhalt. Diese Vorlage ist eine Skelett-Fassung — bitte gemäß Schriftsatz-Typ ({{procedural_event.name}}) ausformulieren.]")
heading(&b, "HLpat-Heading-H2", "II. Anträge")
requestsIntro(&b, "Es wird beantragt:")
requestsLevel1(&b, "[Antrag 1 — gemäß {{procedural_event.legal_source_pretty}}]")
requestsLevel1(&b, "[Antrag 2]")
heading(&b, "HLpat-Heading-H2", "III. Rechtsausführungen")
body0(&b, "[Hier folgen die Rechtsausführungen.]")
heading(&b, "HLpat-Heading-H2", "IV. Beweis")
evidenceOffering(&b, "Beweis: [Beweismittel — z. B. Anlage K1: {{project.patent_number}}]")
heading(&b, "HLpat-Heading-H2", "Schlussformel")
signature(&b, "{{today.long_de}}")
signature(&b, "")
signature(&b, "{{user.display_name}}")
signature(&b, "{{firm.name}}")
// Locale-aware verification block — exercises every EN/DE alias the
// variable bag carries plus the rule.* legacy aliases so a lawyer
// editing the template sees that both surfaces resolve. A real
// submission deletes this section after sanity-checking the render.
heading(&b, "HLpat-Heading-H3", "Locale-Varianten & Legacy-Aliase (SKELETON)")
body1(&b, "EN long date: {{today.long_en}} · Today (bare alias): {{today}}")
body1(&b, "Project our side (EN): {{project.our_side_en}} · Proceeding (EN): {{project.proceeding.name_en}}")
body1(&b, "Proceeding (DE): {{project.proceeding.name_de}}")
body1(&b, "Deadline EN long: {{deadline.due_date_long_en}}")
body1(&b, "Procedural event name (DE): {{procedural_event.name_de}} · (EN): {{procedural_event.name_en}}")
body1(&b, "Rule legacy aliases — name: {{rule.name}}, name_de: {{rule.name_de}}, name_en: {{rule.name_en}}")
body1(&b, "Rule legacy aliases — code: {{rule.submission_code}}, legal_source: {{rule.legal_source}}, legal_source_pretty: {{rule.legal_source_pretty}}")
body1(&b, "Rule legacy aliases — primary_party: {{rule.primary_party}}, event_type: {{rule.event_type}}")
// sectPr — copied verbatim from the source .dotm. Keeps the firm
// letterhead header (rId16=header1.xml, rId18=header2.xml first-page)
// and the firm-address footer (rId17, rId19) on every printed page.
b.WriteString(sectPrXML)
b.WriteString(`</w:body></w:document>`)
return b.String()
}
// sectPrXML matches the source .dotm's section properties exactly so
// the firm header/footer refs and A4 page geometry round-trip.
const sectPrXML = `<w:sectPr><w:headerReference w:type="default" r:id="rId16"/><w:footerReference w:type="default" r:id="rId17"/><w:headerReference w:type="first" r:id="rId18"/><w:footerReference w:type="first" r:id="rId19"/><w:pgSz w:w="11906" w:h="16838" w:code="9"/><w:pgMar w:top="567" w:right="1418" w:bottom="567" w:left="1418" w:header="284" w:footer="284" w:gutter="0"/><w:cols w:space="720"/><w:titlePg/><w:docGrid w:linePitch="286"/></w:sectPr>`
func skeletonBanner(b *strings.Builder) {
b.WriteString(`<w:p><w:pPr><w:pStyle w:val="HLpat-Heading-H1"/></w:pPr><w:r><w:rPr><w:b/><w:color w:val="C00000"/></w:rPr><w:t xml:space="preserve">SKELETON — HL Patents Style mit Platzhaltern (nicht freigegeben)</w:t></w:r></w:p>`)
}
func heading(b *strings.Builder, style, text string) { styledPara(b, style, "", text) }
func headerSection(b *strings.Builder, text string) { styledPara(b, "HLpat-Header-Section", "", text) }
func headerSubsection(b *strings.Builder, text string) { styledPara(b, "HLpat-Header-Subsection", "", text) }
func body0(b *strings.Builder, text string) { styledPara(b, "HLpat-Body-B0", "", text) }
func body1(b *strings.Builder, text string) { styledPara(b, "HLpat-Body-B1", "", text) }
func recitalsParty(b *strings.Builder, text string) { styledPara(b, "HLpat-Table-Recitals-Party", "", text) }
func recitalsPartyDetails(b *strings.Builder, text string) { styledPara(b, "HLpat-Table-Recitals-PartyDetails", "", text) }
func recitalsRoles(b *strings.Builder, text string) { styledPara(b, "HLpat-Table-Recitals-PartyRoles", "", text) }
func recitalsSequencer(b *strings.Builder, text string) { styledPara(b, "HLpat-Table-Recitals-Sequencers", "", text) }
func requestsIntro(b *strings.Builder, text string) { styledPara(b, "HLpat-Requests-Intro", "", text) }
func requestsLevel1(b *strings.Builder, text string) { styledPara(b, "HLpat-Requests-Level1", "", text) }
func evidenceOffering(b *strings.Builder, text string) { styledPara(b, "HLpat-EvidenceOffering", "", text) }
func signature(b *strings.Builder, text string) { styledPara(b, "HLpat-Signature", "", text) }
// styledPara writes one paragraph with the given pStyle (paragraph
// style id) and optional rStyle (character style applied to every run).
// Empty style ids drop the corresponding wrapper. Placeholders inside
// `text` are split into their own runs so the renderer's pass-1
// single-run replace catches each one independently.
func styledPara(b *strings.Builder, pStyle, rStyle, text string) {
b.WriteString(`<w:p>`)
if pStyle != "" {
b.WriteString(`<w:pPr><w:pStyle w:val="`)
b.WriteString(pStyle)
b.WriteString(`"/></w:pPr>`)
}
for _, seg := range splitOnPlaceholders(text) {
b.WriteString(`<w:r>`)
if rStyle != "" {
b.WriteString(`<w:rPr><w:rStyle w:val="`)
b.WriteString(rStyle)
b.WriteString(`"/></w:rPr>`)
}
b.WriteString(`<w:t xml:space="preserve">`)
b.WriteString(xmlEscape(seg))
b.WriteString(`</w:t></w:r>`)
}
b.WriteString(`</w:p>`)
}
func splitOnPlaceholders(s string) []string {
if s == "" {
return []string{""}
}
var out []string
for {
open := strings.Index(s, "{{")
if open < 0 {
out = append(out, s)
return out
}
close := strings.Index(s[open:], "}}")
if close < 0 {
out = append(out, s)
return out
}
end := open + close + 2
if open > 0 {
out = append(out, s[:open])
}
out = append(out, s[open:end])
s = s[end:]
if s == "" {
return out
}
}
}
func xmlEscape(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, `"`, "&quot;")
s = strings.ReplaceAll(s, "'", "&apos;")
return s
}