# Making a desktop-first web app work on a phone

**A playbook for Claude, to be applied to a codebase other than the one it came from.**

This is the distilled result of auditing and repairing a 460-component Vue application that was
built desktop-first and then made installable as a PWA — at which point every latent mobile defect
became a user-visible one. Nothing here is specific to that product. Each item is a defect class,
with the symptom, the underlying cause, a command that finds it, the fix, and a way to stop it
coming back.

**Assumed stack:** Tailwind (v4 idioms shown), a shadcn-style component library over Radix/reka-ui,
Vue 3 or React with a class-merge helper (`cn()` = `clsx` + `tailwind-merge`). Items 1–4 and 8–12
apply to any stack; 5–7 are Tailwind/shadcn-specific in their spelling but not in their substance.

---

## How to run this audit

Work in this order. It is not arbitrary: the first three sections fix defects that live in shared
components, so each one repairs dozens of screens at once, and doing them first means the page-level
pass has far less to do.

1. **Overlays** — dialogs, sheets, drawers, popovers. Highest defect density, always.
2. **Shared layout primitives** — the app shell, the table wrapper, the form controls.
3. **Data tables** — usually the single biggest win.
4. **The one or two screens the product exists for.**
5. **The long tail** of individual pages.

Before starting, get these numbers. They decide how much work this is, and where.

```bash
# How much responsive intent already exists, per breakpoint
grep -rhoE '\b(sm|md|lg|xl|2xl):' src --include=*.vue --include=*.tsx | sort | uniq -c | sort -rn

# How many components carry ANY responsive prefix (a low ratio means desktop-first)
grep -rlE '\b(sm|md|lg|xl):' src --include=*.vue | wc -l
find src -name '*.vue' | wc -l
```

A codebase where `sm:` dwarfs `md:` and `lg:` is usually one where the component library's defaults
supplied the responsiveness and nobody designed a phone layout deliberately. Expect the chrome to be
fine and the content screens to be untouched.

---

## 1. The modal that is wider than the screen

**Symptom.** A dialog opens and has to be scrolled sideways to read. The most common single bug, and
the one users report first.

**Cause, and it is subtle.** A well-built `DialogContent` already caps its width for small screens:

```
w-full max-w-[calc(100%-2rem)] … sm:max-w-lg
```

`cn()` is `tailwind-merge`, which de-duplicates *conflicting* classes by group. A call site passing
`class="max-w-3xl"` therefore **replaces** `max-w-[calc(100%-2rem)]` — the mobile guard is gone, at
every width.

**Then it gets worse, and this is the part worth understanding.** `max-w-3xl` and `sm:max-w-lg` are
*different* merge keys, so the override does **not** remove `sm:max-w-lg`. Both survive with equal
specificity, and Tailwind emits `sm:` after unprefixed utilities — so `sm:max-w-lg` wins above
640px. The author sees "my width does nothing on desktop", reaches for `!important`, and it works.

The result is exactly inverted from the intent: **the requested width never applies on desktop, and
the guard is gone on mobile.** In the audited codebase, 21 of 62 dialogs were in this state, 14 of
them with `!`.

**The fix is one rule.** A dialog sets its width from `sm:` upward, never unprefixed and never with
`!`. `sm:max-w-3xl` gives the identical desktop result *and* keeps the mobile guard. There is no
legitimate case for `!` here.

For anything wider than `lg`, add a middle step — `sm:max-w-2xl lg:max-w-[1000px]` — or a 640px
viewport gets a layout drawn for 1000px.

**Find it:**
```bash
grep -rnE '<(Dialog|Sheet|AlertDialog)Content' src --include=*.vue -A3 \
  | grep -E 'class="[^"]*(!max-w-|(^| )max-w-)'
```

⚠ **Parse elements, not lines.** Prettier wraps long attribute lists, so the tag and its `class`
routinely sit on different lines. A line-based scan silently skips exactly the worst offenders —
in the audited repo, 5 of 64 dialogs including the two widest. Match the tag, then read forward to
the closing `>`.

---

## 2. The dialog whose top is off the screen

**Symptom.** A dialog's title and first field cannot be reached. Scrolling inside it does nothing.

**Cause.** Two units that lie on mobile:

- **`vh` is the URL-bar-*hidden* height.** With the browser chrome showing, `max-h-[90vh]` can
  exceed the visible area.
- **`max-h-screen` on a dialog centred with `translate-y-[-50%]`** lets the box be a full viewport
  tall while its midpoint is pinned to the viewport's midpoint — so its top edge sits above the
  screen. The scroll container is *inside* the box, so no amount of scrolling brings the title back.

**Fix.** `dvh` everywhere: `max-h-[90dvh]`. It tracks the viewport as the chrome shows and hides.
`svh` where you want the smaller, stable value.

