From 58ea0b84a77d289f13f32ba4f9b18e0e309226e1 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 30 Oct 2026 21:26:52 +0100 Subject: [PATCH 1/7] feat(web): text link, card button and dashed button in the kit The uptime monitor kind picker uses segmentedSelect instead of a hand-built track. --- apps/web/src/lab/entries/kit/buttons.ts | 45 +++++++++++++++ apps/web/src/page/alerts/uptime-form/view.ts | 55 ++++++------------- apps/web/src/ui/card-button.ts | 41 ++++++++++++++ apps/web/src/ui/text-link.ts | 49 +++++++++++++++++ .../ui/src/components/ui/button.styles.ts | 2 + 5 files changed, 153 insertions(+), 39 deletions(-) create mode 100644 apps/web/src/ui/card-button.ts create mode 100644 apps/web/src/ui/text-link.ts diff --git a/apps/web/src/lab/entries/kit/buttons.ts b/apps/web/src/lab/entries/kit/buttons.ts index 30baad751d..82908ec9fa 100644 --- a/apps/web/src/lab/entries/kit/buttons.ts +++ b/apps/web/src/lab/entries/kit/buttons.ts @@ -4,6 +4,7 @@ import type { Html, HtmlBuilder } from "foldkit/html" import { defineMessageUnion } from "foldkit/message" import { modifyFields } from "foldkit/struct" import { button } from "../../../ui/button" +import { cardButton } from "../../../ui/card-button" import * as CopyButton from "../../../ui/copy-button" import { badge, kbd, kbdGroup } from "../../../ui/display" import { filterChip } from "../../../ui/filter-chip" @@ -12,6 +13,7 @@ import { iconButton } from "../../../ui/icon-button" import { inlineCode } from "../../../ui/inline-code" import { refreshButton } from "../../../ui/refresh-button" import { statusDot } from "../../../ui/status-dot" +import { textLink } from "../../../ui/text-link" import * as Toggle from "../../../ui/toggle" import * as ToggleGroup from "../../../ui/toggle-group" import { defineLab } from "../../define" @@ -27,6 +29,7 @@ const VARIANTS = [ "destructive", "destructive-outline", "link", + "dashed", ] as const const TEXT_SIZES = ["xs", "sm", "default", "lg", "xl"] as const const ICON_SIZES = ["icon-2xs", "icon-xs", "icon-sm", "icon", "icon-lg", "icon-xl"] as const @@ -358,6 +361,46 @@ const kbdAndInlineCode = (h: HtmlBuilder): Html => ), ]) +const textLinks = (h: HtmlBuilder): Html => + kitRow(h, undefined, [ + textLink(h, { onClick: Message.ClickedRefresh() }, ["View all →"]), + textLink(h, { href: "#text-link" }, ["Open settings"]), + textLink(h, { tone: "muted", onClick: Message.ClickedRefresh() }, ["Clear filters"]), + textLink( + h, + { tone: "muted", isMono: true, className: "text-3xs", onClick: Message.ClickedRefresh() }, + ["+4 more"], + ), + textLink(h, { onClick: Message.ClickedRefresh(), isDisabled: true }, ["Disabled"]), + ]) + +const cardButtons = (h: HtmlBuilder): Html => + kitRow( + h, + undefined, + ( + [ + ["Resting", false, false], + ["Selected", true, false], + ["Disabled", false, true], + ] as const + ).map(([title, isSelected, isDisabled]) => + cardButton( + h, + { + onClick: Message.ClickedRefresh(), + isSelected, + isDisabled, + className: "flex w-56 flex-col gap-1 p-3", + }, + [ + h.span([h.Class("text-sm font-medium")], [title]), + h.span([h.Class("text-xs text-muted-foreground")], ["A whole card as one click target."]), + ], + ), + ), + ) + const view = (model: Model, h: HtmlBuilder): Html => kitPage( h, @@ -369,6 +412,8 @@ const view = (model: Model, h: HtmlBuilder): Html => kitSection(h, "Button variants x sizes", buttonVariantRows(h)), kitSection(h, "Button states", [buttonStates(h)]), kitSection(h, "Icon button", [iconButtons(h)]), + kitSection(h, "Text link", [textLinks(h)]), + kitSection(h, "Card button", [cardButtons(h)]), kitSection(h, "Toggle", [toggles(model, h)]), kitSection(h, "Toggle group", toggleGroups(model, h)), kitSection(h, "Copy and refresh buttons", [copyAndRefresh(model, h)]), diff --git a/apps/web/src/page/alerts/uptime-form/view.ts b/apps/web/src/page/alerts/uptime-form/view.ts index 57a25180f9..7b2b19306f 100644 --- a/apps/web/src/page/alerts/uptime-form/view.ts +++ b/apps/web/src/page/alerts/uptime-form/view.ts @@ -8,6 +8,7 @@ import { errorState } from "../../../ui/error-state" import { icon, type IconName } from "../../../ui/icon" import { alert, alertDescription, alertTitle } from "../../../ui/alert" import { panel, panelHeader } from "../../../ui/panel" +import { segmentedSelect } from "../../../ui/segmented-select" import { skeleton } from "../../../ui/skeleton" import type { PageViewInputs } from "../../contract" import { dashboardPage } from "../../kit/dashboard-page" @@ -51,7 +52,7 @@ const KINDS: ReadonlyArray<{ kind: MonitorKind; label: string; icon: IconName; d }, ] -/** The kind as one row of radios; the picked kind's sentence sits under it. */ +/** The kind as a segmented control; the picked kind's sentence sits under it. */ const kindPicker = (h: H, model: Model): Html => h.section( [h.AriaLabel("Type"), h.Class("grid gap-x-8 gap-y-2 px-4 py-4 sm:grid-cols-[136px_minmax(0,1fr)]")], @@ -60,44 +61,20 @@ const kindPicker = (h: H, model: Model): Html => h.div( [h.Class("flex min-w-0 flex-col gap-2")], [ - h.div( - [ - h.Role("radiogroup"), - h.AriaLabel("Monitor type"), - h.Class("flex w-fit flex-wrap gap-1 rounded-md border bg-surface-sunken p-0.5"), - ], - KINDS.map((option) => { - const isSelected = model.form.kind === option.kind - const isDisabled = model.editingId !== null && !isSelected - return h.button( - [ - h.Key(option.kind), - h.Type("button"), - h.Role("radio"), - h.AriaChecked(isSelected), - ...(isDisabled ? [h.Disabled(true)] : []), - h.OnClick(Message.PickedKind({ kind: option.kind })), - h.Class( - cn( - "flex h-7 items-center gap-1.5 rounded-[5px] px-2.5 text-sm transition-colors", - isSelected - ? "bg-background text-foreground shadow-xs ring-1 ring-border" - : "text-muted-foreground hover:text-foreground", - isDisabled && - "cursor-not-allowed opacity-50 hover:text-muted-foreground", - ), - ), - ], - [ - icon(h, option.icon, { - size: 14, - className: isSelected ? "text-primary" : undefined, - }), - option.label, - ], - ) - }), - ), + segmentedSelect(h, { + ariaLabel: "Monitor type", + size: "sm", + className: "w-fit", + value: model.form.kind, + onChange: (kind) => Message.PickedKind({ kind }), + // Editing keeps the kind: the others stay visible but can't be picked. + options: KINDS.map((option) => ({ + value: option.kind, + label: option.label, + icon: icon(h, option.icon, { size: 14 }), + isDisabled: model.editingId !== null && option.kind !== model.form.kind, + })), + }), h.p( [h.Class("text-xs text-muted-foreground")], [KINDS.find((option) => option.kind === model.form.kind)?.description ?? ""], diff --git a/apps/web/src/ui/card-button.ts b/apps/web/src/ui/card-button.ts new file mode 100644 index 0000000000..eb0c92a899 --- /dev/null +++ b/apps/web/src/ui/card-button.ts @@ -0,0 +1,41 @@ +import { cn } from "@maple/ui/lib/utils" +import type { Attribute, ChildAttribute, Html, HtmlBuilder } from "foldkit/html" + +// `CardButton`: a whole card as one click target (template tiles, source and destination pickers, +// metric cards). The card owns surface, border, hover, selection and focus; the caller's +// `className` owns layout (direction, gap, padding). + +const BASE = + "group rounded-md border bg-card text-left transition-colors hover:border-ring/50 hover:bg-surface-sunken focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:border-border disabled:hover:bg-card" + +const SELECTED = "border-primary/60 bg-primary/5 hover:border-primary/60 hover:bg-primary/5" + +export type CardButtonOptions = Readonly<{ + onClick: M + isSelected?: boolean + isDisabled?: boolean + className?: string + attributes?: ReadonlyArray | ChildAttribute> +}> + +export const cardButtonClass = (isSelected = false, className?: string): string => + cn(BASE, isSelected && SELECTED, className) + +export const cardButton = ( + h: HtmlBuilder, + options: CardButtonOptions, + children: ReadonlyArray, +): Html => + h.button( + [ + h.Type("button"), + h.Class(cardButtonClass(options.isSelected, options.className)), + h.OnClick(options.onClick), + ...(options.isSelected === undefined + ? [] + : [h.AriaPressed(options.isSelected ? "true" : "false")]), + ...((options.isDisabled ?? false) ? [h.Disabled(true)] : []), + ...(options.attributes ?? []), + ], + children, + ) diff --git a/apps/web/src/ui/text-link.ts b/apps/web/src/ui/text-link.ts new file mode 100644 index 0000000000..3e509f0bd7 --- /dev/null +++ b/apps/web/src/ui/text-link.ts @@ -0,0 +1,49 @@ +import { cn } from "@maple/ui/lib/utils" +import type { Attribute, ChildAttribute, Html, HtmlBuilder } from "foldkit/html" + +// `TextLink`: an inline action that reads as a link inside running text or a card footer, with no +// button box. `primary` is a call to action ("View all →"), `muted` a quiet secondary action +// ("Clear filters", "Show 4 more"). An `href` renders an anchor, an `onClick` a button. + +export type TextLinkTone = "primary" | "muted" + +const BASE = + "inline-flex cursor-pointer items-center gap-1 underline-offset-2 transition-colors hover:underline focus-visible:rounded-xs focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" + +const TONE: Readonly> = { + primary: "font-medium text-primary", + muted: "text-muted-foreground hover:text-foreground", +} + +export type TextLinkOptions = Readonly< + { + tone?: TextLinkTone + /** Monospace, for links that sit among IDs and attribute values. */ + isMono?: boolean + className?: string + attributes?: ReadonlyArray | ChildAttribute> + } & ({ href: string } | { onClick: M; isDisabled?: boolean }) +> + +export const textLinkClass = (tone: TextLinkTone = "primary", isMono = false, className?: string): string => + cn(BASE, TONE[tone], isMono && "font-mono", className) + +export const textLink = ( + h: HtmlBuilder, + options: TextLinkOptions, + children: ReadonlyArray, +): Html => { + const className = h.Class(textLinkClass(options.tone, options.isMono, options.className)) + const extra = options.attributes ?? [] + if ("href" in options) return h.a([className, h.Href(options.href), ...extra], children) + return h.button( + [ + h.Type("button"), + className, + h.OnClick(options.onClick), + ...((options.isDisabled ?? false) ? [h.Disabled(true)] : []), + ...extra, + ], + children, + ) +} diff --git a/packages/ui/src/components/ui/button.styles.ts b/packages/ui/src/components/ui/button.styles.ts index add255e203..19ff640a13 100644 --- a/packages/ui/src/components/ui/button.styles.ts +++ b/packages/ui/src/components/ui/button.styles.ts @@ -31,6 +31,8 @@ export const buttonVariants = cva( "not-disabled:inset-shadow-[0_1px_--theme(--color-white/16%)] border-destructive bg-destructive text-white shadow-destructive/24 shadow-xs hover:bg-destructive/90 data-pressed:bg-destructive/90 dark:border-destructive/80 dark:bg-destructive/80 dark:hover:bg-destructive/72 dark:data-pressed:bg-destructive/72 *:data-[slot=button-loading-indicator]:text-white [:active,[data-pressed]]:inset-shadow-[0_1px_--theme(--color-black/8%)] [:disabled,:active,[data-pressed]]:shadow-none", "destructive-outline": "border-input bg-popover not-dark:bg-clip-padding text-destructive-foreground shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] hover:border-destructive/32 hover:bg-destructive/4 data-pressed:border-destructive/32 data-pressed:bg-destructive/4 *:data-[slot=button-loading-indicator]:text-foreground dark:bg-input/32 dark:not-disabled:before:shadow-[0_-1px_--theme(--color-white/2%)] dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] [:disabled,:active,[data-pressed]]:shadow-none", + // Add-a-row affordances ("Add threshold", "New dashboard"): an outline that reads as an empty slot. + dashed: "border-dashed border-input bg-transparent text-muted-foreground hover:bg-surface-sunken hover:text-foreground data-pressed:bg-surface-sunken *:data-[slot=button-loading-indicator]:text-foreground", ghost: "border-transparent text-foreground hover:bg-accent data-pressed:bg-accent *:data-[slot=button-loading-indicator]:text-foreground", link: "border-transparent text-primary underline-offset-4 active:scale-100! hover:underline data-pressed:underline *:data-[slot=button-loading-indicator]:text-foreground", outline: From aae1e862918fc9019a86f6b3b208fdb7a6dc86b7 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 30 Oct 2026 21:34:30 +0100 Subject: [PATCH 2/7] refactor(web): sweep hand-built links, cards and buttons onto the kit 17 inline text links move to textLink (monoLinkButton is gone), 7 card pickers to cardButton, 3 dashed add buttons to the dashed variant, and 5 copies of Button/IconButton styling to the components. --- .../tool/view-error-modal-payload.ts | 16 ++-- apps/web/src/page/alerts/destination/view.ts | 30 ++++---- .../src/page/alerts/editor/view-details.ts | 16 ++-- .../src/page/alerts/editor/view-templates.ts | 62 ++++++--------- apps/web/src/page/alerts/hub/view-empty.ts | 10 +-- .../uptime-form/uptime-form.scene.test.ts | 6 +- .../page/anomalies/detail/view-link-dialog.ts | 76 +++++++++++++------ apps/web/src/page/code-review/ci/view-runs.ts | 36 +++++---- apps/web/src/page/dashboards/view/picker.ts | 14 +--- .../dashboards/widget/view-rail-thresholds.ts | 15 ++-- .../web/src/page/errors/issue/view-sidebar.ts | 27 ++++--- .../cloudflare/zone/view-breakdown.ts | 17 +++-- .../page/infra-integrations/gcp/list/view.ts | 17 +++-- .../kit/filter-chip-rail.ts | 30 ++++---- .../infra-integrations/planetscale/db/view.ts | 17 +++-- apps/web/src/page/integrations/view-hub.ts | 14 +--- apps/web/src/page/kit/message-response.ts | 25 +++--- apps/web/src/page/metrics/browse/view-grid.ts | 22 +++--- .../src/page/onboarding/create-org-view.ts | 14 ++-- .../src/page/security/kit/add-target-view.ts | 16 ++-- apps/web/src/page/security/run/view-panels.ts | 31 +++----- .../src/page/security/tabs/view-overview.ts | 37 ++++----- .../page/services/detail/view-footprint.ts | 14 +--- .../src/page/services/detail/view-panels.ts | 12 +-- .../src/page/settings/billing/view-cycle.ts | 18 ++--- apps/web/src/page/sources/new/view-picker.ts | 17 +---- .../page/traces/detail/timeline/view-row.ts | 29 +++---- 27 files changed, 306 insertions(+), 332 deletions(-) diff --git a/apps/web/src/page/agent-sessions/tool/view-error-modal-payload.ts b/apps/web/src/page/agent-sessions/tool/view-error-modal-payload.ts index 7c9925d241..52ff396dc8 100644 --- a/apps/web/src/page/agent-sessions/tool/view-error-modal-payload.ts +++ b/apps/web/src/page/agent-sessions/tool/view-error-modal-payload.ts @@ -153,14 +153,14 @@ const argumentsBlock = ( label: "Arguments", attribute: "gen_ai.tool.call.arguments", bytes: row.argumentsBytes, - action: h.button( - [ - h.Class( - "font-mono text-2xs text-muted-foreground transition-colors hover:text-foreground", - ), - h.Type("button"), - h.OnClick(Message.ClickedCopyArguments({ text: row.arguments })), - ], + action: button( + h, + { + variant: "ghost", + size: "xs", + className: "font-mono text-2xs text-muted-foreground hover:text-foreground sm:text-2xs", + attributes: [h.OnClick(Message.ClickedCopyArguments({ text: row.arguments }))], + }, [copyLabel], ), }), diff --git a/apps/web/src/page/alerts/destination/view.ts b/apps/web/src/page/alerts/destination/view.ts index 3742b1a045..64981c7325 100644 --- a/apps/web/src/page/alerts/destination/view.ts +++ b/apps/web/src/page/alerts/destination/view.ts @@ -6,6 +6,7 @@ import { isClerkAuthEnabled } from "../../../lib/services/common/auth-mode" import { Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { button } from "../../../ui/button" +import { cardButton } from "../../../ui/card-button" import * as DialogView from "../../../ui/dialog-view" import { titleId } from "../../../ui/dialog" import { eyebrow } from "../../../ui/eyebrow" @@ -110,23 +111,20 @@ const providerTile = ( }>, ): Html => { const provider = destinationProvider(options.type, options.chatConnector) - return h.button( - [ - h.Key(options.key), - h.Type("button"), - h.OnClick(options.onSelect), - ...(options.isBusy === true ? [h.Disabled(true)] : []), - h.AriaPressed(options.isSelected ? "true" : "false"), - h.Class( - cn( - "relative flex flex-col items-start gap-2 overflow-hidden rounded-md border p-3 text-left transition-colors", - options.isSelected - ? "border-(--tile-accent) bg-surface-sunken" - : "border-border-subtle bg-card hover:border-border hover:bg-surface-sunken", - ), + return cardButton( + h, + { + onClick: options.onSelect, + isSelected: options.isSelected, + isDisabled: options.isBusy === true, + // Selection takes the provider's accent; a busy tile stays solid so its spinner reads. + className: cn( + "relative flex flex-col items-start gap-2 overflow-hidden p-3 disabled:opacity-100", + options.isSelected && + "border-(--tile-accent) bg-surface-sunken hover:border-(--tile-accent) hover:bg-surface-sunken", ), - h.Style({ "--tile-accent": provider.accent }), - ], + attributes: [h.Key(options.key), h.Style({ "--tile-accent": provider.accent })], + }, [ h.span([ h.AriaHidden(true), diff --git a/apps/web/src/page/alerts/editor/view-details.ts b/apps/web/src/page/alerts/editor/view-details.ts index 160257e893..0202fd2c16 100644 --- a/apps/web/src/page/alerts/editor/view-details.ts +++ b/apps/web/src/page/alerts/editor/view-details.ts @@ -122,14 +122,14 @@ const nameField = (h: H, model: DetailsView, form: RuleForm): Html => { const notes = (h: H, model: DetailsView, form: RuleForm): Html => { if (!model.isNotesOpen) - return h.button( - [ - h.Type("button"), - h.OnClick(Message.ClickedAddNotes()), - h.Class( - "flex w-full items-center gap-1.5 rounded-md border border-dashed border-border-subtle px-3 py-1.5 text-left text-xs text-muted-foreground hover:border-border hover:text-foreground", - ), - ], + return button( + h, + { + variant: "dashed", + size: "xs", + className: "w-full justify-start", + attributes: [h.OnClick(Message.ClickedAddNotes())], + }, [icon(h, "PlusIcon", { size: 12 }), "Add notes"], ) const state = fieldStateOf(form.notes, model.mountedForm?.notes) diff --git a/apps/web/src/page/alerts/editor/view-templates.ts b/apps/web/src/page/alerts/editor/view-templates.ts index 68d5d9e713..73dc4c0f0c 100644 --- a/apps/web/src/page/alerts/editor/view-templates.ts +++ b/apps/web/src/page/alerts/editor/view-templates.ts @@ -1,7 +1,7 @@ -import { cn } from "@maple/ui/lib/utils" import { createLazy, type Html, type HtmlBuilder } from "foldkit/html" import { AppRoute, hrefOf } from "../../../route" import { button } from "../../../ui/button" +import { cardButton } from "../../../ui/card-button" import * as DialogView from "../../../ui/dialog-view" import { icon, type IconName } from "../../../ui/icon" import { inlineCode } from "../../../ui/inline-code" @@ -13,12 +13,7 @@ import type { Model } from "./model" type H = HtmlBuilder -const TILE_CLASS = cn( - "group flex h-full flex-col gap-3 rounded-md border bg-card p-4 text-left transition-colors", - "hover:border-primary/40 hover:bg-primary/[0.03] focus-visible:outline-none", - // Focus reads like hover (the primary tint), not a neutral ring that looks like a selection. - "focus-visible:border-primary/60 focus-visible:bg-primary/[0.03] focus-visible:ring-2 focus-visible:ring-primary/20", -) +const TILE_LAYOUT = "flex h-full flex-col gap-3 p-4" const tileHead = (h: H, iconName: IconName, title: string, subtitle: string): Html => h.div( @@ -50,44 +45,37 @@ const chevron = (h: H): Html => const tiles = (h: H): ReadonlyArray => [ ...ALERT_TEMPLATES.map((template) => - h.button( - [h.Type("button"), h.Class(TILE_CLASS), h.OnClick(Message.ClickedTemplate({ id: template.id }))], - [ - tileHead(h, template.icon, template.title, template.description), - h.div( - [h.Class("mt-auto flex min-w-0 items-center justify-between gap-2")], - [ - inlineCode( - h, - { - className: - "min-w-0 truncate whitespace-nowrap text-2xs text-muted-foreground", - }, - [template.summary], - ), - chevron(h), - ], - ), - ], - ), - ), - h.button( - [h.Type("button"), h.Class(TILE_CLASS), h.OnClick(Message.ClickedStartBlank())], - [ - tileHead(h, "SquarePlusIcon", "Start blank", "Build a rule from scratch."), + cardButton(h, { onClick: Message.ClickedTemplate({ id: template.id }), className: TILE_LAYOUT }, [ + tileHead(h, template.icon, template.title, template.description), h.div( [h.Class("mt-auto flex min-w-0 items-center justify-between gap-2")], [ - // One line, so the chevron sits on the same row as the siblings' chips. - h.p( - [h.Class("min-w-0 truncate font-prose text-muted-foreground text-xs")], - ["Choose a signal"], + inlineCode( + h, + { + className: "min-w-0 truncate whitespace-nowrap text-2xs text-muted-foreground", + }, + [template.summary], ), chevron(h), ], ), - ], + ]), ), + cardButton(h, { onClick: Message.ClickedStartBlank(), className: TILE_LAYOUT }, [ + tileHead(h, "SquarePlusIcon", "Start blank", "Build a rule from scratch."), + h.div( + [h.Class("mt-auto flex min-w-0 items-center justify-between gap-2")], + [ + // One line, so the chevron sits on the same row as the siblings' chips. + h.p( + [h.Class("min-w-0 truncate font-prose text-muted-foreground text-xs")], + ["Choose a signal"], + ), + chevron(h), + ], + ), + ]), // Instructions, not a choice: a note under the tiles rather than another card. h.p( [h.Class("mt-1 flex items-center gap-2 font-prose text-muted-foreground text-xs md:col-span-3")], diff --git a/apps/web/src/page/alerts/hub/view-empty.ts b/apps/web/src/page/alerts/hub/view-empty.ts index 97497114a1..88aba40882 100644 --- a/apps/web/src/page/alerts/hub/view-empty.ts +++ b/apps/web/src/page/alerts/hub/view-empty.ts @@ -1,6 +1,7 @@ import { cn } from "@maple/ui/lib/utils" import type { Html, HtmlBuilder } from "foldkit/html" import { button, linkButton } from "../../../ui/button" +import { cardButtonClass } from "../../../ui/card-button" import { empty, emptyDescription, emptyHeader, emptyTitle } from "../../../ui/empty" import { icon } from "../../../ui/icon" import { inlineCode } from "../../../ui/inline-code" @@ -126,13 +127,8 @@ const templateTile = (h: HtmlBuilder, template: AlertTemplate, serviceName return h.a( [ h.Href(createHref({ template: template.id, serviceName })), - h.Class( - cn( - "group flex flex-col gap-1.5 rounded-md border bg-card p-3 text-left transition-colors", - "hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background", - tone?.hoverBorder, - ), - ), + // A link, so it keeps the anchor and borrows the card button's surface. + h.Class(cardButtonClass(false, cn("flex flex-col gap-1.5 p-3", tone?.hoverBorder))), ], [ h.div( diff --git a/apps/web/src/page/alerts/uptime-form/uptime-form.scene.test.ts b/apps/web/src/page/alerts/uptime-form/uptime-form.scene.test.ts index 94d8b2ee2a..e6fd1ff44e 100644 --- a/apps/web/src/page/alerts/uptime-form/uptime-form.scene.test.ts +++ b/apps/web/src/page/alerts/uptime-form/uptime-form.scene.test.ts @@ -82,7 +82,7 @@ describe("uptime monitor form", () => { scene( { update: pageUpdate, view }, given(fresh()), - click(role("radio", { name: /TCP port/u })), + click(role("button", { name: /TCP port/u })), type(role("textbox", { name: "Host" }), "db.acme.dev"), type(role("textbox", { name: "Port" }), "99999"), expectView(text("The port must be a whole number from 1 to 65535.")).toExist(), @@ -93,7 +93,7 @@ describe("uptime monitor form", () => { scene( { update: pageUpdate, view }, given(fresh()), - click(role("radio", { name: /Heartbeat/u })), + click(role("button", { name: /Heartbeat/u })), expectView(text("The ping URL appears after you create the monitor.")).toExist(), expectView(role("textbox", { name: "Grace period" })).toExist(), expectView(role("textbox", { name: "URL" })).not.toExist(), @@ -262,7 +262,7 @@ describe("uptime monitor form", () => { scene( { update: pageUpdate, view }, given(fresh()), - click(role("radio", { name: /TCP port/u })), + click(role("button", { name: /TCP port/u })), type(role("textbox", { name: "Host" }), "db.acme.dev"), type(role("textbox", { name: "Port" }), "5432"), expectView(role("textbox", { name: "Name" })).toHaveValue("db.acme.dev:5432"), diff --git a/apps/web/src/page/anomalies/detail/view-link-dialog.ts b/apps/web/src/page/anomalies/detail/view-link-dialog.ts index 890f5c27e4..ae9a7a8262 100644 --- a/apps/web/src/page/anomalies/detail/view-link-dialog.ts +++ b/apps/web/src/page/anomalies/detail/view-link-dialog.ts @@ -6,6 +6,7 @@ import { errorIssueFromV2 } from "../../../lib/services/error-issues" import { AsyncData } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import * as Combobox from "../../../ui/combobox" +import { textLink } from "../../../ui/text-link" import { commandHiddenInput, commandInput } from "../../../ui/command" import { view as dialogView } from "../../../ui/dialog-view" import { scrollArea } from "../../../ui/scroll-area" @@ -78,7 +79,12 @@ const item = (h: H, model: Model, issue: ErrorIssueDocument, index: number, time ], ) -const listBody = (h: H, model: Model, incident: AnomalyIncidentDocument, timeZone: string): ReadonlyArray => { +const listBody = ( + h: H, + model: Model, + incident: AnomalyIncidentDocument, + timeZone: string, +): ReadonlyArray => { const query = model.linkCombobox.inputValue if (AsyncData.isLoading(model.linkIssues.data) || AsyncData.isIdle(model.linkIssues.data)) return [ @@ -132,7 +138,12 @@ const listBody = (h: H, model: Model, incident: AnomalyIncidentDocument, timeZon ] } -export const linkIssueDialog = (h: H, model: Model, incident: AnomalyIncidentDocument, timeZone: string): Html => +export const linkIssueDialog = ( + h: H, + model: Model, + incident: AnomalyIncidentDocument, + timeZone: string, +): Html => h.submodel({ slotId: "anomaly-link-issue-dialog", model: model.linkDialog, @@ -155,39 +166,58 @@ export const linkIssueDialog = (h: H, model: Model, incident: AnomalyIncidentDoc groups: [ { label: "", - items: filteredOf(model, incident).map((issue) => ({ title: String(issue.id) })), + items: filteredOf(model, incident).map((issue) => ({ + title: String(issue.id), + })), }, ], }, toCombobox, ), - scrollArea(h, { idPrefix: `${model.linkCombobox.id}-scroll`, scrollFade: true, scrollbarGutter: true }, [ - h.div( - [ - h.Class(cn(A.autocompleteListStyles, commandListStyles)), - h.DataAttribute("empty", ""), - h.DataAttribute("slot", "command-list"), - h.Id(Combobox.listId(model.linkCombobox.id)), - h.Role("listbox"), - h.Tabindex(-1), - ], - listBody(h, model, incident, timeZone), - ), - ]), + scrollArea( + h, + { + idPrefix: `${model.linkCombobox.id}-scroll`, + scrollFade: true, + scrollbarGutter: true, + }, + [ + h.div( + [ + h.Class(cn(A.autocompleteListStyles, commandListStyles)), + h.DataAttribute("empty", ""), + h.DataAttribute("slot", "command-list"), + h.Id(Combobox.listId(model.linkCombobox.id)), + h.Role("listbox"), + h.Tabindex(-1), + ], + listBody(h, model, incident, timeZone), + ), + ], + ), h.div( - [h.Class("flex items-center justify-between border-t border-border-subtle px-3 py-2")], + [ + h.Class( + "flex items-center justify-between border-t border-border-subtle px-3 py-2", + ), + ], [ h.span( [h.Class("text-xs text-muted-foreground")], - [model.linkAllServices ? "Showing issues across all services" : `Scoped to ${incident.serviceName}`], + [ + model.linkAllServices + ? "Showing issues across all services" + : `Scoped to ${incident.serviceName}`, + ], ), - h.button( + textLink( + h, + { className: "text-xs", onClick: Message.ToggledLinkScope() }, [ - h.Type("button"), - h.OnClick(Message.ToggledLinkScope()), - h.Class("text-xs text-primary hover:underline"), + model.linkAllServices + ? `Only ${incident.serviceName}` + : "Search all services", ], - [model.linkAllServices ? `Only ${incident.serviceName}` : "Search all services"], ), ], ), diff --git a/apps/web/src/page/code-review/ci/view-runs.ts b/apps/web/src/page/code-review/ci/view-runs.ts index 5ec65766e4..369160378b 100644 --- a/apps/web/src/page/code-review/ci/view-runs.ts +++ b/apps/web/src/page/code-review/ci/view-runs.ts @@ -7,6 +7,7 @@ import { Option } from "effect" import { AsyncData } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { linkButton } from "../../../ui/button" +import { textLink } from "../../../ui/text-link" import { errorState } from "../../../ui/error-state" import { icon } from "../../../ui/icon" import { listFooter, loadingMoreRow } from "../../../ui/list-footer" @@ -37,15 +38,15 @@ const runMeta = (h: H, run: CodeReviewCiRun, prUrl: string | null): Html => ...(run.branch ? [ dot(h), - h.button( - [ - h.Type("button"), - h.OnClick(Message.ClickedBranch({ branch: run.branch })), - h.Title(`Show runs on ${run.branch}`), - h.Class( - "max-w-48 truncate font-mono hover:text-foreground hover:underline focus-visible:underline", - ), - ], + textLink( + h, + { + tone: "muted", + isMono: true, + className: "block max-w-48 truncate", + onClick: Message.ClickedBranch({ branch: run.branch }), + attributes: [h.Title(`Show runs on ${run.branch}`)], + }, [run.branch], ), ] @@ -57,13 +58,16 @@ const runMeta = (h: H, run: CodeReviewCiRun, prUrl: string | null): Html => dot(h), prUrl === null ? h.span([], ["#", String(run.pullRequest)]) - : h.a( - [ - h.Href(prUrl), - h.Attribute("target", "_blank"), - h.Attribute("rel", "noreferrer"), - h.Class("hover:text-foreground hover:underline"), - ], + : textLink( + h, + { + tone: "muted", + href: prUrl, + attributes: [ + h.Attribute("target", "_blank"), + h.Attribute("rel", "noreferrer"), + ], + }, ["#", String(run.pullRequest)], ), ]), diff --git a/apps/web/src/page/dashboards/view/picker.ts b/apps/web/src/page/dashboards/view/picker.ts index e7d3170379..a1a020e2f7 100644 --- a/apps/web/src/page/dashboards/view/picker.ts +++ b/apps/web/src/page/dashboards/view/picker.ts @@ -9,6 +9,7 @@ import type { WidgetPresetDefinition } from "@maple/web-data/dashboards/widget-p import type { Html, HtmlBuilder } from "foldkit/html" import * as Chart from "../../../charts/chart" import { isChartRuntimeReady } from "../../../charts/chart-runtime" +import { cardButton } from "../../../ui/card-button" import { chartSkeleton } from "../../../ui/chart-skeleton" import { descriptionId, titleId } from "../../../ui/dialog" import * as DialogView from "../../../ui/dialog-view" @@ -29,15 +30,7 @@ type H = HtmlBuilder /** `PickerCard`: the thumbnail over its title and description. */ const pickerCard = (h: H, title: string, description: string, onClick: Message, preview: Html): Html => - h.button( - [ - h.Type("button"), - h.OnClick(onClick), - h.Class( - "group ring-1 ring-border hover:ring-foreground/30 bg-background p-4 text-left transition-all flex flex-col gap-3 rounded-md", - ), - ], - [ + cardButton(h, { onClick, className: "flex flex-col gap-3 p-4" }, [ preview, h.div( [h.Class("flex flex-col gap-0.5")], @@ -46,8 +39,7 @@ const pickerCard = (h: H, title: string, description: string, onClick: Message, ...(description === "" ? [] : [h.div([h.Class("text-2xs text-muted-foreground")], [description])]), ], ), - ], - ) + ]) /** `PickerSection`: a heading on the All tab, then a three-column grid of cards. */ const pickerSection = (h: H, title: string, showHeading: boolean, cards: ReadonlyArray): Html => diff --git a/apps/web/src/page/dashboards/widget/view-rail-thresholds.ts b/apps/web/src/page/dashboards/widget/view-rail-thresholds.ts index 6618e98ad8..27f92ebb0f 100644 --- a/apps/web/src/page/dashboards/widget/view-rail-thresholds.ts +++ b/apps/web/src/page/dashboards/widget/view-rail-thresholds.ts @@ -2,6 +2,7 @@ import { cn } from "@maple/ui/lib/utils" import { eyebrowVariants } from "@maple/ui/styles/ui/eyebrow" import { fieldLabelStyles, fieldStyles } from "@maple/ui/styles/ui/field" import type { Html, HtmlBuilder } from "foldkit/html" +import { button } from "../../../ui/button" import { icon } from "../../../ui/icon" import { iconButton } from "../../../ui/icon-button" import { input } from "../../../ui/input" @@ -60,14 +61,12 @@ export const thresholds = (h: H, state: EditorState): Html => ], ), ), - h.button( - [ - h.Type("button"), - h.OnClick(Message.ClickedAddThreshold()), - h.Class( - "h-8 rounded-md border border-dashed text-xs text-muted-foreground transition-colors hover:text-foreground", - ), - ], + button( + h, + { + variant: "dashed", + attributes: [h.OnClick(Message.ClickedAddThreshold())], + }, ["+ Add threshold"], ), ], diff --git a/apps/web/src/page/errors/issue/view-sidebar.ts b/apps/web/src/page/errors/issue/view-sidebar.ts index c0dc67c596..416491b523 100644 --- a/apps/web/src/page/errors/issue/view-sidebar.ts +++ b/apps/web/src/page/errors/issue/view-sidebar.ts @@ -160,18 +160,21 @@ const shortIssueId = (id: string): string => id.replace(/-/g, "").slice(0, 7).to /** `IssueIdCopy`: the short id that copies the full one. TODO(tooltip): its "Copy issue ID" popup. */ const issueIdCopy = (h: H, model: Model, id: string): Html => - h.button( - [ - h.Type("button"), - h.OnClick( - Message.GotIssueIdCopyMessage({ message: CopyButton.Message.ClickedCopy({ text: id }) }), - ), - h.Class( - "group/copy -mr-1.5 inline-flex h-7 items-center gap-1.5 rounded-md px-1.5 font-mono text-xs tabular-nums text-muted-foreground transition-colors hover:bg-surface-strong hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", - ), - h.DataAttribute("base-ui-tooltip-trigger", ""), - h.DataAttribute("slot", "tooltip-trigger"), - ], + button( + h, + { + variant: "ghost", + size: "xs", + slot: "tooltip-trigger", + className: + "group/copy -mr-1.5 px-1.5 font-mono tabular-nums text-muted-foreground hover:text-foreground", + attributes: [ + h.OnClick( + Message.GotIssueIdCopyMessage({ message: CopyButton.Message.ClickedCopy({ text: id }) }), + ), + h.DataAttribute("base-ui-tooltip-trigger", ""), + ], + }, [ shortIssueId(id), CopyButton.copyIndicator(h, { diff --git a/apps/web/src/page/infra-integrations/cloudflare/zone/view-breakdown.ts b/apps/web/src/page/infra-integrations/cloudflare/zone/view-breakdown.ts index 72c8399731..8255a1cd11 100644 --- a/apps/web/src/page/infra-integrations/cloudflare/zone/view-breakdown.ts +++ b/apps/web/src/page/infra-integrations/cloudflare/zone/view-breakdown.ts @@ -7,6 +7,7 @@ import { badgeVariants } from "@maple/ui/styles/ui/badge" import { AsyncData } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { icon } from "../../../../ui/icon" +import { textLink } from "../../../../ui/text-link" import { listFooter } from "../../../../ui/list-footer" import { panel, panelHeader, panelTitle } from "../../../../ui/panel" import { segmentedSelect } from "../../../../ui/segmented-select" @@ -26,8 +27,6 @@ type H = HtmlBuilder const ROW_CLASS = "flex items-center gap-4 border-b border-border-subtle px-4 py-3 last:border-0 hover:bg-surface-sunken" -const MONO_LINK = - "font-mono text-3xs text-muted-foreground underline-offset-2 transition-colors hover:text-foreground hover:underline" const warehouseTimeToMs = (value: string) => new Date(`${value.replace(" ", "T")}Z`).getTime() @@ -250,12 +249,14 @@ const stored = ( ...(collectedFrom === null ? [] : [h.span([], ["collected from ", collectedFrom])]), ...(canSearchLive ? [ - h.button( - [ - h.Type("button"), - h.OnClick(Message.ClickedSearchLive()), - h.Class(cn(MONO_LINK, "ml-auto")), - ], + textLink( + h, + { + tone: "muted", + isMono: true, + className: "ml-auto text-3xs", + onClick: Message.ClickedSearchLive(), + }, ["Search all ", dimension.noun, " in Cloudflare"], ), ] diff --git a/apps/web/src/page/infra-integrations/gcp/list/view.ts b/apps/web/src/page/infra-integrations/gcp/list/view.ts index 71c3979dcb..a88db940df 100644 --- a/apps/web/src/page/infra-integrations/gcp/list/view.ts +++ b/apps/web/src/page/infra-integrations/gcp/list/view.ts @@ -32,6 +32,7 @@ import { AsyncData, Submodel } from "foldkit" import type { Html } from "foldkit/html" import { ResolveThemeColors, type ThemeColors } from "../../../../charts/theme-colors" import { button } from "../../../../ui/button" +import { textLink } from "../../../../ui/text-link" import { errorState } from "../../../../ui/error-state" import { icon } from "../../../../ui/icon" import type * as Menu from "../../../../ui/menu" @@ -230,14 +231,14 @@ const regionChip = (h: H, region: string): Html => { ), ], ), - h.button( - [ - h.Type("button"), - h.OnClick(Message.ClickedClearRegion()), - h.Class( - "font-mono text-3xs text-muted-foreground underline-offset-2 transition-colors hover:text-foreground hover:underline ml-1", - ), - ], + textLink( + h, + { + tone: "muted", + isMono: true, + className: "ml-1 text-3xs", + onClick: Message.ClickedClearRegion(), + }, ["Clear all"], ), ], diff --git a/apps/web/src/page/infra-integrations/kit/filter-chip-rail.ts b/apps/web/src/page/infra-integrations/kit/filter-chip-rail.ts index 2c1fbf0830..7bca44f131 100644 --- a/apps/web/src/page/infra-integrations/kit/filter-chip-rail.ts +++ b/apps/web/src/page/infra-integrations/kit/filter-chip-rail.ts @@ -1,25 +1,15 @@ -import { cn } from "@maple/ui/lib/utils" import type { Html, HtmlBuilder } from "foldkit/html" import { button } from "../../../ui/button" import { icon } from "../../../ui/icon" +import { textLink } from "../../../ui/text-link" -// `FilterChipRail` and `MonoLinkButton`: the active filters as removable chips, six until expanded. +// `FilterChipRail`: the active filters as removable chips, six until expanded. const VISIBLE_LIMIT = 6 const CHIP_CLASS = "inline-flex items-center gap-1 rounded-sm border border-border-subtle bg-background/60 py-0.5 pr-0.5 pl-1.5 font-mono text-3xs text-muted-foreground" -const MONO_LINK = - "font-mono text-3xs text-muted-foreground underline-offset-2 transition-colors hover:text-foreground hover:underline" - -export const monoLinkButton = ( - h: HtmlBuilder, - onClick: M, - children: ReadonlyArray, - className?: string, -): Html => h.button([h.Type("button"), h.OnClick(onClick), h.Class(cn(MONO_LINK, className))], children) - export type FilterChip = Readonly<{ key: string; value: string; label: string }> export const filterChipRail = ( @@ -59,8 +49,20 @@ export const filterChipRail = ( ], ), ), - ...(hidden > 0 ? [monoLinkButton(h, options.onExpand, ["+", String(hidden), " more"])] : []), - monoLinkButton(h, options.onClear, ["Clear all"], "ml-1"), + ...(hidden > 0 + ? [ + textLink( + h, + { tone: "muted", isMono: true, className: "text-3xs", onClick: options.onExpand }, + ["+", String(hidden), " more"], + ), + ] + : []), + textLink( + h, + { tone: "muted", isMono: true, className: "ml-1 text-3xs", onClick: options.onClear }, + ["Clear all"], + ), ], ) } diff --git a/apps/web/src/page/infra-integrations/planetscale/db/view.ts b/apps/web/src/page/infra-integrations/planetscale/db/view.ts index ea366b8b69..facc3200b1 100644 --- a/apps/web/src/page/infra-integrations/planetscale/db/view.ts +++ b/apps/web/src/page/infra-integrations/planetscale/db/view.ts @@ -7,6 +7,7 @@ import { AsyncData, Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { ResolveThemeColors } from "../../../../charts/theme-colors" import { button } from "../../../../ui/button" +import { textLink } from "../../../../ui/text-link" import { empty, emptyContent, @@ -283,14 +284,14 @@ const filterChipRail = (h: H, model: Model): ReadonlyArray => { ], ), ), - h.button( - [ - h.Type("button"), - h.OnClick(Message.ClickedClearFilters()), - h.Class( - "font-mono text-3xs text-muted-foreground underline-offset-2 transition-colors hover:text-foreground hover:underline ml-1", - ), - ], + textLink( + h, + { + tone: "muted", + isMono: true, + className: "ml-1 text-3xs", + onClick: Message.ClickedClearFilters(), + }, ["Clear all"], ), ], diff --git a/apps/web/src/page/integrations/view-hub.ts b/apps/web/src/page/integrations/view-hub.ts index 59e30846d8..0f0fdd737b 100644 --- a/apps/web/src/page/integrations/view-hub.ts +++ b/apps/web/src/page/integrations/view-hub.ts @@ -3,6 +3,7 @@ import { pageDescriptionStyles } from "@maple/ui/styles/ui/page-layout" import type { Html, HtmlBuilder } from "foldkit/html" import type { LoadFailure } from "../../data/load-failure" import { button } from "../../ui/button" +import { cardButton } from "../../ui/card-button" import { errorState } from "../../ui/error-state" import { badge } from "../../ui/display" import { eyebrow } from "../../ui/eyebrow" @@ -156,15 +157,7 @@ const connectedRow = ( export const newBadge = (h: H): Html => badge(h, { variant: "info", size: "sm" }, ["New"]) const availableCard = (h: H, entry: CatalogEntry, cta: AvailableOverview["cta"], onSelect: Select): Html => - h.button( - [ - h.Type("button"), - h.OnClick(onSelect(entry.id)), - h.Class( - "group flex items-center gap-4 rounded-md border border-border-subtle bg-card p-4 text-left outline-none transition-colors hover:border-border hover:bg-surface-sunken focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/40", - ), - ], - [ + cardButton(h, { onClick: onSelect(entry.id), className: "flex items-center gap-4 p-4" }, [ entryPlate(h, entry, { plateClassName: "size-9 rounded-lg", size: 20 }), h.span( [h.Class("flex min-w-0 flex-1 flex-col gap-0.5")], @@ -193,8 +186,7 @@ const availableCard = (h: H, entry: CatalogEntry, cta: AvailableOverview["cta"], ], [cta, " →"], ), - ], - ) + ]) const skeletonRow = (h: H): Html => h.div( diff --git a/apps/web/src/page/kit/message-response.ts b/apps/web/src/page/kit/message-response.ts index c290b7ca18..048e93971b 100644 --- a/apps/web/src/page/kit/message-response.ts +++ b/apps/web/src/page/kit/message-response.ts @@ -2,6 +2,7 @@ import * as Markdown from "@foldkit/markdown" import { cn } from "@maple/ui/lib/utils" import { Option } from "effect" import type { Html, HtmlBuilder } from "foldkit/html" +import { iconButton } from "../../ui/icon-button" // Lightweight markdown over Streamdown: // laid out with Streamdown's element classes. The parser loads on first use, never at startup. @@ -66,17 +67,19 @@ const codeAction = ( d: string, onClick: M | undefined, ): Html => - h.button( - [ - h.AriaLabel(label), - h.Class( - "cursor-pointer p-1 text-muted-foreground transition-all hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50", - ), - h.DataAttribute("streamdown", slot), - h.Title(label), - h.Type("button"), - ...(onClick === undefined ? [] : [h.OnClick(onClick)]), - ], + iconButton( + h, + { + label, + size: "icon-xs", + hasTooltip: false, + className: "text-muted-foreground hover:text-foreground", + attributes: [ + h.DataAttribute("streamdown", slot), + h.Title(label), + ...(onClick === undefined ? [] : [h.OnClick(onClick)]), + ], + }, [glyph(h, d)], ) diff --git a/apps/web/src/page/metrics/browse/view-grid.ts b/apps/web/src/page/metrics/browse/view-grid.ts index 2c9b26b931..5c1e49ece0 100644 --- a/apps/web/src/page/metrics/browse/view-grid.ts +++ b/apps/web/src/page/metrics/browse/view-grid.ts @@ -5,6 +5,7 @@ import type { Html, HtmlBuilder } from "foldkit/html" import * as Chart from "../../../charts/chart" import * as Sparkline from "../../../charts/sparkline" import { resolveColor, type ThemeColors } from "../../../charts/theme-colors" +import { cardButton } from "../../../ui/card-button" import { badge } from "../../../ui/display" import { listFooter } from "../../../ui/list-footer" import { skeletonList } from "../../../ui/skeleton" @@ -119,17 +120,16 @@ const previewCard = (h: H, theme: ThemeColors, entry: PreviewEntry, sparklines: : sparklines.failedTypes.has(entry.metricType) ? "Preview unavailable" : "Not enough datapoints for a preview" - return h.button( - [ - h.Key(entryKey(entry.metricName, entry.metricType)), - h.Type("button"), - h.Class( - "group flex flex-col gap-2 rounded-md border bg-card p-3 text-left transition-colors hover:border-primary/40 hover:bg-accent/50", - ), - h.OnClick( - Message.ClickedMetricCard({ metricName: entry.metricName, metricType: entry.metricType }), - ), - ], + return cardButton( + h, + { + onClick: Message.ClickedMetricCard({ + metricName: entry.metricName, + metricType: entry.metricType, + }), + className: "flex flex-col gap-2 p-3", + attributes: [h.Key(entryKey(entry.metricName, entry.metricType))], + }, [ h.div( [h.Class("flex w-full items-start justify-between gap-2")], diff --git a/apps/web/src/page/onboarding/create-org-view.ts b/apps/web/src/page/onboarding/create-org-view.ts index 1e7a7adc3b..1328d00ff0 100644 --- a/apps/web/src/page/onboarding/create-org-view.ts +++ b/apps/web/src/page/onboarding/create-org-view.ts @@ -6,6 +6,7 @@ import { hasMultipleRegions } from "../../lib/region" import type { ClerkOrganization, UserInvitation } from "../../platform/clerk" import { switchTargetOf } from "../../shell/clerk-org-switcher" import { button } from "../../ui/button" +import { textLink } from "../../ui/text-link" import { icon, type IconName } from "../../ui/icon" import { input } from "../../ui/input" import { Message, type Model, MountDitherField } from "./create-org" @@ -41,12 +42,13 @@ const header = (h: H, email: string | null): Html => [h.Class("flex min-w-0 items-center gap-3 text-xs")], [ ...(email === null ? [] : [h.span([h.Class("truncate text-muted-foreground")], [email])]), - h.button( - [ - h.Type("button"), - h.Class("shrink-0 text-foreground underline-offset-4 hover:underline"), - h.OnClick(Message.ClickedSignOut()), - ], + textLink( + h, + { + tone: "muted", + className: "shrink-0 text-foreground underline-offset-4", + onClick: Message.ClickedSignOut(), + }, ["Sign out"], ), ], diff --git a/apps/web/src/page/security/kit/add-target-view.ts b/apps/web/src/page/security/kit/add-target-view.ts index 45bc45f75d..27f1611ac1 100644 --- a/apps/web/src/page/security/kit/add-target-view.ts +++ b/apps/web/src/page/security/kit/add-target-view.ts @@ -10,6 +10,7 @@ import * as Checkbox from "../../../ui/checkbox" import * as Field from "../../../ui/field" import { formDialog } from "../../../ui/form-dialog" import { icon } from "../../../ui/icon" +import { iconButton } from "../../../ui/icon-button" import { inputGroup, inputGroupAddon, inputGroupInput } from "../../../ui/input-group" import * as RadioGroup from "../../../ui/radio-group" import * as SelectView from "../../../ui/select-view" @@ -230,13 +231,14 @@ const scopeChip = (h: H, path: string): Html => [ icon(h, "FolderIcon", { size: 12, className: "text-muted-foreground" }), h.span([h.Class("truncate")], [path]), - h.button( - [ - h.Type("button"), - h.AriaLabel(`Remove ${path}`), - h.Class("rounded-xs p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground"), - h.OnClick(Message.RemovedScopePath({ path })), - ], + iconButton( + h, + { + label: `Remove ${path}`, + size: "icon-2xs", + className: "text-muted-foreground hover:text-foreground", + attributes: [h.OnClick(Message.RemovedScopePath({ path }))], + }, [icon(h, "XmarkIcon", { size: 12 })], ), ], diff --git a/apps/web/src/page/security/run/view-panels.ts b/apps/web/src/page/security/run/view-panels.ts index 25c7c20a4d..c99676dbe9 100644 --- a/apps/web/src/page/security/run/view-panels.ts +++ b/apps/web/src/page/security/run/view-panels.ts @@ -3,6 +3,7 @@ import { cn } from "@maple/ui/lib/utils" import { AsyncData } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { badge } from "../../../ui/display" +import { textLink } from "../../../ui/text-link" import { filterChip } from "../../../ui/filter-chip" import { errorState } from "../../../ui/error-state" import { panel, panelHeader } from "../../../ui/panel" @@ -162,16 +163,9 @@ const ledgerToggle = (h: H, label: string): Html => h.div( [h.Class("border-t px-4 py-2.5")], [ - h.button( - [ - h.Type("button"), - h.Class( - "text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground", - ), - h.OnClick(Message.ToggledLedger()), - ], - [label], - ), + textLink(h, { tone: "muted", className: "text-xs underline", onClick: Message.ToggledLedger() }, [ + label, + ]), ], ) @@ -323,15 +317,14 @@ export const eventsPanel = ( h.div( [h.Class("border-t px-4 py-2.5")], [ - h.button( - [ - h.Type("button"), - h.Disabled(model.isLoadingOlder), - h.Class( - "text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground", - ), - h.OnClick(Message.ClickedLoadOlder()), - ], + textLink( + h, + { + tone: "muted", + className: "text-xs underline", + onClick: Message.ClickedLoadOlder(), + isDisabled: model.isLoadingOlder, + }, [model.isLoadingOlder ? "Loading older events…" : "Load older events"], ), ], diff --git a/apps/web/src/page/security/tabs/view-overview.ts b/apps/web/src/page/security/tabs/view-overview.ts index 153f30999a..35178866dd 100644 --- a/apps/web/src/page/security/tabs/view-overview.ts +++ b/apps/web/src/page/security/tabs/view-overview.ts @@ -6,6 +6,7 @@ import { cn } from "@maple/ui/lib/utils" import type { Html, HtmlBuilder } from "foldkit/html" import { AppRoute, hrefOf } from "../../../route" import { button } from "../../../ui/button" +import { textLink } from "../../../ui/text-link" import { errorState } from "../../../ui/error-state" import { icon } from "../../../ui/icon" import { meter } from "../../../ui/meter" @@ -108,16 +109,9 @@ const spendTile = (h: H, model: Model): Html => { value: "Unavailable", valueClassName: "text-base", compact: true, - action: h.button( - [ - h.Type("button"), - h.OnClick(Message.ClickedRetry()), - h.Class( - "text-2xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline", - ), - ], - ["Retry"], - ), + action: textLink(h, { tone: "muted", className: "text-2xs", onClick: Message.ClickedRetry() }, [ + "Retry", + ]), subline: "Couldn't load the spend limit", }) if (settings === null) { @@ -138,14 +132,9 @@ const spendTile = (h: H, model: Model): Html => { tone: isReached ? "warn" : undefined, compact: true, action: model.isAdmin - ? h.button( - [ - h.Type("button"), - h.OnClick(Message.ClickedSpendAndModels()), - h.Class( - "text-2xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline", - ), - ], + ? textLink( + h, + { tone: "muted", className: "text-2xs", onClick: Message.ClickedSpendAndModels() }, ["Edit limit"], ) : undefined, @@ -555,11 +544,13 @@ const topFindingsPanel = (h: H, model: Model, targets: ReadonlyArray { ...visible.map((target) => stripChip(h, target, onViewAll)), ...(hiddenCount > 0 ? [ - h.button( - [ - h.Type("button"), - h.OnClick(onViewAll), - h.Class( - "inline-flex items-center rounded-md border border-dashed px-2 py-0.5 text-xs text-muted-foreground hover:bg-surface-sunken focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", - ), - ], - [`+${hiddenCount} more`], - ), + button(h, { variant: "dashed", size: "xs", attributes: [h.OnClick(onViewAll)] }, [ + `+${hiddenCount} more`, + ]), ] : []), viewAllButton(h, onViewAll), diff --git a/apps/web/src/page/services/detail/view-panels.ts b/apps/web/src/page/services/detail/view-panels.ts index 58b22af559..4113523a31 100644 --- a/apps/web/src/page/services/detail/view-panels.ts +++ b/apps/web/src/page/services/detail/view-panels.ts @@ -6,6 +6,7 @@ import * as Chart from "../../../charts/chart" import * as Sparkline from "../../../charts/sparkline" import { resolveColor, type ThemeColors } from "../../../charts/theme-colors" import { backdropBar } from "../../../ui/meter" +import { textLink } from "../../../ui/text-link" import { panel, panelBody, panelHeader } from "../../../ui/panel" import { truncatedText } from "../../../ui/truncated-text" import { errorRateValue, latencyValue } from "../../../ui/values" @@ -32,16 +33,7 @@ export const sectionCard = ( /** `ViewAllButton`: jumps from an Overview card to the full tab. */ export const viewAllButton = (h: H, onClick: Message): Html => - h.button( - [ - h.Type("button"), - h.OnClick(onClick), - h.Class( - "text-xs text-primary hover:underline focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", - ), - ], - ["View all →"], - ) + textLink(h, { className: "text-xs", onClick }, ["View all →"]) const PANEL_LIMIT = 5 const SPARKLINE_MODEL = Chart.init() diff --git a/apps/web/src/page/settings/billing/view-cycle.ts b/apps/web/src/page/settings/billing/view-cycle.ts index e0d631ae58..f2ca59487c 100644 --- a/apps/web/src/page/settings/billing/view-cycle.ts +++ b/apps/web/src/page/settings/billing/view-cycle.ts @@ -23,6 +23,7 @@ import { bucketTooltipBody } from "../../../charts/bucket-tooltip" import { plotFrame } from "../../../charts/plot-frame" import { resolveColor, ResolveThemeColors, type ThemeColors } from "../../../charts/theme-colors" import { chartEmpty, chartLoading } from "../../../ui/chart-state" +import { textLink } from "../../../ui/text-link" import { badge } from "../../../ui/display" import { errorState } from "../../../ui/error-state" import { eyebrow } from "../../../ui/eyebrow" @@ -403,17 +404,12 @@ export const cyclePanel = (h: H, model: Model, inputs: CyclePanelInputs): Html = : `${inputs.uncappedCount} features have no cap`, ...(model.isAdmin ? [ - h.button( - [ - h.Class( - "font-medium text-primary hover:underline disabled:pointer-events-none disabled:opacity-50", - ), - ...(model.isSavingCap - ? [h.Disabled(true)] - : []), - h.Type("button"), - h.OnClick(Message.ClickedSetCaps()), - ], + textLink( + h, + { + onClick: Message.ClickedSetCaps(), + isDisabled: model.isSavingCap, + }, ["Set caps"], ), ] diff --git a/apps/web/src/page/sources/new/view-picker.ts b/apps/web/src/page/sources/new/view-picker.ts index 851530941f..9a87345865 100644 --- a/apps/web/src/page/sources/new/view-picker.ts +++ b/apps/web/src/page/sources/new/view-picker.ts @@ -1,5 +1,5 @@ import type { PushSourcePlatform } from "@maple/domain/primitives" -import { cn } from "@maple/ui/lib/utils" +import { cardButton } from "../../../ui/card-button" import type { Html, HtmlBuilder } from "foldkit/html" import { eyebrow } from "../../../ui/eyebrow" import { icon } from "../../../ui/icon" @@ -41,18 +41,9 @@ const card = ( onSelect: Message, badge: Html | null, ): Html => - h.button( - [ - h.Key(key), - h.Type("button"), - h.OnClick(onSelect), - h.Class( - cn( - "group bg-card flex items-center gap-3 rounded-md border px-3 py-3 text-left transition-colors", - "hover:bg-surface-sunken focus-visible:ring-ring outline-none focus-visible:ring-2", - ), - ), - ], + cardButton( + h, + { onClick: onSelect, className: "flex items-center gap-3 px-3 py-3", attributes: [h.Key(key)] }, [ leading, h.span( diff --git a/apps/web/src/page/traces/detail/timeline/view-row.ts b/apps/web/src/page/traces/detail/timeline/view-row.ts index 33df86ec3f..2adc152f5b 100644 --- a/apps/web/src/page/traces/detail/timeline/view-row.ts +++ b/apps/web/src/page/traces/detail/timeline/view-row.ts @@ -7,6 +7,7 @@ import { cn } from "@maple/ui/lib/utils" import type { Html, HtmlBuilder } from "foldkit/html" import { describeSpan } from "../../../../service-map/trace-flow/span-category" import { icon } from "../../../../ui/icon" +import { iconButton } from "../../../../ui/icon-button" import type { Bar } from "./layout" import { Message } from "./message" @@ -118,19 +119,21 @@ const labelCell = (h: H, bar: Bar, state: RowState, durationLabel: string): Html ] : []), bar.hasChildren - ? h.button( - [ - h.AriaLabel(verb), - attr( - h, - "flex items-center justify-center size-4 shrink-0 text-muted-foreground hover:text-foreground", - ), - h.Tabindex(-1), - h.Title(`${verb} — ⌥ for the whole subtree`), - h.Type("button"), - h.OnClick(Message.ClickedToggleCollapse({ spanId }), { propagation: "Stop" }), - h.DataAttribute("toggle-span", spanId), - ], + ? iconButton( + h, + { + label: verb, + size: "icon-2xs", + // The native title carries the shortcut hint, so no tooltip trigger per row. + hasTooltip: false, + className: "text-muted-foreground hover:text-foreground", + attributes: [ + h.Tabindex(-1), + h.Title(`${verb} — ⌥ for the whole subtree`), + h.OnClick(Message.ClickedToggleCollapse({ spanId }), { propagation: "Stop" }), + h.DataAttribute("toggle-span", spanId), + ], + }, [icon(h, bar.isCollapsed ? "ChevronRightIcon" : "ChevronDownIcon", { size: 12 })], ) : h.span([attr(h, "inline-block size-4 shrink-0")], []), From 6ee56e2fd3fc53cea2e0fa584a8f00e8a370a0ed Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 30 Oct 2026 21:43:25 +0100 Subject: [PATCH 3/7] fix(web): review fixes for the kit buttons - segmentedSelect: every enabled option is a tab stop. Only the selected one was, with no arrow keys, so keyboard users could not change the value (34 callers, including the uptime kind picker). - textLink: href and onClick are exclusive in the type, so isDisabled on a link no longer compiles and gets ignored. - dashed buttons don't shrink on press (full-width rows). - The timeline collapse chevron and scope-path remove button drop the 44px coarse-pointer hit area that covered their neighbours. - The footprint '+N more' chip is sized like the chips beside it. --- apps/web/src/page/security/kit/add-target-view.ts | 2 +- apps/web/src/page/services/detail/view-footprint.ts | 11 ++++++++++- apps/web/src/page/traces/detail/timeline/view-row.ts | 3 ++- apps/web/src/ui/segmented-select.ts | 3 ++- apps/web/src/ui/text-link.ts | 7 +++++-- packages/ui/src/components/ui/button.styles.ts | 2 +- 6 files changed, 21 insertions(+), 7 deletions(-) diff --git a/apps/web/src/page/security/kit/add-target-view.ts b/apps/web/src/page/security/kit/add-target-view.ts index 27f1611ac1..a59e725369 100644 --- a/apps/web/src/page/security/kit/add-target-view.ts +++ b/apps/web/src/page/security/kit/add-target-view.ts @@ -236,7 +236,7 @@ const scopeChip = (h: H, path: string): Html => { label: `Remove ${path}`, size: "icon-2xs", - className: "text-muted-foreground hover:text-foreground", + className: "text-muted-foreground hover:text-foreground pointer-coarse:after:hidden", attributes: [h.OnClick(Message.RemovedScopePath({ path }))], }, [icon(h, "XmarkIcon", { size: 12 })], diff --git a/apps/web/src/page/services/detail/view-footprint.ts b/apps/web/src/page/services/detail/view-footprint.ts index d89a3ea27e..2420d7f859 100644 --- a/apps/web/src/page/services/detail/view-footprint.ts +++ b/apps/web/src/page/services/detail/view-footprint.ts @@ -277,7 +277,16 @@ export const dependencyStrip = (h: H, model: Model): Html => { ...visible.map((target) => stripChip(h, target, onViewAll)), ...(hiddenCount > 0 ? [ - button(h, { variant: "dashed", size: "xs", attributes: [h.OnClick(onViewAll)] }, [ + button( + h, + { + variant: "dashed", + size: "xs", + // Sized like the chips it follows, not like a toolbar button. + className: "h-auto py-0.5 text-xs font-normal sm:h-auto", + attributes: [h.OnClick(onViewAll)], + }, + [ `+${hiddenCount} more`, ]), ] diff --git a/apps/web/src/page/traces/detail/timeline/view-row.ts b/apps/web/src/page/traces/detail/timeline/view-row.ts index 2adc152f5b..411808db41 100644 --- a/apps/web/src/page/traces/detail/timeline/view-row.ts +++ b/apps/web/src/page/traces/detail/timeline/view-row.ts @@ -126,7 +126,8 @@ const labelCell = (h: H, bar: Bar, state: RowState, durationLabel: string): Html size: "icon-2xs", // The native title carries the shortcut hint, so no tooltip trigger per row. hasTooltip: false, - className: "text-muted-foreground hover:text-foreground", + // No coarse-pointer hit area: it would cover the span label beside it. + className: "text-muted-foreground hover:text-foreground pointer-coarse:after:hidden", attributes: [ h.Tabindex(-1), h.Title(`${verb} — ⌥ for the whole subtree`), diff --git a/apps/web/src/ui/segmented-select.ts b/apps/web/src/ui/segmented-select.ts index ab4d5a81dd..f04ec11f24 100644 --- a/apps/web/src/ui/segmented-select.ts +++ b/apps/web/src/ui/segmented-select.ts @@ -168,7 +168,8 @@ export const segmentedSelect = ( }), ), slot: "toggle", - tabIndex: isSelected ? 0 : -1, + // Stateless, so no arrow-key roving: every enabled option is its own tab stop. + tabIndex: (option.isDisabled ?? false) ? -1 : 0, attributes: [ h.AriaDisabled(option.isDisabled ?? false), h.AriaLabel(option.ariaLabel ?? option.label), diff --git a/apps/web/src/ui/text-link.ts b/apps/web/src/ui/text-link.ts index 3e509f0bd7..b91fd4c32c 100644 --- a/apps/web/src/ui/text-link.ts +++ b/apps/web/src/ui/text-link.ts @@ -22,7 +22,10 @@ export type TextLinkOptions = Readonly< isMono?: boolean className?: string attributes?: ReadonlyArray | ChildAttribute> - } & ({ href: string } | { onClick: M; isDisabled?: boolean }) + } & ( + | { href: string; onClick?: never; isDisabled?: never } + | { onClick: M; isDisabled?: boolean; href?: never } + ) > export const textLinkClass = (tone: TextLinkTone = "primary", isMono = false, className?: string): string => @@ -35,7 +38,7 @@ export const textLink = ( ): Html => { const className = h.Class(textLinkClass(options.tone, options.isMono, options.className)) const extra = options.attributes ?? [] - if ("href" in options) return h.a([className, h.Href(options.href), ...extra], children) + if (options.href !== undefined) return h.a([className, h.Href(options.href), ...extra], children) return h.button( [ h.Type("button"), diff --git a/packages/ui/src/components/ui/button.styles.ts b/packages/ui/src/components/ui/button.styles.ts index 19ff640a13..7a3316a1ec 100644 --- a/packages/ui/src/components/ui/button.styles.ts +++ b/packages/ui/src/components/ui/button.styles.ts @@ -32,7 +32,7 @@ export const buttonVariants = cva( "destructive-outline": "border-input bg-popover not-dark:bg-clip-padding text-destructive-foreground shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] hover:border-destructive/32 hover:bg-destructive/4 data-pressed:border-destructive/32 data-pressed:bg-destructive/4 *:data-[slot=button-loading-indicator]:text-foreground dark:bg-input/32 dark:not-disabled:before:shadow-[0_-1px_--theme(--color-white/2%)] dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] [:disabled,:active,[data-pressed]]:shadow-none", // Add-a-row affordances ("Add threshold", "New dashboard"): an outline that reads as an empty slot. - dashed: "border-dashed border-input bg-transparent text-muted-foreground hover:bg-surface-sunken hover:text-foreground data-pressed:bg-surface-sunken *:data-[slot=button-loading-indicator]:text-foreground", + dashed: "active:scale-100! border-dashed border-input bg-transparent text-muted-foreground hover:bg-surface-sunken hover:text-foreground data-pressed:bg-surface-sunken *:data-[slot=button-loading-indicator]:text-foreground", ghost: "border-transparent text-foreground hover:bg-accent data-pressed:bg-accent *:data-[slot=button-loading-indicator]:text-foreground", link: "border-transparent text-primary underline-offset-4 active:scale-100! hover:underline data-pressed:underline *:data-[slot=button-loading-indicator]:text-foreground", outline: From f00a79bf3394fae1b9aafeda434d742b4f7d1ed5 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 30 Oct 2026 21:52:34 +0100 Subject: [PATCH 4/7] fix(web): textLink tones keep their literal types (satisfies) --- apps/web/src/ui/text-link.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/ui/text-link.ts b/apps/web/src/ui/text-link.ts index b91fd4c32c..e0e2455887 100644 --- a/apps/web/src/ui/text-link.ts +++ b/apps/web/src/ui/text-link.ts @@ -10,10 +10,10 @@ export type TextLinkTone = "primary" | "muted" const BASE = "inline-flex cursor-pointer items-center gap-1 underline-offset-2 transition-colors hover:underline focus-visible:rounded-xs focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" -const TONE: Readonly> = { +const TONE = { primary: "font-medium text-primary", muted: "text-muted-foreground hover:text-foreground", -} +} as const satisfies Record export type TextLinkOptions = Readonly< { From f97b889aa22096d178a630e27de21224040bc0e0 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 30 Oct 2026 21:57:10 +0100 Subject: [PATCH 5/7] test(web): segmentedSelect keeps every enabled option tabbable --- .../web/src/ui/segmented-select.scene.test.ts | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 apps/web/src/ui/segmented-select.scene.test.ts diff --git a/apps/web/src/ui/segmented-select.scene.test.ts b/apps/web/src/ui/segmented-select.scene.test.ts new file mode 100644 index 0000000000..f842e83b69 --- /dev/null +++ b/apps/web/src/ui/segmented-select.scene.test.ts @@ -0,0 +1,42 @@ +import type { Html, HtmlBuilder } from "foldkit/html" +import { click, expect, given, role, scene } from "foldkit/scene" +import { describe, test } from "vitest" +import { segmentedSelect } from "./segmented-select" + +type Kind = "http" | "tcp" | "dns" +type Model = Readonly<{ kind: Kind }> +type Message = Readonly<{ _tag: "Picked"; kind: Kind }> + +const picked = (kind: Kind): Message => ({ _tag: "Picked", kind }) + +const update = (_model: Model, message: Message) => ({ model: { kind: message.kind }, commands: [] }) + +const view = (model: Model, h: HtmlBuilder): Html => + segmentedSelect(h, { + ariaLabel: "Monitor type", + value: model.kind, + onChange: picked, + options: [ + { value: "http", label: "HTTP" }, + { value: "tcp", label: "TCP" }, + { value: "dns", label: "DNS", isDisabled: true }, + ], + }) + +const option = (name: string) => role("button", { name }) + +describe("segmented select", () => { + test("every enabled option is a tab stop, so the keyboard can reach and pick each one", () => { + scene( + { update, view }, + given({ kind: "http" }), + expect(option("HTTP")).toHaveAttr("tabIndex", "0"), + expect(option("TCP")).toHaveAttr("tabIndex", "0"), + expect(option("DNS")).toHaveAttr("tabIndex", "-1"), + click(option("TCP")), + expect(option("TCP")).toHaveAttr("aria-pressed", "true"), + expect(option("HTTP")).toHaveAttr("aria-pressed", "false"), + expect(option("HTTP")).toHaveAttr("tabIndex", "0"), + ) + }) +}) From 42afb11fe1b2e5e0131d856e2362f03844729388 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 30 Oct 2026 22:17:45 +0100 Subject: [PATCH 6/7] fix(web): segmented select pill shows in light mode The selected pill is -z-10 inside a button with no stacking context, so it painted behind the track's tint and only the bolder label marked the selection in light mode (dark mode's pressed styles hid it). isolate keeps it above the track. --- apps/web/src/ui/segmented-select.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/ui/segmented-select.ts b/apps/web/src/ui/segmented-select.ts index f04ec11f24..7cc9186203 100644 --- a/apps/web/src/ui/segmented-select.ts +++ b/apps/web/src/ui/segmented-select.ts @@ -160,7 +160,8 @@ export const segmentedSelect = ( size: groupSize, variant: "segment", className: cn( - "relative", + // `isolate` keeps the -z-10 pill above the track's tint instead of behind it. + "relative isolate", segmentItemHeight[size], size === "xs" && XS_ITEM, "data-pressed:border-transparent data-pressed:bg-transparent data-pressed:shadow-none dark:data-pressed:bg-transparent", From 5da7b35d3d3d21d348e0723843848a4cc994797e Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 30 Oct 2026 22:35:46 +0100 Subject: [PATCH 7/7] fix(web): uptime kind picker wraps on narrow screens like the old track --- apps/web/src/page/alerts/uptime-form/view.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/page/alerts/uptime-form/view.ts b/apps/web/src/page/alerts/uptime-form/view.ts index 7b2b19306f..c94fac2ef5 100644 --- a/apps/web/src/page/alerts/uptime-form/view.ts +++ b/apps/web/src/page/alerts/uptime-form/view.ts @@ -64,7 +64,8 @@ const kindPicker = (h: H, model: Model): Html => segmentedSelect(h, { ariaLabel: "Monitor type", size: "sm", - className: "w-fit", + // Four labelled kinds outgrow a phone column, so the track wraps like the old picker. + className: "w-fit flex-wrap", value: model.form.kind, onChange: (kind) => Message.PickedKind({ kind }), // Editing keeps the kind: the others stay visible but can't be picked.