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.
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.
| Slot | Holds | Employee | Team | Project |
|---|---|---|---|---|
| 1 · Identity | Glyph (people only), key, name, status tags, descriptor | Avatar, 1042, job title | PLAT-CORE, methodology | PLT-4, portfolio |
| 2 · Actions | One primary action; rest in PageActions | View Teams | Add Member | Status transition |
| 3 · Record facts | LabeledContent rail — the stable facts | Email, number, hire date | Code, type, sizing | Dates, lifecycle, category |
| 4 · Sections | Named content areas + reports | Teams, Projects, Work | Backlog, Sprints, Risks… | Team, Plan, Work Items |
| 5 · Relationships | Links out of this record | Manager, direct reports | Parent team, scrum master | Portfolio, 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:
| Prop | Holds |
|---|---|
name | The record's name |
recordKey | The key chip's text — see Which value fills the key chip |
avatar | The glyph — employees only |
parent | Breadcrumb link back up; an array walks outermost-first for a record reached through more than one hop |
subtitle | What kind of page this is ('Team Details'), closing the breadcrumb trail |
descriptor | What the record is, in its own words — a job title. Secondary, inline after the tags |
tags | Status and other qualifiers beside the name |
actions | Record-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 has | Chip shows | Numeric key goes |
|---|---|---|
String key (PLT-4) | The key | — |
Numeric key only (7) | The key | — |
Numeric key and a code | The code | Record 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.
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 holds | Shape | Example |
|---|---|---|
| Fields only | Config list — ConfigListPanel | Expenditure category, estimation scale |
| A child collection, a matrix, a sequence, an editor | Record page — RecordLayout | Work process (its work-type→workflow schemes), scoring model (criteria, scales, outputs) |
| Neither — it is a dashboard or a tool | Neither. PageTitle and content | Background 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 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.
| Sections | Renders as |
|---|---|
| 0 or 1 | No rail. Content starts at the identity bar |
| 2–3 | Judgement — 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:
| Level | Renders as | Size |
|---|---|---|
| Record name | RecordHeader — Title level={4}, weight 600 | 20px |
| Section heading | RecordLayout — Title level={5} | 16px |
| Block heading inside a section | Text strong at fontSize: 14 | 14px |
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>
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.
RecordLayoutedits onlysection, so a page's own params (a selected team, an overview tab) are not reset. scroll: falseis 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.
RecordLayoutvalidates against thesectionsandreportsit is given and falls back to the default, so pass only the entries this viewer may open.
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.
| Boundary | File / owner | Survives |
|---|---|---|
| Section | RecordLayout | Rail, identity, context rail all keep working |
| Record | error.tsx | App shell and navigation |
| App shell | global-error.tsx | Last 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>
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.
| Situation | Use |
|---|---|
Route transition (loading.tsx) | Skeleton |
| Section body | Skeleton |
| Grid or list rows | Skeleton (row-shaped) |
| Form submit, status change | Spinner on the button |
| Modal or drawer fetching | Spinner |
| Metric tile | Skeleton — MetricCard's loading, two lines for a label and a number |
| Chart | Skeleton — ChartSkeleton, or ChartCard's loading, which draws it |
| Timeline, org chart | Spinner — shape is data-dependent |
| Refetch of visible content | Neither — keep the stale data |
isLoading, not isFetchingisLoading 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.
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:
- Would I share this? → URL query param
- Should it outlive the browser? →
localStorage, via a Context or slice - Should it survive leaving and coming back? → Redux slice, keyed by record id
- Otherwise →
useState
Most state is (4), and that is fine. Reach for anything heavier only when a specific behaviour demands it.
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.
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:
| Element | Token |
|---|---|
| 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).
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 ownautoFit: 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"
/>
simple | advanced | |
|---|---|---|
| Toolbar | Hidden | Search, row count, refresh, clear, export, help |
| Column filters | Off | Popup + floating filter row |
| Column menu (sort/pin/autosize) | On | On |
| Height | Fits its rows | Fills the remaining viewport |
| Layout persistence | Don't set persistStateKey | Opt-in via persistStateKey |
| Row virtualization | On | On |
| Typical size | Under ~25 rows | Unbounded |
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 modeIt 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
| Breakpoint | Section rail | Record facts |
|---|---|---|
md+ (≥768) | Visible | Details panel — closed by default, edge handle to open; open state and width remembered |
sm/xs (<768) | Section Select | Inline, 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,
Selectoptions, buttons, and rail entries. - Horizontal scroll belongs to the element, never the page. A wide grid or timeline scrolls inside its own
overflow-x: autocontainer; 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:
| Width | Represents | What must be true |
|---|---|---|
| 1440 | Desktop | Section rail and open Details panel side by side; nothing clipped |
| 1100 | Small laptop | Details panel open beside the content; content still usable |
| 834 | Tablet (md) | App sider and section rail still visible; Details panel still fits |
| 390 | Phone (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
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:
- Record facts rail — move flat
Descriptionsgrids intoLabeledContent. Ships alone, independently valuable. RecordLayout+ section rail — prove it on the area's largest record first; the header comes with it.- Responsive — drawer and section
Select, checked at real widths. - 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 is | Frame | Gutters |
|---|---|---|
| A record | RecordLayout / RecordShell | Supplied by the component — runs edge-to-edge |
| A list, dashboard, or tool | its own root element | className="page-gutters" |
| An area with its own chrome (docs) | that area's layout | Whatever the area draws |
| Full-viewport (login, logout, setup) | the page itself | None — it paints its own background |
.page-gutters (in styles/globals.css) holds the measurement once: 16px 24px 24px, tightening below 768px.
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
- WaydGrid — data grids, column meta, persistence
- Frontend Development — API calls, theming, project structure
- Coding Standards — naming and conventions