diff --git a/frontend/src/client/submission-draft.ts b/frontend/src/client/submission-draft.ts
index 044ac29..8f195d5 100644
--- a/frontend/src/client/submission-draft.ts
+++ b/frontend/src/client/submission-draft.ts
@@ -605,6 +605,90 @@ function paintPreview(): void {
const host = document.getElementById("submission-draft-preview");
if (!host || !state.view) return;
host.innerHTML = state.view.preview_html ?? "";
+ wireDraftVars(host);
+}
+
+// t-paliad-261 (B) — click a substituted variable in the preview to
+// jump to the matching sidebar input. Re-wires on every paintPreview
+// since the preview HTML is replaced wholesale. The server side wraps
+// each substituted placeholder (resolved OR missing marker) in
+// …; clicks here scroll
+// the corresponding input into view, focus + select, and flash the row.
+// If the key has no matching sidebar input (derived variables not
+// exposed in VARIABLE_GROUPS), the click is a silent no-op — the span
+// is still rendered so the user gets the visible hint that this is a
+// resolved variable.
+function wireDraftVars(previewHost: HTMLElement): void {
+ previewHost.querySelectorAll(".draft-var").forEach((el) => {
+ const key = el.dataset.var;
+ if (!key) return;
+ if (findVarInput(key)) {
+ el.classList.add("draft-var--has-input");
+ el.setAttribute("role", "button");
+ el.setAttribute("tabindex", "0");
+ el.setAttribute(
+ "aria-label",
+ (isEN() ? "Edit variable " : "Variable bearbeiten: ") + labelFor(key),
+ );
+ }
+ el.addEventListener("click", (ev) => onDraftVarClick(key, ev));
+ el.addEventListener("keydown", (ev) => {
+ if (ev.key === "Enter" || ev.key === " ") {
+ ev.preventDefault();
+ onDraftVarClick(key, ev);
+ }
+ });
+ });
+}
+
+function findVarInput(key: string): HTMLInputElement | null {
+ const host = document.getElementById("submission-draft-variables");
+ if (!host) return null;
+ return host.querySelector(
+ `.submission-draft-var-input[data-var="${cssEscape(key)}"]`,
+ );
+}
+
+function cssEscape(s: string): string {
+ // CSS.escape covers our placeholder keys ([A-Za-z][A-Za-z0-9_.]*) but
+ // older browsers may lack it; defensive fallback escapes characters
+ // CSS treats as special. Placeholder keys never carry whitespace or
+ // quotes so escaping is straightforward.
+ if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
+ return CSS.escape(s);
+ }
+ return s.replace(/([!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, "\\$1");
+}
+
+function onDraftVarClick(key: string, ev: Event): void {
+ const input = findVarInput(key);
+ if (!input) return;
+ ev.preventDefault();
+ ev.stopPropagation();
+ // Smooth-scroll the input into view, then focus on the next tick so
+ // the scroll animation has started and the focus call doesn't trigger
+ // a second jarring jump.
+ input.scrollIntoView({ behavior: "smooth", block: "center" });
+ window.setTimeout(() => {
+ input.focus();
+ try {
+ input.select();
+ } catch {
+ /* select() throws on number/email inputs; safe to ignore */
+ }
+ }, 50);
+ flashVarRow(input);
+}
+
+function flashVarRow(input: HTMLElement): void {
+ const row = input.closest(".submission-draft-var-row");
+ if (!row) return;
+ row.classList.remove("submission-draft-var-row--flash");
+ // Force reflow so removing+re-adding the class restarts the animation
+ // even on rapid successive clicks.
+ void row.offsetWidth;
+ row.classList.add("submission-draft-var-row--flash");
+ window.setTimeout(() => row.classList.remove("submission-draft-var-row--flash"), 1200);
}
// ─────────────────────────────────────────────────────────────────────
@@ -643,11 +727,18 @@ async function flushAutosave(): Promise {
if (!state.pendingOverrides) return;
const payload = { variables: state.pendingOverrides };
state.pendingOverrides = null;
+ // t-paliad-261 (A) — paintVariables() below replaces every input in
+ // the sidebar via innerHTML, which blows away the active-element
+ // reference. Capture the focused input's key + selection range before
+ // the repaint and restore on the new element after, so the user can
+ // keep typing without clicking back into the field.
+ const focusSnap = captureVarFocus();
try {
const view = await patchDraft(payload);
state.view = view;
paintVariables();
paintPreview();
+ restoreVarFocus(focusSnap);
setSaveStatus(isEN() ? "Saved" : "Gespeichert");
} catch (err) {
if ((err as Error).name === "AbortError") return;
@@ -656,6 +747,64 @@ async function flushAutosave(): Promise {
}
}
+// captureVarFocus / restoreVarFocus — focus-preservation across the
+// paintVariables() innerHTML-replace cycle (t-paliad-261 part A).
+// Tracks selection start/end/direction so the cursor lands exactly
+// where it was before the repaint, including any active selection
+// range. Handles both and