Skip to main content

Record Pages

Every entity in Wayd — a project, a team, an employee, a program — gets a list page, and most get a record page (detail page) as well. This document defines the structure they follow, so that a developer or agent building a page for a new entity does not have to invent one, and a user moving between areas sees one product.

Start with Does it get a record page at all? — a record with nothing but its own fields is better served by a panel beside its list than by a page of its own.

Status

This is the target pattern. Existing pages are migrating to it area by area (see Adoption). When adding a new record page, follow this document rather than copying an older page.

The five slots​

A record page is made of five slots. Each area fills them with its own content — that is where per-entity difference belongs, not in the layout.

SlotHoldsEmployeeTeamProject
1 · IdentityGlyph (people only), key, name, status tags, descriptorAvatar, 1042, job titlePLAT-CORE, methodologyPLT-4, portfolio
2 · ActionsOne primary action; rest in PageActionsView TeamsAdd MemberStatus transition
3 · Record factsLabeledContent rail — the stable factsEmail, number, hire dateCode, type, sizingDates, lifecycle, category
4 · SectionsNamed content areas + reportsTeams, Projects, WorkBacklog, Sprints, Risks…Team, Plan, Work Items
5 · RelationshipsLinks out of this recordManager, direct reportsParent team, scrum masterPortfolio, program

Slots 1, 2, 3 and 5 are present on every record page. Only slot 4 varies — see Sections.

Identity​

A record page's header is RecordHeader, not PageTitle. They are separate components on purpose: PageTitle heads a list page, where the title is the largest thing on screen; RecordHeader is an identity bar, where the name shares a tight row with an avatar, key chip, status and actions. Keeping them apart means neither carries branching for the other.

Pages do not render it directly — RecordLayout takes a record prop and renders the bar itself, so a page supplies data rather than layout:

<RecordLayout
record={{
name: team.name,
recordKey: team.code,
parent: { label: 'Teams', href: '/organizations/teams' },
subtitle: 'Team Details',
tags: <InactiveTag isActive={team.isActive} />,
actions: <PageActions actionItems={items} />,
}}
sections={sections}
defaultSection="overview"
facts={<TeamFacts team={team} />}
>

RecordLayout and RecordHeader come from @/src/components/common/record; PageActions and InactiveTag from @/src/components/common. The record prop is RecordHeaderProps:

PropHolds
nameThe record's name
recordKeyThe key chip's text — see Which value fills the key chip
avatarThe glyph — employees only
parentBreadcrumb link back up; an array walks outermost-first for a record reached through more than one hop
subtitleWhat kind of page this is ('Team Details'), closing the breadcrumb trail
descriptorWhat the record is, in its own words — a job title. Secondary, inline after the tags
tagsStatus and other qualifiers beside the name
actionsRecord-level actions, aligned right — usually PageActions

subtitle names the page and descriptor describes the record; do not put a job title in subtitle. The facts toggle in the bar is supplied by RecordLayout when the page passes facts.

A record whose content is one interactive surface rather than sections — a story map board, a live poker session — uses RecordShell (record plus children): the same identity bar over a padded content area, with no rail or facts panel. Anything with sections or facts uses RecordLayout.

A record route's loading.tsx renders RecordLoading with a title, which draws the same full-bleed identity bar over a skeleton so the header does not jump when the record arrives.

The identity bar answers two different questions with two different elements. Do not conflate them.

  • The glyph says what kind of thing this is. A circle with initials for a person — employees are the only person entity. Everything else has no glyph: the icon added nothing the page did not already say, since the rail and the breadcrumb both name the record type, and a square badge on every record made the identity bars read the same at a glance.
  • The key chip says which one. Always present, monospace, tabular, immediately before the name.

Which value fills the key chip​

Entity hasChip showsNumeric key goes
String key (PLT-4)The key—
Numeric key only (7)The key—
Numeric key and a codeThe codeRecord facts rail

Teams and Team of Teams are the only entities with a code. The route still resolves on the numeric key; the code wins the chip because it is what people say out loud.

