From 899a237eb342db48c8d5d38217402da0826cd804 Mon Sep 17 00:00:00 2001 From: benjaminleonard Date: Wed, 2 Sep 2026 12:16:39 +0100 Subject: [PATCH 1/3] First pass --- app/components/CopyCode.tsx | 7 +- app/components/RouteTabs.tsx | 35 +++++-- app/components/oxql-metrics/OxqlMetric.tsx | 8 +- app/pages/SiloUtilizationPage.tsx | 8 +- app/pages/project/instances/CpuMetricsTab.tsx | 4 +- .../project/instances/DiskMetricsTab.tsx | 4 +- app/pages/project/instances/MetricsTab.tsx | 2 +- .../project/instances/NetworkMetricsTab.tsx | 4 +- app/pages/system/UtilizationPage.tsx | 8 +- app/ui/lib/DateRangePicker.tsx | 6 +- app/ui/lib/PropertiesTable.tsx | 18 ++-- app/ui/lib/Tabs.tsx | 4 +- app/ui/styles/components/Tabs.css | 50 +++++++++- test/e2e/mobile-layout.e2e.ts | 93 +++++++++++++++++++ 14 files changed, 210 insertions(+), 41 deletions(-) diff --git a/app/components/CopyCode.tsx b/app/components/CopyCode.tsx index 3f0da93486..4d97decbab 100644 --- a/app/components/CopyCode.tsx +++ b/app/components/CopyCode.tsx @@ -98,7 +98,12 @@ export function EquivalentCliCommand({ project, instance }: EquivProps) { return ( <> - ) => { } } +function RouteTabList({ + className, + children, +}: { + className?: string + children: ReactNode +}) { + return ( + // Keyboard nav is handled on the tablist; individual tabs take focus. + // oxlint-disable-next-line jsx-a11y/interactive-supports-focus +
+ {children} +
+ ) +} + export interface RouteTabsProps { children: ReactNode fullWidth?: boolean @@ -54,23 +70,24 @@ export function RouteTabs({ tabListClassName, }: RouteTabsProps) { /* TODO: Add aria-describedby for active tab */ + const tabList = ( + + {children} + + ) + return (
- {/* eslint-disable-next-line jsx-a11y/interactive-supports-focus */} -
- {children} -
+ {sideTabs ? tabList :
{tabList}
}
diff --git a/app/components/oxql-metrics/OxqlMetric.tsx b/app/components/oxql-metrics/OxqlMetric.tsx index 7a28b68ae4..0c1c580437 100644 --- a/app/components/oxql-metrics/OxqlMetric.tsx +++ b/app/components/oxql-metrics/OxqlMetric.tsx @@ -124,7 +124,13 @@ export function OxqlMetric({ title, description, unit, ...queryObj }: OxqlMetric export const MetricHeader = ({ children }: { children: ReactNode }) => { // If header has only one child, align it to the end of the container const justify = Children.count(children) === 1 ? 'justify-end' : 'justify-between' - return
{children}
+ return ( +
*]:w-full flex flex-wrap gap-2 ${justify}`} + > + {children} +
+ ) } export const MetricCollection = classed.div`mt-3 flex flex-col gap-4` diff --git a/app/pages/SiloUtilizationPage.tsx b/app/pages/SiloUtilizationPage.tsx index a653a596b6..4c0bfa8948 100644 --- a/app/pages/SiloUtilizationPage.tsx +++ b/app/pages/SiloUtilizationPage.tsx @@ -96,13 +96,13 @@ export default function SiloUtilizationPage() { -
-
+
+
{intervalPicker}
-
{dateTimeRangePicker}
+
{dateTimeRangePicker}
diff --git a/app/pages/project/instances/CpuMetricsTab.tsx b/app/pages/project/instances/CpuMetricsTab.tsx index 62ca844dad..22d085ef18 100644 --- a/app/pages/project/instances/CpuMetricsTab.tsx +++ b/app/pages/project/instances/CpuMetricsTab.tsx @@ -62,9 +62,9 @@ export default function CpuMetricsTab() { return ( <> -
+
-
+
in RouteTabs return ( - + CPU Disk Network diff --git a/app/pages/project/instances/NetworkMetricsTab.tsx b/app/pages/project/instances/NetworkMetricsTab.tsx index a6f60cb1ce..7aad722945 100644 --- a/app/pages/project/instances/NetworkMetricsTab.tsx +++ b/app/pages/project/instances/NetworkMetricsTab.tsx @@ -100,9 +100,9 @@ function NetworkMetrics({ nics }: { nics: InstanceNetworkInterface[] }) { return ( <> -
+
{ return ( <> -
-
+
+
{intervalPicker} { onChange={setFilterId} />
-
{dateTimeRangePicker}
+
{dateTimeRangePicker}
-
+
{label}
{state.isInvalid && (
@@ -77,8 +77,8 @@ export function DateRangePicker(props: DateRangePickerProps) {
)}
-
- +
+
diff --git a/app/ui/lib/PropertiesTable.tsx b/app/ui/lib/PropertiesTable.tsx index b691b4d653..47f2a4a590 100644 --- a/app/ui/lib/PropertiesTable.tsx +++ b/app/ui/lib/PropertiesTable.tsx @@ -46,13 +46,15 @@ export function PropertiesTable({ aria-label="Properties table" className={cn( className, - 'properties-table bg-default border-default min-w-min basis-6/12 rounded-lg border', - '*:border-secondary *:border-t *:pr-6 *:pl-3 [&>*:nth-child(-n+2)]:border-t-0!', - 'grid grid-cols-[minmax(min-content,1fr)_3fr]', - { - '1000:grid-cols-[minmax(min-content,1fr)_3fr_minmax(min-content,1fr)_3fr] 1000:[&>*:nth-child(-n+4)]:border-t-0! 1000:[&>*:nth-child(4n-2)]:border-r': - columns === 2, - } + 'properties-table bg-default border-default w-full min-w-0 basis-6/12 rounded-lg border 1000:min-w-min', + '*:border-secondary *:border-t *:pr-6 *:pl-3 [&>*:nth-child(1)]:border-t-0!', + // stack label above value below the layout breakpoint so long IDs don't overflow + 'grid grid-cols-1', + 'max-1000:[&>*:nth-child(odd)]:pt-2 max-1000:[&>*:nth-child(odd)]:pb-1', + 'max-1000:[&>*:nth-child(even)]:border-t-0 max-1000:[&>*:nth-child(even)]:pb-2', + columns === 2 + ? '1000:grid-cols-[minmax(min-content,1fr)_3fr_minmax(min-content,1fr)_3fr] 1000:[&>*:nth-child(-n+4)]:border-t-0! 1000:[&>*:nth-child(4n-2)]:border-r' + : '1000:grid-cols-[minmax(min-content,1fr)_3fr] 1000:[&>*:nth-child(-n+2)]:border-t-0!' )} > {children} @@ -69,7 +71,7 @@ PropertiesTable.Row = ({ label, children }: PropertiesTableRowProps) => ( {label} -
+
{children}
diff --git a/app/ui/lib/Tabs.tsx b/app/ui/lib/Tabs.tsx index 12c48d9d0f..d5fda191de 100644 --- a/app/ui/lib/Tabs.tsx +++ b/app/ui/lib/Tabs.tsx @@ -23,7 +23,9 @@ export const Tabs = { ), List: ({ className, ...props }: BaseTabs.List.Props) => ( - +
+ +
), Content: ({ className, ...props }: BaseTabs.Panel.Props) => ( diff --git a/app/ui/styles/components/Tabs.css b/app/ui/styles/components/Tabs.css index bd86395cab..95a4ed4e7e 100644 --- a/app/ui/styles/components/Tabs.css +++ b/app/ui/styles/components/Tabs.css @@ -6,9 +6,18 @@ * Copyright Oxide Computer Company */ +/* Sticky lives on a wrapper so the list can overflow-x without becoming + its own scroll container (which would prevent sticking). */ +.ox-tabs-list-wrap { + position: sticky; + top: calc(var(--top-bar-height) + var(--preview-banner-height)); + z-index: var(--z-top-bar); + @apply bg-default light:bg-raise; +} + /* Tab list container styles */ .ox-tabs-list { - @apply mb-8 flex bg-transparent; + @apply flex; } /* let long tab lists scroll horizontally on small screens instead of @@ -21,11 +30,31 @@ } } +.ox-tabs > .ox-tabs-panel { + @apply mt-8; +} + +/* forms can have multiple tab lists on one page; keep those in flow */ +.ox-form .ox-tabs-list-wrap { + position: static; + top: auto; + z-index: auto; + background-color: transparent; +} + .ox-tabs-list:after { - @apply border-secondary block w-full border-b; + @apply border-secondary block w-full flex-grow border-b; content: ''; } +@media (max-width: 999px) { + .ox-tabs-list:after { + width: var(--content-gutter); + min-width: max-content; + flex-shrink: 0; + } +} + /* Panel styles */ .ox-tabs-panel:focus-visible { @apply outline-accent-secondary outline-2 outline-offset-[1rem]; @@ -77,15 +106,30 @@ } .ox-tabs.full-width .ox-tabs-list:before { - @apply border-secondary block w-10 min-w-max flex-shrink-0 border-b; + @apply border-secondary block min-w-max flex-shrink-0 border-b; + width: var(--content-gutter); content: ''; } /* Side tabs styles */ +.ox-side-tabs { + @apply max-1000:flex-col flex; +} + .ox-side-tabs-list { @apply sticky top-10 flex w-[180px] flex-shrink-0 flex-col gap-0.5 self-start; } +@media (max-width: 999px) { + .ox-side-tabs-list { + position: static; + top: auto; + width: 100%; + flex-direction: row; + flex-wrap: wrap; + } +} + .ox-side-tabs-list .ox-tab { @apply text-sans-md text-secondary h-auto rounded-md p-2 py-1.5 whitespace-nowrap normal-case !no-underline; border-bottom: none; diff --git a/test/e2e/mobile-layout.e2e.ts b/test/e2e/mobile-layout.e2e.ts index e67b69cd05..af0ef5d0db 100644 --- a/test/e2e/mobile-layout.e2e.ts +++ b/test/e2e/mobile-layout.e2e.ts @@ -121,3 +121,96 @@ test('mobile dialogs and toasts stay within the viewport', async ({ page }) => { ) .toEqual({ left: 16, right: 304, width: 288 }) }) + +test('properties table stacks label above value on small screens', async ({ page }) => { + await page.goto('/projects/mock-project/instances/db1') + const props = page.getByLabel('Properties table') + const label = props.getByText('cpu', { exact: true }) + const value = props.getByText('2 vCPUs') + const columnCount = () => + props.evaluate( + (element) => getComputedStyle(element).gridTemplateColumns.split(' ').length + ) + + await page.setViewportSize({ width: 404, height: 800 }) + await expect.poll(columnCount).toBe(1) + const stacked = await Promise.all([label.boundingBox(), value.boundingBox()]) + expect(stacked[0]).toBeTruthy() + expect(stacked[1]).toBeTruthy() + expect(stacked[0]!.y).toBeLessThan(stacked[1]!.y) + + await page.setViewportSize({ width: 1000, height: 800 }) + await expect.poll(columnCount).toBe(4) + const sideBySide = await Promise.all([label.boundingBox(), value.boundingBox()]) + expect(sideBySide[0]).toBeTruthy() + expect(sideBySide[1]).toBeTruthy() + expect(sideBySide[0]!.x).toBeLessThan(sideBySide[1]!.x) +}) + +test('date range picker is icon-only on small screens', async ({ page }) => { + await page.goto('/projects/mock-project/instances/db1/metrics/cpu') + const picker = page.getByLabel('Choose a date range', { exact: true }) + const button = picker.getByRole('button') + + await page.setViewportSize({ width: 404, height: 800 }) + await expect + .poll(() => button.evaluate((element) => element.getBoundingClientRect().width)) + .toBeLessThan(50) + + await page.setViewportSize({ width: 1000, height: 800 }) + await expect + .poll(() => button.evaluate((element) => element.getBoundingClientRect().width)) + .toBeGreaterThan(200) +}) + +test('metrics filters go full width on small screens', async ({ page }) => { + await page.goto('/system/utilization?tab=metrics') + const listbox = page.getByRole('button', { name: 'Filter by silo' }) + + await page.setViewportSize({ width: 404, height: 800 }) + await expect + .poll(() => listbox.evaluate((element) => element.getBoundingClientRect().width)) + .toBeGreaterThan(300) + + await page.setViewportSize({ width: 1000, height: 800 }) + await expect + .poll(() => + listbox.evaluate((element) => Math.round(element.getBoundingClientRect().width)) + ) + .toBe(208) +}) + +test('tab list sticks under the top bar', async ({ page }) => { + // Short viewport so the page can scroll far enough for the tabs to hit the + // stick point (stacked properties above the tabs eat most of an 800px frame). + await page.setViewportSize({ width: 404, height: 480 }) + await page.goto('/projects/mock-project/instances/db1/storage') + + const tabList = page.getByRole('tablist') + const wrap = page.locator('.ox-tabs-list-wrap') + await expect + .poll(() => wrap.evaluate((element) => getComputedStyle(element).position)) + .toBe('sticky') + + await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight)) + await expect + .poll(async () => { + const box = await tabList.boundingBox() + const top = await wrap.evaluate((element) => + parseFloat(getComputedStyle(element).top) + ) + return box ? Math.abs(box.y - top) < 2 : false + }) + .toBe(true) +}) + +test('CLI command is hidden on small screens', async ({ page }) => { + await page.goto('/projects/mock-project/instances/db1/connect') + const cli = page.getByRole('button', { name: 'CLI Command' }) + + await page.setViewportSize({ width: 404, height: 800 }) + await expect(cli).toBeHidden() + + await page.setViewportSize({ width: 1000, height: 800 }) + await expect(cli).toBeVisible() +}) From 96a2329d76e86a1229bd76398682078e45636098 Mon Sep 17 00:00:00 2001 From: benjaminleonard Date: Wed, 2 Sep 2026 12:23:42 +0100 Subject: [PATCH 2/3] Sneak in grad fix --- app/components/TimeSeriesChart.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/TimeSeriesChart.tsx b/app/components/TimeSeriesChart.tsx index f01381bfd8..4d9af13f60 100644 --- a/app/components/TimeSeriesChart.tsx +++ b/app/components/TimeSeriesChart.tsx @@ -481,7 +481,7 @@ const MetricsMessage = ({
Date: Wed, 2 Sep 2026 13:08:30 +0100 Subject: [PATCH 3/3] More mobile improvements --- app/components/ErrorPage.tsx | 2 +- app/components/Pagination.tsx | 2 +- app/components/TopBar.tsx | 4 +-- .../form/fields/DisksTableField.tsx | 36 +++++++++---------- app/pages/project/instances/NetworkingTab.tsx | 34 +++++++++--------- app/ui/lib/CardBlock.tsx | 6 ++-- app/ui/lib/PageHeader.tsx | 13 ++++--- app/ui/styles/components/Tabs.css | 2 +- app/ui/styles/index.css | 2 +- app/ui/styles/vars.css | 1 + 10 files changed, 53 insertions(+), 49 deletions(-) diff --git a/app/components/ErrorPage.tsx b/app/components/ErrorPage.tsx index c6f731ce32..6695dcd27b 100644 --- a/app/components/ErrorPage.tsx +++ b/app/components/ErrorPage.tsx @@ -40,7 +40,7 @@ export function ErrorPage({ children }: Props) {
-
+
diff --git a/app/components/Pagination.tsx b/app/components/Pagination.tsx index f6b795bcce..dbaf87682b 100644 --- a/app/components/Pagination.tsx +++ b/app/components/Pagination.tsx @@ -17,7 +17,7 @@ const Tunnel = tunnel() export function Pagination(props: UIPaginationProps) { return ( - + ) } diff --git a/app/components/TopBar.tsx b/app/components/TopBar.tsx index 8c1eacff02..924922464b 100644 --- a/app/components/TopBar.tsx +++ b/app/components/TopBar.tsx @@ -62,13 +62,13 @@ function MobileNavToggle() { return ( // full-height cell with a right border so the toggle reads as its own // region, mirroring the desktop home button cell -
+
diff --git a/app/components/form/fields/DisksTableField.tsx b/app/components/form/fields/DisksTableField.tsx index dc97736031..a73223eb96 100644 --- a/app/components/form/fields/DisksTableField.tsx +++ b/app/components/form/fields/DisksTableField.tsx @@ -75,25 +75,23 @@ export function DisksTableField({ emptyState={{ title: 'No disks', body: 'Add a disk to see it here' }} /> -
- - -
+ +
{showDiskCreate && ( diff --git a/app/pages/project/instances/NetworkingTab.tsx b/app/pages/project/instances/NetworkingTab.tsx index 1d88d4ef7b..967e0c2d02 100644 --- a/app/pages/project/instances/NetworkingTab.tsx +++ b/app/pages/project/instances/NetworkingTab.tsx @@ -696,24 +696,22 @@ export default function NetworkingTab() {
-
- - -
+ +
diff --git a/app/ui/lib/CardBlock.tsx b/app/ui/lib/CardBlock.tsx index 269debed9d..71cfa1c122 100644 --- a/app/ui/lib/CardBlock.tsx +++ b/app/ui/lib/CardBlock.tsx @@ -45,7 +45,7 @@ type HeaderProps = { } CardBlock.Header = ({ title, description, children, titleId }: HeaderProps) => ( -
+
{title} @@ -53,7 +53,9 @@ CardBlock.Header = ({ title, description, children, titleId }: HeaderProps) => ( {description &&
{description}
}
-
{children}
+
+ {children} +
) diff --git a/app/ui/lib/PageHeader.tsx b/app/ui/lib/PageHeader.tsx index 89b9ff7b56..6b82bd9662 100644 --- a/app/ui/lib/PageHeader.tsx +++ b/app/ui/lib/PageHeader.tsx @@ -9,17 +9,22 @@ import type { ReactElement } from 'react' import { classed } from '~/util/classed' -export const PageHeader = classed.header`mb-16 mt-12 flex items-center justify-between max-1000:mt-8` +import { Truncate } from './Truncate' + +// Title is allowed to shrink (`min-w-0`); actions keep their intrinsic width so +// long names ellipsize instead of shoving buttons off-screen. Below 500px the +// actions stack above the title and each row can use the full width. +export const PageHeader = classed.header`mb-16 mt-12 flex w-full min-w-0 justify-between gap-4 max-500:flex-col max-500:*:last:order-0 max-500:*:first:order-1 500:items-center 500:[&>h1]:flex-1 500:[&>:not(h1)]:shrink-0 max-1000:mt-8` interface PageTitleProps { icon?: ReactElement - children: React.ReactNode + children: string } export const PageTitle = ({ children: title, icon }: PageTitleProps) => { return ( -

+

{icon} - {title} +

) } diff --git a/app/ui/styles/components/Tabs.css b/app/ui/styles/components/Tabs.css index 95a4ed4e7e..924efcb9e2 100644 --- a/app/ui/styles/components/Tabs.css +++ b/app/ui/styles/components/Tabs.css @@ -11,7 +11,7 @@ .ox-tabs-list-wrap { position: sticky; top: calc(var(--top-bar-height) + var(--preview-banner-height)); - z-index: var(--z-top-bar); + z-index: var(--z-sticky); @apply bg-default light:bg-raise; } diff --git a/app/ui/styles/index.css b/app/ui/styles/index.css index d8d364fd7b..d8b60ecd1e 100644 --- a/app/ui/styles/index.css +++ b/app/ui/styles/index.css @@ -165,11 +165,11 @@ body, #root { height: 100%; + overscroll-behavior-y: none; /* open for discussion but makes it feel more app-like */ } body { @apply text-default bg-default font-sans; - overscroll-behavior-y: none; } /* https://github.com/tailwindlabs/tailwindcss/blob/v2.2.4/src/plugins/css/preflight.css#L57 */ diff --git a/app/ui/styles/vars.css b/app/ui/styles/vars.css index 43426a150b..07797d82d9 100644 --- a/app/ui/styles/vars.css +++ b/app/ui/styles/vars.css @@ -16,6 +16,7 @@ --z-side-modal-overlay: 25; --z-top-bar-dropdown: 20; --z-top-bar: 15; + --z-sticky: 12; --z-popover: 10; --z-content-dropdown: 10; --z-content: 0;