Submissions draft editor: autosave-refresh steals focus + add click-variable-in-preview → jump to field #92

Open
opened 2026-05-25 12:59:43 +00:00 by mAi · 1 comment
Collaborator

m's report (2026-05-25 14:58)

When autosaving in the submission generator form, the cursor leaves the form when refreshing the preview - that makes filling the form annoying, can we fix that?

Also: could we make it so that clicking on a specific variable in the preview that exists in the form jump there?!

A. Autosave-refresh steals focus

The variable-editor sidebar autosaves on edit; the autosave triggers a preview re-render; the re-render replaces preview DOM and (apparently) blows away the focused form-field reference. User has to click back into the input to keep typing.

Likely cause options:

  1. Preview re-render swaps a parent DOM tree that ALSO contains the form, instead of swapping only the preview pane. Scope the re-render to the preview container.
  2. Preview re-render programmatically sets focus on its own first element. Remove that.
  3. The autosave POST response triggers a wholesale form re-render that disposes inputs and re-creates them; the focus is lost in the DOM dispose/create cycle. Preserve focus by capturing the active-element id before re-render and restoring after.

Fix the smallest correct one. Add a regression test or playwright smoke if practical.

B. Click-variable-in-preview → jump to form field

The preview pane renders the merged template with substituted values. Make each rendered variable a tiny clickable target so the user can click {{project.case_number}}'s rendered value and the variable-editor sidebar scrolls to + focuses that field.

Implementation sketch:

  • When the merge engine renders a placeholder substitution, wrap the value in <span class="draft-var" data-var="project.case_number">…</span> (preview-only; export-to-.docx unchanged).
  • Client-side click handler on .draft-var reads data-var, scrolls the sidebar to the matching input, focuses it, and adds a brief lime-accent flash.
  • Variables that are RESOLVED but read-only (e.g. {{firm.name}} from config) get a non-clickable rendering OR a tooltip explaining "from config".
  • Variables with no input on screen (e.g. derived ones not exposed in the sidebar) stay non-clickable.

Decide whether to use data-var or a different attribute; coder picks the cleanest collision-free name.

Files most likely touched

  • frontend/src/client/submission-draft.ts — autosave debounce + preview render handler + new click handler
  • frontend/src/submission-draft.tsx — if the preview pane container needs structural change
  • internal/services/submission_draft_service.go / internal/services/submission_render.go — render-to-HTML output: wrap substitutions in <span class="draft-var" data-var="..."> (only the preview path, NOT the export-to-.docx path)
  • frontend/src/styles/global.css.draft-var:hover + .draft-var--flash accent
  • i18n: aria-label / tooltip on .draft-var

Hard rules

  • Export-to-.docx output unchanged<span class="draft-var"> is HTML-preview-only. The Word .docx must not carry these spans.
  • No autosave regression — autosave still fires after edit (debounced ~600ms), still hits the server, still resolves new variables.
  • Focus preservation works for both text inputs AND textareas — different element types but both need the same restore.
  • go build ./... && go test ./internal/... && cd frontend && bun run build clean.
  • Branch: mai/<worker>/draft-editor-focus-jump.

Out of scope

  • Inline-editing variables from inside the preview (separate feature).
  • A diff view between draft state and saved state.
  • Multi-cursor / collaborative editing.

Reporting

mai report completed with branch + SHAs + UX path: A) type in a field, wait for autosave + preview refresh, confirm cursor stays in the field; B) click on a substituted variable in the preview, confirm sidebar scrolls + focuses the corresponding input with a brief flash.