Never build the title by concatenation

Do not write title={`${key} - ${name}`}. A concatenated key cannot be styled, copied cleanly, or aligned across records of differing key lengths. Pass recordKey instead.

Never give a non-person a glyph​

Initials on a record read as a person, and collide immediately — "Identity Platform" and "Infrastructure Program" are both IP. An entity icon avoids the collision but earns nothing: every project carries the same one, so it identifies the type the page has already named rather than the record. Leave avatar unset on anything that is not an employee, and let the key chip carry uniqueness.

Record facts​

A record's stable facts go in RecordLayout's facts prop — a stack of LabeledContent (@/src/components/common/content), each a 12px secondary label over its value, with an optional tooltip on the label. Group them with RecordFactsGroup (label, e.g. "Record" or "Relationships") so a list of people does not read as another field. For links out of the record, RecordLinkList lists related records ({ id, name, href }[], no avatar) and RecordPersonLink renders a person as a small avatar and a link. All three come from @/src/components/common/record.

<RecordFactsGroup label="Relationships">
<LabeledContent label="Manager">
<RecordPersonLink name={manager.name} href={`/organizations/employees/${manager.key}`} />
</LabeledContent>
<LabeledContent label="Teams">
<RecordLinkList items={teams} />
</LabeledContent>
</RecordFactsGroup>

From md up, the facts render in a Details panel beside the section content. It is closed by default, opened from the identity bar's toggle or the edge handle, and resizable by dragging or with the arrow keys between 220px and 560px. Whether it is open and how wide it is are display preferences, stored in localStorage (wayd-record:facts-panel-open, wayd-record:facts-panel-width) and shared by every record type. Below md the facts render inline after the section content, with nothing to toggle. Omit facts and no panel or toggle appears.

Does it get a record page at all?​

Not every entity earns one. Some records have nothing to say beyond their own fields, and giving those a page spends a navigation round trip on one block of labels — when the real task is scanning and editing sibling rows.

Ask one question: does the record have content that is not a flat list of its own fields?

The record holdsShapeExample
Fields onlyConfig list — ConfigListPanelExpenditure category, estimation scale
A child collection, a matrix, a sequence, an editorRecord page — RecordLayoutWork process (its work-type→workflow schemes), scoring model (criteria, scales, outputs)
Neither — it is a dashboard or a toolNeither. PageTitle and contentBackground jobs, messaging

An operational page is not a record, so none of the section rules below apply to it — including the one against tab strips. Background jobs shows three peer views of the scheduler, and Tabs is still the right control for that: there is no record for a rail to belong to.

A config list keeps the list on screen and renders the selected record's fields in a panel beside it. Selecting a row changes the panel rather than navigating, so comparing three categories costs three clicks instead of six. The open record lives in ?selected=, because which record you are looking at is a place worth sharing — where a filter is a working preference and stays in local storage.

useSelectedRecord() owns that param — it returns selectedId, select(id) and clear(), writes with router.replace and scroll: false, and carries the rest of the query across. Wire it to ConfigListPanel and the grid's row activation (both from @/src/components/common/config-list):

const { selectedId, select, clear } = useSelectedRecord()

<ConfigListPanel
open={!!selectedId}
onClose={clear}
title={selected?.name}
details={<CategoryPanel category={selected} />}
actionItems={selected && getActionItems(selected)}
isLoading={isLoadingSelected}
>
<WaydGrid
columns={columns}
data={categories}
onRowActivate={(c) => select(String(c.id))}
activatedRowId={selectedId}
getRowActivateLabel={(c) => c.name}
/>
</ConfigListPanel>

useSelectedRecord reads the query string, so the page needs a Suspense boundary around it. app/settings/ppm/expenditure-categories/page.tsx is the reference.

A child collection is content, even a small one

A work process's schemes list looked like "just one more field" and is not: it has its own query, its own row count, and grows independently of the record. That is a record page. The test is the shape of the data, not how much of it there happens to be today.

