From ce2bf1585acc881b44112eb81d5b1f6ce0bde711 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:23:45 -0400 Subject: [PATCH 1/7] fix(planning): rebalance the interview page layout and add a someone-else-decides deferral The answer dock no longer starves the question: the question pane keeps min(18rem, 50%), the choices list has no scroller of its own, the answer grid stays two columns down to 900 px, and Save stays pinned in view. At phone width the current question comes before the question list, and a pick from the list scrolls its question into view. A Someone else decides choice records a defer whose text names the person holding the decision; the summary lists these. surface.md documents it as the input to the existing Step 5 questionnaire routing and stops offering /planning:wayfind at wrap-up, matching SKILL.md. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C8UZGiNHApTFuNQ9zKFKk3 --- .../fix-interview-web-view-layout-88848e44.md | 13 +++++++ .../skills/interview/context/surface.md | 5 ++- plugins/planning/surface/index.html | 37 +++++++++++-------- plugins/planning/surface/tests/ui_a.js | 17 ++++++++- plugins/planning/surface/tests/ui_b.js | 4 +- plugins/planning/surface/tests/ui_c.js | 15 +++++++- 6 files changed, 68 insertions(+), 23 deletions(-) create mode 100644 .changes/planning/fix-interview-web-view-layout-88848e44.md diff --git a/.changes/planning/fix-interview-web-view-layout-88848e44.md b/.changes/planning/fix-interview-web-view-layout-88848e44.md new file mode 100644 index 0000000000..acfb8fc80d --- /dev/null +++ b/.changes/planning/fix-interview-web-view-layout-88848e44.md @@ -0,0 +1,13 @@ +--- +bump: minor +--- + +### Added + +- The interview page has a `Someone else decides` choice: a deferral whose note names the person who holds the decision. The summary lists these, and at wrap-up they are the person-held deferrals `/planning:interview` routes to `/planning:questionnaire`. + +### Fixed + +- The interview page's answer area no longer starves the question: the question keeps at least half the pane (or 18rem), every choice shows without its own scroller, the answer grid stays two columns down to 900 px, and Save stays in view. +- At phone width the interview page shows the current question before the question list, and a pick from the list scrolls its question into view. +- The interview surface reference no longer offers `/planning:wayfind` at wrap-up; it names wayfind only where the skill does. diff --git a/plugins/planning/skills/interview/context/surface.md b/plugins/planning/skills/interview/context/surface.md index 94e31ee8cc..bf850ac254 100644 --- a/plugins/planning/skills/interview/context/surface.md +++ b/plugins/planning/skills/interview/context/surface.md @@ -198,7 +198,7 @@ Rules R-A to R-K: | # | Rule | Enforced by | |---|---|---| | R-A | Frontier gating: prerequisites first; write later questions after the answers that shape them | skill, group gating on the page | -| R-B | Size, not a count, signals decomposition: growth in branching or open items prompts the offer below | skill | +| R-B | Size, not a count, signals decomposition: growth in branching or open items prompts, mid-session, the routing SKILL.md's Stance and Step 5 give | skill | | R-C | Decomposition runs locally: a split-level session, then one smaller interview per piece; tracker writes only when the user says so | skill | | R-D | Read back a long or unclear dictated answer in one line ("Understood as: ...") | skill | | R-E | Flag a note that ends mid-sentence and ask the user to finish it | skill, finished-screen loose ends | @@ -216,7 +216,8 @@ Every question states its decision in plain words. Before `add-round`, scan each ## Offers - **Dedicated session.** When a page session starts in a heavy context, offer once, in one line, to run the interview in a small dedicated session so each wake is cheap. Never force it. -- **Decomposition at wrap-up (R6).** Name `/planning:wayfind` and, when enabled, `/work-items:decompose` as steps the user runs; never run them. The local outputs are `export-brief` and `export-report`. +- **Decomposition at wrap-up (R6).** When enabled, name `/work-items:decompose` as a step the user runs; never run it. The local outputs are `export-brief` and `export-report`. `/planning:wayfind` is not a wrap-up offer: it is named only where SKILL.md's Stance and Step 5 name it, for an effort too big and still too foggy to phrase as questions. +- **Someone else decides.** The page's `Someone else decides` choice records a `defer` whose `text` starts `someone else decides: ` and names who holds the decision; the summary lists these under the same heading. They are the person-held deferrals SKILL.md Step 5 routes to `/planning:questionnaire`, so name it in the `finish` op's `next` when any exist. ## Wrap-up diff --git a/plugins/planning/surface/index.html b/plugins/planning/surface/index.html index 3fa1ff7f45..8b38d1c223 100644 --- a/plugins/planning/surface/index.html +++ b/plugins/planning/surface/index.html @@ -172,7 +172,7 @@ .chip.aside{color:var(--muted);background:none;border:0;padding-inline:0;font-style:italic;cursor:default} .chip.aside::before{display:none} -main.detail{background:var(--panel);border:1px solid var(--line);border-radius:8px;display:grid;grid-template-rows:minmax(0,1fr) auto;min-height:0;min-width:0;overflow:hidden} +main.detail{background:var(--panel);border:1px solid var(--line);border-radius:8px;display:grid;grid-template-rows:minmax(min(18rem,50%),1fr) minmax(0,auto);min-height:0;min-width:0;overflow:hidden} .dscroll{overflow-y:auto;overscroll-behavior:contain;padding:18px var(--pad) 22px;display:grid;align-content:start;gap:16px;overflow-wrap:anywhere} .dscroll.enter{animation:enter .22s ease-out} @keyframes enter{from{opacity:0;transform:translateY(8px)}} @@ -238,7 +238,7 @@ .rcpt span.no{color:var(--chg)} .empty{color:var(--faint);font-size:14px} -section.answer{border-top:1px solid var(--line-strong);background:var(--panel);padding:10px var(--pad) 10px;display:grid;gap:8px;box-shadow:0 -6px 14px -12px rgba(0,0,0,.25)} +section.answer{border-top:1px solid var(--line-strong);background:var(--panel);padding:10px var(--pad) 10px;display:grid;align-content:start;gap:8px;min-height:0;overflow-y:auto;overscroll-behavior:contain;box-shadow:0 -6px 14px -12px rgba(0,0,0,.25)} section.answer[hidden]{display:none} .cur{display:flex;flex-wrap:wrap;gap:6px 10px;align-items:center;font-size:13.5px;min-height:22px} .cur q{color:var(--muted)} @@ -249,7 +249,8 @@ .agrid{display:grid;grid-template-columns:minmax(0,1.05fr) minmax(0,1fr);gap:14px;align-items:start} fieldset{border:1px solid var(--line);border-radius:6px;margin:0;padding:6px 10px 8px;min-width:0} legend{font:500 11.5px var(--mono);letter-spacing:.07em;text-transform:uppercase;color:var(--muted);padding:0 4px} -.choices{display:grid;gap:4px;max-height:26vh;overflow-y:auto;padding:2px} +.choices{display:grid;gap:4px;padding:2px} +#decideRow{position:sticky;bottom:0;background:var(--panel);padding:4px 0 6px;z-index:1} .choice{display:grid;grid-template-columns:auto minmax(0,1fr);gap:8px;align-items:center;padding:4px 8px;border:1px solid var(--line);border-radius:6px;cursor:pointer;font-size:13.5px} .choice:hover{border-color:var(--line-strong)} .choice input{margin:0;accent-color:var(--accent)} @@ -352,7 +353,7 @@ .flash{animation:flash 1.8s ease-out} @keyframes flash{0%{box-shadow:0 0 0 3px var(--chg);background-color:var(--chg-soft)}100%{box-shadow:0 0 0 0 transparent}} -@media (max-width:1200px){ .agrid{grid-template-columns:minmax(0,1fr)} } +@media (max-width:900px){ .agrid{grid-template-columns:minmax(0,1fr)} } @media (max-width:820px){ body{overflow:auto} .app{height:auto;min-height:100vh} @@ -360,13 +361,15 @@ .strip{flex-direction:row;order:-1} nav.rail{max-height:none;padding-bottom:12px;border-bottom:1px solid var(--line-strong);box-shadow:0 12px 10px -10px var(--line)} .rail-list{overflow:visible} + nav.rail{order:1} + main.detail{grid-template-rows:auto auto} + section.answer{overflow:visible} h1{font-size:20px;margin-top:2px} header.top{gap:4px;padding-bottom:4px} .cline{white-space:normal;overflow-wrap:anywhere;overflow:visible;text-overflow:clip} .notice{max-width:100%} aside.fly.open{position:fixed;inset:0;width:auto;border-radius:0} } -@media (max-height:800px){ .choices{max-height:calc(40vh - 90px)} fieldset.decide{max-height:40vh;overflow-y:auto} } @media (prefers-reduced-motion: reduce){*,*::before{animation:none!important;transition:none!important}} @@ -485,6 +488,8 @@