**Find it:** `grep -rn 'max-h-screen\|max-h-\[[0-9]*vh\]' src --include=*.vue`

---

## 3. The page that zooms when you tap a field

**Symptom.** Tapping a search box or text field zooms the whole page in, and it never zooms back.

**Cause.** Safari on iOS zooms when a focused form control's computed font-size is under 16px. Good
component libraries already ship `text-base md:text-sm` on `Input`/`Textarea` for exactly this. The
failure is a call site passing `class="text-sm"`, which tailwind-merge resolves in favour of the
override — silently undoing the protection.

**Fix.** `text-base md:text-sm`. Small only once there is a mouse.

**Find it** — and note the class is usually on a different line from the tag, so grep by line finds
almost nothing. Parse the element:

```python
import re, pathlib
TAG = re.compile(r'<(input|textarea|Input|Textarea)\b')
for f in pathlib.Path('src').rglob('*.vue'):
    s = f.read_text(encoding='utf-8')
    for m in TAG.finditer(s):
        end = s.find('>', m.start())
        el = s[m.start():end]
        cls = re.search(r'class="([^"]*)"', el)
        if cls and re.search(r'\btext-(sm|xs)\b', cls.group(1)) and 'md:text-' not in cls.group(1):
            print(f'{f}:{s.count(chr(10), 0, m.start()) + 1}')
```

In the audited repo a line-based grep found 0; this found 35, in 31 files — including the comment
box on a 196-question form, i.e. one zoom per question.

---

## 4. Controls too small to hit

**Symptom.** Buttons and icon controls are hard to tap; destructive ones are easy to hit by mistake.

**Cause.** Component library defaults are mouse-sized: 32–40px is typical. Touch guidance asks for
~44px.

**The fix that does not cost a redesign.** Do **not** raise sizes at a breakpoint — that changes
every desktop screen and turns a fix into a visual redesign of the data-dense pages. Use the
pointer-type media query, which keys off the *input device*:

```
default: 'h-9 pointer-coarse:h-11 px-4 py-2'
sm:      'h-8 pointer-coarse:h-10 …'
icon:    'size-9 pointer-coarse:size-11'
```

Set once in the button variant table, it covers the whole application and **no desktop screen moves
by a pixel**. (Plain CSS: `@media (pointer: coarse) { … }`.)

For an icon button whose glyph should stay small, grow the *hit area* and keep the icon:
`pointer-coarse:size-11` plus `inline-flex items-center justify-center`, with a negative margin if
it must stay optically in place.

⚠ **This cannot be verified in browser device-emulation mode**, which emulates viewport width but
not pointer type. It needs a real device. Budget for that or you will think it has not worked.

---

## 5. The overlay you cannot close

**Symptom.** A drawer or dialog opens on a phone and there is no way out but reloading the page.

**Cause.** Three reasonable decisions that are fatal together:

1. The project disables dismiss-on-outside-click (a sane default — a stray tap should not discard a
   half-filled form).
2. A component hides the built-in close button, usually because it looks wrong in that layout.
3. Escape closes it — **and a phone has no Escape key.**

Each is defensible alone. Together they are a one-way door, and it is **invisible on a desktop**,
where Escape still works. That is why it ships.

**Fix.** Any overlay must keep at least one dismissal that works without a keyboard. Prefer both: a
visible, labelled close control *and* outside-click.

**Guard it** — this is the one rule here worth enforcing repo-wide, because the next such component
will be written by someone copying the last one:

> Any component that hides an overlay's close button must also enable outside dismissal.

---

## 6. The page that scrolls sideways for no visible reason

**Symptom.** The whole document scrolls horizontally. Nothing on screen looks too wide.

**Cause, nearly always.** A flex item with default `min-width: auto`, which **refuses to shrink
below its content's intrinsic width**. One wide table inside it widens the flex row past the
viewport. The container looks innocent:

```html
<div class="flex flex-1 items-center">   <!-- missing min-w-0 -->
```

**Fix.** `min-w-0` on any flex item that wraps content which can be wide — the page container, a
header's left side, any `flex-1`.

⚠ **Look for an existing workaround before removing anything.** In the audited codebase a bare
`class="w-[100px]"` on the page container read as debris and was the *only* thing preventing
document-level overflow on every signed-in page: a definite width also clamps the automatic minimum.
Deleting it without adding `min-w-0` would have broken every table screen. Replace, do not remove.

**Find it:** look for `flex-1` without `min-w-0` in shell and layout components first — that is
where it does the most damage.

```bash
grep -rn 'flex-1' src/layouts src/components --include=*.vue | grep -v 'min-w-0'
```

**Do not fix it with `overflow-x: hidden` on `html`/`body`.** That breaks `position: sticky` in
descendants and, on iOS, silently disables sticky headers. If you need a net, `overflow-x: clip` on
the page container is the safe form — but fix the cause.