Facts panel, or an Overview section?​

Both hold a record's fields, and choosing wrong is the easiest mistake on this page. The facts panel is closed by default. So a record whose fields are the content opens showing everything except the point.

  • Facts panel — the fields are reference material you consult while working in a section. A scoring model's state and description sit beside its criteria; the criteria are why you came.
  • Overview section — the fields are the record. A user account, an identity provider: there is no other content the fields would sit beside, so they lead.

A useful proxy: if you removed the fields entirely, would the page still have a reason to exist? Scoring models, yes — the criteria remain. A user account, no.

An Overview section also has the full content width, so its fields lay out in cards and columns rather than a 296px stack.

Sections​

Sections are the named content areas of a record. They render as a vertical rail, not a horizontal tab strip.

Show the rail only when there is more than one section. A rail holding one item spends 190px telling the user there is nowhere to go.

SectionsRenders as
0 or 1No rail. Content starts at the identity bar
2–3Judgement — rail if the record will grow sections or holds wide grids
4+Rail. Always

Reports (cycle time, history views) are rail entries in a Reports group below a divider — never appended tabs.

RecordLayout heads the content with the active section's label, so a section never opens as an unlabelled grid. Actions for the active section go in sectionActions, which renders right-aligned beside that heading. A section that draws its own title — because the title carries controls, like a report's date pickers — sets hideHeading: true on its entry. A record with a single section and no sectionActions has nothing to echo, so it gets no heading.

A RecordSection is { id, label, count?, hideHeading? }; count renders beside the label in the rail.

The type scale runs downward in three steps:

LevelRenders asSize
Record nameRecordHeader — Title level={4}, weight 60020px
Section headingRecordLayout — Title level={5}16px
Block heading inside a sectionText strong at fontSize: 1414px

The record name's weight, not its size, is what makes it read first — 600 against the section heading's default. A list page's PageTitle keeps its 30px heading; a record name is not the largest thing on its page.

Section labels name what the section holds, from the reader's point of view — "Assigned Work Items", not "Work Items", which reads like everything on the record. The id is a URL contract and does not change when a label is reworded.

<RecordLayout
sections={[
{ id: 'overview', label: 'Overview' },
{ id: 'backlog', label: 'Backlog', count: 42 },
{ id: 'team-memberships', label: 'Team Memberships', count: 4 },
]}
reports={[{ id: 'cycle-time', label: 'Cycle Time' }]}
defaultSection="overview"
>
{(section) => renderSection(section)}
</RecordLayout>
Do not invent sections to qualify for a rail

Splitting a record into "Details" and "Projects" so it looks consistent with other pages makes it worse — one scroll becomes two clicks. Let the record's real shape decide, and accept that some pages have no rail.

URL state​

Sections are addressable. RecordLayout owns this; pages do not implement it.

/organizations/teams/14 ← default section, no param
/organizations/teams/14?section=backlog
/organizations/teams/14?section=cycle-time

Use a query param, not a hash. Query params are correct on first render, restore on back/forward without a listener, reach the server, and compose with other state (?section=risks&status=open). A fragment does none of these.

const params = useSearchParams()
const requested = params.get('section')
const all = [...sections, ...reports]
const active = all.some((s) => s.id === requested) ? requested : defaultSection

// Carry the rest of the query across — the section is one axis of page state
const next = new URLSearchParams(params.toString())
if (id === defaultSection) next.delete('section')
else next.set('section', id)

// replace, not push — Back returns to the list, not through six sections
const query = next.toString()
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false })

Rules:

  • The default section writes a bare URL. The canonical link to a record stays clean.
  • Other query params survive a section change. RecordLayout edits only section, so a page's own params (a selected team, an overview tab) are not reset.
  • scroll: false is required. Without it the router scrolls to top on every section change.
  • Validate against the sections the current viewer can see, not the full list — sections are permission-gated, and a shared link can reach someone without access. RecordLayout validates against the sections and reports it is given and falls back to the default, so pass only the entries this viewer may open.
