Cycle-close audit for the case-page copy+close cycle (spec 0002, plan
0003, feat 0308245). Architect drift review + scripts/check.sh.
Regression gate (scripts/check.sh): exit 0 — 0 failures across 19
stages (common/server/clients-desktop/experiments fmt+clippy+build+test,
wearos assembleDebug+lintDebug+testDebugUnitTest), 74s. The single
`wearos :: lintDebug` WARN is pre-existing and untouched by this
frontend-only cycle; not introduced here, carried forward.
Architect findings and resolution:
- [high] template emits two separate close forms, not the spec's single
#copy-close-form with a has_document label switch. Resolved as a SPEC
defect, not a code defect: the spec's single-form snippet was
infeasible — #doc-body exists only in the has-document match arm, so a
form anchored after it can never serve the documentless case, and it
must precede the copy <script> to be bound. The shipped two-form split
is canonical; spec 0002 §Concrete code shapes + §Data flow reconciled
to it (this commit).
- [high] the spec's `if (!getElementById("doc-body")) return` guard is
absent from the shipped handler. Not a defect: the no-document form
carries no id, so the handler (which binds #copy-close-form) never
attaches to it — the guard is structurally unnecessary. Spec script
block + prose updated to state this mechanism.
- [medium] the documentless form's distinctness was unpinned. Added an
assertion to case_page_empty_case_shows_placeholder that the empty
case renders no #copy-close-form (it is the bare close form).
- [low] plan counter 0003 vs spec counter 0002: not drift — the naming
policy is per-directory counters (stable_per_directory_4digit over
[docs/specs, docs/plans]); independent sequences are expected.
- [note] deferred case-list half of #14 leaves a coherent state
(refs #14, my_cases.html untouched). No drift.
Recommendation: carry-on. No fix iteration needed; the high items were
spec-vs-code reconciliation (docs) and a test guard, both done here.
refs #14
11 KiB
Copy + Close on the Case Page — Design Spec
Date: 2026-06-01 Status: Draft — awaiting user spec review Authors: orchestrator + Claude
Goal
Give a user finishing a case a single gesture on the case detail page
(server/templates/case_page.html): copy the document text to the
clipboard, close the case, and land back on the case list (/cases).
Today these are two separate controls — the icon copy button
(#copy-btn, above the document) and the trash-can close form (in the
<h1> title row). The close already redirects to /cases from a case
page (see Data flow), so "back to list" needs no new server work; what
is missing is fusing copy + close into one button.
Scope is the case page only. Issue #14 also asks for a per-row
copy+close on the case list (my_cases.html); that part is explicitly
deferred — this cycle leaves the list untouched and the issue stays
open (refs #14, not closes).
Architecture
Frontend-only. One template file (server/templates/case_page.html)
and its inline <script>. No Rust change — no route, handler, or
struct is touched. The POST /cases/{case_id}/close endpoint
(server/src/routes/case_actions.rs:437) and its Referer-derived
redirect stay exactly as they are; the combined button merely submits
the existing close form after the clipboard write completes.
This keeps every server-side invariant fixed: the .closed
discovery-gate semantics (commit 5e86cb5), the IDOR guard, the CSRF
contract, and the close redirect target are all unchanged because no
server code moves.
Concrete code shapes
User-facing artefact (the headline)
What renders directly under the document for an open case. For a case with a document the button copies then closes; for an open case without a document it is a bare close (nothing to copy).
Two physically separate forms, not one with a label switch
(reconciled to the shipped implementation at audit). #doc-body only
exists in the {% when Some %} (has-document) arm of the template's
{% match document_html %}, so a single form anchored after it could
never serve the documentless case — and it must be emitted before the
copy <script> so the submit handler can bind it. The two cases
therefore live in their two respective match arms:
In the has-document arm, after #doc-body — the combined control,
wired to copyDocText():
<div id="doc-body">{{ html|safe }}</div>
{% if !is_closed %}
<div class="doc-footer-actions">
<form id="copy-close-form" method="post" action="/cases/{{ case_id }}/close">
{% call csrf::field(csrf_token) %}
<button type="submit" class="copy-close-btn">Kopieren + Schließen</button>
</form>
<p id="copy-close-error" class="copy-error" role="alert" hidden>
Kopieren fehlgeschlagen — Fall nicht geschlossen.
</p>
</div>
{% endif %}
In the no-document arm — a bare close form. It carries no id, so
the submit handler below (which binds #copy-close-form) never attaches
to it and it submits normally (nothing to copy):
{% if !is_closed %}
<div class="doc-footer-actions">
<form method="post" action="/cases/{{ case_id }}/close">
{% call csrf::field(csrf_token) %}
<button type="submit" class="copy-close-btn">Schließen</button>
</form>
</div>
{% endif %}
The inline script, with the two-path clipboard write extracted into a shared function used by both the icon copy button and the close form:
(() => {
// Shared two-path clipboard write: async Clipboard API in secure
// contexts, textarea + execCommand fallback otherwise. Returns
// whether the copy actually succeeded so callers can gate the
// follow-up close on it.
async function copyDocText() {
const body = document.getElementById("doc-body");
if (!body) return false; // no document → nothing to copy
const text = body.innerText;
let ok = false;
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
ok = true;
}
} catch (_) {
/* fall through to execCommand */
}
if (!ok) {
const ta = document.createElement("textarea");
ta.value = text;
ta.setAttribute("readonly", "");
ta.style.position = "fixed";
ta.style.top = "-1000px";
document.body.appendChild(ta);
ta.select();
try {
ok = document.execCommand("copy");
} catch (_) {}
ta.remove();
}
return ok;
}
const copyBtn = document.getElementById("copy-btn");
if (copyBtn) {
copyBtn.hidden = false;
copyBtn.addEventListener("click", async () => {
if (await copyDocText()) {
copyBtn.classList.add("copied");
setTimeout(() => copyBtn.classList.remove("copied"), 1500);
}
});
}
// Combined "copy then close": the clipboard write must finish inside
// the user-gesture context before the form navigates, and a failed
// copy aborts the close so the user never loses a document they
// believed was on the clipboard.
// Only the has-document form carries id="copy-close-form", so this
// handler binds only there; the bare no-document "Schließen" form is
// never matched and submits normally (no copy).
const closeForm = document.getElementById("copy-close-form");
if (closeForm) {
closeForm.addEventListener("submit", async (e) => {
e.preventDefault();
if (await copyDocText()) {
closeForm.submit(); // close → 303 → /cases
} else {
document.getElementById("copy-close-error").hidden = false;
}
});
}
})();
Implementation shape (supporting — before → after)
Remove the H1 trash-can close form (open-case branch). The
{% if is_closed %} reopen branch is untouched.
Before (server/templates/case_page.html, the {% else %} arm of the
title-row form, ~lines 438–458):
{% else %}
<form class="delete-form" method="post" action="/cases/{{ case_id }}/close">
{% call csrf::field(csrf_token) %}
<button type="submit" class="delete-btn" title="Fall schließen" ...>
<svg ...><!-- trash icon --></svg>
</button>
</form>
{% endif %}
After: the {% else %} arm is dropped entirely — for an open case the
title row carries no close affordance (it moves below the document).
The {% if is_closed %} reopen arm and the {% endif %} remain.
Refactor the copy IIFE: the existing anonymous click handler that
inlines the clipboard logic becomes copyDocText() (shown above); the
icon button keeps its exact current behaviour by calling it.
Components
- H1 title row — open-case trash-close form removed; closed-case reopen form unchanged.
doc-footer-actionsblock (new, after#doc-body,!is_closedonly) — one close form whose button label isKopieren + Schließenwhenhas_document, elseSchließen; plus a hidden error paragraph.- Inline script —
copyDocText()shared by the icon copy button and the close form's submit handler.
No new template variables: is_closed, has_document, case_id, and
csrf_token are already in the template's context.
Data flow
- User clicks Kopieren + Schließen.
submitevent fires → handlerpreventDefault()s.await copyDocText()runs the clipboard write inside the gesture.- On
true:form.submit()→POST /cases/{id}/close→ handler writes the.closedmarker and returns303toresolve_list_return_path(headers). TheRefereris the case page/cases/{uuid}, which that function strips to/cases. Browser lands on the list. - On
false:#copy-close-erroris unhidden; no submit; case stays open.
The bare Schließen button (open case, no document) lives in a
separate form with no id, so the submit handler never binds it; it
submits normally → same close → /cases.
Error handling
- Copy failure (both clipboard paths fail — e.g. insecure context
and no
execCommand): close is aborted, the case stays open, and#copy-close-erroris shown. The user never ends up with a closed (list-hidden) case and an empty clipboard. - CSRF: the new close form carries
{% call csrf::field(csrf_token) %}, identical to the form it replaces. - Closed case: neither button renders (
{% if !is_closed %}); closing an already-closed case is impossible. The reopen affordance is the only state-changing control, unchanged.
Testing strategy
RED→GREEN in server/tests/case_page_test.rs, which renders the page
and asserts on the HTML. The JS gesture ordering (copy-before-navigate,
abort-on-failure) is not covered by Rust tests — the project has no
headless-browser harness — and is verified manually.
New / extended assertions:
- Open case with document renders
id="copy-close-form", the textKopieren + Schließen, and a CSRF token; and no longer renders the H1 trash-can close button (assert its absence so the removal is pinned). - Open case without document (
case_page_empty_case_shows_placeholder, extended): the close form withaction="…/close"is still present and its label isSchließen. - Closed case renders no
copy-close-form; the reopen control is still present. case_page_document_has_copy_buttonstays green — the icon copy button survives the refactor.
Whole suite green via cargo test --manifest-path server/Cargo.toml -j 4;
cargo clippy and cargo fmt clean.
Acceptance criteria
A medical user finishing a case naturally reaches for "put the letter
on the clipboard and be done with this case" in one click — the
Kopieren + Schließen button above is that gesture, and is the
empirical evidence for the criterion. It introduces no new failure
class: a failed copy aborts the close (no false sense of a saved
document), CSRF is preserved, and the closed-case discovery-gate and
IDOR guarantees are untouched because no server code changes.
Checklist (the case-page half of issue #14):
- Case page: one control copies the document to the clipboard,
closes the case, and lands on
/cases, with the clipboard write guaranteed to complete before navigation. - On copy failure the case is not closed and an error is shown.
- Open case without a document keeps a working close control.
- The standalone icon copy button (copy without close) still works.
- The clipboard
execCommandfallback path still applies. - Closed cases render neither button; reopen unchanged.
- Case list (
my_cases.html) is untouched this cycle (refs #14).