From 16f3e37738231a801480db9cd47aff37f59f3810 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 30 Oct 2026 18:48:38 +0100 Subject: [PATCH 1/2] refactor(web): one route-tab strip, row link, sheet header and page title linkTabStrip gains optional icon, count and onClick, and the alerts hub, alert rule, investigation and infra strips render through it. ui/table exports rowLink for stretched-link row navigation, replacing four copies of the overlay class. sheetDetailHeader gains segmented kinds and an adornment slot; the pod peek, review sheet and investigation action sheet use it. Every page h1 now goes through pageTitle. --- .../page/agent-sessions/list/view-table.ts | 8 +- apps/web/src/page/alerts/hub/view-inbox.ts | 46 ++-- apps/web/src/page/alerts/hub/view-rules.ts | 23 +- apps/web/src/page/alerts/hub/view-uptime.ts | 18 +- apps/web/src/page/alerts/hub/view.ts | 44 ++-- apps/web/src/page/alerts/rule/view.ts | 27 +-- apps/web/src/page/alerts/shared/links.ts | 8 +- .../src/page/anomalies/detail/view-body.ts | 203 +++++++++++++----- .../page/code-review/kit/review-sheet-view.ts | 107 ++++----- apps/web/src/page/dashboards/list/view.ts | 5 +- .../web/src/page/dashboards/templates/view.ts | 5 +- apps/web/src/page/dashboards/view/view.ts | 41 ++-- .../page/infra-integrations/aws/list/view.ts | 4 +- .../cloudflare/list/view.ts | 4 +- .../page/infra-integrations/gcp/list/view.ts | 4 +- apps/web/src/page/infra/kit/page-parts.ts | 68 +++--- apps/web/src/page/infra/kit/view-tabs.ts | 48 ++--- .../page/infra/kubernetes/peek/view-header.ts | 93 ++++---- apps/web/src/page/integrations/view-header.ts | 5 +- apps/web/src/page/integrations/view-hub.ts | 5 +- .../detail/view-action-sheet.ts | 136 +++++++----- .../src/page/investigations/detail/view.ts | 43 ++-- apps/web/src/page/investigations/list/view.ts | 14 +- apps/web/src/page/kit/dashboard-page.ts | 38 ++-- apps/web/src/page/kit/sheet-detail-header.ts | 26 ++- apps/web/src/page/kit/underline-tabs.ts | 44 +++- .../web/src/page/metrics/browse/view-table.ts | 27 ++- apps/web/src/page/security/kit/layout.ts | 5 +- apps/web/src/page/sources/new/view.ts | 10 +- apps/web/src/page/traces/list/view-row.ts | 17 +- apps/web/src/ui/page-layout.ts | 10 +- apps/web/src/ui/table.ts | 27 +++ packages/ui/src/components/ui/table.styles.ts | 7 + 33 files changed, 612 insertions(+), 558 deletions(-) diff --git a/apps/web/src/page/agent-sessions/list/view-table.ts b/apps/web/src/page/agent-sessions/list/view-table.ts index 17c781e916..751adc1e53 100644 --- a/apps/web/src/page/agent-sessions/list/view-table.ts +++ b/apps/web/src/page/agent-sessions/list/view-table.ts @@ -8,7 +8,7 @@ import type { Attribute, Html, HtmlBuilder } from "foldkit/html" import { AppRoute, hrefOf } from "../../../route" import { icon, type IconName } from "../../../ui/icon" import { loadingMoreRow } from "../../../ui/list-footer" -import { table, tableBody, tableCell, tableHead, tableHeader, tableRow } from "../../../ui/table" +import { rowLink, table, tableBody, tableCell, tableHead, tableHeader, tableRow } from "../../../ui/table" import { tableSkeleton } from "../../../ui/table-skeleton" import { servicePills } from "../../../ui/trace-badges" import type { DetectedModel } from "../kit/detected-models" @@ -127,9 +127,7 @@ export const listSkeleton = (h: H): Html => })), }) -// A row link stretched over its row, and the lift for tooltips above it. -const ROW_STRETCHED_LINK_CLASS = - "after:absolute after:inset-0 after:content-[''] focus-visible:outline-none focus-visible:after:ring-1 focus-visible:after:ring-ring focus-visible:after:ring-inset" +// The lift for tooltips above the row link. const ROW_LIFT_TOOLTIPS = "[&_[data-slot=tooltip-trigger]]:relative [&_[data-slot=tooltip-trigger]]:z-10" const triggerAttributes = (h: H): ReadonlyArray> => [ @@ -234,7 +232,7 @@ const sessionCell = (h: H, row: DisplayRow, context: RowContext): Html => { return h.div( [h.Class("min-w-0")], [ - h.a([h.AriaHidden(true), h.Class(ROW_STRETCHED_LINK_CLASS), h.Href(href), h.Tabindex(-1)], []), + rowLink(h, { href, attributes: [h.AriaHidden(true), h.Tabindex(-1)] }), h.div( [h.Class("flex min-w-0 items-center gap-2")], [ diff --git a/apps/web/src/page/alerts/hub/view-inbox.ts b/apps/web/src/page/alerts/hub/view-inbox.ts index 00c0fbbad5..150b59d815 100644 --- a/apps/web/src/page/alerts/hub/view-inbox.ts +++ b/apps/web/src/page/alerts/hub/view-inbox.ts @@ -24,8 +24,9 @@ import type { Html, HtmlBuilder } from "foldkit/html" import { badge } from "../../../ui/display" import { eyebrow } from "../../../ui/eyebrow" import { icon } from "../../../ui/icon" +import { rowLink } from "../../../ui/table" import { severityBadge } from "../shared/badges" -import { incidentHref, ROW_STRETCHED_LINK_CLASS, ruleHref } from "../shared/links" +import { incidentHref, ruleHref } from "../shared/links" import { sortIncidents } from "../shared/sort" import { isoMs, msIso } from "../shared/time" @@ -179,12 +180,13 @@ const openIncidentRow = (h: HtmlBuilder, incident: AlertIncidentDocument, "Held", ]) : severityBadge(h, incident.severity), - h.a( - [ - h.Class(cn("min-w-0 truncate font-medium", ROW_STRETCHED_LINK_CLASS)), - h.Href(incidentHref(incident.id)), - h.Title(incidentTitle(incident)), - ], + rowLink( + h, + { + href: incidentHref(incident.id), + className: "min-w-0 truncate font-medium", + attributes: [h.Title(incidentTitle(incident))], + }, [isHeld ? `${incident.ruleName}: waiting on data` : incidentTitle(incident)], ), ], @@ -396,16 +398,13 @@ const blindSpotRow = (h: HtmlBuilder, spot: BlindSpot): Html => { ), ], ), - h.a( - [ - h.Class( - cn( - "inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-foreground group-hover:bg-accent", - ROW_STRETCHED_LINK_CLASS, - ), - ), - h.Href(ruleHref(spot.rule.id)), - ], + rowLink( + h, + { + href: ruleHref(spot.rule.id), + className: + "inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-foreground group-hover:bg-accent", + }, [meta.action, icon(h, "ArrowRightIcon", { className: "size-3 text-muted-foreground" })], ), ], @@ -475,12 +474,13 @@ const headroomRow = (h: HtmlBuilder, row: RuleHeadroom, now: number): Html h.div( [h.Class("min-w-0")], [ - h.a( - [ - h.Class(cn("block truncate text-sm", ROW_STRETCHED_LINK_CLASS)), - h.Href(ruleHref(rule.id)), - h.Title(rule.name), - ], + rowLink( + h, + { + href: ruleHref(rule.id), + className: "block truncate text-sm", + attributes: [h.Title(rule.name)], + }, [rule.name], ), ...(row.groupKey !== null diff --git a/apps/web/src/page/alerts/hub/view-rules.ts b/apps/web/src/page/alerts/hub/view-rules.ts index d51827e591..3543dad59a 100644 --- a/apps/web/src/page/alerts/hub/view-rules.ts +++ b/apps/web/src/page/alerts/hub/view-rules.ts @@ -22,8 +22,9 @@ import * as Menu from "../../../ui/menu" import type { MenuEntry } from "../../../ui/menu-view" import { rowActionsMenu } from "../../../ui/row-actions-menu" import { statusDot } from "../../../ui/status-dot" +import { ROW_LINK_LIFT, rowLink } from "../../../ui/table" import { filteredEmpty } from "../../kit/signal-empty-state" -import { ROW_LINK_LIFT, ROW_STRETCHED_LINK_CLASS, ruleHref } from "../shared/links" +import { ruleHref } from "../shared/links" import { isoMs } from "../shared/time" import { Message, type RuleBucket } from "./message" import type { Model } from "./model" @@ -307,18 +308,16 @@ const ruleRow = (h: H, model: Model, row: RowInputs): Html => { h.div( [h.Class("min-w-0 space-y-0.5")], [ - h.a( - [ - h.Class( - cn( - "block truncate text-sm font-medium", - !rule.enabled && "text-muted-foreground", - ROW_STRETCHED_LINK_CLASS, - ), + rowLink( + h, + { + href: ruleHref(rule.id), + className: cn( + "block truncate text-sm font-medium", + !rule.enabled && "text-muted-foreground", ), - h.Href(ruleHref(rule.id)), - h.Title(rule.name), - ], + attributes: [h.Title(rule.name)], + }, [rule.name], ), h.p( diff --git a/apps/web/src/page/alerts/hub/view-uptime.ts b/apps/web/src/page/alerts/hub/view-uptime.ts index 5e2a3832a7..9d3db205ee 100644 --- a/apps/web/src/page/alerts/hub/view-uptime.ts +++ b/apps/web/src/page/alerts/hub/view-uptime.ts @@ -17,9 +17,9 @@ import { rowActionsMenu } from "../../../ui/row-actions-menu" import { searchInput } from "../../../ui/search-input" import { segmentedSelect } from "../../../ui/segmented-select" import { skeleton } from "../../../ui/skeleton" +import { ROW_LINK_LIFT, rowLink } from "../../../ui/table" import { emptyActions, filteredEmpty } from "../../kit/signal-empty-state" import { statRail, statRailItem, statRailLoading } from "../../kit/stat-rail" -import { ROW_LINK_LIFT, ROW_STRETCHED_LINK_CLASS } from "../shared/links" import { isoMs } from "../shared/time" import { monitorHref, newMonitorHref } from "../shared/uptime-commands" import { @@ -263,16 +263,12 @@ const monitorRow = (h: H, model: Model, monitor: UptimeMonitor, nowMs: number): h.div( [h.Class("flex min-w-0 items-center gap-2")], [ - h.a( - [ - h.Href(monitorHref(monitor.id)), - h.Class( - cn( - "truncate text-sm font-medium", - ROW_STRETCHED_LINK_CLASS, - ), - ), - ], + rowLink( + h, + { + href: monitorHref(monitor.id), + className: "truncate text-sm font-medium", + }, [monitor.name], ), ...(displayStatus(monitor) === "up" diff --git a/apps/web/src/page/alerts/hub/view.ts b/apps/web/src/page/alerts/hub/view.ts index b9feb60325..4e2c387f23 100644 --- a/apps/web/src/page/alerts/hub/view.ts +++ b/apps/web/src/page/alerts/hub/view.ts @@ -1,5 +1,4 @@ import type { AlertsOverviewReady } from "@maple/web-data/alerts/overview" -import { cn } from "@maple/ui/lib/utils" import { makeClientErrorBody } from "@maple/web-data/lib/error-messages" import { Option } from "effect" import { AsyncData, Submodel } from "foldkit" @@ -13,7 +12,7 @@ import * as MenuView from "../../../ui/menu-view" import { skeleton, skeletonList } from "../../../ui/skeleton" import type { PageViewInputs } from "../../contract" import { dashboardPage } from "../../kit/dashboard-page" -import { underlineTabClass, underlineTabStrip } from "../../kit/underline-tabs" +import { linkTabStrip } from "../../kit/underline-tabs" import { alertsHref, createHref } from "../shared/links" import { type AlertsTab, destinationsOf, overviewOf, resolveStatus, resolveTab } from "./derive" import { Message } from "./message" @@ -45,9 +44,9 @@ const tabBar = ( active: AlertsTab, ): Html => { const monitorCount = Option.map(AsyncData.getData(model.monitors.data), (monitors) => monitors.length) - return underlineTabStrip( + return linkTabStrip( h, - { label: "Alerts sections", navigation: true }, + "Alerts sections", ALERTS_TABS.map((tab) => { const count = tab.value === "uptime" @@ -61,34 +60,15 @@ const tabBar = ( ? data.rules.length : undefined, }) - return h.a( - [ - h.Key(tab.value), - h.Href(alertsHref({ ...model.search, tab: tab.value })), - ...(tab.value === active ? [h.AriaCurrent("page")] : []), - h.Class(underlineTabClass(tab.value === active)), - ], - [ - tab.label, - ...(count !== undefined && count > 0 - ? [ - h.span( - [ - h.Class( - cn( - "font-mono text-xs tabular-nums", - tab.value === "inbox" - ? "text-severity-error" - : "text-muted-foreground", - ), - ), - ], - [String(count)], - ), - ] - : []), - ], - ) + return { + key: tab.value, + href: alertsHref({ ...model.search, tab: tab.value }), + label: tab.label, + count: count !== undefined && count > 0 ? count : undefined, + countTone: tab.value === "inbox" ? "error" : "muted", + isCurrent: tab.value === active, + isRouteActive: tab.value === active, + } }), ) } diff --git a/apps/web/src/page/alerts/rule/view.ts b/apps/web/src/page/alerts/rule/view.ts index eb3367c14b..da04dc2837 100644 --- a/apps/web/src/page/alerts/rule/view.ts +++ b/apps/web/src/page/alerts/rule/view.ts @@ -13,7 +13,7 @@ import { dashboardPage } from "../../kit/dashboard-page" import { resourceNotFound } from "../../kit/resource-not-found" import { timeControls } from "../../kit/time-controls" import * as TimeRangePicker from "../../kit/time-range-picker" -import { underlineTabClass, underlineTabStrip } from "../../kit/underline-tabs" +import { linkTabStrip } from "../../kit/underline-tabs" import { alertStatusBadge, severityBadge } from "../shared/badges" import { alertsHref, createHref, ruleHref } from "../shared/links" import { RULE_CHART_TOKENS } from "../shared/rule-chart" @@ -107,21 +107,17 @@ const header = (h: H, model: Model, rule: AlertRuleDocument) => { const tabStrip = (h: H, model: Model): Html => { const active = activeTabOf(model) - return underlineTabStrip( + return linkTabStrip( h, - { label: "Rule sections", navigation: true }, - TABS.map((tab) => - h.a( - [ - h.Key(tab), - // `search={(prev) => ({ ...prev, tab })}`: the time range rides along. - h.Href(ruleHref(model.ruleId, { ...model.search, tab })), - ...(tab === active ? [h.AriaCurrent("page")] : []), - h.Class(underlineTabClass(tab === active)), - ], - [tab === "overview" ? "Overview" : "History"], - ), - ), + "Rule sections", + TABS.map((tab) => ({ + key: tab, + // `search={(prev) => ({ ...prev, tab })}`: the time range rides along. + href: ruleHref(model.ruleId, { ...model.search, tab }), + label: tab === "overview" ? "Overview" : "History", + isCurrent: tab === active, + isRouteActive: tab === active, + })), ) } @@ -170,4 +166,3 @@ export const breadcrumbsOf = (model: Model) => { { label: model.rules === null ? "Loading…" : rule === null ? "Not found" : rule.name }, ] } - diff --git a/apps/web/src/page/alerts/shared/links.ts b/apps/web/src/page/alerts/shared/links.ts index 67547f5312..bf622381e2 100644 --- a/apps/web/src/page/alerts/shared/links.ts +++ b/apps/web/src/page/alerts/shared/links.ts @@ -3,11 +3,6 @@ import { Option, Schema } from "effect" import { AppRoute, hrefOf } from "../../../route" import type { AlertRuleSearch, AlertsCreateSearch, AlertsSearch } from "../../../route/search" -// `ROW_STRETCHED_LINK_CLASS` and `ROW_LINK_LIFT`: a row-wide link and the lift for controls above it. -export const ROW_STRETCHED_LINK_CLASS = - "after:absolute after:inset-0 after:content-[''] focus-visible:outline-none focus-visible:after:ring-1 focus-visible:after:ring-ring focus-visible:after:ring-inset" -export const ROW_LINK_LIFT = "relative z-10" - const decodeRuleId = Schema.decodeUnknownOption(AlertRuleId) /** A rule's page. Derived rows carry the id as a plain string; one that is not a rule id links home. */ @@ -20,8 +15,7 @@ export const ruleHref = (rawRuleId: string, search: typeof AlertRuleSearch.Type export const incidentHref = (incidentId: string): string => hrefOf(AppRoute.AlertIncident({ incidentId, search: {} })) -export const alertsHref = (search: typeof AlertsSearch.Type): string => - hrefOf(AppRoute.Alerts({ search })) +export const alertsHref = (search: typeof AlertsSearch.Type): string => hrefOf(AppRoute.Alerts({ search })) export const createHref = (search: typeof AlertsCreateSearch.Type): string => hrefOf(AppRoute.AlertsCreate({ search })) diff --git a/apps/web/src/page/anomalies/detail/view-body.ts b/apps/web/src/page/anomalies/detail/view-body.ts index 1c7f3a5593..6857763720 100644 --- a/apps/web/src/page/anomalies/detail/view-body.ts +++ b/apps/web/src/page/anomalies/detail/view-body.ts @@ -28,6 +28,7 @@ import { badge } from "../../../ui/display" import { card, cardPanel } from "../../../ui/card" import { emptyMessage } from "../../../ui/empty" import { eyebrow } from "../../../ui/eyebrow" +import { pageTitle } from "../../../ui/page-layout" import { icon } from "../../../ui/icon" import { iconButton } from "../../../ui/icon-button" import { inlineCode } from "../../../ui/inline-code" @@ -60,7 +61,10 @@ export const anomalyHero = (h: H, incident: AnomalyIncidentDocument, nowMs: numb const tone = severityToneFor(incident) const isStale = isStaleOpenIncident(incident, nowMs) const dev = deviation(incident) - const accent = cn("font-mono font-medium", incident.status === "open" && !isStale ? tone.text : "text-foreground") + const accent = cn( + "font-mono font-medium", + incident.status === "open" && !isStale ? tone.text : "text-foreground", + ) const fmt = (value: number) => formatSignalValue(incident.signalType, value) return h.div( [h.Class("space-y-2")], @@ -75,17 +79,16 @@ export const anomalyHero = (h: H, incident: AnomalyIncidentDocument, nowMs: numb h.div( [h.Class("flex flex-wrap items-baseline gap-3")], [ - h.h1( - [h.Class("font-display text-2xl font-semibold leading-[1.1] tracking-tight text-foreground")], + pageTitle( + h, + { + title: `${SIGNAL_LABEL[incident.signalType]} · ${incident.serviceName}`, + className: "min-w-0 text-foreground", + }, [ - h.span( - [h.Class("break-words")], - [ - SIGNAL_LABEL[incident.signalType], - h.span([h.Class("text-muted-foreground/60")], [" · "]), - incident.serviceName, - ], - ), + SIGNAL_LABEL[incident.signalType], + h.span([h.Class("text-muted-foreground/60")], [" · "]), + incident.serviceName, ], ), ], @@ -98,13 +101,23 @@ export const anomalyHero = (h: H, incident: AnomalyIncidentDocument, nowMs: numb h.span([h.Class(accent)], [fmt(incident.lastObservedValue)]), " ", "against a ", - h.span([h.Class("font-mono text-foreground")], [fmt(incident.baselineMedian)]), + h.span( + [h.Class("font-mono text-foreground")], + [fmt(incident.baselineMedian)], + ), " 7-day baseline:", " ", h.span([h.Class(accent)], [dev.label]), - dev.kind === "sigma" ? " above median" : dev.kind === "percent" ? " vs baseline" : "", + dev.kind === "sigma" + ? " above median" + : dev.kind === "percent" + ? " vs baseline" + : "", ", threshold ", - h.span([h.Class("font-mono text-foreground")], [fmt(incident.thresholdValue)]), + h.span( + [h.Class("font-mono text-foreground")], + [fmt(incident.thresholdValue)], + ), ".", isStale ? ` Last triggered ${formatRelativeTime(incident.lastTriggeredAt, nowMs)}; this stale detector state does not represent current service health.` @@ -119,21 +132,29 @@ export const anomalyHero = (h: H, incident: AnomalyIncidentDocument, nowMs: numb ) } -const chartView = Submodel.defineView>((model, inputs, h) => - plotFrame(h, { - chartId: "anomaly-signal", - spec: inputs.spec, - ariaLabel: "Observed signal", - className: "h-64 w-full", - tooltipBody: (portal) => - Option.flatMap(model.tooltip, (tooltip) => - tooltip._tag === "Bucket" ? Option.some(bucketTooltipBody(h, tooltip, portal)) : Option.none(), - ), - }), +const chartView = Submodel.defineView>( + (model, inputs, h) => + plotFrame(h, { + chartId: "anomaly-signal", + spec: inputs.spec, + ariaLabel: "Observed signal", + className: "h-64 w-full", + tooltipBody: (portal) => + Option.flatMap(model.tooltip, (tooltip) => + tooltip._tag === "Bucket" + ? Option.some(bucketTooltipBody(h, tooltip, portal)) + : Option.none(), + ), + }), ) /** `AnomalySignalChart`: loading until the series, the theme and the chart runtime are all in. */ -export const signalChart = (h: H, model: Model, incident: AnomalyIncidentDocument, timeZone: string): Html => { +export const signalChart = ( + h: H, + model: Model, + incident: AnomalyIncidentDocument, + timeZone: string, +): Html => { const colors = model.themeColors const isReady = colors !== null && isChartRuntimeReady(model.chartRuntime) return AsyncData.match(model.timeseries.data, { @@ -150,7 +171,10 @@ export const signalChart = (h: H, model: Model, incident: AnomalyIncidentDocumen if (series.buckets.length === 0) return emptyMessage( h, - { dashed: true, className: "flex h-64 w-full items-center justify-center border-border/50 py-0 text-xs" }, + { + dashed: true, + className: "flex h-64 w-full items-center justify-center border-border/50 py-0 text-xs", + }, ["No signal data in window"], ) const stroke = resolveColor(colors, SEVERITY_STROKE[incident.severity]) @@ -166,7 +190,10 @@ export const signalChart = (h: H, model: Model, incident: AnomalyIncidentDocumen view: chartView, viewInputs: { spec: ChartSpec.AnomalySignal({ - points: series.buckets.map((bucket) => ({ bucket: bucket.bucket, value: bucket.value })), + points: series.buckets.map((bucket) => ({ + bucket: bucket.bucket, + value: bucket.value, + })), signalType: series.signalType, baselineMedian: series.baselineMedian, thresholdValue: series.thresholdValue, @@ -190,8 +217,18 @@ export const signalChart = (h: H, model: Model, incident: AnomalyIncidentDocumen swatch: "dashed", items: [ { key: "observed", label: "Observed", color: stroke, swatch: "line" }, - { key: "baseline", label: "Baseline median", value: fmt(series.baselineMedian), color: "var(--muted-foreground)" }, - { key: "threshold", label: "Threshold", value: fmt(series.thresholdValue), color: "var(--severity-error)" }, + { + key: "baseline", + label: "Baseline median", + value: fmt(series.baselineMedian), + color: "var(--muted-foreground)", + }, + { + key: "threshold", + label: "Threshold", + value: fmt(series.thresholdValue), + color: "var(--severity-error)", + }, ], }), ], @@ -212,27 +249,51 @@ const WORKFLOW_TONE = { wontfix: TONE_SOFT.neutral, } as const -const cardActions = (h: H, issueId: NonNullable, isBusy: boolean): Html => +const cardActions = ( + h: H, + issueId: NonNullable, + isBusy: boolean, +): Html => h.div( [h.Class("flex shrink-0 items-center gap-1.5")], [ - linkButton(h, { size: "sm", variant: "outline", href: hrefOf(AppRoute.ErrorIssue({ issueId, search: {} })) }, [ - "Open issue", - icon(h, "ArrowRightIcon"), - ]), + linkButton( + h, + { + size: "sm", + variant: "outline", + href: hrefOf(AppRoute.ErrorIssue({ issueId, search: {} })), + }, + ["Open issue", icon(h, "ArrowRightIcon")], + ), iconButton( h, - { label: "Link a different issue", isDisabled: isBusy, attributes: [h.OnClick(Message.ClickedLinkIssue())] }, + { + label: "Link a different issue", + isDisabled: isBusy, + attributes: [h.OnClick(Message.ClickedLinkIssue())], + }, [icon(h, "LinkIcon")], ), - iconButton(h, { label: "Unlink issue", isDisabled: isBusy, attributes: [h.OnClick(Message.ClickedUnlink())] }, [ - icon(h, "XmarkIcon"), - ]), + iconButton( + h, + { + label: "Unlink issue", + isDisabled: isBusy, + attributes: [h.OnClick(Message.ClickedUnlink())], + }, + [icon(h, "XmarkIcon")], + ), ], ) /** `AnomalyLinkedIssueCard`. */ -export const linkedIssueCard = (h: H, model: Model, incident: AnomalyIncidentDocument, isBusy: boolean): Html => { +export const linkedIssueCard = ( + h: H, + model: Model, + incident: AnomalyIncidentDocument, + isBusy: boolean, +): Html => { const issueId = incident.errorIssueId if (issueId === null) return h.div( @@ -256,16 +317,25 @@ export const linkedIssueCard = (h: H, model: Model, incident: AnomalyIncidentDoc ...(incident.fingerprintHash === null ? [] : [ - inlineCode(h, { className: "inline-block px-1.5 py-0.5 text-2xs text-muted-foreground" }, [ - "fingerprint ", - incident.fingerprintHash, - ]), + inlineCode( + h, + { + className: + "inline-block px-1.5 py-0.5 text-2xs text-muted-foreground", + }, + ["fingerprint ", incident.fingerprintHash], + ), ]), ], ), button( h, - { size: "sm", variant: "outline", isDisabled: isBusy, attributes: [h.OnClick(Message.ClickedLinkIssue())] }, + { + size: "sm", + variant: "outline", + isDisabled: isBusy, + attributes: [h.OnClick(Message.ClickedLinkIssue())], + }, [icon(h, "LinkIcon"), "Link issue"], ), ], @@ -286,7 +356,12 @@ export const linkedIssueCard = (h: H, model: Model, incident: AnomalyIncidentDoc [ h.p( [h.Class("text-muted-foreground")], - ["Linked to issue", " ", h.code([h.Class("font-mono text-xs")], [shortIssueId(id)]), ". Couldn't load its details."], + [ + "Linked to issue", + " ", + h.code([h.Class("font-mono text-xs")], [shortIssueId(id)]), + ". Couldn't load its details.", + ], ), cardActions(h, id, isBusy), ], @@ -304,12 +379,23 @@ export const linkedIssueCard = (h: H, model: Model, incident: AnomalyIncidentDoc h.div( [h.Class("flex flex-wrap items-center gap-2")], [ - badge(h, { variant: "outline", className: WORKFLOW_TONE[issue.workflowState] }, [WORKFLOW_LABEL[issue.workflowState]]), - h.code([h.Class("font-mono text-xs tabular-nums text-muted-foreground")], [shortIssueId(issue.id)]), - badge(h, { variant: "meta", pill: true, size: "sm", className: META_CHIP_CLASS }, [ - serviceDot(h, { serviceName: issue.serviceName, size: "sm" }), - h.span([h.Class("max-w-[140px] truncate")], [issue.serviceName]), - ]), + badge( + h, + { variant: "outline", className: WORKFLOW_TONE[issue.workflowState] }, + [WORKFLOW_LABEL[issue.workflowState]], + ), + h.code( + [h.Class("font-mono text-xs tabular-nums text-muted-foreground")], + [shortIssueId(issue.id)], + ), + badge( + h, + { variant: "meta", pill: true, size: "sm", className: META_CHIP_CLASS }, + [ + serviceDot(h, { serviceName: issue.serviceName, size: "sm" }), + h.span([h.Class("max-w-[140px] truncate")], [issue.serviceName]), + ], + ), ], ), h.p( @@ -317,7 +403,12 @@ export const linkedIssueCard = (h: H, model: Model, incident: AnomalyIncidentDoc [ issue.exceptionType || "Unknown error", ...(issue.exceptionMessage - ? [h.span([h.Class("ml-2 font-normal text-muted-foreground")], [issue.exceptionMessage])] + ? [ + h.span( + [h.Class("ml-2 font-normal text-muted-foreground")], + [issue.exceptionMessage], + ), + ] : []), ], ), @@ -326,7 +417,11 @@ export const linkedIssueCard = (h: H, model: Model, incident: AnomalyIncidentDoc [ h.span( [h.Class("tabular-nums")], - [formatNumber(issue.occurrenceCount), " ", pluralize(issue.occurrenceCount, "event")], + [ + formatNumber(issue.occurrenceCount), + " ", + pluralize(issue.occurrenceCount, "event"), + ], ), h.span( [h.Class("flex items-center gap-1.5")], diff --git a/apps/web/src/page/code-review/kit/review-sheet-view.ts b/apps/web/src/page/code-review/kit/review-sheet-view.ts index a704a4c236..8d4620db6b 100644 --- a/apps/web/src/page/code-review/kit/review-sheet-view.ts +++ b/apps/web/src/page/code-review/kit/review-sheet-view.ts @@ -1,16 +1,13 @@ import type { CodeReviewDetail } from "@maple/domain/http" -import { cn } from "@maple/ui/lib/utils" -import * as SheetStyles from "@maple/ui/styles/ui/sheet" import { AsyncData, Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" -import { descriptionId, titleId } from "../../../ui/dialog" import * as DialogView from "../../../ui/dialog-view" import { linkButton } from "../../../ui/button" import { errorState } from "../../../ui/error-state" -import { eyebrow } from "../../../ui/eyebrow" import { icon } from "../../../ui/icon" import { skeleton } from "../../../ui/skeleton" import { truncatedId } from "../../../ui/truncated-id" +import { sheetDetailHeader } from "../../kit/sheet-detail-header" import { authorLabel } from "./author" import type { ReviewSheet } from "./review-sheet" import { SheetMessage } from "./review-sheet" @@ -20,25 +17,6 @@ import { reviewSections } from "./review-sheet-sections" type H = HtmlBuilder -const sheetHeader = (h: H, children: ReadonlyArray): Html => - h.div( - [ - h.Class(cn(SheetStyles.sheetHeaderStyles, "gap-1.5 pr-14")), - h.DataAttribute("slot", "sheet-header"), - ], - children, - ) - -const sheetTitle = (h: H, id: string, className: string | undefined, children: ReadonlyArray): Html => - h.h2( - [ - h.Class(cn(SheetStyles.sheetTitleStyles, className)), - h.DataAttribute("slot", "sheet-title"), - h.Id(titleId(id)), - ], - children, - ) - const linkOut = (h: H, href: string, label: string): Html => linkButton( h, @@ -54,52 +32,36 @@ const linkOut = (h: H, href: string, label: string): Html => /** `SheetDetailHeader` with the review's kind, title, repository line and links out. */ const detailHeader = (h: H, id: string, detail: CodeReviewDetail): Html => { const { review } = detail - return sheetHeader(h, [ - eyebrow(h, { as: "div", className: "flex flex-wrap items-center gap-x-2 gap-y-1" }, [ - h.span([h.Class("truncate")], ["Review"]), - h.span([h.AriaHidden(true), h.Class("text-muted-foreground/40")], ["·"]), - h.span([h.Class("truncate")], [`#${review.number}`]), - ]), - sheetTitle(h, id, "flex flex-wrap items-center gap-2 text-title leading-tight", [ - h.span([h.Class("min-w-0 break-words")], [review.title ?? "Untitled pull request"]), - ]), - h.p( - [ - h.Class(cn(SheetStyles.sheetDescriptionStyles, "sr-only")), - h.DataAttribute("slot", "sheet-description"), - h.Id(descriptionId(id)), - ], - [`Review of ${review.repositoryFullName} #${review.number}`], - ), - h.div( - [h.Class("flex flex-wrap items-center gap-1.5")], - [ - h.p( - [h.Class("w-full text-sm text-muted-foreground")], - [ - review.repositoryFullName, - ...(review.authorLogin === null - ? [] - : [" · opened by ", authorLabel(h, review.authorLogin, "align-middle")]), - " ", - "· head ", - truncatedId(h, { value: review.headSha, kind: "sha" }), - ], - ), - h.div( - [h.Class("flex flex-wrap gap-2 pt-1")], - [ - linkOut(h, review.url, "Pull request"), - ...(review.commentUrl === null - ? [] - : [linkOut(h, review.commentUrl, "Summary comment")]), - ...(detail.reviewUrl === null ? [] : [linkOut(h, detail.reviewUrl, "Inline review")]), - ...(detail.checkRunUrl === null ? [] : [linkOut(h, detail.checkRunUrl, "Check run")]), - ], - ), - ], - ), - ]) + return sheetDetailHeader(h, { + dialogId: id, + kind: ["Review", `#${review.number}`], + mono: false, + title: [review.title ?? "Untitled pull request"], + description: `Review of ${review.repositoryFullName} #${review.number}`, + meta: [ + h.p( + [h.Class("w-full text-sm text-muted-foreground")], + [ + review.repositoryFullName, + ...(review.authorLogin === null + ? [] + : [" · opened by ", authorLabel(h, review.authorLogin, "align-middle")]), + " ", + "· head ", + truncatedId(h, { value: review.headSha, kind: "sha" }), + ], + ), + h.div( + [h.Class("flex flex-wrap gap-2 pt-1")], + [ + linkOut(h, review.url, "Pull request"), + ...(review.commentUrl === null ? [] : [linkOut(h, review.commentUrl, "Summary comment")]), + ...(detail.reviewUrl === null ? [] : [linkOut(h, detail.reviewUrl, "Inline review")]), + ...(detail.checkRunUrl === null ? [] : [linkOut(h, detail.checkRunUrl, "Check run")]), + ], + ), + ], + }) } const panel = (h: H, id: string, className: string | undefined, children: ReadonlyArray): Html => @@ -111,7 +73,12 @@ const body = (h: H, sheet: ReviewSheet, timeZone: string): ReadonlyArray = if (sheet.reviewId === null) return [] return AsyncData.matchData(sheet.detail.data, { onEmpty: () => [ - sheetHeader(h, [sheetTitle(h, id, undefined, [skeleton(h, "h-6 w-72")])]), + DialogView.dialogHeader( + h, + "sheet", + [DialogView.dialogTitle(h, "sheet", id, [skeleton(h, "h-6 w-72")])], + "gap-1.5 pr-14", + ), panel(h, id, "space-y-4", [skeleton(h, "h-20 w-full"), skeleton(h, "h-40 w-full")]), ], onFailure: (failure) => [ diff --git a/apps/web/src/page/dashboards/list/view.ts b/apps/web/src/page/dashboards/list/view.ts index 1fb373eab6..39771413f5 100644 --- a/apps/web/src/page/dashboards/list/view.ts +++ b/apps/web/src/page/dashboards/list/view.ts @@ -1,5 +1,3 @@ -import { cn } from "@maple/ui/lib/utils" -import { pageTitleStyles } from "@maple/ui/styles/ui/page-layout" import { TONE_TEXT } from "@maple/ui/lib/tone" import { Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" @@ -8,6 +6,7 @@ import { button } from "../../../ui/button" import { errorState } from "../../../ui/error-state" import type { LoadFailure } from "../../../data/load-failure" import { icon } from "../../../ui/icon" +import { pageTitle } from "../../../ui/page-layout" import type { PageViewInputs } from "../../contract" import { dashboardPage } from "../../kit/dashboard-page" import { headerSummary } from "./derive" @@ -178,7 +177,7 @@ export const view = Submodel.defineView((model, ] : [] const reason = readOnlyReason(model) - const title = h.div([], [h.h1([h.Class(cn(pageTitleStyles, "leading-8"))], ["Dashboards"]), ...summary]) + const title = h.div([], [pageTitle(h, { className: "leading-8" }, ["Dashboards"]), ...summary]) return dashboardPage( h, { diff --git a/apps/web/src/page/dashboards/templates/view.ts b/apps/web/src/page/dashboards/templates/view.ts index 6077c53b4f..772234fd99 100644 --- a/apps/web/src/page/dashboards/templates/view.ts +++ b/apps/web/src/page/dashboards/templates/view.ts @@ -3,10 +3,9 @@ import { Option } from "effect" import type { Html, HtmlBuilder } from "foldkit/html" import { WIDGET_CHART_TOKENS } from "../kit/chart-tokens" import { ResolveThemeColors } from "../../../charts/theme-colors" -import { cn } from "@maple/ui/lib/utils" -import { pageTitleStyles } from "@maple/ui/styles/ui/page-layout" import type { LoadFailure } from "../../../data/load-failure" import { errorState } from "../../../ui/error-state" +import { pageTitle } from "../../../ui/page-layout" import type { PageViewInputs } from "../../contract" import { dashboardPage } from "../../kit/dashboard-page" import { BLANK_TEMPLATE_ID, computeReadiness } from "./derive" @@ -76,7 +75,7 @@ export const view = Submodel.defineView((model, titleContent: h.div( [h.Class("flex flex-col gap-1")], [ - h.h1([h.Class(cn(pageTitleStyles, "leading-8"))], ["Templates"]), + pageTitle(h, { className: "leading-8" }, ["Templates"]), ...(isLoaded && metrics !== null ? [summary(h, readyCount, listed.length)] : []), ], ), diff --git a/apps/web/src/page/dashboards/view/view.ts b/apps/web/src/page/dashboards/view/view.ts index 77315b6e2a..91195fe126 100644 --- a/apps/web/src/page/dashboards/view/view.ts +++ b/apps/web/src/page/dashboards/view/view.ts @@ -1,6 +1,7 @@ import type { DashboardDocument } from "@maple/domain/http" import { TONE_TEXT } from "@maple/ui/lib/tone" import { cn } from "@maple/ui/lib/utils" +import { pageTitleStyles } from "@maple/ui/styles/ui/page-layout" import { Option } from "effect" import { AsyncData, Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" @@ -13,6 +14,7 @@ import { chartSkeleton } from "../../../ui/chart-skeleton" import { empty, emptyContent, emptyDescription, emptyHeader, emptyMedia, emptyTitle } from "../../../ui/empty" import { icon } from "../../../ui/icon" import { inlineCode } from "../../../ui/inline-code" +import { pageTitle } from "../../../ui/page-layout" import { skeleton } from "../../../ui/skeleton" import type { PageContext, PageViewInputs } from "../../contract" import { variablesOf } from "./plan" @@ -51,30 +53,35 @@ const title = (h: H, model: Model, name: string): Html => { ), h.Autofocus(true), h.Class( - "font-display text-2xl font-semibold tracking-tight leading-[1.1] bg-transparent border-b border-foreground/20 outline-none focus:border-foreground/50 w-full", + cn( + pageTitleStyles, + "bg-transparent border-b border-foreground/20 outline-none focus:border-foreground/50 w-full", + ), ), ]), ], ) const isBlank = name.trim() === "" - return h.h1( - [ - h.Role("button"), - h.Tabindex(0), - h.OnClick(Message.ClickedTitle()), - h.OnKeyDownPreventDefault((key) => - key === "Enter" || key === " " ? Option.some(Message.ClickedTitle()) : Option.none(), + const shown = isBlank ? "Untitled dashboard" : name + return pageTitle( + h, + { + title: shown, + className: cn( + !isReadOnly && "cursor-pointer hover:text-foreground/80", + isBlank && "text-muted-foreground", ), - h.AriaDisabled(isReadOnly), - h.Class( - cn( - "font-display text-2xl font-semibold tracking-tight truncate leading-[1.1]", - !isReadOnly && "cursor-pointer hover:text-foreground/80", - isBlank && "text-muted-foreground", + attributes: [ + h.Role("button"), + h.Tabindex(0), + h.OnClick(Message.ClickedTitle()), + h.OnKeyDownPreventDefault((key) => + key === "Enter" || key === " " ? Option.some(Message.ClickedTitle()) : Option.none(), ), - ), - ], - [isBlank ? "Untitled dashboard" : name], + h.AriaDisabled(isReadOnly), + ], + }, + [shown], ) } diff --git a/apps/web/src/page/infra-integrations/aws/list/view.ts b/apps/web/src/page/infra-integrations/aws/list/view.ts index ffc4040bcf..ed612c271c 100644 --- a/apps/web/src/page/infra-integrations/aws/list/view.ts +++ b/apps/web/src/page/infra-integrations/aws/list/view.ts @@ -15,7 +15,7 @@ import { dashboardPage } from "../../../kit/dashboard-page" import { dataTableEmpty } from "../../../infra/kit/data-table" import { FLEET_BAND_BOXED, fleetBand, fleetBandLoading } from "../../../infra/kit/fleet-band" import { pageHeroParts, searchToolbar } from "../../../infra/kit/page-parts" -import { linkTabStrip } from "../../../infra/kit/view-tabs" +import { viewTabStrip } from "../../../infra/kit/view-tabs" import { integrationNotConnected } from "../../kit/empty-states" import { integrationConnectCta } from "../../kit/integration-empty" import { AWS_ACCENT } from "../../../integrations/catalog" @@ -60,7 +60,7 @@ const loading = (h: H): Html => const nameLabel = (kind: AwsInfraKindId) => AWS_INFRA_KINDS[kind].identity[0]?.[0] ?? "Resource" const tabs = (h: H, model: Model, reads: ReadonlyArray, active: AwsInfraKindId): Html => - linkTabStrip( + viewTabStrip( h, "AWS resource kinds", reads.map((read) => ({ diff --git a/apps/web/src/page/infra-integrations/cloudflare/list/view.ts b/apps/web/src/page/infra-integrations/cloudflare/list/view.ts index a188dbfc2e..c10e442a7e 100644 --- a/apps/web/src/page/infra-integrations/cloudflare/list/view.ts +++ b/apps/web/src/page/infra-integrations/cloudflare/list/view.ts @@ -30,7 +30,7 @@ import { dashboardPage } from "../../../kit/dashboard-page" import { docsLink } from "../../../kit/signal-empty-state" import { FLEET_BAND_BOXED, fleetBand, fleetBandLoading } from "../../../infra/kit/fleet-band" import { heroFieldChip } from "../../../infra/kit/hero-chip" -import { linkTabStrip } from "../../../infra/kit/view-tabs" +import { viewTabStrip } from "../../../infra/kit/view-tabs" import { pageHero, pageHeroParts, searchToolbar, headerActions } from "../../../infra/kit/page-parts" import { pageTime } from "../../kit/page-time" import type { Status, ZoneRow } from "../data" @@ -294,7 +294,7 @@ const header = ( const viewTabs = (h: H, model: Model, scope: ListScope, inPlace: boolean): Html | undefined => { const views = CLOUDFLARE_VIEWS.filter((v) => v === scope.view || scope.counts[v] !== 0) if (views.length < 2) return undefined - return linkTabStrip( + return viewTabStrip( h, "Cloudflare views", views.map((v) => ({ 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 4f1ff0ddb0..e64fb56aba 100644 --- a/apps/web/src/page/infra-integrations/gcp/list/view.ts +++ b/apps/web/src/page/infra-integrations/gcp/list/view.ts @@ -43,7 +43,7 @@ import { integrationConnectCta } from "../../kit/integration-empty" import { AppRoute, hrefOf } from "../../../../route" import { GCP_ACCENT } from "../../../integrations/catalog" import { heroFieldChip } from "../../../infra/kit/hero-chip" -import { linkTabStrip } from "../../../infra/kit/view-tabs" +import { viewTabStrip } from "../../../infra/kit/view-tabs" import { pageHeroParts, searchToolbar, headerActions } from "../../../infra/kit/page-parts" import type * as TimeRangePicker from "../../../kit/time-range-picker" import { pageTime } from "../../kit/page-time" @@ -558,7 +558,7 @@ export const gcpListPageParts = (h: H, input: ListPageInput): PageParts => { const health = input.projectHealth const tabs = views.length > 1 - ? linkTabStrip( + ? viewTabStrip( h, "Google Cloud views", views.map((option) => ({ diff --git a/apps/web/src/page/infra/kit/page-parts.ts b/apps/web/src/page/infra/kit/page-parts.ts index edf3a7b86e..bec552a060 100644 --- a/apps/web/src/page/infra/kit/page-parts.ts +++ b/apps/web/src/page/infra/kit/page-parts.ts @@ -1,4 +1,3 @@ -import { pageTitleStyles } from "@maple/ui/styles/ui/page-layout" import type { DocsPage } from "../../../lib/docs" import { countLabel as nounCount } from "@maple/ui/lib/format" import { cn } from "@maple/ui/lib/utils" @@ -6,6 +5,7 @@ import type { Html, HtmlBuilder } from "foldkit/html" import { button } from "../../../ui/button" import { empty, emptyDescription, emptyHeader, emptyMedia, emptyMessage, emptyTitle } from "../../../ui/empty" import { icon } from "../../../ui/icon" +import { pageTitle } from "../../../ui/page-layout" import { searchInput } from "../../../ui/search-input" import { docsLink, emptyActions } from "../../kit/signal-empty-state" @@ -28,46 +28,6 @@ const isHtmlList = (value: Html | ReadonlyArray): value is ReadonlyArray => trailing === undefined ? [] : isHtmlList(trailing) ? trailing : [trailing] -export const pageHero = (h: HtmlBuilder, options: PageHeroOptions): Html => - h.header( - [h.Class(cn("flex flex-wrap items-start gap-x-6 gap-y-3", options.className))], - [ - h.div( - [h.Class("min-w-0 flex-1 space-y-1")], - [ - h.div( - [h.Class("flex flex-wrap items-baseline gap-3")], - [ - h.h1( - [ - h.Class( - "font-display text-2xl font-semibold leading-[1.1] tracking-tight text-foreground", - ), - ], - [options.title], - ), - ...trailingOf(options.trailing), - ], - ), - ...(options.description === undefined || options.description === "" - ? [] - : [ - h.p( - [h.Class("max-w-2xl font-prose text-sm text-muted-foreground")], - [options.description], - ), - ]), - ...(options.meta === undefined - ? [] - : [h.div([h.Class("mt-2 flex flex-wrap items-center gap-2")], options.meta)]), - ], - ), - ...(options.actions === undefined || options.actions.length === 0 - ? [] - : [h.div([h.Class("flex shrink-0 items-center gap-2")], options.actions)]), - ], - ) - /** * `pageHero` split for `dashboardPage`: the title, its trailing badge and the description go in the * header beside the time controls (one title row on every page); meta opens the body. @@ -83,7 +43,14 @@ export const pageHeroParts = ( // Centred, not baseline: a small chip on a baseline rides high beside the large title. [h.Class("flex min-w-0 flex-wrap items-center gap-3")], [ - h.h1([h.Class(cn(pageTitleStyles, "leading-8 text-foreground"))], [options.title]), + pageTitle( + h, + { + title: typeof options.title === "string" ? options.title : undefined, + className: "min-w-0 leading-8 text-foreground", + }, + [options.title], + ), ...trailingOf(options.trailing), ], ), @@ -103,6 +70,23 @@ export const pageHeroParts = ( : h.div([h.Class("flex flex-wrap items-center gap-2")], options.meta), }) +/** `PageHero` for a page that draws its header in the body: `pageHeroParts` with actions beside it. */ +export const pageHero = (h: HtmlBuilder, options: PageHeroOptions): Html => { + const parts = pageHeroParts(h, options) + return h.header( + [h.Class(cn("flex flex-wrap items-start gap-x-6 gap-y-3", options.className))], + [ + h.div( + [h.Class("min-w-0 flex-1 space-y-2")], + parts.body === null ? [parts.title] : [parts.title, parts.body], + ), + ...(options.actions === undefined || options.actions.length === 0 + ? [] + : [h.div([h.Class("flex shrink-0 items-center gap-2")], options.actions)]), + ], + ) +} + /** The header's right side: a page's own actions before its time controls. */ export const headerActions = (h: HtmlBuilder, extra: ReadonlyArray, controls: Html): Html => extra.length === 0 diff --git a/apps/web/src/page/infra/kit/view-tabs.ts b/apps/web/src/page/infra/kit/view-tabs.ts index bfe4f98840..47ae8ec157 100644 --- a/apps/web/src/page/infra/kit/view-tabs.ts +++ b/apps/web/src/page/infra/kit/view-tabs.ts @@ -1,11 +1,9 @@ -import { cn } from "@maple/ui/lib/utils" import type { Html, HtmlBuilder } from "foldkit/html" import { AppRoute, hrefOf } from "../../../route" import type { TimeSearch } from "../../kit/time-controls" -import { underlineTabClass, underlineTabStrip } from "../../kit/underline-tabs" +import { linkTabStrip } from "../../kit/underline-tabs" -// The link strip on `UnderlineTabStrip navigation` and the two section strips built on it: -// `HostsViewTabs` and `KubernetesViewTabs`. +// `HostsViewTabs` and `KubernetesViewTabs`, on the kit's `linkTabStrip`. /** `onClick` handles a plain click in place of the router; the href still serves new tabs. */ export type TabLink = Readonly<{ @@ -15,38 +13,24 @@ export type TabLink = Readonly<{ onClick?: M | undefined }> -/** A `` strip: TanStack marks the current one `active` with `data-status`. */ -export const linkTabStrip = ( +/** Maps `TabLink`s onto the kit strip; the infra-integrations list pages still call it this way. */ +export const viewTabStrip = ( h: HtmlBuilder, label: string, tabs: ReadonlyArray>, activeId: string, - /** Off when the strip sits in page flow rather than a header's `tabs` slot. */ - bleed = true, ): Html => - underlineTabStrip( + linkTabStrip( h, - { label, navigation: true, bleed }, - tabs.map((tab) => { - const isActive = tab.id === activeId - return h.a( - [ - h.Key(tab.id), - h.Href(tab.href), - ...(tab.onClick === undefined - ? [] - : [h.OnClick(tab.onClick, { defaultAction: "Prevent" })]), - ...(isActive - ? [ - h.AriaCurrent("page"), - h.DataAttribute("status", "active"), - h.Class(cn(underlineTabClass(true), "active")), - ] - : [h.Class(underlineTabClass(false))]), - ], - [tab.title], - ) - }), + label, + tabs.map((tab) => ({ + key: tab.id, + href: tab.href, + label: tab.title, + onClick: tab.onClick, + isCurrent: tab.id === activeId, + isRouteActive: tab.id === activeId, + })), ) export type { TimeSearch } @@ -62,7 +46,7 @@ export type HostsView = "hosts" | "containers" export const hostsViewTabs = (h: HtmlBuilder, view: HostsView, search: TimeSearch): Html => { const window = pickTimeSearch(search) - return linkTabStrip( + return viewTabStrip( h, "Hosts views", [ @@ -100,7 +84,7 @@ const kubernetesViews = (search: TimeSearch): ReadonlyArray => { /** `KubernetesViewTabs`. */ export const kubernetesViewTabs = (h: HtmlBuilder, view: KubernetesView, search: TimeSearch): Html => - linkTabStrip(h, "Kubernetes views", kubernetesViews(search), view) + viewTabStrip(h, "Kubernetes views", kubernetesViews(search), view) /** `KubernetesShell`'s breadcrumbs: the view crumb links back to its list once a trail follows. */ export const kubernetesBreadcrumbs = ( diff --git a/apps/web/src/page/infra/kubernetes/peek/view-header.ts b/apps/web/src/page/infra/kubernetes/peek/view-header.ts index db930a8d01..7d1a4a03cd 100644 --- a/apps/web/src/page/infra/kubernetes/peek/view-header.ts +++ b/apps/web/src/page/infra/kubernetes/peek/view-header.ts @@ -1,68 +1,49 @@ -import * as SheetStyles from "@maple/ui/styles/ui/sheet" -import { cn } from "@maple/ui/lib/utils" import type { Html, HtmlBuilder } from "foldkit/html" -import { descriptionId, titleId } from "../../../../ui/dialog" -import { dialogHeader } from "../../../../ui/dialog-view" -import { eyebrow } from "../../../../ui/eyebrow" import { heroChip } from "../../kit/hero-chip" import { relativeTime } from "../../../kit/relative-time" +import { sheetDetailHeader } from "../../../kit/sheet-detail-header" import { hostStatusBadge } from "../../kit/status" import type { PodRow } from "../pod-data" -// `SheetDetailHeader` as the peek uses it: the "Pod" eyebrow, the mono title with its status, and -// the workload chips. +// The peek's `SheetDetailHeader`: the "Pod" eyebrow, the mono title with its status, and the +// workload chips. -export type HeaderInputs = Readonly<{ dialogId: string; referenceTime: string; nowMs: number; timeZone: string }> +export type HeaderInputs = Readonly<{ + dialogId: string + referenceTime: string + nowMs: number + timeZone: string +}> const chip = (h: HtmlBuilder, prefix: string, value: string | null | undefined): ReadonlyArray => value ? [heroChip(h, [`${prefix} `, value])] : [] -export const peekHeader = (h: HtmlBuilder, pod: PodRow, inputs: HeaderInputs): Html => { - const badge = hostStatusBadge(h, { quiet: true, lastSeen: pod.lastSeen, referenceMs: inputs.referenceTime }) - return dialogHeader( - h, - "sheet", - [ - eyebrow(h, { as: "div", className: "flex flex-wrap items-center gap-x-2 gap-y-1" }, [ - h.span([h.Class("truncate")], ["Pod"]), - ]), - h.h2( - [ - h.Class(cn(SheetStyles.sheetTitleStyles, "flex flex-wrap items-center gap-2 text-title leading-tight font-mono")), - h.DataAttribute("slot", "sheet-title"), - h.Id(titleId(inputs.dialogId)), - ], - [h.span([h.Class("min-w-0 break-all")], [pod.podName]), ...(badge === null ? [] : [badge])], - ), - h.p( - [ - h.Class(cn(SheetStyles.sheetDescriptionStyles, "sr-only")), - h.DataAttribute("slot", "sheet-description"), - h.Id(descriptionId(inputs.dialogId)), - ], - [`Peak utilization and metrics for ${pod.podName}`], - ), - h.div( - [h.Class("flex flex-wrap items-center gap-1.5")], - [ - ...chip(h, "ns", pod.namespace), - ...chip(h, "deploy", pod.deploymentName), - ...chip(h, "sts", pod.statefulsetName), - ...chip(h, "ds", pod.daemonsetName), - ...chip(h, "job", pod.jobName), - ...chip(h, "node", pod.nodeName), - ...chip(h, "qos", pod.qosClass), - relativeTime(h, { - value: pod.lastSeen, - nowMs: inputs.nowMs, - timeZone: inputs.timeZone, - prefix: "seen", - mono: true, - className: "ml-auto text-2xs text-muted-foreground", - }), - ], - ), +export const peekHeader = (h: HtmlBuilder, pod: PodRow, inputs: HeaderInputs): Html => + sheetDetailHeader(h, { + dialogId: inputs.dialogId, + kind: "Pod", + title: [pod.podName], + adornment: hostStatusBadge(h, { + quiet: true, + lastSeen: pod.lastSeen, + referenceMs: inputs.referenceTime, + }), + description: `Peak utilization and metrics for ${pod.podName}`, + meta: [ + ...chip(h, "ns", pod.namespace), + ...chip(h, "deploy", pod.deploymentName), + ...chip(h, "sts", pod.statefulsetName), + ...chip(h, "ds", pod.daemonsetName), + ...chip(h, "job", pod.jobName), + ...chip(h, "node", pod.nodeName), + ...chip(h, "qos", pod.qosClass), + relativeTime(h, { + value: pod.lastSeen, + nowMs: inputs.nowMs, + timeZone: inputs.timeZone, + prefix: "seen", + mono: true, + className: "ml-auto text-2xs text-muted-foreground", + }), ], - "gap-1.5 pr-14", - ) -} + }) diff --git a/apps/web/src/page/integrations/view-header.ts b/apps/web/src/page/integrations/view-header.ts index 2e82069a4a..d44723e482 100644 --- a/apps/web/src/page/integrations/view-header.ts +++ b/apps/web/src/page/integrations/view-header.ts @@ -2,6 +2,7 @@ import type { Html, HtmlBuilder } from "foldkit/html" import { badge } from "../../ui/display" import { icon } from "../../ui/icon" import { iconButton } from "../../ui/icon-button" +import { pageTitle } from "../../ui/page-layout" import { statusDot } from "../../ui/status-dot" import { catalogEntry, type IntegrationId } from "./catalog" import { cardStatusOf, HEALTH_TONE, overviewOf } from "./derive" @@ -54,9 +55,9 @@ export const integrationHeader = ( [h.Class("flex min-w-0 flex-1 flex-col gap-0.5")], [ h.div( - [h.Class("flex items-center gap-2")], + [h.Class("flex min-w-0 items-center gap-2")], [ - h.h1([h.Class("font-display text-lg/6 font-semibold")], [entry.name]), + pageTitle(h, { title: entry.name, className: "min-w-0" }, [entry.name]), ...(connected === null && status !== null ? [badge(h, { variant: status.variant }, [status.label])] : []), diff --git a/apps/web/src/page/integrations/view-hub.ts b/apps/web/src/page/integrations/view-hub.ts index 2e362e302e..62e31d6418 100644 --- a/apps/web/src/page/integrations/view-hub.ts +++ b/apps/web/src/page/integrations/view-hub.ts @@ -1,5 +1,5 @@ import { cn } from "@maple/ui/lib/utils" -import { pageDescriptionStyles, pageTitleStyles } from "@maple/ui/styles/ui/page-layout" +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" @@ -7,6 +7,7 @@ import { errorState } from "../../ui/error-state" import { badge } from "../../ui/display" import { eyebrow } from "../../ui/eyebrow" import { icon } from "../../ui/icon" +import { pageTitle } from "../../ui/page-layout" import { panel } from "../../ui/panel" import { skeleton } from "../../ui/skeleton" import { statusDot } from "../../ui/status-dot" @@ -220,7 +221,7 @@ export const hubTitle = (h: H): Html => h.div( [h.Class("flex flex-col gap-1")], [ - h.h1([h.Class(cn(pageTitleStyles, "leading-8"))], ["Integrations"]), + pageTitle(h, { className: "leading-8" }, ["Integrations"]), h.p( [h.Class(cn(pageDescriptionStyles, "text-sm"))], [ diff --git a/apps/web/src/page/investigations/detail/view-action-sheet.ts b/apps/web/src/page/investigations/detail/view-action-sheet.ts index 4ed664b6c9..d9fd2e5b9c 100644 --- a/apps/web/src/page/investigations/detail/view-action-sheet.ts +++ b/apps/web/src/page/investigations/detail/view-action-sheet.ts @@ -1,14 +1,12 @@ import type { V2Investigation } from "@maple/domain/http/v2" import { cn } from "@maple/ui/lib/utils" -import { sheetDescriptionStyles, sheetTitleStyles } from "@maple/ui/styles/ui/sheet" import type { ActionNodeData } from "@maple/web-data/investigations/provenance-graph" import type { Html, HtmlBuilder } from "foldkit/html" import { linkButton } from "../../../ui/button" -import { descriptionId, titleId } from "../../../ui/dialog" import * as DialogView from "../../../ui/dialog-view" import { eyebrow } from "../../../ui/eyebrow" import { icon } from "../../../ui/icon" -import { detailEyebrow } from "../../kit/detail-header" +import { sheetDetailHeader } from "../../kit/sheet-detail-header" import { CONFIDENCE_TONE } from "../kit" import { openActionIndexOf } from "./derive" import { graphOf } from "./graph" @@ -23,7 +21,8 @@ import { evidenceChips, type TipTrigger } from "./view-sections" type H = HtmlBuilder type Report = NonNullable -const sectionEyebrow = (h: H, title: string): Html => eyebrow(h, { as: "h3", className: "block mb-0" }, [title]) +const sectionEyebrow = (h: H, title: string): Html => + eyebrow(h, { as: "h3", className: "block mb-0" }, [title]) export const actionSheetContent = ( h: H, @@ -33,39 +32,28 @@ export const actionSheetContent = ( trigger: TipTrigger, ): ReadonlyArray => { const glyph = ACTION_GLYPH[action.kind] - const evidence = (report?.evidence ?? []).filter((item) => item.note || item.traceIds.length > 0 || item.logPatterns.length > 0) + const evidence = (report?.evidence ?? []).filter( + (item) => item.note || item.traceIds.length > 0 || item.logPatterns.length > 0, + ) return [ - DialogView.dialogHeader( - h, - "sheet", - [ - detailEyebrow(h, [ - h.span( - [h.Class("flex items-center gap-2")], - [ - icon(h, glyph.icon, { size: 12, className: "shrink-0" }), - "Proposed action ", - String(action.index + 1).padStart(2, "0"), - h.span([h.Class("text-muted-foreground/40")], ["·"]), - glyph.label, - ], - ), - ]) ?? h.span([], []), - h.h2( + sheetDetailHeader(h, { + dialogId, + kind: [ + h.span( + [h.Class("flex items-center gap-2")], [ - h.Class(cn(sheetTitleStyles, "flex flex-wrap items-center gap-2 text-title leading-tight")), - h.DataAttribute("slot", "sheet-title"), - h.Id(titleId(dialogId)), + icon(h, glyph.icon, { size: 12, className: "shrink-0" }), + "Proposed action ", + String(action.index + 1).padStart(2, "0"), + h.span([h.Class("text-muted-foreground/40")], ["·"]), + glyph.label, ], - [h.span([h.Class("min-w-0 break-words")], [action.text])], - ), - h.p( - [h.Class(cn(sheetDescriptionStyles, "sr-only")), h.DataAttribute("slot", "sheet-description"), h.Id(descriptionId(dialogId))], - ["What this proposed action asks for, and the diagnosis behind it."], ), ], - "gap-1.5 pr-14", - ), + mono: false, + title: [action.text], + description: "What this proposed action asks for, and the diagnosis behind it.", + }), DialogView.dialogPanel( h, "sheet", @@ -77,13 +65,18 @@ export const actionSheetContent = ( ...(action.target === null ? [] : [ - linkButton(h, { size: "sm", href: action.target.href, className: "gap-1.5" }, [ - action.target.label, - icon(h, "ArrowRightIcon"), - ]), + linkButton( + h, + { size: "sm", href: action.target.href, className: "gap-1.5" }, + [action.target.label, icon(h, "ArrowRightIcon")], + ), ]), h.span( - [h.Class("rounded border border-dashed px-2 py-1 text-3xs tracking-wider text-muted-foreground/70")], + [ + h.Class( + "rounded border border-dashed px-2 py-1 text-3xs tracking-wider text-muted-foreground/70", + ), + ], [action.promise], ), ], @@ -95,13 +88,33 @@ export const actionSheetContent = ( [h.Class("flex flex-col gap-2")], [ sectionEyebrow(h, "From the diagnosis"), - h.p([h.Class("text-sm leading-relaxed text-foreground")], [report.suspectedCause]), h.p( - [h.Class("flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground")], + [h.Class("text-sm leading-relaxed text-foreground")], + [report.suspectedCause], + ), + h.p( + [ + h.Class( + "flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground", + ), + ], [ - h.span([h.Class(cn("font-medium capitalize", CONFIDENCE_TONE[report.confidence]))], [report.confidence, " confidence"]), + h.span( + [ + h.Class( + cn( + "font-medium capitalize", + CONFIDENCE_TONE[report.confidence], + ), + ), + ], + [report.confidence, " confidence"], + ), ...(report.affectedScope - ? [h.span([h.Class("text-muted-foreground/40")], ["·"]), h.span([], [report.affectedScope])] + ? [ + h.span([h.Class("text-muted-foreground/40")], ["·"]), + h.span([], [report.affectedScope]), + ] : []), ], ), @@ -117,10 +130,25 @@ export const actionSheetContent = ( sectionEyebrow(h, "Evidence behind the diagnosis"), ...evidence.map((item, index) => { const chips = evidenceChips(h, item, "sheet", trigger) - return h.keyed("div")(String(index), [h.Class("flex flex-col gap-1.5 rounded-md bg-muted/40 p-3")], [ - ...(item.note ? [h.p([h.Class("text-sm leading-relaxed text-foreground")], [item.note])] : []), - ...(chips === null ? [] : [chips]), - ]) + return h.keyed("div")( + String(index), + [h.Class("flex flex-col gap-1.5 rounded-md bg-muted/40 p-3")], + [ + ...(item.note + ? [ + h.p( + [ + h.Class( + "text-sm leading-relaxed text-foreground", + ), + ], + [item.note], + ), + ] + : []), + ...(chips === null ? [] : [chips]), + ], + ) }), ], ), @@ -132,11 +160,18 @@ export const actionSheetContent = ( } /** The open action's panel; a stale or mangled `?action=` finds nothing and renders closed. */ -export const actionSheet = (h: H, model: Model, investigation: V2Investigation, timeZone: string, trigger: TipTrigger): Html => { +export const actionSheet = ( + h: H, + model: Model, + investigation: V2Investigation, + timeZone: string, + trigger: TipTrigger, +): Html => { const index = openActionIndexOf(model) const action = - graphOf(investigation, timeZone).nodes.flatMap((node) => (node.type === "action" && node.data.index === index ? [node.data] : []))[0] ?? - null + graphOf(investigation, timeZone).nodes.flatMap((node) => + node.type === "action" && node.data.index === index ? [node.data] : [], + )[0] ?? null const dialogId = model.actionSheet.id return h.submodel({ slotId: `${dialogId}-slot`, @@ -152,10 +187,7 @@ export const actionSheet = (h: H, model: Model, investigation: V2Investigation, action === null ? [ DialogView.dialogHeader(h, "sheet", [ - h.h2( - [h.Class(cn(sheetTitleStyles, "sr-only")), h.DataAttribute("slot", "sheet-title"), h.Id(titleId(dialogId))], - ["Proposed action"], - ), + DialogView.dialogTitle(h, "sheet", dialogId, "Proposed action", "sr-only"), ]), ] : actionSheetContent(h, dialogId, action, investigation.report, trigger), diff --git a/apps/web/src/page/investigations/detail/view.ts b/apps/web/src/page/investigations/detail/view.ts index 7acbe0ba21..c1e7aedc07 100644 --- a/apps/web/src/page/investigations/detail/view.ts +++ b/apps/web/src/page/investigations/detail/view.ts @@ -26,7 +26,7 @@ import type { PageViewInputs } from "../../contract" import { dashboardPage } from "../../kit/dashboard-page" import { detailActions, detailTitle, detailTitleSkeleton } from "../../kit/detail-header" import { resourceNotFound } from "../../kit/resource-not-found" -import { underlineTabClass, underlineTabStrip } from "../../kit/underline-tabs" +import { linkTabStrip } from "../../kit/underline-tabs" import { severityBadge } from "../../errors/kit/severity" import { investigationKindLabel, statusBadge } from "../kit" import { breadcrumbLabel, type Tab, tabOf, traceTips } from "./derive" @@ -151,39 +151,22 @@ const TABS: ReadonlyArray<{ value: Tab; label: string }> = [ /** `InvestigationTabs`: links, so the open tab is in the URL; Evidence only when there is some. */ const tabs = (h: H, investigation: V2Investigation, active: Tab): Html => { const evidenceCount = investigation.report?.evidence.length ?? 0 - return underlineTabStrip( + return linkTabStrip( h, - { label: "Investigation sections" }, - TABS.filter((tab) => tab.value !== "evidence" || evidenceCount > 0).map((tab) => { - const isActive = tab.value === active - const href = hrefOf( + "Investigation sections", + TABS.filter((tab) => tab.value !== "evidence" || evidenceCount > 0).map((tab) => ({ + key: tab.value, + href: hrefOf( AppRoute.Investigation({ id: investigation.id, search: tab.value === "overview" ? {} : { tab: tab.value }, }), - ) - return h.keyed("a")( - tab.value, - [ - h.Href(href), - h.Role("tab"), - h.AriaSelected(isActive), - ...(isActive ? [h.AriaCurrent("page"), h.DataAttribute("status", "active")] : []), - h.Class(`${underlineTabClass(isActive)}${isActive ? " active" : ""}`), - ], - [ - tab.label, - ...(tab.value === "evidence" - ? [ - h.span( - [h.Class("text-xs text-muted-foreground tabular-nums")], - [String(evidenceCount)], - ), - ] - : []), - ], - ) - }), + ), + label: tab.label, + count: tab.value === "evidence" ? evidenceCount : undefined, + isCurrent: tab.value === active, + isRouteActive: tab.value === active, + })), ) } @@ -299,7 +282,7 @@ const readyPage = (h: H, model: Model, investigation: V2Investigation, timeZone: { titleContent: titleContent(h, investigation), actions: headerActions(h, model, investigation), - sticky: tabs(h, investigation, tab), + tabs: tabs(h, investigation, tab), fill: true, }, [ diff --git a/apps/web/src/page/investigations/list/view.ts b/apps/web/src/page/investigations/list/view.ts index 690cfc50ed..d293265c1f 100644 --- a/apps/web/src/page/investigations/list/view.ts +++ b/apps/web/src/page/investigations/list/view.ts @@ -18,6 +18,7 @@ import { button, linkButton } from "../../../ui/button" import { empty, emptyContent, emptyDescription, emptyHeader, emptyTitle } from "../../../ui/empty" import { errorState } from "../../../ui/error-state" import { eyebrow } from "../../../ui/eyebrow" +import { pageTitle } from "../../../ui/page-layout" import { icon } from "../../../ui/icon" import { listRow } from "../../../ui/list-row" import { panel } from "../../../ui/panel" @@ -357,6 +358,8 @@ const HERO_SUGGESTIONS = [ "Which service is slowest right now, and why?", ] +const HUB_TITLE = "Ask, and Maple goes and finds out." + /** `HubHero`: the first-run page. */ const hubHero = (h: H, model: Model): Html => { const presence = presenceOf(model.presence, "traces") @@ -373,14 +376,9 @@ const hubHero = (h: H, model: Model): Html => { h.div( [h.Class("flex flex-wrap items-baseline gap-3")], [ - h.h1( - [ - h.Class( - "font-display text-2xl font-semibold leading-[1.1] tracking-tight text-foreground", - ), - ], - ["Ask, and Maple goes and finds out."], - ), + pageTitle(h, { title: HUB_TITLE, className: "text-foreground" }, [ + HUB_TITLE, + ]), ], ), h.p( diff --git a/apps/web/src/page/kit/dashboard-page.ts b/apps/web/src/page/kit/dashboard-page.ts index 9f1e79fc5c..bf0ff3533d 100644 --- a/apps/web/src/page/kit/dashboard-page.ts +++ b/apps/web/src/page/kit/dashboard-page.ts @@ -2,21 +2,22 @@ import { cn } from "@maple/ui/lib/utils" import { pageBodyStyles, pageContentStyles, - pageDescriptionStyles, pageFillStyles, pageFilterSidebarStyles, pageFilterSidebarWidthStyles, - pageHeaderActionsStyles, - pageHeaderStyles, - pageHeaderTextStyles, pageRightSidebarStyles, pageRightSidebarWidthStyles, pageScrollAreaStyles, pageStickyAreaScrolledStyles, pageStickyAreaStyles, - pageTitleStyles, } from "@maple/ui/styles/ui/page-layout" import type { Html, HtmlBuilder } from "foldkit/html" +import { + pageDescription, + pageHeaderActions, + pageHeader as layoutHeader, + pageTitle, +} from "../../ui/page-layout" // The dashboard page below the shell's top bar: // `PageLayout.Body` with its filter rail, content column, sticky header and scroll area. @@ -84,29 +85,18 @@ export const pageContent = (h: HtmlBuilder, children: ReadonlyArray) children, ) -/** `PageLayout.Header` + `HeaderActions`. */ +/** `PageLayout.Header` + `HeaderActions` from a composed title block and actions. */ export const pageHeader = ( h: HtmlBuilder, titleContent: Html | undefined, actions: Html | undefined, className?: string, ): Html => - h.div( - [h.DataAttribute("slot", "page-header"), h.Class(cn(pageHeaderStyles, className))], - [ - h.div([h.Class(pageHeaderTextStyles)], titleContent === undefined ? [] : [titleContent]), - ...(actions === undefined - ? [] - : [ - h.div( - [ - h.DataAttribute("slot", "page-header-actions"), - h.Class(pageHeaderActionsStyles), - ], - [actions], - ), - ]), - ], + layoutHeader( + h, + // An empty text block still holds the actions to the right. + { titleContent: titleContent ?? h.div([], []), className }, + actions === undefined ? [] : [pageHeaderActions(h, [actions])], ) /** `PageLayout.StickyArea`; the page reports the scrolled shadow. */ @@ -172,14 +162,14 @@ export const dashboardPage = ( const heading = options.title === undefined ? undefined - : h.h1([h.Class(cn(pageTitleStyles, "leading-8"))], [options.title]) + : pageTitle(h, { title: options.title, className: "leading-8" }, [options.title]) const titleContent = options.titleContent ?? (heading === undefined || options.description === undefined ? heading : h.div( [h.Class("flex flex-col gap-1")], - [heading, h.p([h.Class(cn(pageDescriptionStyles, "text-sm"))], [options.description])], + [heading, pageDescription(h, [options.description], "text-sm")], )) const hasHeader = titleContent !== undefined || options.actions !== undefined const headerWidth = options.width === "settings" ? PAGE_WIDTH.settings : undefined diff --git a/apps/web/src/page/kit/sheet-detail-header.ts b/apps/web/src/page/kit/sheet-detail-header.ts index 5f3a85edf9..442597c77c 100644 --- a/apps/web/src/page/kit/sheet-detail-header.ts +++ b/apps/web/src/page/kit/sheet-detail-header.ts @@ -1,7 +1,7 @@ import { cn } from "@maple/ui/lib/utils" import type { Html, HtmlBuilder } from "foldkit/html" import { dialogDescription, dialogHeader, dialogTitle } from "../../ui/dialog-view" -import { eyebrow } from "../../ui/eyebrow" +import { detailEyebrow } from "./detail-header" // `SheetDetailHeader`: a detail sheet's // eyebrow kind, title, screen-reader description and meta row. @@ -9,8 +9,11 @@ import { eyebrow } from "../../ui/eyebrow" export type SheetDetailHeaderOptions = Readonly<{ /** The sheet Dialog's id: the title and description are its `aria-labelledby`/`describedby`. */ dialogId: string - kind?: string + /** The eyebrow: one kind, or segments joined by a quiet dot. */ + kind?: string | ReadonlyArray title: ReadonlyArray + /** Shown beside the title, e.g. a status badge. */ + adornment?: Html | null description: string meta?: ReadonlyArray mono?: boolean @@ -19,22 +22,25 @@ export type SheetDetailHeaderOptions = Readonly<{ export const sheetDetailHeader = (h: HtmlBuilder, options: SheetDetailHeaderOptions): Html => { const isMono = options.mono ?? true + const eyebrowNode = + options.kind === undefined + ? null + : detailEyebrow(h, typeof options.kind === "string" ? [options.kind] : options.kind) return dialogHeader( h, "sheet", [ - ...(options.kind === undefined - ? [] - : [ - eyebrow(h, { as: "div", className: "flex flex-wrap items-center gap-x-2 gap-y-1" }, [ - h.span([h.Class("truncate")], [options.kind]), - ]), - ]), + ...(eyebrowNode === null ? [] : [eyebrowNode]), dialogTitle( h, "sheet", options.dialogId, - [h.span([h.Class(cn("min-w-0", isMono ? "break-all" : "break-words"))], options.title)], + [ + h.span([h.Class(cn("min-w-0", isMono ? "break-all" : "break-words"))], options.title), + ...(options.adornment === undefined || options.adornment === null + ? [] + : [options.adornment]), + ], cn("flex flex-wrap items-center gap-2 text-title leading-tight", isMono && "font-mono"), ), dialogDescription(h, "sheet", options.dialogId, options.description, "sr-only"), diff --git a/apps/web/src/page/kit/underline-tabs.ts b/apps/web/src/page/kit/underline-tabs.ts index f3ca4c7a45..78b925e58d 100644 --- a/apps/web/src/page/kit/underline-tabs.ts +++ b/apps/web/src/page/kit/underline-tabs.ts @@ -42,11 +42,16 @@ export const underlineTabClass = (isActive: boolean): string => : "border-transparent text-muted-foreground hover:text-foreground", ) -export type LinkTab = Readonly<{ +export type LinkTab = Readonly<{ key: string href: string label: string - icon: IconName + icon?: IconName + /** A tally after the label; `error` tints it for counts that need attention. */ + count?: number | undefined + countTone?: "muted" | "error" + /** Handles a plain click in place of the router; the href still serves new tabs. */ + onClick?: M | undefined /** The tab the page shows: underlined, its label and icon in the foreground colour. */ isCurrent: boolean /** The link matches the URL (path and search): `aria-current` and the router's `active`. */ @@ -54,7 +59,7 @@ export type LinkTab = Readonly<{ }> /** A page's tabs as links: each one a route, the current one underlined. */ -export const linkTabStrip = (h: HtmlBuilder, label: string, tabs: ReadonlyArray): Html => +export const linkTabStrip = (h: HtmlBuilder, label: string, tabs: ReadonlyArray>): Html => underlineTabStrip( h, { @@ -71,18 +76,39 @@ export const linkTabStrip = (h: HtmlBuilder, label: string, tabs: Readonly ...(tab.isRouteActive ? [h.AriaCurrent("page")] : []), h.Class( cn( - cn( - underlineTabClass(tab.isCurrent), - "h-9 gap-[7px] px-3 first:pl-0.5", - tab.isCurrent ? "[&_svg]:text-foreground" : "[&_svg]:text-muted-foreground", - ), + underlineTabClass(tab.isCurrent), + "h-9 gap-[7px] px-3 first:pl-0.5", + tab.isCurrent ? "[&_svg]:text-foreground" : "[&_svg]:text-muted-foreground", tab.isRouteActive && "active", ), ), ...(tab.isRouteActive ? [h.DataAttribute("status", "active")] : []), h.Href(tab.href), + ...(tab.onClick === undefined + ? [] + : [h.OnClick(tab.onClick, { defaultAction: "Prevent" })]), + ], + [ + ...(tab.icon === undefined ? [] : [icon(h, tab.icon, { size: 14 })]), + tab.label, + ...(tab.count === undefined + ? [] + : [ + h.span( + [ + h.Class( + cn( + "font-mono text-xs tabular-nums", + tab.countTone === "error" + ? "text-severity-error" + : "text-muted-foreground", + ), + ), + ], + [String(tab.count)], + ), + ]), ], - [icon(h, tab.icon, { size: 14 }), tab.label], ), ), ) diff --git a/apps/web/src/page/metrics/browse/view-table.ts b/apps/web/src/page/metrics/browse/view-table.ts index 440bf26b64..bcc1fd76cc 100644 --- a/apps/web/src/page/metrics/browse/view-table.ts +++ b/apps/web/src/page/metrics/browse/view-table.ts @@ -4,7 +4,16 @@ import { cn } from "@maple/ui/lib/utils" import type { Html, HtmlBuilder } from "foldkit/html" import { badge } from "../../../ui/display" import { listFooter } from "../../../ui/list-footer" -import { table, tableBody, tableCell, tableHead, tableHeader, tableRow } from "../../../ui/table" +import { + ROW_LINK_LIFT, + rowLink, + table, + tableBody, + tableCell, + tableHead, + tableHeader, + tableRow, +} from "../../../ui/table" import { tableSkeleton } from "../../../ui/table-skeleton" import { truncatedText } from "../../../ui/truncated-text" import { serviceDot } from "../../../ui/values" @@ -16,11 +25,6 @@ import { relativeTime } from "../../kit/relative-time" type H = HtmlBuilder -// A row link stretched over its row, and the lift for controls above it. -const ROW_STRETCHED_LINK_CLASS = - "after:absolute after:inset-0 after:content-[''] focus-visible:outline-none focus-visible:after:ring-1 focus-visible:after:ring-ring focus-visible:after:ring-inset" -const ROW_LINK_LIFT = "relative z-10" - const MD_CELL = "hidden md:table-cell" export const loadingTable = (h: H): Html => @@ -64,11 +68,12 @@ const metricRow = (h: H, model: Model, metric: MetricRow, timeZone: string): Htm h.div( [h.Class("flex min-w-0 flex-col gap-0.5")], [ - h.a( - [ - h.Href(metricLink(model, metric.metricName, metric.metricType)), - h.Class(cn("block min-w-0", ROW_STRETCHED_LINK_CLASS)), - ], + rowLink( + h, + { + href: metricLink(model, metric.metricName, metric.metricType), + className: "block min-w-0", + }, [ truncatedText(h, { text: metric.metricName, diff --git a/apps/web/src/page/security/kit/layout.ts b/apps/web/src/page/security/kit/layout.ts index 50e9149abb..9edb9ac9c1 100644 --- a/apps/web/src/page/security/kit/layout.ts +++ b/apps/web/src/page/security/kit/layout.ts @@ -1,9 +1,8 @@ -import { cn } from "@maple/ui/lib/utils" -import { pageTitleStyles } from "@maple/ui/styles/ui/page-layout" import type { Html, HtmlBuilder } from "foldkit/html" import { AppRoute, hrefOf } from "../../../route" import type { BreadcrumbEntry } from "../../../shell" import type { IconName } from "../../../ui/icon" +import { pageTitle } from "../../../ui/page-layout" import { dashboardPage } from "../../kit/dashboard-page" import { linkTabStrip } from "../../kit/underline-tabs" import type { Tab } from "../tabs/model" @@ -55,7 +54,7 @@ const securityTitle = (h: HtmlBuilder): Html => h.div( [h.Class("min-w-0 space-y-0.5")], [ - h.h1([h.Class(cn(pageTitleStyles, "leading-8 text-foreground"))], ["Security"]), + pageTitle(h, { className: "leading-8 text-foreground" }, ["Security"]), h.p([h.Class("max-w-2xl font-prose text-sm text-muted-foreground")], [DESCRIPTION]), ], ) diff --git a/apps/web/src/page/sources/new/view.ts b/apps/web/src/page/sources/new/view.ts index bae16890ee..59f16a2397 100644 --- a/apps/web/src/page/sources/new/view.ts +++ b/apps/web/src/page/sources/new/view.ts @@ -1,4 +1,5 @@ import { Submodel } from "foldkit" +import { pageTitleStyles } from "@maple/ui/styles/ui/page-layout" import type { Html, HtmlBuilder } from "foldkit/html" import { AppRoute, hrefOf } from "../../../route" import type { BreadcrumbEntry } from "../../../shell" @@ -6,6 +7,7 @@ import type { PageViewInputs } from "../../contract" import { dashboardPage } from "../../kit/dashboard-page" import { linkButton } from "../../../ui/button" import { icon } from "../../../ui/icon" +import { pageTitle } from "../../../ui/page-layout" import { platformMeta } from "../kit/platforms" import { Message } from "./message" import type { FlowHost, Model } from "./model" @@ -33,8 +35,6 @@ const metaOf = (model: Model) => ? integrationMeta(model.integration) : null -const TITLE_CLASS = "font-display text-2xl font-medium tracking-tight" - /** The source's mark, its title and the trail under it; `titleId` names the drawer's dialog by it. */ export const titleContent = (h: H, model: Model, titleId?: string): Html => { const meta = metaOf(model) @@ -55,12 +55,12 @@ export const titleContent = (h: H, model: Model, titleId?: string): Html => { ), ]), h.div( - [h.Class("space-y-2")], + [h.Class("min-w-0 space-y-2")], [ // In the drawer the title is the dialog's name, under the onboarding page's own h1. titleId === undefined - ? h.h1([h.Class(TITLE_CLASS)], [titleOf(model)]) - : h.h2([h.Class(TITLE_CLASS), h.Id(titleId)], [titleOf(model)]), + ? pageTitle(h, { title: titleOf(model) }, [titleOf(model)]) + : h.h2([h.Class(pageTitleStyles), h.Id(titleId)], [titleOf(model)]), trail(h, model), ], ), diff --git a/apps/web/src/page/traces/list/view-row.ts b/apps/web/src/page/traces/list/view-row.ts index 8139a6942f..f73f1eb105 100644 --- a/apps/web/src/page/traces/list/view-row.ts +++ b/apps/web/src/page/traces/list/view-row.ts @@ -15,7 +15,7 @@ import { httpSpanLabel } from "../../../ui/http-span-label" import { truncatedText } from "../../../ui/truncated-text" import { servicePills, traceStatusBadge } from "../../../ui/trace-badges" import { button } from "../../../ui/button" -import { tableCell } from "../../../ui/table" +import { rowLink, tableCell } from "../../../ui/table" import { Message } from "./message" import type { TraceRow } from "./model" @@ -77,16 +77,11 @@ const rootSpanCell = ( [h.Class("flex flex-col min-w-0")], [ // A plain click peeks (`MountPeekRows`); modified, middle and right clicks follow the link. - h.a( - [ - h.AriaLabel(`Open trace ${row.traceId}`), - h.Attribute("class", "after:absolute after:inset-0 after:content-['']"), - h.DataAttribute("peek-index", String(index)), - h.Href(traceHref(search, row)), - h.Tabindex(-1), - ], - [], - ), + rowLink(h, { + href: traceHref(search, row), + label: `Open trace ${row.traceId}`, + attributes: [h.DataAttribute("peek-index", String(index)), h.Tabindex(-1)], + }), httpSpanLabel(h, { spanName: displayName(row), spanAttributes: row.rootAttributes, diff --git a/apps/web/src/ui/page-layout.ts b/apps/web/src/ui/page-layout.ts index 90d99b68f1..fa08f1143e 100644 --- a/apps/web/src/ui/page-layout.ts +++ b/apps/web/src/ui/page-layout.ts @@ -7,7 +7,7 @@ import { pageLayoutStyles, pageTitleStyles, } from "@maple/ui/styles/ui/page-layout" -import type { Html, HtmlBuilder } from "foldkit/html" +import type { Attribute, Html, HtmlBuilder } from "foldkit/html" // Port of the header half of packages/ui/src/components/ui/page-layout.tsx (Root, Header, Title, // Description, HeaderActions). The sticky area, sidebars and sheets belong to the app shell port. @@ -20,7 +20,12 @@ export const pageLayoutRoot = (h: HtmlBuilder, children: Children, classNa /** `PageLayout.Title`: the one canonical page `