Interview

const NOT_LISTENING = "No session is listening; type next in the terminal"; const DEAF_AFTER = 30; // seconds with no watcher poll before the rung 5 message const LINE_CAP = 500; // the server's one-line cap, which a hedged condition must fit + const PERSON = "someone else decides: "; // a defer whose text names the person who holds the decision + const personOf = d => !!d && d.decision === "defer" && String(d.text || "").startsWith(PERSON); const FREEZE_MS = 10000; // longest wrap-up freeze on decision saves const KEYS = [["Ctrl/Cmd+Enter", "Save the armed choice with the note"], ["Ctrl/Cmd+Shift+Enter", "Ask Claude, no decision"], ["1 to 9", "Arm the Nth row of Decide, counted from the top"], ["a", "Arm Rec; on a stale question with a decision, Reconfirm it (the first row)"], ["o", "Arm Own answer and focus the note"], ["d", "Arm Defer"], ["h", "Arm Hedged (accept with a condition) and focus the note"], ["r", "Reopen"], ["i", "Focus the note"], @@ -1036,12 +1041,12 @@

Interview

const r = reconfirmOf(q), c = r ? [r] : []; c.push({kind: "accept", label: "Rec", text: firstLine(q.recommendation) || "the recommendation"}, {kind: "accept", wn: true, label: "Accept with note", text: "the recommendation, your note attached"}); altsOf(q).forEach(a => c.push({kind: "alt", alt: a.key, label: "(" + a.key + ")", text: a.text})); - c.push({kind: "own", label: "Own answer", text: "uses your note"}, {kind: "defer", label: "Defer", text: "decide later"}, {kind: "hedged", label: "Hedged", text: "accept, the note is the condition"}); + c.push({kind: "own", label: "Own answer", text: "uses your note"}, {kind: "defer", label: "Defer", text: "decide later"}, {kind: "defer", who: true, label: "Someone else decides", text: "the note names who"}, {kind: "hedged", label: "Hedged", text: "accept, the note is the condition"}); return c.map((x, i) => Object.assign(x, {n: i + 1})); } function armedOf(cs){ // the armed choice by identity, never by the number it had when armed const a = S.armed; - return a ? cs.find(c => c.kind === a.kind && (c.alt ?? null) === a.alt && !!c.re === a.re && !!c.wn === !!a.wn) : undefined; + return a ? cs.find(c => c.kind === a.kind && (c.alt ?? null) === a.alt && !!c.re === a.re && !!c.wn === !!a.wn && !!c.who === !!a.who) : undefined; } function renderDetail(opts){ opts = opts || {}; @@ -1124,15 +1129,15 @@

Interview

(st === "wait" ? 'Counts once Claude\'s research on ' + esc(q.id) + " returns." : "") : 'No decision yet.' + (setting("shortcuts") ? " Number keys pick; Ctrl+Enter saves." : " Ctrl+Enter saves.") + "" + (last ? receipts(last) : ""); if (d && !d.aside && d.text) $("cur").innerHTML += '
Your earlier answer' + esc(d.text) + "
"; // read-only; the note box never takes it - const cs = choicesOf(q), cur = d && d.decision && !d.aside ? cs.find(c => !c.re && c.kind === d.decision && (c.kind !== "alt" || c.alt === d.alt)) : null; - const letter = c => c.re ? " a" : c.kind === "accept" ? (cs[0].re || c.wn ? "" : " a") : c.kind === "own" ? " o" : c.kind === "defer" ? " d" : c.kind === "hedged" ? " h" : ""; + const cs = choicesOf(q), cur = d && d.decision && !d.aside ? cs.find(c => !c.re && c.kind === d.decision && (c.kind !== "alt" || c.alt === d.alt) && !!c.who === personOf(d)) : null; + const letter = c => c.re ? " a" : c.kind === "accept" ? (cs[0].re || c.wn ? "" : " a") : c.kind === "own" ? " o" : c.kind === "defer" && !c.who ? " d" : c.kind === "hedged" ? " h" : ""; const armed = armedOf(cs); $("choices").innerHTML = cs.map(c => '").join(""); const needText = armed && needsNote(armed) && !text, frozen = wrapFreeze() > 0; const label = armed ? (st === "wait" ? "Answer anyway: " : "Save: ") + (armed.kind === "accept" && !armed.re && (armed.wn || withNote(q, armed)) ? "Accept with note" : armed.label) : "Save"; const asks = armed && armed.kind === "own" && /\?$/.test(text), cut = !asks && midSentence(text); - $("decideRow").innerHTML = '' + (frozen ? "Wrapping up" : needText ? (armed.kind === "hedged" ? "Hedged needs a condition" : armed.wn ? "Accept with note needs a note" : "Own answer needs a note") : "Ctrl+Enter") + "" + + $("decideRow").innerHTML = '' + (frozen ? "Wrapping up" : needText ? (armed.kind === "hedged" ? "Hedged needs a condition" : armed.wn ? "Accept with note needs a note" : armed.who ? "Name who decides in the note" : "Own answer needs a note") : "Ctrl+Enter") + "" + (asks ? 'This reads as a question. Ask Claude instead?" : "") + (cut ? 'This note looks cut off. Finish it before saving?' : "") + (d && d.decision && !d.aside ? '' : ""); @@ -1294,6 +1299,8 @@

Interview

let h = "

" + (open.length ? open.length + " still open" : waits.length ? "Your part is done for now. Claude is researching " + waits.map(q => esc(q.id)).join(", ") + "." : "All answered") + "

" + esc(st) + "
"; if (open.length) h += '

Still open

' + open.map(q => refHtml(q.id)).join(" ") + "

"; if (waits.length) h += '

Pending research

' + waits.map(q => refHtml(q.id)).join(" ") + "

"; + const held = l.filter(q => stateOf(q) === "defer" && personOf(decisionOf(q))); + if (held.length) h += '

Someone else decides

' + held.map(q => refHtml(q.id) + " " + esc(trunc(decisionOf(q).text.slice(PERSON.length), 80))).join("; ") + '

At wrap-up Claude can turn these into a questionnaire for that person to fill in.

'; setHtml($("sumTop"), h); setHtml($("uBox"), uBoxHtml()); setHtml($("cBox"), cBoxHtml()); const ta = $("offText"); if (ta && document.activeElement !== ta && ta.value !== (S.offDraft || "")) ta.value = S.offDraft || ""; h = '

Decisions

' + @@ -1565,11 +1572,11 @@

Interview

function arm(n){ const q = Q[S.sel]; if (!q || S.busy) return; const c = choicesOf(q).find(x => x.n === n); if (!c) return; - S.armed = {kind: c.kind, alt: c.alt ?? null, re: !!c.re, wn: !!c.wn}; renderAnswer(); + S.armed = {kind: c.kind, alt: c.alt ?? null, re: !!c.re, wn: !!c.wn, who: !!c.who}; renderAnswer(); if (needsNote(c)) $("note").focus(); } const noteKind = k => k === "own" || k === "hedged"; // decisions whose note is the answer itself - const needsNote = c => (noteKind(c.kind) || !!c.wn) && keptText(c) === undefined; // Accept with note is empty without a note + const needsNote = c => (noteKind(c.kind) || !!c.wn || !!c.who) && keptText(c) === undefined; // Accept with note is empty without a note function keptText(c){ // Reconfirm sends a note typed over the kept one, else the kept note; never an empty own answer or condition if (!c.re || (!c.keep && noteKind(c.kind))) return undefined; const t = $("note").value.trim(); @@ -1647,13 +1654,13 @@

Interview

const keep = keptText(c); if (c.kind === "accept" && waitingReply(q) && !await confirmAccept()) return; const note = keep !== undefined ? keep : $("note").value.trim(); - if ((noteKind(c.kind) || c.wn) && !note) { toast(c.kind === "hedged" ? "Hedged needs a condition in the note." : c.wn ? "Accept with note needs a note." : "Own answer needs a note.", true); $("note").focus(); return; } + if ((noteKind(c.kind) || c.wn || c.who) && !note) { toast(c.kind === "hedged" ? "Hedged needs a condition in the note." : c.wn ? "Accept with note needs a note." : c.who ? "Name who decides in the note." : "Own answer needs a note.", true); $("note").focus(); return; } if (c.kind === "hedged" && note.length > LINE_CAP) { toast("The condition is too long: at most " + LINE_CAP + " characters.", true); $("note").focus(); return; } if (c.kind === "own" && keep === undefined) { // own text that starts from a card label is most often an accept with a remark const r = await resolveRef(q, note, "Save as own answer"); if (r === false) return; if (r) { await send(r.kind, r.alt, undefined, undefined, r.kind === "accept"); return; } } - await send(c.kind, c.alt, undefined, keep, withNote(q, c)); + await send(c.kind, c.alt, undefined, c.who && !note.startsWith(PERSON) ? PERSON + note : keep, withNote(q, c)); } function confirmAccept(){ // Claude has not answered your ask yet, so the recommendation may still change const dlg = $("dlg"); @@ -1964,7 +1971,7 @@

Interview

if (t.closest("#keysClose")) { $("keysDlg").close(); return; } if (t.closest("#finClose")) { $("finDlg").close(); return; } if (t.closest("[data-sum]")) { select(SUMMARY, {animate: true}); return; } - const ref = t.closest(".ref[data-q], .qbtn"); if (ref) { select(ref.dataset.q, {animate: true}); return; } + const ref = t.closest(".ref[data-q], .qbtn"); if (ref) { const inRail = !!ref.closest("nav.rail"); select(ref.dataset.q, {animate: true}); if (inRail && matchMedia("(max-width:820px)").matches) document.querySelector("main.detail").scrollIntoView({block: "start"}); return; } // a phone stacks the list below the question const au = t.closest("[data-auditround]"); if (au) { const s = sections().secs.find(x => x.round === au.dataset.auditround); if (s) acceptAllDialog(s.items, s.title, s.round); return; } const aa = t.closest("[data-acceptall]"); if (aa) { acceptAllDialog(S.doc.questions.filter(q => q.group === aa.dataset.acceptall), groupTitle(aa.dataset.acceptall)); return; } const sh = t.closest(".sec-h"); diff --git a/plugins/planning/surface/tests/ui_a.js b/plugins/planning/surface/tests/ui_a.js index dc1c84bac9..cc235022be 100644 --- a/plugins/planning/surface/tests/ui_a.js +++ b/plugins/planning/surface/tests/ui_a.js @@ -140,18 +140,31 @@ async page => { ok("Esc closes full screen", await page.evaluate(() => document.getElementById("fs").hidden)); await page.keyboard.press("Escape"); - // medium widths: the agrid is one column, the rail cut is marked, nothing widens the page + // medium widths: the agrid stays two columns down to 900 px, the rail cut is marked, nothing widens the page await page.click('.qbtn[data-q="N2"]'); await page.waitForTimeout(200); + for (const [w, h] of [[1440, 900], [1000, 800]]) { // the dock never starves the question, and the choices never scroll inside it + await page.setViewportSize({width: w, height: h}); await page.waitForTimeout(250); + const m = await page.evaluate(() => { const c = document.getElementById("choices"), d = document.getElementById("dscroll"), main = document.querySelector("main.detail"), save = document.querySelector("[data-save]").getBoundingClientRect(), dock = document.getElementById("answer").getBoundingClientRect(); + return {choicesScroll: c.scrollHeight > c.clientHeight + 1, detail: d.clientHeight, main: main.clientHeight, saveShown: save.top >= dock.top && save.bottom <= dock.bottom + 1}; }); + ok("at " + w + "x" + h + " the question keeps at least 40% of the pane, every choice shows without its own scroller, and Save is in view", !m.choicesScroll && m.detail >= 0.4 * m.main && m.saveShown, JSON.stringify(m)); + } for (const w of [1024, 820]) { await page.setViewportSize({width: w, height: 860}); await page.waitForTimeout(250); const m = await page.evaluate(() => ({cols: getComputedStyle(document.querySelector(".agrid")).gridTemplateColumns.split(" ").length, wide: document.scrollingElement.scrollWidth - innerWidth, railB: getComputedStyle(document.querySelector("nav.rail")).borderBottomWidth, choice: getComputedStyle(document.querySelector(".choice .t")).webkitLineClamp})); - ok("at " + w + " px the answer grid is one column and the page does not widen", m.cols === 1 && m.wide <= 1, JSON.stringify(m)); + ok("at " + w + " px the answer grid is " + (w > 900 ? "two columns" : "one column") + " and the page does not widen", m.cols === (w > 900 ? 2 : 1) && m.wide <= 1, JSON.stringify(m)); if (w === 820) ok("at 820 px the rail ends in a border", m.railB === "1px", m.railB); ok("an option title may take two lines at " + w + " px", m.choice === "2", m.choice); await page.click(".sumbtn"); await page.waitForTimeout(250); ok("the summary does not widen the page at " + w + " px", await page.evaluate(() => document.scrollingElement.scrollWidth <= innerWidth + 1)); await page.click('.qbtn[data-q="N2"]').catch(() => {}); await page.waitForTimeout(150); } + await page.setViewportSize({width: 390, height: 844}); await page.waitForTimeout(250); + const ph = await page.evaluate(() => ({main: Math.round(document.querySelector("main.detail").getBoundingClientRect().top + scrollY), rail: Math.round(document.querySelector("nav.rail").getBoundingClientRect().top + scrollY)})); + ok("at 390 px the current question comes before the question list", ph.main < ph.rail, JSON.stringify(ph)); + await page.click('.rail-list .qbtn[data-q="N1"]'); await page.waitForTimeout(300); + const pt = await page.evaluate(() => ({sel: (document.querySelector('.qbtn[aria-current="true"]') || {}).dataset?.q, top: Math.round(document.querySelector("main.detail").getBoundingClientRect().top)})); + ok("at 390 px a pick from the list scrolls its question into view", pt.sel === "N1" && pt.top >= -1 && pt.top < 200, JSON.stringify(pt)); + await page.evaluate(() => scrollTo(0, 0)); await page.click('.qbtn[data-q="N2"]').catch(() => {}); await page.setViewportSize({width: 1400, height: 860}); await page.waitForTimeout(250); ok("the meter title states the archived count", /\d+ archived/.test(await page.getAttribute("#meterText", "title")), await page.getAttribute("#meterText", "title")); await page.selectOption("#filter", "pending"); await page.waitForTimeout(200); diff --git a/plugins/planning/surface/tests/ui_b.js b/plugins/planning/surface/tests/ui_b.js index 69591241e8..c15e4a2561 100644 --- a/plugins/planning/surface/tests/ui_b.js +++ b/plugins/planning/surface/tests/ui_b.js @@ -26,8 +26,8 @@ async page => { await page.waitForTimeout(900); ok("Claude line reads 'Claude is working on ...'", /^Claude is working on /.test(await page.textContent("#claudeLine")), await page.textContent("#claudeLine")); await page.setViewportSize({width: 1280, height: 720}); await page.waitForTimeout(200); - const dh = await page.evaluate(() => ({h: document.querySelector("fieldset.decide").getBoundingClientRect().height, vh: innerHeight})); - ok("at 1280x720 the decide fieldset is at most 40% of the viewport", dh.h <= dh.vh * 0.4 + 1, JSON.stringify(dh)); + const dh = await page.evaluate(() => { const c = document.getElementById("choices"); return {detail: document.getElementById("dscroll").clientHeight, main: document.querySelector("main.detail").clientHeight, choicesScroll: c.scrollHeight > c.clientHeight + 1}; }); + ok("at 1280x720 the question keeps at least 40% of the pane and the choices have no scroller of their own", dh.detail >= dh.main * 0.4 && !dh.choicesScroll, JSON.stringify(dh)); await page.setViewportSize({width: 390, height: 844}); await page.waitForTimeout(200); const ph = await page.evaluate(() => { const c = document.getElementById("claudeLine"); return {hs: document.documentElement.scrollWidth - innerWidth, cut: c.scrollWidth > c.clientWidth + 1 || c.scrollHeight > c.clientHeight + 1, shown: !c.hidden}; }); ok("at 390x844 there is no horizontal page scroll and the status line is not truncated", ph.hs <= 0 && ph.shown && !ph.cut, JSON.stringify(ph)); diff --git a/plugins/planning/surface/tests/ui_c.js b/plugins/planning/surface/tests/ui_c.js index 5893c027a4..3cfa8e2087 100644 --- a/plugins/planning/surface/tests/ui_c.js +++ b/plugins/planning/surface/tests/ui_c.js @@ -123,6 +123,17 @@ async page => { s = await state(); ok("a hedged question's commitments count in To confirm", (await counter()) === (openAssumptions(s) ? open(openAssumptions(s)) : ""), (await counter()) + " vs " + openAssumptions(s)); await page.click("main.detail h3"); await page.keyboard.press("r"); await page.waitForTimeout(600); + // Someone else decides: a defer that names the person who holds the decision + await pick("Q3"); await page.click('.choice:has-text("Someone else decides")'); await page.waitForTimeout(150); + ok("Someone else decides needs a note naming who before it saves", /Someone else decides/.test(await armed()) && await page.$eval("[data-save]", el => el.disabled) && /Name who decides/.test(await page.textContent("#decideRow")), await armed()); + await page.click("#note"); await page.keyboard.type("Dana, the DBA"); await page.keyboard.press("Control+Enter"); await page.waitForTimeout(700); + const ep = await events(), pd = ep[ep.length - 1]; + ok("Someone else decides posts a defer whose text names the person", pd.id === "Q3" && pd.kind === "defer" && pd.text === "someone else decides: Dana, the DBA", JSON.stringify(pd)); + await pick("Q3"); + ok("the person-held deferral shows as Someone else decides, not plain Defer", /Someone else decides/.test(await page.textContent(".choice.current").catch(() => "")), await page.textContent(".choice.current").catch(() => "")); + await page.click(".sumbtn"); await page.waitForTimeout(300); + ok("the summary lists the person-held deferral and the questionnaire hand-off", /Q3/.test(await page.textContent("#personHeld").catch(() => "")) && /Dana, the DBA/.test(await page.textContent("#personHeld").catch(() => "")) && /questionnaire/.test(await page.textContent("#personHeld").catch(() => "")), await page.textContent("#personHeld").catch(() => "none")); + await pick("Q3"); await page.click("main.detail h3"); await page.keyboard.press("r"); await page.waitForTimeout(600); await pick("Q1"); await page.click("main.detail h3"); const nBeforeReopen = (await events()).length; await page.keyboard.press("r"); await page.waitForTimeout(700); @@ -374,8 +385,8 @@ async page => { // SPEC 4.1: mobile stacks, no horizontal scroll at 800 px await page.setViewportSize({width: 800, height: 900}); await page.waitForTimeout(300); - const mob = await page.evaluate(() => { const r = document.querySelector("nav.rail").getBoundingClientRect(), d = document.querySelector("main.detail").getBoundingClientRect(), a = document.getElementById("answer"); a.scrollIntoView(); const ar = a.getBoundingClientRect(); return {hscroll: document.scrollingElement.scrollWidth > innerWidth, stacked: r.bottom <= d.top + 1, answer: ar.height > 0 && ar.top < innerHeight && ar.bottom > 0}; }); - ok("mobile: panes stack, no horizontal scroll, answer pane reachable", !mob.hscroll && mob.stacked && mob.answer, JSON.stringify(mob)); + const mob = await page.evaluate(() => { const r = document.querySelector("nav.rail").getBoundingClientRect(), d = document.querySelector("main.detail").getBoundingClientRect(), a = document.getElementById("answer"); a.scrollIntoView(); const ar = a.getBoundingClientRect(); return {hscroll: document.scrollingElement.scrollWidth > innerWidth, stacked: d.bottom <= r.top + 1, answer: ar.height > 0 && ar.top < innerHeight && ar.bottom > 0}; }); + ok("mobile: panes stack with the question first, no horizontal scroll, answer pane reachable", !mob.hscroll && mob.stacked && mob.answer, JSON.stringify(mob)); await page.setViewportSize({width: 1400, height: 860}); await page.waitForTimeout(300); ok("AC36: page never scrolls at 1400 by 860", await page.evaluate(() => document.scrollingElement.scrollHeight <= innerHeight + 1 && document.scrollingElement.scrollWidth <= innerWidth)); From 601bc94cdf560fd398339fdf524d714f5c3b0f28 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:31:10 -0400 Subject: [PATCH 2/7] fix(planning): mark the pinned Save bar and check the last choice clears it Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C8UZGiNHApTFuNQ9zKFKk3 --- plugins/planning/surface/index.html | 2 +- plugins/planning/surface/tests/ui_a.js | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/plugins/planning/surface/index.html b/plugins/planning/surface/index.html index 8b38d1c223..22c6dc8d33 100644 --- a/plugins/planning/surface/index.html +++ b/plugins/planning/surface/index.html @@ -250,7 +250,7 @@ fieldset{border:1px solid var(--line);border-radius:6px;margin:0;padding:6px 10px 8px;min-width:0} legend{font:500 11.5px var(--mono);letter-spacing:.07em;text-transform:uppercase;color:var(--muted);padding:0 4px} .choices{display:grid;gap:4px;padding:2px} -#decideRow{position:sticky;bottom:0;background:var(--panel);padding:4px 0 6px;z-index:1} +#decideRow{position:sticky;bottom:0;background:var(--panel);padding:6px 0;margin-top:2px;z-index:1;box-shadow:0 -1px 0 var(--line),0 -8px 10px -8px rgba(0,0,0,.2)} .choice{display:grid;grid-template-columns:auto minmax(0,1fr);gap:8px;align-items:center;padding:4px 8px;border:1px solid var(--line);border-radius:6px;cursor:pointer;font-size:13.5px} .choice:hover{border-color:var(--line-strong)} .choice input{margin:0;accent-color:var(--accent)} diff --git a/plugins/planning/surface/tests/ui_a.js b/plugins/planning/surface/tests/ui_a.js index cc235022be..b169f4321b 100644 --- a/plugins/planning/surface/tests/ui_a.js +++ b/plugins/planning/surface/tests/ui_a.js @@ -147,6 +147,9 @@ async page => { const m = await page.evaluate(() => { const c = document.getElementById("choices"), d = document.getElementById("dscroll"), main = document.querySelector("main.detail"), save = document.querySelector("[data-save]").getBoundingClientRect(), dock = document.getElementById("answer").getBoundingClientRect(); return {choicesScroll: c.scrollHeight > c.clientHeight + 1, detail: d.clientHeight, main: main.clientHeight, saveShown: save.top >= dock.top && save.bottom <= dock.bottom + 1}; }); ok("at " + w + "x" + h + " the question keeps at least 40% of the pane, every choice shows without its own scroller, and Save is in view", !m.choicesScroll && m.detail >= 0.4 * m.main && m.saveShown, JSON.stringify(m)); + const clr = await page.evaluate(() => { const a = document.getElementById("answer"); a.scrollTop = a.scrollHeight; const cs = document.querySelectorAll("#choices .choice"), l = cs[cs.length - 1].getBoundingClientRect(), s = document.getElementById("decideRow").getBoundingClientRect(), dock = a.getBoundingClientRect(); a.scrollTop = 0; + return {last: [Math.round(l.top), Math.round(l.bottom)], save: [Math.round(s.top), Math.round(s.bottom)], dock: [Math.round(dock.top), Math.round(dock.bottom)], clear: l.bottom <= s.top + 0.5 && l.top >= dock.top - 0.5}; }); + ok("at " + w + "x" + h + " scrolled to the end, the last choice sits fully clear of the pinned Save bar", clr.clear, JSON.stringify(clr)); } for (const w of [1024, 820]) { await page.setViewportSize({width: w, height: 860}); await page.waitForTimeout(250); From 2ffa8fd32758f6c1b4598ccc4c247b5c7644e1db Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:39:03 -0400 Subject: [PATCH 3/7] fix(planning): put the interview page's Save in a non-scrolling footer The choice list is the scrolling region (flex:1, min-height:0) and the Save row a flex:none footer, so no choice renders behind or below Save. The ui_a check clips each choice to its scroll containers and fails on 601bc94 at rest at 1000x800 and 1000x640. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C8UZGiNHApTFuNQ9zKFKk3 --- plugins/planning/surface/index.html | 12 +++++++----- plugins/planning/surface/tests/ui_a.js | 25 ++++++++++++++++++++----- plugins/planning/surface/tests/ui_b.js | 2 +- 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/plugins/planning/surface/index.html b/plugins/planning/surface/index.html index 22c6dc8d33..811a6da831 100644 --- a/plugins/planning/surface/index.html +++ b/plugins/planning/surface/index.html @@ -238,7 +238,7 @@ .rcpt span.no{color:var(--chg)} .empty{color:var(--faint);font-size:14px} -section.answer{border-top:1px solid var(--line-strong);background:var(--panel);padding:10px var(--pad) 10px;display:grid;align-content:start;gap:8px;min-height:0;overflow-y:auto;overscroll-behavior:contain;box-shadow:0 -6px 14px -12px rgba(0,0,0,.25)} +section.answer{border-top:1px solid var(--line-strong);background:var(--panel);padding:10px var(--pad) 10px;display:flex;flex-direction:column;gap:8px;min-height:0;overflow-y:auto;overscroll-behavior:contain;box-shadow:0 -6px 14px -12px rgba(0,0,0,.25)} section.answer[hidden]{display:none} .cur{display:flex;flex-wrap:wrap;gap:6px 10px;align-items:center;font-size:13.5px;min-height:22px} .cur q{color:var(--muted)} @@ -246,11 +246,13 @@ .cur .earlier{flex:1 0 100%;display:grid;gap:2px;max-height:9em;overflow-y:auto;overflow-wrap:anywhere} .cur .earlier q{white-space:pre-wrap;color:var(--ink)} .cur .aside-why{flex:1 1 20em} -.agrid{display:grid;grid-template-columns:minmax(0,1.05fr) minmax(0,1fr);gap:14px;align-items:start} +.agrid{display:grid;grid-template-columns:minmax(0,1.05fr) minmax(0,1fr);grid-template-rows:minmax(0,1fr);gap:14px;flex:1 1 auto;min-height:0} +.compose{align-content:start} +fieldset.decide{display:flex;flex-direction:column;min-height:0} fieldset{border:1px solid var(--line);border-radius:6px;margin:0;padding:6px 10px 8px;min-width:0} legend{font:500 11.5px var(--mono);letter-spacing:.07em;text-transform:uppercase;color:var(--muted);padding:0 4px} -.choices{display:grid;gap:4px;padding:2px} -#decideRow{position:sticky;bottom:0;background:var(--panel);padding:6px 0;margin-top:2px;z-index:1;box-shadow:0 -1px 0 var(--line),0 -8px 10px -8px rgba(0,0,0,.2)} +.choices{display:grid;align-content:start;gap:4px;padding:2px;flex:1 1 auto;min-height:0;overflow-y:auto;overscroll-behavior:contain} +#decideRow{flex:none} .choice{display:grid;grid-template-columns:auto minmax(0,1fr);gap:8px;align-items:center;padding:4px 8px;border:1px solid var(--line);border-radius:6px;cursor:pointer;font-size:13.5px} .choice:hover{border-color:var(--line-strong)} .choice input{margin:0;accent-color:var(--accent)} @@ -353,7 +355,7 @@ .flash{animation:flash 1.8s ease-out} @keyframes flash{0%{box-shadow:0 0 0 3px var(--chg);background-color:var(--chg-soft)}100%{box-shadow:0 0 0 0 transparent}} -@media (max-width:900px){ .agrid{grid-template-columns:minmax(0,1fr)} } +@media (max-width:900px){ .agrid{grid-template-columns:minmax(0,1fr);grid-template-rows:none;flex:none} .choices{overflow:visible} } @media (max-width:820px){ body{overflow:auto} .app{height:auto;min-height:100vh} diff --git a/plugins/planning/surface/tests/ui_a.js b/plugins/planning/surface/tests/ui_a.js index b169f4321b..768d470646 100644 --- a/plugins/planning/surface/tests/ui_a.js +++ b/plugins/planning/surface/tests/ui_a.js @@ -142,14 +142,29 @@ async page => { // medium widths: the agrid stays two columns down to 900 px, the rail cut is marked, nothing widens the page await page.click('.qbtn[data-q="N2"]'); await page.waitForTimeout(200); - for (const [w, h] of [[1440, 900], [1000, 800]]) { // the dock never starves the question, and the choices never scroll inside it + for (const [w, h] of [[1440, 900], [1000, 800], [1000, 640]]) { // the dock never starves the question, Save sits in a footer, and no choice renders behind or below it await page.setViewportSize({width: w, height: h}); await page.waitForTimeout(250); const m = await page.evaluate(() => { const c = document.getElementById("choices"), d = document.getElementById("dscroll"), main = document.querySelector("main.detail"), save = document.querySelector("[data-save]").getBoundingClientRect(), dock = document.getElementById("answer").getBoundingClientRect(); return {choicesScroll: c.scrollHeight > c.clientHeight + 1, detail: d.clientHeight, main: main.clientHeight, saveShown: save.top >= dock.top && save.bottom <= dock.bottom + 1}; }); - ok("at " + w + "x" + h + " the question keeps at least 40% of the pane, every choice shows without its own scroller, and Save is in view", !m.choicesScroll && m.detail >= 0.4 * m.main && m.saveShown, JSON.stringify(m)); - const clr = await page.evaluate(() => { const a = document.getElementById("answer"); a.scrollTop = a.scrollHeight; const cs = document.querySelectorAll("#choices .choice"), l = cs[cs.length - 1].getBoundingClientRect(), s = document.getElementById("decideRow").getBoundingClientRect(), dock = a.getBoundingClientRect(); a.scrollTop = 0; - return {last: [Math.round(l.top), Math.round(l.bottom)], save: [Math.round(s.top), Math.round(s.bottom)], dock: [Math.round(dock.top), Math.round(dock.bottom)], clear: l.bottom <= s.top + 0.5 && l.top >= dock.top - 0.5}; }); - ok("at " + w + "x" + h + " scrolled to the end, the last choice sits fully clear of the pinned Save bar", clr.clear, JSON.stringify(clr)); + ok("at " + w + "x" + h + " the question keeps at least 40% of the pane and Save is in view" + (w === 1440 ? ", with every choice shown unscrolled" : ""), (w !== 1440 || !m.choicesScroll) && m.detail >= 0.4 * m.main && m.saveShown, JSON.stringify(m)); + const bad = await page.evaluate(() => { // each choice's rect clipped to every scroll container above it, at rest and scrolled to the end + const a = document.getElementById("answer"), c = document.getElementById("choices"), out = []; + const check = when => { + const s = document.getElementById("decideRow").getBoundingClientRect(); + document.querySelectorAll("#choices .choice").forEach((el, i) => { + let r = el.getBoundingClientRect(), top = r.top, bot = r.bottom; + for (const p of [c, a]) { const b = p.getBoundingClientRect(); top = Math.max(top, b.top); bot = Math.min(bot, b.bottom); } + if (bot - top <= 0.5) return; + if (top < s.bottom - 0.5 && bot > s.top + 0.5) out.push(when + " #" + (i + 1) + " behind Save [" + Math.round(top) + "," + Math.round(bot) + "] vs [" + Math.round(s.top) + "," + Math.round(s.bottom) + "]"); + else if (top >= s.bottom - 0.5) out.push(when + " #" + (i + 1) + " below Save at " + Math.round(top)); + }); + }; + a.scrollTop = 0; c.scrollTop = 0; check("rest"); + a.scrollTop = a.scrollHeight; c.scrollTop = c.scrollHeight; check("end"); + a.scrollTop = 0; c.scrollTop = 0; + return out; + }); + ok("at " + w + "x" + h + " no choice renders behind or below Save, at rest or scrolled to the end", bad.length === 0, bad.join("; ")); } for (const w of [1024, 820]) { await page.setViewportSize({width: w, height: 860}); await page.waitForTimeout(250); diff --git a/plugins/planning/surface/tests/ui_b.js b/plugins/planning/surface/tests/ui_b.js index c15e4a2561..7d6afd3a02 100644 --- a/plugins/planning/surface/tests/ui_b.js +++ b/plugins/planning/surface/tests/ui_b.js @@ -27,7 +27,7 @@ async page => { ok("Claude line reads 'Claude is working on ...'", /^Claude is working on /.test(await page.textContent("#claudeLine")), await page.textContent("#claudeLine")); await page.setViewportSize({width: 1280, height: 720}); await page.waitForTimeout(200); const dh = await page.evaluate(() => { const c = document.getElementById("choices"); return {detail: document.getElementById("dscroll").clientHeight, main: document.querySelector("main.detail").clientHeight, choicesScroll: c.scrollHeight > c.clientHeight + 1}; }); - ok("at 1280x720 the question keeps at least 40% of the pane and the choices have no scroller of their own", dh.detail >= dh.main * 0.4 && !dh.choicesScroll, JSON.stringify(dh)); + ok("at 1280x720 the question keeps at least 40% of the pane", dh.detail >= dh.main * 0.4, JSON.stringify(dh)); await page.setViewportSize({width: 390, height: 844}); await page.waitForTimeout(200); const ph = await page.evaluate(() => { const c = document.getElementById("claudeLine"); return {hs: document.documentElement.scrollWidth - innerWidth, cut: c.scrollWidth > c.clientWidth + 1 || c.scrollHeight > c.clientHeight + 1, shown: !c.hidden}; }); ok("at 390x844 there is no horizontal page scroll and the status line is not truncated", ph.hs <= 0 && ph.shown && !ph.cut, JSON.stringify(ph)); From c1c517e763127b7ae382c5edc7ee391e8a5dbf23 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 12 Oct 2026 01:32:50 -0400 Subject: [PATCH 4/7] fix(planning): keep Save in view at 821-900 px and keep person-held deferrals person-held At 821-900 px the one-column answer grid now keeps the choices scrolling above a pinned Save row; only phone width lets the answer area grow. A Reconfirm of a someone-else-decides deferral keeps the person prefix when a new name is typed, and a note that repeats the label in any case is not prefixed twice. surface.md names /planning:questionnaire only in an engineering session and only if enabled, as SKILL.md Step 5 does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C8UZGiNHApTFuNQ9zKFKk3 --- plugins/planning/skills/interview/context/surface.md | 2 +- plugins/planning/surface/index.html | 12 +++++++----- plugins/planning/surface/tests/ui_a.js | 2 +- plugins/planning/surface/tests/ui_c.js | 4 ++-- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/plugins/planning/skills/interview/context/surface.md b/plugins/planning/skills/interview/context/surface.md index ff60e2c411..ef6fe799bc 100644 --- a/plugins/planning/skills/interview/context/surface.md +++ b/plugins/planning/skills/interview/context/surface.md @@ -217,7 +217,7 @@ Every question states its decision in plain words. Before `add-round`, scan each - **Dedicated session.** When a page session starts in a heavy context, offer once, in one line, to run the interview in a small dedicated session so each wake is cheap. Never force it. - **Decomposition at wrap-up (R6).** When enabled, name `/work-items:decompose` as a step the user runs; never run it. The local outputs are `export-brief` and `export-report`. `/planning:wayfind` is not a wrap-up offer: it is named only where SKILL.md's Stance and Step 5 name it, for an effort too big and still too foggy to phrase as questions. -- **Someone else decides.** The page's `Someone else decides` choice records a `defer` whose `text` starts `someone else decides: ` and names who holds the decision; the summary lists these under the same heading. They are the person-held deferrals SKILL.md Step 5 routes to `/planning:questionnaire`, so name it in the `finish` op's `next` when any exist. +- **Someone else decides.** The page's `Someone else decides` choice records a `defer` whose `text` starts `someone else decides: ` and names who holds the decision; the summary lists these under the same heading. Count them with SKILL.md Step 5's person-held deferrals: in an engineering session, name `/planning:questionnaire` (if enabled) in the `finish` op's `next` when any exist; a general session stays terminal. ## Wrap-up diff --git a/plugins/planning/surface/index.html b/plugins/planning/surface/index.html index 95c3f7ced0..aa783b0d32 100644 --- a/plugins/planning/surface/index.html +++ b/plugins/planning/surface/index.html @@ -356,7 +356,7 @@ .flash{animation:flash 1.8s ease-out} @keyframes flash{0%{box-shadow:0 0 0 3px var(--chg);background-color:var(--chg-soft)}100%{box-shadow:0 0 0 0 transparent}} -@media (max-width:900px){ .agrid{grid-template-columns:minmax(0,1fr);grid-template-rows:none;flex:none} .choices{overflow:visible} } +@media (max-width:900px){ .agrid{grid-template-columns:minmax(0,1fr);grid-template-rows:minmax(0,1fr) auto} } @media (max-width:820px){ body{overflow:auto} .app{height:auto;min-height:100vh} @@ -367,6 +367,8 @@ nav.rail{order:1} main.detail{grid-template-rows:auto auto} section.answer{overflow:visible} + .agrid{grid-template-rows:none;flex:none} + .choices{overflow:visible} h1{font-size:20px;margin-top:2px} header.top{gap:4px;padding-bottom:4px} .cline{white-space:normal;overflow-wrap:anywhere;overflow:visible;text-overflow:clip} @@ -1037,8 +1039,8 @@

Interview

const a = (q.alternatives || []).find(x => x.key === d.alt); if (k === "alt" && a && !altsOf(q).includes(a)) return {re: true, kind: "accept", alt: null, keep: d.text || "", label: "Reconfirm", text: "the recommendation"}; if (k === "alt" && !a) return null; // a revise removed the kept alternative: nothing to reconfirm - const text = k === "accept" ? "the recommendation" : k === "alt" ? "alternative (" + d.alt + "): " + a.text : k === "own" ? "your own answer" : k === "hedged" ? "hedged: " + (d.text || "") : "deferred"; - return {re: true, kind: k, alt: k === "alt" ? d.alt : null, keep: d.text || "", label: "Reconfirm", text}; + const text = k === "accept" ? "the recommendation" : k === "alt" ? "alternative (" + d.alt + "): " + a.text : k === "own" ? "your own answer" : k === "hedged" ? "hedged: " + (d.text || "") : personOf(d) ? d.text : "deferred"; + return {re: true, kind: k, alt: k === "alt" ? d.alt : null, keep: d.text || "", label: "Reconfirm", text, who: personOf(d)}; // who: a new name typed over the kept one stays person-held } const normAlt = t => String(t).toLowerCase().replace(/\(recommended\)/g, " ").split(/\s+/).filter(Boolean).join(" ").replace(/[.,;:!?)]+$/, "").trim(); function altsOf(q){ // alternatives minus any that restate the recommendation: those fold into Accept @@ -1309,7 +1311,7 @@

Interview

if (open.length) h += '

Still open

' + open.map(q => refHtml(q.id)).join(" ") + "

"; if (waits.length) h += '

Pending research

' + waits.map(q => refHtml(q.id)).join(" ") + "

"; const held = l.filter(q => stateOf(q) === "defer" && personOf(decisionOf(q))); - if (held.length) h += '

Someone else decides

' + held.map(q => refHtml(q.id) + " " + esc(trunc(decisionOf(q).text.slice(PERSON.length), 80))).join("; ") + '

At wrap-up Claude can turn these into a questionnaire for that person to fill in.

'; + if (held.length) h += '

Someone else decides

' + held.map(q => refHtml(q.id) + " " + esc(trunc(decisionOf(q).text.slice(PERSON.length), 80))).join("; ") + '

At wrap-up Claude may suggest a questionnaire for that person to fill in.

'; setHtml($("sumTop"), h); setHtml($("uBox"), uBoxHtml()); setHtml($("cBox"), cBoxHtml()); const ta = $("offText"); if (ta && document.activeElement !== ta && ta.value !== (S.offDraft || "")) ta.value = S.offDraft || ""; h = '

Decisions

QuestionDecisionDecided
' + @@ -1670,7 +1672,7 @@

Interview

const r = await resolveRef(q, note, "Save as own answer"); if (r === false) return; if (r) { await send(r.kind, r.alt, undefined, undefined, r.kind === "accept"); return; } } - await send(c.kind, c.alt, undefined, c.who && !note.startsWith(PERSON) ? PERSON + note : keep, withNote(q, c)); + await send(c.kind, c.alt, undefined, c.who ? PERSON + note.replace(/^someone else decides:\s*/i, "") : keep, withNote(q, c)); } function confirmAccept(){ // Claude has not answered your ask yet, so the recommendation may still change const dlg = $("dlg"); diff --git a/plugins/planning/surface/tests/ui_a.js b/plugins/planning/surface/tests/ui_a.js index 768d470646..eb38a4a356 100644 --- a/plugins/planning/surface/tests/ui_a.js +++ b/plugins/planning/surface/tests/ui_a.js @@ -142,7 +142,7 @@ async page => { // medium widths: the agrid stays two columns down to 900 px, the rail cut is marked, nothing widens the page await page.click('.qbtn[data-q="N2"]'); await page.waitForTimeout(200); - for (const [w, h] of [[1440, 900], [1000, 800], [1000, 640]]) { // the dock never starves the question, Save sits in a footer, and no choice renders behind or below it + for (const [w, h] of [[1440, 900], [1000, 800], [1000, 640], [860, 640]]) { // the dock never starves the question, Save sits in a footer, and no choice renders behind or below it await page.setViewportSize({width: w, height: h}); await page.waitForTimeout(250); const m = await page.evaluate(() => { const c = document.getElementById("choices"), d = document.getElementById("dscroll"), main = document.querySelector("main.detail"), save = document.querySelector("[data-save]").getBoundingClientRect(), dock = document.getElementById("answer").getBoundingClientRect(); return {choicesScroll: c.scrollHeight > c.clientHeight + 1, detail: d.clientHeight, main: main.clientHeight, saveShown: save.top >= dock.top && save.bottom <= dock.bottom + 1}; }); diff --git a/plugins/planning/surface/tests/ui_c.js b/plugins/planning/surface/tests/ui_c.js index 26b7fc5b4c..bff3ba6135 100644 --- a/plugins/planning/surface/tests/ui_c.js +++ b/plugins/planning/surface/tests/ui_c.js @@ -126,9 +126,9 @@ async page => { // Someone else decides: a defer that names the person who holds the decision await pick("Q3"); await page.click('.choice:has-text("Someone else decides")'); await page.waitForTimeout(150); ok("Someone else decides needs a note naming who before it saves", /Someone else decides/.test(await armed()) && await page.$eval("[data-save]", el => el.disabled) && /Name who decides/.test(await page.textContent("#decideRow")), await armed()); - await page.click("#note"); await page.keyboard.type("Dana, the DBA"); await page.keyboard.press("Control+Enter"); await page.waitForTimeout(700); + await page.click("#note"); await page.keyboard.type("Someone else decides: Dana, the DBA"); await page.keyboard.press("Control+Enter"); await page.waitForTimeout(700); const ep = await events(), pd = ep[ep.length - 1]; - ok("Someone else decides posts a defer whose text names the person", pd.id === "Q3" && pd.kind === "defer" && pd.text === "someone else decides: Dana, the DBA", JSON.stringify(pd)); + ok("Someone else decides posts a defer whose text names the person once, even when the note repeats the label", pd.id === "Q3" && pd.kind === "defer" && pd.text === "someone else decides: Dana, the DBA", JSON.stringify(pd)); await pick("Q3"); ok("the person-held deferral shows as Someone else decides, not plain Defer", /Someone else decides/.test(await page.textContent(".choice.current").catch(() => "")), await page.textContent(".choice.current").catch(() => "")); await page.click(".sumbtn"); await page.waitForTimeout(300); From 5e21408a79192eaf3231fad5404a45ac9af147dd Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 12 Oct 2026 01:33:20 -0400 Subject: [PATCH 5/7] docs(planning): match the layout changelog fragment to the questionnaire conditions Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C8UZGiNHApTFuNQ9zKFKk3 --- .changes/planning/fix-interview-web-view-layout-88848e44.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changes/planning/fix-interview-web-view-layout-88848e44.md b/.changes/planning/fix-interview-web-view-layout-88848e44.md index acfb8fc80d..5401c76009 100644 --- a/.changes/planning/fix-interview-web-view-layout-88848e44.md +++ b/.changes/planning/fix-interview-web-view-layout-88848e44.md @@ -4,10 +4,10 @@ bump: minor ### Added -- The interview page has a `Someone else decides` choice: a deferral whose note names the person who holds the decision. The summary lists these, and at wrap-up they are the person-held deferrals `/planning:interview` routes to `/planning:questionnaire`. +- The interview page has a `Someone else decides` choice: a deferral whose note names the person who holds the decision. The summary lists these, and at wrap-up of an engineering session `/planning:interview` counts them with the person-held deferrals it routes to `/planning:questionnaire` (if enabled). ### Fixed -- The interview page's answer area no longer starves the question: the question keeps at least half the pane (or 18rem), every choice shows without its own scroller, the answer grid stays two columns down to 900 px, and Save stays in view. +- The interview page's answer area no longer starves the question: the question keeps at least half the pane (or 18rem), every choice shows without its own scroller when the pane has room, the answer grid stays two columns down to 900 px, and Save stays in view at every width. - At phone width the interview page shows the current question before the question list, and a pick from the list scrolls its question into view. - The interview surface reference no longer offers `/planning:wayfind` at wrap-up; it names wayfind only where the skill does. From 42efc2635c1d519a81f0b788e6581ce3e527b773 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 12 Oct 2026 01:42:59 -0400 Subject: [PATCH 6/7] docs(planning): drop the trailing space from a surface.md code span Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C8UZGiNHApTFuNQ9zKFKk3 --- plugins/planning/skills/interview/context/surface.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/planning/skills/interview/context/surface.md b/plugins/planning/skills/interview/context/surface.md index ef6fe799bc..4468553503 100644 --- a/plugins/planning/skills/interview/context/surface.md +++ b/plugins/planning/skills/interview/context/surface.md @@ -217,7 +217,7 @@ Every question states its decision in plain words. Before `add-round`, scan each - **Dedicated session.** When a page session starts in a heavy context, offer once, in one line, to run the interview in a small dedicated session so each wake is cheap. Never force it. - **Decomposition at wrap-up (R6).** When enabled, name `/work-items:decompose` as a step the user runs; never run it. The local outputs are `export-brief` and `export-report`. `/planning:wayfind` is not a wrap-up offer: it is named only where SKILL.md's Stance and Step 5 name it, for an effort too big and still too foggy to phrase as questions. -- **Someone else decides.** The page's `Someone else decides` choice records a `defer` whose `text` starts `someone else decides: ` and names who holds the decision; the summary lists these under the same heading. Count them with SKILL.md Step 5's person-held deferrals: in an engineering session, name `/planning:questionnaire` (if enabled) in the `finish` op's `next` when any exist; a general session stays terminal. +- **Someone else decides.** The page's `Someone else decides` choice records a `defer` whose `text` starts `someone else decides:` and a space, then names who holds the decision; the summary lists these under the same heading. Count them with SKILL.md Step 5's person-held deferrals: in an engineering session, name `/planning:questionnaire` (if enabled) in the `finish` op's `next` when any exist; a general session stays terminal. ## Wrap-up From ccaa1db6b8925bf8b402cec3fc2be973fcd8ca84 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 12 Oct 2026 01:49:55 -0400 Subject: [PATCH 7/7] fix(planning): require a name for a someone-else-decides deferral A note that only repeats the "Someone else decides:" label named nobody but passed the note-required check. The Save gate and the save path now check the name left after the label is stripped. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C8UZGiNHApTFuNQ9zKFKk3 --- plugins/planning/surface/index.html | 9 +++++---- plugins/planning/surface/tests/ui_c.js | 4 +++- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/plugins/planning/surface/index.html b/plugins/planning/surface/index.html index aa783b0d32..74933e7f72 100644 --- a/plugins/planning/surface/index.html +++ b/plugins/planning/surface/index.html @@ -495,7 +495,8 @@

Interview

const DEAF_AFTER = 30; // seconds with no watcher poll before the rung 5 message const LINE_CAP = 500; // the server's one-line cap, which a hedged condition must fit const PERSON = "someone else decides: "; // a defer whose text names the person who holds the decision - const personOf = d => !!d && d.decision === "defer" && String(d.text || "").startsWith(PERSON); + const named = t => String(t || "").replace(/^someone else decides:\s*/i, "").trim(); // the person a note names, without a repeated label + const personOf = d =>!!d && d.decision === "defer" && String(d.text || "").startsWith(PERSON); const FREEZE_MS = 10000; // longest wrap-up freeze on decision saves const KEYS = [["Ctrl/Cmd+Enter", "Save the armed choice with the note"], ["Ctrl/Cmd+Shift+Enter", "Ask Claude, no decision"], ["1 to 9", "Arm the Nth row of Decide, counted from the top"], ["a", "Arm Rec; on a stale question with a decision, Reconfirm it (the first row)"], ["o", "Arm Own answer and focus the note"], ["d", "Arm Defer"], ["h", "Arm Hedged (accept with a condition) and focus the note"], ["r", "Reopen"], ["i", "Focus the note"], @@ -1145,7 +1146,7 @@

Interview

const armed = armedOf(cs); $("choices").innerHTML = cs.map(c => '").join(""); - const needText = armed && needsNote(armed) && !text, frozen = wrapFreeze() > 0; + const needText = armed && needsNote(armed) && !(armed.who ? named(text) : text), frozen = wrapFreeze() > 0; const label = armed ? (st === "wait" ? "Answer anyway: " : "Save: ") + (armed.kind === "accept" && !armed.re && (armed.wn || withNote(q, armed)) ? "Accept with note" : armed.label) : "Save"; const asks = armed && armed.kind === "own" && /\?$/.test(text), cut = !asks && midSentence(text); $("decideRow").innerHTML = '' + (shut ? closedWhy() : frozen ? "Wrapping up" : needText ? (armed.kind === "hedged" ? "Hedged needs a condition" : armed.wn ? "Accept with note needs a note" : armed.who ? "Name who decides in the note" : "Own answer needs a note") : "Ctrl+Enter") + "" + @@ -1666,13 +1667,13 @@

Interview

const keep = keptText(c); if (c.kind === "accept" && waitingReply(q) && !await confirmAccept()) return; const note = keep !== undefined ? keep : $("note").value.trim(); - if ((noteKind(c.kind) || c.wn || c.who) && !note) { toast(c.kind === "hedged" ? "Hedged needs a condition in the note." : c.wn ? "Accept with note needs a note." : c.who ? "Name who decides in the note." : "Own answer needs a note.", true); $("note").focus(); return; } + if ((noteKind(c.kind) || c.wn || c.who) && !(c.who ? named(note) : note)) { toast(c.kind === "hedged" ? "Hedged needs a condition in the note." : c.wn ? "Accept with note needs a note." : c.who ? "Name who decides in the note." : "Own answer needs a note.", true); $("note").focus(); return; } if (c.kind === "hedged" && note.length > LINE_CAP) { toast("The condition is too long: at most " + LINE_CAP + " characters.", true); $("note").focus(); return; } if (c.kind === "own" && keep === undefined) { // own text that starts from a card label is most often an accept with a remark const r = await resolveRef(q, note, "Save as own answer"); if (r === false) return; if (r) { await send(r.kind, r.alt, undefined, undefined, r.kind === "accept"); return; } } - await send(c.kind, c.alt, undefined, c.who ? PERSON + note.replace(/^someone else decides:\s*/i, "") : keep, withNote(q, c)); + await send(c.kind, c.alt, undefined, c.who ? PERSON + named(note) : keep, withNote(q, c)); } function confirmAccept(){ // Claude has not answered your ask yet, so the recommendation may still change const dlg = $("dlg"); diff --git a/plugins/planning/surface/tests/ui_c.js b/plugins/planning/surface/tests/ui_c.js index bff3ba6135..c6537b20b6 100644 --- a/plugins/planning/surface/tests/ui_c.js +++ b/plugins/planning/surface/tests/ui_c.js @@ -126,7 +126,9 @@ async page => { // Someone else decides: a defer that names the person who holds the decision await pick("Q3"); await page.click('.choice:has-text("Someone else decides")'); await page.waitForTimeout(150); ok("Someone else decides needs a note naming who before it saves", /Someone else decides/.test(await armed()) && await page.$eval("[data-save]", el => el.disabled) && /Name who decides/.test(await page.textContent("#decideRow")), await armed()); - await page.click("#note"); await page.keyboard.type("Someone else decides: Dana, the DBA"); await page.keyboard.press("Control+Enter"); await page.waitForTimeout(700); + await page.click("#note"); await page.keyboard.type("Someone else decides:"); await page.waitForTimeout(150); + ok("a note that only repeats the label names nobody, so Save stays off", await page.$eval("[data-save]", el => el.disabled) && /Name who decides/.test(await page.textContent("#decideRow")), await page.textContent("#decideRow")); + await page.keyboard.type(" Dana, the DBA"); await page.keyboard.press("Control+Enter"); await page.waitForTimeout(700); const ep = await events(), pd = ep[ep.length - 1]; ok("Someone else decides posts a defer whose text names the person once, even when the note repeats the label", pd.id === "Q3" && pd.kind === "defer" && pd.text === "someone else decides: Dana, the DBA", JSON.stringify(pd)); await pick("Q3");
QuestionDecisionDecided