---

## 7. The breakpoint that is true twice

**Symptom.** At exactly one width, two layouts render at once — a mobile drawer *and* a desktop rail.

**Cause.** `useMediaQuery('(max-width: 768px)')` beside Tailwind's `md:` = `(min-width: 768px)`.
Both are true at 768.

**Fix.** `(max-width: 767.98px)`. Trivial, and worth grepping for every JS-side media query in the
codebase to check it against the CSS breakpoints it is meant to complement.

---

## 8. The table with fourteen columns

**Symptom.** A list screen scrolls sideways for several screens' width.

**Cause.** Table cells carry `whitespace-nowrap`, so the table's intrinsic width is the sum of its
untruncated content. A scroll container means it does not break the page — but it is unreadable, and
worse, **a horizontal scroll gives no hint that more columns exist**, so on a phone the table
quietly becomes "the first three fields".

**Fix: one card per row below `md`.** This is usually the single highest-leverage change available,
because every list screen goes through one or two shared wrappers. In the audited codebase one new
component fixed 33 screens with no page edited.

Design rules that made it work:

- **The card component holds no data logic.** Searching, sorting, paging, selection and export stay
  in the wrapper. Duplicating any of it is how the two views start disagreeing about what a row says.
  Pass the wrapper's own formatting functions in as props.
- **Forward the per-cell slots** (or render-props) into the card, so a page that renders a status
  badge in a table cell gets the identical badge on the card, with nothing to change per page.
- **Make the default require no configuration** — e.g. the first three visible columns become the
  card's heading and summary, the rest fold behind a disclosure. This is what lets every screen
  improve without being edited. Allow an opt-in `priority: 'primary'` for the screens where the
  first three columns are not the identifying ones.
- **Fold, never drop.** Every column stays reachable.
- Use real semantics: a list of `<dl>`/`<dt>`/`<dd>`, a real `<button>` for the row action, and
  `aria-expanded` on the disclosure.

---

## 9. The calendar, and other third-party widgets

**Symptom.** A full-calendar widget's own toolbar overflows before you even reach the grid.

**Cause.** These widgets ship desktop defaults and no responsive behaviour: a month grid is seven
columns (~44px a day on a phone — a coloured block and no words), a week/time grid is seven columns
plus a time gutter, and a three-group toolbar needs ~400px.

**Fix.** Switch the *view*, not the styling. Most such widgets already ship a list/agenda view:
below `sm`, open it as a list and collapse the toolbar. A list is what a phone user is actually
asking for.

⚠ **`initialView` is read once.** Changing it reactively does nothing. Force a remount when the
breakpoint is crossed (`:key` on the component), and pass an `initialDate` so the remount does not
jump back to today.

Also make the widget's toolbar `flex-wrap: wrap` — it costs nothing at any width and stops the next
button anyone adds from pushing the title off screen.

---

## 10. Information that only exists on hover

**Symptom.** Nothing — which is the problem.

**Cause.** `title="..."` never fires on touch. There is no hover state to enter. Every such string is
invisible on a phone. The audited codebase had **189** of them, against a tooltip component with
zero call sites.

**Fix.** Triage rather than sweep: many are decorative repeats of adjacent visible text and can go.
Any that is the *only* place a piece of information appears must become visible text, a disclosure,
or a tap-to-open popover.

The same applies to `group-hover:` reveals — pair them with `group-focus-visible:` for keyboards, but
note that neither fires on a tap.

**Find it:** `grep -rn 'title="' src --include=*.vue | wc -l`

---

## 11. Grids and tabs that never collapse

**Symptom.** Three or four columns squeezed into 360px.

**Cause.** `grid-cols-3` with no responsive prefix. Note the *correct* idiom produces no such string:
`grid gap-4 sm:grid-cols-2 lg:grid-cols-4` is right, because one column is the CSS default. So do not
count `sm:grid-cols-*` as a violation — only a **bare** `grid-cols-N` where N ≥ 2.

**Fix.** `grid-cols-1 sm:grid-cols-3`, or for a tab list `grid-cols-2 sm:grid-cols-4`.

**Judgement, and this matters more than the grep.** Distinguish *unusable* from *tight*. In the
audited codebase, of 67 bare `grid-cols-2` only about 8 were genuine two-field form rows that needed
stacking; the other 59 were label/value pairs that are merely snug and were left alone. Of 13 bare
`grid-cols-3/4`, 7 were tab lists with labels like "Security Events" in 104px — real — and 6 were
stat tiles showing a number and a one-word label, which are fine. **Do not spend the budget where
nothing is broken**; an audit that reports everything is as useless as one that reports nothing.