Section ids are a public contract

Ids appear in URLs people paste into tickets and Slack. Renaming one breaks existing links. Treat ids as stable, let label change freely, and use kebab-case (work-items, cycle-time).

Boundaries​

Three nested boundaries, so that a failure costs the smallest useful thing. RecordLayout provides the section boundary; the other two are route files.

BoundaryFile / ownerSurvives
SectionRecordLayoutRail, identity, context rail all keep working
Recorderror.tsxApp shell and navigation
App shellglobal-error.tsxLast resort — must render its own <html>/<body>

The section boundary is keyed by section id, so switching sections clears a caught error automatically. Its failure message names the section and offers Try again and, outside the default section, Back to overview. RecordLayout also wraps itself and each section in the Suspense boundary that useSearchParams requires, with an antd Skeleton as the fallback.

<SectionBoundary
key={activeSection}
sectionLabel={activeLabel}
onLeave={activeSection === defaultSection ? undefined : () => goTo(defaultSection)}
>
<Suspense fallback={<SectionFallback />}>{children(activeSection)}</Suspense>
</SectionBoundary>
note

The client defines neither error.tsx nor global-error.tsx, so an error outside a section reaches Next.js's built-in error page.

Distinguish the two failure kinds: a render error is a bug — say so and offer a reset. A query error is usually transient — name what failed and offer a retry. Collapsing both into "Something went wrong" trains people to reload for problems a retry would fix.

Loading states​

If you know the shape of what is coming, draw the shape. If you do not, spin.

SituationUse
Route transition (loading.tsx)Skeleton
Section bodySkeleton
Grid or list rowsSkeleton (row-shaped)
Form submit, status changeSpinner on the button
Modal or drawer fetchingSpinner
Metric tileSkeleton — MetricCard's loading, two lines for a label and a number
ChartSkeleton — ChartSkeleton, or ChartCard's loading, which draws it
Timeline, org chartSpinner — shape is data-dependent
Refetch of visible contentNeither — keep the stale data
Drive skeletons off isLoading, not isFetching

isLoading is the first load; isFetching is any load. Replacing content the user is already reading with a skeleton on refetch is worse than showing slightly stale data.

A skeleton that lies is worse than a spinner. If it shows three rows and twenty arrive, the page lurches — when you cannot predict the shape, spin honestly.

A chart is the case where that rule is easy to misread. Its data is unpredictable, but its frame is not: a plot area of known height, in a card of known width. ChartSkeleton draws that frame with a chart glyph in it, which is honest — nothing about it implies how many series are coming. A timeline or org chart has no fixed frame, so it still spins.

Never use a placeholder value as a loading state

value={isLoading ? '—' : count} reads as a real measurement — "not applicable" — rather than saying a number is on its way, and the tile then jumps as the dash is replaced. Pass loading instead and let the card draw its skeleton.

Where page state lives​

Decided by how long the value should survive, applied in this order:

  1. Would I share this? → URL query param
  2. Should it outlive the browser? → localStorage, via a Context or slice
  3. Should it survive leaving and coming back? → Redux slice, keyed by record id
  4. Otherwise → useState

Most state is (4), and that is fine. Reach for anything heavier only when a specific behaviour demands it.

Every persisted value has exactly one owner

A Context or a slice — never a component calling useLocalStorageState directly. Which of the two matters less than the single-owner rule; pick a Context when there is one consumer and no interaction with other state, a slice when several features read it.

Model dialogs as one value​

A page with many actions should hold one activeDialog, not one boolean per dialog:

// Do this
const [dialog, setDialog] = useState<DialogId | null>(null)

// Not this — 13 booleans that are one value, and nothing prevents two being open
const [openEditForm, setOpenEditForm] = useState(false)
const [openDeleteForm, setOpenDeleteForm] = useState(false)
// …

