From 86ccd40715ccbc978103d3c7c34ecf29370919eb Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 19:41:54 +0100 Subject: [PATCH 1/2] feat(ui): one page edge and a left-aligned 1536px cap The top bar, page header, filter rail and body share one gutter (16px, 24px from lg), the sticky header draws a hairline on scroll instead of a shadow, and page columns cap at 1536px, left-aligned. Canvas pages (traces, service map, dashboards, the widget editor, metric detail) and pages that own their scrolling stay full width. --- apps/web/src/page/dashboards/view/view.ts | 3 +- apps/web/src/page/dashboards/widget/view.ts | 2 +- apps/web/src/page/kit/dashboard-page.ts | 35 ++++++++++++------- apps/web/src/page/metrics/detail/view.ts | 1 + .../src/page/security/tabs/view-overview.ts | 2 +- apps/web/src/page/service-map/view.ts | 2 +- apps/web/src/page/traces/detail/view.ts | 1 + apps/web/src/page/traces/list/view.ts | 1 + apps/web/src/shell/view-topbar.ts | 2 +- packages/views/src/ui/page-layout.styles.ts | 14 +++++--- 10 files changed, 41 insertions(+), 22 deletions(-) diff --git a/apps/web/src/page/dashboards/view/view.ts b/apps/web/src/page/dashboards/view/view.ts index 7de0b3fdef..4003ba301e 100644 --- a/apps/web/src/page/dashboards/view/view.ts +++ b/apps/web/src/page/dashboards/view/view.ts @@ -287,8 +287,9 @@ export const view = Submodel.defineView((model, dashboardPage( h, model.document === null - ? {} + ? { width: "full" } : { + width: "full", titleContent: title(h, model, model.document.name), actions: toolbar(h, model, inputs.context), ...(model.history.isOpen diff --git a/apps/web/src/page/dashboards/widget/view.ts b/apps/web/src/page/dashboards/widget/view.ts index 829b215c5c..63a7b025ea 100644 --- a/apps/web/src/page/dashboards/widget/view.ts +++ b/apps/web/src/page/dashboards/widget/view.ts @@ -174,7 +174,7 @@ export const view = Submodel.defineView((model, h.div( [h.Class("contents"), h.OnMount(ResolveThemeColors({ tokens: WIDGET_CHART_TOKENS }))], [ - dashboardPage(h, {}, [ + dashboardPage(h, { width: "full" }, [ model.status === "Ready" && model.state !== null ? editor(h, model, inputs.context.timeZone) : model.status === "Missing" diff --git a/apps/web/src/page/kit/dashboard-page.ts b/apps/web/src/page/kit/dashboard-page.ts index 79bf85f485..af3b948e45 100644 --- a/apps/web/src/page/kit/dashboard-page.ts +++ b/apps/web/src/page/kit/dashboard-page.ts @@ -37,7 +37,10 @@ export type DashboardPageOptions = Readonly<{ /** Page tabs pinned under the header. */ tabs?: Html scrollClassName?: string - /** A centred capped column (`page-column`). */ + /** + * The body's column (`page-column`). Defaults to `wide`, a 1536px cap; `full` is for a + * page whose content is the canvas (traces, the service map, a dashboard grid). + */ width?: PageWidth /** The gap between the body's top-level blocks. */ gap?: PageGap @@ -106,23 +109,31 @@ export const pageHeader = ( actions === undefined ? [] : [pageHeaderActions(h, [actions])], ) -const stickyArea = (h: HtmlBuilder, children: ReadonlyArray, isScrolled = false): Html => +const stickyArea = ( + h: HtmlBuilder, + children: ReadonlyArray, + isScrolled: boolean, + width: PageWidth, +): Html => h.div( [ h.DataAttribute("slot", "page-sticky-area"), h.Class(cn(pageStickyAreaStyles, isScrolled && pageStickyAreaScrolledStyles)), ], - children, + // The header caps at the body's width, so the title sits over the column it heads. + PAGE_WIDTH[width] === null + ? children + : [h.div([h.Class(cn("w-full space-y-4", PAGE_WIDTH[width]))], children)], ) const fillArea = (h: HtmlBuilder, children: ReadonlyArray): Html => h.div([h.DataAttribute("slot", "page-fill"), h.Class(pageFillStyles)], children) -// `settings` caps the header and the column at the same left-aligned width (settings, integrations, account). +// Every capped width is left-aligned on the page gutter, so the header, body and top bar share one edge. const PAGE_WIDTH = { full: null, - reading: "mx-auto max-w-4xl", - narrow: "mx-auto max-w-3xl", + wide: "max-w-(--breakpoint-2xl)", + narrow: "max-w-3xl", settings: "max-w-4xl", } as const const PAGE_GAP = { none: null, sm: "gap-3", md: "gap-4", lg: "gap-6" } as const @@ -133,7 +144,7 @@ const scrollArea = ( h: HtmlBuilder, children: ReadonlyArray, className?: string, - width: PageWidth = "full", + width: PageWidth = "wide", gap: PageGap = "none", ): Html => width === "full" @@ -183,9 +194,9 @@ export const dashboardPage = ( [heading, pageDescription(h, [options.description], "text-sm")], )) const hasHeader = titleContent !== undefined || options.actions !== undefined - const headerWidth = options.width === "settings" ? PAGE_WIDTH.settings : undefined - const header = - options.header ?? (hasHeader ? pageHeader(h, titleContent, options.actions, headerWidth) : undefined) + // A page that owns its scrolling is a canvas: its header spans the full width with it. + const width = options.width ?? (options.fill === true ? "full" : "wide") + const header = options.header ?? (hasHeader ? pageHeader(h, titleContent, options.actions) : undefined) const sticky = [ ...(header === undefined ? [] : [header]), ...(options.tabs === undefined @@ -198,10 +209,10 @@ export const dashboardPage = ( ? [] : [filterRail(h, options.filters, options.filtersWidth, options.filtersLabel)]), pageContent(h, [ - ...(sticky.length > 0 ? [stickyArea(h, sticky, options.isScrolled)] : []), + ...(sticky.length > 0 ? [stickyArea(h, sticky, options.isScrolled ?? false, width)] : []), options.fill === true ? fillArea(h, children) - : scrollArea(h, children, options.scrollClassName, options.width, options.gap), + : scrollArea(h, children, options.scrollClassName, width, options.gap), ]), ...(options.rightPanel === undefined ? [] diff --git a/apps/web/src/page/metrics/detail/view.ts b/apps/web/src/page/metrics/detail/view.ts index 0dc98ea311..b7fa80bbc6 100644 --- a/apps/web/src/page/metrics/detail/view.ts +++ b/apps/web/src/page/metrics/detail/view.ts @@ -121,6 +121,7 @@ export const view = Submodel.defineView((model, dashboardPage( h, { + width: "full", // A metric name is an identifier, so it is set in mono like elsewhere. titleContent: detailTitle(h, { kind: ["Metric", typeLabelOf(model)], diff --git a/apps/web/src/page/security/tabs/view-overview.ts b/apps/web/src/page/security/tabs/view-overview.ts index 35cd0261bc..d7e74149fd 100644 --- a/apps/web/src/page/security/tabs/view-overview.ts +++ b/apps/web/src/page/security/tabs/view-overview.ts @@ -394,7 +394,7 @@ const coverageCell = (h: H, model: Model, run: SecurityAuditRun | undefined): Ht const metaLine = (h: H, parts: ReadonlyArray, title: string): Html => h.span( [ - h.Class(cn("flex max-w-72 min-w-0 overflow-hidden text-xs text-muted-foreground", ROW_LINK_LIFT)), + h.Class(cn("flex max-w-60 min-w-0 overflow-hidden text-xs text-muted-foreground", ROW_LINK_LIFT)), h.Title(title), ], Arr.map(parts, (part, index) => diff --git a/apps/web/src/page/service-map/view.ts b/apps/web/src/page/service-map/view.ts index d7f285a27a..3ba559576e 100644 --- a/apps/web/src/page/service-map/view.ts +++ b/apps/web/src/page/service-map/view.ts @@ -234,7 +234,7 @@ export const view = Submodel.defineView((model, h.div( [h.Class("contents"), h.OnMount(ResolveThemeColors({ tokens: DB_CHART_TOKENS }))], [ - dashboardPage(h, { title: "Service map", actions: headerActions(h, model) }, [ + dashboardPage(h, { title: "Service map", width: "full", actions: headerActions(h, model) }, [ Option.match(AsyncData.getError(model.facets.data), { onSome: (failure) => errorState(h, { diff --git a/apps/web/src/page/traces/detail/view.ts b/apps/web/src/page/traces/detail/view.ts index cf967f238d..ffbb345939 100644 --- a/apps/web/src/page/traces/detail/view.ts +++ b/apps/web/src/page/traces/detail/view.ts @@ -255,6 +255,7 @@ export const view = Submodel.defineView((model, return dashboardPage( h, { + width: "full", // A failed load still gets a header naming the trace, so the error sits under a title. titleContent: trace !== undefined diff --git a/apps/web/src/page/traces/list/view.ts b/apps/web/src/page/traces/list/view.ts index b97e08f845..146f93dad8 100644 --- a/apps/web/src/page/traces/list/view.ts +++ b/apps/web/src/page/traces/list/view.ts @@ -42,6 +42,7 @@ export const view = Submodel.defineView((model, h, { title: "Traces", + width: "full", filters: filtersRail(h, model, inputs.context.pinnedNamespace), actions: hstack(h, { gap: "2", wrap: true }, [ advancedFilterButton(h, { diff --git a/apps/web/src/shell/view-topbar.ts b/apps/web/src/shell/view-topbar.ts index 3eeff37758..0dfe769a3b 100644 --- a/apps/web/src/shell/view-topbar.ts +++ b/apps/web/src/shell/view-topbar.ts @@ -80,7 +80,7 @@ export const topbar = ( gap: "2", align: "center", as: "header", - className: "h-12 shrink-0 border-b px-4", + className: "h-12 shrink-0 border-b px-(--page-gutter)", attributes: [h.DataAttribute("slot", "app-topbar")], }, [ diff --git a/packages/views/src/ui/page-layout.styles.ts b/packages/views/src/ui/page-layout.styles.ts index dda87dd2c9..bef18698a0 100644 --- a/packages/views/src/ui/page-layout.styles.ts +++ b/packages/views/src/ui/page-layout.styles.ts @@ -1,4 +1,5 @@ -export const pageLayoutStyles = "flex min-h-0 flex-1 flex-col" +/** `--page-gutter` is the one page edge: the top bar, header, filter rail and body all sit on it. */ +export const pageLayoutStyles = "flex min-h-0 flex-1 flex-col [--page-gutter:--spacing(4)] lg:[--page-gutter:--spacing(6)]" export const pageTitleStyles = "font-display text-2xl font-semibold tracking-tight truncate leading-6.5" @@ -14,9 +15,10 @@ export const pageHeaderTextStyles = "min-w-0 flex-1" export const pageHeaderActionsStyles = "min-w-0 @2xl/page:shrink-0" -export const pageStickyAreaStyles = "shrink-0 space-y-4 p-4 transition-shadow" +export const pageStickyAreaStyles = + "shrink-0 space-y-4 border-b border-transparent px-(--page-gutter) pt-(--page-gutter) pb-4" -export const pageStickyAreaScrolledStyles = "shadow-popup" +export const pageStickyAreaScrolledStyles = "border-border-subtle" export const pageBodyStyles = "flex min-h-0 flex-1 overflow-hidden" @@ -26,11 +28,13 @@ export const pageFilterSheetStyles = "w-72 overflow-y-auto p-4 pt-12" export const pageFilterSheetHeaderStyles = "sr-only" -export const pageFilterSidebarStyles = "sticky top-0 h-full shrink-0 overflow-y-auto border-r p-4" +export const pageFilterSidebarStyles = + "sticky top-0 h-full shrink-0 overflow-y-auto border-r px-4 pt-(--page-gutter) pb-4" export const pageContentStyles = "@container/page flex min-h-0 min-w-0 flex-1 flex-col" -export const pageScrollAreaStyles = "flex min-h-0 flex-1 flex-col overflow-auto p-4" +export const pageScrollAreaStyles = + "flex min-h-0 flex-1 flex-col overflow-auto px-(--page-gutter) pt-4 pb-(--page-gutter)" export const pageFillStyles = "flex min-h-0 min-w-0 flex-1 flex-col" From fd31eb943109240636910c38f12781c278d74b80 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 20:09:06 +0100 Subject: [PATCH 2/2] fix(ui): keep the page gutter at 16px A 24px gutter cost every page 16px of content width for little visual gain. The shared edge stays, at the width pages had before, and the Security targets table no longer needs its narrower subline. --- apps/web/src/page/security/tabs/view-overview.ts | 2 +- packages/views/src/ui/page-layout.styles.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/page/security/tabs/view-overview.ts b/apps/web/src/page/security/tabs/view-overview.ts index d7e74149fd..35cd0261bc 100644 --- a/apps/web/src/page/security/tabs/view-overview.ts +++ b/apps/web/src/page/security/tabs/view-overview.ts @@ -394,7 +394,7 @@ const coverageCell = (h: H, model: Model, run: SecurityAuditRun | undefined): Ht const metaLine = (h: H, parts: ReadonlyArray, title: string): Html => h.span( [ - h.Class(cn("flex max-w-60 min-w-0 overflow-hidden text-xs text-muted-foreground", ROW_LINK_LIFT)), + h.Class(cn("flex max-w-72 min-w-0 overflow-hidden text-xs text-muted-foreground", ROW_LINK_LIFT)), h.Title(title), ], Arr.map(parts, (part, index) => diff --git a/packages/views/src/ui/page-layout.styles.ts b/packages/views/src/ui/page-layout.styles.ts index bef18698a0..774c393ad4 100644 --- a/packages/views/src/ui/page-layout.styles.ts +++ b/packages/views/src/ui/page-layout.styles.ts @@ -1,5 +1,5 @@ /** `--page-gutter` is the one page edge: the top bar, header, filter rail and body all sit on it. */ -export const pageLayoutStyles = "flex min-h-0 flex-1 flex-col [--page-gutter:--spacing(4)] lg:[--page-gutter:--spacing(6)]" +export const pageLayoutStyles = "flex min-h-0 flex-1 flex-col [--page-gutter:--spacing(4)]" export const pageTitleStyles = "font-display text-2xl font-semibold tracking-tight truncate leading-6.5"