diff --git a/AGENTS.md b/AGENTS.md index 23fd700..0209f90 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -420,6 +420,11 @@ run becomes `deployed`. The storefront check must also pass: the HTML of the storefront, or a script that it loads, must contain the public hostname of the API. The API checks cannot see the hostname that a browser uses. +Each stage in `RUN_STAGES` has an item in the stage list of +`public/index.html`, in the same order, with a tooltip that tells what the +stage does and where it runs. `tests/gateway.test.ts` checks this, so a new +stage needs an item and a tooltip. + When a deploy fails, the deploy manager diagnoses it from the logs of that deploy, which workflow code gives it. `fetchDeployLogs()` reads them in the time range of the deploy, with no type filter. Thus it gets the build logs, diff --git a/app/store.ts b/app/store.ts index c165400..5562a74 100644 --- a/app/store.ts +++ b/app/store.ts @@ -21,21 +21,26 @@ export type FinishedStatus = Exclude< >; /** - * Coarse progress for GET /v1/apps/:runId. Cosmetic; never gates a run. - * stageOrder in public/app.js must list these stages in this order. If the - * stage of a run is not in that list, the UI shows no progress for the run. + * Coarse progress for GET /v1/apps/:runId, in pipeline order. Cosmetic; + * never gates a run. The stage list in public/index.html must list these + * stages in this order, each with a tooltip, and tests/gateway.test.ts checks + * it. If the stage of a run is not in that list, the UI shows no progress for + * the run. */ -export type RunStage = - | "designing" - | "provisioning" - | "curating" - | "building" - | "verifying" - | "publishing" - | "waiting_for_services" - | "waiting_for_deploys" - | "smoke_testing" - | "done"; +export const RUN_STAGES = [ + "designing", + "provisioning", + "curating", + "building", + "verifying", + "publishing", + "waiting_for_services", + "waiting_for_deploys", + "smoke_testing", + "done", +] as const; + +export type RunStage = (typeof RUN_STAGES)[number]; export interface RunRecord { id: string; diff --git a/docs/FAQ.md b/docs/FAQ.md index 6a93db4..60b0dcf 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -72,6 +72,8 @@ Architecture overview and how Render products fit together: - The UI shows these stages: Designing, Provisioning (only for an app with a database), Curating, Building, Verifying, Publishing, Waiting For Services, Waiting For Deploys, Smoke Testing, and Done. +- Each stage has a tooltip that tells what the stage does and where it runs. + Hover over the stage, or go to it with the Tab key. - A typical run takes **5–10 minutes**. The builder takes the largest part. - The CLI `npm run demo` follows the status endpoint and prints final URLs. - If a run looks stuck, `GET /v1/apps/:runId` shows the current `stage` and `progress`. diff --git a/public/app.js b/public/app.js index 5deefc7..375b4d8 100644 --- a/public/app.js +++ b/public/app.js @@ -28,18 +28,13 @@ const deleteCancel = document.querySelector("#delete-cancel"); const activeStatuses = ["running", "deleting"]; const healthyStatuses = [...activeStatuses, "deployed", "awaiting_blueprint"]; -const stageOrder = [ - "designing", - "provisioning", - "curating", - "building", - "verifying", - "publishing", - "waiting_for_services", - "waiting_for_deploys", - "smoke_testing", - "done", -]; +/** + * The stage list is static HTML, with the tooltip of each stage. A poll + * changes only the class of each stage. It does not replace the stages, so an + * open tooltip stays open and a focused stage keeps the focus. + */ +const stageItems = [...stages.querySelectorAll("li")]; +const stageOrder = stageItems.map((item) => item.dataset.stage); let runs = []; let selectedRunId = null; @@ -84,6 +79,18 @@ deleteConfirm.addEventListener("input", () => { deleteCancel.addEventListener("click", () => deleteDialog.close()); +/** + * Escape closes an open stage tooltip, and the pointer and the focus stay + * where they are (WCAG 1.4.13). The next stage that the pointer or the focus + * goes to opens its tooltip again. + */ +document.addEventListener("keydown", (event) => { + if (event.key === "Escape") stages.classList.add("tips-closed"); +}); +for (const type of ["pointerover", "focusin"]) { + stages.addEventListener(type, () => stages.classList.remove("tips-closed")); +} + deleteForm.addEventListener("submit", async (event) => { event.preventDefault(); deleteDialog.close(); @@ -236,21 +243,18 @@ function renderRun(run) { stages.hidden = ["deleting", "delete_failed"].includes(run.status); const current = stageOrder.indexOf(run.stage); - stages.replaceChildren( - ...stageOrder.map((stage, index) => { - const item = document.createElement("li"); - item.textContent = label(stage); - if (index < current || run.status === "deployed") item.className = "complete"; - if (index === current && run.status === "running") item.className = "active"; - if ( - index === current && - !["running", "deployed", "awaiting_blueprint"].includes(run.status) - ) { - item.className = "failed-stage"; - } - return item; - }), - ); + stageItems.forEach((item, index) => { + let state = ""; + if (index < current || run.status === "deployed") state = "complete"; + if (index === current && run.status === "running") state = "active"; + if ( + index === current && + !["running", "deployed", "awaiting_blueprint"].includes(run.status) + ) { + state = "failed-stage"; + } + item.className = state; + }); const deployed = run.status === "deployed" && Boolean(run.urls?.web); result.hidden = !deployed; diff --git a/public/index.html b/public/index.html index e95881a..d90f078 100644 --- a/public/index.html +++ b/public/index.html @@ -73,7 +73,82 @@
Here’s what was built
diff --git a/public/style.css b/public/style.css index a9244fe..a4cde90 100644 --- a/public/style.css +++ b/public/style.css @@ -452,6 +452,7 @@ input { } .stages li { + position: relative; display: flex; align-items: center; gap: 10px; @@ -462,6 +463,23 @@ input { font-size: 13px; line-height: 20px; letter-spacing: 0; + cursor: help; +} + +.stages li:hover { + background: var(--hover-tint); +} + +/* The name of a stage is a button only so that the keyboard can open its tooltip. */ +.stage-label { + padding: 0; + border: 0; + background: none; + color: inherit; + font: inherit; + letter-spacing: inherit; + text-align: left; + cursor: inherit; } .stages li::before { @@ -501,6 +519,47 @@ input { background: var(--danger); } +/* + * The tooltip of a stage opens above it on hover or keyboard focus, so a + * pointer that moves down the list does not go into an open tooltip. It + * touches its stage, so the pointer can move onto it and it stays open. + * Escape closes it (app.js). It is as wide as the column of its stage. + */ +.stage-tip { + position: absolute; + right: 0; + bottom: 100%; + left: 0; + z-index: 1; + display: none; + flex-direction: column; + gap: 8px; + padding: 12px; + border: 1px solid var(--border-strong); + background: var(--surface-muted); + color: var(--text-secondary); + font-family: var(--font-default); + font-size: 14px; + font-weight: 400; + line-height: 20px; + letter-spacing: 0.01em; + cursor: auto; +} + +.stages:not(.tips-closed) li:hover .stage-tip, +.stages:not(.tips-closed) .stage-label:focus-visible + .stage-tip { + display: flex; +} + +.stage-tip-where { + display: flex; + flex-direction: column; + gap: 4px; + padding-top: 8px; + border-top: 1px solid var(--border); + color: var(--text); +} + .progress { color: var(--text-secondary); font-size: 14px; @@ -599,6 +658,7 @@ input { .secondary-button:focus-visible, .danger-button:focus-visible, .run-item:focus-visible, +.stage-label:focus-visible, .primary-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; diff --git a/tests/gateway.test.ts b/tests/gateway.test.ts index 795d456..3cd4cb5 100644 --- a/tests/gateway.test.ts +++ b/tests/gateway.test.ts @@ -224,6 +224,24 @@ describe("browser UI", () => { expect(await response.text()).toContain("