diff --git a/apps/local-ui/src/shell/filter-drawer.ts b/apps/local-ui/src/shell/filter-drawer.ts index bf231fb874..ffe3cb1b62 100644 --- a/apps/local-ui/src/shell/filter-drawer.ts +++ b/apps/local-ui/src/shell/filter-drawer.ts @@ -23,6 +23,7 @@ const cloneNode = (node: VNode): VNode => ({ export const filterRailOf = (model: Model, page: Html): Option.Option => model.filterDrawer.isOpen ? Option.map(splitPageRails(page).maybeFilters, (rail) => ({ + ...rail, children: rail.children.map((child) => (child === null ? child : cloneNode(child))), })) : Option.none() diff --git a/apps/web/src/page/agent-sessions/list/view-filters.ts b/apps/web/src/page/agent-sessions/list/view-filters.ts index 4f2d91276f..a7fc0ca2e3 100644 --- a/apps/web/src/page/agent-sessions/list/view-filters.ts +++ b/apps/web/src/page/agent-sessions/list/view-filters.ts @@ -4,7 +4,6 @@ import { Option } from "effect" import { AsyncData } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import * as Checkbox from "../../../ui/checkbox" -import { separator } from "../../../ui/display" import { filterDescriptionMark, filterSection } from "../../../ui/filters/filter-section" import { filterSidebarBody, @@ -13,6 +12,7 @@ import { filterSidebarFrame, filterSidebarHeader, filterSidebarLoading, + filterSidebarDivider, } from "../../../ui/filters/filter-sidebar" import { percentilePresets, toLogBuckets } from "../../../ui/filters/range-distribution" import { type RangePreset, rangeFilterSection } from "../../../ui/filters/range-filter-section" @@ -175,7 +175,7 @@ const singleCheckbox = (h: H, title: string, isChecked: boolean, onToggle: Messa h, { className: - "flex-1 min-w-0 flex items-center gap-1.5 cursor-pointer text-xs text-foreground font-normal", + "flex-1 min-w-0 flex items-center gap-1.5 cursor-pointer text-xs text-foreground font-normal sm:text-xs", attributes: [h.For(inputId), h.Id(Checkbox.labelId(inputId)), h.Title(title)], }, [h.span([h.Class("truncate")], [title]), filterDescriptionMark(h, title)], @@ -237,9 +237,9 @@ const loadedRail = (h: H, model: Model, facets: Facets): Html => { { title: "Environment", facet: "environments", isSearchable: false, hasDescription: false }, facets.environments, ), - separator(h, "horizontal", "my-2"), + filterSidebarDivider(h), ...RANGES.map((spec) => rangeSection(h, model, distributions, spec)), - separator(h, "horizontal", "my-2"), + filterSidebarDivider(h), singleCheckbox( h, "Hide single-trace sessions", diff --git a/apps/web/src/page/analytics/view-filters.ts b/apps/web/src/page/analytics/view-filters.ts index 29d1b187ca..231c64759b 100644 --- a/apps/web/src/page/analytics/view-filters.ts +++ b/apps/web/src/page/analytics/view-filters.ts @@ -4,8 +4,6 @@ import { countryLabel, languageLabel, referrerLabel, utmLabel } from "../../comp import { Option } from "effect" import { AsyncData } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" -import { separator } from "../../ui/display" -import { eyebrow } from "../../ui/eyebrow" import { type FilterOption, filterSection, singleCheckboxFilter } from "../../ui/filters/filter-section" import * as FilterSections from "../../ui/filters/filter-sections" import { @@ -15,6 +13,8 @@ import { filterSidebarFrame, filterSidebarHeader, filterSidebarLoading, + filterSidebarDivider, + filterSidebarGroupLabel, } from "../../ui/filters/filter-sidebar" import { icon, type IconName } from "../../ui/icon" import { browserIconFor, deviceIconFor } from "../replays/kit/sessions-parts" @@ -58,7 +58,7 @@ const section = ( } const checkboxGroup = (h: H, title: string, children: ReadonlyArray): Html => - h.div([h.Class("py-1")], [eyebrow(h, { as: "h4", variant: "label", className: "py-1" }, [title]), ...children]) + h.div([h.Class("pb-3")], [filterSidebarGroupLabel(h, title), ...children]) const loadedRail = (h: H, model: Model, breakdowns: Breakdowns): Html => { const filters = filtersOf(model.search) @@ -85,7 +85,7 @@ const loadedRail = (h: H, model: Model, breakdowns: Breakdowns): Html => { onToggle: Message.ToggledTraffic({ which: "bots", isOn: !trafficIncludes(filters.traffic, "bots") }), }), ]), - separator(h, "horizontal", "my-1"), + filterSidebarDivider(h), section(h, model, "host", breakdowns.hosts, { renderOptionIcon: hostIcon }), section(h, model, "pagePath", breakdowns.entryPaths), events.length > 0 || filters.eventName !== undefined @@ -96,7 +96,7 @@ const loadedRail = (h: H, model: Model, breakdowns: Breakdowns): Html => { events.map((event) => ({ name: event.name, count: event.sessions })), ) : null, - separator(h, "horizontal", "my-1"), + filterSidebarDivider(h), section(h, model, "referrerHost", breakdowns.referrerHosts, { getOptionLabel: referrerLabel, renderOptionIcon: (name) => @@ -110,7 +110,7 @@ const loadedRail = (h: H, model: Model, breakdowns: Breakdowns): Html => { }), section(h, model, "utmMedium", breakdowns.utmMediums, { isSearchable: false, getOptionLabel: utmLabel }), section(h, model, "utmCampaign", breakdowns.utmCampaigns, { getOptionLabel: utmLabel }), - separator(h, "horizontal", "my-1"), + filterSidebarDivider(h), checkboxGroup(h, FILTER_SECTION_LABEL.visitorType, [ singleCheckboxFilter(h, { title: "New", @@ -128,7 +128,7 @@ const loadedRail = (h: H, model: Model, breakdowns: Breakdowns): Html => { ]), section(h, model, "country", breakdowns.countries, { getOptionLabel: countryLabel }), section(h, model, "language", breakdowns.languages, { getOptionLabel: languageLabel }), - separator(h, "horizontal", "my-1"), + filterSidebarDivider(h), section(h, model, "deviceType", breakdowns.deviceTypes, { isSearchable: false, getOptionIcon: deviceIconFor }), section(h, model, "browserName", breakdowns.browsers, { isSearchable: false, getOptionIcon: browserIconFor }), section(h, model, "osName", breakdowns.operatingSystems, { isSearchable: false }), diff --git a/apps/web/src/page/anomalies/detail/view-sidebar.ts b/apps/web/src/page/anomalies/detail/view-sidebar.ts index ce53b766b6..33a74a0507 100644 --- a/apps/web/src/page/anomalies/detail/view-sidebar.ts +++ b/apps/web/src/page/anomalies/detail/view-sidebar.ts @@ -28,7 +28,12 @@ import { Message } from "./message" type H = HtmlBuilder -export type SidebarInput = Readonly<{ incident: AnomalyIncidentDocument; isBusy: boolean; nowMs: number; timeZone: string }> +export type SidebarInput = Readonly<{ + incident: AnomalyIncidentDocument + isBusy: boolean + nowMs: number + timeZone: string +}> const row = (h: H, label: string, children: ReadonlyArray, title?: string): Html => detailRailRow(h, { label, title }, children) @@ -48,7 +53,7 @@ export const anomalySidebar = (h: H, input: SidebarInput): Html => { const time = (value: string, className?: string) => relativeTime(h, { value, nowMs: input.nowMs, timeZone: input.timeZone, tooltip: "title", className }) return h.div( - [h.Class("flex h-full w-72 shrink-0 flex-col overflow-y-auto border-l bg-card/30")], + [h.Class("flex flex-col")], [ detailRailGroup(h, "Actions", [ ...(isOpen @@ -95,11 +100,21 @@ export const anomalySidebar = (h: H, input: SidebarInput): Html => { h.span( [h.Class("text-right text-sm text-foreground")], isStale - ? ["stale · ", relativeTime(h, { value: incident.lastTriggeredAt, nowMs: input.nowMs, timeZone: input.timeZone, prefix: "last seen" })] + ? [ + "stale · ", + relativeTime(h, { + value: incident.lastTriggeredAt, + nowMs: input.nowMs, + timeZone: input.timeZone, + prefix: "last seen", + }), + ] : [isOpen ? "open" : "resolved"], ), ]), - row(h, "Signal", [h.span([h.Class("text-sm text-foreground")], [SIGNAL_LABEL[incident.signalType]])]), + row(h, "Signal", [ + h.span([h.Class("text-sm text-foreground")], [SIGNAL_LABEL[incident.signalType]]), + ]), row(h, "Severity", [ h.span( [h.Class(cn("text-sm font-medium", live ? tone.text : "text-muted-foreground"))], @@ -120,11 +135,18 @@ export const anomalySidebar = (h: H, input: SidebarInput): Html => { ], incident.serviceName, ), - row(h, "Environment", [h.span([h.Class("text-sm text-foreground")], [incident.deploymentEnv || EMPTY_VALUE])]), + row(h, "Environment", [ + h.span([h.Class("text-sm text-foreground")], [incident.deploymentEnv || EMPTY_VALUE]), + ]), row( h, "Detector", - [h.code([h.Class("block max-w-full truncate font-mono text-xs text-muted-foreground")], [incident.detectorKey])], + [ + h.code( + [h.Class("block max-w-full truncate font-mono text-xs text-muted-foreground")], + [incident.detectorKey], + ), + ], incident.detectorKey, ), ...(incident.fingerprintHash === null @@ -135,7 +157,11 @@ export const anomalySidebar = (h: H, input: SidebarInput): Html => { "Fingerprint", [ h.code( - [h.Class("block max-w-full truncate font-mono text-xs text-muted-foreground")], + [ + h.Class( + "block max-w-full truncate font-mono text-xs text-muted-foreground", + ), + ], [incident.fingerprintHash], ), ], @@ -144,7 +170,9 @@ export const anomalySidebar = (h: H, input: SidebarInput): Html => { ]), ]), detailRailGroup(h, "Values", [ - row(h, "Observed", [mono(h, fmt(incident.lastObservedValue), live ? tone.text : "text-foreground")]), + row(h, "Observed", [ + mono(h, fmt(incident.lastObservedValue), live ? tone.text : "text-foreground"), + ]), row(h, "At open", [mono(h, fmt(incident.openedValue), "text-muted-foreground")]), row(h, "Baseline", [mono(h, fmt(incident.baselineMedian), "text-muted-foreground")]), row(h, "Threshold", [mono(h, fmt(incident.thresholdValue), "text-muted-foreground")]), @@ -160,7 +188,9 @@ export const anomalySidebar = (h: H, input: SidebarInput): Html => { h.div( [ h.Key(fingerprint.fingerprintHash), - h.Class("grid min-h-7 grid-cols-[1fr_auto] items-center gap-x-2 py-0.5"), + h.Class( + "grid min-h-7 grid-cols-[1fr_auto] items-center gap-x-2 py-0.5", + ), h.Title(fingerprint.fingerprintHash), ], [ @@ -186,13 +216,18 @@ export const anomalySidebar = (h: H, input: SidebarInput): Html => { : truncatedId(h, { value: fingerprint.fingerprintHash, length: 10, - className: "truncate text-xs text-muted-foreground", + className: + "truncate text-xs text-muted-foreground", }), ], ), h.span( [h.Class("font-mono text-xs tabular-nums text-muted-foreground")], - [fingerprint.resolvedAt !== null ? "resolved" : fmt(fingerprint.lastValue)], + [ + fingerprint.resolvedAt !== null + ? "resolved" + : fmt(fingerprint.lastValue), + ], ), ], ), @@ -201,7 +236,9 @@ export const anomalySidebar = (h: H, input: SidebarInput): Html => { ] : []), detailRailGroup(h, "Timing", [ - row(h, "First triggered", [time(incident.firstTriggeredAt, "text-right text-sm tabular-nums text-foreground")]), + row(h, "First triggered", [ + time(incident.firstTriggeredAt, "text-right text-sm tabular-nums text-foreground"), + ]), ...(incident.reopenCount > 0 && incident.lastReopenedAt !== null ? [ row(h, "Reopened", [ @@ -215,24 +252,45 @@ export const anomalySidebar = (h: H, input: SidebarInput): Html => { ]), ] : []), - row(h, "Last triggered", [time(incident.lastTriggeredAt, "text-right text-sm tabular-nums text-foreground")]), + row(h, "Last triggered", [ + time(incident.lastTriggeredAt, "text-right text-sm tabular-nums text-foreground"), + ]), ...(incident.resolvedAt === null ? [] - : [row(h, "Resolved", [time(incident.resolvedAt, "text-right text-sm tabular-nums text-muted-foreground")])]), + : [ + row(h, "Resolved", [ + time( + incident.resolvedAt, + "text-right text-sm tabular-nums text-muted-foreground", + ), + ]), + ]), ...(incident.resolveReason === null ? [] : [ row(h, "Reason", [ - h.span([h.Class("text-right text-sm text-muted-foreground")], [RESOLVE_REASON_LABEL[incident.resolveReason]]), + h.span( + [h.Class("text-right text-sm text-muted-foreground")], + [RESOLVE_REASON_LABEL[incident.resolveReason]], + ), ]), ]), ]), detailRailGroup(h, "Triage", [ triageChip === null - ? h.p([h.Class("text-xs text-muted-foreground")], ["No AI triage has run for this incident."]) - : badge(h, { pill: true, size: "sm", className: cn(META_CHIP_CLASS, "w-fit font-medium", triageChip.tone) }, [ - triageChip.label, - ]), + ? h.p( + [h.Class("text-xs text-muted-foreground")], + ["No AI triage has run for this incident."], + ) + : badge( + h, + { + pill: true, + size: "sm", + className: cn(META_CHIP_CLASS, "w-fit font-medium", triageChip.tone), + }, + [triageChip.label], + ), ]), ], ) diff --git a/apps/web/src/page/dashboards/view/view-history.ts b/apps/web/src/page/dashboards/view/view-history.ts index b499558b1e..c089dbd395 100644 --- a/apps/web/src/page/dashboards/view/view-history.ts +++ b/apps/web/src/page/dashboards/view/view-history.ts @@ -191,8 +191,8 @@ const list = (h: H, model: Model, versions: ReadonlyArray, clock: Cl export const historyPanel = (h: H, model: Model, clock: Clock): Html => { const versions = model.history.versions const count = AsyncData.getOrElse(versions, () => []).length - return h.aside( - [h.Class("flex h-full w-80 shrink-0 flex-col border-l bg-background")], + return h.div( + [h.Class("flex h-full flex-col")], [ h.div( [h.Class("flex items-center gap-2 border-b px-4 py-3")], diff --git a/apps/web/src/page/dashboards/view/view.ts b/apps/web/src/page/dashboards/view/view.ts index 9ba983d37c..e8152bf116 100644 --- a/apps/web/src/page/dashboards/view/view.ts +++ b/apps/web/src/page/dashboards/view/view.ts @@ -276,6 +276,7 @@ export const view = Submodel.defineView((model, nowMs: model.nowMs ?? 0, timeZone: inputs.context.timeZone, }), + rightPanelWidth: "w-80", } : undefined), }, diff --git a/apps/web/src/page/errors/issue/view-sidebar.ts b/apps/web/src/page/errors/issue/view-sidebar.ts index cba4dffef6..3d77b9b6ee 100644 --- a/apps/web/src/page/errors/issue/view-sidebar.ts +++ b/apps/web/src/page/errors/issue/view-sidebar.ts @@ -259,7 +259,7 @@ export const issueSidebar = ( const isTerminal = issue.workflowState === "cancelled" || issue.workflowState === "done" const canClaim = issue.leaseHolder === null && !isTerminal return h.div( - [h.Class("flex flex-col bg-card/30")], + [h.Class("flex flex-col")], [ detailRailGroup(h, "Details", [ detailRailRow(h, { label: "Status" }, [stateSelect(h, model, issue.workflowState)]), diff --git a/apps/web/src/page/errors/issue/view-skeleton.ts b/apps/web/src/page/errors/issue/view-skeleton.ts index c2b459b4ac..3dbada551b 100644 --- a/apps/web/src/page/errors/issue/view-skeleton.ts +++ b/apps/web/src/page/errors/issue/view-skeleton.ts @@ -82,7 +82,7 @@ const railRow = (h: H, label: string, width: string): Html => const railSkeleton = (h: H): Html => h.div( - [h.Class("flex flex-col bg-card/30")], + [h.Class("flex flex-col")], [ detailRailGroup(h, "Details", [ railRow(h, "Status", "w-24"), diff --git a/apps/web/src/page/infra/containers/detail/view.ts b/apps/web/src/page/infra/containers/detail/view.ts index 267bd0231b..d3dae58a1c 100644 --- a/apps/web/src/page/infra/containers/detail/view.ts +++ b/apps/web/src/page/infra/containers/detail/view.ts @@ -48,7 +48,7 @@ const METRIC_LABELS = new Map([ ]) const rail = (h: H, summary: Summary): Html => - resourceAttributesCard(h, "DockerIcon", [ + resourceAttributesCard(h, [ metaRow(h, { label: "container.name", value: summary.containerName }), metaRow(h, { label: "container.id", value: summary.containerId }), metaRow(h, { label: "container.image.name", value: summary.imageName }), @@ -62,7 +62,7 @@ const rightPanel = (h: H, model: Model): Html | undefined => { const summary = AsyncData.getOrElse(model.summary.data, () => null) if (summary !== null) return rail(h, summary) return AsyncData.isLoading(model.summary.data) || AsyncData.isIdle(model.summary.data) - ? resourceAttributesCardSkeleton(h, "DockerIcon") + ? resourceAttributesCardSkeleton(h) : undefined } diff --git a/apps/web/src/page/infra/hosts/detail/view-panels.ts b/apps/web/src/page/infra/hosts/detail/view-panels.ts index 82c0f1c646..247e2beb7e 100644 --- a/apps/web/src/page/infra/hosts/detail/view-panels.ts +++ b/apps/web/src/page/infra/hosts/detail/view-panels.ts @@ -7,7 +7,11 @@ import { panel, panelHeader } from "../../../../ui/panel" import { middleTruncate } from "../../../../ui/middle-truncate" import { formatValueWithUnit } from "../../../../components/infra/chart-units" import { LINKED_CURSOR_STYLE, MountLinkedCursor } from "../../../../charts/linked-cursor" -import { INFRA_METRIC_CHART_HEIGHT, type InfraSeriesInfo, metricView as metricChartView } from "../../../../charts/infra-metric-chart" +import { + INFRA_METRIC_CHART_HEIGHT, + type InfraSeriesInfo, + metricView as metricChartView, +} from "../../../../charts/infra-metric-chart" import { chartError, chartLoading, chartPlotArea } from "../../../../ui/chart-state" import { attributeSection, metaRow, resourceAttributesCard } from "../../kit/resource-attributes" import { seriesLegend } from "../../../kit/series-legend" @@ -19,8 +23,18 @@ import type { HostSummary, Model } from "./model" type H = HtmlBuilder -export const hostMetadataPanel = (h: H, model: Model, summary: NonNullable, nowMs: number, timeZone: string): Html => { - const row = (label: string, value: string, extra: Readonly<{ copyable?: boolean; copyValue?: string; tooltip?: string }>) => +export const hostMetadataPanel = ( + h: H, + model: Model, + summary: NonNullable, + nowMs: number, + timeZone: string, +): Html => { + const row = ( + label: string, + value: string, + extra: Readonly<{ copyable?: boolean; copyValue?: string; tooltip?: string }>, + ) => metaRow(h, { label, value, @@ -28,41 +42,42 @@ export const hostMetadataPanel = (h: H, model: Model, summary: NonNullable Message.GotCopyMessage({ label: l, message }), }) - return resourceAttributesCard( - h, - "ServerIcon", - [ - attributeSection(h, "Identity", [row("host.name", summary.hostName, { copyable: true })]), - attributeSection(h, "Platform", [ - row("os.type", summary.osType, { copyable: true }), - row("host.arch", summary.hostArch, { copyable: true }), - ]), - attributeSection(h, "Cloud", [ - row("cloud.provider", summary.cloudProvider, { copyable: true }), - row("cloud.region", summary.cloudRegion, { copyable: true }), - ]), - attributeSection(h, "Lifecycle", [ - row("first seen", formatRelativeTime(summary.firstSeen, nowMs), { - copyValue: summary.firstSeen, - tooltip: formatSeen(summary.firstSeen, timeZone), - }), - row("last seen", formatRelativeTime(summary.lastSeen, nowMs), { - copyValue: summary.lastSeen, - tooltip: formatSeen(summary.lastSeen, timeZone), - }), - ]), - ], - "divide-y divide-border-subtle", - ) + return resourceAttributesCard(h, [ + h.div( + [h.Class("divide-y divide-border-subtle")], + [ + attributeSection(h, "Identity", [row("host.name", summary.hostName, { copyable: true })]), + attributeSection(h, "Platform", [ + row("os.type", summary.osType, { copyable: true }), + row("host.arch", summary.hostArch, { copyable: true }), + ]), + attributeSection(h, "Cloud", [ + row("cloud.provider", summary.cloudProvider, { copyable: true }), + row("cloud.region", summary.cloudRegion, { copyable: true }), + ]), + attributeSection(h, "Lifecycle", [ + row("first seen", formatRelativeTime(summary.firstSeen, nowMs), { + copyValue: summary.firstSeen, + tooltip: formatSeen(summary.firstSeen, timeZone), + }), + row("last seen", formatRelativeTime(summary.lastSeen, nowMs), { + copyValue: summary.lastSeen, + tooltip: formatSeen(summary.lastSeen, timeZone), + }), + ]), + ], + ), + ]) } -export const METRIC_STRIPS: ReadonlyArray> = [ - { metric: "cpu", label: "CPU", caption: "Per-mode utilization · stacked area" }, - { metric: "memory", label: "Memory", caption: "Used / cached / free · stacked" }, - { metric: "filesystem", label: "Filesystem", caption: "Mountpoint utilization" }, - { metric: "network", label: "Network", caption: "Throughput in/out per device" }, - { metric: "load15", label: "Load 15m", caption: "Linux load average" }, -] +export const METRIC_STRIPS: ReadonlyArray> = + [ + { metric: "cpu", label: "CPU", caption: "Per-mode utilization · stacked area" }, + { metric: "memory", label: "Memory", caption: "Used / cached / free · stacked" }, + { metric: "filesystem", label: "Filesystem", caption: "Mountpoint utilization" }, + { metric: "network", label: "Network", caption: "Throughput in/out per device" }, + { metric: "load15", label: "Load 15m", caption: "Linux load average" }, + ] const SERIES_LABELS = new Map([ ["cpu", "CPU"], @@ -86,7 +101,8 @@ const stripChart = (h: H, model: Model, metric: HostMetric, timeZone: string): H slotId: `host-chart-${metric}`, model: model.charts[metric] ?? Chart.init(), view: metricChartView, - toParentMessage: (message: Chart.Message) => Message.GotChartMessage({ metric, message }), + toParentMessage: (message: Chart.Message) => + Message.GotChartMessage({ metric, message }), viewInputs: { chartId: `host-${metric}`, rows: series.rows, @@ -108,7 +124,10 @@ const stripChart = (h: H, model: Model, metric: HostMetric, timeZone: string): H /** `MetricStrip`. */ const metricStrip = (h: H, model: Model, strip: (typeof METRIC_STRIPS)[number], timeZone: string): Html => h.section( - [h.Key(strip.metric), h.Class("grid grid-cols-1 gap-0 border-t first:border-t-0 lg:grid-cols-[160px_1fr]")], + [ + h.Key(strip.metric), + h.Class("grid grid-cols-1 gap-0 border-t first:border-t-0 lg:grid-cols-[160px_1fr]"), + ], [ h.div( [h.Class("border-b px-1 py-3 lg:border-b-0 lg:border-r lg:py-5")], @@ -117,7 +136,10 @@ const metricStrip = (h: H, model: Model, strip: (typeof METRIC_STRIPS)[number], h.div([h.Class("mt-1 text-2xs leading-relaxed text-muted-foreground")], [strip.caption]), ], ), - h.div([h.Class("lg:pl-4")], [chartPlotArea(h, INFRA_METRIC_CHART_HEIGHT, [stripChart(h, model, strip.metric, timeZone)])]), + h.div( + [h.Class("lg:pl-4")], + [chartPlotArea(h, INFRA_METRIC_CHART_HEIGHT, [stripChart(h, model, strip.metric, timeZone)])], + ), ], ) @@ -126,7 +148,10 @@ export const metricsPanel = (h: H, model: Model, timeZone: string): Html => panel(h, { className: "overflow-visible" }, [ panelHeader(h, { title: "Metrics", - action: h.span([h.Class("text-xs tabular-nums text-muted-foreground")], [countLabel(METRIC_STRIPS.length, "signal")]), + action: h.span( + [h.Class("text-xs tabular-nums text-muted-foreground")], + [countLabel(METRIC_STRIPS.length, "signal")], + ), }), h.div( [h.Class("px-4"), h.Style(LINKED_CURSOR_STYLE), h.OnMount(MountLinkedCursor())], diff --git a/apps/web/src/page/infra/hosts/detail/view.ts b/apps/web/src/page/infra/hosts/detail/view.ts index 2a10738617..cfb92714db 100644 --- a/apps/web/src/page/infra/hosts/detail/view.ts +++ b/apps/web/src/page/infra/hosts/detail/view.ts @@ -49,9 +49,7 @@ const rightPanel = (h: H, model: Model, inputs: PageViewInputs): Html | undefine const summary = AsyncData.getOrElse(data, () => null) if (summary !== null) return hostMetadataPanel(h, model, summary, model.frame.nowMs, inputs.context.timeZone) - return AsyncData.isLoading(data) || AsyncData.isIdle(data) - ? resourceAttributesCardSkeleton(h, "ServerIcon") - : undefined + return AsyncData.isLoading(data) || AsyncData.isIdle(data) ? resourceAttributesCardSkeleton(h) : undefined } export const view = Submodel.defineView((model, inputs, h) => { @@ -69,8 +67,7 @@ export const view = Submodel.defineView((model, h, { titleContent: hero.title, - actions: - headerActions(h, hero.actions, timeControls), + actions: headerActions(h, hero.actions, timeControls), rightPanel: rightPanel(h, model, inputs), }, [h.div([h.Class("space-y-8")], [hero.body, metricsPanel(h, model, inputs.context.timeZone)])], diff --git a/apps/web/src/page/infra/kit/resource-attributes.ts b/apps/web/src/page/infra/kit/resource-attributes.ts index 27b58bab9f..6dc9ce4e0c 100644 --- a/apps/web/src/page/infra/kit/resource-attributes.ts +++ b/apps/web/src/page/infra/kit/resource-attributes.ts @@ -1,12 +1,8 @@ -import { cn } from "@maple/ui/lib/utils" -import { cardStyles } from "@maple/ui/styles/ui/card" import { Schema } from "effect" import { Command } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" -import { card, cardHeader, cardPanel, cardTitle } from "../../../ui/card" import * as CopyButton from "../../../ui/copy-button" -import { detailRailMetaRow } from "../../../ui/detail-rail" -import { icon, type IconName } from "../../../ui/icon" +import { detailRailGroup, detailRailMetaRow } from "../../../ui/detail-rail" import { skeleton } from "../../../ui/skeleton" // `ResourceAttributesCard` / `ResourceAttributesCardSkeleton` and `DetailRail.MetaRow` with its copy buttons. @@ -29,25 +25,9 @@ export const updateCopy = ( } } -const titled = (h: HtmlBuilder, iconName: IconName) => - cardHeader( - h, - [ - cardTitle( - h, - [icon(h, iconName, { size: 14, className: "text-muted-foreground" }), "Resource attributes"], - "flex items-center gap-2 text-sm font-medium", - ), - ], - "pb-3", - ) - -export const resourceAttributesCard = ( - h: HtmlBuilder, - iconName: IconName, - children: ReadonlyArray, - contentClassName?: string, -): Html => card(h, [titled(h, iconName), cardPanel(h, children, contentClassName ?? "space-y-1")]) +/** `ResourceAttributesCard`: the infra detail rail's attribute group, flat like every other rail group. */ +export const resourceAttributesCard = (h: HtmlBuilder, children: ReadonlyArray): Html => + detailRailGroup(h, "Resource attributes", children) const SKELETON_ROWS = [ ["w-12", "w-24"], @@ -58,20 +38,19 @@ const SKELETON_ROWS = [ ["w-16", "w-24"], ] as const -export const resourceAttributesCardSkeleton = (h: HtmlBuilder, iconName: IconName): Html => +export const resourceAttributesCardSkeleton = (h: HtmlBuilder): Html => h.div( - [h.Class(cn(cardStyles)), h.DataAttribute("slot", "card"), h.AriaBusy(true)], + [h.AriaBusy(true)], [ - titled(h, iconName), - cardPanel( + detailRailGroup( h, + "Resource attributes", SKELETON_ROWS.map(([label, value]) => h.div( - [h.Class("flex items-center justify-between gap-3")], + [h.Class("flex items-center justify-between gap-3 py-1")], [skeleton(h, `h-2.5 ${label}`), skeleton(h, `h-2.5 ${value}`)], ), ), - "space-y-3", ), ], ) diff --git a/apps/web/src/page/infra/kubernetes/lens/rail-view.ts b/apps/web/src/page/infra/kubernetes/lens/rail-view.ts index ee5284eb8a..7f0f33a91a 100644 --- a/apps/web/src/page/infra/kubernetes/lens/rail-view.ts +++ b/apps/web/src/page/infra/kubernetes/lens/rail-view.ts @@ -73,11 +73,11 @@ export const lensRail = ( const state = railState(model) return h.div( [ - h.Class(cn("flex h-full flex-col gap-0.5 p-3", refreshingClass(state.isWaiting))), + h.Class(cn("flex h-full flex-col gap-0.5", refreshingClass(state.isWaiting))), ...(state.isWaiting ? [h.AriaBusy(true)] : []), ], [ - eyebrow(h, { as: "div", className: "px-2 pb-2.5" }, ["Services on this cluster"]), + eyebrow(h, { as: "div", className: "flex h-8 items-center px-2" }, ["Services on this cluster"]), ...(state.isLoading ? [skeletonList(h, { rows: 6, rowClassName: "h-[22px]", className: "gap-1.5 px-2 pt-1" })] : state.services.length === 0 @@ -90,7 +90,7 @@ export const lensRail = ( ...(state.unlinkedCount > 0 ? [ h.div( - [h.Class("mt-auto space-y-1.5 border-t px-2 pb-1 pt-3.5")], + [h.Class("-mx-4 mt-auto space-y-1.5 border-t px-6 pb-1 pt-3.5")], [ h.div([h.Class("text-2xs text-foreground")], [ String(state.unlinkedCount), @@ -126,9 +126,9 @@ const railRow = (h: HtmlBuilder, service: RailService, isActive: boolean, h.Href(hrefOf(AppRoute.InfraKubernetesService({ serviceName: service.serviceName, search: timeSearch }))), h.Class( cn( - "flex h-[34px] items-center gap-2.5 rounded-md px-2 transition-colors", + "flex h-8 items-center gap-2.5 rounded-lg px-2 transition-colors", rowSelectedClass(isActive), - !isActive && "hover:bg-surface-sunken active:bg-surface-sunken", + isActive ? "rounded-l-none" : "hover:bg-surface-sunken active:bg-surface-sunken", ), ), ], diff --git a/apps/web/src/page/infra/kubernetes/node/view.ts b/apps/web/src/page/infra/kubernetes/node/view.ts index 4f55462da0..02b239ab6a 100644 --- a/apps/web/src/page/infra/kubernetes/node/view.ts +++ b/apps/web/src/page/infra/kubernetes/node/view.ts @@ -57,14 +57,14 @@ const METRIC_LABELS = new Map([ const rightPanel = (h: H, model: Model): Html | undefined => { const summary = AsyncData.getOrElse(model.summary.data, () => null) if (summary !== null) - return resourceAttributesCard(h, "ServerIcon", [ + return resourceAttributesCard(h, [ metaRow(h, { label: "k8s.node.name", value: summary.nodeName }), metaRow(h, { label: "k8s.node.uid", value: summary.nodeUid }), metaRow(h, { label: "k8s.kubelet.version", value: summary.kubeletVersion }), metaRow(h, { label: "container.runtime", value: summary.containerRuntime }), ]) return AsyncData.isLoading(model.summary.data) || AsyncData.isIdle(model.summary.data) - ? resourceAttributesCardSkeleton(h, "ServerIcon") + ? resourceAttributesCardSkeleton(h) : undefined } diff --git a/apps/web/src/page/infra/kubernetes/pod/view.ts b/apps/web/src/page/infra/kubernetes/pod/view.ts index a2e66b8f46..ce122d89bd 100644 --- a/apps/web/src/page/infra/kubernetes/pod/view.ts +++ b/apps/web/src/page/infra/kubernetes/pod/view.ts @@ -39,7 +39,7 @@ const METRIC_OPTIONS: ReadonlyArray - resourceAttributesCard(h, "FolderIcon", [ + resourceAttributesCard(h, [ metaRow(h, { label: "k8s.pod.name", value: summary.podName }), metaRow(h, { label: "k8s.namespace.name", value: summary.namespace }), metaRow(h, { label: "k8s.node.name", value: summary.nodeName }), @@ -55,7 +55,7 @@ const rightPanel = (h: H, model: Model): Html | undefined => { const summary = AsyncData.getOrElse(model.summary.data, () => null) if (summary !== null) return rail(h, summary) return AsyncData.isLoading(model.summary.data) || AsyncData.isIdle(model.summary.data) - ? resourceAttributesCardSkeleton(h, "FolderIcon") + ? resourceAttributesCardSkeleton(h) : undefined } diff --git a/apps/web/src/page/infra/kubernetes/service/view.ts b/apps/web/src/page/infra/kubernetes/service/view.ts index 85a3ff8e53..b4afd76552 100644 --- a/apps/web/src/page/infra/kubernetes/service/view.ts +++ b/apps/web/src/page/infra/kubernetes/service/view.ts @@ -312,6 +312,7 @@ export const view = Submodel.defineView((model, { actions: Frame.controls(h, model.frame, { search: model.search, defaultPreset: DEFAULT_PRESET }, toFrame), tabs: kubernetesViewTabs(h, "services", model.search), + filtersLabel: "Services", filters: lensRail(h, model.rail, model.serviceName ?? undefined, model.search), filtersWidth: "w-72", }, diff --git a/apps/web/src/page/infra/kubernetes/workload/view.ts b/apps/web/src/page/infra/kubernetes/workload/view.ts index c51162845e..3a1b1be38c 100644 --- a/apps/web/src/page/infra/kubernetes/workload/view.ts +++ b/apps/web/src/page/infra/kubernetes/workload/view.ts @@ -54,14 +54,14 @@ export const KIND_LABEL = new Map([ const rightPanel = (h: H, model: Model): Html | undefined => { const summary = AsyncData.getOrElse(model.summary.data, () => null) if (summary !== null) - return resourceAttributesCard(h, "GridIcon", [ + return resourceAttributesCard(h, [ metaRow(h, { label: "kind", value: KIND_LABEL.get(model.kind) }), metaRow(h, { label: `k8s.${model.kind}.name`, value: summary.workloadName }), metaRow(h, { label: "k8s.namespace.name", value: summary.namespace }), metaRow(h, { label: "pods", value: String(summary.podCount) }), ]) return AsyncData.isLoading(model.summary.data) || AsyncData.isIdle(model.summary.data) - ? resourceAttributesCardSkeleton(h, "GridIcon") + ? resourceAttributesCardSkeleton(h) : undefined } diff --git a/apps/web/src/page/integrations/view.ts b/apps/web/src/page/integrations/view.ts index 77a612930e..1aec5f3fc4 100644 --- a/apps/web/src/page/integrations/view.ts +++ b/apps/web/src/page/integrations/view.ts @@ -96,6 +96,7 @@ const detailView = (h: H, model: Model, integration: IntegrationId, timeZone: st dashboardPage( h, { + filtersLabel: "Settings", filters: settingsNav(h, model), titleContent: integrationHeader(h, model, integration, { connect: connectButton(h, model, integration), @@ -121,6 +122,7 @@ export const view = Submodel.defineView((model, h, { titleContent: hubTitle(h), + filtersLabel: "Settings", filters: settingsNav(h, model), actions: integrationsSummary(h, model) ?? h.empty, width: "settings", diff --git a/apps/web/src/page/kit/dashboard-page.ts b/apps/web/src/page/kit/dashboard-page.ts index bf0ff3533d..c6eaae1284 100644 --- a/apps/web/src/page/kit/dashboard-page.ts +++ b/apps/web/src/page/kit/dashboard-page.ts @@ -27,6 +27,8 @@ export type DashboardPageOptions = Readonly<{ /** `DashboardLayout.Filters`'s children. */ filters?: Html filtersWidth?: string + /** What the filter rail holds when it is not filters (a settings nav): names its sheet below `lg`. */ + filtersLabel?: string /** `DashboardLayout.Header`'s `titleContent`. */ titleContent?: Html | undefined /** A plain page title, drawn as the page `

` when there is no `titleContent`. */ @@ -63,10 +65,11 @@ export const pageBody = (h: HtmlBuilder, children: ReadonlyArray): H * `PageLayout.FilterSidebar` as the inline rail (lg and up). A rail with no facets to offer * (`filterSidebarFrame`'s `isIdle`) is hidden, so the content takes its width. */ -export const filterRail = (h: HtmlBuilder, child: Html, width?: string): Html => +export const filterRail = (h: HtmlBuilder, child: Html, width?: string, label?: string): Html => h.aside( [ h.DataAttribute("slot", "page-filter-sidebar"), + ...(label === undefined ? [] : [h.DataAttribute("rail-label", label)]), h.Class( cn( pageFilterSidebarStyles, @@ -183,7 +186,9 @@ export const dashboardPage = ( ...(options.sticky === undefined || options.sticky === null ? [] : [options.sticky]), ] return pageBody(h, [ - ...(options.filters === undefined ? [] : [filterRail(h, options.filters, options.filtersWidth)]), + ...(options.filters === undefined + ? [] + : [filterRail(h, options.filters, options.filtersWidth, options.filtersLabel)]), pageContent(h, [ ...(sticky.length > 0 ? [stickyArea(h, sticky, options.isScrolled)] : []), options.fill === true diff --git a/apps/web/src/page/logs/list/view-filters.ts b/apps/web/src/page/logs/list/view-filters.ts index 4b429345cd..3913e7ac10 100644 --- a/apps/web/src/page/logs/list/view-filters.ts +++ b/apps/web/src/page/logs/list/view-filters.ts @@ -6,7 +6,6 @@ import type { Html, HtmlBuilder } from "foldkit/html" import { isSameFailure, type LoadFailure } from "../../../data/load-failure" import { kbd } from "../../../ui/display" import { errorState } from "../../../ui/error-state" -import { eyebrow } from "../../../ui/eyebrow" import * as FilterSections from "../../../ui/filters/filter-sections" import { type FilterOption, filterSection } from "../../../ui/filters/filter-section" import { @@ -15,6 +14,7 @@ import { filterSidebarFrame, filterSidebarHeader, filterSidebarLoading, + filterSidebarGroupLabel, } from "../../../ui/filters/filter-sidebar" import type * as Popover from "../../../ui/popover" import { MountFilterRail } from "./command" @@ -76,14 +76,10 @@ const logSearchInput = (h: HtmlBuilder, value: string, help: Popover.Mo h.div( [h.Class("pb-3")], [ - h.div( - [h.Class("flex items-center gap-1")], - [eyebrow(h, { variant: "label" }, ["Search"]), searchHelp(h, help)], - ), + filterSidebarGroupLabel(h, "Search", [searchHelp(h, help)]), searchInput(h, { id: "logs-search-input", size: "sm", - className: "mt-2", placeholder: "Text or trace id", value, onInput: (text) => Message.ChangedSearchText({ text }), diff --git a/apps/web/src/page/recommendations/view.ts b/apps/web/src/page/recommendations/view.ts index fe83ad2306..68cfab969d 100644 --- a/apps/web/src/page/recommendations/view.ts +++ b/apps/web/src/page/recommendations/view.ts @@ -65,7 +65,11 @@ const recSentence = (h: H, issue: V2Recommendation): ReadonlyArray { [ h.p( [h.Class("flex items-center gap-2 text-sm text-severity-info")], - [icon(h, "CircleCheckIcon", { size: 14 }), issue.status === "resolved" ? "Resolved" : "Mapping is active"], + [ + icon(h, "CircleCheckIcon", { size: 14 }), + issue.status === "resolved" ? "Resolved" : "Mapping is active", + ], ), - linkButton(h, { variant: "outline", size: "sm", className: "w-full", href: INGESTION_HREF }, ["Manage mappings"]), + linkButton(h, { variant: "outline", size: "sm", className: "w-full", href: INGESTION_HREF }, [ + "Manage mappings", + ]), ], ) if (issue.status === "dismissed") @@ -131,16 +140,26 @@ const actionGroup = (h: H, model: Model, issue: V2Recommendation): Html => { isApplyable(issue) ? button( h, - { className: "w-full", isLoading: model.busy === "apply", attributes: [h.OnClick(Message.ClickedApply())] }, + { + className: "w-full", + isLoading: model.busy === "apply", + attributes: [h.OnClick(Message.ClickedApply())], + }, [icon(h, "BoltIcon"), "Apply mapping"], ) : h.p( [h.Class("text-xs leading-relaxed text-muted-foreground")], - ["This one is a manual fix: rename the attribute at your SDK. Maple can't apply it for you."], + [ + "This one is a manual fix: rename the attribute at your SDK. Maple can't apply it for you.", + ], ), button( h, - { className: "w-full", isLoading: model.busy === "dismiss", attributes: [h.OnClick(Message.ClickedDismiss())] }, + { + className: "w-full", + isLoading: model.busy === "dismiss", + attributes: [h.OnClick(Message.ClickedDismiss())], + }, [icon(h, "XmarkIcon"), "Dismiss recommendation"], ), ], @@ -152,18 +171,25 @@ const sidebar = (h: H, model: Model, issue: V2Recommendation, timeZone: string): const mode = issue.kind === "rename" ? MODE.auto : MODE.manual const status = STATUS_BADGE[issue.status] return h.div( - [h.Class("flex h-full w-80 shrink-0 flex-col overflow-y-auto border-l bg-card/30")], + [h.Class("flex flex-col")], [ detailRailGroup(h, "Details", [ row(h, "Status", [badge(h, { variant: status.variant }, [status.label])]), row(h, "Type", [badge(h, { variant: kindBadge.variant }, [kindBadge.label])]), row(h, "Fix", [ - badge(h, { variant: "outline", className: cn("gap-1", mode.className), attributes: [h.Title(mode.title)] }, [ - icon(h, mode.icon, { size: 12 }), - mode.label, - ]), + badge( + h, + { + variant: "outline", + className: cn("gap-1", mode.className), + attributes: [h.Title(mode.title)], + }, + [icon(h, mode.icon, { size: 12 }), mode.label], + ), + ]), + row(h, "Spans", [ + h.span([h.Class("tabular-nums text-foreground")], [formatNumber(issue.usage_count)]), ]), - row(h, "Spans", [h.span([h.Class("tabular-nums text-foreground")], [formatNumber(issue.usage_count)])]), row(h, "Opened", [ relativeTime(h, { value: issue.opened_at, @@ -173,7 +199,17 @@ const sidebar = (h: H, model: Model, issue: V2Recommendation, timeZone: string): className: "tabular-nums text-muted-foreground", }), ]), - row(h, "Key", [h.code([h.Class("truncate font-mono text-xs text-muted-foreground")], [issue.source_key])], issue.source_key), + row( + h, + "Key", + [ + h.code( + [h.Class("truncate font-mono text-xs text-muted-foreground")], + [issue.source_key], + ), + ], + issue.source_key, + ), ]), detailRailGroup(h, "How this resolves", [ h.ul( @@ -181,7 +217,13 @@ const sidebar = (h: H, model: Model, issue: V2Recommendation, timeZone: string): RESOLVES.map((line) => h.li( [h.Class("flex gap-2")], - [h.span([h.AriaHidden(true), h.Class("select-none text-foreground-faint")], ["·"]), h.span([], [line])], + [ + h.span( + [h.AriaHidden(true), h.Class("select-none text-foreground-faint")], + ["·"], + ), + h.span([], [line]), + ], ), ), ), @@ -199,18 +241,30 @@ export const view = Submodel.defineView((model, onLoading: () => dashboardPage(h, column, loadingBody(h)), onFailure: (failure) => dashboardPage(h, column, [ - errorState(h, { failure, title: "Couldn't load recommendation", onRetry: Message.ClickedRetry() }), + errorState(h, { + failure, + title: "Couldn't load recommendation", + onRetry: Message.ClickedRetry(), + }), ]), onStale: ({ error }) => dashboardPage(h, column, [ - errorState(h, { failure: error, title: "Couldn't load recommendation", onRetry: Message.ClickedRetry() }), + errorState(h, { + failure: error, + title: "Couldn't load recommendation", + onRetry: Message.ClickedRetry(), + }), ]), onSuccess: () => loaded(h, model, inputs.context.timeZone), onRefreshing: () => loaded(h, model, inputs.context.timeZone), }), ) -const loadingBody = (h: H) => [skeleton(h, "h-12 w-full"), skeleton(h, "h-28 w-full"), skeleton(h, "h-24 w-full")] +const loadingBody = (h: H) => [ + skeleton(h, "h-12 w-full"), + skeleton(h, "h-28 w-full"), + skeleton(h, "h-24 w-full"), +] const loaded = (h: H, model: Model, timeZone: string): Html => Option.match(selectedOf(model), { @@ -219,7 +273,8 @@ const loaded = (h: H, model: Model, timeZone: string): Html => resourceNotFound(h, { icon: icon(h, "PulseIcon", { className: "text-muted-foreground" }), title: "Recommendation not found", - description: "This recommendation isn't in your list anymore. It may have resolved on its own.", + description: + "This recommendation isn't in your list anymore. It may have resolved on its own.", back: { href: INGESTION_HREF, label: "Back to recommendations" }, }), ]), @@ -236,6 +291,7 @@ const loaded = (h: H, model: Model, timeZone: string): Html => meta: [badge(h, { variant: status.variant, size: "lg" }, [status.label])], }), rightPanel: sidebar(h, model, issue, timeZone), + rightPanelWidth: "w-80", }, body(h, model, issue), ) diff --git a/apps/web/src/page/replays/list/view-filters.ts b/apps/web/src/page/replays/list/view-filters.ts index 57686d39d1..668fc96695 100644 --- a/apps/web/src/page/replays/list/view-filters.ts +++ b/apps/web/src/page/replays/list/view-filters.ts @@ -8,7 +8,6 @@ import { import { Option } from "effect" import { AsyncData } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" -import { eyebrow } from "../../../ui/eyebrow" import { type FilterOption, filterSection } from "../../../ui/filters/filter-section" import * as FilterSections from "../../../ui/filters/filter-sections" import { @@ -18,12 +17,13 @@ import { filterSidebarFrame, filterSidebarHeader, filterSidebarLoading, + filterSidebarDivider, + filterSidebarGroupLabel, } from "../../../ui/filters/filter-sidebar" import { type RangePreset, rangeFilterSection } from "../../../ui/filters/range-filter-section" import { percentilePresets, toLogBuckets } from "../../../ui/filters/range-distribution" import type { IconName } from "../../../ui/icon" import { searchInput } from "../../../ui/search-input" -import { separator } from "../../../ui/display" import { statusDot } from "../../../ui/status-dot" import { browserIconFor, deviceIconFor } from "../kit/sessions-parts" import { hasSidebarFilters, selectedTags } from "./derive" @@ -202,8 +202,8 @@ const loadedRail = (h: HtmlBuilder, model: Model, facets: ReplaysFacets }), singleSection(h, model, "Country", "country", lists.countries), lists.groups.length > 0 ? singleSection(h, model, "Group", "group", lists.groups) : null, - separator(h, "horizontal", "my-2"), - eyebrow(h, { as: "h4", variant: "label", className: "py-1" }, ["Identity"]), + filterSidebarDivider(h), + filterSidebarGroupLabel(h, "Identity"), textFilter(h, { id: "replays-user-search", key: `user:${search.user ?? ""}`, diff --git a/apps/web/src/page/settings/account/view.ts b/apps/web/src/page/settings/account/view.ts index 969366396f..0839f016da 100644 --- a/apps/web/src/page/settings/account/view.ts +++ b/apps/web/src/page/settings/account/view.ts @@ -119,6 +119,7 @@ export const view = Submodel.defineView((model, : dashboardPage( h, { + filtersLabel: "Account settings", filters: navShell(h, accountNavSections, activeTabOf(model), (tab) => Message.ClickedTab({ tab }), ), diff --git a/apps/web/src/page/settings/kit/nav-shell.ts b/apps/web/src/page/settings/kit/nav-shell.ts index 3757a8466f..7185e6a234 100644 --- a/apps/web/src/page/settings/kit/nav-shell.ts +++ b/apps/web/src/page/settings/kit/nav-shell.ts @@ -1,4 +1,5 @@ import { cn } from "@maple/ui/lib/utils" +import { ROW_LANE } from "@maple/ui/styles/ui/list-row" import type { Html, HtmlBuilder } from "foldkit/html" import { eyebrow } from "../../../ui/eyebrow" import { icon, type IconName } from "../../../ui/icon" @@ -16,21 +17,19 @@ export type NavShellSection = Readonly<{ items: ReadonlyArray> }> +// The app sidebar's row beside it: same height, type, radius and flush 2px active lane. const rowClass = (isActive: boolean) => cn( - "group relative flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm transition-colors text-left", + ROW_LANE, + "group flex h-8 items-center gap-2 rounded-lg px-2 text-xs transition-colors text-left", isActive - ? "bg-accent text-accent-foreground font-medium" - : "text-muted-foreground hover:bg-surface-sunken active:bg-surface-sunken hover:text-foreground active:text-foreground", + ? "rounded-l-none bg-accent font-medium text-primary before:bg-primary" + : "text-muted-foreground before:bg-transparent hover:bg-surface-sunken active:bg-surface-sunken hover:text-foreground active:text-foreground", ) -const activeIndicator = (h: HtmlBuilder): Html => - h.span([h.AriaHidden(true), h.Class("absolute inset-y-1.5 left-0 w-[2px] rounded-full bg-primary")], []) - -const rowChildren = (h: HtmlBuilder, isActive: boolean, name: IconName, label: string) => [ - ...(isActive ? [activeIndicator(h)] : []), +const rowChildren = (h: HtmlBuilder, name: IconName, label: string) => [ icon(h, name, { size: 16, className: "shrink-0" }), - label, + h.span([h.Class("truncate")], [label]), ] export const navShell = ( @@ -45,7 +44,7 @@ export const navShell = ( h.div( [h.Key(section.id), h.Class("flex flex-col gap-1")], [ - eyebrow(h, { as: "div", className: "px-2.5" }, [section.title]), + eyebrow(h, { as: "div", className: "flex h-6 items-center px-2" }, [section.title]), h.div( [h.Class("flex flex-col gap-0.5")], section.items.map((row) => { @@ -64,7 +63,7 @@ export const navShell = ( ] : [h.Class(rowClass(isActive))]), ], - rowChildren(h, isActive, row.icon, row.label), + rowChildren(h, row.icon, row.label), ) : h.button( [ @@ -73,7 +72,7 @@ export const navShell = ( h.Type("button"), h.OnClick(onSelectTab(row.id)), ], - rowChildren(h, isActive, row.icon, row.label), + rowChildren(h, row.icon, row.label), ) }), ), diff --git a/apps/web/src/page/settings/view.ts b/apps/web/src/page/settings/view.ts index 402fdbe62a..273b63681e 100644 --- a/apps/web/src/page/settings/view.ts +++ b/apps/web/src/page/settings/view.ts @@ -338,6 +338,7 @@ export const view = Submodel.defineView((model, width: "settings", // Holds the header row while the session resolves, so the content does not jump down. titleContent: isLoading ? skeleton(h, "h-8 w-40") : undefined, + filtersLabel: "Settings", filters: isLoading || empty ? undefined diff --git a/apps/web/src/shell/page-rails.ts b/apps/web/src/shell/page-rails.ts index ae390dffa3..08efca4fc7 100644 --- a/apps/web/src/shell/page-rails.ts +++ b/apps/web/src/shell/page-rails.ts @@ -12,7 +12,7 @@ const slotOf = (node: Child): string | undefined => typeof node === "string" ? undefined : node.data?.attrs?.["data-slot"]?.toString() /** The rails' children, already built in the page's boundary, so their Messages stay the page's. */ -export type LiftedRail = Readonly<{ children: ReadonlyArray }> +export type LiftedRail = Readonly<{ children: ReadonlyArray; label: Option.Option }> export type SplitPage = Readonly<{ body: Html @@ -21,9 +21,13 @@ export type SplitPage = Readonly<{ }> const railOf = (children: ReadonlyArray, slot: string): Option.Option => - Option.map( - Option.fromNullishOr(children.find((child) => slotOf(child) === slot)), - (rail) => ({ children: typeof rail === "string" ? [] : (rail.children ?? []).filter(isNode) }), + Option.map(Option.fromNullishOr(children.find((child) => slotOf(child) === slot)), (rail) => + typeof rail === "string" + ? { children: [], label: Option.none() } + : { + children: (rail.children ?? []).filter(isNode), + label: Option.fromNullishOr(rail.data?.attrs?.["data-rail-label"]?.toString()), + }, ) const isNode = (child: Child): child is VNode => typeof child !== "string" @@ -51,7 +55,10 @@ const liftFrom = (node: VNode, depth: number): Option.Option => { ? Option.none() : Option.map(liftFrom(child, depth - 1), (split) => ({ ...split, - body: { ...node, children: children.map((other, at) => (at === index ? split.body : other)) }, + body: { + ...node, + children: children.map((other, at) => (at === index ? split.body : other)), + }, })), ), ) diff --git a/apps/web/src/shell/page-sheets.scene.test.ts b/apps/web/src/shell/page-sheets.scene.test.ts index 33c50334cd..fd62b3910e 100644 --- a/apps/web/src/shell/page-sheets.scene.test.ts +++ b/apps/web/src/shell/page-sheets.scene.test.ts @@ -36,6 +36,18 @@ const view = (model: Model, h: HtmlBuilder) => topbarActions: () => null, })()(model, h) +/** A settings-style page whose filter slot holds a nav, named by `filtersLabel`. */ +const settingsView = (model: Model, h: HtmlBuilder) => + withViewInputs(frameView, { + currentPath: "/settings", + breadcrumbs: [{ label: "Settings" }], + page: () => + dashboardPage(h, { filtersLabel: "Settings", filters: h.p([], ["Members tab"]) }, [ + h.p([], ["General"]), + ]), + topbarActions: () => null, + })()(model, h) + const filtersDialog = role("dialog", { name: "Filters" }) const openFilters = role("button", { name: "Open filters" }) @@ -70,6 +82,19 @@ describe("page rails below lg", () => { ) }) + test("a rail named by filtersLabel names its trigger and sheet after it", () => { + scene( + { update, view: settingsView }, + given(signedIn()), + expect(openFilters).toBeAbsent(), + click(role("button", { name: "Open settings" })), + Mount.resolve(Dialog.MountDialog, Dialog.Message.CompletedMountDialog()), + Command.resolve(Animation.WaitForPaint, Animation.Message.CompletedWaitForPaint({ generation: 1 })), + Command.resolve(Animation.WaitForAnimationSettled, Animation.Message.EndedAnimation({ generation: 1 })), + expect(role("dialog", { name: "Settings" })).toContainText("Members tab"), + ) + }) + test("at lg and up the rails stay inline and no trigger is drawn", () => { scene( { update, view }, diff --git a/apps/web/src/shell/view-page-sheets.ts b/apps/web/src/shell/view-page-sheets.ts index 27b4131761..58713bf72c 100644 --- a/apps/web/src/shell/view-page-sheets.ts +++ b/apps/web/src/shell/view-page-sheets.ts @@ -4,6 +4,7 @@ import { pageRightSheetHeaderStyles, pageRightSheetStyles, } from "@maple/ui/styles/ui/page-layout" +import { Option } from "effect" import type { Html, HtmlBuilder } from "foldkit/html" import * as Dialog from "../ui/dialog" import * as DialogView from "../ui/dialog-view" @@ -18,7 +19,12 @@ import type { LiftedRail } from "./page-rails" /** `