diff --git a/apps/web/src/lab/entries/onboarding-checklist.ts b/apps/web/src/lab/entries/onboarding-checklist.ts new file mode 100644 index 0000000000..e5d4d4c3d3 --- /dev/null +++ b/apps/web/src/lab/entries/onboarding-checklist.ts @@ -0,0 +1,135 @@ +import type { V2OnboardingChecklist, V2OnboardingChecklistStep } from "@maple/domain/http/v2" +import { Effect, Schema } from "effect" +import type { Html, HtmlBuilder } from "foldkit/html" +import * as OnboardingChecklist from "../../shell/onboarding-checklist" +import * as OnboardingChecklistView from "../../shell/onboarding-checklist-view" +import { defineLabWithFlags } from "../define" + +// `/lab/onboarding-checklist`: the top bar's credits pill with its one-time pointer, then the panel in +// each state, over fixtures. Nothing is claimed. + +const HOUR = 3_600_000 + +const step = ( + id: V2OnboardingChecklistStep["id"], + title: string, + completed: boolean, + optional = false, +): V2OnboardingChecklistStep => ({ + object: "onboarding_checklist_step", + id, + title, + completed, + href: "/lab/onboarding-checklist", + optional, +}) + +const checklistOf = ( + status: V2OnboardingChecklist["status"], + done: ReadonlyArray, + nowMs: number, +): V2OnboardingChecklist => { + const steps = [ + step("send_telemetry", "Send your first telemetry", done[0] ?? false), + step("connect_github", "Connect GitHub", done[1] ?? false), + step("create_alert_rule", "Create an alert with a destination", done[2] ?? false), + step("connect_mcp_agent", "Connect an MCP agent", done[3] ?? false), + step("invite_teammate", "Invite a teammate", done[4] ?? false, true), + ] + const required = steps.filter((entry) => !entry.optional) + return { + object: "onboarding_checklist", + status, + reward_amount_usd: 30, + deadline_at: new Date(nowMs + 17 * HOUR + 42 * 60_000).toISOString(), + claimed_at: status === "claimed" ? new Date(nowMs).toISOString() : null, + completed_count: required.filter((entry) => entry.completed).length, + total_count: required.length, + steps, + } +} + +export const Model = Schema.Struct({ nowMs: Schema.Number }) +export type Model = typeof Model.Type + +/** The pill's own messages, dropped: the lab only draws its states. */ +type Message = OnboardingChecklist.Message + +const modelOf = ( + nowMs: number, + checklist: V2OnboardingChecklist, + isAdmin = true, +): OnboardingChecklist.Model => ({ + ...OnboardingChecklist.init(), + checklist, + isAdmin, + isSeen: false, + nowMs, +}) + +const frame = (h: HtmlBuilder, label: string, children: ReadonlyArray): Html => + h.section( + [h.Class("flex flex-col gap-2")], + [ + h.span([h.Class("text-xs text-muted-foreground")], [label]), + h.div( + [ + h.Class( + "w-[22rem] overflow-hidden rounded-lg border bg-popover text-popover-foreground shadow-popup", + ), + ], + children, + ), + ], + ) + +const panelOf = (h: HtmlBuilder, model: OnboardingChecklist.Model): ReadonlyArray => + model.checklist === null ? [] : OnboardingChecklistView.checklistPanel(h, model, model.checklist) + +export const view = (model: Model, h: HtmlBuilder): Html => { + const inProgress = modelOf( + model.nowMs, + checklistOf("in_progress", [true, true, false, false, false], model.nowMs), + ) + const claimable = modelOf( + model.nowMs, + checklistOf("claimable", [true, true, true, true, false], model.nowMs), + ) + const claimed = { + ...modelOf(model.nowMs, checklistOf("claimed", [true, true, true, true, true], model.nowMs)), + isJustClaimed: true, + } + return h.div( + [h.Class("flex min-h-dvh flex-col gap-10 bg-background p-10 text-foreground")], + [ + h.div( + [h.Class("flex justify-end pb-24")], + [ + h.submodel({ + slotId: "lab-onboarding-checklist-pill", + model: inProgress, + view: OnboardingChecklistView.view, + toParentMessage: (message) => message, + }), + ], + ), + h.div( + [h.Class("flex flex-wrap items-start gap-8")], + [ + frame(h, "In progress", panelOf(h, inProgress)), + frame(h, "Every step done", panelOf(h, claimable)), + frame(h, "Claimed", panelOf(h, claimed)), + ], + ), + ], + ) +} + +export const lab = defineLabWithFlags("Onboarding checklist", { + Model, + Flags: Model, + flags: Effect.sync(() => ({ nowMs: Date.now() })), + init: (flags) => ({ model: { nowMs: flags.nowMs } }), + update: (model) => ({ model }), + view, +}) diff --git a/apps/web/src/lab/entries/onboarding.ts b/apps/web/src/lab/entries/onboarding.ts index 5a47c91b44..f82ba84f0d 100644 --- a/apps/web/src/lab/entries/onboarding.ts +++ b/apps/web/src/lab/entries/onboarding.ts @@ -2,6 +2,8 @@ import { CatalogPlan, CatalogPlanItem, CatalogPlanItemPrice } from "@maple/domai import { Effect, Schema } from "effect" import { Command, Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" +import * as CreateOrg from "../../page/onboarding/create-org" +import * as CreateOrgView from "../../page/onboarding/create-org-view" import * as PricingCardsView from "../../page/onboarding/pricing-cards-view" import * as QuickStartTeamView from "../../page/onboarding/quick-start-team-view" import * as QuickStartView from "../../page/onboarding/quick-start-view" @@ -18,7 +20,8 @@ import { DEFAULT_QUICK_START_STATE, STEP_IDS } from "../../page/onboarding/quick import { ready, TeamMessage, updateTeam } from "../../page/onboarding/quick-start-team" // `/lab/onboarding`: the real quick-start screen with a sample catalog; checkout, rename and invites are -// simulated and nothing is saved. `?step=stack` opens a later step, Restart starts over. +// simulated and nothing is saved. `?step=stack` opens a later step (`org` is the create-org screen, +// `region` the region step), Restart starts over. const meteredItem = ( featureId: string, @@ -51,11 +54,19 @@ export const PLANS = [ }), ] +const LAB_STEPS = ["org", "region", ...STEP_IDS] as const +type LabStep = (typeof LAB_STEPS)[number] + export const Model = Schema.Struct({ quickStart: QuickStart.Model, + /** The create-org screen, shown instead of the quick start until its form is sent. */ + createOrg: Schema.NullOr(CreateOrg.Model), + needsRegion: Schema.Boolean, /** Picking a sample plan stands in for checkout. */ hasPlan: Schema.Boolean, isComplete: Schema.Boolean, + /** The name sent from the create-org screen, shown by the steps after it. */ + orgName: Schema.String, /** Bumped by Restart, so every step remounts. */ run: Schema.Number, }) @@ -65,19 +76,40 @@ export const Message = defineMessageUnion({ GotStepMessage: { message: QuickStart.Message }, ClickedRestart: {}, SimulatedTeamOperation: { message: TeamMessage }, + GotCreateOrgMessage: { message: CreateOrg.Message }, }) export type Message = typeof Message.Type -const Step = Schema.Literals(STEP_IDS) +const Step = Schema.Literals(LAB_STEPS) + +const SAMPLE_ORG: CreateOrg.Model = { + _tag: "OrgRequired", + name: "Acme", + suggestedFrom: "acme.com", + region: "us", + isCreating: false, + errorMessage: null, + invitations: [], + joiningId: null, + openingId: null, + createdOrgId: null, + invitationsError: null, +} + +const quickStartStep = (step: LabStep): (typeof STEP_IDS)[number] => + step === "org" || step === "region" ? "plan" : step -export const init = (step: (typeof STEP_IDS)[number], run = 0): Model => ({ +export const init = (step: LabStep, run = 0): Model => ({ quickStart: { ...QuickStart.init("lab-onboarding").model, - state: { ...DEFAULT_QUICK_START_STATE, activeStep: step }, + state: { ...DEFAULT_QUICK_START_STATE, activeStep: quickStartStep(step) }, team: ready("Acme", [], true, null), }, - hasPlan: step !== "plan", + createOrg: step === "org" ? SAMPLE_ORG : null, + needsRegion: step === "org" || step === "region", + hasPlan: quickStartStep(step) !== "plan", isComplete: false, + orgName: SAMPLE_ORG.name, run, }) @@ -138,6 +170,8 @@ const stepMessage = (model: Model, message: QuickStart.Message): Return => { return message.message._tag === "GotSnippetCopyMessage" ? copyStep(model, message.message.snippet, message.message.message) : { model: screenStep(model, message) } + case "ClickedRegionContinue": + return { model: { ...model, needsRegion: false } } case "GotPricingMessage": return message.message._tag === "ClickedPlan" ? { model: { ...model, hasPlan: true } } : { model } case "ClickedContinue": @@ -151,10 +185,37 @@ const stepMessage = (model: Model, message: QuickStart.Message): Return => { export const update = (model: Model, message: Message): Return => Message.match(message, { GotStepMessage: ({ message: child }) => stepMessage(model, child), - ClickedRestart: () => ({ model: init("plan", model.run + 1) }), + ClickedRestart: () => ({ model: init("org", model.run + 1) }), SimulatedTeamOperation: ({ message: child }) => teamStep(model, child), + GotCreateOrgMessage: ({ message: child }) => createOrgStep(model, child), }) +/** The create-org form's own edits; sending it moves on to the region step, nothing is created. */ +const createOrgStep = (model: Model, message: CreateOrg.Message): Return => { + if (model.createOrg === null) return { model } + switch (message._tag) { + case "ChangedName": + case "PickedRegion": + return { model: { ...model, createOrg: CreateOrg.update(model.createOrg, message).model } } + case "SubmittedCreate": + return model.createOrg.name.trim() === "" + ? { model } + : { + model: { + ...model, + orgName: model.createOrg.name.trim(), + createOrg: null, + quickStart: { + ...model.quickStart, + region: { ...model.quickStart.region, selected: model.createOrg.region }, + }, + }, + } + default: + return { model } + } +} + // VIEW const plansView = Submodel.defineView((_, h) => @@ -188,9 +249,9 @@ const stepView = Submodel.defineView( separator(h, "vertical", "h-4"), linkButton(h, { href: "/lab", size: "xs", variant: "ghost" }, ["Exit preview"]), ]), - needsRegion: false, + needsRegion: model.needsRegion, hasPlan: model.hasPlan, - orgName: "Acme", + orgName: model.orgName, teamStep: () => QuickStartTeamView.teamStep(h, model.quickStart.team), planStep: QuickStartTeamView.planStep( h, @@ -205,25 +266,36 @@ const stepView = Submodel.defineView( }) ?? h.empty, ) +const createOrgView = Submodel.defineView((model, h) => + CreateOrgView.view(h, model, "you@acme.com", []), +) + export const view = (model: Model, h: HtmlBuilder): Html => model.isComplete ? complete(h) - : h.div( - [h.Key(String(model.run))], - [ - h.submodel({ - slotId: "lab-onboarding-step", - model, - view: stepView, - toParentMessage: (message) => Message.GotStepMessage({ message }), - }), - ], - ) + : model.createOrg !== null + ? h.submodel({ + slotId: "lab-onboarding-org", + model: model.createOrg, + view: createOrgView, + toParentMessage: (message) => Message.GotCreateOrgMessage({ message }), + }) + : h.div( + [h.Key(String(model.run))], + [ + h.submodel({ + slotId: "lab-onboarding-step", + model, + view: stepView, + toParentMessage: (message) => Message.GotStepMessage({ message }), + }), + ], + ) /** `?step=stack` opens the preview on a later step. */ -const initialStep = (search: string): (typeof STEP_IDS)[number] => +const initialStep = (search: string): LabStep => Schema.decodeUnknownOption(Step)(new URLSearchParams(search).get("step")).pipe((step) => - step._tag === "Some" ? step.value : "plan", + step._tag === "Some" ? step.value : "org", ) export const lab = defineLabWithFlags("Onboarding", { diff --git a/apps/web/src/lab/registry.ts b/apps/web/src/lab/registry.ts index 581abf464a..52cd26bb83 100644 --- a/apps/web/src/lab/registry.ts +++ b/apps/web/src/lab/registry.ts @@ -108,6 +108,14 @@ export const LAB_ENTRIES: ReadonlyArray = [ kind: "lab", session: "none", }, + { + path: "/lab/onboarding-checklist", + title: "Onboarding checklist", + description: + "The top bar's credits pill and its checklist panel in progress, claimable and claimed, over fixtures.", + kind: "lab", + session: "none", + }, { path: "/lab/loaders", title: "Chat loaders", diff --git a/apps/web/src/lib/onboarding-stack.ts b/apps/web/src/lib/onboarding-stack.ts index 61d99c894f..7523851833 100644 --- a/apps/web/src/lib/onboarding-stack.ts +++ b/apps/web/src/lib/onboarding-stack.ts @@ -172,7 +172,7 @@ export const STACK_PLATFORMS = { aws: { label: "AWS", description: "CloudWatch", icon: "AwsIcon", fills: ["infrastructure", "metrics"] }, gcp: { label: "Google Cloud", - description: "Logging, monitoring", + description: "Logs, metrics", icon: "GoogleCloudIcon", fills: ["logs", "metrics"], }, @@ -196,7 +196,7 @@ export const STACK_PLATFORMS = { }, prometheus: { label: "Prometheus", - description: "Scrape an endpoint", + description: "Scrape targets", icon: "PrometheusIcon", fills: ["metrics"], }, diff --git a/apps/web/src/page/onboarding/create-org-view.ts b/apps/web/src/page/onboarding/create-org-view.ts index 1328d00ff0..1eeafb4aa8 100644 --- a/apps/web/src/page/onboarding/create-org-view.ts +++ b/apps/web/src/page/onboarding/create-org-view.ts @@ -71,7 +71,7 @@ const nameField = (h: H, model: Model): Html => [ h.AriaHidden(true), h.Class( - "flex size-12 shrink-0 items-center justify-center rounded-lg bg-primary font-display text-xl font-semibold text-primary-foreground", + "flex size-12 shrink-0 items-center justify-center rounded-lg border border-border bg-surface-strong font-display text-xl font-semibold text-foreground", ), ], [initialOf(model.name)], @@ -80,7 +80,7 @@ const nameField = (h: H, model: Model): Html => id: "org-required-name", size: "lg", className: "flex-1", - controlClassName: "h-12 text-base", + controlClassName: "h-12 leading-12 text-base sm:h-12 sm:leading-12 sm:text-base", isDisabled: model.isCreating || model.createdOrgId !== null, attributes: [ h.Maxlength(100), @@ -213,7 +213,7 @@ const membershipRow = (h: H, model: Model, organization: ClerkOrganization): Htm const form = (h: H, model: Model, memberships: ReadonlyArray): Html => h.form( [ - h.Class("flex flex-col gap-9"), + h.Class("flex flex-col gap-8"), h.AriaLabel("Create your organization"), h.OnSubmit(Message.SubmittedCreate()), ], @@ -221,12 +221,16 @@ const form = (h: H, model: Model, memberships: ReadonlyArray) nameField(h, model), ...(hasMultipleRegions ? [regionField(h, model)] : []), h.div( - [h.Class("flex flex-col gap-4")], + [h.Class("flex flex-col gap-3")], [ - h.p( - [h.AriaLive("polite"), h.Class(cn("min-h-4 text-xs", TONE_TEXT.crit))], - model.errorMessage === null ? [] : [model.errorMessage], - ), + ...(model.errorMessage === null + ? [] + : [ + h.p( + [h.Role("alert"), h.Class(cn("text-xs", TONE_TEXT.crit))], + [model.errorMessage], + ), + ]), button( h, { @@ -273,14 +277,10 @@ const workspacePreview = (h: H, model: Model): Html => h.div( [h.AriaHidden(true), h.Class("relative flex w-full max-w-[640px] flex-col items-center gap-5")], [ - h.span([h.Class("text-sm font-medium text-foreground")], ["A peek at your new workspace"]), + h.span([h.Class("text-xs text-muted-foreground")], ["A peek at your new workspace"]), h.div( [h.Class("w-full overflow-hidden rounded-md border border-border bg-background")], [ - h.div( - [h.Class("flex h-9 items-center gap-1.5 border-b border-border bg-sidebar px-3.5")], - [0, 1, 2].map(() => h.span([h.Class("size-2.5 rounded-full bg-input")], [])), - ), h.div( [h.Class("flex h-[420px]")], [ @@ -294,14 +294,14 @@ const workspacePreview = (h: H, model: Model): Html => h.div( [ h.Class( - "flex items-center gap-2 rounded-md border border-primary bg-sidebar-accent p-1.5", + "flex items-center gap-2 rounded-md border border-border bg-sidebar-accent p-1.5", ), ], [ h.span( [ h.Class( - "flex size-6 shrink-0 items-center justify-center rounded-sm bg-primary text-xs font-semibold text-primary-foreground", + "flex size-6 shrink-0 items-center justify-center rounded-sm border border-border bg-surface-strong text-xs font-semibold text-foreground", ), ], [initialOf(model.name)], @@ -350,7 +350,7 @@ const workspacePreview = (h: H, model: Model): Html => ], [ h.span( - [h.Class("text-primary")], + [h.Class("text-muted-foreground")], [icon(h, "FrameworkOpenTelemetryIcon", { size: 28 })], ), h.span( @@ -383,30 +383,33 @@ export const view = ( [h.Class("flex h-dvh overflow-hidden bg-background text-foreground")], [ h.div( - [h.Class("flex h-dvh w-full shrink-0 flex-col lg:w-[640px]")], + [h.Class("flex h-dvh w-full shrink-0 flex-col xl:w-[760px]")], [ - h.div([h.Class("shrink-0 px-6 pt-8 sm:px-12 lg:px-20 lg:pt-12")], [header(h, email)]), + h.div([h.Class("shrink-0 px-6 py-5 sm:px-12 lg:px-16")], [header(h, email)]), h.div( [ h.Class( - "min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 pb-10 sm:px-12 lg:px-20", + "min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 pb-10 sm:px-12 lg:px-16", ), ], [ h.div( - [h.Class("flex flex-col gap-10 pt-12")], + [h.Class("flex max-w-lg flex-col gap-10 pt-4")], [ h.div( [h.Class("flex flex-col gap-3")], [ - h.p([h.Class("text-sm text-primary")], ["Welcome to Maple"]), + h.p( + [h.Class("text-sm text-muted-foreground")], + ["Welcome to Maple"], + ), h.h1( [ h.Class( - "font-display text-4xl font-semibold leading-[1.1] tracking-tight", + "font-display text-3xl font-semibold leading-tight tracking-tight", ), ], - ["Let's give your team a home."], + ["Give your team a home"], ), h.p( [ @@ -415,7 +418,7 @@ export const view = ( ), ], [ - "Your traces, logs, dashboards and alerts all live in one organization. It takes ten seconds.", + "Your traces, logs, dashboards and alerts live in one organization.", ], ), ], @@ -428,7 +431,7 @@ export const view = ( ], ), h.div( - [h.Class("relative hidden h-dvh flex-1 border-l border-border bg-sidebar lg:block")], + [h.Class("relative hidden h-dvh flex-1 border-l border-border bg-sidebar xl:block")], [ h.div( [h.Class("pointer-events-none absolute inset-0 overflow-hidden")], diff --git a/apps/web/src/page/onboarding/create-org.scene.test.ts b/apps/web/src/page/onboarding/create-org.scene.test.ts index 24e2adc56c..4f09756d0f 100644 --- a/apps/web/src/page/onboarding/create-org.scene.test.ts +++ b/apps/web/src/page/onboarding/create-org.scene.test.ts @@ -62,7 +62,7 @@ describe("create your first organization", () => { { update, view }, given(init(user).model), mounted, - expectView(role("heading", { name: "Let's give your team a home." })).toExist(), + expectView(role("heading", { name: "Give your team a home" })).toExist(), expectView(nameField).toHaveValue("Acme"), expectView(text("We guessed from acme.dev. Rename it whenever you like.")).toExist(), type(nameField, "Acme Labs"), diff --git a/apps/web/src/page/onboarding/option-card.ts b/apps/web/src/page/onboarding/option-card.ts index fcbd55a431..8dcb294ae5 100644 --- a/apps/web/src/page/onboarding/option-card.ts +++ b/apps/web/src/page/onboarding/option-card.ts @@ -82,10 +82,6 @@ export type OptionCardOptions = Readonly<{ layout?: "row" | "stacked" /** Content pinned to the bottom of a `stacked` card. */ footer?: Html - /** Hex colour that replaces primary for the selected border and tint. */ - accent?: string - /** Classes for the selected check, paired with `accent`. */ - checkClassName?: string }> /** `OptionCard`: a hidden native input behind a bordered card. */ @@ -94,7 +90,7 @@ export const optionCard = (h: HtmlBuilder, options: OptionCardOptions): const check = drawnCheck( h, options.isChecked, - cn(layout === "row" && "mt-0.5", options.isChecked ? options.checkClassName : options.type === "radio" && "opacity-0"), + cn(layout === "row" && "mt-0.5", !options.isChecked && options.type === "radio" && "opacity-0"), ) const body: ReadonlyArray = layout === "row" @@ -104,7 +100,10 @@ export const optionCard = (h: HtmlBuilder, options: OptionCardOptions): [h.Class("min-w-0 flex-1 pt-px")], [ h.span([h.Class("block text-sm font-semibold")], [options.title]), - h.span([h.Class("mt-1 block text-xs leading-relaxed text-muted-foreground")], [options.description]), + h.span( + [h.Class("mt-1 block text-xs leading-relaxed text-muted-foreground")], + [options.description], + ), ], ), check, @@ -114,13 +113,18 @@ export const optionCard = (h: HtmlBuilder, options: OptionCardOptions): h.div( [h.Class("space-y-1")], [ - h.span([h.Class("block text-base font-semibold tracking-tight")], [options.title]), + h.span( + [h.Class("block text-base font-semibold tracking-tight")], + [options.title], + ), h.span([h.Class("block text-sm text-muted-foreground")], [options.description]), ], ), - ...(options.footer === undefined ? [] : [h.div([h.Class("mt-auto flex")], [options.footer])]), + ...(options.footer === undefined + ? [] + : [h.div([h.Class("mt-auto flex")], [options.footer])]), ] - const isAccented = options.isChecked && options.accent !== undefined + return h.label( [h.Class("group relative cursor-pointer")], [ @@ -135,14 +139,13 @@ export const optionCard = (h: HtmlBuilder, options: OptionCardOptions): ]), h.div( [ - ...(isAccented ? [h.Style({ "border-color": options.accent ?? "", "background-color": `color-mix(in oklab, ${options.accent ?? ""} 6%, transparent)` })] : []), h.Class( cn( "flex h-full rounded-md border transition-colors duration-150 motion-reduce:transition-none peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-background", layout === "row" ? "items-start gap-3 p-4" : "flex-col gap-5 p-5", !options.isChecked ? "border-border group-hover:border-border-strong group-hover:bg-surface-faint" - : options.accent === undefined && "border-primary bg-primary-a1", + : "border-primary bg-primary-a1", ), ), ], @@ -179,7 +182,9 @@ export const pixelGlyph = (h: HtmlBuilder, name: PixelGlyphName, isSelecte h.Class( cn( "transition-colors duration-200 motion-reduce:transition-none", - isSelected ? "text-primary/40" : "text-muted-foreground group-hover:text-foreground", + isSelected + ? "text-primary/40" + : "text-muted-foreground group-hover:text-foreground", ), ), ], @@ -187,7 +192,9 @@ export const pixelGlyph = (h: HtmlBuilder, name: PixelGlyphName, isSelecte ), h.g( [h.Class("text-primary")], - segments.map((d) => segmentPath(h, d, [h.Style({ opacity: isSelected ? "1" : "0" })])), + segments.map((d) => + segmentPath(h, d, [h.Style({ opacity: isSelected ? "1" : "0" })]), + ), ), ], ), diff --git a/apps/web/src/page/onboarding/pricing-cards-view.ts b/apps/web/src/page/onboarding/pricing-cards-view.ts index b0160d1494..a57d49b5d2 100644 --- a/apps/web/src/page/onboarding/pricing-cards-view.ts +++ b/apps/web/src/page/onboarding/pricing-cards-view.ts @@ -55,9 +55,7 @@ const dataRow = (h: H, feature: FeatureRow, isDetailed: boolean): Html => { h.div( [h.Class("text-muted-foreground flex items-center gap-2.5")], [ - ...(iconName === undefined - ? [] - : [icon(h, iconName, { className: "size-4" })]), + ...(iconName === undefined ? [] : [icon(h, iconName, { className: "size-4" })]), h.span([h.Class("font-medium")], [feature.label]), ], ), @@ -214,7 +212,7 @@ const enterpriseCard = (h: H): Html => cardHeader(h, [ h.div( [h.Class("flex items-center justify-between gap-2")], - [cardTitle(h, ["Enterprise"], eyebrowVariants({ className: "text-primary" }))], + [cardTitle(h, ["Enterprise"], eyebrowVariants())], ), h.div( [h.Class("mt-3 flex items-baseline gap-1")], @@ -256,7 +254,7 @@ const enterpriseCard = (h: H): Html => "mt-auto flex-col gap-2 items-stretch", ), ], - "flex flex-col border-primary-a3 bg-primary/[0.02]", + "flex flex-col", ) /** `PricingCards`: skeletons while the catalog loads, its error row, or the plan grid. */ diff --git a/apps/web/src/page/onboarding/quick-start-live.scene.test.ts b/apps/web/src/page/onboarding/quick-start-live.scene.test.ts index 9fe381e843..2962ee34b4 100644 --- a/apps/web/src/page/onboarding/quick-start-live.scene.test.ts +++ b/apps/web/src/page/onboarding/quick-start-live.scene.test.ts @@ -40,7 +40,7 @@ describe("you're live step", () => { scene( { update, view }, given(atLive([], {})), - expectView(role("heading", { name: "Acme is ready for its first data." })).toExist(), + expectView(role("heading", { name: "Acme is ready for its first data" })).toExist(), expectView(text("Connect Cloudflare")).toExist(), expectView(text("Your first span, log or metric")).toExist(), ) @@ -50,7 +50,7 @@ describe("you're live step", () => { scene( { update, view }, given(atLive([], { cloudflare: true })), - expectView(role("heading", { name: "Acme is ready for its first data." })).toExist(), + expectView(role("heading", { name: "Acme is ready for its first data" })).toExist(), expectView(text("Connect Cloudflare")).not.toExist(), expectView(text("Connect AWS")).toExist(), ) @@ -60,7 +60,7 @@ describe("you're live step", () => { scene( { update, view }, given(atLive(["traces"], {})), - expectView(role("heading", { name: "Acme is live." })).toExist(), + expectView(role("heading", { name: "Acme is live" })).toExist(), expectView(text("Your first span, log or metric")).not.toExist(), ) }) diff --git a/apps/web/src/page/onboarding/quick-start-region-view.ts b/apps/web/src/page/onboarding/quick-start-region-view.ts index aa6409a328..e5ff04c08c 100644 --- a/apps/web/src/page/onboarding/quick-start-region-view.ts +++ b/apps/web/src/page/onboarding/quick-start-region-view.ts @@ -3,7 +3,6 @@ import { TONE_TEXT } from "@maple/ui/lib/tone" import { cn } from "@maple/ui/lib/utils" import type { Html, HtmlBuilder } from "foldkit/html" import { button } from "../../ui/button" -import { eyebrow } from "../../ui/eyebrow" import { icon } from "../../ui/icon" import { optionCard } from "./option-card" import { Message, type RegionChoice } from "./quick-start" @@ -19,39 +18,25 @@ export const REGION_CARDS = { name: "United States", place: "North America", residency: "Data stays in the US", - accent: "var(--color-region-us)", - check: "border-region-us bg-region-us text-white", + dot: "bg-region-us", }, eu: { name: "European Union", place: "Frankfurt, Germany", residency: "Data stays in the EU", - accent: "var(--color-region-eu)", - check: "border-region-eu bg-region-eu text-white", + dot: "bg-region-eu", }, -} as const satisfies Record +} as const satisfies Record -const residency = (h: H, region: Region, isActive: boolean): Html => { - const card = REGION_CARDS[region] - return h.span( - [ - h.Class( - "inline-flex items-center gap-1.5 rounded-full border border-border px-2 py-0.5 text-2xs font-medium text-muted-foreground", - ), - ...(isActive - ? [h.Style({ - color: card.accent, - "border-color": `color-mix(in oklab, ${card.accent} 40%, transparent)`, - "background-color": `color-mix(in oklab, ${card.accent} 8%, transparent)`, - })] - : []), - ], +/** The residency line, its dot in the region's map colour. */ +const residency = (h: H, region: Region): Html => + h.span( + [h.Class("inline-flex items-center gap-1.5 text-xs text-muted-foreground")], [ - h.span([h.AriaHidden(true), h.Class("size-1.5 rounded-full"), h.Style({ "background-color": card.accent })], []), - card.residency, + h.span([h.AriaHidden(true), h.Class(cn("size-1.5 rounded-full", REGION_CARDS[region].dot))], []), + REGION_CARDS[region].residency, ], ) -} const regionCard = (h: H, choice: RegionChoice, region: Region): Html => { const isActive = choice.selected === region @@ -66,8 +51,6 @@ const regionCard = (h: H, choice: RegionChoice, region: Region): Html => { label: card.name, title: card.name, description: card.place, - accent: card.accent, - checkClassName: card.check, media: regionFlag( h, region, @@ -76,26 +59,27 @@ const regionCard = (h: H, choice: RegionChoice, region: Region): Html => { !isActive && "opacity-70 saturate-50 group-hover:opacity-100 group-hover:saturate-100", ), ), - footer: residency(h, region, isActive), + footer: residency(h, region), }) } /** `StepRegion`. */ export const regionStep = (h: H, choice: RegionChoice): Html => h.div( - [h.Class("flex flex-1 flex-col items-center justify-center overflow-auto px-6 py-12")], + [h.Class("flex flex-1 flex-col items-center justify-center px-6 pb-12 pt-4 sm:px-12")], [ h.div( [h.Class("flex w-full max-w-3xl flex-col gap-8")], [ h.div( - [h.Class("space-y-3 text-center")], + [h.Class("flex flex-col items-center gap-2.5 text-center")], [ - h.div([h.AriaHidden(true), h.Class("mx-auto mb-6 w-fit text-primary")], [icon(h, "MapleMark", { size: 56 })]), - eyebrow(h, { variant: "label", className: "text-primary" }, ["Before you start"]), - h.h1([h.Class("text-3xl font-semibold tracking-tight")], ["Where should your data live?"]), + h.h1( + [h.Class("font-display text-3xl font-semibold tracking-tight")], + ["Where should your data live?"], + ), h.p( - [h.Class("mx-auto max-w-md text-sm leading-relaxed text-muted-foreground")], + [h.Class("max-w-md text-title leading-relaxed text-muted-foreground")], [ "Each region is a separate Maple. Your telemetry, dashboards and alerts are stored and processed only in the one you pick.", ], @@ -104,7 +88,10 @@ export const regionStep = (h: H, choice: RegionChoice): Html => ), h.fieldset( [h.Class("grid min-w-0 gap-3 sm:grid-cols-2")], - [h.legend([h.Class("sr-only")], ["Data region"]), ...MapleRegion.literals.map((region) => regionCard(h, choice, region))], + [ + h.legend([h.Class("sr-only")], ["Data region"]), + ...MapleRegion.literals.map((region) => regionCard(h, choice, region)), + ], ), h.p( [ @@ -112,7 +99,7 @@ export const regionStep = (h: H, choice: RegionChoice): Html => h.AriaAtomic(true), h.Class( cn( - "min-h-10 text-center text-xs leading-relaxed", + "min-h-5 text-center text-xs leading-relaxed", choice.errorMessage !== null ? TONE_TEXT.crit : "text-muted-foreground", ), ), @@ -120,7 +107,7 @@ export const regionStep = (h: H, choice: RegionChoice): Html => [choice.errorMessage ?? "An organization stays in the region it starts in."], ), h.div( - [h.Class("flex items-center justify-end")], + [h.Class("flex justify-center")], [ button( h, @@ -130,7 +117,7 @@ export const regionStep = (h: H, choice: RegionChoice): Html => isLoading: choice.isSaving, attributes: [h.OnClick(Message.ClickedRegionContinue())], }, - ["Continue", h.span([h.Class("ml-2")], ["→"])], + ["Continue", icon(h, "ArrowRightIcon", { size: 16 })], ), ], ), diff --git a/apps/web/src/page/onboarding/quick-start-team-view.ts b/apps/web/src/page/onboarding/quick-start-team-view.ts index 9b6caf0db5..f0db9fb064 100644 --- a/apps/web/src/page/onboarding/quick-start-team-view.ts +++ b/apps/web/src/page/onboarding/quick-start-team-view.ts @@ -66,7 +66,7 @@ const TOOLS: ReadonlyArray = [ const toolRow = (h: H, tool: Tool): Html => h.li( - [h.Class("flex items-center gap-3.5 px-4 py-4")], + [h.Class("flex flex-wrap items-center gap-x-3.5 gap-y-3 px-4 py-4")], [ h.span( [ @@ -78,7 +78,7 @@ const toolRow = (h: H, tool: Tool): Html => [icon(h, tool.icon, { size: 20 })], ), h.div( - [h.Class("flex min-w-0 flex-1 flex-col gap-1")], + [h.Class("flex min-w-0 flex-1 basis-48 flex-col gap-1")], [ h.span([h.Class("text-sm font-medium")], [tool.name]), h.span([h.Class("text-xs leading-snug text-muted-foreground")], [tool.line]), @@ -90,6 +90,7 @@ const toolRow = (h: H, tool: Tool): Html => href: tool.href, variant: "outline", size: "sm", + className: "max-sm:ml-13.5", attributes: [ h.Target("_blank"), h.Rel("noopener"), @@ -120,14 +121,20 @@ export const teamStep = (h: H, team: Team): Html => { ), ], ), - h.ul( + // Wrapped, so the column's side padding lands outside the list's border, not inside it. + h.div( + [], [ - h.AriaLabel("Tools"), - h.Class( - "flex flex-col divide-y divide-border overflow-hidden rounded-md border border-border bg-card", + h.ul( + [ + h.AriaLabel("Tools"), + h.Class( + "flex flex-col divide-y divide-border overflow-hidden rounded-md border border-border bg-card", + ), + ], + TOOLS.map((tool) => toolRow(h, tool)), ), ], - TOOLS.map((tool) => toolRow(h, tool)), ), team.isLoaded ? h.div( @@ -270,27 +277,22 @@ const pricingSkeleton = (h: H): Html => /** `StepPlan` / `StepPlanLayout`: the trial pitch over the pricing cards; it is the first step, so no Back. */ export const planStep = (h: H, isCustomerLoading: boolean, pricing: Html): Html => h.div( - [h.Class("flex-1 flex flex-col items-center px-6 py-12 overflow-auto")], + [h.Class("flex flex-1 flex-col items-center px-6 pb-12 pt-4 sm:px-12 lg:px-16")], [ h.div( [h.Class("w-full max-w-5xl")], [ h.div( - [h.Class("text-center mb-10")], + [h.Class("mb-10 flex flex-col items-center gap-2.5 text-center")], [ - eyebrow(h, { variant: "label", className: "text-primary" }, ["Pick a plan"]), - h.h2( - [h.Class("font-display text-3xl font-semibold tracking-tight mt-2")], - ["Pick a plan to keep going"], + h.h1( + [h.Class("font-display text-3xl font-semibold tracking-tight")], + [`Start your ${TRIAL_DURATION_DAYS}-day free trial`], ), h.p( - [h.Class("font-display text-muted-foreground text-sm mt-3 max-w-lg mx-auto")], + [h.Class("max-w-lg text-title leading-relaxed text-muted-foreground")], [ - "Start a ", - String(TRIAL_DURATION_DAYS), - "-day free trial: we'll save your card now and won't charge until day ", - String(TRIAL_DURATION_DAYS), - ". Cancel anytime from settings.", + `Pick a plan to keep going. Your card is saved now and charged on day ${TRIAL_DURATION_DAYS}; cancel anytime from settings.`, ], ), ], @@ -303,20 +305,16 @@ export const planStep = (h: H, isCustomerLoading: boolean, pricing: Html): Html const inviteForm = (h: H, team: Team, isBusy: boolean): Html => h.form( - [h.Class("space-y-3 border-t pt-6"), h.OnSubmit(toTeam(TeamMessage.SubmittedInvite()))], + [h.Class("space-y-3"), h.OnSubmit(toTeam(TeamMessage.SubmittedInvite()))], [ - label(h, { attributes: [h.For("onboarding-teammate-email")] }, [ - "Invite a teammate", - " ", - h.span([h.Class("font-normal text-muted-foreground")], ["(optional)"]), - ]), + label(h, { attributes: [h.For("onboarding-teammate-email")] }, ["Invite a teammate"]), h.div( [h.Class("flex flex-wrap gap-2")], [ input(h, { id: "onboarding-teammate-email", type: "email", - className: "min-w-40 flex-1", + className: "min-w-56 flex-1", isDisabled: !team.canManage || isBusy, attributes: [ h.Autocomplete("email"), @@ -348,7 +346,7 @@ const inviteForm = (h: H, team: Team, isBusy: boolean): Html => [ isAlreadyInvited(team) ? "This teammate already has a pending invitation." - : "They’ll receive an email invitation to join this workspace as a member.", + : "They get an email invitation to join as a member.", ], ), ], diff --git a/apps/web/src/page/onboarding/quick-start-view.ts b/apps/web/src/page/onboarding/quick-start-view.ts index 340a85bfdf..811f4c4e1d 100644 --- a/apps/web/src/page/onboarding/quick-start-view.ts +++ b/apps/web/src/page/onboarding/quick-start-view.ts @@ -17,6 +17,7 @@ import { AppRoute, hrefOf } from "../../route" import { button } from "../../ui/button" import { icon } from "../../ui/icon" import { searchInput } from "../../ui/search-input" +import { textLink } from "../../ui/text-link" import { type PixelGlyphName, pixelGlyph } from "./option-card" import * as CopyButton from "../../ui/copy-button" import * as GuidedSetup from "../kit/guided-setup" @@ -100,7 +101,10 @@ const trail = (h: H, items: ReadonlyArray): Html => : h.span( [h.Class("flex items-center gap-1.5 text-muted-foreground")], [ - icon(h, "CheckIcon", { size: 12, className: "text-severity-info" }), + icon(h, "CheckIcon", { + size: 12, + className: "text-severity-info", + }), item.answer, ], ), @@ -432,7 +436,7 @@ const stackStep = (h: H, state: QuickStartState, query: string, hasPlan: boolean ...(picked === 0 ? [ h.span( - [h.Class("text-sm text-muted-foreground")], + [h.Class("hidden text-sm text-muted-foreground sm:inline")], ["Not sure yet? You can skip this."], ), ] @@ -476,16 +480,21 @@ const stackPreview = (h: H, orgName: string, state: QuickStartState): Html => { eyebrow(h, { variant: "label" }, [`What ${orgName} gets`]), h.span( [h.Class("font-display text-xl font-semibold")], - [ - surfaces.length === 0 - ? "Pick what you run to see it here" - : `${surfaces.length} places light up`, - ], + [surfaces.length === 0 ? "Nothing picked yet" : `${surfaces.length} places light up`], ), ], ), ...(surfaces.length === 0 - ? [] + ? [ + h.p( + [ + h.Class( + "rounded-md border border-dashed border-border px-4 py-8 text-center text-xs leading-relaxed text-muted-foreground", + ), + ], + ["Pick a language or platform to see the pages it fills."], + ), + ] : [ h.ul( [ @@ -636,7 +645,7 @@ export const connectStep = (h: H, model: Model, state: QuickStartState): Html => h.p( [h.Class("text-title leading-relaxed text-muted-foreground")], [ - "Connect each provider without leaving setup. Your code sends its traces, logs and metrics with OpenTelemetry: one prompt to your coding agent, or a few lines by hand.", + "Your code sends traces, logs and metrics with OpenTelemetry. Hand one prompt to your coding agent, or add a few lines by hand.", ], ), ], @@ -670,7 +679,7 @@ export const connectStep = (h: H, model: Model, state: QuickStartState): Html => ]), // The languages picked, or every SDK there are snippets for when none was. h.span( - [h.AriaHidden(true), h.Class("flex items-center gap-1.5")], + [h.AriaHidden(true), h.Class("flex items-center gap-1.5 max-sm:hidden")], (state.stack.languages.length === 0 ? ([ "FrameworkNodejsIcon", @@ -733,7 +742,7 @@ export const connectStep = (h: H, model: Model, state: QuickStartState): Html => h, [ h.span( - [h.Class("text-sm text-muted-foreground")], + [h.Class("hidden text-sm text-muted-foreground sm:inline")], ["We keep listening while you move on."], ), button( @@ -953,7 +962,7 @@ export const liveStep = (h: H, model: Model, state: QuickStartState, orgName: st ), h.h1( [h.Class("font-display text-4xl font-semibold tracking-tight")], - [isLive ? `${orgName} is live.` : `${orgName} is ready for its first data.`], + [isLive ? `${orgName} is live` : `${orgName} is ready for its first data`], ), h.ul( [ @@ -997,33 +1006,53 @@ export const liveStep = (h: H, model: Model, state: QuickStartState, orgName: st "grid gap-px overflow-hidden rounded-md border border-border bg-border sm:grid-cols-3", ), ], + // The whole card is the link; its glyph and arrow answer the hover. startCards(findings).map((card) => h.li( - [h.Class("flex flex-col gap-3.5 bg-background p-5")], + [h.Class("flex bg-background")], [ - pixelGlyph(h, card.glyph, true), - h.div( - [h.Class("flex flex-col gap-1")], + h.a( [ - h.span([h.Class("text-title font-medium")], [card.title]), + h.Href(card.href), + h.Class( + "group flex flex-1 flex-col gap-3.5 p-5 outline-none hover:bg-surface-faint focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring active:bg-surface-faint", + ), + ], + [ + pixelGlyph(h, card.glyph, false), h.span( + [h.Class("flex flex-col gap-1")], [ - h.Class( - "text-ui leading-relaxed text-muted-foreground", + h.span( + [h.Class("text-title font-medium")], + [card.title], + ), + h.span( + [ + h.Class( + "text-ui leading-relaxed text-muted-foreground", + ), + ], + [card.finding], ), ], - [card.finding], ), - ], - ), - h.a( - [ - h.Href(card.href), - h.Class( - "mt-auto flex items-center gap-1.5 text-ui text-primary hover:underline active:underline", + h.span( + [ + h.Class( + "mt-auto flex items-center gap-1.5 text-ui text-muted-foreground group-hover:text-foreground", + ), + ], + [ + card.action, + icon(h, "ArrowRightIcon", { + size: 12, + className: + "transition-transform duration-150 group-hover:translate-x-0.5 motion-reduce:transition-none", + }), + ], ), ], - [card.action, icon(h, "ArrowRightIcon", { size: 12 })], ), ], ), @@ -1063,19 +1092,22 @@ export const liveStep = (h: H, model: Model, state: QuickStartState, orgName: st `Connect ${STACK_PLATFORMS[platform].label}`, ], ), - h.button( - [ - h.Type("button"), - h.AriaLabel( - `Set up ${STACK_PLATFORMS[platform].label}`, - ), - h.OnClick( - Message.ClickedSetUp({ platform }), - ), - h.Class( - "text-xs text-primary hover:underline active:underline", - ), - ], + button( + h, + { + variant: "outline", + size: "xs", + attributes: [ + h.AriaLabel( + `Set up ${STACK_PLATFORMS[platform].label}`, + ), + h.OnClick( + Message.ClickedSetUp({ + platform, + }), + ), + ], + }, ["Set up"], ), ], @@ -1102,10 +1134,13 @@ export const liveStep = (h: H, model: Model, state: QuickStartState, orgName: st h.span( [ h.Class( - "text-xs text-muted-foreground", + "flex items-center gap-1.5 text-xs text-muted-foreground", ), ], - ["Listening"], + [ + statusDot(h, { tone: "ok" }), + "Listening", + ], ), ], ), @@ -1116,26 +1151,36 @@ export const liveStep = (h: H, model: Model, state: QuickStartState, orgName: st ), ]), h.div( - [h.Class("flex flex-col items-center gap-3")], + [h.Class("flex flex-col items-center gap-4")], [ - button( - h, - { - size: "lg", - attributes: [h.OnClick(Message.ClickedContinue({ step: "live" }))], - }, - [`Go to ${orgName}`, icon(h, "ArrowRightIcon", { size: 16 })], - ), - h.a( + h.div( + [h.Class("flex max-w-full flex-wrap items-center justify-center gap-2")], [ - h.Href(hrefOf(AppRoute.Alerts({ search: {} }))), - h.Class( - "text-xs text-muted-foreground underline-offset-4 hover:underline active:underline", + backButton(h, "team"), + button( + h, + { + size: "lg", + attributes: [ + h.OnClick(Message.ClickedContinue({ step: "live" })), + ], + }, + [ + h.span([h.Class("max-w-64 truncate")], [`Go to ${orgName}`]), + icon(h, "ArrowRightIcon", { size: 16 }), + ], ), ], + ), + textLink( + h, + { + tone: "muted", + className: "text-xs", + href: hrefOf(AppRoute.Alerts({ search: {} })), + }, ["Or set up your first alert"], ), - backButton(h, "team"), ], ), ], diff --git a/apps/web/src/page/overview/setup-checklist-view.ts b/apps/web/src/page/overview/setup-checklist-view.ts index 064f526902..20158bd22d 100644 --- a/apps/web/src/page/overview/setup-checklist-view.ts +++ b/apps/web/src/page/overview/setup-checklist-view.ts @@ -12,6 +12,7 @@ import { icon } from "../../ui/icon" import { iconButton } from "../../ui/icon-button" import { listRow } from "../../ui/list-row" import { skeleton } from "../../ui/skeleton" +import { statusDot } from "../../ui/status-dot" import * as GuidedSetup from "../kit/guided-setup" import { Message, type Model } from "./setup-checklist" import { frameworkOf, isMounted, realServices } from "./setup-checklist-update" @@ -31,7 +32,9 @@ const keysUnavailable = (h: H): Html => h.a( [ h.Href(hrefOf(AppRoute.Settings({ search: { tab: "ingestion" } }))), - h.Class("font-medium text-foreground underline underline-offset-2 hover:no-underline active:no-underline"), + h.Class( + "font-medium text-foreground underline underline-offset-2 hover:no-underline active:no-underline", + ), ], ["Settings → Ingestion"], ), @@ -109,17 +112,13 @@ const guidedSetup = (h: H, model: Model, framework: string): Html => ], ) -/** "Watching for your first trace…" while the card waits for data. */ +/** "Listening for your first trace" while the card waits for data. */ const watchingStrip = (h: H): Html => h.div( + [h.Class("flex items-center gap-2.5 rounded-md border border-dashed border-border px-4 py-3")], [ - h.Class( - "flex items-center gap-2.5 rounded-md border border-dashed border-primary-a3 bg-primary-a1 p-4", - ), - ], - [ - icon(h, "PulseIcon", { size: 14, className: "text-primary" }), - h.span([h.Class("font-prose text-xs text-muted-foreground")], ["Watching for your first trace…"]), + statusDot(h, { tone: "ok" }), + h.span([h.Class("text-xs text-muted-foreground")], ["Listening for your first trace"]), ], ) @@ -134,7 +133,7 @@ const celebration = (h: H, serviceName: string | undefined): Html => h.div( [ h.Class( - "flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary-a2 text-primary", + "flex size-10 shrink-0 items-center justify-center rounded-lg bg-severity-info-a2 text-severity-info", ), ], [icon(h, "CircleCheckIcon", { size: 20 })], @@ -142,16 +141,13 @@ const celebration = (h: H, serviceName: string | undefined): Html => h.div( [h.Class("min-w-0 flex-1 basis-40")], [ + h.p([h.Class("text-sm font-semibold tracking-tight")], ["First trace received"]), h.p( - [h.Class("text-sm font-semibold tracking-tight")], - ["First trace received. You're live"], - ), - h.p( - [h.Class("mt-0.5 font-prose text-xs text-muted-foreground")], + [h.Class("mt-0.5 text-xs text-muted-foreground")], [ serviceName === undefined - ? "We're seeing your telemetry. Jump in to explore." - : `We're seeing telemetry from ${serviceName}. Open it to explore.`, + ? "Your telemetry is arriving." + : `${serviceName} is sending telemetry.`, ], ), ], @@ -163,7 +159,7 @@ const celebration = (h: H, serviceName: string | undefined): Html => className: "gap-2 shrink-0 max-sm:order-last max-sm:w-full", attributes: [h.OnClick(Message.ClickedExploreTraces())], }, - ["Explore your traces", icon(h, "RocketIcon", {})], + ["Explore your traces", icon(h, "ArrowRightIcon", {})], ), button( h, @@ -182,7 +178,7 @@ const celebration = (h: H, serviceName: string | undefined): Html => "flex flex-wrap items-center gap-4 p-5", ), ], - "mb-4 shrink-0 border-primary-a4 bg-primary/[0.04] overflow-hidden", + "mb-4 shrink-0 overflow-hidden", ) /** `SetupChecklistCard` while waiting: the collapsible "Connect your app" card. */ @@ -205,13 +201,13 @@ const waitingCard = ( leading: h.span( [ h.Class( - "flex size-9 items-center justify-center rounded-lg bg-primary-a2 text-primary", + "flex size-9 items-center justify-center rounded-lg border border-border bg-surface-strong text-foreground", ), ], [icon(h, "CodeIcon", { size: 16 })], ), title: "Connect your app to see real data", - meta: h.span([h.Class("font-prose")], [getOnboardingSetupHint(intents)]), + meta: getOnboardingSetupHint(intents), }), h.div( [h.Class("flex items-center gap-1 shrink-0")], @@ -250,14 +246,14 @@ const waitingCard = ( cardPanel( h, [guidedSetup(h, model, framework), watchingStrip(h)], - "border-t border-primary-a3 p-5 space-y-5", + "border-t p-5 space-y-5", ), ], ), ], ), ], - "mb-4 shrink-0 border-primary-a3 bg-primary/[0.02] overflow-hidden", + "mb-4 shrink-0 overflow-hidden", ) /** The first read's failure while the card is mounted; the page's error strip shows it. */ diff --git a/apps/web/src/page/overview/setup-checklist.scene.test.ts b/apps/web/src/page/overview/setup-checklist.scene.test.ts index ec9cdbca9b..42316ba888 100644 --- a/apps/web/src/page/overview/setup-checklist.scene.test.ts +++ b/apps/web/src/page/overview/setup-checklist.scene.test.ts @@ -102,7 +102,7 @@ describe("overview setup checklist", () => { ...pageMounts, ...tabMounts, expectView(text("Connect your app to see real data")).toExist(), - expectView(text("Watching for your first trace…")).toExist(), + expectView(text("Listening for your first trace")).toExist(), expectView(role("button", { name: "Dismiss setup checklist" })).toExist(), ) }) @@ -112,7 +112,7 @@ describe("overview setup checklist", () => { { update: pageUpdate, view }, given(withServices(entered(true).read.model, ["checkout"])), ...pageMounts, - expectView(text("We're seeing telemetry from checkout. Open it to explore.")).toExist(), + expectView(text("checkout is sending telemetry.")).toExist(), expectView(text("Connect your app to see real data")).not.toExist(), ) }) @@ -174,7 +174,7 @@ describe("overview setup checklist", () => { given(entered(true).read.model), ...pageMounts, expectView(text("Connect your app to see real data")).not.toExist(), - expectView(text(/We're seeing telemetry/)).not.toExist(), + expectView(text(/is sending telemetry/)).not.toExist(), ) }) @@ -184,7 +184,7 @@ describe("overview setup checklist", () => { given(failOverview(entered(true).read.model)), ...pageMounts, expectView(text(failureSentence(toLoadFailure("boom")), { exact: false })).toExist(), - expectView(text("Watching for your first trace…")).not.toExist(), + expectView(text("Listening for your first trace")).not.toExist(), ) }) diff --git a/apps/web/src/shell/onboarding-checklist-view.ts b/apps/web/src/shell/onboarding-checklist-view.ts index 29a633acba..43ab0ef761 100644 --- a/apps/web/src/shell/onboarding-checklist-view.ts +++ b/apps/web/src/shell/onboarding-checklist-view.ts @@ -19,7 +19,10 @@ const credits = (checklist: V2OnboardingChecklist): string => `$${checklist.rewa const deadlineOf = (checklist: V2OnboardingChecklist): number | null => checklist.deadline_at === null ? null - : Option.match(DateTime.make(checklist.deadline_at), { onNone: () => null, onSome: DateTime.toEpochMillis }) + : Option.match(DateTime.make(checklist.deadline_at), { + onNone: () => null, + onSome: DateTime.toEpochMillis, + }) /** `ProgressChip`: `2/5`, written out. */ const progressChip = (h: H, completed: number, total: number): Html => @@ -43,13 +46,13 @@ const rewardCallout = (h: H, checklist: V2OnboardingChecklist): Html => [ h.Role("status"), h.Class( - "absolute top-full right-0 z-40 mt-3 w-max max-w-[calc(100vw-1rem)] rounded-lg border border-primary-a4 bg-popover text-popover-foreground shadow-popup animate-in fade-in-0 slide-in-from-top-1", + "absolute top-full right-0 z-40 mt-3 w-max max-w-[calc(100vw-1rem)] rounded-lg border bg-popover text-popover-foreground shadow-popup animate-in fade-in-0 slide-in-from-top-1", ), ], [ h.span( [ - h.Class("absolute -top-1.5 right-6 size-3 rotate-45 border-t border-l border-primary-a4 bg-popover"), + h.Class("absolute -top-1.5 right-6 size-3 rotate-45 border-t border-l bg-popover"), h.AriaHidden(true), ], [], @@ -66,10 +69,13 @@ const rewardCallout = (h: H, checklist: V2OnboardingChecklist): Html => ), ], [ - h.span([h.Class("block whitespace-nowrap text-sm font-semibold")], ["Finish the onboarding list"]), + h.span( + [h.Class("block whitespace-nowrap text-sm font-semibold")], + ["Finish setting up"], + ), h.span( [h.Class("mt-0.5 block text-xs text-muted-foreground")], - ["and get ", credits(checklist), " in credits."], + ["Then claim ", credits(checklist), " in credits."], ), ], ), @@ -90,12 +96,12 @@ const rewardCallout = (h: H, checklist: V2OnboardingChecklist): Html => const optionalTag = (h: H): Html => badge(h, { variant: "meta", size: "xs", pill: true }, ["Optional"]) /** `StepRow`: a done step struck through, an open one linking to where it is done. */ -const stepRow = (h: H, step: V2OnboardingChecklistStep, index: number): Html => +const stepRow = (h: H, step: V2OnboardingChecklistStep): Html => step.completed ? h.li( [h.Class("flex items-center gap-3 rounded-md px-2 py-2 text-sm text-muted-foreground")], [ - icon(h, "CircleCheckIcon", { size: 18, className: "shrink-0 text-primary" }), + icon(h, "CircleCheckIcon", { size: 16, className: "shrink-0 text-severity-info" }), h.span( [h.Class("min-w-0 flex-1 leading-snug line-through decoration-muted-foreground/40")], [step.title], @@ -118,11 +124,11 @@ const stepRow = (h: H, step: V2OnboardingChecklistStep, index: number): Html => h.span( [ h.Class( - "flex size-4.5 shrink-0 items-center justify-center rounded-full border border-foreground-faint font-mono text-3xs text-muted-foreground transition-colors group-hover:border-primary group-hover:text-primary", + "size-4 shrink-0 rounded-full border border-dashed border-foreground-faint group-hover:border-solid group-hover:border-foreground", ), h.AriaHidden(true), ], - [String(index)], + [], ), h.span([h.Class("min-w-0 flex-1 leading-snug")], [step.title]), ...(step.optional ? [optionalTag(h)] : []), @@ -137,10 +143,11 @@ const stepRow = (h: H, step: V2OnboardingChecklistStep, index: number): Html => ) /** `OnboardingChecklistPanel`: the reward, the progress meter, the steps and the footer. */ -const panel = (h: H, model: Model, checklist: V2OnboardingChecklist): ReadonlyArray => { +export const checklistPanel = (h: H, model: Model, checklist: V2OnboardingChecklist): ReadonlyArray => { const isClaimed = model.isJustClaimed || checklist.status === "claimed" const deadlineMs = deadlineOf(checklist) - const timeLeft = deadlineMs === null ? null : formatCountdown(deadlineMs - model.nowMs, { fixedHours: true }) + const timeLeft = + deadlineMs === null ? null : formatCountdown(deadlineMs - model.nowMs, { fixedHours: true }) const progress = checklist.total_count === 0 ? 0 : checklist.completed_count / checklist.total_count const amount = credits(checklist) return [ @@ -148,7 +155,7 @@ const panel = (h: H, model: Model, checklist: V2OnboardingChecklist): ReadonlyAr [], [ h.div( - [h.Class("relative space-y-3 border-b bg-primary/[0.06] px-4 pt-4 pb-3")], + [h.Class("relative space-y-3 border-b px-4 pt-4 pb-3")], [ h.div( [h.Class("flex items-start gap-3")], @@ -156,34 +163,46 @@ const panel = (h: H, model: Model, checklist: V2OnboardingChecklist): ReadonlyAr h.span( [ h.Class( - "flex size-9 shrink-0 items-center justify-center rounded-lg border border-primary-a3 bg-primary-a2 text-primary", + "flex size-9 shrink-0 items-center justify-center rounded-lg border border-border bg-surface-strong text-foreground", ), ], - [icon(h, isClaimed ? "CircleCheckIcon" : "RocketIcon", { size: 18 })], + [icon(h, isClaimed ? "CircleCheckIcon" : "StarIcon", { size: 18 })], ), h.div( [h.Class("min-w-0 space-y-1")], [ h.h3( [h.Class("text-base font-semibold leading-tight")], - [isClaimed ? `${amount} in credits added` : `Get ${amount} in credits`], + [ + isClaimed + ? `${amount} in credits added` + : `Get ${amount} in credits`, + ], ), h.p( [h.Class("text-xs leading-relaxed text-muted-foreground")], [ isClaimed ? `${amount} in credits applied. It comes off your next invoice.` - : "Finish these steps within 24 hours of creating your org and we'll credit your balance.", + : "Finish these within 24 hours of creating your org, then claim the credits.", ], ), ], ), ], ), - meter(h, { value: progress, minVisible: 0, className: "bg-primary-a2", fillClassName: "bg-primary" }), + meter(h, { + value: progress, + minVisible: 0, + className: "bg-primary-a2", + fillClassName: "bg-primary", + }), ], ), - h.ol([h.Class("space-y-0.5 p-2")], checklist.steps.map((step, index) => stepRow(h, step, index + 1))), + h.ol( + [h.Class("space-y-0.5 p-2")], + checklist.steps.map((step) => stepRow(h, step)), + ), h.div( [h.Class("flex items-center justify-between gap-3 border-t px-4 py-3 text-xs")], panelFooter(h, model, checklist, isClaimed, timeLeft), @@ -199,7 +218,8 @@ export const view = Submodel.defineView((model, h) => { if (checklist === null) return null const isActive = checklist.status === "in_progress" || checklist.status === "claimable" const deadlineMs = deadlineOf(checklist) - const isWindowOpen = checklist.status === "claimable" || (deadlineMs !== null && deadlineMs >= model.nowMs) + const isWindowOpen = + checklist.status === "claimable" || (deadlineMs !== null && deadlineMs >= model.nowMs) if (!model.isJustClaimed && !(isActive && isWindowOpen)) return null const isClaimable = checklist.status === "claimable" return h.submodel({ @@ -208,7 +228,7 @@ export const view = Submodel.defineView((model, h) => { view: PopoverView.view, viewInputs: { className: "w-[22rem] max-w-[calc(100vw-1rem)] p-0", - toContent: () => (model.popover.isOpen ? panel(h, model, checklist) : []), + toContent: () => (model.popover.isOpen ? checklistPanel(h, model, checklist) : []), toView: ({ trigger, withTrigger }) => h.div( [h.Class("relative")], @@ -219,10 +239,7 @@ export const view = Submodel.defineView((model, h) => { { variant: "outline", size: "sm", - className: cn( - "gap-2 border-primary-a4 bg-primary-a2 text-foreground hover:bg-primary-a3 active:bg-primary-a3", - isClaimable && "border-primary bg-primary-a3", - ), + className: cn("gap-2", isClaimable && "border-primary"), slot: "popover-trigger", attributes: trigger, }, @@ -232,7 +249,11 @@ export const view = Submodel.defineView((model, h) => { ? h.span([], ["Claim ", credits(checklist), " credits"]) : h.span( [h.Class("font-mono tabular-nums")], - [formatCountdown(deadlineMs - model.nowMs, { fixedHours: true })], + [ + formatCountdown(deadlineMs - model.nowMs, { + fixedHours: true, + }), + ], ), progressChip(h, checklist.completed_count, checklist.total_count), ], @@ -246,7 +267,13 @@ export const view = Submodel.defineView((model, h) => { }) }) -const panelFooter = (h: H, model: Model, checklist: V2OnboardingChecklist, isClaimed: boolean, timeLeft: string | null): ReadonlyArray => { +const panelFooter = ( + h: H, + model: Model, + checklist: V2OnboardingChecklist, + isClaimed: boolean, + timeLeft: string | null, +): ReadonlyArray => { if (isClaimed) return [ h.span([h.Class("text-muted-foreground")], ["You're all set."]), @@ -256,16 +283,32 @@ const panelFooter = (h: H, model: Model, checklist: V2OnboardingChecklist, isCla return model.isAdmin ? [ h.span( - [h.Class(cn("text-muted-foreground", model.claimError !== null && "text-severity-error"))], + [ + h.Class( + cn( + "text-muted-foreground", + model.claimError !== null && "text-severity-error", + ), + ), + ], [model.claimError ?? "Every step is done."], ), - button(h, { size: "sm", isLoading: model.isClaimPending, attributes: [h.OnClick(Message.ClickedClaim())] }, [ - "Claim ", - credits(checklist), - " credits", - ]), + button( + h, + { + size: "sm", + isLoading: model.isClaimPending, + attributes: [h.OnClick(Message.ClickedClaim())], + }, + ["Claim ", credits(checklist), " credits"], + ), + ] + : [ + h.span( + [h.Class("text-muted-foreground")], + ["All done. Ask an org admin to claim the credits."], + ), ] - : [h.span([h.Class("text-muted-foreground")], ["All done. Ask an org admin to claim the credits."])] return [ h.span( [h.Class("font-mono tabular-nums text-muted-foreground")], @@ -275,7 +318,11 @@ const panelFooter = (h: H, model: Model, checklist: V2OnboardingChecklist, isCla ? [] : [ h.span( - [h.Class("inline-flex items-center gap-1 font-mono font-medium tabular-nums text-primary")], + [ + h.Class( + "inline-flex items-center gap-1 font-mono tabular-nums text-muted-foreground", + ), + ], [icon(h, "ClockIcon", { size: 12 }), timeLeft], ), ]),