## m's report (2026-05-25 14:58) > When autosaving in the submission generator form, the cursor leaves the form when refreshing the preview - that makes filling the form annoying, can we fix that? > > Also: could we make it so that clicking on a specific variable in the preview that exists in the form jump there?! ## Scope — two related polish fixes to the submission-draft editor ### A. Autosave-refresh steals focus The variable-editor sidebar autosaves on edit; the autosave triggers a preview re-render; the re-render replaces preview DOM and (apparently) blows away the focused form-field reference. User has to click back into the input to keep typing. Likely cause options: 1. Preview re-render swaps a parent DOM tree that ALSO contains the form, instead of swapping only the preview pane. Scope the re-render to the preview container. 2. Preview re-render programmatically sets focus on its own first element. Remove that. 3. The autosave POST response triggers a wholesale form re-render that disposes inputs and re-creates them; the focus is lost in the DOM dispose/create cycle. Preserve focus by capturing the active-element id before re-render and restoring after. Fix the smallest correct one. Add a regression test or playwright smoke if practical. ### B. Click-variable-in-preview → jump to form field The preview pane renders the merged template with substituted values. Make each rendered variable a tiny clickable target so the user can click `{{project.case_number}}`'s rendered value and the variable-editor sidebar scrolls to + focuses that field. Implementation sketch: - When the merge engine renders a placeholder substitution, wrap the value in `<span class="draft-var" data-var="project.case_number">…</span>` (preview-only; export-to-.docx unchanged). - Client-side click handler on `.draft-var` reads `data-var`, scrolls the sidebar to the matching input, focuses it, and adds a brief lime-accent flash. - Variables that are RESOLVED but read-only (e.g. `{{firm.name}}` from config) get a non-clickable rendering OR a tooltip explaining "from config". - Variables with no input on screen (e.g. derived ones not exposed in the sidebar) stay non-clickable. Decide whether to use `data-var` or a different attribute; coder picks the cleanest collision-free name. ## Files most likely touched - `frontend/src/client/submission-draft.ts` — autosave debounce + preview render handler + new click handler - `frontend/src/submission-draft.tsx` — if the preview pane container needs structural change - `internal/services/submission_draft_service.go` / `internal/services/submission_render.go` — render-to-HTML output: wrap substitutions in `<span class="draft-var" data-var="...">` (only the preview path, NOT the export-to-.docx path) - `frontend/src/styles/global.css` — `.draft-var:hover` + `.draft-var--flash` accent - i18n: aria-label / tooltip on `.draft-var` ## Hard rules - **Export-to-.docx output unchanged** — `<span class="draft-var">` is HTML-preview-only. The Word .docx must not carry these spans. - **No autosave regression** — autosave still fires after edit (debounced ~600ms), still hits the server, still resolves new variables. - **Focus preservation works for both text inputs AND textareas** — different element types but both need the same restore. - `go build ./... && go test ./internal/... && cd frontend && bun run build` clean. - Branch: `mai/<worker>/draft-editor-focus-jump`. ## Out of scope - Inline-editing variables from inside the preview (separate feature). - A diff view between draft state and saved state. - Multi-cursor / collaborative editing. ## Reporting `mai report completed` with branch + SHAs + UX path: A) type in a field, wait for autosave + preview refresh, confirm cursor stays in the field; B) click on a substituted variable in the preview, confirm sidebar scrolls + focuses the corresponding input with a brief flash.
mAi self-assigned this 2026-05-25 12:59:43 +00:00
Author
Collaborator

Done — both polish fixes shipped together. m/paliad#92 has two parts; commit covers both.

(A) Autosave focus preservation

Root causeflushAutosavepaintVariables() does host.innerHTML = html, which replaces every input element. The focused-input reference dies with the old DOM tree, the cursor drops out. (Confirmed in frontend/src/client/submission-draft.ts:594.)

Fix — option 3 from the issue's three sketches. New helpers captureVarFocus() / restoreVarFocus():

  • Before the repaint, capture the active input's data-var key + selectionStart + selectionEnd + selectionDirection.
  • After the repaint, look up the new input by data-var, call focus() + setSelectionRange(start, end, dir).
  • Works for both <input> and <textarea> via the shared HTMLInputElement | HTMLTextAreaElement contract (per hard-rule in the issue).
  • Wired in flushAutosave — the only spot where paintVariables() runs while a user is mid-edit. Rename/reset go through dedicated flows that don't preserve the typing position (intentional — they don't reuse the same input).

(B) Click variable in preview → jump

Renderer side (internal/services/submission_merge.go) — added a valueWrapperFn plumbed through the substitution chain (substituteInDocumentXMLsubstituteInTextNodes / substituteAcrossRunsreplacePlaceholders). Render() (.docx export) passes nil. RenderHTML() (preview) passes htmlPreviewWrapper which wraps each substituted value with three Private-Use-Area sentinels (U+E100, U+E101, U+E102). emitTextWithDraftVars() — invoked inside docXMLToHTML — converts each [BEGIN]key[MID]value[END] triplet into <span class="draft-var" data-var="<key>">value-html-escaped</span>. PUA chars survive xmlEncode/xmlDecode/htmlEscape unchanged and are stripped at the HTML emission step.