`; `title` is the native tooltip when it truncates. */ export const pageTitle = ( h: HtmlBuilder, - options: { readonly title?: string; readonly className?: string }, + options: { + readonly title?: string + readonly className?: string + /** Behaviour on the heading itself (an inline-editable title's click to rename). */ + readonly attributes?: ReadonlyArray> + }, children: Children, ): Html => h.h1( @@ -28,6 +33,7 @@ export const pageTitle = ( h.DataAttribute("slot", "page-title"), h.Class(cn(pageTitleStyles, options.className)), ...(options.title === undefined ? [] : [h.Title(options.title)]), + ...(options.attributes ?? []), ], children, ) diff --git a/apps/web/src/ui/table.ts b/apps/web/src/ui/table.ts index 9fbca23451..62744aa7af 100644 --- a/apps/web/src/ui/table.ts +++ b/apps/web/src/ui/table.ts @@ -7,6 +7,8 @@ import { tableHeadStyles, tableHeaderStickyStyles, tableHeaderStyles, + tableRowLinkLiftStyles, + tableRowLinkStyles, tableRowStyles, tableStyles, } from "@maple/ui/styles/ui/table" @@ -120,3 +122,28 @@ export const tableCell = ( ], children, ) + +/** Lift for a control inside a `rowLink` row: it stays clickable above the overlay. */ +export const ROW_LINK_LIFT = tableRowLinkLiftStyles + +/** Row navigation: a real link stretched over its row (any positioned ancestor), never a row + * onClick. Without children it is an empty overlay and `label` is its accessible name. */ +export const rowLink = ( + h: HtmlBuilder, + options: { + readonly href: string + readonly label?: string + readonly className?: string + readonly attributes?: ReadonlyArray> + }, + children: Children = [], +): Html => + h.a( + [ + h.Href(options.href), + h.Attribute("class", cn(tableRowLinkStyles, options.className)), + ...(options.label === undefined ? [] : [h.AriaLabel(options.label)]), + ...(options.attributes ?? []), + ], + children, + ) diff --git a/packages/ui/src/components/ui/table.styles.ts b/packages/ui/src/components/ui/table.styles.ts index de64709e34..8f366781ac 100644 --- a/packages/ui/src/components/ui/table.styles.ts +++ b/packages/ui/src/components/ui/table.styles.ts @@ -30,3 +30,10 @@ export const tableCellStyles = export const truncatedCellStyles = "w-full max-w-0" export const tableCaptionStyles = "in-data-[variant=card]:my-4 mt-4 text-muted-foreground text-sm" + +// A row's link stretched over the nearest positioned ancestor (the row); the ring shows on keyboard focus. +export const tableRowLinkStyles = + "after:absolute after:inset-0 after:content-[''] focus-visible:outline-none focus-visible:after:ring-1 focus-visible:after:ring-ring focus-visible:after:ring-inset" + +// Lifts a control inside a linked row above the overlay so it keeps its own clicks. +export const tableRowLinkLiftStyles = "relative z-10" From 6bf8b9a6ee3a056138294fde6d90ebc790309af2 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 30 Oct 2026 18:51:50 +0100 Subject: [PATCH 2/2] fix(web): link tab strips scroll when narrow, hero and anomaly titles wrap --- apps/web/src/page/anomalies/detail/view-body.ts | 3 ++- apps/web/src/page/investigations/list/view.ts | 2 +- apps/web/src/page/kit/underline-tabs.ts | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/web/src/page/anomalies/detail/view-body.ts b/apps/web/src/page/anomalies/detail/view-body.ts index 6857763720..6210881285 100644 --- a/apps/web/src/page/anomalies/detail/view-body.ts +++ b/apps/web/src/page/anomalies/detail/view-body.ts @@ -83,7 +83,8 @@ export const anomalyHero = (h: H, incident: AnomalyIncidentDocument, nowMs: numb h, { title: `${SIGNAL_LABEL[incident.signalType]} · ${incident.serviceName}`, - className: "min-w-0 text-foreground", + className: + "min-w-0 whitespace-normal break-words text-foreground", }, [ SIGNAL_LABEL[incident.signalType], diff --git a/apps/web/src/page/investigations/list/view.ts b/apps/web/src/page/investigations/list/view.ts index d293265c1f..2090e1647b 100644 --- a/apps/web/src/page/investigations/list/view.ts +++ b/apps/web/src/page/investigations/list/view.ts @@ -376,7 +376,7 @@ const hubHero = (h: H, model: Model): Html => { h.div( [h.Class("flex flex-wrap items-baseline gap-3")], [ - pageTitle(h, { title: HUB_TITLE, className: "text-foreground" }, [ + pageTitle(h, { className: "min-w-0 whitespace-normal text-foreground" }, [ HUB_TITLE, ]), ], diff --git a/apps/web/src/page/kit/underline-tabs.ts b/apps/web/src/page/kit/underline-tabs.ts index 78b925e58d..90ae17edbb 100644 --- a/apps/web/src/page/kit/underline-tabs.ts +++ b/apps/web/src/page/kit/underline-tabs.ts @@ -67,7 +67,7 @@ export const linkTabStrip = (h: HtmlBuilder, label: string, tabs: Readonly label, bleed: false, divided: false, - className: "gap-0 overflow-visible border-b border-border", + className: "gap-0 border-b border-border", }, tabs.map((tab) => h.a(