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/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/alerts/uptime-form/view.ts b/apps/web/src/page/alerts/uptime-form/view.ts index 57a25180f9..c94fac2ef5 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,21 @@ 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", + // 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. + 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/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..a59e725369 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 pointer-coarse:after:hidden", + 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( + 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)], + }, [ - 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`], - ), + `+${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..411808db41 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,22 @@ 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, + // 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`), + 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")], []), 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/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"), + ) + }) +}) diff --git a/apps/web/src/ui/segmented-select.ts b/apps/web/src/ui/segmented-select.ts index ab4d5a81dd..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", @@ -168,7 +169,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 new file mode 100644 index 0000000000..e0e2455887 --- /dev/null +++ b/apps/web/src/ui/text-link.ts @@ -0,0 +1,52 @@ +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 = { + primary: "font-medium text-primary", + muted: "text-muted-foreground hover:text-foreground", +} as const satisfies Record + +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?: never; isDisabled?: never } + | { onClick: M; isDisabled?: boolean; href?: never } + ) +> + +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 (options.href !== undefined) 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..7a3316a1ec 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: "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: