---
name: enhance-capacitor-ui
description: >
  Cross-surface UIUX separation skill for hybrid web apps that ship as PWA + iOS + Android via Capacitor (or Tauri / Expo Web / Ionic / RN-Web). Use when a previous UI/UX sweep "improved one surface and broke the other" — desktop polished but mobile cramped, or mobile native but desktop wastes space. Also use when the project has ad-hoc useIsMobile / isNative branches scattered across components, a single md: breakpoint doing double duty as "is desktop" and "wider slot", or per-component platform styling instead of mode tokens. Establishes three orthogonal axes — form factor (compact/medium/expanded), platform (web/ios/android), pointer (fine/coarse) — and a three-layer architecture (context hook, mode tokens, container-query primitives) so one sweep can enhance one axis without degrading the others. Catches axis conflation in a single boolean, hover-only affordances on native shells, and SSR/Capacitor first-paint mismatch. For pure visual polish on a single surface, use enhance-web-ui first.
license: MIT
---

> ### Which enhance skill? (surface router)
>
> | Your surface | Use |
> |:-------------|:----|
> | **Web** product page / dashboard — composition, hierarchy, spacing, motion | `enhance-web-ui` |
> | **Web** product page — UX heuristics, flows, data wiring | `enhance-web-ux` |
> | **Web** landing / marketing / portfolio (greenfield, anti-slop) | `enhance-web-landing` |
> | **Web** existing site upgrade (audit-first, preserve behavior) | `enhance-web-redesign` |
> | **Web** 3D / WebGL / cinematic scroll on an existing site (audit-first) | `enhance-web-web3d` |
> | **React Native** screen (Expo / bare) | `mobile-rn-screen` |
> | **Capacitor / hybrid** shell (one web app shipped to iOS + Android) | `enhance-capacitor-ui` (axis architecture first) → then the web or rn skill |
> | Repo **README** showcase | `enhance-readme` |
>
> **You are here: `enhance-capacitor-ui`.** Native iOS/Android (SwiftUI / Compose, no web layer) is out of scope for all of these — use Apple HIG / Material directly.

# Enhance Web ↔ Mobile UI

Separate the mobile and desktop design contracts of a hybrid app so that a
UIUX sweep on one surface cannot silently degrade the other. This is not a
"add a `useIsMobile` hook" skill. It establishes three orthogonal axes,
one source of truth, mode tokens, container-query primitives, and a
chrome allow-list — so per-form-factor / per-platform polish is a
predictable, reviewable change instead of a regression.

> **Vague-but-visceral cross-surface feedback ("looks great on web,
> atrocious on iPhone", "the iPad version is just a stretched phone",
> "Android dock feels wrong", "everything wraps on tablet") almost always
> points to **axis conflation** — one boolean (`isMobile`) is being asked
> to answer three different questions. Start at the architecture, not at
> the broken page.**

> If browser/native automation is used, first follow
> `~/.cursor/skills/protocol-browser-anti-stall/SKILL.md` when present.

---

## Critical Rules

> **Three orthogonal axes — never conflate them.**
> 1. **Form factor** (compact / medium / expanded) = how much screen space
> do I have? — drives layout density, chrome strategy, navigation
> pattern (bottom dock vs side rail vs persistent sidebar).
> 2. **Platform** (web / ios / android) = which OS visual language am I
> inside? — drives motion curves, tap-highlight, overlay radii,
> safe-area math, haptic affordances.
> 3. **Pointer capability** (fine / coarse, hover / no-hover) = can the
> user hover and click precisely? — drives hover-reveal affordances,
> drag-handle sizes, hit-target floors.
> A single `useIsMobile` boolean answering all three is the root cause
> of most cross-surface regressions. Encode the three axes separately.

> **One source of truth.** Detect the three axes once, in a single
> `useViewportContext()` (or equivalent) hook, and write them to
> `<html data-form-factor data-platform data-can-hover>` so CSS, server
> components, and SSR-rendered HTML all read the same answer. Banning
> ad-hoc `useMatchMedia` / `pathname.startsWith("/desktop")` /
> `Capacitor.isNativePlatform()` branches scattered across components.

> **Token modes, not per-component conditionals.** Platform differences
> that are *visual* (overlay radius, press-scale, tap-highlight color)
> live in `:root[data-platform="ios"]` blocks as CSS variable overrides,
> not in `if (platform === "ios")` JSX branches. Components consume the
> token; the platform layer paints it. (Ionic Modes, Konsta UI pattern.)

> **Page chrome = viewport queries; component micro-layout = container
> queries.** App-level chrome (bottom dock vs side rail vs sidebar)
> reacts to the *viewport* — only the app shell knows the global form
> factor. Cards, tiles, grids, hero blocks inside the content column
> react to their *container* — they may be in the main column, a side
> panel, a split-pane, a modal. Use `md:` / `lg:` only in the chrome
> allow-list (typically: app-shell, bottom-nav, side-nav, side-rail).
> Use `@md:` / `@lg:` (container queries) everywhere else. Tailwind 4
> ships container queries built-in; Tailwind 3 needs the plugin.

> **Per-form-factor squint test.** "Looks good at 1440" is one cell of
> a six-cell grid (3 form factors × 3 platforms minimum, plus pointer
> on/off). Before declaring an enhancement done, view it at compact
> (≤640px), medium (768–1023px), and expanded (≥1024px), on web + iOS
> WebView + Android WebView (or the closest available native shell).
> Any cell that looks "templated" or "broken" was not actually
> enhanced — it was avoided.

> **Tier across surfaces, not across the app.** A learning / consumer
> / lifestyle app should be **tier-A vibrant** on the *compact* form
> factor (color carries gamification meaning — Duolingo) and may
> legitimately become **tier-C calm** on the *expanded* form factor
> (more chrome, more data density, less novelty). The mistake is
> applying one saturation tier across all surfaces. Pick a tier per
> form factor; document it. (See `enhance-web-ui` § Domain Colour
> Tier.)

> **Never ship mock, dead, or rogue platform branches.** Platform-mode
> tokens must be wired to the *real* `Capacitor.getPlatform()` result
> (or your stack's equivalent). Don't fork visual logic on a static
> `process.env.MODE === "ios"` literal — the same bundle ships to all
> three surfaces and the platform is only known at runtime.

> **Guardrail the chrome allow-list.** Once `md:` / `lg:` are restricted
> to the chrome allow-list, an ESLint `no-restricted-syntax` rule + a
> Cursor / AGENTS rule (`responsive-design-axes.mdc`) keeps them there.
> A future enhancer (human or AI) will reach for `lg:grid-cols-2` in a
> primitive within a week unless the rule warns them.

> **Patch the axis primitive, not the consumer.** Same rule as
> `enhance-web-ui` *Primitive-First Patch Rule*, scoped to axes: if
> twelve cards each carry their own `lg:flex-row` branch, the bug is
> that the card primitive isn't a `@container`. Patch the primitive
> once, delete the twelve consumer branches.

---

## Workflow Checklist

Copy and track:

```
WEB↔MOBILE SEPARATION /<repo or surface>
- [ ] 0. SURFACE INVENTORY: shipping targets (PWA / iOS / Android / Mac
 Catalyst / Tauri), Capacitor or shell version, breakpoint system,
 Tailwind 3 vs 4 (container queries built-in?)
- [ ] 1. AXES AUDIT: how does the codebase currently answer "is mobile?",
 "is iOS?", "can hover?" — count the call sites, list the helpers
- [ ] 2. CHROME vs CONTENT MAP: which files own page-level chrome (allow
 `md:`/`lg:`), which files are content/primitives (must use `@md:`)
- [ ] 3. PROBE: grep bare `md:`/`lg:` outside the allow-list, count
 `useIsMobile` / `isNative` / `pathname.startsWith` branches,
 inspect SSR first-paint at /compact and /expanded
- [ ] 4. GAP MAP: current state vs three-layer architecture (context →
 tokens → primitives) — what's missing, what's duplicated
- [ ] 5. PLAN: six-step rollout (context hook → mode tokens → form-factor
 gates → container-query primitives → heuristic guide → guardrail)
- [ ] 6. IMPLEMENT: smallest diff per step; SSR-safe defaults at every
 boundary
- [ ] 7. VERIFY: six-cell matrix (3 form factors × 3 platforms), plus
 SSR/first-paint check, plus reduced-motion, plus dark/light if
 supported
- [ ] 8. WRITE-UP: surface → before pain → axis violated → primitive →
 after behaviour
```

---

## Step 0 — Surface Inventory

Before any change, write down what the app actually ships to. This prevents
designing for surfaces that don't exist (and forgetting ones that do).

| Surface | Detection | Notes |
|---------|-----------|-------|
| PWA / browser | default | Includes desktop browser AND iOS/Android Safari/Chrome |
| iOS native (WebView) | `Capacitor.getPlatform() === "ios"` | Capacitor / Ionic shells |
| Android native (WebView) | `Capacitor.getPlatform() === "android"` | Capacitor / Ionic shells |
| Mac Catalyst | iPad-class iOS surface running on macOS | Form factor matters more than platform |
| iPad / large iOS | iOS + ≥768px | Often misses iPad-specific layout if you only test iPhone |
| Foldable / split-screen | window resize event mid-session | Form factor changes at runtime, must subscribe |

Extract from the project:

- `package.json` → Capacitor / Tauri / Ionic / Expo / RN-Web version.
- `capacitor.config.*` → `webDir`, deployment targets, custom URL scheme.
- `tailwind.config.*` or `globals.css` `@theme` → existing breakpoints,
 whether container queries are configured.
- Any `useIsMobile` / `useBreakpoint` / `usePlatform` hook → catalogue.

---

## Step 1 — Axes Audit (find the conflation)

Run these probes; expect to find 5–30 hits per probe in any non-trivial
hybrid app. Each hit is a candidate for axis-separation.

```bash
# How does the codebase currently answer "is mobile?" — count the call sites
rg -n 'useIsMobile|isMobile|isDesktop|breakpoint\.lg|matchMedia.*768|window\.innerWidth' \
 --glob '!**/node_modules/**' --glob '!**/dist/**' | wc -l

# How does it answer "is iOS?"
rg -n 'Capacitor\.getPlatform|isNativePlatform|navigator\.platform|isIOS|isAndroid' \
 --glob '!**/node_modules/**'

# How does it answer "can the user hover?"
rg -n 'matchMedia.*hover|useCanHover|"hover:|@media \(hover' \
 --glob '!**/node_modules/**'

# Bare viewport prefixes outside the chrome allow-list (proxy for
# axis-conflation in primitives)
rg -n '\b(md|lg|xl|2xl):' \
 --glob 'design-system/**' \
 --glob 'components/**/!(@(app-shell|bottom-nav|side-nav|side-rail))*' \
 | wc -l
```

Build the conflation table:

| Hit (file:line) | Question being asked | Axis it should map to | Current proxy | Fix shape |
|-----------------|----------------------|-----------------------|---------------|-----------|
| `Card.tsx:42` `lg:flex-row` | "is the card slot wide?" | container size | viewport `lg:` | `@md:flex-row` after Card becomes `@container` |
| `Toast.tsx:18` `useIsMobile()` | "should I show a sheet or a side toast?" | form factor | viewport `md:` | `data-form-factor="compact"` token + Tailwind `ff-compact:` variant |
| `Button.tsx:30` `if (Capacitor.getPlatform() === "ios")` rounded-2xl | "what's the OS visual language?" | platform | runtime branch | `--btn-radius` token under `:root[data-platform="ios"]` |
| `IconBtn.tsx:15` `hover:bg-muted` | "show hover paint" | pointer capability | bare `hover:` | `can-hover:hover:bg-muted` (axis-aware variant) |

If the table has > 10 rows, the project needs the full architecture; if
< 5, you may be able to land just steps 1–3 (context hook + mode tokens +
gate the chrome).

---

## Step 2 — Chrome vs Content Map (the allow-list)

The single most important architectural decision in this skill: **which
files are allowed to read the viewport, and which must read their
container.** Write the list down before touching anything.

Default allow-list (files that may use bare `md:` / `lg:` / `xl:`):

```
components/app-shell.tsx # the only file that knows global form factor
components/bottom-nav.tsx # mobile dock; `md:hidden` is correct here
components/side-rail.tsx # tablet rail; `hidden md:flex` is correct
components/desktop-side-nav.tsx # desktop sidebar; `hidden lg:flex`
app/layout.tsx # SSR root, sets data-form-factor first-paint fallback
```

Default content list (files that must use `@md:` / `@lg:` container
queries OR axis-token variants):

```
design-system/primitives/** # Card, Button, Input, OptionCard, etc.
features/**/components/** # feature-level UI
app/**/page.tsx # route content (page chrome lives in layout)
```

Any time someone adds a 5th file to the chrome allow-list, that's a code
review smell: usually it should have been a content file with a
container query, not a chrome file with a viewport query.

---

## Step 3 — Probe live (SSR first-paint + runtime axes)

### 3a. SSR / hydration first-paint check

Hybrid web apps render twice: first on the server (no `window`, no
`Capacitor.getPlatform()`), then on the client. If the form factor /
platform decision is made in *render*, you ship a flash of wrong
chrome on every navigation.

| Probe | Where to look | Failure shape |
|-------|---------------|---------------|
| First-paint at /compact | View page source (Next.js `?_rsc` or curl) | If both `<BottomNav>` AND `<DesktopSideNav>` render, then JS hides one — you have FOUC |
| First-paint at /expanded | Same | If neither renders, then JS reveals one — you have a 200ms gap of no nav |
| Capacitor first-paint | iOS/Android WebView devtools | If `data-platform=""` for the first 50ms, your platform-specific tokens haven't applied yet |

Fix shape: render *both* chrome variants on the server (dual-mount), gate
visibility purely with CSS via a viewport `@media` query. The CSS query
runs synchronously at first paint, no JS needed. The form-factor `data-`
attribute is then written by JS *after* hydration, used only for axis-
specific tweaks not chrome visibility.

### 3b. Runtime axes probe (in the browser console, in each shell)

```js
// Generic, framework-free; works in DevTools console / browser MCP / native WebView
const html = document.documentElement;
console.table({
 formFactor: html.dataset.formFactor || '(not set)',
 platform: html.dataset.platform || '(not set)',
 canHover: html.dataset.canHover || '(not set)',
 viewport: `${innerWidth}x${innerHeight}`,
 ua: navigator.userAgent.slice(0, 80),
});
// Then resize the window and re-run — does formFactor flip live?
// On iOS Safari: rotate device — does formFactor flip?
// On Capacitor iOS: send to background and resume — does any value get stale?
```

Any `(not set)` cell at first paint is a bug — your context hook is
running too late.

### 3c. Container-query gate

For any primitive you are about to convert from `lg:` to `@lg:`, measure
the rendered widths *before* the change and *after*. If the change is
correct, the primitive should:

- Render the wider layout when its parent slot is wide (container > N px).
- Render the narrower layout when its parent slot is narrow.
- Be **independent** of the global viewport — drop it inside a 320 px
 side panel on a 1920 px monitor and it should look like the compact
 variant.

```js
// In the browser console / Playwright evaluate
const cards = [...document.querySelectorAll('[data-card]')];
console.table(cards.map(c => ({
 rect: Math.round(c.getBoundingClientRect().width),
 layout: getComputedStyle(c).gridTemplateColumns || getComputedStyle(c).flexDirection,
})));
// Expect: layout flips at the container threshold, not the viewport
```

---

## Hidden Failure Modes (the cross-surface ones)

Parallel to `enhance-web-ui` H1–H16 but specific to multi-surface
contracts. Each entry has a **detection probe** and a **fix shape**.
Generic across stacks.

### M1. Axis conflation in a single boolean

**Symptom:** "Mobile sheet renders on iPad landscape", "desktop side
panel renders on touch laptop", "iOS-style overlay shows on Android".

**Detection:** `rg -n 'useIsMobile|isMobile' --type ts | wc -l` returns
> 5. Read each call site and ask: "is this asking about screen size,
about platform, about pointer capability, or about the *combination*?"

**Fix shape:** Replace the single boolean with three answers from
`useViewportContext()` (or equivalent). Update each call site
explicitly:

```ts
// before
if (isMobile) return <Sheet>{children}</Sheet>;
return <Popover>{children}</Popover>;

// after — three axes addressed independently
const { formFactor, canHover } = useViewportContext();
if (formFactor === "compact") return <Sheet>{children}</Sheet>;
if (!canHover) return <Sheet>{children}</Sheet>;
return <Popover>{children}</Popover>;
```

Most "mobile" branches are actually "compact form factor OR coarse
pointer", which is *not* the same as `viewport < 768`.

### M2. Tier-A heuristics applied to expanded form factor (or vice versa)

**Symptom:** "Phone looks like a B2B dashboard", "desktop looks like a
toy". Result of running a single UIUX sweep that picked one saturation
tier and applied it across all form factors.

**Detection:** Take the desktop screenshot, take the phone screenshot,
side by side. If both surfaces use the same `/8` background tint and
the same primary CTA chrome, you've ignored form factor. Tier-A on
phone (`/15–/25` tints, solid CTAs, semantic hue per category) and
tier-C on desktop (`/6–/12` tints, one accent, achromatic chrome) is
*correct* for a learning / consumer / lifestyle app — not a bug.

**Fix shape:** Document the per-form-factor tier in your `HEURISTICS.md`.
Pick the tier per `data-form-factor` selector at the token layer:

```css
:root[data-form-factor="compact"] {
 --surface-tier-mix: 0.20; /* tier-A vibrant */
 --cta-bg: var(--color-cta); /* solid */
}
:root[data-form-factor="expanded"] {
 --surface-tier-mix: 0.08; /* tier-C restrained */
 --cta-bg: transparent; /* outline */
 --cta-border: var(--color-cta);
}
```

Then primitives consume `--surface-tier-mix` and `--cta-bg` once;
the form factor decides which tier paints.

### M3. No `data-platform` on `<html>` (token forks living in components)

**Symptom:** Buttons forked per platform inside JSX
(`if (platform === "ios") rounded-2xl else rounded-md`). Five
components do this; the sixth forgets and the bug ships to one shell.

**Detection:** `rg -n 'getPlatform\(\)|isNativePlatform' --type tsx`
returns > 3 hits in component code (not in the context hook itself).

**Fix shape:** Detect platform once, write `<html data-platform>`,
declare per-platform token overrides in CSS:

```css
:root[data-platform="ios"] {
 --radius-overlay: 1.25rem;
 --press-scale: 0.96;
 --tap-highlight: transparent;
 -webkit-tap-highlight-color: transparent;
}
:root[data-platform="android"] {
 --radius-overlay: 1.75rem; /* M3 corner-extra-large */
 --press-scale: 1; /* Android uses ripple, not scale */
 --tap-highlight: rgba(0, 0, 0, 0.08);
 -webkit-tap-highlight-color: rgba(0, 0, 0, 0.08);
}
```

Components reference `var(--radius-overlay)` once; the platform layer
picks which value paints. Delete the JSX forks.

### M4. Bare `md:` for component micro-layout

**Symptom:** A card that flips to two columns at viewport-`lg:` looks
broken when dropped into a 400 px side panel on a 1920 px monitor —
the card is narrow but the viewport says "go two-column", so the
content overflows.

**Detection:** Grep for `md:grid-cols-2` / `lg:flex-row` /
`xl:items-center` inside `design-system/primitives/**` or
`features/**/components/**`. Anything outside the chrome allow-list
is a candidate.

**Fix shape:** Make the primitive a `@container` and convert
`md:` → `@md:`, `lg:` → `@lg:`. Pick the container-query breakpoint
that matches the *effective slot width*, not the viewport:

```tsx
// before — viewport-coupled, breaks in side panels
<Card className="grid lg:grid-cols-[1fr_2fr]">…</Card>

// after — slot-coupled, works anywhere
<Card className="@container grid @4xl:grid-cols-[1fr_2fr]">…</Card>
// ^^^^^^^^^^^ ^^^^^^
// container root container threshold
```

Picking the threshold: if the previous viewport `lg:` (1024 px) worked
because the main column was ~896 px wide at that viewport, the new
container threshold is `@4xl:` (~896 px). Container queries don't
inherit Tailwind's viewport scale; they have their own (`@xs` 320, `@sm`
384, `@md` 448, `@lg` 512, `@xl` 576, `@2xl` 672, `@3xl` 768,
`@4xl` 896, `@5xl` 1024, `@6xl` 1152, `@7xl` 1280). Choose the one that
matches the slot's typical width, not the viewport's.

## Further reading

- [M5. `(hover: hover)` media queries copy-pasted across components and more](references/details.md)
