Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changes/planning/fix-interview-web-view-layout-88848e44.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 3 additions & 2 deletions plugins/planning/skills/interview/context/surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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

Expand Down
50 changes: 31 additions & 19 deletions plugins/planning/surface/index.html

Large diffs are not rendered by default.

35 changes: 33 additions & 2 deletions plugins/planning/surface/tests/ui_a.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions plugins/planning/surface/tests/ui_b.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
17 changes: 15 additions & 2 deletions plugins/planning/surface/tests/ui_c.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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));

Expand Down
2 changes: 1 addition & 1 deletion plugins/planning/surface/tests/ui_e.js
Original file line number Diff line number Diff line change
Expand Up @@ -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")));
Expand Down
Loading