Do not​

  • Persist the whole Redux store. It holds the RTK Query cache — you would ship stale server data across restarts.
  • Move dialogs into Redux. A dialog open on a page you have left is a bug.
  • Create a generic uiState[pageKey] slice. It becomes an untyped bag nobody can safely delete from. Use named slices with named fields.

Data fetching​

  • Section counts come from one query on the record, not one query per section. The rail renders complete on first paint; sections still load their own data lazily when opened.
  • Prefetch on rail hover with RTK Query's usePrefetch — people hover before they click.
  • Do not keep sections mounted to make switching fast. It defeats the section-keyed error boundary and holds grid virtualizers alive off-screen; the query cache already makes remounting cheap.
Counts must share invalidation tags with the data they count

Add a risk and the Risks count must move. If the counts query does not carry the same tags, the rail quietly disagrees with the section beside it — and a wrong number is worse than no number.

Styling tokens​

Use Ant Design's tokens; add new ones only where none fits. The theme registry (src/config/theme/theme-registry.tsx) holds nine theme presets — the Wayd theme's four modes and five others — each redefining the same token names, so a raw hex value works in one and breaks in the other eight, and breaks again when a tenth is added.

Prefer var(--ant-*) in CSS modules over theme.useToken(); reach for the hook only when a value is needed in JavaScript logic.

Everything in this document maps to an existing token:

ElementToken
Facts label (12px, muted)--ant-font-size-sm, --ant-color-text-tertiary
Facts value--ant-font-size, --ant-color-text
Rail / panel background--ant-color-bg-container
Section rail active item--ant-color-primary-bg, --ant-color-primary
Rail hover--ant-color-bg-text-hover
Dividers, table borders--ant-color-border-secondary
Table header background--ant-color-fill-quaternary
Key chip background--ant-color-fill-tertiary
Skeleton blocks--ant-color-fill-quaternary
Status tags--ant-color-{success,warning,error,info}-{bg,border,text}

The two genuine gaps​

These are layout constants, not colors — they have no antd equivalent because they describe this pattern specifically. They live in RecordLayoutConstants in src/config/theme/theme-constants.ts, beside COLOR_PRIMARY, so they are defined once rather than repeated per page:

export class RecordLayoutConstants {
/** Section rail width — fits "Dependency Management" on one line. */
static readonly SECTION_RAIL_WIDTH = 190

/** Record facts panel default width — fits a full email address at 14px unbroken. */
static readonly FACTS_RAIL_WIDTH = 296

/** Narrow enough to still show a label and its value on separate lines. */
static readonly FACTS_RAIL_MIN_WIDTH = 220

/** Past this the panel starts competing with the section content it flanks. */
static readonly FACTS_RAIL_MAX_WIDTH = 560
}

ConfigListConstants beside it sizes the config list's panel (340px default, 260–620px).

Do not add a token for a one-off color

A value used in a single place is an inline style or a CSS module rule, not a token. Tokens are for values that must stay in agreement across many places — adding one for a single element means nine theme edits for no benefit.

Overview metrics​

An Overview section's metric tiles use MetricCard (or a specific metric like CycleTimeMetric) from components/common/metrics — never a raw antd Statistic. They carry height: 100%, so tiles in a row match heights even when only one has a secondary value, and secondaryValue renders bottom-right for qualifiers like the window an average covers.

A tile that summarises a section links to it. Pass onClick, which sets the hover affordance and gives the card a button role with Enter/Space handling. A tile with no corresponding section — one whose content is shown inline below — stays unlinked, so the affordance always means something.

<MetricCard
title="Assigned Work Items"
value={count}
onClick={() => goToSection('work-items')}
/>

Count from the same query the section uses, not a separate aggregate, unless an aggregate endpoint genuinely exists. A count that disagrees with the list beside it is worse than no count.

Omit a tile that would only ever read zero. An overview should carry facts, not absences — a "Direct Reports: 0" card is noise on the majority of employee records.

Hide a tile only once its query has resolved. Gating on the count alone (count > 0 && <MetricCard/>) hides the tile during the load and pops it in when data lands, shifting the row under the reader — and makes "none" indistinguishable from "not loaded yet". Gate on isLoading || count > 0 and pass loading.

Charts on an Overview​

Charts come from @ant-design/charts (G2 v5), wrapped in ChartCard so the title, skeleton and card chrome match the metric tiles beside them. Never pass title into the chart config — G2 would draw a second heading inside the canvas.

autoFit is not enough on its own

autoFit: true sizes a chart when it mounts and afterwards re-fits only on the window's resize event. Anything else that changes its width — opening or dragging the record facts rail, collapsing the app sider, a Col settling after first paint — never reaches it, so the canvas keeps its old size and overflows its card, or renders at zero width and appears blank.

Every chart needs autoFit: true and useChartRemountOnResize(): attach its ref to a wrapper div, pass renderKey as the chart's key. This has been missed three times; if a chart is blank or overflowing, check this first.

A closed vocabulary gets a fixed colour scale. Where the categories are a known set — work status, dependency health — pass a scale.color with the full domain, resolved from the same mapping the matching tags use. Two things follow: a category is the same colour on every chart, and dropping an empty category cannot re-index the palette and hand one category another's colour. BreakdownPie takes this as colorScale, and supplying it also switches the chart to a legend naming every value, so a chart showing a single slice still says what the alternatives were.

Open-ended breakdowns — work types, strategic themes — cannot do this: their vocabulary is whatever the data holds. They carry the category name in the slice label instead.

Drop zero-count entries before plotting. A zero slice draws no arc but still renders its label, stacking "0 (0%)" over the real one at the centre of the circle.

Grids inside sections​

Not every grid is a data-exploration surface. A section showing a record's four active projects needs a readable table; the Projects list page needs filtering, search, export and column management. Both are WaydGrid — they differ by variant.

// Inside a record section — read-and-click
<WaydGrid variant="simple" columns={columns} data={projects} />

// A list page — explore, filter, export
<WaydGrid
variant="advanced"
columns={columns}
data={projects}
persistStateKey="ppm-projects"
/>
simpleadvanced
ToolbarHiddenSearch, row count, refresh, clear, export, help
Column filtersOffPopup + floating filter row
Column menu (sort/pin/autosize)OnOn
HeightFits its rowsFills the remaining viewport
Layout persistenceDon't set persistStateKeyOpt-in via persistStateKey
Row virtualizationOnOn
Typical sizeUnder ~25 rowsUnbounded

Choose by the question the user is asking. "What is on this record?" → simple. "Which of these do I want?" → advanced.

A section grid that grows past roughly 25 rows is a signal the section should link to a filtered list page rather than become one — a View all link in the section's sectionActions.

variant is a preset, not a new mode

It sets the existing include* flags together and sizes the grid to its rows. Individual props still override it, so variant="simple" with includeColumnFilters is valid for the rare section that needs one thing back — but a simple grid has no toolbar, so search, export and the toolbar slots cannot come back. Prefer the plain variant where you can — a section grid carrying a full toolbar competes with the page's own chrome.

List pages​

The record page's counterpart, and always variant="advanced". Two rules carry across every list:

  • A primary identity column — glyph + name + one secondary line — absorbing what would otherwise be two or three columns. Key and other identifiers become opt-in columns, not defaults.
  • Common filters are visible, as a chip bar above the grid. The most-used toggle for a list should never be hidden inside a menu.

Everything else follows WaydGrid.

Responsive​

Every page must work at every width. This is a requirement, not an enhancement — check a phone width before opening a PR.

Use Ant Design's useBreakpoint and the existing conventions: !screens.md (<768px) is the app's mobile cut, matching AppHeader (which moves navigation into its Drawer, while AppSideNav renders nothing) and RecordLayout. ResponsiveFlex (@/src/components/common) handles the common stack-on-small case at the same cut.

Rails​

BreakpointSection railRecord facts
md+ (≥768)VisibleDetails panel — closed by default, edge handle to open; open state and width remembered
sm/xs (<768)Section SelectInline, after the section content

