Mobile layout & touch targets
The editor is the one part of the app that was built desktop-first, and it shows most on a phone. This page records the conventions that keep it usable there, so new blocks and controls follow the same rules instead of re-introducing the same problems.
The problem being solved
Every content block is content plus a stack of controls (drag, move up, move down, delete, and a couple of block-specific extras). Those controls used to sit in a fixed column down the right-hand side at every viewport width. The column costs the same number of pixels whatever the screen, and on a 360px phone there aren't many to spare:
| width | |
|---|---|
| viewport | 360 |
less EditorPage's px-4 | 328 |
less SectionCard's p-4 | 296 |
less the block card's p-4 | 264 |
Out of that 264px the control stack took 128px (text, image, question blocks) or 164px (spelling blocks, which have two extra buttons). Subtract the gap-2 between the content and the controls and what remained was a ~128px box for typing a 60-110 word lesson paragraph. A spelling word is worse than the 92px that leaves suggests: each word sits in its own row with a delete button beside it (gap-1 + 32px), so the field itself came out at a ~56px box for typing a 6-9 letter word.
Rule 1: controls become a footer below sm
ContentBlock.jsx defines one shared layout class and uses it for every block type:
const BLOCK_LAYOUT = "flex flex-col gap-2 sm:flex-row sm:items-start";Below sm the row becomes a column: content takes the full width and the controls wrap underneath, right-aligned, with a hairline (border-t … sm:border-t-0) that makes the row read as a footer rather than as more content. From sm up it's the original corner column, unchanged.
SectionCard's section header splits the same way: the section number and name keep the first row and the move/delete buttons drop below them. It does this by rendering the control group twice, once inside the header row (hidden sm:flex) and once beneath it (sm:hidden), rather than by wrapping, because the header row is sticky and the controls must not be pinned with it. Pinning them cost 113px, 13% of an 844px screen. See Navigating large lessons.
If you add a new block type, use BLOCK_LAYOUT rather than a bare flex items-start gap-2.
Rule 2: 40px touch targets, shrinking to 32px only for a mouse
The editor's icon buttons were size="icon-sm" (32px) and its inline buttons size="sm" (h-8, 32px). Both are under Apple's 44pt and Material's 48dp minimums, and they sit in rows of three to five with 4px between them.
IconActionButtonnow appliessize-10 sm:pointer-fine:size-8itself, so every block and section control gets this for free.- Inline
size="sm"buttons use theTOUCH_SM_BUTTONconstant (h-10 sm:pointer-fine:h-8), defined in bothContentBlock.jsxandSectionCard.jsx. ToggleGrouppasses no sizing down to its items, so the alignment/size toggles an image block and a VAKT activity's picture share useTOUCH_TOGGLES, which reaches them by theirdata-slot.
The shrink is gated on the pointer, not only on the width. A tablet is sm and up and still driven by a finger, so keying off the breakpoint alone handed every tablet exactly the 32px targets this rule exists to avoid. The variants are stacked (shrink only where the screen is wide and the pointer is fine) rather than written as a pointer-coarse: override competing with sm:, because those two are equally specific and which one wins would come down to the order Tailwind emits the media queries in.
This is the same signal that hides the drag handle (see Drag-and-drop is desktop-only); pointer-coarse and pointer-fine are the two halves of it.
Rule 3: tooltip is also the accessible name
IconActionButton takes a tooltip prop and now forwards it as aria-label too. A tooltip labels these buttons for a mouse user and nobody else: touch devices have no hover, so on a phone each control was an unlabelled icon, and assistive technology got nothing either. Pass aria-label explicitly only when you want a longer spoken name than the tooltip's text.
Drag-and-drop is desktop-only
Block reordering uses the HTML5 Drag and Drop API (draggable plus dragstart/dragover/drop, see Overview & features). Android Chrome never synthesises drag events from touch, and iOS Safari only does so for a long-press in some cases, so on a phone the grab handle was a control that mostly did nothing, and its touch-none meant touching it swallowed the page scroll as well.
The handle is therefore hidden on coarse pointers (pointer-coarse:hidden in SectionCard.jsx). Nothing is lost: the move up/down buttons sitting next to it reorder blocks without a drag, and they work across the whole lesson the same way. useDragAutoScroll is likewise inert on touch, which is fine: it only runs while a drag is in flight.
Safe areas: notch, status bar and home indicator
index.html's viewport meta sets viewport-fit=cover, which lets the page draw under the status bar, the notch, the rounded corners and the home indicator, and is what makes env(safe-area-inset-*) resolve to anything other than zero. In the installed app iOS also draws under the status bar on purpose (apple-mobile-web-app-status-bar-style: black-translucent), so the app's own chrome fills the top of the screen instead of a mismatched strip.
The catch is that anything pinned to a screen edge has to step back by the inset itself, or it ends up under the clock and battery icons, under the notch in landscape, or in the ~34px home-indicator strip where the swipe gesture wins and a tap doesn't land. The side sheets (the nav menu and the lesson checks) used to do exactly that: they were flush with the top of the screen, so their headers and close buttons sat behind the status bar.
The tokens
globals.css reads each inset once, into a token per edge:
--safe-top: env(safe-area-inset-top, 0px);
--safe-right: env(safe-area-inset-right, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
--safe-left: env(safe-area-inset-left, 0px);
--safe-x: max(var(--safe-left), var(--safe-right));
--safe-y: max(var(--safe-top), var(--safe-bottom));--safe-x and --safe-y are the bigger inset on each axis, for something centred on the screen that has to clear both ends of it (the dialog caps, below).
Use the tokens (or the utilities below), not env() directly. Besides keeping one place that knows where the values come from, it means a layout can be checked in a desktop browser, which has no notch to report, by setting them by hand in the console:
const s = document.documentElement.style;
s.setProperty("--safe-top", "59px"); // iPhone 15, portrait
s.setProperty("--safe-bottom", "34px");
// Landscape: --safe-left and --safe-right both 59px, --safe-bottom 21px.In landscape iOS reports the same inset on both sides, not only on the side the notch happens to be on, so set both.
The utilities
| Utility | Use it for |
|---|---|
pt-safe, pb-safe, pl-safe, pr-safe | An element whose padding on that edge is otherwise zero: sheets, the practice view's header and footer, AppHeader's top |
mb-safe, ml-safe, mr-safe, mx-safe | A floating element that already has an offset, like the add-section FAB's right-4 bottom-4 sm:right-8 sm:bottom-8 |
px-safe-<n> | A content column or bar that already has side padding: px-safe-4 is px-4 plus the inset |
Paddings for things that reach the edge. Padding rather than margin, so the background still runs to the edge of the screen while the contents drop clear of it. That's how AppHeader handles the status bar, and how ui/sheet.jsx handles every side: a sheet pads by the insets of the edges it touches (a left sheet gets pt-safe pb-safe pl-safe) and moves its close button by the same amounts, since an absolutely positioned child is measured from the padding edge and the padding alone wouldn't move it. PageBar needs no top inset because it pins at --appheader-h, which already includes --safe-top.
Margins for floating things. A margin composes with whatever bottom-* or right-* offset an element already uses instead of having to restate it at every breakpoint. The FAB, the collapsed collab-chat launcher and its corner panel from sm up, and the first-lesson wizard all use them.
px-safe-<n> for side padding. In landscape both side insets are about 59px, and the header, PageBar and the page's content columns all run the full width of the screen, so each of them gives up that much at both ends. Writing px-4 px-safe wouldn't work: both set padding-left, so whichever Tailwind emits last replaces the other rather than adding to it, and tailwind-merge doesn't know to drop either. The functional utility takes the spacing step and adds the inset in one declaration (calc(var(--spacing) * 4 + var(--safe-left))).
Content columns use PAGE_GUTTER. layout/PageBody.jsx exports it (currently px-safe-4) next to PAGE_WIDTHS, and it's the side padding for anything that holds content and can reach the sides of the screen: PageBody itself (so every page that renders into one, and the route-level Suspense fallback in App.jsx), the editor's panes, the lesson's tab bar and the home page's hero text. If you add a full-width column that isn't a PageBody, use PAGE_GUTTER rather than a bare px-4. It isn't padding on AppShell's <main>, though that would cover everything at once, because <main> also holds things that have to reach the edge: PageBar's card background, the tab bar's full-width rule and the hero's gradient would all stop 59px short of each side.
Toasts get the same treatment through sonner's offset and mobileOffset props in ui/sonner.jsx, which add the tokens to sonner's default 24px and 16px gaps.
Every inset is zero where nothing is in the way, so none of this changes the layout on a desktop or on a phone without a notch.
Dialogs scroll on short screens
A phone turned sideways is about 390px tall, and a dialog taller than that used to run off both ends of the screen with no way to reach its buttons. So DialogContent is two boxes:
- The frame takes
className: position, size, border, background and the close button. It never scrolls. It is capped atcalc(100dvh - 2rem - 2 * var(--safe-y))tall andcalc(100% - 2rem - 2 * var(--safe-x))wide: 1rem of overlay on every side plus the safe-area inset. The box is centred, so the bigger inset on each axis comes off both ends, or the top of a tall dialog would sit under the clock in the installed app. - The body (
data-slot="dialog-body") takesbodyClassName. It holds the children and scrolls when they don't fit. Its default layout isgrid gap-4 p-6, so a dialog that wants something else says so here, not on the frame:bodyClassName="flex flex-col"for the dialogs with a scrolling list of their own (history, merge, collaborate, variations),bodyClassName="p-0 gap-0"for the follow list.
The split is what keeps the close button in its corner. An absolutely positioned child of a scroll container scrolls with the content, and so does a fixed one inside a transformed parent like the dialog, so with one box the button scrolled out of sight as soon as you scrolled down to the buttons.
The list dialogs keep their own max-h-[85dvh] on the frame, which replaces the default cap through tailwind-merge. That still clears the insets: 7.5% of an 844px-tall phone is 63px, past the 59px status bar, and 7.5% of a 390px landscape screen is 29px, past the 21px home indicator. Their list is an overflow-y-auto item in the body's flex column, which the column is allowed to shrink, so the list gives way first and the body only scrolls if what's around the list can't fit either.
The breadcrumb yields to the page's actions
PageBar's trail (everything before the current page's title) only shows when the crumbs have at least 24rem to themselves. That's a container query on the crumb row (@container/crumbs), not a viewport breakpoint, because the room left depends on the page's actions as much as on the screen: the editor's take about 640px. Keyed off sm, the trail showed on a phone turned sideways, squeezed the lesson's own title down to nothing, and then spilled out under the buttons once the header made room for the notch.
When the trail does show, the ancestor keeps its width (shrink-0, up to max-w-48) and the title takes the rest. 24rem is the ancestor's 12rem, the chevron and the gaps, plus about 10rem kept back for the title, so a short crumb is never cut down to a couple of letters and showing the trail never costs the title its words. Every page passes one ancestor and the title; a deeper trail would need the threshold raised.
The nav is a sheet below md
AppHeader shows its destinations as inline links on desktop and moves them behind a menu button into a Sheet below md. Following a link out of the sheet closes it: a sheet covers the page you just navigated to, so it can't stay put the way inline links do. The sheet also carries a Settings row, because the gear it normally shares the utility cluster with is hidden at that width (the whole cluster is, below sm; the notification bell and the account menu are what stay, since an unread notification is worth a slot at any width).
The header holds one copy of each control at any width. The old --primary header couldn't fit text buttons on a narrow screen, so most controls existed twice (an icon-only copy under md:hidden and a labelled copy under hidden md:inline-flex), and the lesson page kept a whole second copy of its actions in an overflow menu.
dvh, not vh
100vh is the large viewport: it ignores the browser's retractable address bar, so a max-h-[90vh] dialog can be taller than what's actually on screen. Page wrappers use min-h-dvh, the tall dialogs (history, merge, collaborate, variations) use max-h-[85dvh], and every other dialog gets DialogContent's 100dvh-based cap (see Dialogs scroll on short screens).
HomePage's hero deliberately keeps min-h-[70vh] md:min-h-[78vh]: dvh there would resize the hero as the address bar hides and shows during scroll, which is visible jank on a marketing page and worse than the problem it fixes.
The collab chat is a bottom sheet on mobile
CollabChat's expanded panel used to be a 420px-tall floating window inset 16px from the bottom-left at w-[calc(100vw-32px)], which on a phone covered most of the screen and sat on top of the editor's add-section FAB, while still looking like a window that wasn't meant to.
Below sm it's now a proper bottom sheet: flush to the bottom edge, full width, rounded at the top only, h-[60dvh] max-h-[70dvh], with pb-safe so the composer clears the home indicator. From sm up every one of those is reverted and it's the original corner panel, with sm:ml-safe sm:mb-safe so a phone turned sideways (which is sm and up) still keeps it off the notch and the home indicator. Its height cap, sm:max-h-[calc(100dvh-2rem-var(--safe-top)-var(--safe-bottom))], takes off everything between the panel and the ends of the screen. With only the 2rem taken off, the margin pushed a 390px-tall screen's panel up past the top edge and clipped its header row.
Known gaps
- Initial JS payload. Routes are code-split and the export/import libraries (
docx,mammoth,html2pdf.js) load on demand behindsrc/lib/exports/load.js, so a phone that only reads lessons never downloads them; previewing and viewing a lesson render throughLessonViewinstead. What's left to watch is the editor chunk itself; see How the export pipeline works.
Installing to a Home Screen
The app ships a web app manifest and a service worker, so on a phone it can be installed and opened in its own full-screen window, and the editor (already local-first, see Version history) keeps working with no network. The header's install button and the iOS Share menu's "Add to Home Screen" instructions are covered in Installable app & offline use.