Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ Thumbs.db
test-results/
ci-e2e-traces/
playwright-report/
.e2e-logs/
.vercel

# Visual regression snapshots
Expand Down
11 changes: 9 additions & 2 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -98,14 +98,21 @@
"app/layouts/**/*",
"app/forms/**/*",
"*.config.ts",
"*.config.mjs"
"*.config.mjs",
"test/e2e/compact-reporter.ts"
],
"rules": {
"import/no-default-export": "off"
}
},
{
"files": ["**/*.spec.ts", "**/*.config.ts", "**/*.config.mjs", "tools/**/*"],
"files": [
"**/*.spec.ts",
"**/*.config.ts",
"**/*.config.mjs",
"tools/**/*",
"test/e2e/compact-reporter.ts"
],
"rules": {
"import/no-nodejs-modules": "off"
}
Expand Down
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
- Co-locate Vitest specs next to the code they cover; use Testing Library utilities (`render`, `renderHook`, `fireEvent`, fake timers) to assert observable output rather than implementation details (`app/ui/lib/FileInput.spec.tsx`, `app/hooks/use-pagination.spec.ts`).
- For sweeping styling changes, coordinate with the visual regression harness and follow `test/visual/README.md` for the workflow.
- Fix root causes of flaky timing rather than adding `sleep()` workarounds in tests.
- Local Playwright runs write a compact plain-text report to `.e2e-logs/` (gitignored, one timestamped `.log` per run, last 10 kept) via the custom reporter at `test/e2e/compact-reporter.ts`. Top line is `status: ... total=N passed=N ...`; each failure is a `── UNEXPECTED|FLAKY file:line title` block followed by the error (ANSI stripped). Latest run: `ls .e2e-logs | tail -1` — Read it directly, no parsing needed.

# Data fetching pattern

Expand Down Expand Up @@ -100,7 +101,7 @@

# Layout & accessibility

- Build pages inside the shared `PageContainer`/`ContentPane` so you inherit the skip link, sticky footer, pagination target, and scroll restoration tied to `#scroll-container` (`app/layouts/helpers.tsx`, `app/hooks/use-scroll-restoration.ts`).
- Build pages inside the shared `PageContainer`/`ContentPane` so you inherit the skip link, sticky footer, pagination target, and scroll restoration (`app/layouts/helpers.tsx`, `app/hooks/use-scroll-restoration.ts`).
- Surface page-level buttons and pagination via the `PageActions` and `Pagination` tunnels from `tunnel-rat`; anything rendered through `.In` lands in `.Target` automatically.
- For global loading states, reuse `PageSkeleton`—it keeps the MSW banner and grid layout stable, and `skipPaths` lets you opt-out for routes with custom layouts (`app/components/PageSkeleton.tsx`).
- Enforce accessibility at the type level: use `AriaLabel` type from `app/ui/util/aria.ts` which requires exactly one of `aria-label` or `aria-labelledby` on custom interactive components.
Expand Down
35 changes: 23 additions & 12 deletions app/components/PageSkeleton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,15 @@
* Copyright Oxide Computer Company
*/

import cn from 'classnames'
import { useLocation } from 'react-router'

import { PageContainer } from '~/layouts/helpers'
import {
ContentPane,
PageContainer,
sidebarWrapperClass,
topBarWrapperClass,
} from '~/layouts/helpers'
import { classed } from '~/util/classed'

import { MswBanner } from './MswBanner'
Expand All @@ -28,18 +34,22 @@ export function PageSkeleton({ skipPaths }: { skipPaths?: RegExp[] }) {
<>
{process.env.MSW_BANNER ? <MswBanner disableButton /> : null}
<PageContainer>
<div className="border-secondary flex items-center gap-2 border-r border-b p-3">
<Block className="h-8 w-8" />
<Block className="h-4 w-24" />
</div>
<div className="border-secondary flex items-center justify-between gap-2 border-b p-3">
<Block className="h-4 w-24" />
<div className="flex items-center gap-2">
<Block className="h-6 w-16" />
<Block className="h-6 w-32" />
{/* TopBar */}
<div className={topBarWrapperClass}>
<div className="border-secondary flex items-center gap-2 border-r p-3">
<Block className="h-8 w-8" />
<Block className="h-4 w-24" />
</div>
<div className="flex items-center justify-between gap-2 p-3">
<Block className="h-4 w-24" />
<div className="flex items-center gap-2">
<Block className="h-6 w-16" />
<Block className="h-6 w-32" />
</div>
</div>
</div>
<div className="border-secondary border-r p-4">
{/* Sidebar */}
<div className={cn(sidebarWrapperClass, 'p-4')}>
<Block className="mb-10 h-4 w-full" />
<div className="mb-6 space-y-2">
<Block className="h-4 w-32" />
Expand All @@ -52,7 +62,8 @@ export function PageSkeleton({ skipPaths }: { skipPaths?: RegExp[] }) {
<Block className="h-4 w-14" />
</div>
</div>
<div className="light:bg-raise" />
{/* Content */}
<ContentPane />
</PageContainer>
</>
)
Expand Down
8 changes: 7 additions & 1 deletion app/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { Action16Icon, Document16Icon } from '@oxide/design-system/icons/react'

import { useIsActivePath } from '~/hooks/use-is-active-path'
import { openQuickActions } from '~/hooks/use-quick-actions'
import { sidebarWrapperClass } from '~/layouts/helpers'
import { Button } from '~/ui/lib/Button'
import { Truncate } from '~/ui/lib/Truncate'

Expand Down Expand Up @@ -62,7 +63,12 @@ const JumpToButton = () => {

export function Sidebar({ children }: { children: React.ReactNode }) {
return (
<div className="text-sans-md text-raise border-secondary flex flex-col border-r">
<div
className={cn(
sidebarWrapperClass,
'text-sans-md text-raise flex flex-col overflow-y-auto overscroll-none'
)}
>
<div className="mx-3 mt-4">
<JumpToButton />
</div>
Expand Down
17 changes: 7 additions & 10 deletions app/components/TopBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {

import { useCrumbs } from '~/hooks/use-crumbs'
import { useCurrentUser } from '~/hooks/use-current-user'
import { topBarWrapperClass } from '~/layouts/helpers'
import { useThemeStore, type Theme } from '~/stores/theme'
import { buttonStyle } from '~/ui/lib/Button'
import * as DropdownMenu from '~/ui/lib/DropdownMenu'
Expand All @@ -32,16 +33,12 @@ import { pb } from '~/util/path-builder'

export function TopBar({ systemOrSilo }: { systemOrSilo: 'system' | 'silo' }) {
const { me } = useCurrentUser()
// The height of this component is governed by the `PageContainer`
// It's important that this component returns two distinct elements (wrapped in a fragment).
// Each element will occupy one of the top column slots provided by `PageContainer`.
return (
<>
<div className="border-secondary flex items-center border-r border-b px-2">
<div className={topBarWrapperClass}>
<div className="border-secondary flex items-center border-r px-2">
<HomeButton level={systemOrSilo} />
</div>
{/* Height is governed by PageContainer grid */}
<div className="bg-default border-secondary flex items-center justify-between gap-4 border-b px-3">
<div className="flex items-center justify-between gap-4 px-3">
<div className="flex flex-1 gap-2.5">
<Breadcrumbs />
</div>
Expand All @@ -50,7 +47,7 @@ export function TopBar({ systemOrSilo }: { systemOrSilo: 'system' | 'silo' }) {
<UserMenu />
</div>
</div>
</>
</div>
)
}

Expand Down Expand Up @@ -146,7 +143,7 @@ function UserMenu() {
</span>
</div>
</DropdownMenu.Trigger>
<DropdownMenu.Content gap={8}>
<DropdownMenu.Content gap={8} zIndex="topBar">
<DropdownMenu.LinkItem to={pb.profile()}>Settings</DropdownMenu.LinkItem>
<ThemeSubmenu />
<DropdownMenu.Item onSelect={() => logout.mutate({})} label="Sign out" />
Expand Down Expand Up @@ -238,7 +235,7 @@ function SiloSystemPicker({ level }: { level: 'silo' | 'system' }) {
<SelectArrows6Icon className="text-quaternary ml-3 w-1.5!" aria-hidden />
</div>
</DropdownMenu.Trigger>
<DropdownMenu.Content className="mt-2" anchor="bottom start">
<DropdownMenu.Content className="mt-2" anchor="bottom start" zIndex="topBar">
<SystemSiloItem to={pb.silos()} label="System" isSelected={level === 'system'} />
<SystemSiloItem to={pb.projects()} label="Silo" isSelected={level === 'silo'} />
</DropdownMenu.Content>
Expand Down
28 changes: 19 additions & 9 deletions app/hooks/use-scroll-restoration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,30 @@ function setScrollPosition(key: string, pos: number) {
}

/**
* Given a ref to a scrolling container element, keep track of its scroll
* position before navigation and restore it on return (e.g., back/forward nav).
* Note that `location.key` is used in the cache key, not `location.pathname`,
* so the same path navigated to at different points in the history stack will
* not share the same scroll position.
* Keep track of window scroll position before navigation and restore it on
* return (e.g., back/forward nav). Note that `location.key` is used in the
* cache key, not `location.pathname`, so the same path navigated to at
* different points in the history stack will not share the same scroll position.
*
* We tried RR's built-in `<ScrollRestoration />` and it didn't work — on
* back/forward nav, `window.scrollTo` was called with the right value but the
* document was still at viewport height at that moment, so the scroll got
* clamped to 0. We're not sure why; a theory is that RR restores in a
* `useLayoutEffect` which fires before some later render expands the content,
* and our `useEffect` after paint happens to catch that later render.
*/
export function useScrollRestoration(container: React.RefObject<HTMLElement | null>) {
export function useScrollRestoration() {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we even need this hook now? Since we're using a regular window scroll, perhaps we can use the built-in react-router one.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to #2450 if I remove it I was unable to get react-router ScrollRestoration to work

const key = `scroll-position-${useLocation().key}`
const { state } = useNavigation()
useEffect(() => {
// opt out of the browser's native scroll restoration so it doesn't jump
// the still-visible old page to the new page's saved position on POP,
// before the new route's loader resolves. We restore manually below.
window.history.scrollRestoration = 'manual'
if (state === 'loading') {
setScrollPosition(key, container.current?.scrollTop ?? 0)
setScrollPosition(key, window.scrollY)
} else if (state === 'idle') {
container.current?.scrollTo(0, getScrollPosition(key))
window.scrollTo(0, getScrollPosition(key))
}
}, [key, state, container])
}, [key, state])
}
31 changes: 14 additions & 17 deletions app/layouts/helpers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
*
* Copyright Oxide Computer Company
*/
import { useRef } from 'react'
import { Outlet } from 'react-router'

import { PageActionsTarget } from '~/components/PageActions'
Expand All @@ -14,18 +13,18 @@ import { useScrollRestoration } from '~/hooks/use-scroll-restoration'
import { SkipLinkTarget } from '~/ui/lib/SkipLink'
import { classed } from '~/util/classed'

export const PageContainer = classed.div`grid h-screen grid-cols-[14.25rem_1fr] grid-rows-[var(--top-bar-height)_1fr]`
export const PageContainer = classed.div`min-h-full pt-(--top-bar-height)`
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Top bar is fixed, this leaves space for it. Alternatively we use sticky for the top bar but the sidebar would still be fixed and this feels marginally cleaner.


// shared with PageSkeleton so the skeleton doesn't drift from the real layout
export const topBarWrapperClass =
'bg-default border-secondary fixed top-0 right-0 left-0 z-(--z-top-bar) grid h-(--top-bar-height) grid-cols-[var(--sidebar-width)_1fr] border-b'
export const sidebarWrapperClass =
'border-secondary fixed top-(--top-bar-height) bottom-0 left-0 w-(--sidebar-width) border-r'

export function ContentPane() {
const ref = useRef<HTMLDivElement>(null)
useScrollRestoration(ref)
useScrollRestoration()
return (
<div
ref={ref}
className="light:bg-raise flex flex-col overflow-auto"
id="scroll-container"
data-testid="scroll-container"
>
<div className="light:bg-raise ml-(--sidebar-width) flex min-h-[calc(100vh-var(--top-bar-height))] flex-col">
<div className="flex grow flex-col pb-8">
<SkipLinkTarget />
<main className="*:gutter">
Expand All @@ -47,12 +46,10 @@ export function ContentPane() {
* `<div>` because we don't need it.
*/
export const SerialConsoleContentPane = () => (
<div className="flex flex-col overflow-auto">
<div className="flex grow flex-col">
<SkipLinkTarget />
<main className="*:gutter h-full">
<Outlet />
</main>
</div>
<div className="ml-(--sidebar-width) flex h-[calc(100vh-var(--top-bar-height))] flex-col overflow-hidden">
<SkipLinkTarget />
<main className="*:gutter h-full">
<Outlet />
</main>
</div>
)
2 changes: 1 addition & 1 deletion app/table/QueryTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ function useScrollReset(triggerDep: string | undefined) {
const resetRequested = useRef(false)
useEffect(() => {
if (resetRequested.current) {
document.querySelector('#scroll-container')?.scrollTo(0, 0)
window.scrollTo(0, 0)
resetRequested.current = false
}
}, [triggerDep])
Expand Down
6 changes: 5 additions & 1 deletion app/table/columns/action-col.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,11 @@ export const RowActions = ({ id, copyIdLabel = 'Copy ID', actions }: RowActionsP
<More12Icon />
</DropdownMenu.Trigger>
{/* offset moves menu in from the right so it doesn't align with the table border */}
<DropdownMenu.Content anchor={{ to: 'bottom end', offset: -6 }} className="-mt-2">
<DropdownMenu.Content
anchor={{ to: 'bottom end', offset: -6 }}
className="-mt-2"
collisionPadding={{ bottom: 56 }}
>
{id && <CopyIdItem id={id} label={copyIdLabel} />}
{actions?.map(({ className, ...action }) =>
'to' in action ? (
Expand Down
47 changes: 33 additions & 14 deletions app/ui/lib/DialogOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,40 @@
* Copyright Oxide Computer Company
*/

import { Dialog as BaseDialog } from '@base-ui/react/dialog'
import * as m from 'motion/react-m'
import { type Ref } from 'react'

type Props = {
ref?: Ref<HTMLDivElement>
}
import { useIsInModal, useIsInSideModal } from './modal-context'

type Props = { ref?: Ref<HTMLDivElement> }

export const DialogOverlay = ({ ref }: Props) => (
<m.div
ref={ref}
aria-hidden
className="bg-scrim fixed inset-0 z-(--z-modal-overlay) overflow-auto"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15, ease: 'easeOut' }}
/>
)
// Dialog.Backdrop registers itself with base-ui so clicks on it dismiss the
// dialog when modal={true}. A plain <div> here would not.
export const DialogOverlay = ({ ref }: Props) => {
const isInModal = useIsInModal()
const isInSideModal = useIsInSideModal()
// Modal scrim sits above the SideModal popup so Modal-over-SideModal is
// fully covered; SideModal scrim sits below its own popup. Modal wins when
// both contexts are set (Modal nested inside SideModal), mirroring
// usePopoverZIndex's precedence.
const zClass =
isInSideModal && !isInModal ? 'z-(--z-side-modal-overlay)' : 'z-(--z-modal-overlay)'
return (
// forceRender so the Modal scrim still renders when nested inside a
// SideModal — otherwise base-ui hides it and the SideModal stays interactive.
<BaseDialog.Backdrop
forceRender
render={
<m.div
ref={ref}
className={`bg-scrim fixed inset-0 overflow-auto ${zClass}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15, ease: 'easeOut' }}
/>
}
/>
)
}
Loading
Loading