Why sentinels and not e.g. a second-pass regex on the placeholder map: the value itself can contain anything (UPC_CFI_123/2025, M&S <Inc> "X"); regex-matching the value back to a key is fragile. Sentinels carry the key inline, survive XML round-trip, never collide with template text.

Hard rule (.docx unchanged) — new test TestRender_DocxOutputUnchangedByPreviewWrap asserts the .docx export carries <w:t>HLC</w:t> and contains zero occurrences of draft-var / data-var / any of the three sentinels. Passes — the wrap is HTML-preview-only.

Missing markers[KEIN WERT: foo] text is also wrapped in <span class="draft-var">, so clicking a missing variable in the preview jumps to its empty input. Useful in practice: lawyer scans preview, sees red marker, clicks it, fills the value. New test TestRenderHTML_WrapsMissingMarker covers this.

Client side (frontend/src/client/submission-draft.ts)wireDraftVars() runs at the end of paintPreview(). For each .draft-var:

  • If a matching .submission-draft-var-input[data-var="<key>"] exists in the sidebar: add .draft-var--has-input (cursor: pointer + brighter hover) and role=button + tabindex=0 + aria-label.
  • If no matching input (derived variables like today.iso that aren't in VARIABLE_GROUPS): the span stays visually distinct (subtle lime tint) but non-interactive — silent no-op on click.
  • Click handler: scrollIntoView({ behavior: "smooth", block: "center" })setTimeout(focus + select, 50)flashRow() (lime accent fade-out).
  • Keyboard accessible: Enter / Space activate same handler.

CSS — base .draft-var gets rgba(198, 244, 28, 0.12) background tint + 2px padding + box-decoration-break: clone (so multi-line spans render cleanly). .draft-var--has-input layers cursor: pointer + brighter hover. Row flash uses 1.2s paliad-var-flash keyframe with prefers-reduced-motion fallback to a steps(1, end) non-animating swap.

Verification

Playwright on the built dist/submission-draft.html with an injected populated state (sidebar inputs + preview HTML containing five .draft-var spans, one of which — today.iso — has no matching input):

Check Result
.draft-var--has-input applied to sidebar-backed keys rule.name, project.case_number, project.court, firm.name
.draft-var--has-input NOT applied to derived today.iso plain
Click project.case_number span → focus moves to that input activeElement.dataset.var === 'project.case_number'
Click → value selected (Cmd-A behavior on focus) selectionStart=0, selectionEnd=16
Click → row.classList contains '...row--flash' immediately after
Captured focus snap → innerHTML replace → restored on new element isNewElement=true, key + cursor position 7,7 preserved
.draft-var background tint visible on all five spans in preview (screenshot at 1280px)

Build status

  • cd frontend && bun run build — clean
  • go build ./... — clean
  • go test -short ./internal/... — all packages pass, including 4 new/updated tests in submission_merge_test.go

Branch / commit

Branch: mai/hermes/gitster-draft-editor-focus-jump
Commit: 7e66da8def

Ready for review / merge.

Done — both polish fixes shipped together. **m/paliad#92** has two parts; commit covers both. ## (A) Autosave focus preservation **Root cause** — `flushAutosave` → `paintVariables()` does `host.innerHTML = html`, which replaces every input element. The focused-input reference dies with the old DOM tree, the cursor drops out. (Confirmed in `frontend/src/client/submission-draft.ts:594`.) **Fix** — option 3 from the issue's three sketches. New helpers `captureVarFocus()` / `restoreVarFocus()`: - Before the repaint, capture the active input's `data-var` key + `selectionStart` + `selectionEnd` + `selectionDirection`. - After the repaint, look up the new input by `data-var`, call `focus()` + `setSelectionRange(start, end, dir)`. - Works for both `<input>` and `<textarea>` via the shared `HTMLInputElement | HTMLTextAreaElement` contract (per hard-rule in the issue). - Wired in `flushAutosave` — the only spot where `paintVariables()` runs while a user is mid-edit. Rename/reset go through dedicated flows that don't preserve the typing position (intentional — they don't reuse the same input). ## (B) Click variable in preview → jump **Renderer side (`internal/services/submission_merge.go`)** — added a `valueWrapperFn` plumbed through the substitution chain (`substituteInDocumentXML` → `substituteInTextNodes` / `substituteAcrossRuns` → `replacePlaceholders`). `Render()` (.docx export) passes `nil`. `RenderHTML()` (preview) passes `htmlPreviewWrapper` which wraps each substituted value with three Private-Use-Area sentinels (U+E100, U+E101, U+E102). `emitTextWithDraftVars()` — invoked inside `docXMLToHTML` — converts each `[BEGIN]key[MID]value[END]` triplet into `<span class="draft-var" data-var="<key>">value-html-escaped</span>`. PUA chars survive `xmlEncode`/`xmlDecode`/`htmlEscape` unchanged and are stripped at the HTML emission step. Why sentinels and not e.g. a second-pass regex on the placeholder map: the value itself can contain anything (`UPC_CFI_123/2025`, `M&S <Inc> "X"`); regex-matching the value back to a key is fragile. Sentinels carry the key inline, survive XML round-trip, never collide with template text. **Hard rule (.docx unchanged)** — new test `TestRender_DocxOutputUnchangedByPreviewWrap` asserts the .docx export carries `<w:t>HLC</w:t>` and contains zero occurrences of `draft-var` / `data-var` / any of the three sentinels. Passes — the wrap is HTML-preview-only. **Missing markers** — `[KEIN WERT: foo]` text is also wrapped in `<span class="draft-var">`, so clicking a missing variable in the preview jumps to its empty input. Useful in practice: lawyer scans preview, sees red marker, clicks it, fills the value. New test `TestRenderHTML_WrapsMissingMarker` covers this. **Client side (`frontend/src/client/submission-draft.ts`)** — `wireDraftVars()` runs at the end of `paintPreview()`. For each `.draft-var`: - If a matching `.submission-draft-var-input[data-var="<key>"]` exists in the sidebar: add `.draft-var--has-input` (cursor: pointer + brighter hover) and `role=button` + `tabindex=0` + `aria-label`. - If no matching input (derived variables like `today.iso` that aren't in `VARIABLE_GROUPS`): the span stays visually distinct (subtle lime tint) but non-interactive — silent no-op on click. - Click handler: `scrollIntoView({ behavior: "smooth", block: "center" })` → `setTimeout(focus + select, 50)` → `flashRow()` (lime accent fade-out). - Keyboard accessible: Enter / Space activate same handler. **CSS** — base `.draft-var` gets `rgba(198, 244, 28, 0.12)` background tint + 2px padding + box-decoration-break: clone (so multi-line spans render cleanly). `.draft-var--has-input` layers cursor: pointer + brighter hover. Row flash uses 1.2s `paliad-var-flash` keyframe with `prefers-reduced-motion` fallback to a `steps(1, end)` non-animating swap. ## Verification Playwright on the built `dist/submission-draft.html` with an injected populated state (sidebar inputs + preview HTML containing five `.draft-var` spans, one of which — `today.iso` — has no matching input): | Check | Result | |---|---| | `.draft-var--has-input` applied to sidebar-backed keys | ✅ rule.name, project.case_number, project.court, firm.name | | `.draft-var--has-input` NOT applied to derived | ✅ today.iso plain | | Click `project.case_number` span → focus moves to that input | ✅ activeElement.dataset.var === 'project.case_number' | | Click → value selected (Cmd-A behavior on focus) | ✅ selectionStart=0, selectionEnd=16 | | Click → row.classList contains '...row--flash' immediately after | ✅ | | Captured focus snap → innerHTML replace → restored on new element | ✅ isNewElement=true, key + cursor position 7,7 preserved | | `.draft-var` background tint visible on all five spans in preview | ✅ (screenshot at 1280px) | ## Build status - `cd frontend && bun run build` — clean - `go build ./...` — clean - `go test -short ./internal/...` — all packages pass, including 4 new/updated tests in `submission_merge_test.go` ## Branch / commit Branch: `mai/hermes/gitster-draft-editor-focus-jump` Commit: https://mgit.msbls.de/m/paliad/commit/7e66da8defa107a235f9dacf7ae6f4a523fbe2eb Ready for review / merge.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: m/paliad#92
No description provided.