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..5401c76009
--- /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 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 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.
diff --git a/plugins/planning/skills/interview/context/surface.md b/plugins/planning/skills/interview/context/surface.md
index 02d514f59c..4468553503 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 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
diff --git a/plugins/planning/surface/index.html b/plugins/planning/surface/index.html
index e46941f4bb..74933e7f72 100644
--- a/plugins/planning/surface/index.html
+++ b/plugins/planning/surface/index.html
@@ -173,7 +173,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)}}
@@ -239,7 +239,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: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)}
@@ -247,10 +247,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;max-height:26vh;overflow-y:auto;padding:2px}
+.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 +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:1200px){ .agrid{grid-template-columns:minmax(0,1fr)} }
+@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}
@@ -361,13 +364,17 @@
.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}
+ .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}
.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}}
@@ -487,6 +494,9 @@
Interview
const FIRST_WAKE = "Not listening: approve any prompt in the terminal, or type next";
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 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"],
@@ -1030,8 +1040,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
@@ -1042,13 +1052,13 @@
Interview
const r = reconfirmOf(q), c = r ? [r] : [], w = withheld(q); // a withheld question has nothing to accept, with or without a note or condition
if (!w) 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"});
+ 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"});
if (!w) c.push({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 || {};
@@ -1131,15 +1141,15 @@
" + (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 += '
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
Question
Decision
Decided
' +
@@ -1572,12 +1584,12 @@
Interview
function arm(n, onRadio){ // onRadio: picked by arrow keys or Space on the radios, so focus stays on the picked radio
const q = Q[S.sel]; if (!q || S.busy || closed()) 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 (onRadio) { const r = document.querySelector('#choices input[value="' + n + '"]'); if (r) r.focus(); }
else 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();
@@ -1655,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) && !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) && !(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, 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");
@@ -1987,7 +1999,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..eb38a4a356 100644
--- a/plugins/planning/surface/tests/ui_a.js
+++ b/plugins/planning/surface/tests/ui_a.js
@@ -140,18 +140,49 @@ 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], [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}; });
+ 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);
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..7d6afd3a02 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", 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));
diff --git a/plugins/planning/surface/tests/ui_c.js b/plugins/planning/surface/tests/ui_c.js
index 62a30c86e8..c6537b20b6 100644
--- a/plugins/planning/surface/tests/ui_c.js
+++ b/plugins/planning/surface/tests/ui_c.js
@@ -123,6 +123,19 @@ 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("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");
+ 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 +387,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));
diff --git a/plugins/planning/surface/tests/ui_e.js b/plugins/planning/surface/tests/ui_e.js
index 2aeff81e73..f7a3b4ae67 100644
--- a/plugins/planning/surface/tests/ui_e.js
+++ b/plugins/planning/surface/tests/ui_e.js
@@ -38,7 +38,7 @@ async page => { // answer controls: radios by keyboard, a withheld question, pas
// a withheld question offers nothing to accept and stays out of Accept all
await pick("E2");
const l2 = await labels();
- ok("a withheld question shows no Rec, Accept with note or Hedged row", !l2.some(l => /^(Rec|Accept with note|Hedged)$/.test(l)) && l2.includes("Own answer") && l2.includes("Defer"), l2.join(", "));
+ ok("a withheld question shows no Rec, Accept with note or Hedged row, and keeps Someone else decides", !l2.some(l => /^(Rec|Accept with note|Hedged)$/.test(l)) && l2.includes("Own answer") && l2.includes("Defer") && l2.includes("Someone else decides"), l2.join(", "));
ok("its recommendation block reads Recommendation withheld", /^Recommendation withheld$/.test((await text(".blk h4")).trim()), await text(".blk h4"));
await page.click("#qhead"); await page.keyboard.press("a"); await page.waitForTimeout(150);
ok("key a arms nothing on a withheld question", !(await page.$(".choice.armed")));