Instruction file imported from dragoscv/metu (
.github/instructions/page-layout.instructions.md). Copyright stays with the author.
Page Layout & UX Conventions (apps/web)
Every route under apps/web/src/app/(app)/**/page.tsx MUST follow the rules
below. The contract is enforced by the shared primitives exported from
@metu/ui and the (app)/layout.tsx shell.
1. Layout primitives — always use them
Use these components from @metu/ui instead of hand-rolled <div>/<header>
markup. They guarantee consistent spacing, animation, and a11y.
| Primitive | Purpose |
|---|---|
Page |
Outer page container (space-y-6 + data-page=""). |
PageHeader |
h1 title + optional description, eyebrow, accent, back, actions. |
PageSection |
h2 section with optional icon, title adornment, and actions. |
PageTransition |
Already mounted in (app)/layout.tsx. Do not nest another one. |
BackLink |
Used internally by PageHeader — render via back={...}. |
Required pattern
// Good — all top-level pages look like this.
import { Page, PageHeader, PageSection, Badge } from '@metu/ui';
export default async function ThingsPage() {
return (
<Page>
<PageHeader
title="Things"
description="Short, plain-language explanation of what lives here."
actions={
<Badge variant="neutral" size="sm">
{count} total
</Badge>
}
/>
<ThingsToolbar /* nuqs-backed filters live here */ />
<PageSection title="Active" icon={<Icon className="h-4 w-4" />}>
<ThingsList items={active} />
</PageSection>
</Page>
);
}
Detail / edit pages — always render a back prop
Every page reachable via a parent must show a smart back link. Pass the
fallback href; BackLink will prefer router.back() when the user came
from same-origin in-app history.
<PageHeader
size="sm"
back={{ href: `/projects/${id}`, label: project.name }}
title="Edit project"
/>
For sub-resources that scroll into a parent section, link to the anchor:
back={{ href: /projects/${id}#tasks, label: project.name }}.
Forbidden patterns
- ❌ Raw
<div className="space-y-6">as the page root. Use<Page>. - ❌
<header>+<h1 className="text-3xl font-semibold tracking-tight">. UsePageHeader. - ❌
<Link href="/things">← Things</Link>rendered manually. Useback={...}. - ❌
<section className="space-y-3"><h2 className="text-lg font-semibold tracking-tight">…</h2>. Use<PageSection title="…">. - ❌ Wrapping page content in
motion.divfor entrance animation — the shell layout already runsPageTransitionkeyed by pathname.
2. URL state — single source of truth
All UI state that a user might want to share, reload, or bookmark MUST live
in the URL via nuqs. The provider (NuqsAdapter) is mounted at the root.
Conventions
- Filters / sort:
useQueryStates({ status: parseAsString.withDefault(''), sort: parseAsString.withDefault('default') }, { shallow: false }). Useshallow: falseso server components re-render with the new params. - Pagination cursor: key it
before(descending) orafter(ascending); store the opaque cursor returned by the query function. - Tabs:
tabquery param parsed withparseAsStringEnum. - Search box:
q. Debounce in the client component withuseTransition+setQueryStates. - Defaults: read params server-side from the page's
searchParamsprop (Promise in Next 15+) and pass facets/results down to a small client toolbar component that owns the nuqs hooks.
Forbidden patterns
- ❌
useStatefor filters/tabs/pagination on listing pages. - ❌ Reading
window.location.searchdirectly. Use nuqs everywhere. - ❌
router.push('?…')strings. UsesetQueryStates({ … }).
3. Animation & motion
- The
(app)layout wraps every page inPageTransition(keyed bypathname). Do not duplicate it. - Entrance animations on individual items are owned by the
Cardcomponent (already animated) andframer-motion'sAnimatePresencefor transient UI (toasts, drawers, secret cards). - Always honor reduced-motion: framer-motion does this automatically; if you
use CSS transitions, gate them with
@media (prefers-reduced-motion: no-preference). - Keep durations between 160–280ms with the project easing
[0.22, 1, 0.36, 1]. Anything longer feels sluggish.
4. Spacing and width
- Outer padding/width is owned by
(app)/layout.tsx(max-w-6xl, padding). Pages that need a narrower reading column override per-page:<Page className="mx-auto max-w-3xl">. - Inside a page use
space-y-6(default inPage); usespace-y-3inside aPageSection(already its default). - Never set top-margin on the first child of
PageorPageSection— the parent already provides rhythm.
5. Accessibility
- Exactly one
<h1>per page;PageHeaderenforces this. - Section headings inside a page are
<h2>viaPageSection. - Action targets: minimum 36px tap area on mobile (
h-9Tailwind class). - Back links must remain keyboard-focusable; do not suppress the underline on focus-visible.
6. Adding a new page — checklist
- Create the route at
apps/web/src/app/(app)/<segment>/page.tsx. - Wrap output in
<Page>and start with<PageHeader title=… />. - If reachable from a parent, pass
back={{ href, label }}. - If the page has filters/sort/pagination, build a
*-toolbar.tsxclient component that usesnuqsand reads facets passed from the server component. - Group content into one or more
<PageSection>blocks. - Add the route to
apps/web/src/components/sidebar/nav-config.tsonly when it is a primary destination — secondary/detail routes are NOT added there. - Verify with the dev server: header back link works, query params survive reload, transition feels smooth.
7. Sidebar NAV rules
- The sidebar lives in
apps/web/src/components/sidebar/nav-config.ts. - Group parents default their click target to the FIRST child. Re-order
childrento control which page opens when the parent is clicked. - A
NavGroup.hrefoverride is allowed but discouraged — keep "click parent = open primary child" semantics consistent across groups.