From 28428a21d5b771431424f8c408a2a6f2a22173a2 Mon Sep 17 00:00:00 2001 From: Stuart Date: Mon, 28 Sep 2026 17:36:04 +1000 Subject: [PATCH 1/4] STU-445: pipeline holding window matches the mock; split Refine/Regenerate captions Holding window (owner: "The 3 stage holding window does not reflect the mock:"). Rendered the branch, compared it to the canonical pipeline view (custom-code-connect-hero.html .walkthrough-layout / .pipeline-view), and fixed the real deltas: - glyph: removed the legacy spinner circle and its orange arc; the authored STU-390 mark is now the only glyph, drawn in --muted and aligned with the copy's left edge, 24px above the title (16px stacked). - layout: the panel is the mock's two-column interior - the submitted prompt is the left sidebar (30px inset, hairline, Edit prompt closed at its bottom-left) and the stage view is a left-aligned block centred in the main column. At <=920px the workflow leads and the prompt follows beneath it. - typography: title 34px/1.2 display at weight 500; a visible supporting line carrying the mock's stage copy (16px, --muted, 44ch) replaces the duplicated "Step N of 3 - Stage" subtitle, which stays in the live region sr-only. - bar: 4px pill, --fg fill on the --track token (no indigo/blue gradient, no shimmer); meta line is mono 12px --muted. - stage nav: keeps its semantics and locators but paints in the system's ink (--fg dots, --border pending, --bad failed) instead of indigo/green. Results captions (owner: split the scope sentence so each half sits under its button): "Refine & Regenerate acts on the selected artifact" under Refine & Regenerate, "Acts on the whole bundle" under Add FlutterFlow Build Errors & Regenerate; the combined sentence is gone. Each action is a column that owns its caption, and the two action icons are pinned to the intended 14px so the buttons keep one height when Tailwind's CDN utilities are unavailable. Tests: new holding-window contract in stu377 (computed styles and geometry, fails on the old window) plus the copy-split pin in resultsLayout; stu378's action locator widened to a descendant selector because the captions wrap the buttons. Full CI green: 355 unit, 208 Playwright (207 pass + 1 load flake retried), 7 a11y shells, 9/9 deploy-guard, 30/30 proxies, leak scan clean. --- app.js | 17 ++ e2e/stu377-pipeline-progress.spec.js | 151 +++++++++++ e2e/stu378-results.spec.js | 2 +- index.html | 377 ++++++++++++++------------- src/resultsLayout.test.js | 16 ++ 5 files changed, 384 insertions(+), 179 deletions(-) diff --git a/app.js b/app.js index 75ddcf0..26614fe 100644 --- a/app.js +++ b/app.js @@ -7652,6 +7652,19 @@ function pipelineStageButton(step) { return document.getElementById(`pdot-${step}`); } +/** + * The supporting line under the stage title, taken verbatim from the canonical + * pipeline view's stage copy (custom-code-connect-hero.html stageCopy). + */ +function pipelineStageDescription(step) { + const descriptions = { + 1: "Turning your idea into a clear FlutterFlow specification.", + 2: "Building the Dart source, widget parameters and animation.", + 3: "Checking the generated code and preparing the file review.", + }; + return descriptions[step] || ""; +} + /** * Busy feedback on the control that started the run, and - just as important - * a usable control again when the run terminates, however it terminated. @@ -7722,6 +7735,7 @@ function renderPipelineTrack() { */ function renderPipelineStatus(step, { done = false } = {}) { const titleEl = document.getElementById("progress-title-text"); + const descriptionEl = document.getElementById("progress-description-text"); const substepEl = document.getElementById("progress-substep-text"); const state = pipelineStageStates[step]; const finished = done || state === "done"; @@ -7730,6 +7744,9 @@ function renderPipelineStatus(step, { done = false } = {}) { ? PIPELINE_STAGE_DONE_TITLES[step] : PIPELINE_STAGE_TITLES[step]; } + // The visible supporting line follows the mock; the step/stage/state line + // stays in the live region for assistive technology. + if (descriptionEl) descriptionEl.textContent = pipelineStageDescription(step); if (substepEl) { const suffix = finished ? " \u2014 complete" : state === "failed" ? " \u2014 stopped" : ""; substepEl.textContent = `Step ${step} of 3 \u2014 ${PIPELINE_STAGE_LABELS[step]}${suffix}`; diff --git a/e2e/stu377-pipeline-progress.spec.js b/e2e/stu377-pipeline-progress.spec.js index 9317369..fcebba0 100644 --- a/e2e/stu377-pipeline-progress.spec.js +++ b/e2e/stu377-pipeline-progress.spec.js @@ -628,3 +628,154 @@ test.describe("Motion is decorative and settles safely", () => { await expect(stage(page, 1)).toHaveAttribute("data-state", "done"); }); }); + +test.describe("Holding window matches the canonical pipeline view", () => { + test("desktop: the mock's layout, type, bar and meta replace the legacy spinner", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await openHome(page); + const pipeline = await routePipelineStages(page, { hold: ["architect", "generator"] }); + + await page + .locator("#pipeline-input") + .fill("A circular progress gauge with an animated percentage label"); + await page.locator("#hero-send").click(); + await expect(stage(page, 1)).toHaveAttribute("data-state", "active"); + // The stage flips to display:flex one animation frame after the run + // starts; wait for real layout before measuring. + await expect(page.locator(".pipeline-view")).toBeVisible(); + + // The legacy spinner is gone; the authored mark is the only glyph. + await expect(page.locator(".pipeline-progress-icon")).toHaveCount(0); + await expect(page.locator(".pipeline-glyph.logo-loop")).toHaveCount(1); + + const probe = await page.evaluate(() => { + const style = (sel) => { + const el = document.querySelector(sel); + return el ? getComputedStyle(el) : null; + }; + const box = (sel) => { + const el = document.querySelector(sel); + if (!el) return null; + const r = el.getBoundingClientRect(); + return { x: Math.round(r.x), y: Math.round(r.y) }; + }; + // Counts top-level tracks without splitting inside minmax()/var(). + const trackCount = (value) => { + let depth = 0; + let count = 1; + for (const ch of value) { + if (ch === "(") depth += 1; + else if (ch === ")") depth -= 1; + else if (ch === " " && depth === 0) count += 1; + } + return count; + }; + const tokenProbe = document.createElement("span"); + tokenProbe.style.color = "var(--fg)"; + document.body.appendChild(tokenProbe); + const fg = getComputedStyle(tokenProbe).color; + tokenProbe.remove(); + const description = document.querySelector("#progress-description-text"); + const columns = style("#pipeline-progress")?.gridTemplateColumns ?? ""; + return { + columns, + columnCount: trackCount(columns), + firstColumn: columns.split(" ")[0] ?? null, + titleSize: style("#progress-title-text")?.fontSize ?? null, + titleWeight: style("#progress-title-text")?.fontWeight ?? null, + description: description ? description.textContent.trim() : null, + descriptionSize: style("#progress-description-text")?.fontSize ?? null, + trackHeight: style(".progress-track")?.height ?? null, + trackRadius: style(".progress-track")?.borderRadius ?? null, + fillColor: style("#pipeline-progress-fill")?.backgroundColor ?? null, + metaFont: style("#progress-stage-count")?.fontFamily ?? null, + metaSize: style("#progress-stage-count")?.fontSize ?? null, + recapBorder: style(".pipeline-prompt-recap")?.borderRightWidth ?? null, + activeDot: style("#pdot-1 .progress-dot")?.backgroundColor ?? null, + fg, + title: box("#progress-title-text"), + mark: box(".pipeline-glyph.logo-loop"), + recap: box(".pipeline-prompt-recap"), + view: box(".pipeline-view"), + }; + }); + + // Two-column panel: prompt sidebar on the left, stage view in the main + // column, separated by the mock's hairline. + expect(probe.columnCount).toBe(2); + expect(probe.firstColumn).toBe("310px"); + expect(probe.recap).not.toBeNull(); + expect(probe.view).not.toBeNull(); + expect(probe.recap.x).toBeLessThan(probe.view.x); + expect(probe.recapBorder).toBe("1px"); + + // The mark rests on the copy's left edge, as in the mock. + expect(probe.mark.x).toBe(probe.title.x); + + // The mock's typography and the supporting line from its stage copy. + expect(probe.titleSize).toBe("34px"); + expect(probe.titleWeight).toBe("500"); + expect(probe.description).toBe("Turning your idea into a clear FlutterFlow specification."); + expect(probe.descriptionSize).toBe("16px"); + + // One 4px pill, fg on the track token - never the indigo gradient. + expect(probe.trackHeight).toBe("4px"); + expect(probe.trackRadius).toBe("999px"); + expect(probe.fillColor).toBe(probe.fg); + + // The meta line is the mock's mono line, not an 11px sans subtitle. + expect(probe.metaFont).toContain("DM Mono"); + expect(probe.metaSize).toBe("12px"); + + // The stage nav keeps its semantics but paints in the system's ink. + expect(probe.activeDot).toBe(probe.fg); + + // The visible supporting line follows the mock's stage copy as the run + // advances, and a completed stage is ink rather than legacy green. + pipeline.release("architect"); + await expect(stage(page, 2)).toHaveAttribute("data-state", "active"); + await expect(page.locator("#progress-description-text")).toHaveText( + "Building the Dart source, widget parameters and animation.", + ); + const doneDot = await page.evaluate(() => { + const probe = document.createElement("span"); + probe.style.color = "var(--fg)"; + document.body.appendChild(probe); + const fg = getComputedStyle(probe).color; + probe.remove(); + return { dot: getComputedStyle(document.querySelector("#pdot-1 .progress-dot")).backgroundColor, fg }; + }); + expect(doneDot.dot).toBe(doneDot.fg); + }); + + test("stacked: the workflow leads and the prompt follows beneath it", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await openHome(page); + const pipeline = await routePipelineStages(page, { hold: ["architect"] }); + + await page.locator("#pipeline-input").fill("A gauge widget"); + await page.locator("#hero-send").click(); + await expect(stage(page, 1)).toHaveAttribute("data-state", "active"); + await expect(page.locator(".pipeline-view")).toBeVisible(); + + const stacked = await page.evaluate(() => { + const box = (sel) => document.querySelector(sel)?.getBoundingClientRect() ?? null; + const view = box(".pipeline-view"); + const recap = box(".pipeline-prompt-recap"); + const round = (r) => + r ? { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width) } : null; + return { view: round(view), recap: round(recap) }; + }); + expect(stacked.view).not.toBeNull(); + expect(stacked.recap).not.toBeNull(); + // One column: both blocks share the panel's full width. + expect(stacked.view.x).toBe(stacked.recap.x); + expect(stacked.view.w).toBe(stacked.recap.w); + // The workflow leads; the prompt follows beneath it (the mock's order). + expect(stacked.view.y).toBeLessThan(stacked.recap.y); + await expect(page.locator("#pipeline-edit-prompt")).toBeVisible(); + + pipeline.release("architect"); + await expect(stage(page, 1)).toHaveAttribute("data-state", "done"); + }); +}); diff --git a/e2e/stu378-results.spec.js b/e2e/stu378-results.spec.js index 14b8ac2..99c9b5b 100644 --- a/e2e/stu378-results.spec.js +++ b/e2e/stu378-results.spec.js @@ -129,7 +129,7 @@ for (const viewport of [{ name: "mobile", width: 390, height: 844 }, { name: "de await renderResults(page, bundle, { summary: "Long file reviewed.", artifacts: [{ id: "long-file", review: { status: "pass", findings: [] } }] }); await page.getByRole("tab", { name: /LongFile/ }).click(); - const actions = page.locator(".results-action-bar > button"); + const actions = page.locator(".results-action-bar button"); await expect(actions).toHaveCount(3); await expect(actions.nth(0)).toContainText("Deploy to FlutterFlow"); await expect(actions.nth(1)).toContainText("Add FlutterFlow Build Errors & Regenerate"); diff --git a/index.html b/index.html index 8d0c8e4..6096c8a 100644 --- a/index.html +++ b/index.html @@ -920,6 +920,7 @@ .results-action-bar { display: flex; flex-wrap: wrap; + align-items: flex-start; justify-content: flex-end; gap: 10px; padding: 14px 24px; @@ -932,14 +933,33 @@ justify-content: center; min-height: 42px; } - .results-action-bar .btn-error-regen { + /* Each action owns its caption: the button and its scope line share one + column, so the split sentence sits under the control it describes. */ + .results-action { + display: flex; + flex-direction: column; + gap: 6px; + flex: 1 1 180px; + min-width: 0; + } + .results-action-wide { flex-grow: 1.45; } + .results-action .btn-header-action { + flex: none; + width: 100%; + } + /* The two action icons carry Tailwind's w-3.5/h-3.5 classes; pin the same + 14px here too, so the buttons keep one height when those utilities are + not available (the browser suite runs without the CDN's utilities). */ + .results-action-bar .btn-header-action svg { + width: 14px; + height: 14px; + } .results-action-scope { - flex: 0 0 auto; - display: block; - margin: 10px 24px 4px; + margin: 0; font-size: 11px; + line-height: 1.4; color: var(--muted, #64748b); text-align: center; } @@ -1591,6 +1611,7 @@ body.results-fullscreen .results-action-bar { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); + align-items: start; margin-top: auto; padding: 10px 36px; background: #fff; @@ -1699,94 +1720,115 @@ pointer-events: none; } - /* ── Pipeline Progress Bar ───────────────────────── */ + /* ── Pipeline holding window ────────────────────────── + Canonical source: custom-code-connect-hero.html .walkthrough-layout / + .pipeline-view. Desktop is the mock's two-column panel: the submitted + prompt is the left sidebar, and the stage view is a left-aligned block + centred in the main column. The authored mark is the only glyph. */ .pipeline-progress { display: none; - flex-direction: column; - align-items: center; - justify-content: center; + grid-template-columns: 310px minmax(0, 1fr); height: 100%; - padding: 48px; - text-align: center; + overflow-y: auto; + text-align: left; } .pipeline-progress.visible { - display: flex; + display: grid; } - .pipeline-progress-icon { - width: 80px; - height: 80px; - border-radius: 50%; - background: linear-gradient(135deg, #eef2ff 0%, #e0e7ff 100%); + .pipeline-prompt-recap { display: flex; + flex-direction: column; + min-width: 0; + padding: 30px; + border-right: 1px solid var(--border); + } + .pipeline-prompt-kicker { + font: 12px/1.5 var(--font-mono); + letter-spacing: 0.09em; + text-transform: uppercase; + color: var(--muted); + } + .pipeline-prompt-text { + margin: 12px 0 32px; + font-size: 17px; + line-height: 1.5; + color: var(--fg); + overflow-wrap: anywhere; + max-height: 146px; + overflow: auto; + } + .pipeline-prompt-edit { + display: inline-flex; align-items: center; justify-content: center; - margin-bottom: 28px; - position: relative; + margin-top: auto; + align-self: flex-start; + min-height: 44px; + padding: 10px 14px; + border: 1px solid var(--border); + border-radius: 9px; + background: transparent; + color: var(--fg); + font: 500 13px var(--font-display); + cursor: pointer; + transition: background var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease); } - .pipeline-progress-icon::after { - content: ''; - position: absolute; - inset: -4px; - border-radius: 50%; - border: 2px solid transparent; - border-top-color: var(--accent); - animation: spin 1.2s linear infinite; + .pipeline-prompt-edit:hover { + background: color-mix(in oklch, var(--fg) 6%, var(--surface)); + border-color: var(--fg); } - .pipeline-progress-icon svg { - width: 32px; - height: 32px; - color: #4f46e5; + .pipeline-view { + display: grid; + align-content: center; + min-width: 0; + padding: 48px 64px; } - - .pipeline-progress h3 { - font-family: "Delight", "Outfit", sans-serif; - font-size: 18px; - font-weight: 600; + .pipeline-view h3 { + font: 500 34px/1.2 var(--font-display); + letter-spacing: -0.025em; color: var(--fg); - margin-bottom: 6px; - letter-spacing: -0.02em; + margin-bottom: 12px; } - .pipeline-progress .progress-substep { - font-size: 13px; - color: var(--muted-2); - margin-bottom: 28px; + .pipeline-description { + color: var(--muted); + font-size: 16px; + line-height: 1.6; + max-width: 44ch; + } + /* The mock's arena sits 24px above the title and draws the mark in + --muted; the module stylesheet owns the mark's geometry and motion. */ + .pipeline-progress .pipeline-glyph.logo-loop { + color: var(--muted); + margin-bottom: 24px; } + /* One 4px pill, fg on the track token - the mock's bar, stepwise. */ .progress-track { - width: 100%; - max-width: 400px; - height: 6px; - background: #e2e8f0; - border-radius: 3px; + height: 4px; + border-radius: 999px; + margin: 34px 0 16px; + background: var(--track); overflow: hidden; - position: relative; } .progress-fill { height: 100%; - background: linear-gradient(90deg, #6366f1, #3b82f6, #6366f1); - background-size: 200% 100%; - border-radius: 3px; - transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1); - animation: progress-shimmer 2s ease-in-out infinite; width: 0%; - } - @keyframes progress-shimmer { - 0% { background-position: 200% 0; } - 100% { background-position: -200% 0; } + background: var(--fg); + border-radius: 999px; + transition: width var(--t-progress) var(--ease); } + /* Meta: the run's real elapsed time and the mock's step count, in mono. */ .progress-meta { display: flex; align-items: center; justify-content: space-between; - width: 100%; - max-width: 400px; - margin-top: 10px; + font: 12px var(--font-mono); + color: var(--muted); } .progress-meta span { - font-size: 11px; - color: var(--muted-2); - font-weight: 500; + font: inherit; + color: inherit; } .progress-steps-dots { display: flex; @@ -1797,8 +1839,8 @@ width: 8px; height: 8px; border-radius: 50%; - background: #e2e8f0; - transition: all 0.3s ease; + background: var(--border); + transition: background var(--t-fast) var(--ease), box-shadow var(--t-fast) var(--ease); } .progress-dot.active { background: #6366f1; @@ -1809,61 +1851,15 @@ } /* ── Pipeline: real stage, note and failure states (STU-377) ── */ - .pipeline-progress { - overflow-y: auto; - gap: 0; - } - .pipeline-prompt-recap { - width: 100%; - max-width: 400px; - margin-bottom: 24px; - text-align: left; - padding: 14px 16px; - border: 1px solid var(--border); - border-radius: var(--radius-md, 12px); - background: var(--surface); - } - .pipeline-prompt-kicker { - font-family: var(--font-mono); - font-size: 11px; - letter-spacing: var(--ls-mono); - text-transform: uppercase; - color: var(--muted); - margin-bottom: 6px; - } - .pipeline-prompt-text { - font-size: 14px; - color: var(--fg); - overflow-wrap: anywhere; - } - .pipeline-prompt-edit { - margin-top: 8px; - padding: 0; - min-height: 44px; - background: none; - border: none; - color: var(--accent); - font: inherit; - font-size: 13px; - cursor: pointer; - text-decoration: underline; - text-underline-offset: 3px; - } - /* A finished or failed run has nothing left to spin for. */ - .pipeline-progress[data-run-state="failed"] .pipeline-progress-icon::after, - .pipeline-progress[data-run-state="settled"] .pipeline-progress-icon::after { - animation: none; - border-top-color: var(--border); - } .pipeline-progress[data-run-state="failed"] .progress-fill { - animation: none; background: var(--bad); } .progress-steps-dots { flex-wrap: wrap; - justify-content: center; + justify-content: flex-start; gap: 4px; + margin-top: 20px; } .progress-stage { display: inline-flex; @@ -1876,18 +1872,18 @@ border: none; font: inherit; font-size: 11px; - color: var(--muted-2); + color: var(--muted); cursor: pointer; } .progress-stage:disabled { cursor: default; } .progress-stage[data-state="active"] .progress-dot { - background: #6366f1; - box-shadow: 0 0 8px rgba(99, 102, 241, 0.4); + background: var(--fg); + box-shadow: 0 0 0 3px color-mix(in oklch, var(--fg) 14%, transparent); } .progress-stage[data-state="done"] .progress-dot { - background: #22c55e; + background: var(--fg); } .progress-stage[data-state="failed"] .progress-dot { background: var(--bad); @@ -1980,14 +1976,33 @@ background: transparent; } + /* Stacked: the workflow leads, the prompt follows beneath it (mock). */ + @media (max-width: 920px) { + .pipeline-progress { grid-template-columns: minmax(0, 1fr); } + .pipeline-view { + order: 1; + align-content: start; + min-height: 300px; + padding: 34px 24px; + } + .pipeline-view h3 { font-size: 25px; } + .pipeline-progress .pipeline-glyph.logo-loop { margin-bottom: 16px; } + .pipeline-prompt-recap { + order: 2; + padding: 22px; + border-right: 0; + border-top: 1px solid var(--border); + } + .pipeline-prompt-text { + margin: 8px 0 20px; + font-size: 15px; + max-height: 100px; + } + } @media (max-width: 640px) { - .pipeline-progress { padding: 24px 16px 32px; justify-content: flex-start; } - .pipeline-progress-icon { width: 56px; height: 56px; margin-bottom: 18px; } .pipeline-failure-actions button { flex: 1 1 100%; } } @media (prefers-reduced-motion: reduce) { - .pipeline-progress-icon::after, - .progress-fill { animation: none; } .composer-morph { display: none; } } @@ -3447,60 +3462,61 @@

Free generations used

Generations reset at the start of each month.

- +
- - -
+ +
-
- - - -
-
-

Understanding your prompt

-

Step 1 of 3 — Prompt Architect

-
-
-
-
-
- 0s - Step 1 of 3 -
-
- - - -
- + +
+ + +
+

Understanding your prompt

+

Turning your idea into a clear FlutterFlow specification.

+

Step 1 of 3 — Prompt Architect

+
+
+
+
+
+ 0s + Step 1 of 3 +
+
+ + + +
+ -
@@ -3585,16 +3601,21 @@

Generation Results

Deploy to FlutterFlow - - +
+ +

Acts on the whole bundle

+
+
+ +

Refine & Regenerate acts on the selected artifact

+
-

Refine & Regenerate acts on the selected artifact · build-error fixes regenerate the whole bundle.

diff --git a/src/resultsLayout.test.js b/src/resultsLayout.test.js index a5b0ca5..9ff5bf6 100644 --- a/src/resultsLayout.test.js +++ b/src/resultsLayout.test.js @@ -10,3 +10,19 @@ test("hides the prompt sidebar above mobile Results", () => { /@media \(max-width: 768px\)[\s\S]*?body\.results-fullscreen \.sidebar,[\s\S]*?body\.results-fullscreen\.results-with-sidebar \.sidebar \{\s*display: none;/, ); }); + +test("splits the action scope sentence under its own button", () => { + // Each captioned action owns its half of the old combined sentence, in the + // same column as the control it describes. + assert.match( + html, + /class="results-action results-action-wide"[\s\S]*?id="btn-error-regen-header"[\s\S]*?class="results-action-scope">Acts on the whole bundleRefine & Regenerate acts on the selected artifact Date: Mon, 28 Sep 2026 18:56:51 +1000 Subject: [PATCH 2/4] STU-445: pipeline DOM order matches the stacked focus order At <=920px the workflow is displayed above the prompt recap, but the recap still came first in the DOM, so Tab reached Edit prompt before the active stage button. The workflow now precedes the recap in the document; desktop grid areas keep the prompt in the left column, and the stacked breakpoint drops its order overrides so DOM and displayed order agree everywhere, failure actions included. --- e2e/stu377-pipeline-progress.spec.js | 102 +++++++++++++++++++++++++++ index.html | 31 +++++--- 2 files changed, 122 insertions(+), 11 deletions(-) diff --git a/e2e/stu377-pipeline-progress.spec.js b/e2e/stu377-pipeline-progress.spec.js index fcebba0..c644c15 100644 --- a/e2e/stu377-pipeline-progress.spec.js +++ b/e2e/stu377-pipeline-progress.spec.js @@ -80,6 +80,21 @@ async function openHome(page, overrides = {}) { const stage = (page, step) => page.locator(`#pdot-${step}`); const failure = (page) => page.locator("#pipeline-failure"); +/** + * Tab forward from wherever focus currently is, recording the id of each + * focused element, until every wanted id has been seen or `limit` presses + * have been spent. Elements without an id (and focus that leaves the page) + * record an empty string, so callers assert containment, not equality. + */ +async function tabFocusOrder(page, wanted, limit = 60) { + const seen = []; + for (let i = 0; i < limit && !wanted.every((id) => seen.includes(id)); i += 1) { + await page.keyboard.press("Tab"); + seen.push(await page.evaluate(() => document.activeElement?.id ?? "")); + } + return seen; +} + test.describe("Generation progress binds to real stage events", () => { test("walks Architect to Generator to Review and never completes a stage early", async ({ page }) => { await openHome(page); @@ -778,4 +793,91 @@ test.describe("Holding window matches the canonical pipeline view", () => { pipeline.release("architect"); await expect(stage(page, 1)).toHaveAttribute("data-state", "done"); }); + + test("stacked: keyboard focus follows the workflow-then-prompt order", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await openHome(page); + const pipeline = await routePipelineStages(page, { hold: ["architect"] }); + + await page.locator("#pipeline-input").fill("A gauge widget"); + await page.locator("#hero-send").click(); + await expect(stage(page, 1)).toHaveAttribute("data-state", "active"); + await expect(page.locator(".pipeline-view")).toBeVisible(); + + // Walk the page with Tab until both panel regions have been focused. The + // active stage button must be reached before the recap's Edit prompt: + // the workflow is what is displayed first at this width, and CSS order + // must not make focus jump backward against the screen. + await page.evaluate(() => document.activeElement?.blur()); + const focusOrder = await tabFocusOrder(page, ["pdot-1", "pipeline-edit-prompt"]); + expect(focusOrder).toContain("pdot-1"); + expect(focusOrder).toContain("pipeline-edit-prompt"); + expect(focusOrder.indexOf("pdot-1")).toBeLessThan( + focusOrder.indexOf("pipeline-edit-prompt"), + ); + + // The document order backs the focus order, and it matches the screen. + const regions = await page.evaluate(() => { + const view = document.querySelector(".pipeline-view"); + const recap = document.querySelector(".pipeline-prompt-recap"); + return { + viewBeforeRecap: Boolean( + view.compareDocumentPosition(recap) & Node.DOCUMENT_POSITION_FOLLOWING, + ), + viewY: Math.round(view.getBoundingClientRect().y), + recapY: Math.round(recap.getBoundingClientRect().y), + }; + }); + expect(regions.viewBeforeRecap).toBe(true); + expect(regions.viewY).toBeLessThan(regions.recapY); + + pipeline.release("architect"); + await expect(stage(page, 1)).toHaveAttribute("data-state", "done"); + }); + + test("stacked: a failure's actions stay ahead of the recap in DOM and focus order", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await openHome(page); + const pipeline = await routePipelineStages(page, { + hold: ["architect"], + responses: { architect: providerError }, + }); + + await page.locator("#pipeline-input").fill("A gauge widget"); + await page.locator("#hero-send").click(); + await expect(stage(page, 1)).toHaveAttribute("data-state", "active"); + + pipeline.release("architect"); + await expect(failure(page)).toBeVisible(); + await expect(failure(page).locator('button[data-action="edit"]')).toBeVisible(); + + // Every failure action lives in the workflow region, which still precedes + // the recap in the document and on screen. + const regions = await page.evaluate(() => { + const view = document.querySelector(".pipeline-view"); + const recap = document.querySelector(".pipeline-prompt-recap"); + const edit = document.getElementById("pipeline-edit-prompt"); + const actions = [...document.querySelectorAll("#pipeline-failure-actions button")]; + return { + viewBeforeRecap: Boolean( + view.compareDocumentPosition(recap) & Node.DOCUMENT_POSITION_FOLLOWING, + ), + actionsBeforeRecapEdit: actions.every((action) => + Boolean(action.compareDocumentPosition(edit) & Node.DOCUMENT_POSITION_FOLLOWING), + ), + viewY: Math.round(view.getBoundingClientRect().y), + recapY: Math.round(recap.getBoundingClientRect().y), + }; + }); + expect(regions.viewBeforeRecap).toBe(true); + expect(regions.actionsBeforeRecapEdit).toBe(true); + expect(regions.viewY).toBeLessThan(regions.recapY); + + // Termination focuses a permitted action; tabbing on reaches the recap's + // Edit prompt without jumping back up the page. + const focusedAction = await page.evaluate(() => document.activeElement?.dataset?.action); + expect(["retry", "edit", "upgrade"]).toContain(focusedAction); + const afterActions = await tabFocusOrder(page, ["pipeline-edit-prompt"], 4); + expect(afterActions).toContain("pipeline-edit-prompt"); + }); }); diff --git a/index.html b/index.html index 6096c8a..f8243a2 100644 --- a/index.html +++ b/index.html @@ -1728,6 +1728,7 @@ .pipeline-progress { display: none; grid-template-columns: 310px minmax(0, 1fr); + grid-template-areas: "recap view"; height: 100%; overflow-y: auto; text-align: left; @@ -1736,6 +1737,7 @@ display: grid; } .pipeline-prompt-recap { + grid-area: recap; display: flex; flex-direction: column; min-width: 0; @@ -1778,6 +1780,7 @@ border-color: var(--fg); } .pipeline-view { + grid-area: view; display: grid; align-content: center; min-width: 0; @@ -1976,11 +1979,15 @@ background: transparent; } - /* Stacked: the workflow leads, the prompt follows beneath it (mock). */ + /* Stacked: the workflow leads, the prompt follows beneath it (mock). + The DOM already orders the workflow first, so the displayed order and + the focus order stay the same at this breakpoint. */ @media (max-width: 920px) { - .pipeline-progress { grid-template-columns: minmax(0, 1fr); } + .pipeline-progress { + grid-template-columns: minmax(0, 1fr); + grid-template-areas: "view" "recap"; + } .pipeline-view { - order: 1; align-content: start; min-height: 300px; padding: 34px 24px; @@ -1988,7 +1995,6 @@ .pipeline-view h3 { font-size: 25px; } .pipeline-progress .pipeline-glyph.logo-loop { margin-bottom: 16px; } .pipeline-prompt-recap { - order: 2; padding: 22px; border-right: 0; border-top: 1px solid var(--border); @@ -3465,13 +3471,9 @@

Free generations used

- - +
@@ -3518,6 +3520,13 @@

+ + From 9f21cbe9fd75e4341581e6204f641ea4a1ec9126 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:43:25 +0000 Subject: [PATCH 3/4] STU-445: keep the pipeline panel's DOM in its displayed order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Devin Review flagged that on desktop the workflow column received focus before the left-hand prompt recap: the DOM led with .pipeline-view while grid-template-areas painted it second, so Tab travelled right-to-left. initPipelinePanelOrder now moves the two regions to match the active breakpoint — workflow first when stacked (<=920px), recap first on desktop — and plain grid auto-placement paints them in that order, so document order, displayed order and sequential focus order agree at every width. A 1440px spec pins Edit prompt before the active stage button; the <=920px assertions are unchanged. Co-Authored-By: Stuart Gardoll --- app.js | 33 +++++++++++++++++++++++ e2e/stu377-pipeline-progress.spec.js | 40 ++++++++++++++++++++++++++++ index.html | 21 ++++++++------- 3 files changed, 84 insertions(+), 10 deletions(-) diff --git a/app.js b/app.js index 26614fe..6ac65b2 100644 --- a/app.js +++ b/app.js @@ -6639,6 +6639,10 @@ function showToast(message, type = 'info') { document.addEventListener("DOMContentLoaded", async () => { initializeModalShells(); + // The holding window's regions display in a different order per breakpoint; + // reorder them in the DOM so focus never travels against the screen. + initPipelinePanelOrder(); + // Initialize highlight.js hljs.configure({ tabReplace: " ", @@ -7652,6 +7656,35 @@ function pipelineStageButton(step) { return document.getElementById(`pdot-${step}`); } +/** + * Keep the holding window's DOM order equal to its displayed order so + * sequential focus never travels backwards across the panel. The two regions + * lead at different widths: the prompt recap is the desktop panel's left + * sidebar, while the workflow leads the stacked (<=920px) layout. Focus + * follows the DOM, so the recap is moved ahead of the view on wide screens + * and back after it on narrow ones; grid auto-placement then paints the + * regions in the same order it reads them. + */ +const pipelinePanelStacked = window.matchMedia("(max-width: 920px)"); + +function syncPipelinePanelOrder() { + const progress = document.getElementById("pipeline-progress"); + const view = progress?.querySelector(":scope > .pipeline-view"); + const recap = progress?.querySelector(":scope > .pipeline-prompt-recap"); + if (!view || !recap) return; + const wantRecapAfterView = pipelinePanelStacked.matches; + const recapAfterView = + (view.compareDocumentPosition(recap) & Node.DOCUMENT_POSITION_FOLLOWING) !== + 0; + if (recapAfterView === wantRecapAfterView) return; + progress.insertBefore(recap, wantRecapAfterView ? view.nextSibling : view); +} + +function initPipelinePanelOrder() { + syncPipelinePanelOrder(); + pipelinePanelStacked.addEventListener("change", syncPipelinePanelOrder); +} + /** * The supporting line under the stage title, taken verbatim from the canonical * pipeline view's stage copy (custom-code-connect-hero.html stageCopy). diff --git a/e2e/stu377-pipeline-progress.spec.js b/e2e/stu377-pipeline-progress.spec.js index c644c15..4c4d51f 100644 --- a/e2e/stu377-pipeline-progress.spec.js +++ b/e2e/stu377-pipeline-progress.spec.js @@ -835,6 +835,46 @@ test.describe("Holding window matches the canonical pipeline view", () => { await expect(stage(page, 1)).toHaveAttribute("data-state", "done"); }); + test("desktop: keyboard focus follows the prompt-then-workflow order", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await openHome(page); + const pipeline = await routePipelineStages(page, { hold: ["architect"] }); + + await page.locator("#pipeline-input").fill("A gauge widget"); + await page.locator("#hero-send").click(); + await expect(stage(page, 1)).toHaveAttribute("data-state", "active"); + await expect(page.locator(".pipeline-view")).toBeVisible(); + + // The recap is the left column on desktop, so it leads the DOM and the + // tab order: Edit prompt is reached before the active stage button, and + // focus never travels from the right column back to the left. + await page.evaluate(() => document.activeElement?.blur()); + const focusOrder = await tabFocusOrder(page, ["pipeline-edit-prompt", "pdot-1"]); + expect(focusOrder).toContain("pipeline-edit-prompt"); + expect(focusOrder).toContain("pdot-1"); + expect(focusOrder.indexOf("pipeline-edit-prompt")).toBeLessThan( + focusOrder.indexOf("pdot-1"), + ); + + // The document order backs the focus order, and it matches the screen. + const regions = await page.evaluate(() => { + const view = document.querySelector(".pipeline-view"); + const recap = document.querySelector(".pipeline-prompt-recap"); + return { + recapBeforeView: Boolean( + recap.compareDocumentPosition(view) & Node.DOCUMENT_POSITION_FOLLOWING, + ), + recapX: Math.round(recap.getBoundingClientRect().x), + viewX: Math.round(view.getBoundingClientRect().x), + }; + }); + expect(regions.recapBeforeView).toBe(true); + expect(regions.recapX).toBeLessThan(regions.viewX); + + pipeline.release("architect"); + await expect(stage(page, 1)).toHaveAttribute("data-state", "done"); + }); + test("stacked: a failure's actions stay ahead of the recap in DOM and focus order", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await openHome(page); diff --git a/index.html b/index.html index f8243a2..e5e79d4 100644 --- a/index.html +++ b/index.html @@ -1724,11 +1724,13 @@ Canonical source: custom-code-connect-hero.html .walkthrough-layout / .pipeline-view. Desktop is the mock's two-column panel: the submitted prompt is the left sidebar, and the stage view is a left-aligned block - centred in the main column. The authored mark is the only glyph. */ + centred in the main column. The authored mark is the only glyph. + Grid auto-placement paints the regions in DOM order; app.js keeps that + order equal to the displayed order at every breakpoint so sequential + focus never travels backwards across the panel. */ .pipeline-progress { display: none; grid-template-columns: 310px minmax(0, 1fr); - grid-template-areas: "recap view"; height: 100%; overflow-y: auto; text-align: left; @@ -1737,7 +1739,6 @@ display: grid; } .pipeline-prompt-recap { - grid-area: recap; display: flex; flex-direction: column; min-width: 0; @@ -1780,7 +1781,6 @@ border-color: var(--fg); } .pipeline-view { - grid-area: view; display: grid; align-content: center; min-width: 0; @@ -1980,12 +1980,11 @@ } /* Stacked: the workflow leads, the prompt follows beneath it (mock). - The DOM already orders the workflow first, so the displayed order and - the focus order stay the same at this breakpoint. */ + The DOM is reordered to match at this width, so the displayed order + and the focus order stay the same here too. */ @media (max-width: 920px) { .pipeline-progress { grid-template-columns: minmax(0, 1fr); - grid-template-areas: "view" "recap"; } .pipeline-view { align-content: start; @@ -3471,9 +3470,11 @@

Free generations used

- +
From 83972f15d6c2b1f008419b0d3e763a8122caa30e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:46:53 +0000 Subject: [PATCH 4/4] STU-445: carry focus across the breakpoint reorder Devin Review round 2: reparenting the recap blurs a focused descendant, so crossing 920px with Edit prompt focused dropped keyboard focus to the document. The focused element is captured before the move and refocused afterward; a spec drives the resize in both directions with Edit prompt focused and asserts both the new order and the retained focus. Co-Authored-By: Stuart Gardoll --- app.js | 5 ++++ e2e/stu377-pipeline-progress.spec.js | 40 ++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/app.js b/app.js index 6ac65b2..fe69ce6 100644 --- a/app.js +++ b/app.js @@ -7677,7 +7677,12 @@ function syncPipelinePanelOrder() { (view.compareDocumentPosition(recap) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0; if (recapAfterView === wantRecapAfterView) return; + // Reparenting blurs a focused descendant; carry it across the move. + const focused = document.activeElement; progress.insertBefore(recap, wantRecapAfterView ? view.nextSibling : view); + if (focused instanceof HTMLElement && recap.contains(focused)) { + focused.focus({ preventScroll: true }); + } } function initPipelinePanelOrder() { diff --git a/e2e/stu377-pipeline-progress.spec.js b/e2e/stu377-pipeline-progress.spec.js index 4c4d51f..b7e62d7 100644 --- a/e2e/stu377-pipeline-progress.spec.js +++ b/e2e/stu377-pipeline-progress.spec.js @@ -875,6 +875,46 @@ test.describe("Holding window matches the canonical pipeline view", () => { await expect(stage(page, 1)).toHaveAttribute("data-state", "done"); }); + test("resizing across the breakpoint keeps focus inside the moved recap", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await openHome(page); + const pipeline = await routePipelineStages(page, { hold: ["architect"] }); + + await page.locator("#pipeline-input").fill("A gauge widget"); + await page.locator("#hero-send").click(); + await expect(stage(page, 1)).toHaveAttribute("data-state", "active"); + + const order = () => + page.evaluate(() => { + const view = document.querySelector(".pipeline-view"); + const recap = document.querySelector(".pipeline-prompt-recap"); + return { + focusedId: document.activeElement?.id ?? "", + recapAfterView: Boolean( + view.compareDocumentPosition(recap) & Node.DOCUMENT_POSITION_FOLLOWING, + ), + }; + }); + + // Desktop -> stacked: the recap moves behind the view and reparenting must + // not drop the focus its Edit prompt was holding. + await page.locator("#pipeline-edit-prompt").focus(); + await page.setViewportSize({ width: 390, height: 844 }); + await expect + .poll(order, { message: "stacked order: view leads, focus retained" }) + .toEqual({ focusedId: "pipeline-edit-prompt", recapAfterView: true }); + + // Stacked -> desktop: the recap moves back ahead of the view, still with + // focus intact. + await page.setViewportSize({ width: 1440, height: 900 }); + await expect + .poll(order, { message: "desktop order: recap leads, focus retained" }) + .toEqual({ focusedId: "pipeline-edit-prompt", recapAfterView: false }); + + pipeline.release("architect"); + await expect(stage(page, 1)).toHaveAttribute("data-state", "done"); + }); + test("stacked: a failure's actions stay ahead of the recap in DOM and focus order", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await openHome(page);