Nothing is dropped on mobile — it only moves. The inline facts are the same fields in the same order as the panel.

Grids​

WaydGrid has no breakpoint behaviour of its own: both variants keep the same table at every width, and a table wider than its container scrolls horizontally inside the grid.

Rules that apply everywhere​

  • Hit targets ≥44px. Rows, Select options, buttons, and rail entries.
  • Horizontal scroll belongs to the element, never the page. A wide grid or timeline scrolls inside its own overflow-x: auto container; the page body never scrolls sideways.
  • Give virtualized content a real height. WaydGrid renders zero rows in an unconstrained <Flex vertical> parent — a known trap. Keep it a block-level child with a measurable height.
  • Do not draw device chrome. No fake status bars or keyboards in mobile views.

Testing it​

Use the browser's device toolbar (Ctrl/Cmd+Shift+M in Chrome or Edge DevTools) — this is the expected check before opening a PR, not an optional extra. In development, the breakpoint chip in the bottom-right corner shows which Ant Design breakpoint is active, so you can confirm you are actually testing the boundary you think you are.

Check each page at four widths, and at the boundaries either side of each rule above:

WidthRepresentsWhat must be true
1440DesktopSection rail and open Details panel side by side; nothing clipped
1100Small laptopDetails panel open beside the content; content still usable
834Tablet (md)App sider and section rail still visible; Details panel still fits
390Phone (xs)App sider gone to the header Drawer; section Select with all sections; facts inline after the content; wide grids scroll inside themselves

At every width:

  • The page body does not scroll horizontally — only the wide element does
  • No grid renders zero rows (the virtualizer has a measurable height)
  • Hit targets are ≥44px
  • Text does not wrap into overlap in the identity bar — long record names truncate
  • No content is unreachable — dropped columns are genuinely low priority, not the only copy of a value
Test the boundaries, not just the middles

Bugs live at 767/768, not at 390. The chip tells you which side you are on.

Emulated widths catch layout; they do not catch touch ergonomics or real-device font rendering. Worth one pass on an actual phone before rolling the pattern across areas — but DevTools is enough for day-to-day work.

Adoption​

Nothing forces a big-bang migration. RecordLayout and RecordHeader are components pages opt into, and PageTitle is untouched — an unmigrated page renders exactly as it does today.

Recommended order for an area:

  1. Record facts rail — move flat Descriptions grids into LabeledContent. Ships alone, independently valuable.
  2. RecordLayout + section rail — prove it on the area's largest record first; the header comes with it.
  3. Responsive — drawer and section Select, checked at real widths.
  4. List page — identity column and filter chips.

PageTitle and RecordHeader are peers, not predecessor and successor. PageTitle heads a list page, where the title is the largest thing on screen; RecordHeader is a record's identity bar. Every list page in the app renders PageTitle and will continue to — retiring it would leave list pages with no heading component, and RecordHeader cannot stand in for one without growing the branching the two were split apart to avoid.

Page padding​

.app-main-content has no padding, permanently. It is a scroll container and nothing else. Padding is opted into by whatever owns a page's frame:

The page isFrameGutters
A recordRecordLayout / RecordShellSupplied by the component — runs edge-to-edge
A list, dashboard, or toolits own root elementclassName="page-gutters"
An area with its own chrome (docs)that area's layoutWhatever the area draws
Full-viewport (login, logout, setup)the page itselfNone — it paints its own background

.page-gutters (in styles/globals.css) holds the measurement once: 16px 24px 24px, tightening below 768px.

Never pad a parent and negate it in the child

A child cannot reliably un-pad its parent — negative margins work until something clips, which is what .app-main-content's overflow-x: hidden was there to hide. This rule is why SettingsRecordShell no longer exists: settings used to pad its content column, so its record pages each had to cancel it again. The padding moved onto the settings list pages instead, and the negation went away.

An area holding both list and record pages — settings does, and so does the top level — cannot pick one frame for all of them. Keep the gutters on the pages that want them.

Reference​