Every screen lays out for the window the app has now, on iOS and Android: one window classifier, breakpoints in one module, a readable column on wide windows, a list beside its detail when both fit in the box the panes get, grids sized from their own box, no orientation lock or resizing opt-out the platforms ignore anyway, and a selection that lives in the route so rotation, resizing and links keep it. A zero-dependency checker proves what a reviewer would otherwise check by eye.
Rules that must hold
- Layout decides by the window, never the device. No
Platform.isPad,interfaceIdiom, expo-deviceDeviceType,isTablet()orisPhonehelper picks a layout, and nogetOrientationAsyncor orientation listener shapes one: readuseWindowClass()(and itsisLandscape, the window's shape). Why: iPad Split View, Slide Over and Stage Manager, Android multi-window and freeform windows, and a foldable opening all resize the app while it runs; an iPad in a third of the screen is a compact, portrait-shaped window. A hardware capability insrc/services/(no haptic engine on iPad) is not a layout decision; the rule reads UI code only. (device-branch,device-orientation) - One window classifier, never forked.
src/hooks/use-window-class.ts(react-components-and-hooks owns it) gives{ width: 'compact' | 'regular' (600), isLandscape, isLargeText (1.25) }; no seconduseBreakpoint,useIsTabletorREGULAR_WIDTH_MINelsewhere. Why: two classifiers disagree at the boundary and screens stop agreeing on what "regular" means. (window-class-fork) - Breakpoints beyond the class live in
src/ui/adaptive/pane-layout.ts, as content rules. A width or height compared with a number of 300 or more anywhere else fails; a new rule is a pure function ("two panes when the list at 320 and the detail at 400 fit") tested at the window table. Why: a number in a screen drifts from every other screen's, and a content rule explains itself. (raw-breakpoint) - Every screen caps its reading width at
CONTENT_MAX_WIDTH. ThroughScreenFrame,ListDetailLayout, or a list'scontentContainerStyle(width: '100%', maxWidth: CONTENT_MAX_WIDTH, alignSelf: 'center'); a canvas, map, camera or video screen is listed incontracts/adaptive-layout.jsonfullBleedScreenswith its reason; no hand-pickedmaxWidth: 720. A screen in acontracts/architecture.jsontestOnlyFoldersfolder (a capture harness, a test build's screen) is not judged: it never ships in a store build. Why: on a 13-inch iPad in landscape an uncapped line runs 1,300 pt wide. (readable-width,readable-width-token) - No fixed width wider than the narrowest window allows (288). Regions size by
flex, percentages, the pane layout'slistWidthor a measured container. Why: a 320 pt Split View pane or small phone minus two 16 pt gutters leaves 288; anything wider scrolls sideways or clips. (fixed-width) - A list and its detail share one screen through
ListDetailLayout, sized by its own box. The pane count comes fromuseListDetailLayout(): one pane on a compact window; on a regular one, two panes only when the list, a hairline and the detail minimum fit in the widthListDetailLayoutmeasures inside the screen'sSafeAreaView(721 pt, 801 pt with large text; before the first layout, the window minus the side insets). Each screen inlistDetailScreensrenders it withpanes={model.panes}. Why: a regular window is not always wide enough (a half-width iPad Split View is 678 pt; an iPad mini beside an 80 pt side rail leaves 664 pt; a form sheet is narrower still), and the notch side is not usable space. (list-detail-screens) - The selection is the route's, and each route owns its param. The list route carries
?selected=<id>, the detail route is[id], both render the same screen and each reads only its own param; selecting callsrouter.pushon one pane, and on two panesrouter.setParamswith the param the current route owns ({ selected }on the list route,{ id }on the detail route); nothing navigates when the window changes; nouseStateselection. Why: a rotation, a resize, a restored route and a link all land on the same item, back never walks through every tapped row, and/tasks/t2?selected=t3would leave the detail pane ont2. (list-detail-selection) - Grid columns come from the grid's own box, and a new count remounts the list.
useMeasuredWidth()+gridColumnsOf({ width, minCellWidth, gap, maxColumns }), andkey={`grid-${columns}`}on theFlatList. Why: inside a pane the window is far wider than the grid, and React Native throws "Changing numColumns on the fly is not supported" on the first rotation. (grid-columns-source,grid-columns-key) - Orientation is not locked, except where the contract says why. The app config sets
orientation: 'portrait'(a phone-only lock in practice) or'default';'landscape'needsappOrientation: 'landscape'in the contract. A per-screen lock (lockAsync, a Stack screen'sorientationoption) sits only in anorientationLocksfile with its reason, and everylockAsynchas itsunlockAsync; expo-screen-orientation'sinitialOrientationlock needs the matchingappOrientation. Why: iPads with multitasking and Android displays 600 dp and wider (apps targeting API 36) ignore locks, so the layout must handle both shapes anyway. (orientation-lock,orientation-unlock,config-orientation) - The app never opts out of resizing, and supports tablets. No
requireFullScreen: trueorUIRequiresFullScreen(unlessUIRequiresFullScreenIgnoredStartingWithVersionof 27 or lower, usually'26', limits it to older iOS), noresizeableActivity, aspect-ratio limits orPROPERTY_COMPAT_ALLOW_RESTRICTED_RESIZABILITYin the config or a plugin, not even set to the default (TN3192 asks apps to drop the key; the checker calls that redundant);ios.supportsTablet: trueunless the contract saystablets: falsewith a reason. Why: Apple deprecated full screen in iPadOS 26 and ignores it for apps built with the iOS 27 SDK; Android 16 ignores the opt-outs on large displays and the last one stops working at API 37; a phone-only app runs in a scaled frame on iPad. (full-screen-opt-out,tablet-support) - Insets are physical and change with the window. Rotation moves the notch or cutout to a side;
take both sides (or
SafeAreaViewedges), neverpaddingStart: insets.left, and never count the notch side as pane width (measure inside the edges; subtract the side insets from a window). Why: the cutout does not mirror in RTL, and a detail pane under the notch is unusable. (react-components-and-hooks'inset-direction; the pane tests here) - Every layout decision is a pure function tested at the window table, at
fontScale1 and 2. 320 × 568, 360 × 800, 402 × 874, 874 × 402 (62 pt side insets), 678 × 1032, 744 × 1133, 800 × 1280, 1376 × 1032; screenshots on named devices at the same sizes. Why: Jest lays nothing out, and adaptive bugs live at the size nobody opened.
Workflow
- Read references/window-classes-and-panes.md: window versus device, the classifier, the window table, the pane rule, readable widths, grids, fixed widths, large text, and the decisions with their reasons.
- First time in an app. Make sure
src/hooks/use-window-class.tsexists (react-components-and-hooks ships it; this skill's synced copy is the same file). Copytemplates/src/ui/adaptive/tosrc/ui/adaptive/with every test, andtemplates/contracts/adaptive-layout.jsonplus its schema tocontracts/. The kit imports design-system-structure's@/theme/*; the tests use unit-and-component-tests'@test/render-with-providers. - The app config. Read references/platforms-orientation-and-resizing.md
sections 1-3: keep
orientation: 'portrait'or'default', setios.supportsTablet: true, remove every resizing opt-out, and record a deliberate profile (landscape app, no iPad layout yet) in the contract (section 8). - A screen. Put it in
ScreenFrame(or cap its list'scontentContainerStyle); a full-bleed screen goes intofullBleedScreenswith its reason. Decide variations fromuseWindowClass(); if a decision needs a width, add a pure function topane-layout.tswith window-table tests. - A list and its detail. Read references/list-detail-and-navigation.md
and imitate examples/tasks-list-detail.md: two thin routes over one
screen, the model hook reads the route's own param from
useLocalSearchParams()and the panes fromuseListDetailLayout(),onSelectsets the current route's param on two panes, the screen renders<ListDetailLayout panes={model.panes}>in aSafeAreaViewwith the named edges. Add the screen tolistDetailScreens, and the example's route tests (both routes, a measured box). - A grid. Imitate examples/task-board.tsx: measure the container,
gridColumnsOfandgridCellWidthOf, render nothing until the first layout, key the list by the column count. - An orientation lock (camera, full-screen video): platforms reference section 5. Lock in an
effect whose cleanup unlocks, list the file in
orientationLockswith its reason, and still lay the screen out for both shapes. - Tests first. Follow references/testing-and-checker.md
sections 1-2: window-table tables at
fontScale1 and 2, theuseWindowDimensionsmock (Jest's defaultfontScaleis 2),SafeAreaProviderwithinitialMetrics, a fired layout event, the crossing test. Route tests use the router helper as in the example. - Check. Run
npm run typecheck,npx eslint <changed paths> --max-warnings 0and the Jest tests, thennode ${CLAUDE_SKILL_DIR}/scripts/check-adaptive-layout.mjs .. Fix everyFAILline (file:line, rule, fix) and rerun until it printsRESULT: PASS. For a monorepo pass--app-dir apps/mobile; for a moved source folder or other globs read testing reference section 5. - On devices (by hand, before a release): screenshots at the window table on named simulators
and emulators (
<project>-tablet, a foldable profile), including a half-width iPad Split View,fontScale2 and an RTL locale; fold and unfold with a task selected. ios-simulator-build, android-emulator-build and e2e-maestro own the commands.
Definition of done
- No layout decision reads the device kind or the device orientation; every one comes from
useWindowClass(),useListDetailLayout()or a measured container. - No width breakpoint outside
src/hooks/use-window-class.tsandsrc/ui/adaptive/; each new layout function has a window-table test atfontScale1 and 2. - Every screen caps its reading width, or is listed in
fullBleedScreenswith a reason; no fixed width above 288. - Every list-detail screen renders
ListDetailLayout, is inlistDetailScreens, and reads its selection from the route; route tests prove push on a phone window,setParamsof the current route's own param on a wide one (from the list and from the detail route), and one pane in a box narrower than two panes. - Every computed
numColumnscomes fromgridColumnsOfon a measured width and keys the list; cells takegridCellWidthOf, so a short last row keeps the cell size. - The app config keeps
portraitordefault(or the contract'sappOrientation), supports tablets (or the contract says why not), and opts out of no resizing; each orientation lock is in the contract with its reason and unlocks. -
npm run typecheck, ESLint--max-warnings 0and Jest pass. -
node ${CLAUDE_SKILL_DIR}/scripts/check-adaptive-layout.mjs .printsRESULT: PASS. A passing run may print only these SKIP lines while the app is being set up:SKIP src/hooks/use-window-class.ts [window-class-fork] not yet created (react-components-and-hooks creates it)andSKIP app.config.ts [config-orientation] not yet created (expo-project-bootstrap creates it); it printsNOT APPLICABLE: react-native is not in package.jsonoutside a React Native app.
Anti-patterns
Platform.isPad ? 2 : 1columns. An iPad in Split View gets two cramped columns and a phone in landscape gets one; usegridColumnsOfon the measured width.width >= 768in a screen. A third breakpoint nobody else shares; use the window class or add a content rule topane-layout.ts.- "Regular means two panes." A 678 pt Split View pane is regular and too narrow; let
listDetailLayoutOfdecide from what fits. - Deciding panes from the window beside a side bar or in a sheet. The window is wider than the
panes there; pass the panes' own measured box (
ListDetailLayoutdoes it throughpanes). router.setParams({ selected })from the detail route. It gives/tasks/t2?selected=t3and the detail stays ont2; set{ id }there.useEffect(() => { if (isTwoPane) router.replace(...) }, [isTwoPane]). It fights the back stack on every rotation; both routes already render both ways.const [selectedId, setSelectedId] = useState()for the detail pane. A resize remounts it and a link cannot set it; use the route param.- A list-detail screen inside
ScreenFrame. Its 640 pt column is narrower than two panes, so the screen never splits; renderListDetailLayoutin aSafeAreaViewwith the named edges. numColumns={columns}without a key. React Native throws the first time the count changes.orientation: 'landscape'"because it looks better", orlockAsyncwithoutunlockAsync. Tablets rotate anyway, and every later screen stays locked.requireFullScreen: trueto avoid iPad work. Deprecated and ignored from the iOS 27 SDK; the app gets resized with a layout that never planned for it.Dimensions.get('window')at module level. It never updates (react-components-and-hooks bans it); readuseWindowDimensions()through the hooks.
Files in this skill
| File | What it is | Read/run when |
|---|---|---|
| references/window-classes-and-panes.md | Window versus device, the classifier, the window table, the pane rule, readable widths, grids, fixed widths, large text, wide-window navigation facts, decisions and re-verify commands | Workflow steps 1 and 4 |
| references/list-detail-and-navigation.md | The route-owned selection with Expo Router: files, selecting, crossing the two-pane width, back and links, what not to do, tests | Workflow step 5 |
| references/platforms-orientation-and-resizing.md | App config keys, iPadOS multitasking and TN3192, Android 16 large screens, foldables, per-screen locks, device orientation versus window shape, insets, profiles | Workflow steps 3 and 7 |
| references/testing-and-checker.md | Jest recipes, device runs at the window table, the checker's rules and scope (layout rules read UI code only), the contract keys, multi-package use, limits to review by hand | Workflow steps 8 and 9 |
| examples/tasks-list-detail.md | Example (shape only): routes, model hook, screen, a supporting pane, five route tests and the contract entry | Workflow step 5 |
| examples/task-board.tsx | Example (shape only): a grid whose columns and cell width follow its measured container | Workflow step 6 |
templates/src/ui/adaptive/ |
The pane kit, each file with its test: pane-layout.ts (listDetailLayoutOf, gridColumnsOf, gridCellWidthOf, the pane constants: the only home of extra breakpoints, tested at the window table), use-list-detail-layout.ts ({ layout, onLayout }: the pane count for the panes' measured box, the window minus the side insets until the first layout), list-detail-layout.tsx (ListDetailLayout: one pane per route or two side by side, measures its own box, a readable detail column, panes kept across a crossing), use-measured-width.ts (a container's whole-point width from onLayout) |
Workflow steps 2, 4, 5 and 6; copy to src/ui/adaptive/ |
templates/src/hooks/ |
use-window-class.ts and its test: the canonical window classifier, synced from react-components-and-hooks (never edit this copy) |
Workflow step 2 |
templates/contracts/ |
adaptive-layout.json (the app's adaptive facts: orientation, tablets, list-detail and full-bleed screens, locks) and adaptive-layout.schema.json |
Workflow steps 2, 3, 5 and 7; copy to contracts/ |
scripts/check-adaptive-layout.mjs |
The checker for every rule above (--help lists them) |
Workflow step 9, and at the end |
scripts/selftest.mjs |
Proves the checker passes the templates over a small Tasks app and catches each planted bug | After changing the checker |
scripts/check-lib.mjs |
Shared script helper (CLI, reporting, repo walk, layout, self-test), synced (do not edit here) | Never by hand |
scripts/lib/source-scan.mjs |
Source lexer and JSX helpers, synced (do not edit here) | Never by hand |
assets/shared.json |
Declares the shared files this skill copies in | When adding a shared file |
tests/bases/app/ |
The small Tasks app the fixtures start from (the templates are laid over it) | When adding a rule |
tests/fixtures/ |
Overlay cases: good, one planted bug per rule, passing profiles (landscape app, no tablets, older-iOS full screen, monorepo, small values) and bad input | When adding a rule |
Related skills
react-components-and-hooks- ownsuseWindowClass,ScreenFrame, safe-area edges andDimensions.design-system-structure- ownsCONTENT_MAX_WIDTH, spacing and the theme the kit styles with.navigation-and-routing- tab bars and side bars on wide windows, route params, predictive back.accessibility-as-code- the large-text threshold, 200 % text and touch targets.rtl-and-direction- mirrored panes and physical insets in right-to-left layouts.forms-and-input- keyboard avoidance (not a layout class).skia-canvas-rendering- canvases size from their own box, never the window.unit-and-component-tests- render helpers, the router helper, Jest conventions.ios-simulator-build,android-emulator-build,e2e-maestro- named devices and screenshots at the window table.expo-prebuild-and-config-plugins- config plugins that touch the manifest or Info.plist.