diff --git a/app.js b/app.js index 75ddcf0..fe69ce6 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,53 @@ 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; + // 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() { + 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). + */ +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 +7773,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 +7782,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..b7e62d7 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); @@ -628,3 +643,321 @@ 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"); + }); + + 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("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("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); + 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/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..e5e79d4 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,118 @@ 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. + 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; - 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 +1842,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 +1854,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 +1875,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 +1979,35 @@ background: transparent; } + /* Stacked: the workflow leads, the prompt follows beneath it (mock). + 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); + } + .pipeline-view { + 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 { + 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,61 +3467,67 @@

Free generations used

Generations reset at the start of each month.

- +
- - @@ -3585,16 +3611,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