enhance-capacitor-ui — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited enhance-capacitor-ui (Agent Skill) and scored it 45/100 (orange). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 1 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 1 flagged
A base64 string of 128+ characters appears in a documentation file. Encoded prompt injection hides the hostile instruction in base64 — invisible to keyword filters — and relies on the agent's ability to decode it at runtime. There is no normal authoring reason to embed a multi-hundred-byte base64 blob in skill docs.
*.sig, SIGNATURES) outside the documentation.Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
### 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.
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.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 singleuseViewportContext()(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-hocuseMatchMedia/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 inif (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. Usemd:/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 realCapacitor.getPlatform()result (or your stack's equivalent). Don't fork visual logic on a staticprocess.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. Oncemd:/lg:are restricted to the chrome allow-list, an ESLintno-restricted-syntaxrule + a Cursor / AGENTS rule (responsive-design-axes.mdc) keeps them there. A future enhancer (human or AI) will reach forlg:grid-cols-2in a primitive within a week unless the rule warns them.
Patch the axis primitive, not the consumer. Same rule asenhance-web-uiPrimitive-First Patch Rule, scoped to axes: if twelve cards each carry their ownlg:flex-rowbranch, the bug is that the card primitive isn't a@container. Patch the primitive once, delete the twelve consumer branches.
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 behaviourBefore 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.
useIsMobile / useBreakpoint / usePlatform hook → catalogue.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.
# 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 -lBuild 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).
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 fallbackDefault 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.
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.
// 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.
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:
side panel on a 1920 px monitor and it should look like the compact variant.
// 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 viewportParallel 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.
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:
// 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.
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; /* 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.
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; /* 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.
md: for component micro-layoutSymptom: 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:
// 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 thresholdPicking 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.
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.