**Find it:**
```bash
grep -rnoE 'class="[^"]*\bgrid-cols-[2-9]\b[^"]*"' src --include=*.vue \
  | grep -vE '(sm|md|lg|xl):grid-cols-'
```

---

## 11b. The scrolling row that cannot be scrolled to the start

Worth its own entry, because it is counter-intuitive and it appears wherever a row of things is
allowed to overflow: tab strips, filter chips, segmented controls, category pills.

**Symptom.** A horizontally scrolling row is missing items at the *beginning*, and no amount of
swiping reveals them.

**Cause.** `justify-content: center` on a flex container whose content overflows places the first
item at a **negative** offset relative to the content box. `scrollLeft` cannot go below zero, so that
region is unreachable by any scroll. Centring is a very natural thing to write for a row of tabs, and
it is invisible until the row is too wide — which on a desktop it never is.

**Fix.** `justify-content: flex-start` on anything that can overflow. If the row also needs to be
centred when it *fits*, that is what `margin-inline: auto` on the inner row is for, not
`justify-content`.

**While you are there**, two neighbours of the same bug:

- A tab strip is typically `w-fit` with `white-space: nowrap` triggers and **no `overflow-x`** at
  all — so it does not scroll, it just ends. Six tabs at ~90px is 540px; on a phone the last two or
  three vanish with nothing on screen to say they exist. Add `max-width: 100%` and `overflow-x: auto`.
- Give the items `flex-shrink: 0`, or they squeeze instead of scrolling.

⚠ **Know the cost:** setting `overflow-x` makes `overflow-y` compute to `auto` rather than `visible`,
so a focus ring on a child can clip by a pixel or two at the top and bottom edge. Worth it against
unreachable content, but decide it rather than discover it.

---

## 12. Toasts, and other things pinned to an edge

Three defects, all common:

- **A minimum width wider than a small phone.** Toast libraries often set `min-width: ~326px` and
  never reset it in their own mobile media query. On a 320px screen — an older phone, or any phone
  in split-screen — that scrolls the document sideways.
- **No safe-area inset.** Anything pinned to `top: 0` renders under the notch and status bar once the
  app is installed and there is no browser chrome. Add
  `padding-top: env(safe-area-inset-top, 0px)` (and the bottom equivalent).
- **An unbounded stack.** `maxToasts: 20` full-width banners covers an entire phone screen with no
  way back to the page. Keep it to about five.

**While you are there, check the toast pipeline actually renders.** In the audited codebase two toast
libraries were installed; the flash-message handler called the one whose `<Toaster />` was mounted
**nowhere**, so every server flash message had been silently discarded — on desktop as well. Confirm
end to end, do not assume.

---

## Testing: be honest about what is possible

If the project has no browser-driving test tool (no Playwright/Cypress/Dusk — check before assuming),
then **layout cannot be asserted**. Say so plainly rather than implying coverage you do not have.

What you *can* do, and it is worth doing:

**Source-scanning guards.** Every rule above has the same shape — breaking it produces no symptom on
the developer's machine and a broken screen on the user's. That asymmetry is what justifies a test
that reads a class string. Write them as scans over the whole tree, not assertions on one file,
because the next offender will be written by someone copying the last one.

Rules worth a guard: no unprefixed/`!` width on an overlay · no `vh`/`max-h-screen` height cap · no
`text-sm` on a form control without `md:` · the page container keeps `min-w-0` · the button variants
keep their `pointer-coarse:` sizes · no hand-written `<table>` outside a scroll container · every
overlay keeps a non-keyboard way out.

**Two disciplines that caught real mistakes:**

1. **Prove each guard fails against the unfixed code before believing it.** Stash the fix, run the
   test, confirm it fails, restore. A guard that passes for the wrong reason is worse than none.
2. **Mutation-test the important ones.** Re-break one instance deliberately and confirm the guard
   fires. This is how the element-vs-line scanning bug in §1 was found: the guard was green while
   blind to the two widest dialogs in the codebase.

**And then use a real device.** Device-emulation mode gets you width and nothing else — not pointer
type, not the URL bar's effect on `vh`, not the notch, not how a 44px target actually feels. Every
defect in this playbook was found on a handset, not in DevTools.

---

## Order of work, and what it is worth

| | Work | Screens fixed |
|---|---|---|
| 1 | Overlay widths and heights | every dialog in the app |
| 2 | Form-control font size | every form |
| 3 | Touch targets via pointer-type | every screen, desktop untouched |
| 4 | `min-w-0` on the page container | every page with wide content |
| 5 | Table → cards in the shared wrapper | every list screen |
| 6 | The one or two screens the product exists for | the ones that matter |
| 7 | Third-party widgets | one screen each, but nothing else fixes them |
| 8 | Page-level grids and tables | the long tail |

Items 1–5 are a handful of files. They are worth doing before anything else, and in a desktop-first
codebase they are usually most of the problem.
