Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/page/cost/view-add-planetscale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -132,6 +133,7 @@ export const planetScaleStep = (h: H, model: Model, id: string): ReadonlyArray<H
connectionStep(h, {
dialogId: id,
title: "Add PlanetScale",
trail: addTrail(h, "PlanetScale"),
description:
"PlanetScale bills by the month, so Maple spreads each invoice line evenly over the days its branch ran.",
read: model.planetscale.data,
Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/page/cost/view-add-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { textLink } from "@maple/views/ui/text-link"
import { typography } from "@maple/views/ui/typography"
import { AppRoute, hrefOf } from "../../route"
import { connectionStep } from "../kit/connection-step"
import { addTrail } from "./view-add-trail"
import { CREDENTIAL_SOURCES, type CredentialSource } from "./credential-sources"
import { Message } from "./message"
import type { Model } from "./model"
Expand Down Expand Up @@ -204,6 +205,8 @@ const credentialForm = (
return formDialogForm(h, canSubmit ? Message.SubmittedCredential() : undefined, [
DialogView.dialogHeader(h, "dialog", [
DialogView.dialogTitle(h, "dialog", id, text.title),
// A replace is one step: no trail.
...(isReplacing ? [] : [addTrail(h, COST_PROVIDERS[provider].name)]),
DialogView.dialogDescription(h, "dialog", id, text.description),
]),
DialogView.dialogPanel(h, "dialog", id, [credentialField(h, model, source)]),
Expand Down Expand Up @@ -350,6 +353,7 @@ const githubStep = (h: H, model: Model, id: string): ReadonlyArray<Html> =>
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,
Expand Down Expand Up @@ -392,6 +396,7 @@ export const addSourceDialog = (h: H, model: Model, sources: ReadonlyArray<CostS
? [
DialogView.dialogHeader(h, "dialog", [
DialogView.dialogTitle(h, "dialog", id, "Add a cost source"),
addTrail(h, null),
DialogView.dialogDescription(
h,
"dialog",
Expand Down
22 changes: 22 additions & 0 deletions apps/web/src/page/cost/view-add-trail.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { Html, HtmlBuilder } from "foldkit/html"
import { answerTrail } from "@maple/views/ui/answer-trail"
import type { Message } from "./message"

// The add-source dialog's progress: the provider picked, then connecting it.

/** `provider` is null on the picker. */
export const addTrail = (h: HtmlBuilder<Message>, 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" },
],
})
2 changes: 2 additions & 0 deletions apps/web/src/page/cost/view-add-vercel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -124,6 +125,7 @@ export const vercelStep = (h: H, model: Model, id: string): ReadonlyArray<Html>
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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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 =>
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/page/kit/connection-step.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ export const connectionStep = <M, A>(
dialogId: string
title: string
description: string
/** The flow's answer trail, under the title. */
trail?: Html
read: AsyncData.AsyncData<A, LoadFailure>
/** What the step says while the read is out: "Reading the Vercel connection". */
readingLabel: string
Expand All @@ -64,6 +66,7 @@ export const connectionStep = <M, A>(
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, [
Expand Down
67 changes: 11 additions & 56 deletions apps/web/src/page/onboarding/quick-start-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<TrailItem>): 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 }),
})),
}),
],
)

Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/page/sources/new/aws-flow.scene.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/page/sources/new/cloudflare-flow.scene.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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(),
)
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/page/sources/new/gcp-flow.scene.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
)
Expand All @@ -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(),
Expand All @@ -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(),
)
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/page/sources/new/github-flow.scene.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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(
Expand Down
8 changes: 4 additions & 4 deletions apps/web/src/page/sources/new/new.scene.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
)
})

Expand Down Expand Up @@ -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()),
Expand Down Expand Up @@ -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(),
)
})

Expand Down Expand Up @@ -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(),
)
})
Expand Down
Loading
Loading