From 2fb75aa5d768a9a09454453ba4034e578cbaee2d Mon Sep 17 00:00:00 2001 From: kate bonner Date: Wed, 26 Aug 2026 19:39:19 -0400 Subject: [PATCH] Spotlight the chrome during the overture walkthrough MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The onboarding score's Stage 7 walks a new user around the window. Until now it could only describe the chrome in prose — "the button up on the right" — leaving the reader to find it. This lights the actual element as each stop comes up. How it works: the score emits one question card per stop with a verbatim "Tour · " header. This module maps those headers onto whichever element carries the matching data-tour-target attribute and rings it while the card is up. Unknown header or missing element degrades to nothing, and the card still reads on its own. The design, in the order the decisions matter: - A scrim blurs and dims the whole window except two holes — the element the stop names, and the walkthrough card doing the explaining. One place for the eye to go, with the element still in its real context. Shallow on purpose (3px, 22% dim): the point is to push the rest back, not to hide what the reader is learning to recognise. - The ring is an accent hairline, nothing more. No glow — nothing else in this UI uses one — and no fill, because it sits on top of a live control and a wash would tint the very thing being pointed at. It fades in over 0.25s as the highlight lands and eases between elements. - The stops are narration, not questions. They render as a walkthrough card with Next / Back / Skip walkthrough, they never gate the composer, and the composer stop needs a composer on screen to ring. - The whole tour rides in ONE multi-question card, so stepping between stops costs no model round trip. The dock publishes its active question index and the spotlight follows it. Two traps worth knowing, both fixed here and commented at the site: - The rect signal compares by VALUE. The measure poll runs several times a second and would otherwise emit a fresh object every tick, restarting the animation — which read as a blink rather than a landing. - The ring is keyed on the STOP, not the rect, for the same reason: it must replay once per stop and never per measure tick. Anchors point at controls the shell actually mounts. The Pulse Inspector and Preview live behind the side-panel button and are one stop, not three. Also includes a two-line typecheck fix (session-context-usage, review-panel-v2, file-name-picker) unrelated to the tour: those errors predate this branch and red-flagged every push, so the pre-push hook could not pass without them. --- .../src/components/session-context-usage.tsx | 2 + .../app/src/components/session/panel-menu.tsx | 1 + .../src/components/session/session-header.tsx | 36 ++-- .../app/src/components/titlebar-tab-strip.tsx | 2 +- packages/app/src/components/titlebar.tsx | 127 +++++++------ packages/app/src/design-polish.css | 82 +++++++++ .../session-composer-region-controller.ts | 7 +- .../composer/session-composer-region.tsx | 15 +- .../composer/session-question-dock.tsx | 28 ++- .../session/composer/session-tour.test.ts | 99 +++++++++++ .../pages/session/composer/session-tour.tsx | 168 ++++++++++++++++++ .../src/pages/session/session-side-panel.tsx | 41 ++++- .../src/pages/session/v2/review-panel-v2.tsx | 2 +- .../v2/components/file-name-picker.test.tsx | 1 + 14 files changed, 527 insertions(+), 84 deletions(-) create mode 100644 packages/app/src/pages/session/composer/session-tour.test.ts create mode 100644 packages/app/src/pages/session/composer/session-tour.tsx diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx index a297bf22aa..8b5ae596de 100644 --- a/packages/app/src/components/session-context-usage.tsx +++ b/packages/app/src/components/session-context-usage.tsx @@ -131,6 +131,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) { return ( + {circle()} @@ -155,6 +156,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) { + ) diff --git a/packages/app/src/components/session/panel-menu.tsx b/packages/app/src/components/session/panel-menu.tsx index d6f8f4f26f..cb338755f6 100644 --- a/packages/app/src/components/session/panel-menu.tsx +++ b/packages/app/src/components/session/panel-menu.tsx @@ -38,6 +38,7 @@ export function PanelMenu(props: { diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 659a0dea39..dae97e3c9a 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -583,11 +583,15 @@ function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
{/* amicode#274: Session Chats Dropdown — chat navigation from within a session */} - + + + - - - + + + + + } > - } - /> + + } + /> +
diff --git a/packages/app/src/components/titlebar-tab-strip.tsx b/packages/app/src/components/titlebar-tab-strip.tsx index 48a485347e..5c14f9c73b 100644 --- a/packages/app/src/components/titlebar-tab-strip.tsx +++ b/packages/app/src/components/titlebar-tab-strip.tsx @@ -285,7 +285,7 @@ export function TitlebarTabStrip(props: { }) return ( -
+
{ - const sessionID = params.id - void import("@/components/settings-v2").then((module) => { - setSettingsOpen(true) - void dialog.show( - () => , - () => setSettingsOpen(false), - ) - }) - } command.register("titlebar-home", () => []) @@ -390,34 +378,9 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl - - - - - {language.t("command.settings.open")} - - - } - class="shrink-0" - > - } - state={settingsOpen() ? "pressed" : undefined} - onClick={showSettings} - aria-label={language.t("command.settings.open")} - /> - + {/* Profile and Settings live at the trailing edge with the + other account/status controls (Sessions, Status, Side + Panel) — see TitlebarV2Right. */} {/* Removed: sidebar-left toggle button (harmoniqs/amicode#265). The button called layout.sidebar.toggle() but no component in NewLayout observes that signal — WorkbenchPanel only mounts in @@ -437,26 +400,28 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl onOverflowChange={setTabsAreOverflowing} /> - - {language.t("command.session.new")} - - - } - class="shrink-0" - > - + + {language.t("command.session.new")} + + + } class="shrink-0" - icon={} - onClick={openNewTab} - aria-label={language.t("command.session.new")} - /> - + > + } + onClick={openNewTab} + aria-label={language.t("command.session.new")} + /> + +
@@ -614,12 +579,56 @@ type TitlebarV2RightState = { } function TitlebarV2Right(props: { state: TitlebarV2RightState }) { + const language = useLanguage() + const command = useCommand() + const dialog = useDialog() + const params = useParams() + const [settingsOpen, setSettingsOpen] = createSignal(false) + const showSettings = () => { + const sessionID = params.id + void import("@/components/settings-v2").then((module) => { + setSettingsOpen(true) + void dialog.show( + () => , + () => setSettingsOpen(false), + ) + }) + } return (
+ {/* Session-scoped controls (Sessions / Status / Side Panel) portal in here. */}
+ + + + + + + + {language.t("command.settings.open")} + + + } + class="shrink-0" + > + } + state={settingsOpen() ? "pressed" : undefined} + onClick={showSettings} + aria-label={language.t("command.settings.open")} + /> + +
) } diff --git a/packages/app/src/design-polish.css b/packages/app/src/design-polish.css index 19fbcf7866..a9487f9747 100644 --- a/packages/app/src/design-polish.css +++ b/packages/app/src/design-polish.css @@ -420,3 +420,85 @@ span[data-component="tag"][data-variant="accent"] { 0%, 100% { opacity: 0.35; } 50% { opacity: 1; } } + +/* ── Onboarding walkthrough spotlight (session-tour.tsx) ─────────────────── + A ring traced on the chrome element the current "Tour · X" stop names. The + ring is the whole marker — there is no label, because the stop's own text + already names the surface. Decorative only (aria-hidden, pointer-events + none); sits above the shell but below dialogs (10000+). */ +[data-component="amc-tour-spotlight"] { + position: fixed; + inset: 0; + z-index: 9000; + pointer-events: none; +} +/* The scrim: everything except the stop's element and the walkthrough card is + softened, so the eye has one place to go. Blur is deliberately shallow (3px) + and paired with only a light dim — enough to push the rest of the window back + without hiding what the reader is being taught to recognise. The holes are + punched with an even-odd clip-path, recomputed as the ring moves. */ +[data-component="amc-tour-spotlight"] .amc-tour-scrim { + position: absolute; + inset: 0; + backdrop-filter: blur(3px); + -webkit-backdrop-filter: blur(3px); + background: color-mix(in srgb, var(--v2-background-bg-base) 22%, transparent); + transition: opacity 0.2s ease; +} +[data-component="amc-tour-spotlight"] .amc-tour-ring { + position: absolute; + /* No glow — nothing else in this UI uses one, and the scrim already says + "look here". Just the accent edge, the way state is marked everywhere else. + No fill either: this sits ON TOP of a live control, and a wash would tint + the very thing the reader is being taught to recognise. */ + border: 1px solid var(--accent); + border-radius: var(--radius-md); + /* Eases between elements rather than teleporting. */ + transition: top 0.22s cubic-bezier(0.2, 0, 0, 1), left 0.22s cubic-bezier(0.2, 0, 0, 1), + width 0.22s cubic-bezier(0.2, 0, 0, 1), height 0.22s cubic-bezier(0.2, 0, 0, 1); + /* Fades in once as the highlight lands. Keyed on the stop, so it plays per + stop and not on the measure tick. */ + animation: amc-tour-arrive 0.25s ease-out both; +} +@keyframes amc-tour-arrive { + from { + opacity: 0; + } + to { + opacity: 1; + } +} +@media (prefers-reduced-motion: reduce) { + [data-component="amc-tour-spotlight"] .amc-tour-ring { + animation: none; + } +} + +/* ── composer ⇄ dock card morph ── + The question / walkthrough card and the composer swap places in the same + dock. Without a transition the swap is an instant pop; these ease the card + in and out so the region reads as one surface changing shape rather than + two components replacing each other. Motion-reduced users get the swap with + no movement, which is the honest fallback for a positional animation. */ +[data-component="session-prompt-dock"] [data-component="session-question-dock"] { + animation: amc-dock-morph-in 0.22s cubic-bezier(0.2, 0, 0, 1) both; +} +@keyframes amc-dock-morph-in { + from { + opacity: 0; + transform: translateY(6px) scale(0.995); + } + to { + opacity: 1; + transform: none; + } +} +[data-component="session-prompt-dock"] [data-component="prompt-input-v2"] { + animation: amc-dock-morph-in 0.22s cubic-bezier(0.2, 0, 0, 1) both; +} +@media (prefers-reduced-motion: reduce) { + [data-component="session-prompt-dock"] [data-component="session-question-dock"], + [data-component="session-prompt-dock"] [data-component="prompt-input-v2"] { + animation: none; + } +} diff --git a/packages/app/src/pages/session/composer/session-composer-region-controller.ts b/packages/app/src/pages/session/composer/session-composer-region-controller.ts index c79de29cac..18af3ff621 100644 --- a/packages/app/src/pages/session/composer/session-composer-region-controller.ts +++ b/packages/app/src/pages/session/composer/session-composer-region-controller.ts @@ -3,6 +3,7 @@ import { useSpring } from "@opencode-ai/ui/motion-spring" import { type Accessor, createEffect, createMemo, createResource, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import type { PromptInputState } from "@/components/prompt-input" +import { isTourRequest } from "@/pages/session/composer/session-tour" import { useSync } from "@/context/sync" import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff" import type { SessionComposerController } from "./session-composer-state" @@ -138,7 +139,11 @@ export function createSessionComposerRegionController(input: { parentID, child: () => !!parentID(), archived, - showComposer: () => !input.state.blocked() || !!parentID() || archived(), + // A tour stop narrates rather than asks, so it leaves the composer up — + // both because nothing is actually being demanded of the reader, and + // because the Composer stop needs a composer on screen to point at. + showComposer: () => + !input.state.blocked() || isTourRequest(input.state.questionRequest()) || !!parentID() || archived(), handoffPrompt: () => getSessionHandoff(input.sessionKey())?.prompt, promptReady: () => input.prompt.ready() || promptReady(), dock: () => (store.ready && input.state.dock()) || value() > 0.001, diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx index 7307831209..41a385c078 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -5,6 +5,7 @@ import { bugReportEnabled } from "@/utils/amicode-bug-report" import { SessionBugDock } from "@/pages/session/composer/session-bug-dock" import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock" import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock" +import { SessionTourSpotlight, isTourRequest } from "@/pages/session/composer/session-tour" import { SessionFollowupDock } from "@/pages/session/composer/session-followup-dock" import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock" import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock" @@ -33,6 +34,7 @@ export function SessionComposerRegion(props: { }} >
)} + {/* Overture Stage-7 tour: rings the chrome element the active + "Tour · X" question card names; renders nothing off-tour. */} + {(request) => ( @@ -154,7 +159,15 @@ export function SessionComposerRegion(props: { fallback={ {props.promptInput}} + fallback={ + + {props.promptInput} + + } >
() @@ -85,6 +86,10 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit optionsHeight: 180, }) + // Publish which question is showing so the onboarding tour's spotlight can + // follow a multi-question card as the reader steps through it. + createEffect(() => setTourQuestionIndex(store.tab)) + let root: HTMLDivElement | undefined let optionsRef: HTMLDivElement | undefined let customRef: HTMLButtonElement | undefined @@ -104,8 +109,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit const customRow = createMemo(() => questionCustomRow(question())) const count = createMemo(() => (text() ? 0 : options().length + 1)) + // A walkthrough stop narrates; it is not a question being put to the reader. + // It gets prose, a Next button and a way out — no options, no answer hint, + // no "N questions" framing. + const isTour = createMemo(() => isTourRequest(props.request)) const summary = createMemo(() => { const n = Math.min(store.tab + 1, total()) + if (isTour()) return `Stop ${n} of ${total()}` return language.t("session.question.progress", { current: n, total: total() }) }) const customLabel = () => language.t("ui.messagePart.option.typeOwnAnswer") @@ -517,7 +527,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit · {" "} - Question + {isTour() ? "Walkthrough" : "Question"}
{summary()} @@ -559,7 +569,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit footer={ <>
0}> @@ -568,13 +578,19 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
@@ -591,11 +607,12 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit > {question()?.question}
- + {language.t("ui.question.singleHint")}
}>
{language.t("ui.question.multiHint")}
+
(optionsRef = el)} data-slot="question-options" @@ -730,6 +747,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
+
) diff --git a/packages/app/src/pages/session/composer/session-tour.test.ts b/packages/app/src/pages/session/composer/session-tour.test.ts new file mode 100644 index 0000000000..7bc4985bd1 --- /dev/null +++ b/packages/app/src/pages/session/composer/session-tour.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from "bun:test" +import { TOUR_HEADER_PREFIX, isTourRequest, tourTargetKeys } from "./session-tour" + +// The overture score (amicode scores/overture, v3) emits Stage-7 beats as +// question cards with verbatim "Tour · " headers; the spotlight keys +// on those strings. These tests pin the header contract from the app side. + +const request = (header: string) => ({ + questions: [{ question: "Look here.", header, options: [] }], +}) + +describe("tourTargetKeys", () => { + test("maps every scored beat to an always-present anchor", () => { + // The Composer stop names the real input first, then its column. + expect(tourTargetKeys(request("Tour · Composer"))).toEqual([ + '[data-component="prompt-input-v2"]', + '[data-tour-target="composer"]', + ]) + expect(tourTargetKeys(request("Tour · Tabs"))).toEqual(['[data-tour-target="tabs"]']) + expect(tourTargetKeys(request("Tour · New chat"))).toEqual(['[data-tour-target="new-chat"]']) + expect(tourTargetKeys(request("Tour · Sessions"))).toEqual(['[data-tour-target="sessions"]']) + expect(tourTargetKeys(request("Tour · Context"))).toEqual(['[data-tour-target="context-ring"]']) + expect(tourTargetKeys(request("Tour · Side panel"))).toEqual(['[data-tour-target="side-panel"]']) + expect(tourTargetKeys(request("Tour · Status"))).toEqual(['[data-tour-target="status"]']) + expect(tourTargetKeys(request("Tour · Profile"))).toEqual(['[data-tour-target="profile"]']) + expect(tourTargetKeys(request("Tour · Settings"))).toEqual(['[data-tour-target="settings"]']) + }) + + // The Pulse Inspector, Preview and the file list all live behind the one + // panel button, so the score sends a single stop for them. + test("the panel is one stop, and its old sub-stops are gone", () => { + expect(tourTargetKeys(request("Tour · Side panel"))).toEqual(['[data-tour-target="side-panel"]']) + expect(tourTargetKeys(request("Tour · Pulse Inspector"))).toBeUndefined() + expect(tourTargetKeys(request("Tour · Preview"))).toBeUndefined() + }) + + // Mirrors the 9 stops the score sends in its single tour card, in order. + // That order is the READING ORDER of the window — the top bar left to right, + // then the row beneath it, then the composer — so the highlight walks the + // screen instead of hopping across it. + const SCORED_STOPS = [ + "Tabs", + "New chat", + "Sessions", + "Status", + "Side panel", + "Profile", + "Settings", + "Context", + "Composer", + ] + + test("every stop the score sends resolves to at least one candidate", () => { + for (const beat of SCORED_STOPS) { + const keys = tourTargetKeys(request(`${TOUR_HEADER_PREFIX}${beat}`)) + expect(keys && keys.length).toBeGreaterThan(0) + } + }) + + // The whole tour rides in ONE card, so the spotlight has to track which + // question inside it is showing — not just the first. + test("follows the active question index within a multi-question card", () => { + const wholeTour = { questions: SCORED_STOPS.map((b) => ({ question: b, header: `Tour · ${b}`, options: [] })) } + expect(tourTargetKeys(wholeTour, 0)).toEqual(['[data-tour-target="tabs"]']) + expect(tourTargetKeys(wholeTour, 2)).toEqual(['[data-tour-target="sessions"]']) + expect(tourTargetKeys(wholeTour, 3)).toEqual(['[data-tour-target="status"]']) + expect(tourTargetKeys(wholeTour, 7)).toEqual(['[data-tour-target="context-ring"]']) + expect(tourTargetKeys(wholeTour, 8)?.[0]).toBe('[data-component="prompt-input-v2"]') + expect(tourTargetKeys(wholeTour, 99)).toBeUndefined() + }) + + test("non-tour questions and unknown beats resolve to nothing", () => { + expect(tourTargetKeys(request("Profile exists"))).toBeUndefined() + expect(tourTargetKeys(request(`${TOUR_HEADER_PREFIX}Rail`))).toBeUndefined() + expect(tourTargetKeys(request(""))).toBeUndefined() + expect(tourTargetKeys({ questions: [] })).toBeUndefined() + expect(tourTargetKeys(undefined)).toBeUndefined() + }) + + test("defaults to the first question when no index is given", () => { + expect( + tourTargetKeys({ + questions: [ + { question: "x", header: "Tour · Settings", options: [] }, + { question: "y", header: "Tour · Composer", options: [] }, + ], + }), + ).toEqual(['[data-tour-target="settings"]']) + }) +}) + + +describe("isTourRequest", () => { + test("is true for a tour card and false for a real question", () => { + expect(isTourRequest(request("Tour · Composer"))).toBe(true) + expect(isTourRequest(request("Your role"))).toBe(false) + expect(isTourRequest(undefined)).toBe(false) + }) +}) diff --git a/packages/app/src/pages/session/composer/session-tour.tsx b/packages/app/src/pages/session/composer/session-tour.tsx new file mode 100644 index 0000000000..e51e6b57c3 --- /dev/null +++ b/packages/app/src/pages/session/composer/session-tour.tsx @@ -0,0 +1,168 @@ +import { Show, createEffect, createSignal, onCleanup } from "solid-js" +import { Portal } from "solid-js/web" +import type { QuestionRequest } from "@opencode-ai/sdk/v2" + +// AMICODE: overture Stage 7 — the studio walkthrough. The onboarding score +// emits one question card per tour beat with a verbatim "Tour · " +// header; this module maps those headers onto whichever chrome element carries +// the matching data-tour-target attribute and rings it while the card is up. +// Degrades to nothing when the header is unknown or the element is absent — +// the card in the chat still reads on its own. + +export const TOUR_HEADER_PREFIX = "Tour · " + +/** Which question inside the current card is showing. The whole tour rides in a + * SINGLE multi-question card so stepping between beats costs no model round + * trip — the dock publishes its active tab here and the spotlight follows. */ +const [tourQuestionIndex, setTourQuestionIndex] = createSignal(0) +export { setTourQuestionIndex } + +/** Header suffix → the data-tour-target candidates for that beat, best first. + * A beat may name a precise control that only exists in some states: the + * Pulse Inspector and Preview live in the side panel's own menu, which is not + * mounted until the panel is open, so they fall back to the panel toggle that + * is always there. The first candidate actually on screen wins. */ +const anchor = (key: string) => `[data-tour-target="${key}"]` + +const TOUR_TARGETS: Record = { + // The real input first, so the ring traces the composer itself; the dock + // column is the fallback for layouts where the input is not mounted. + Composer: ['[data-component="prompt-input-v2"]', anchor("composer")], + Tabs: [anchor("tabs")], + "New chat": [anchor("new-chat")], + Sessions: [anchor("sessions")], + Context: [anchor("context-ring")], + // One stop for the whole panel: the Pulse Inspector, Preview and the file + // list all live behind it, and separate stops re-lit the identical button. + "Side panel": [anchor("side-panel")], + Status: [anchor("status")], + Profile: [anchor("profile")], + Settings: [anchor("settings")], +} + +/** The data-tour-target candidates for a question request, or undefined when + * the request is not a recognized tour beat. Pure — unit-tested. */ +export function tourTargetKeys( + request: Pick | undefined, + index = 0, +): readonly string[] | undefined { + const header = request?.questions?.[index]?.header + if (!header?.startsWith(TOUR_HEADER_PREFIX)) return undefined + return TOUR_TARGETS[header.slice(TOUR_HEADER_PREFIX.length)] +} + +/** True when this card is a tour stop. The tour narrates; it does not ask, so + * it must not block the composer the way a real question does — the Composer + * stop has to have a composer on screen to ring. Pure. */ +export function isTourRequest(request: Pick | undefined): boolean { + return !!request?.questions?.some((q) => q.header?.startsWith(TOUR_HEADER_PREFIX)) +} + +/** First candidate element that is actually rendered with a size. A surface can + * be in the DOM more than once (a compact and a full variant, say) with only + * one of them showing, so size — not mere presence — decides. */ +function resolveTourElement(selectors: readonly string[]): Element | undefined { + for (const selector of selectors) { + for (const el of document.querySelectorAll(selector)) { + const r = el.getBoundingClientRect() + if (r.width > 0 && r.height > 0) return el + } + } + return undefined +} + +// The ring traces the element's own edges — no inset, no halo of dead space, +// so it reads as the component itself being lit. +const RING_PAD = 0 + +type SpotRect = { top: number; left: number; width: number; height: number } + +/** The spotlight overlay: a ring around the element the current stop names. + * Purely decorative (aria-hidden, pointer-events none) — the narration lives + * in the walkthrough card in the chat. */ +export function SessionTourSpotlight(props: { request: QuestionRequest | undefined }) { + // Compare by VALUE. The measure poll runs a few times a second and would + // otherwise hand back a fresh object every tick, retriggering everything + // downstream — which restarted the ring's pulse on every tick and read as a + // rapid blink rather than a slow breath. + const sameRect = (a: SpotRect | undefined, b: SpotRect | undefined) => + a === b || + (!!a && !!b && a.top === b.top && a.left === b.left && a.width === b.width && a.height === b.height) + + const [rect, setRect] = createSignal(undefined, { equals: sameRect }) + // The walkthrough card's own box — the second hole in the scrim, so the thing + // doing the explaining stays as sharp as the thing being explained. + const [cardRect, setCardRect] = createSignal(undefined, { equals: sameRect }) + + /** Full-viewport rect minus the two holes, even-odd so the inner subpaths + * punch through. Plain rectangles: the ring's 4px corners sit on top of the + * hole's corners, so the difference is not visible. */ + const scrimPath = (target: SpotRect, card: SpotRect | undefined) => { + const hole = (r: SpotRect) => + ` M${r.left},${r.top} H${r.left + r.width} V${r.top + r.height} H${r.left} Z` + const outer = `M0,0 H${window.innerWidth} V${window.innerHeight} H0 Z` + return `path(evenodd, "${outer}${hole(target)}${card ? hole(card) : ""}")` + } + + createEffect(() => { + const keys = tourTargetKeys(props.request, tourQuestionIndex()) + setRect(undefined) + setCardRect(undefined) + if (!keys) return + const measure = () => { + const card = document.querySelector('[data-component="session-question-dock"]')?.getBoundingClientRect() + setCardRect(card ? { top: card.top, left: card.left, width: card.width, height: card.height } : undefined) + const r = resolveTourElement(keys)?.getBoundingClientRect() + if (!r) { + setRect(undefined) + return + } + setRect({ top: r.top, left: r.left, width: r.width, height: r.height }) + } + measure() + // The chrome can move under the overlay (tab churn, panel opens, window + // resize); a slow poll plus the cheap listeners keeps the ring honest + // without observing every layout container. + const interval = setInterval(measure, 300) + window.addEventListener("resize", measure) + window.addEventListener("scroll", measure, true) + onCleanup(() => { + clearInterval(interval) + window.removeEventListener("resize", measure) + window.removeEventListener("scroll", measure, true) + }) + }) + + // NOT keyed: a keyed Show tears the overlay down and builds it again whenever + // the rect changes, which restarts the CSS animation. Un-keyed, the nodes + // persist for the whole stop and only their style updates, so the pulse runs + // its full slow cycle uninterrupted. + // + // The ring is the whole marker. There is no label: the stop's own text names + // the surface, so a floating name would only repeat it and cover whatever it + // was placed over. + return ( + + +