"""Unit: phase 115 — the doc-draft discard (DELETE route + the edit-screen Discard control) and the draft title fix, at the frontend layer (house source-assertion style — ``test_doc_edit_screen.py`` / ``test_save_as_doc_button.py``). No Python logic exists for the frontend half of the phase — the behavior lives in ``frontend/doc-edit.html`` + ``frontend/assets/doc-edit.js`` + ``frontend/assets/app.js`` + ``styles.css``, and it is E2E-gated by ``tests/e2e/test_save_doc_session.py`` (the discard flow + the title-after-retry pins). This module pins the HTML/JS/CSS markers the discard + title loop depends on, so a silent regression is caught without a browser: * the Discard control — ``#discard-draft`` in the actions row, between the push button and the back link (the SECONDARY action — ``type="button"``, never a submit), the exact "Discard draft" copy, the cannot-be-undone ``title``; * ``doc-edit.js`` — the handler: without a token the banner, no fetch; the native ``confirm()`` FIRST (destructive + irreversible — the shell's alertdialog is page-local to the Sources view, not a shared asset); then ``DELETE /api/doc-drafts/`` with the SAME uuid4 ``draftToken`` the GET/PUT ran on (the screen's credential); 204 → ``location.assign("/")`` (back to the chat — the draft's only other home, no drafts list exists) and the 204 arm is the file's ONLY navigation; a non-204 (a 404 race) or a network failure → the #push-error inline banner (the server's detail, 422 shape-aware; the stale success line cleared first) and NO navigation, no crash; the §7.4 in-flight lifecycle (disable + "Discarding…", restored in the finally — never stale); * ``app.js`` — the title fix: ``defaultDocTitle(wrap)`` prefers the user bubble PAIRED with the saved brain bubble — the NEAREST preceding ``.msg.user`` in the DOM conversation flow (the redo-in-place reorders the DOM, and the structural pair IS the answer's question by construction) — over the pre-115 last-conversation-record rule, which survives only as the no-wrap / no-pair fallback; the ``DOC_TITLE_MAX`` slice, the whitespace collapse, and the defensive "Note" are unchanged; the ``saveAsDoc`` call site passes the button's own bubble (``btn.closest(".msg.brain")``); * ``styles.css`` — the ``.discard-draft`` ghost family (44px floor, --line border, ink-soft on transparent — visually subordinate to the brand primary) with the err family on hover (the destructive state stays text + color, never color alone, B5) and the :disabled "Discarding…" affordance (the global 3px :focus-visible ring covers the control). The API half (``DELETE /api/doc-drafts/{token}`` → 204 / 404, admin-gated like the whole router) is pinned by ``tests/integration/test_doc_drafts_api.py`` (the delete section). """ from __future__ import annotations import re from pathlib import Path FRONTEND = Path(__file__).resolve().parents[2] / "frontend" ASSETS = FRONTEND / "assets" DOC_EDIT_HTML = FRONTEND / "doc-edit.html" DOC_EDIT_JS = ASSETS / "doc-edit.js" APP_JS = ASSETS / "app.js" STYLES_CSS = ASSETS / "styles.css" def _html() -> str: assert DOC_EDIT_HTML.is_file(), "frontend/doc-edit.html is missing" return DOC_EDIT_HTML.read_text(encoding="utf-8") def _doc_edit_js() -> str: assert DOC_EDIT_JS.is_file(), "frontend/assets/doc-edit.js is missing" return DOC_EDIT_JS.read_text(encoding="utf-8") def _app_js() -> str: assert APP_JS.is_file(), "frontend/assets/app.js is missing" return APP_JS.read_text(encoding="utf-8") def _css() -> str: return STYLES_CSS.read_text(encoding="utf-8") def _discard_fn(js: str) -> str: """The source of ``wireDiscard()`` (to its close).""" start = js.find("function wireDiscard() {") assert start != -1, "wireDiscard() must exist in doc-edit.js" return js[start : js.find("\n}\n", start) + 4] # ---------- doc-edit.html — the Discard control ---------- def test_discard_button_sits_in_the_actions_row_next_to_push() -> None: """#discard-draft: ``type="button"`` (NEVER a submit — the push button is the form's submit), the .discard-draft class (the CSS ghost-family hook), the exact house copy "Discard draft", and the cannot-be-undone ``title`` (the warning before the click). Layout: the actions row, BETWEEN the push button and the back link — the secondary destructive action, visually subordinate to the brand primary.""" html = _html() btn = re.search(r"]*id=\"discard-draft\"[^>]*>", html) assert btn, "#discard-draft is missing from the edit screen" tag = btn.group(0) assert 'type="button"' in tag, ( "the Discard control must not submit the form (push is the submit)" ) assert 'class="discard-draft"' in tag assert "cannot be undone" in tag, "the title attribute must warn it is final" assert ">Discard draft" in html, ( "the exact house copy: 'Discard draft'" ) actions = html[ html.find('class="doc-edit-actions"') : html.find("") ] push_i = actions.find('id="push-doc-btn"') discard_i = actions.find('id="discard-draft"') back_i = actions.find('class="doc-edit-back"') assert 0 <= push_i < discard_i < back_i, ( "the Discard control sits in the actions row, between the push " "button and the back link" ) # ---------- doc-edit.js — the discard handler ---------- def test_discard_handler_guards_and_confirms_before_any_request() -> None: """The handler runs on #discard-draft and gates the request: without a token the "No draft specified." banner (no fetch — the same guard as the push), then the native ``confirm()`` — the destructive action is confirmed FIRST, before the DELETE leaves the browser. A dismissed confirm must not delete anything.""" js = _doc_edit_js() fn = _discard_fn(js) assert 'document.querySelector("#discard-draft")' in fn no_token_i = fn.find('showError("No draft specified.")') confirm_i = fn.find('confirm("Discard this draft? This cannot be undone.")') first_fetch = fn.find("await fetch(") assert -1 < no_token_i < confirm_i < first_fetch, ( "token guard → confirm() → the DELETE: in that order, nothing " "fetches before the confirm" ) # A dismissed confirm returns BEFORE the in-flight state starts # (the button is never left disabled). disabled_i = fn.find("discardBtn.disabled = true") assert confirm_i < disabled_i, ( "a dismissed confirm must not disable the button" ) def test_discard_uses_the_same_token_delete_route() -> None: """The DELETE runs on ``/api/doc-drafts/${draftToken}`` — the SAME uuid4 ``draftToken`` the load (GET) and the save (PUT) ran on: the screen's credential, set once in boot. No second token source may exist in the file.""" js = _doc_edit_js() fn = _discard_fn(js) assert 'fetch(`/api/doc-drafts/${draftToken}`, {' in fn, ( "the DELETE must use the screen's draftToken credential" ) fetch_i = fn.find('fetch(`/api/doc-drafts/${draftToken}`, {') assert 'method: "DELETE"' in fn[fetch_i : fetch_i + 120], ( "the request method is DELETE" ) # The token comes from the single boot assignment (no re-derivation # — a divergent token would delete a different row than the one # shown). assert js.count("draftToken = token") == 1 assert "new URLSearchParams" not in _discard_fn(js) def test_discard_204_redirects_and_non_204_inlines_without_navigation() -> None: """The outcomes: 204 → ``location.assign("/")`` (back to the chat — the draft's only other home; no drafts list exists) — and that is the file's ONLY navigation. A non-204 (a 404 race — the row vanished under us) clears the stale success line first, then lands the server's detail in the #push-error inline banner (422 shape-aware via apiDetail) and does NOT navigate, no crash. A network failure → the fixed one-line copy, same recovery.""" js = _doc_edit_js() fn = _discard_fn(js) ok_i = fn.find("r.status === 204") nav_i = fn.find('location.assign("/")') err_i = fn.find("showError(await apiDetail(r") assert -1 < ok_i < nav_i, "the 204 arm must navigate back to the chat" # The header comment quotes the 204 line too — count the CODE only # (from the first import on): the 204 redirect is the file's only # navigation, a failure never leaves the edit screen. code = js[js.find("\nimport ") :] assert code.count('location.assign("/")') == 1, ( "the 204 redirect is the file's only navigation — a failure " "never leaves the edit screen" ) # The non-204 arm comes after the 204 navigation … assert err_i > nav_i, "the non-204 error arm must follow the 204 arm" # …clearing the stale success line first (one claim at a time) … clear_i = fn.find('setStatus("")', nav_i) assert -1 < clear_i < err_i, ( "a failed discard clears the stale status line before the banner" ) # …with the 422-shape-aware server detail … assert "apiDetail(" in fn # …and a network failure → the fixed one-line copy. assert "is the app running?" in fn def test_discard_in_flight_lifecycle_is_never_stale() -> None: """The §7.4 in-flight lifecycle: the button disables + relabels "Discarding…" while the DELETE is out (one discard per click); the finally restores BOTH on every outcome — success OR failure — with DISCARD_LABEL, the exact static button copy (a mismatch would relabel the button into an unknown state).""" js = _doc_edit_js() fn = _discard_fn(js) disable_i = fn.find("discardBtn.disabled = true") relabel_i = fn.find('discardBtn.textContent = "Discarding…"') fetch_i = fn.find("await fetch(") assert -1 < disable_i < relabel_i < fetch_i, ( "disable + relabel before the request goes out" ) finally_i = fn.find("} finally {") assert finally_i != -1, "the finally block is the never-stale guarantee" after = fn[finally_i:] assert "discardBtn.disabled = false" in after assert "discardBtn.textContent = DISCARD_LABEL" in after assert 'const DISCARD_LABEL = "Discard draft";' in js, ( "the restored label is the static button copy" ) # ---------- app.js — the title: the answer's own question ---------- def test_default_doc_title_prefers_the_paired_user_bubble() -> None: """Phase 115 (task 03): ``defaultDocTitle(wrap)`` — the title is the text of the user bubble PAIRED with the saved brain bubble: the NEAREST preceding ``.msg.user`` in the DOM conversation flow (the redo-in-place reorders the DOM, and the structural pair IS the answer's question by construction — the last conversation record, after a retry + a trailing question, can be unrelated). The text is read from that bubble's ``.bubble`` (the meta rows carry button labels — never the whole wrap). No wrap given, or no paired user bubble found (first-turn edge / DOM mismatch) → the pre-115 fallback: the LAST user record in ``conversation`` (iterated backwards). The ``DOC_TITLE_MAX`` slice, the whitespace collapse, and the defensive "Note" are unchanged.""" js = _app_js() assert "const DOC_TITLE_MAX = 120;" in js fn_idx = js.find("function defaultDocTitle(wrap) {") assert fn_idx != -1, "defaultDocTitle(wrap) is missing (or lost the wrap arg)" fn_body = js[fn_idx : js.find("function docSlug", fn_idx)] # The paired-bubble walk: from the saved bubble's wrap, down the # conversation flow (previousElementSibling chain) to the first # .msg.user … assert "if (wrap) {" in fn_body assert "wrap.previousElementSibling" in fn_body, ( "the pairing must walk the DOM conversation flow backwards" ) assert 'el.classList.contains("msg")' in fn_body assert 'el.classList.contains("user")' in fn_body assert "textContent" in fn_body # …reading the question from that bubble's .bubble … assert 'el.querySelector(".bubble")' in fn_body # …and the pre-115 rule survives ONLY as the no-pair fallback # (entered when the paired walk found nothing). assert "if (!question) {" in fn_body, ( "the last-conversation-record rule must be the fallback, not the rule" ) assert "conversation.length - 1" in fn_body assert 'conversation[i].who === "user"' in fn_body # The unchanged title rule: collapse, 120-cap, defensive "Note". assert 'question.replace(/\\s+/g, " ").trim().slice(0, DOC_TITLE_MAX)' in fn_body assert '|| "Note"' in fn_body def test_save_as_doc_call_site_passes_the_bubble_ancestor() -> None: """The ``saveAsDoc`` call site passes the button's OWN bubble — the .save-as-doc-btn lives in the bubble's .msg-meta row, so ``closest(".msg.brain")`` climbs button → meta → body → wrap — the title pairs the answer with ITS question (the redo-in-place fix). No call site may stay on the no-wrap fallback: the button always knows its own bubble.""" js = _app_js() fn_idx = js.find("async function saveAsDoc(btn) {") assert fn_idx != -1, "saveAsDoc is missing" fn_body = js[fn_idx : fn_idx + 3000] assert 'defaultDocTitle(btn.closest(".msg.brain"))' in fn_body, ( "the call site must pass the button's own .msg.brain ancestor" ) assert "defaultDocTitle()" not in js, ( "no caller may stay on the no-wrap fallback once the button " "knows its own bubble" ) # ---------- styles.css — the .discard-draft ghost family ---------- def test_discard_draft_css_is_secondary_with_err_hover_and_disabled() -> None: """.discard-draft: the SECONDARY destructive action — visually subordinate to the brand primary: ink-soft on transparent with the --line border, the 44px touch floor. Hover joins the err family (the destructive state stays text + color, never color alone — B5); :disabled is the "Discarding…" in-flight affordance; the global 3px :focus-visible ring covers the control (AGENTS.md rule 5).""" css = _css() base = re.search(r"\.discard-draft \{([^}]*?)\}", css) assert base, "the .discard-draft rule is missing" bbody = base.group(1) assert "min-height: 44px" in bbody, "the WCAG touch floor" assert "border: 1px solid var(--line)" in bbody assert "background: transparent" in bbody, ( "the ghost family — subordinate to the brand primary" ) assert "var(--ink-soft)" in bbody hover = re.search(r"\.discard-draft:hover[^{]*\{([^}]*?)\}", css) assert hover, "the hover state must be styled" hbody = hover.group(1) assert "var(--err-bg)" in hbody and "var(--err-ink)" in hbody, ( "hover joins the err family (text + color, never color alone)" ) assert "var(--err-line)" in hbody disabled = re.search(r"\.discard-draft:disabled \{([^}]*?)\}", css) assert disabled and "opacity" in disabled.group(1), ( "the :disabled state is the 'Discarding…' affordance" ) assert ":focus-visible" in css, "the global focus ring (AGENTS.md rule 5)"