| 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 the
protocol-browser-anti-stall skill when present.
Critical Rules
Three orthogonal axes — never conflate them.
- 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).
- Platform (web / ios / android) = which OS visual language am I
inside? — drives motion curves, tap-highlight, overlay radii,
safe-area math, haptic affordances.
- 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.
rg -n 'useIsMobile|isMobile|isDesktop|breakpoint\.lg|matchMedia.*768|window\.innerWidth' \
--glob '!**/node_modules/**' --glob '!**/dist/**' | wc -l
rg -n 'Capacitor\.getPlatform|isNativePlatform|navigator\.platform|isIOS|isAndroid' \
--glob '!**/node_modules/**'
rg -n 'matchMedia.*hover|useCanHover|"hover:|@media \(hover' \
--glob '!**/node_modules/**'
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)
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),
});
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.
const cards = [...document.querySelectorAll('[data-card]')];
console.table(cards.map(c => ({
rect: Math.round(c.getBoundingClientRect().width),
layout: getComputedStyle(c).gridTemplateColumns || getComputedStyle(c).flexDirection,
})));
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:
if (isMobile) return <Sheet>{children}</Sheet>;
return <Popover>{children}</Popover>;
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:
:root[data-form-factor="compact"] {
--surface-tier-mix: 0.20;
--cta-bg: var(--color-cta);
}
:root[data-form-factor="expanded"] {
--surface-tier-mix: 0.08;
--cta-bg: transparent;
--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:
: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;
--press-scale: 1;
--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:
<Card className="grid lg:grid-cols-[1fr_2fr]">…</Card>
<Card className="@container grid @4xl:grid-cols-[1fr_2fr]">…</Card>
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