From b532aac2fef6561578fa7005f1e2a3af0a47787c Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Thu, 5 Nov 2026 15:09:55 -0800 Subject: [PATCH 1/4] Show the Planner's state on its machine button, warn at the composer when it can't connect, and say a lost connection once at the top of the document MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A healthy Chopin now says nothing. The "Planner: attached" line under the composer is gone, and so is the connection label inside it, so the composer is the same size in every state. What is left is told where a person already looks. The Planner's state is on the document header's machine button (and in the phone top bar): its dot becomes a thin petrol spinner while the machine is being connected to, a warning where it cannot be, and a muted grey while the Planner is away. The button's name and the top of its menu say it in words about the machine, such as "Connecting to Build box" or "Can’t connect to Build box" with the reason in full, what has been tried and a "How to fix" link, now documented in docs/chopin-cli.md. A machine that cannot be connected to also warns on the composer's existing attached top strip, which gains a warning tone and eases open and shut with the shared collapse motion; the other states, and a Planner that is not attached, are told there only while a message is addressed to Chopin, so a person with no machine button still hears of them when it matters. The status model is one pure module, so the button, its menu and the strip cannot disagree. A lost connection is one InlineNotice in the warning roles at the top of the document's content (the top of Chat where Chat is all that shows, and just under the floating top bar on a phone): "You’re offline. Your changes are kept and will sync when you reconnect." It waits out the existing 1.5 second grace period so a blip shows nothing, and Retry joins it only after four failed reconnect attempts. Wire now reports an outage and counts each failed attempt, automatic or a person's, reusing its existing jittered backoff; the timing and decisions are pure and unit tested. The header status, the Reconnect to Reload fallback and the composer's label and button are removed, and nothing confirms a state that clears. A screen reader hears the notice once as it appears and "Reconnected" once when it is gone. Placement and copy follow Primer's degraded-experience, loading and notification messaging patterns and Apple's progress-indicator guidance, in Chopin's own tokens and components; the research and the vendors' documented offline behaviour are cited in the pull request. The e2e traces the notice and the strip frame by frame, so they are shown to ease and not jump, and to settle at once under reduced motion. The design audit plates, DESIGN.md and the docs describe the new places. Assistant-model: Claude Sonnet 5.5 Assistant-workflow: goal (run 19ea4b06-26bd-4515-94f7-1201b0f8463c) Assistant-verification: bun test (apps/web, packages, scripts) passed: 2617 tests across 322 files, including the new unit tests for outage counting, timing, planner status and the strips Assistant-verification: bun test (full, as run here) failed: connector and routes tests need an unsigned git config (332 pass with GIT_CONFIG_GLOBAL=/dev/null) and the memory adapter flaked under load and passes alone Assistant-verification: bun run types passed: every package and the e2e project type-check Assistant-verification: bun run ci passed: dprint, oxlint, tokens, design contract, design record and Impeccable show no new findings Assistant-verification: bun run design:behavior passed: 22 keyboard, focus, reduced-motion and reflow checks at two widths Assistant-verification: bun run e2e (14 specs) passed: 137 passed on the built client, the offline notice, Retry, reduced motion, the machine button states and the composer strips in Chromium Assistant-verification: bun run e2e machines.e2e.ts passed: 2 tests that failed only on commit signing pass with GIT_CONFIG_GLOBAL=/dev/null Assistant-verification: screenshot review passed: desktop and phone shots of each state were opened and checked against DESIGN.md Assistant-verification: design:browser unavailable: Linux visual baselines were not regenerated User-preference: Status UI stays quiet when things are healthy and calm when they aren't, like Notion Co-authored-by: Alex Lavaee Assistant-duration: 72m 28s --- apps/web/DESIGN.md | 4 +- apps/web/src/chat/chat.tsx | 34 ++- apps/web/src/chat/composer.css | 192 ++++++----------- apps/web/src/chat/composer.test.tsx | 77 ++++++- apps/web/src/chat/composer.tsx | 157 ++++++++------ apps/web/src/chat/planner-attachment.test.tsx | 103 --------- apps/web/src/chat/planner-attachment.tsx | 75 ------- apps/web/src/chat/planner-status.test.ts | 149 +++++++++++++ apps/web/src/chat/planner-status.ts | 128 ++++++++++++ apps/web/src/design-audit/inventory.ts | 2 +- .../src/design-audit/plates/conversation.css | 35 ++++ .../src/design-audit/plates/conversation.tsx | 83 ++++++-- .../design-audit/plates/loading-status.css | 17 ++ .../design-audit/plates/loading-status.tsx | 19 +- .../src/design-audit/plates/workflow-runs.tsx | 2 - apps/web/src/design-audit/specimens.test.tsx | 11 +- apps/web/src/machine-button.css | 115 +++++++++- apps/web/src/machine-button.test.tsx | 113 +++++++++- apps/web/src/machine-button.tsx | 67 +++++- apps/web/src/machine-choice.test.ts | 17 ++ apps/web/src/machine-choice.ts | 11 +- apps/web/src/offline-notice.css | 20 ++ apps/web/src/offline-notice.test.tsx | 40 ++++ apps/web/src/offline-notice.tsx | 81 ++++++++ apps/web/src/phone-header.tsx | 6 +- apps/web/src/room-workspace.tsx | 46 ++-- apps/web/src/wire.test.ts | 139 +++++++++++++ apps/web/src/wire.ts | 18 ++ apps/web/src/workspace.tsx | 10 +- docs/chopin-cli.md | 43 +++- docs/hosted-agent.md | 20 +- e2e/connection.e2e.ts | 196 ++++++++++++++---- e2e/editing.e2e.ts | 58 +++--- e2e/motion.ts | 44 ++++ e2e/planner-attachment.e2e.ts | 183 ++++++++++------ e2e/planner-picker.e2e.ts | 169 ++++++++++----- e2e/planner-status.ts | 50 +++++ packages/editor/src/connection-notice.test.ts | 56 ++++- packages/editor/src/connection-notice.ts | 32 +++ packages/editor/src/index.ts | 10 +- packages/editor/src/status.test.ts | 42 ++-- packages/editor/src/status.tsx | 66 ++---- .../exceptions/dynamic-web.json | 60 +++++- 43 files changed, 2062 insertions(+), 738 deletions(-) delete mode 100644 apps/web/src/chat/planner-attachment.test.tsx delete mode 100644 apps/web/src/chat/planner-attachment.tsx create mode 100644 apps/web/src/chat/planner-status.test.ts create mode 100644 apps/web/src/chat/planner-status.ts create mode 100644 apps/web/src/offline-notice.css create mode 100644 apps/web/src/offline-notice.test.tsx create mode 100644 apps/web/src/offline-notice.tsx create mode 100644 e2e/motion.ts create mode 100644 e2e/planner-status.ts diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index 64e578bf5..da59d9331 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -172,7 +172,7 @@ Rectangular controls use a restrained four-step radius scale: small for grips an - **Buttons:** Primary, secondary, outline, ghost, and destructive tiers (outline is a white button with a quiet 12% edge, for a single action inside a card) share one shape and compact text treatment. Resting outlines use at most 15% opacity, including primary and danger treatments; secondary buttons keep the lighter 7% passive edge. Sizes follow shadcn's Nova scale: `btn-xs` 24px, `btn-sm` 28px, `btn-md` 32px and `btn-lg` 36px, with icon-only `btn-icon-xs` (24px) and `btn-icon` (28px). Extra-small and small buttons use the extra-small text role, a 4px icon gap and the medium radius; medium and large use the small text role, a 6px gap and the large radius. Every button chooses a size. Hover and pressed states change the relevant color role. Keyboard focus shows the brand focus ring; a pointer press hides it on buttons and menus, leaves the clicked Chat composer only its quiet brand edge, and other text fields keep theirs. - **Recovery actions:** Use `btn-outline-danger` on danger surfaces and `icon-danger` for error indicators. Hover and pressed states stay within the danger palette. - **Fields and selections:** White fields use the medium radius, control edge, and subtle resting shadow. Invalid fields use the destructive role. Disabled controls use neutral fill and muted text. -- **Navigation:** The current location uses a white surface and primary ink. Other items stay quieter and reveal their affordance on hover or focus. A navigation item that leads to a full page discloses progressively: a click opens a popover with the essential information and the most common choice (Machines lists the machines you can use, yours and those teammates share, sorted together newest first, and sets your default), plus a link to the full page. A document's own machine, which plans and builds it, is the exception: an icon-only Local or Cloud status button left of Build plan, never in the Chat composer. +- **Navigation:** The current location uses a white surface and primary ink. Other items stay quieter and reveal their affordance on hover or focus. A navigation item that leads to a full page discloses progressively: a click opens a popover with the essential information and the most common choice (Machines lists the machines you can use, yours and those teammates share, sorted together newest first, and sets your default), plus a link to the full page. A document's own machine, which plans and builds it, is the exception: an icon-only Local or Cloud status button left of Build plan, never in the Chat composer. It also carries the state of the document's Planner, which says nothing while it is attached: the dot becomes a thin petrol spinner while the machine is being connected to, the warning role where it cannot be, and a muted grey while the Planner is away. The button's name and the top of its menu say it in words that speak of the machine (_Connecting to Build box_, _Can’t connect to Build box_ with the reason and what has been tried, _Build box is offline. Its work is kept and picks up when it’s back_). - **Menus and popovers:** Every menu follows shadcn's Nova menu. The panel has 4px padding, the large radius, and a hairline ring with the raised shadow. Items are 28px: 4px by 6px padding, a 6px icon gap, the medium radius and the small text role. Group labels use the extra-small text role in medium weight and tertiary ink with the item's inset. Dividers are 1px, run through the panel's padding to both edges and keep 4px above and below. A selected item is tinted with the selected surface, never marked with a check. Secondary detail sits right-aligned in tertiary ink on the same line. - **Icons:** Icon size is contextual: the container chooses it, not the call site. 16px is the default for menus, medium and large buttons, icon buttons and standalone interface icons. 14px sits beside small text: badges, pills, capsules and small buttons. 12px is for compact controls: extra-small buttons and the compact sidebar Search, disclosure, add-project, document-creation and document-action controls, which keep 24px targets. Icons in one row share one size and one column. - **Badges and status graphics:** Neutral, success, warning, and danger use paired semantic surface, icon, graphic, and text roles. A badge combines an icon and label in a pill. Merged (plum) is the fifth quad, reserved for finished GitHub work: a merged pull request or a completed issue, as GitHub colours them. @@ -216,4 +216,4 @@ Important visual dimensions and colors belong to the shared theme. Use finite co SeeCode uses a fixed canvas typography catalog in `packages/diagrams/src/core/tokens.mjs`: node labels 14px, subtitles 12px, tags 10px and edge labels 12px. Measurement and SVG drawing variables derive from the same catalog, so changing type updates boxes and routing together. These are coordinate-system roles; interface controls retain the fluid UI scale. Architecture graphs may use a narrower derived layout, targeting the smaller of 1100px or the document lane, while preserving saved source, node identities, connections and playback. Fitting has a readability floor; dense diagrams keep native scrolling and zoom controls below the diagram. -`InlineNotice` reserves space in the page flow for recoverable failures, using the shared semantic surface, icon and text roles. Navigation retry controls align within it; notices never cover document content. Connection labels and recovery buttons use the same compact text role. +`InlineNotice` reserves space in the page flow for recoverable failures, using the shared semantic surface, icon and text roles. Navigation retry controls align within it; notices never cover document content. A lost connection is one such notice at the top of the document's content (or of Chat where Chat is all that shows), after a short grace period so a blip shows nothing: _You’re offline. Your changes are kept and will sync when you reconnect._ It eases open and shut in the shared collapse motion, and Retry joins it only once automatic reconnects have failed repeatedly. Nothing follows it when the connection returns, and nothing about connection state sits in the composer. The composer's attached top strip takes the same warning roles for a machine that cannot be connected to, with the accent link _How to fix_; it too eases open and shut, and a state that clears leaves quietly, with no confirmation. Reduced motion settles each of them at once. diff --git a/apps/web/src/chat/chat.tsx b/apps/web/src/chat/chat.tsx index f20f2e880..60df7737a 100644 --- a/apps/web/src/chat/chat.tsx +++ b/apps/web/src/chat/chat.tsx @@ -27,7 +27,8 @@ import { import { chatDraftKey } from "../demo-actor"; import { canWithdraw } from "./withdraw"; import type { DraftInputHandle } from "./draft-input"; -import { PlannerAttachment } from "./planner-attachment"; +import { plannerNotice } from "./planner-status"; +import type { PlannerStatus } from "./planner-status"; import { TypingReporter } from "./typing"; import { ChatComposer } from "./composer"; @@ -84,7 +85,7 @@ import type { ExcerptCorrectionAction } from "../conversation-plan/analysis-over import type { ChatDestination } from "../conversation-plan/source"; import type { ResearchOfferControls } from "./research-offer"; -import type { Chat as Wire, ConversationPlan, Session } from "@chopin/protocol"; +import type { Chat as Wire, ConversationPlan } from "@chopin/protocol"; import type { Repository } from "../api"; import type { ResearchLaunchBlock, ResearchLaunchResult } from "@chopin/editor"; import type { ChatCommand } from "./commands"; @@ -139,10 +140,13 @@ export type ChatProps = { notice?: string; /** One calm line shown while the transcript has loaded and is still empty. */ emptyNotice?: string; - /** Who holds the document's remote Planner; absent under every other harness. */ - planner?: Session.PlannerAttachment; - /** The viewer's own machine that is starting the Planner, by name, when one is. */ - plannerMachine?: string; + /** + * What is not well with the document's remote Planner, when something is: its machine button + * says so all the time, and the composer says so when it cannot start, or when a message is + * addressed to Chopin that nothing can answer yet. Absent while it is attached, and under every + * other harness. + */ + planner?: PlannerStatus; }; function draftKey(room: string, handle: string): string { @@ -213,7 +217,6 @@ export function Chat( notice, emptyNotice, planner, - plannerMachine, onShowDecisions, onResearch, onShowResearch, @@ -265,8 +268,7 @@ export function Chat( let submission = useRef(undefined); let draftRef = useRef(draft); draftRef.current = draft; - // A reload, including the header's fallback when reconnecting keeps - // failing, must not take an unsent message with it. Kept as it changes + // A reload must not take an unsent message with it. Kept as it changes // rather than on unload, which a browser does not promise to announce. useEffect(() => keepDraft(room, handle, draft), [room, handle, draft]); let pickerId = useId(); @@ -288,14 +290,8 @@ export function Chat( let typingEnabled = composerReady && active && agent && !!participationState?.enabled; // A draft never waits on the connection; only sending does. let draftable = !readonly && !archived; + // The document says when the connection is lost; Chat only holds back what it cannot send. let connectionNotice = useConnectionNotice(!connected); - let connectionLabel = connectionNotice === "none" - ? undefined - : connectionNotice === "offline" || wire?.status === "closed" || wire?.status === "denied" - ? "Offline" - : wire?.status === "connecting" - ? "Connecting…" - : "Reconnecting…"; let effectiveMode = !!agent && (mode || draft.submission?.to === "planner" || addressedOutsideReferences(draft.text, draft.references)); let workingTurn = transcriptReady ? turn : undefined; @@ -719,7 +715,6 @@ export function Chat( {transcriptView}

{notice}

-
); @@ -772,7 +767,7 @@ export function Chat( ? { tone: "info", text: "Research isn’t available in this document" } : !agent ? { tone: "info", text: "Chopin unavailable" } - : undefined} + : plannerNotice(planner, effectiveMode)} unavailable={archived ? "archived" : readonly ? "readonly" : undefined} references={referencesEnabled ? { @@ -794,8 +789,6 @@ export function Chat( || blockedCommand || (connectionNotice !== "none" && !draftCommand(draft.text))} onSubmit={submit} - connectionLabel={connectionLabel} - onReconnect={connectionNotice === "offline" && wire ? () => wire.reconnect() : undefined} run={agent && (busy || counts.active > 0) ? { action: "stop", @@ -842,7 +835,6 @@ export function Chat( )} } - attachment={} input={{ "aria-activedescendant": commandOpen ? referenceOptionId(commandPickerId, commandActive) diff --git a/apps/web/src/chat/composer.css b/apps/web/src/chat/composer.css index dd0dee4cb..4558dbed2 100644 --- a/apps/web/src/chat/composer.css +++ b/apps/web/src/chat/composer.css @@ -123,6 +123,30 @@ background: var(--color-danger-surface); color: var(--color-danger-text); } +/* Something to act on, in the warning roles; the link keeps the one accent. */ +.chat-composer .composer-notice[data-warning] { + background: var(--color-warning-surface); + color: var(--color-warning-text); +} +.chat-composer .composer-notice-icon { + --color-icon: var(--color-warning-icon); +} +.chat-composer .composer-notice-link { + color: var(--color-brand-ink); + text-decoration: underline; + text-underline-offset: 0.2em; + white-space: nowrap; +} +/* + * A strip that eases open and shut carries the tuck under the composer itself, so the collapsing + * track holds the whole strip and the composer rises over its last half rem as before. + */ +.chat-composer .composer-notice-slot { + margin-bottom: -0.5rem; +} +.chat-composer .composer-notice-slot .composer-notice { + margin-bottom: 0; +} .chat-composer .composer-unavailable { display: flex; flex-direction: column; @@ -231,129 +255,57 @@ } /* - * Who holds the remote Planner: one quiet line under the composer, aligned with - * the draft's text. The words name the state; the dot only repeats it. + * The remote Planner's status, in the row directly under the composer. A healthy Planner has no + * row: it is a collapsed grid track until there is something to say, and it eases open and shut + * through the shared collapse motion. Quiet states are one muted line in the compact text role; + * a Planner that cannot start is a warning notice as wide as the composer, so what the person + * must act on is the only thing in colour. Petrol is only the working spinner and the link. */ -.chat-composer .composer-planner { +.chat-composer .planner-status-body { + padding-top: calc(var(--spacing) * 2); +} +.chat-composer .planner-status-line { display: flex; - align-items: center; - gap: 0.375rem; - padding: 0.375rem 0.75rem 0; - color: var(--color-text-secondary); + align-items: flex-start; + gap: calc(var(--spacing) * 1.5); + padding-inline: var(--spacing); + color: var(--color-text-tertiary); font-size: var(--text-xs); line-height: var(--text-xs--line-height); } -.chat-composer .composer-planner-label { - min-width: 0; - overflow-wrap: anywhere; -} -.chat-composer .composer-planner-dot { - flex: none; - width: 0.375rem; - height: 0.375rem; - border-radius: var(--radius-full); - background: var(--color-success); -} -.chat-composer .composer-planner[data-planner-state="held"] .composer-planner-dot, -.chat-composer .composer-planner[data-planner-state="disconnected"] .composer-planner-dot, -.chat-composer .composer-planner[data-planner-state="starting"] .composer-planner-dot { - background: var(--color-warning); -} -.chat-composer .composer-planner[data-planner-state="none"] { - color: var(--color-text-tertiary); -} -/* Hollow: nothing holds the Planner. */ -.chat-composer .composer-planner[data-planner-state="none"] .composer-planner-dot { - border: var(--edge-width) solid currentColor; - background: transparent; -} - -/* - * A disconnected Planner says what it kept after its name, in quieter ink, and may wrap, so the - * dot lines up with the first line. - */ -.chat-composer .composer-planner[data-planner-state="disconnected"] .composer-planner-detail { - color: var(--color-text-tertiary); -} - -/* - * A machine whose Planner backend keeps failing to start: still the starting state, so the dot and - * the line sit where they did, but the dot is red and the line has three: what is wrong, in the - * same colour, then why, then how often it was tried in the quietest ink. They wrap, so the dot - * lines up with the first. - */ -.chat-composer .composer-planner[data-planner-failing], -.chat-composer .composer-planner[data-planner-state="disconnected"] { - align-items: flex-start; -} -.chat-composer .composer-planner[data-planner-failing] .composer-planner-dot, -.chat-composer .composer-planner[data-planner-state="disconnected"] .composer-planner-dot { - margin-block-start: calc((1lh - 0.375rem) / 2); -} -.chat-composer .composer-planner[data-planner-failing] .composer-planner-dot { - background: var(--color-danger-graphic); -} -.chat-composer .composer-planner-lead, -.chat-composer .composer-planner-reason, -.chat-composer .composer-planner-meta { - display: block; -} -.chat-composer .composer-planner-lead { - color: var(--color-danger-text); -} -.chat-composer .composer-planner-meta { - color: var(--color-text-tertiary); +.chat-composer .planner-status-line:empty { + display: none; } - -/* Inside the footer row, so losing the connection moves nothing above it. */ -.chat-composer .composer-connection { - display: inline-flex; +.chat-composer .planner-status-text { min-width: 0; + overflow-wrap: anywhere; } -.chat-composer .composer-connection-group { - display: contents; +.chat-composer .planner-status-icon { + --color-icon: var(--color-neutral-icon); } -.chat-composer .composer-connection-label { +.chat-composer .planner-status-spinner { display: inline-flex; - align-items: center; - gap: 0.375rem; - padding-inline: 0.25rem; - color: var(--color-text-secondary); - font-size: var(--text-xs); - line-height: var(--text-xs--line-height); - white-space: nowrap; -} -.chat-composer .composer-connection-label::before { - content: ""; - width: 0.375rem; - height: 0.375rem; - flex: none; - border-radius: var(--radius-full); - background: var(--color-warning); + width: 0.875rem; + height: 0.875rem; + color: var(--color-brand); } -/* The same words, tone and dot as the document's own status in its header. */ -.chat-composer .composer-connection-label[data-offline] { - color: var(--color-destructive-ink); +.chat-composer .planner-status-spinner [data-progress-track] { + stroke: var(--color-selected-strong); } -.chat-composer .composer-connection-label[data-offline]::before { - display: none; -} -@keyframes composer-connection-enter { - from { - opacity: 0; - } +.chat-composer .planner-status-spinner [data-progress-arc] { + stroke: var(--color-brand); } +/* Reduced motion keeps the still ring; its words say it is working. */ @media (prefers-reduced-motion: no-preference) { - .chat-composer .composer-connection-label { - animation: composer-connection-enter var(--duration-fast) var(--ease-out); + .chat-composer .planner-status-spinner { + animation: button-loader-spin calc(var(--duration-base) * 4) linear infinite; } } -/* - * Side by side with the document, its header already offers Reconnect, and - * one state gets one action. Without it in view, as on a phone, Chat does. - */ -[data-workspace-mode="split"] .chat-composer .composer-reconnect { - display: none; +.chat-composer .planner-status-link { + color: var(--color-brand-ink); + text-decoration: underline; + text-underline-offset: 0.2em; + white-space: nowrap; } /* @@ -489,23 +441,8 @@ .chat-composer .composer-chopin-chip:active:not(:disabled) { color: var(--color-brand-active); } - /* Connection state becomes a slim first row inside the pill. */ - .chat-composer .composer-connection-group:has(.composer-connection-label) { - display: flex; - order: 0; - flex-basis: 100%; - align-items: center; - justify-content: space-between; - gap: 0.5rem; - padding: 0.125rem 0.25rem 0 0.5rem; - } - .chat-composer - .composer-connection-group:not(:has(.composer-connection-label)) - .composer-connection { - position: absolute; - } - .chat-composer .composer-connection-group .composer-reconnect { - min-height: var(--control-height-compact); + .chat-composer .composer-notice-slot { + margin-bottom: 0; } /* Status above the pill is one quiet line rather than a tab behind it. */ .chat-composer .composer-notice { @@ -521,6 +458,11 @@ margin-bottom: 0.375rem; background: var(--color-danger-surface); } + /* Something to act on keeps its warning surface, as a failed send keeps its danger one. */ + .chat-composer .composer-notice[data-warning] { + margin-bottom: 0.375rem; + background: var(--color-warning-surface); + } .chat-composer .composer-unavailable { flex-basis: 100%; min-height: 0; @@ -555,6 +497,6 @@ .chat-composer .composer-actions { min-width: 0; } -.chat-composer :is(.composer-left, .composer-connection, .composer-reconnect, .send-action) { +.chat-composer :is(.composer-left, .send-action) { flex: none; } diff --git a/apps/web/src/chat/composer.test.tsx b/apps/web/src/chat/composer.test.tsx index 7f37ab289..cdca75146 100644 --- a/apps/web/src/chat/composer.test.tsx +++ b/apps/web/src/chat/composer.test.tsx @@ -70,14 +70,75 @@ test("read-only and archived documents replace the draft and hide notices", () = expect(render({ unavailable: "archived" })).toContain("Document archived"); }); -test("shows the connection, Reconnect, and one run control", () => { - let markup = render({ - connectionLabel: "Offline", - onReconnect: () => {}, - run: { action: "stop", disabled: false, onClick: () => {} }, - }); - expect(markup).toContain('data-offline="true"'); - expect(markup).toContain("Reconnect"); +test("shows one run control, and no connection state of its own", () => { + let markup = render({ run: { action: "stop", disabled: false, onClick: () => {} } }); expect(markup).toContain('aria-label="Stop Chopin"'); expect(markup).not.toContain('aria-label="Resume Chopin"'); + // The document says when the connection is lost, once, at the top of its content. + expect(markup).not.toContain("composer-connection"); + expect(markup).not.toContain("Reconnect"); + expect(markup).not.toContain("Retry"); +}); + +test("puts nothing beneath the composer, whatever the Planner is doing", () => { + let markup = render({ + notice: { + tone: "warning", + text: "Can’t connect to Build box.", + link: { label: "How to fix", href: "https://example.test/fix" }, + }, + }); + expect(markup.endsWith("")).toBe(true); + expect(markup.lastIndexOf("composer-notice")).toBeLessThan(markup.indexOf("composer-surface")); +}); + +test("a warning is an alert on the composer's top edge: the words, a link, and what a screen reader adds", () => { + let markup = render({ + notice: { + tone: "warning", + text: "Can’t connect to Build box.", + detail: "The reason. Tried 3 times, last at 6:29 PM. Still trying.", + link: { label: "How to fix", href: "https://example.test/fix" }, + }, + }); + expect(markup).toMatch(/