From 18fb7470064e9edff2b1fb845b4f4fa84f57d61c Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 31 Oct 2026 02:14:55 +0100 Subject: [PATCH 1/2] style(web): one layout for second sidebars, right rails and filter rails Settings rail rows match the app sidebar and name their sheet below lg. Right rails stop drawing a second border, width or card inside the kit rail. Filter rails pin option labels to 12px, share one right edge with the header, run the header rule edge to edge, and use shared group dividers and labels. --- apps/local-ui/src/shell/filter-drawer.ts | 1 + .../page/agent-sessions/list/view-filters.ts | 8 +- apps/web/src/page/analytics/view-filters.ts | 14 +-- .../src/page/anomalies/detail/view-sidebar.ts | 96 ++++++++++++---- .../src/page/dashboards/view/view-history.ts | 4 +- apps/web/src/page/dashboards/view/view.ts | 1 + .../web/src/page/errors/issue/view-sidebar.ts | 2 +- .../src/page/errors/issue/view-skeleton.ts | 2 +- .../src/page/infra/containers/detail/view.ts | 4 +- .../page/infra/hosts/detail/view-panels.ts | 105 +++++++++++------- apps/web/src/page/infra/hosts/detail/view.ts | 7 +- .../src/page/infra/kit/resource-attributes.ts | 39 ++----- .../page/infra/kubernetes/lens/rail-view.ts | 10 +- .../src/page/infra/kubernetes/node/view.ts | 4 +- .../web/src/page/infra/kubernetes/pod/view.ts | 4 +- .../src/page/infra/kubernetes/service/view.ts | 1 + .../page/infra/kubernetes/workload/view.ts | 4 +- apps/web/src/page/integrations/view.ts | 2 + apps/web/src/page/kit/dashboard-page.ts | 9 +- apps/web/src/page/logs/list/view-filters.ts | 8 +- apps/web/src/page/recommendations/view.ts | 92 ++++++++++++--- .../web/src/page/replays/list/view-filters.ts | 8 +- apps/web/src/page/settings/account/view.ts | 1 + apps/web/src/page/settings/kit/nav-shell.ts | 23 ++-- apps/web/src/page/settings/view.ts | 1 + apps/web/src/shell/page-rails.ts | 17 ++- apps/web/src/shell/view-page-sheets.ts | 20 +++- apps/web/src/shell/view.ts | 2 +- apps/web/src/ui/filters/filter-section.ts | 4 +- apps/web/src/ui/filters/filter-sidebar.ts | 37 ++++-- .../src/components/ui/page-layout.styles.ts | 4 +- 31 files changed, 346 insertions(+), 188 deletions(-) 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/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" /** `