diff --git a/.oxlintrc.json b/.oxlintrc.json index 1f453bb956..d4f49c91cb 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -121,6 +121,7 @@ "maple/kit-class-layout-only": "error", "maple/no-arbitrary-z": "error", "maple/no-handrolled-kit-look": "error", + "maple/one-step-trail": "error", "maple/popup-radius": "error", "maple/sheet-size": "error", "maple/surface-translucency": "error", diff --git a/apps/web/src/page/cost/view-add-planetscale.ts b/apps/web/src/page/cost/view-add-planetscale.ts index 66699b947b..245871e3dd 100644 --- a/apps/web/src/page/cost/view-add-planetscale.ts +++ b/apps/web/src/page/cost/view-add-planetscale.ts @@ -5,6 +5,7 @@ import { listRow } from "@maple/views/ui/list-row" import { textLink } from "@maple/views/ui/text-link" import { typography } from "@maple/views/ui/typography" import { AppRoute, hrefOf } from "../../route" +import { addTrail } from "./view-add-trail" import { connectionStep, type ConnectionStepAction } from "../kit/connection-step" import { Message } from "./message" import { type Model, type PlanetScaleStep, planetScaleStepOf } from "./model" @@ -132,6 +133,7 @@ export const planetScaleStep = (h: H, model: Model, id: string): ReadonlyArray => connectionStep(h, { dialogId: id, title: "Add GitHub", + trail: addTrail(h, "GitHub"), description: "Maple reads an organization's billed usage through the GitHub App it already has. No key is needed.", read: model.githubInstallations, @@ -392,6 +396,7 @@ export const addSourceDialog = (h: H, model: Model, sources: ReadonlyArray, provider: string | null): Html => + answerTrail(h, { + label: "Add source progress", + steps: + provider === null + ? [ + { label: "Provider", state: "current" }, + { label: "Connect", state: "next" }, + ] + : [ + // Not a button: the dialog's first focus belongs to its field, and Back is in the footer. + { label: "Provider", state: "done", answer: provider }, + { label: "Connect", state: "current" }, + ], + }) diff --git a/apps/web/src/page/cost/view-add-vercel.ts b/apps/web/src/page/cost/view-add-vercel.ts index 5dd8435dbd..3dedd97eb0 100644 --- a/apps/web/src/page/cost/view-add-vercel.ts +++ b/apps/web/src/page/cost/view-add-vercel.ts @@ -8,6 +8,7 @@ import { listRow } from "@maple/views/ui/list-row" import { textLink } from "@maple/views/ui/text-link" import { typography } from "@maple/views/ui/typography" import { AppRoute, hrefOf } from "../../route" +import { addTrail } from "./view-add-trail" import { connectionStep, type ConnectionStepAction } from "../kit/connection-step" import { Message } from "./message" import { type Model, type VercelStep, vercelStepOf } from "./model" @@ -124,6 +125,7 @@ export const vercelStep = (h: H, model: Model, id: string): ReadonlyArray connectionStep(h, { dialogId: id, title: "Add Vercel", + trail: addTrail(h, "Vercel"), description: "Maple reads what Vercel bills the team each day by product, project and region, plan fees included.", read: model.vercel.data, diff --git a/apps/web/src/page/infra-integrations/gcp/integration/view-gcp-configure-parts.ts b/apps/web/src/page/infra-integrations/gcp/integration/view-gcp-configure-parts.ts index e154f55ee1..d4e1c02b1b 100644 --- a/apps/web/src/page/infra-integrations/gcp/integration/view-gcp-configure-parts.ts +++ b/apps/web/src/page/infra-integrations/gcp/integration/view-gcp-configure-parts.ts @@ -2,11 +2,12 @@ import { cn } from "@maple/ui/lib/utils" import type { Html, HtmlBuilder } from "foldkit/html" import * as DialogView from "@maple/views/ui/dialog-view" import { icon } from "@maple/views/ui/icon" +import { answerTrail } from "@maple/views/ui/answer-trail" import { STEPS } from "./gcp-configure-copy" import { CONFIGURE_DIALOG_ID } from "./gcp-configure" import type { Message } from "../../../integrations/message" import { GCP_LINK } from "./view-gcp-parts" -import { hstack, hstackClass } from "@maple/views/ui/stack" +import { hstack } from "@maple/views/ui/stack" import { Array as Arr } from "effect" import { typography } from "@maple/views/ui/typography" @@ -57,49 +58,15 @@ export const footerNote = (h: H, children: Children): Html => export type StepIndex = 0 | 1 | 2 | 3 -/** Where the flow is, as a text trail like `/sources/new`: done steps checked, the current one underlined. */ +/** Where the flow is, the same trail as `/sources/new`: done steps checked, the current one underlined. */ const stepper = (h: H, current: StepIndex): Html => - h.ol( - [h.AriaLabel("Setup progress"), h.Class("flex flex-wrap items-center gap-2 font-prose text-xs")], - Arr.flatMap(STEPS, (label, index) => { - const done = index < current - const active = index === current - const step = h.li( - [ - ...(active ? [h.AriaCurrent("step")] : []), - h.Class( - cn( - hstackClass({ gap: "1.5", inline: true }), - active - ? "font-medium text-foreground underline decoration-primary underline-offset-4" - : "text-muted-foreground", - ), - ), - ], - [ - ...(done ? [icon(h, "CheckIcon", { size: 12, className: "text-severity-info" })] : []), - label, - ...(done ? [h.span([h.Class("sr-only")], [" (done)"])] : []), - ], - ) - return index === 0 - ? [step] - : [ - hstack( - h, - { - align: "stretch", - inline: true, - as: "li", - className: "text-foreground-subtle", - attributes: [h.AriaHidden(true)], - }, - [icon(h, "ArrowRightIcon", { size: 12 })], - ), - step, - ] - }), - ) + answerTrail(h, { + label: "Setup progress", + steps: Arr.map(STEPS, (label, index) => ({ + label, + state: index < current ? "done" : index === current ? "current" : "next", + })), + }) /** `step` is null once every step is done: the finished flow shows no stepper. */ export const configureHeader = (h: H, title: Children, step: StepIndex | null): Html => diff --git a/apps/web/src/page/kit/connection-step.ts b/apps/web/src/page/kit/connection-step.ts index ab3ac1273a..c5afc31115 100644 --- a/apps/web/src/page/kit/connection-step.ts +++ b/apps/web/src/page/kit/connection-step.ts @@ -43,6 +43,8 @@ export const connectionStep = ( dialogId: string title: string description: string + /** The flow's answer trail, under the title. */ + trail?: Html read: AsyncData.AsyncData /** What the step says while the read is out: "Reading the Vercel connection". */ readingLabel: string @@ -64,6 +66,7 @@ export const connectionStep = ( return [ DialogView.dialogHeader(h, "dialog", [ DialogView.dialogTitle(h, "dialog", options.dialogId, options.title), + ...(options.trail === undefined ? [] : [options.trail]), DialogView.dialogDescription(h, "dialog", options.dialogId, options.description), ]), DialogView.dialogPanel(h, "dialog", options.dialogId, [ diff --git a/apps/web/src/page/onboarding/quick-start-view.ts b/apps/web/src/page/onboarding/quick-start-view.ts index cf21c13316..6589b88f18 100644 --- a/apps/web/src/page/onboarding/quick-start-view.ts +++ b/apps/web/src/page/onboarding/quick-start-view.ts @@ -20,6 +20,7 @@ import { heading } from "@maple/views/ui/heading" import { icon } from "@maple/views/ui/icon" import { searchInput } from "@maple/views/ui/search-input" import { textLink } from "@maple/views/ui/text-link" +import { answerTrail } from "@maple/views/ui/answer-trail" import { type PixelGlyphName, pixelGlyph } from "./option-card" import * as CopyButton from "@maple/views/ui/copy-button" import * as GuidedSetup from "../kit/guided-setup" @@ -80,66 +81,20 @@ type TrailItem = Readonly<{ to: StepId | null }> -const trailAnswer = (h: H, item: TrailItem, answer: string): Html => { - const content = [icon(h, "CheckIcon", { size: 12, className: "text-severity-info" }), answer] - return item.to === null - ? hstack(h, { gap: "1.5", as: "span", className: typographyClass({ tone: "muted" }) }, content) - : textLink( - h, - { - tone: "muted", - onClick: Message.ClickedBack({ to: item.to }), - className: "gap-1.5 underline-offset-4", - // Starts with the visible text, so voice control can say what it sees. - attributes: [ - h.AriaLabel( - answer === item.label ? `Go to ${item.label}` : `${answer}, go to ${item.label}`, - ), - ], - }, - content, - ) -} - /** The quiet progress line: done steps show their answer and reopen it, the current one is underlined. */ const trail = (h: H, items: ReadonlyArray): Html => h.nav( - [h.AriaLabel("Onboarding progress"), h.Class("hidden items-center gap-2 text-xs md:flex")], + [h.AriaLabel("Onboarding progress"), h.Class("hidden md:flex")], [ - h.ol( - [h.Class(hstackClass({ gap: "2" }))], - Arr.map(items, (item, index) => - hstack( - h, - { - gap: "2", - align: "center", - as: "li", - className: "whitespace-nowrap", - attributes: [...(item.isCurrent ? [h.AriaCurrent("step")] : [])], - }, - [ - ...(index === 0 - ? [] - : [h.span([h.AriaHidden(true), h.Class("text-input")], ["/"])]), - item.answer === null - ? h.span( - [ - h.Class( - cn( - item.isCurrent - ? "text-foreground underline underline-offset-4" - : "text-muted-foreground", - ), - ), - ], - [item.label], - ) - : trailAnswer(h, item, item.answer), - ], - ), - ), - ), + answerTrail(h, { + label: "Onboarding steps", + steps: Arr.map(items, (item) => ({ + label: item.label, + state: item.isCurrent ? "current" : item.answer === null ? "next" : "done", + answer: item.answer, + onClick: item.to === null ? null : Message.ClickedBack({ to: item.to }), + })), + }), ], ) diff --git a/apps/web/src/page/sources/new/aws-flow.scene.test.ts b/apps/web/src/page/sources/new/aws-flow.scene.test.ts index 0138e1cb9c..c768fe6450 100644 --- a/apps/web/src/page/sources/new/aws-flow.scene.test.ts +++ b/apps/web/src/page/sources/new/aws-flow.scene.test.ts @@ -264,7 +264,7 @@ describe("aws connect flow", () => { scene( { update: pageUpdate, view }, given(streaming("2026-10-23T08:59:30.000Z")), - expectView(role("listitem", { name: "Live" })).toExist(), + expectView(role("listitem", { name: "Live (done)" })).toExist(), expectView(text("Receiving metrics")).toExist(), ) scene( diff --git a/apps/web/src/page/sources/new/cloudflare-flow.scene.test.ts b/apps/web/src/page/sources/new/cloudflare-flow.scene.test.ts index 58c071f7ab..7e4eb6731e 100644 --- a/apps/web/src/page/sources/new/cloudflare-flow.scene.test.ts +++ b/apps/web/src/page/sources/new/cloudflare-flow.scene.test.ts @@ -80,7 +80,7 @@ describe("cloudflare connect flow", () => { expectView(role("button", { name: "Authorize again" })).toExist(), click(role("button", { name: "Continue" })), Command.resolve(ScrollToTop, Message.CompletedScrollToTop()), - expectView(role("listitem", { name: "Acme" })).toExist(), + expectView(role("listitem", { name: "Acme (done)" })).toExist(), expectView(role("listitem", { current: "step", name: "Go live" })).toExist(), expectView(text("Access works")).toExist(), expectView(text("Zones found: 2")).toExist(), @@ -106,7 +106,7 @@ describe("cloudflare connect flow", () => { given(withStatus(status({ zones: [zone("acme.com", NOW - 60_000)] }))), click(role("button", { name: "Continue" })), Command.resolve(ScrollToTop, Message.CompletedScrollToTop()), - expectView(role("listitem", { name: "Live" })).toExist(), + expectView(role("listitem", { name: "Live (done)" })).toExist(), expectView(text("Cloudflare is live")).toExist(), expectView(text("Backfilled the last 24 hours")).toExist(), ) diff --git a/apps/web/src/page/sources/new/gcp-flow.scene.test.ts b/apps/web/src/page/sources/new/gcp-flow.scene.test.ts index 4ba6ca47c0..ddbe27663e 100644 --- a/apps/web/src/page/sources/new/gcp-flow.scene.test.ts +++ b/apps/web/src/page/sources/new/gcp-flow.scene.test.ts @@ -106,7 +106,7 @@ describe("google cloud connect flow", () => { click(role("button", { name: "Continue" })), Command.resolve(ScrollToTop, PageMessage.CompletedScrollToTop()), expectView(role("listitem", { current: "step", name: "Choose data" })).toExist(), - expectView(role("listitem", { name: "Project acme-prod" })).toExist(), + expectView(role("listitem", { name: "Project acme-prod (done)" })).toExist(), expectView(role("button", { name: "Create connection" })).toExist(), expectView(role("button", { name: "Continue" })).toBeDisabled(), ) @@ -127,7 +127,7 @@ describe("google cloud connect flow", () => { { update: pageUpdate, view }, given(created()), expectView(role("listitem", { current: "step", name: "Go live" })).toExist(), - expectView(role("listitem", { name: "Logs + metrics" })).toExist(), + expectView(role("listitem", { name: "Logs + metrics (done)" })).toExist(), expectView(text("Run this script in Cloud Shell")).toExist(), expectView(role("listitem", { name: /Script run/ })).toExist(), expectView(role("listitem", { name: /Logs arrive/ })).toExist(), @@ -148,7 +148,7 @@ describe("google cloud connect flow", () => { last_metrics_received_at: "2026-10-24T11:59:50.000Z", }), ), - expectView(role("listitem", { name: "Live" })).toExist(), + expectView(role("listitem", { name: "Live (done)" })).toExist(), expectView(text("Confirmed: data shows up in Maple on its own from now on.")).toExist(), expectView(text("Run this script in Cloud Shell")).not.toExist(), ) diff --git a/apps/web/src/page/sources/new/github-flow.scene.test.ts b/apps/web/src/page/sources/new/github-flow.scene.test.ts index 81c90a8c35..71f134e111 100644 --- a/apps/web/src/page/sources/new/github-flow.scene.test.ts +++ b/apps/web/src/page/sources/new/github-flow.scene.test.ts @@ -571,7 +571,7 @@ describe("github connect flow", () => { expectView(text("Installed on acme with 2 repositories.")).toExist(), click(role("button", { name: "Continue" })), Command.resolve(ScrollToTop, Message.CompletedScrollToTop()), - expectView(role("listitem", { name: "Installed on acme" })).toExist(), + expectView(role("listitem", { name: "Installed on acme (done)" })).toExist(), expectView(role("listitem", { current: "step", name: "Go live" })).toExist(), expectView(text("Repositories syncing")).toExist(), expectView(text("acme/api")).toExist(), @@ -584,7 +584,7 @@ describe("github connect flow", () => { given(withStatus(status({ repositories: [repo("1", "web", "ready")] }))), click(role("button", { name: "Continue" })), Command.resolve(ScrollToTop, Message.CompletedScrollToTop()), - expectView(role("listitem", { name: "Live" })).toExist(), + expectView(role("listitem", { name: "Live (done)" })).toExist(), expectView(text("GitHub is live")).toExist(), ) scene( diff --git a/apps/web/src/page/sources/new/new.scene.test.ts b/apps/web/src/page/sources/new/new.scene.test.ts index a0caa0d1e9..9d3cf00949 100644 --- a/apps/web/src/page/sources/new/new.scene.test.ts +++ b/apps/web/src/page/sources/new/new.scene.test.ts @@ -213,7 +213,7 @@ describe("connect a source", () => { given(receiving.model), indicatorMeasured(), expectView(role("link", { name: "Open source" })).toExist(), - expectView(role("listitem", { name: "Receiving data" })).toExist(), + expectView(role("listitem", { name: "Receiving data (done)" })).toExist(), ) }) @@ -315,7 +315,7 @@ describe("connect a source", () => { expectView(role("listitem", { current: "step", name: "Connect" })).toExist(), click(role("button", { name: "Continue" })), Command.resolve(ScrollToTop, Message.CompletedScrollToTop()), - expectView(role("listitem", { name: "Acme" })).toExist(), + expectView(role("listitem", { name: "Acme (done)" })).toExist(), expectView(role("link", { name: "Open source" })).toExist(), click(role("button", { name: "Back" })), Command.resolve(ScrollToTop, Message.CompletedScrollToTop()), @@ -393,7 +393,7 @@ describe("connect a source", () => { scene( { update: pageUpdate, view }, given(live(status("2026-10-23T08:59:30.000Z"))), - expectView(role("listitem", { name: "Live" })).toExist(), + expectView(role("listitem", { name: "Live (done)" })).toExist(), ) }) @@ -463,7 +463,7 @@ describe("connect a source", () => { Command.resolve(ScrollToTop, Message.CompletedScrollToTop()), click(role("button", { name: "Continue" })), Command.resolve(ScrollToTop, Message.CompletedScrollToTop()), - expectView(role("listitem", { name: "api.warpstream.com" })).toExist(), + expectView(role("listitem", { name: "api.warpstream.com (done)" })).toExist(), expectView(role("link", { name: "View sources" })).toExist(), ) }) diff --git a/apps/web/src/page/sources/new/planetscale-flow.scene.test.ts b/apps/web/src/page/sources/new/planetscale-flow.scene.test.ts index a6e20dbb1b..a7b986c446 100644 --- a/apps/web/src/page/sources/new/planetscale-flow.scene.test.ts +++ b/apps/web/src/page/sources/new/planetscale-flow.scene.test.ts @@ -120,7 +120,7 @@ describe("PlanetScale connect flow", () => { click(role("button", { name: "Continue" })), Command.resolve(ScrollToTop, Message.CompletedScrollToTop()), expectView(role("listitem", { current: "step", name: "Choose data" })).toExist(), - expectView(role("listitem", { name: "Authorized" })).toExist(), + expectView(role("listitem", { name: "Authorized (done)" })).toExist(), expectView(role("radio", { name: "labs" })).toExist(), expectView(role("button", { name: "Start collecting" })).toBeDisabled(), expectView(role("button", { name: "Continue" })).toBeDisabled(), @@ -149,7 +149,7 @@ describe("PlanetScale connect flow", () => { scene( { update: pageUpdate, view }, given(choosing), - expectView(role("listitem", { name: "acme" })).toExist(), + expectView(role("listitem", { name: "acme (done)" })).toExist(), expectView(role("listitem", { current: "step", name: "Choose data" })).toExist(), expectView(role("button", { name: "Continue" })).toBeEnabled(), ) @@ -241,7 +241,7 @@ describe("PlanetScale connect flow", () => { given( live(status({ api_metrics: { last_polled_at: "2026-10-24T11:57:00Z", last_error: null } })), ), - expectView(role("listitem", { name: "Live" })).toExist(), + expectView(role("listitem", { name: "Live (done)" })).toExist(), expectView(text("PlanetScale is live")).toExist(), ) }) diff --git a/apps/web/src/page/sources/new/prometheus-flow.scene.test.ts b/apps/web/src/page/sources/new/prometheus-flow.scene.test.ts index 1c76de2550..a80f4317da 100644 --- a/apps/web/src/page/sources/new/prometheus-flow.scene.test.ts +++ b/apps/web/src/page/sources/new/prometheus-flow.scene.test.ts @@ -146,7 +146,7 @@ describe("prometheus connect flow", () => { click(role("button", { name: "Continue" })), Command.resolve(ScrollToTop, Message.CompletedScrollToTop()), expectView(role("listitem", { current: "step", name: "Choose data" })).toExist(), - expectView(role("listitem", { name: "redis.internal:9121" })).toExist(), + expectView(role("listitem", { name: "redis.internal:9121 (done)" })).toExist(), expectView(role("button", { name: "Continue" })).toBeEnabled(), click(role("button", { name: "Continue" })), Command.resolve(ScrollToTop, Message.CompletedScrollToTop()), @@ -194,7 +194,7 @@ describe("prometheus connect flow", () => { scene( { update: pageUpdate, view }, given(withCheck(live, true)), - expectView(role("listitem", { name: "Live" })).toExist(), + expectView(role("listitem", { name: "Live (done)" })).toExist(), expectView(text("Last scrape read 1.3K samples in 200ms")).toExist(), ) scene( diff --git a/apps/web/src/page/sources/new/supabase-flow.scene.test.ts b/apps/web/src/page/sources/new/supabase-flow.scene.test.ts index a4be5ae5ff..43197e9ef3 100644 --- a/apps/web/src/page/sources/new/supabase-flow.scene.test.ts +++ b/apps/web/src/page/sources/new/supabase-flow.scene.test.ts @@ -135,7 +135,7 @@ describe("supabase connect flow", () => { click(role("button", { name: "Continue" })), Command.resolve(ScrollToTop, Message.CompletedScrollToTop()), expectView(role("listitem", { current: "step", name: "Choose data" })).toExist(), - expectView(role("listitem", { name: "Acme" })).toExist(), + expectView(role("listitem", { name: "Acme (done)" })).toExist(), expectView(role("switch", { name: "Collect metrics for storefront" })).toBeEnabled(), expectView(text("Turn on at least one project to continue.")).toExist(), expectView(role("button", { name: "Continue" })).toBeDisabled(), @@ -173,7 +173,7 @@ describe("supabase connect flow", () => { scene( { update: pageUpdate, view }, given(live(withRows(collecting, [row("2026-10-01 11:59:37")]))), - expectView(role("listitem", { name: "Live" })).toExist(), + expectView(role("listitem", { name: "Live (done)" })).toExist(), expectView(text("1 of 1 projects reporting CPU, memory, disk and Postgres")).toExist(), ) }) diff --git a/apps/web/src/page/sources/new/view-trail.ts b/apps/web/src/page/sources/new/view-trail.ts index 179e7b13cf..1b92ad934d 100644 --- a/apps/web/src/page/sources/new/view-trail.ts +++ b/apps/web/src/page/sources/new/view-trail.ts @@ -1,13 +1,11 @@ -import { cn } from "@maple/ui/lib/utils" import type { Html, HtmlBuilder } from "foldkit/html" -import { icon } from "@maple/views/ui/icon" +import { answerTrail } from "@maple/views/ui/answer-trail" import type { FlowStage } from "../../integrations/embed" import { platformMeta } from "../kit/platforms" import { Message } from "./message" import type { Model } from "./model" import { flowSpecOf, isReceiving } from "./update" import { integrationMeta } from "./view-picker" -import { hstack, hstackClass } from "@maple/views/ui/stack" import { Array as Arr } from "effect" // The connect flow's progress: one line under the title. A finished step shows its answer, not its name. @@ -64,49 +62,5 @@ export const trailOf = (model: Model): ReadonlyArray => { return [current("Source"), next("Connect"), next("Go live")] } -const ITEM_CLASS = { - done: "text-muted-foreground", - current: "text-foreground border-primary border-b pb-0.5 font-medium", - next: "text-muted-foreground", - blocked: "text-severity-warn border-severity-warn border-b pb-0.5 font-medium", -} satisfies Record - -const item = (h: H, entry: TrailItem): Html => - h.li( - [ - h.Class(cn(hstackClass({ gap: "1.5", inline: true }), ITEM_CLASS[entry.state])), - ...(entry.state === "current" || entry.state === "blocked" ? [h.AriaCurrent("step")] : []), - ], - [ - ...(entry.state === "done" - ? [icon(h, "CheckIcon", { size: 12, className: "text-severity-info" })] - : entry.state === "blocked" - ? [icon(h, "AlertWarningIcon", { size: 12, className: "text-severity-warn" })] - : []), - entry.label, - ], - ) - export const trail = (h: H, model: Model): Html => - h.ol( - [h.AriaLabel("Setup progress"), h.Class("flex flex-wrap items-center gap-2 font-mono text-xs")], - Arr.flatMap(trailOf(model), (entry, index) => - index === 0 - ? [item(h, entry)] - : [ - // An arrow, not a slash: the steps are a flow, not a path back up. - hstack( - h, - { - align: "stretch", - inline: true, - as: "li", - className: "text-foreground-subtle", - attributes: [h.AriaHidden(true)], - }, - [icon(h, "ArrowRightIcon", { size: 12 })], - ), - item(h, entry), - ], - ), - ) + answerTrail(h, { label: "Setup progress", steps: trailOf(model) }) diff --git a/apps/web/src/page/status-pages/editor/editor.scene.test.ts b/apps/web/src/page/status-pages/editor/editor.scene.test.ts index 33cd0309d8..efb947dd90 100644 --- a/apps/web/src/page/status-pages/editor/editor.scene.test.ts +++ b/apps/web/src/page/status-pages/editor/editor.scene.test.ts @@ -651,7 +651,8 @@ describe("posting an incident", () => { given(opened), mounted, expectView(role("dialog", { name: "Post incident" })).toExist(), - expectView(role("list", { name: "Steps" })).toHaveText("1 Write/2 Review"), + expectView(role("listitem", { name: "Write" })).toHaveAttr("aria-current", "step"), + expectView(role("listitem", { name: "Review" })).not.toHaveAttr("aria-current", "step"), expectView(role("textbox", { name: "Title" })).toExist(), expectView(role("checkbox", { name: "Component 2" })).toBeChecked(), expectView(role("checkbox", { name: "Component 1" })).not.toBeChecked(), diff --git a/apps/web/src/page/status-pages/editor/view-incident-dialogs.ts b/apps/web/src/page/status-pages/editor/view-incident-dialogs.ts index 752319f12e..9c27d3719f 100644 --- a/apps/web/src/page/status-pages/editor/view-incident-dialogs.ts +++ b/apps/web/src/page/status-pages/editor/view-incident-dialogs.ts @@ -25,7 +25,7 @@ import { dividedList } from "@maple/views/ui/rule" import { segmentedSelect } from "@maple/views/ui/segmented-select" import { hstack, vstack, vstackClass } from "@maple/views/ui/stack" import { statusDot } from "@maple/views/ui/status-dot" -import { stepTrail } from "@maple/views/ui/step-trail" +import { answerTrail } from "@maple/views/ui/answer-trail" import { textLink } from "@maple/views/ui/text-link" import { typography } from "@maple/views/ui/typography" import { relativeTime } from "../../kit/relative-time" @@ -72,8 +72,13 @@ const header = ( options: Readonly<{ step: ComposeStep; title: string; description?: ReadonlyArray }>, ): Html => DialogView.dialogHeader(h, "dialog", [ - stepTrail(h, { steps: ["Write", "Review"], current: options.step === "write" ? 0 : 1 }), DialogView.dialogTitle(h, "dialog", id, options.title), + answerTrail(h, { + steps: [ + { label: "Write", state: options.step === "write" ? "current" : "done" }, + { label: "Review", state: options.step === "write" ? "next" : "current" }, + ], + }), ...(options.description === undefined ? [] : [ diff --git a/docs/design-rules.md b/docs/design-rules.md index aa03269fc3..dff414aac4 100644 --- a/docs/design-rules.md +++ b/docs/design-rules.md @@ -110,6 +110,9 @@ bordered box takes its parent's radius minus 1px: `before:rounded-lg-inset` insi - Layers come from the z scale, never `z-[…]` (`maple/no-arbitrary-z`): `z-1`..`z-10` inside a surface, `z-50` dialogs and sheets, `z-55` popovers and tooltips, `z-60` toasts and nested lists. A popup takes a popup radius (`maple/popup-radius`). +- Every step flow (connect page, setup dialog, onboarding) shows its progress with `answerTrail`: + done steps as answer chips, the current one underlined in primary, sans, no numbers. Marking + `aria-current="step"` anywhere else is a second trail (`maple/one-step-trail`). - Neutral washes never take a `/NN`: `bg-muted/50` is `bg-surface-sunken` (`maple/surface-washes`). - Hover fills: a row, list item or chip hovers `bg-surface-sunken`, and a chip already on `surface-sunken` hovers `surface-strong`. A control (ghost button, toggle, tab, menu trigger) diff --git a/packages/views/src/ui/INDEX.md b/packages/views/src/ui/INDEX.md index 5b4aa4b3ad..1fddbad0bb 100644 --- a/packages/views/src/ui/INDEX.md +++ b/packages/views/src/ui/INDEX.md @@ -10,6 +10,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `packages/views/src/ui/action-bar.ts` `actionBar`: `ActionBar`: the one sticky bar a form, editor or step flow pins to the bottom of its scroll area: a floating card, so it reads the same in a page, a capped settings column and... - `packages/views/src/ui/alert.ts` `alert`, `alertTitle`, `alertDescription`, `alertAction`: The Alert callout: a toned box with an icon, title, description and an action. - `packages/views/src/ui/anchor.ts` `computePosition`, `measurePosition`: Base UI's anchor positioning (`internals/useAnchorPositioning.mjs` over Floating UI) for the cases the kit uses: offset, flip, shift with `limitShift`, size and the `transformOr... +- `packages/views/src/ui/answer-trail.ts` `answerTrail`: `AnswerTrail`: every step flow's progress line (connect pages, setup dialogs, onboarding). - `packages/views/src/ui/attributes/attribute-row.ts` `copyableValue`, `attributeFilterAction`, `attributeRow`: An attribute row: a copyable or selectable value and collapsible JSON, against an AttributesPanel Model. - `packages/views/src/ui/attributes/attributes-panel.ts` `isOpen`, `copyStatusOf` (stateful: Model, init, update): The log attributes panel's state: the attribute search, which collapsibles (namespace groups, JSON values, "Maple internal") are open, and the status of the last copy. - `packages/views/src/ui/attributes/attributes-view.ts` `attributesSection`, `tableView`, `view`: The attributes table, the attribute and resource-attribute sections, and the log attributes panel. @@ -161,7 +162,6 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `packages/views/src/ui/stack.ts` `hstackClass`, `vstackClass`, `hstack`, `vstack`: `HStack` and `VStack`: a flex row (items centred by default) or column with its gap on the spacing scale, instead of a hand-written `flex items-center gap-2` string. - `packages/views/src/ui/stat-figure.ts` `statFigure`: A headline number with a muted unit. - `packages/views/src/ui/status-dot.ts` `statusDot`: A static, decorative status dot. -- `packages/views/src/ui/step-trail.ts` `stepTrail`: A step flow's place, over a dialog's title: `1 WRITE / 2 REVIEW`. - `packages/views/src/ui/swatch.ts` `swatch`: A small square colour key beside a legend, tooltip or table label; `dashed` draws an outline in `color` instead of a fill, `round` a dot for a series that is a share, not a line. - `packages/views/src/ui/switch.ts` `switchControl`, `labelId`, `view` (stateful: Model, init, update): Base UI `Switch`: on or off, with a thumb and a visually hidden checkbox. - `packages/views/src/ui/table-skeleton.ts` `tableSkeleton`: The loading placeholder for row tables. diff --git a/packages/views/src/ui/answer-trail.ts b/packages/views/src/ui/answer-trail.ts new file mode 100644 index 0000000000..699f4af818 --- /dev/null +++ b/packages/views/src/ui/answer-trail.ts @@ -0,0 +1,110 @@ +import { cn } from "@maple/ui/lib/utils" +import type { Html, HtmlBuilder } from "foldkit/html" +import { Array as Arr } from "effect" +import { icon } from "./icon" +import { pressable } from "./pressable" + +// `AnswerTrail`: every step flow's progress line (connect pages, setup dialogs, onboarding). A done +// step shows the answer it was given as a receipt chip, the current step is underlined in primary. +// The one place a flow marks `aria-current="step"`; `maple/one-step-trail` keeps it that way. + +export type AnswerTrailStep = Readonly<{ + label: string + state: "done" | "current" | "next" | "blocked" + /** What a done step was answered with ("us-east-1"); the label when absent. */ + answer?: string | null + /** Reopens a done step. */ + onClick?: M | null +}> + +export type AnswerTrailOptions = Readonly<{ + steps: ReadonlyArray> + /** The list's accessible name. */ + label?: string + className?: string +}> + +const CHIP = + "inline-flex max-w-full min-w-0 items-center gap-1.5 whitespace-nowrap rounded-full border border-border bg-card py-0.5 pr-2 pl-1.5 text-foreground" + +const doneStep = (h: HtmlBuilder, step: AnswerTrailStep): Html => { + const answer = step.answer ?? step.label + // A long answer truncates inside the chip rather than pushing the trail past a phone's width. + const content = [ + icon(h, "CheckIcon", { size: 12, className: "shrink-0 text-severity-info" }), + h.span([h.Class("truncate")], [answer]), + ] + const onClick = step.onClick ?? null + // The check is decorative, so a static chip says its state in words; a button says it in its name. + return onClick === null + ? h.span([h.Class(CHIP)], [...content, h.span([h.Class("sr-only")], [" (done)"])]) + : pressable( + h, + { + onClick, + // Starts with the visible text, so voice control can say what it sees. + label: answer === step.label ? `Go to ${step.label}` : `${answer}, go to ${step.label}`, + className: cn(CHIP, "hover:bg-surface-sunken active:bg-surface-sunken"), + }, + content, + ) +} + +const step = (h: HtmlBuilder, entry: AnswerTrailStep): Html => { + const isCurrent = entry.state === "current" || entry.state === "blocked" + return h.li( + [ + h.Key(entry.label), + h.Class("inline-flex min-w-0 max-w-full items-center"), + ...(isCurrent ? [h.AriaCurrent("step")] : []), + ], + [ + entry.state === "done" + ? doneStep(h, entry) + : entry.state === "blocked" + ? h.span( + [ + h.Class( + "inline-flex items-center gap-1.5 whitespace-nowrap font-medium text-severity-warn underline decoration-severity-warn underline-offset-4", + ), + ], + [icon(h, "AlertWarningIcon", { size: 12 }), entry.label], + ) + : h.span( + [ + h.Class( + entry.state === "current" + ? "whitespace-nowrap font-medium text-foreground underline decoration-primary underline-offset-4" + : "whitespace-nowrap text-muted-foreground", + ), + ], + [entry.label], + ), + ], + ) +} + +export const answerTrail = (h: HtmlBuilder, options: AnswerTrailOptions): Html => + h.ol( + [ + h.AriaLabel(options.label ?? "Steps"), + h.Class(cn("flex flex-wrap items-center gap-x-2 gap-y-1.5 font-sans text-xs", options.className)), + h.DataAttribute("slot", "answer-trail"), + ], + Arr.flatMap(options.steps, (entry, index) => + index === 0 + ? [step(h, entry)] + : [ + // An arrow, not a slash: the steps are a flow, not a path back up. + h.li( + [ + h.Key(`${entry.label}-sep`), + h.AriaHidden(true), + h.Class("inline-flex text-foreground-subtle"), + ], + [icon(h, "ArrowRightIcon", { size: 12 })], + ), + step(h, entry), + ], + ), + ) diff --git a/packages/views/src/ui/step-trail.ts b/packages/views/src/ui/step-trail.ts deleted file mode 100644 index 3b73a169a3..0000000000 --- a/packages/views/src/ui/step-trail.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { cn } from "@maple/ui/lib/utils" -import type { Html, HtmlBuilder } from "foldkit/html" -import { Array as Arr } from "effect" - -// A step flow's place, over a dialog's title: `1 WRITE / 2 REVIEW`. - -/** `StepTrail`: a step flow's numbered steps in one mono uppercase row, the current one in primary. */ -export const stepTrail = ( - h: HtmlBuilder, - options: Readonly<{ steps: ReadonlyArray; current: number; className?: string }>, -): Html => - h.ol( - [ - h.AriaLabel("Steps"), - h.Class( - cn( - "flex items-center gap-2 font-mono text-2xs uppercase tracking-caption text-muted-foreground", - options.className, - ), - ), - h.DataAttribute("slot", "step-trail"), - ], - Arr.map(options.steps, (step, index) => - h.li( - [ - h.Key(step), - h.Class("flex items-center gap-2"), - ...(index === options.current ? [h.AriaCurrent("step")] : []), - ], - [ - ...(index === 0 ? [] : [h.span([h.AriaHidden(true)], ["/"])]), - h.span( - [h.Class(index === options.current ? "text-primary" : "")], - [`${index + 1} ${step}`], - ), - ], - ), - ), - ) diff --git a/scripts/oxlint-plugins/consistency-rules.mjs b/scripts/oxlint-plugins/consistency-rules.mjs index ee727d3435..432262a061 100644 --- a/scripts/oxlint-plugins/consistency-rules.mjs +++ b/scripts/oxlint-plugins/consistency-rules.mjs @@ -58,7 +58,8 @@ const classChunks = (node) => { if (node === null || node === undefined) return [] if (node.type === "Literal" && typeof node.value === "string") return [node.value] if (node.type === "TemplateLiteral") return node.quasis.map((quasi) => quasi.value.raw) - if (node.type === "ConditionalExpression") return [...classChunks(node.consequent), ...classChunks(node.alternate)] + if (node.type === "ConditionalExpression") + return [...classChunks(node.consequent), ...classChunks(node.alternate)] if (node.type === "LogicalExpression") return classChunks(node.right) if (node.type === "CallExpression" && node.callee.type === "Identifier" && node.callee.name === "cn") return node.arguments.flatMap(classChunks) @@ -76,7 +77,10 @@ const kitClassLayoutOnly = ratcheted("kit-class-layout-only", { const source = node.source.value if (typeof source !== "string" || !KIT_SOURCE.test(source)) return for (const specifier of node.specifiers) - if (specifier.type === "ImportSpecifier" && LOOK_OWNERS.has(specifier.imported.name ?? specifier.local.name)) + if ( + specifier.type === "ImportSpecifier" && + LOOK_OWNERS.has(specifier.imported.name ?? specifier.local.name) + ) kitNames.add(specifier.local.name) }, CallExpression(node) { @@ -125,12 +129,16 @@ const classTokensOf = (node) => { /** `vstack(h, { as, className }, …)`: a stack is a raw element with a className, so it counts too. */ const stackElement = (node) => { - if (node.callee.type !== "Identifier" || (node.callee.name !== "vstack" && node.callee.name !== "hstack")) return + if (node.callee.type !== "Identifier" || (node.callee.name !== "vstack" && node.callee.name !== "hstack")) + return const options = node.arguments[1] if (options?.type !== "ObjectExpression") return const value = (key) => options.properties.find( - (property) => property.type === "Property" && property.key.type === "Identifier" && property.key.name === key, + (property) => + property.type === "Property" && + property.key.type === "Identifier" && + property.key.name === key, )?.value const as = value("as") return { @@ -175,7 +183,8 @@ const KIT_LOOKS = [ { kit: "`dividedList` (ui/rule)", element: /./, - match: (t) => t.includes("divide-y") && t.includes("border") && t.some((x) => x.startsWith("rounded")), + match: (t) => + t.includes("divide-y") && t.includes("border") && t.some((x) => x.startsWith("rounded")), }, ] @@ -190,7 +199,9 @@ const noHandrolledKitLook = ratcheted("no-handrolled-kit-look", { if (element === undefined) return const tokens = stack?.tokens ?? classTokensOf(node) if (tokens.length === 0) return - const look = KIT_LOOKS.find((candidate) => candidate.element.test(element) && candidate.match(tokens)) + const look = KIT_LOOKS.find( + (candidate) => candidate.element.test(element) && candidate.match(tokens), + ) if (look !== undefined) push(node, { element, kit: look.kit }) }, }), @@ -200,12 +211,16 @@ const noHandrolledKitLook = ratcheted("no-handrolled-kit-look", { const COPY = [ { pattern: /’/, fix: "a straight apostrophe `'`" }, { - pattern: /\b(?:in the window|in window|in this (?:time )?range|(?:in|over) the selected (?:time )?(?:range|window))\b/i, + pattern: + /\b(?:in the window|in window|in this (?:time )?range|(?:in|over) the selected (?:time )?(?:range|window))\b/i, fix: '"in this window"', }, { pattern: /^Retry$/, fix: '"Try again", the kit error-state\'s label' }, { pattern: /\bLoading\.\.\.$/, fix: '"Loading…"' }, - { pattern: /\bNo matching \w|\bNo matches found\b/, fix: '"No {noun} match" ("No matches" without a noun)' }, + { + pattern: /\bNo matching \w|\bNo matches found\b/, + fix: '"No {noun} match" ("No matches" without a noun)', + }, ] const uiCopy = ratcheted("ui-copy", { @@ -219,7 +234,11 @@ const uiCopy = ratcheted("ui-copy", { return { Literal(node) { if (typeof node.value !== "string") return - if (node.parent?.type === "ImportDeclaration" || node.parent?.type === "ExportNamedDeclaration") return + if ( + node.parent?.type === "ImportDeclaration" || + node.parent?.type === "ExportNamedDeclaration" + ) + return check(node, node.value) }, TemplateElement(node) { @@ -249,28 +268,35 @@ const emptyCopy = ratcheted("empty-copy", { /** Whole texts: a template reads as one string, its holes as a word. */ const strings = (node) => { if (node?.type === "ArrayExpression") return node.elements.flatMap(strings) - if (node?.type === "TemplateLiteral") return [node.quasis.map((quasi) => quasi.value.cooked ?? "").join("x")] - if (node?.type === "ConditionalExpression") return [...strings(node.consequent), ...strings(node.alternate)] + if (node?.type === "TemplateLiteral") + return [node.quasis.map((quasi) => quasi.value.cooked ?? "").join("x")] + if (node?.type === "ConditionalExpression") + return [...strings(node.consequent), ...strings(node.alternate)] if (node?.type === "LogicalExpression") return strings(node.right) return node?.type === "Literal" && typeof node.value === "string" ? [node.value] : [] } // A slot whose branches include a multi-sentence text is a description: periods stay. const report = (node, values) => { if (values.some((text) => /[.?!]\s/.test(text.trim()))) return - for (const text of values) if (isOneLineSentence(text)) push(node, { text: text.trim().slice(-50) }) + for (const text of values) + if (isOneLineSentence(text)) push(node, { text: text.trim().slice(-50) }) } return { CallExpression(node) { if (node.callee.type !== "Identifier" || !EMPTY_CALLS.has(node.callee.name)) return for (const argument of node.arguments) { // Text that runs into an inline control ("Couldn't load. [Try again]") is a sentence, not a one-liner. - if (argument?.type === "ArrayExpression" && argument.elements.some((element) => element?.type === "CallExpression")) + if ( + argument?.type === "ArrayExpression" && + argument.elements.some((element) => element?.type === "CallExpression") + ) continue report(node, strings(argument)) } }, Property(node) { - if (node.key.type === "Identifier" && EMPTY_KEYS.has(node.key.name)) report(node, strings(node.value)) + if (node.key.type === "Identifier" && EMPTY_KEYS.has(node.key.name)) + report(node, strings(node.value)) }, } }, @@ -328,7 +354,10 @@ const sheetSize = ratcheted("sheet-size", { ObjectExpression(node) { const property = (key) => node.properties.find( - (entry) => entry.type === "Property" && entry.key.type === "Identifier" && entry.key.name === key, + (entry) => + entry.type === "Property" && + entry.key.type === "Identifier" && + entry.key.name === key, )?.value const side = property("side") if (side?.type !== "Literal" || (side.value !== "left" && side.value !== "right")) return @@ -337,17 +366,64 @@ const sheetSize = ratcheted("sheet-size", { for (const chunk of classChunks(property("className"))) for (const token of chunk.split(/\s+/).filter(Boolean)) // A variant aimed at a descendant (`[&_[data-slot=…]]:max-w-xl`) sizes that part, not the sheet. - if (SHEET_WIDTH.test(splitToken(token).utility) && !splitToken(token).variants.some((v) => v.startsWith("["))) + if ( + SHEET_WIDTH.test(splitToken(token).utility) && + !splitToken(token).variants.some((v) => v.startsWith("[")) + ) push(node, { token }) }, }), }) +/** + * Every step flow shows its progress with the kit's `answerTrail`: one look for connect pages, + * setup dialogs and onboarding. A flow that marks its own `aria-current="step"` is a second trail. + */ +const oneStepTrail = { + meta: { + type: "problem", + docs: { description: "Step progress is drawn by the kit's answerTrail, never hand-rolled." }, + messages: { + oneStepTrail: + "A hand-rolled step trail. Use `answerTrail` from `@maple/views/ui/answer-trail` so every wizard shows progress the same way: done steps as answer chips, the current one underlined.", + }, + }, + create(context) { + if ((context.filename ?? context.getFilename()).endsWith("packages/views/src/ui/answer-trail.ts")) + return {} + const isStep = (node) => node?.type === "Literal" && node.value === "step" + return { + CallExpression(node) { + const callee = node.callee + const name = + callee.type === "MemberExpression" && !callee.computed + ? callee.property.name + : callee.type === "Identifier" + ? callee.name + : null + if (name === "AriaCurrent" && isStep(node.arguments[0])) + context.report({ node, messageId: "oneStepTrail" }) + }, + Property(node) { + const key = + node.key.type === "Literal" + ? node.key.value + : node.key.type === "Identifier" + ? node.key.name + : null + if ((key === "aria-current" || key === "ariaCurrent") && isStep(node.value)) + context.report({ node, messageId: "oneStepTrail" }) + }, + } + }, +} + export const consistencyRules = { "empty-copy": emptyCopy, "kit-class-layout-only": kitClassLayoutOnly, "no-arbitrary-z": noArbitraryZ, "no-handrolled-kit-look": noHandrolledKitLook, + "one-step-trail": oneStepTrail, "popup-radius": popupRadius, "sheet-size": sheetSize, "surface-translucency": surfaceTranslucency,