本文へ移動
cccskills
無料GitHub で公開

ios-mobile-first

iOS-native mobile web UX rules. Use when building any UI component, fixing mobile issues, reviewing responsive code, or touching viewport heights (dvh vs h-screen), safe areas, Dialog vs Drawer, tabs on mobile, touch targets, or input zoom.

インストール方法を見る

含まれるファイル(1)

  • SKILL.md18.1 KB

SKILL.md(原文)

インストールする前に、エージェントに与えられる指示の中身を確認できます。

iOS Mobile-First Design

Official guide: ~/.arman/rules/nextjs-best-practices/nextjs-guide.md — See the official guide for core architecture. The old §13 (Mobile-First) was removed; mobile-first patterns in this file will move to a dedicated Mobile & Responsive UX guide.

Single source of truth for mobile UX. Desktop stays unchanged; mobile gets iOS-native treatment.

Reference implementations: features/feedback/FeedbackButton.tsx (tap button), features/canvas/social/CanvasShareSheet.tsx (explicit Drawer branch).


Golden Rules

  1. Always dvh — never vh or h-screen
  2. Always pb-safe — on fixed bottom elements
  3. Fields are ≥16px on touch — enforced globally by THE iOS ZOOM FLOOR in app/globals.css; author mobile-facing fields text-base anyway, and never set an inline fontSize below 16px on one
  4. Always 44pt touch targets — and NEVER one element at a time. Put matrx-touch-targets on the feature/section/dialog ROOT (app/globals.css): it floors every button, tab, menu item and a[data-tap-target] inside it — including files written later — under pointer: coarse or below lg only, so desktop density never moves. A control that must stay SMALL because its size is the control (a bare checkbox, a radio, a 14px switch) is excluded there by design and takes matrx-tap-area on its <label> instead: an invisible 44×44 ring, painted box unchanged. Opt one control out of the floor with data-touch-exempt + a reason at the call site. A Radix dialog PORTALS out of its route's subtree, so its DialogContent carries the class itself
  5. Header tokens: --shell-header-h / --header-height — never hardcode in calc
  6. Dialogs become bottom sheets on mobile automatically — a plain <Dialog> already does it (see "Dialog = Desktop, Drawer = Mobile"); hand-roll a Drawer branch only for a drag handle or a genuinely different mobile layout
  7. Never tabs on mobile — stack vertically
  8. Never nested scrolling — single scroll area per view
  9. Always test iOS Safari — on real device
  10. A data table reflows on a phone — <Table wrapperClassName="phone-stack"> (see Tables on phones); a fixed-width table with no reflow fails pnpm check:phone-layout
  11. Nothing is hover-only on touch — a control revealed by opacity-0 group-hover:opacity-100 also carries pointer-coarse:opacity-100, so a phone shows it. Dense desktop sizes (h-5/h-7 icon buttons) are fine on desktop; the 44px floor (rule 4) grows them on touch only. The one sanctioned exception is RouteModeNav (see core-route-headers), whose phone reach is its bottom-sheet rows

Viewport & Layout

Dynamic Viewport Units

// ✅ Adapts to mobile browser chrome
<div className="h-dvh">     <div className="min-h-dvh">     <div className="max-h-dvh">

// ❌ Breaks when browser chrome hides/shows
<div className="h-screen">  <div className="min-h-screen">

Safe Areas

// ✅ Respects iPhone home indicator / notch
<div className="fixed bottom-0 pb-safe">
<div className="mb-safe">

// Utilities in globals.css:
// .pb-safe { padding-bottom: env(safe-area-inset-bottom, 1rem); }
// .mb-safe { margin-bottom: env(safe-area-inset-bottom, 1rem); }

Header Height & Page Layout ((core) AppShell)

.shell-main fills the viewport (negative margin pull-up). Body wrapper: h-full overflow-hidden — do not subtract header height.

// ✅ (core) full-height workspace — chat, tasks, agent build
<>
  <PageHeader>{/* route chrome */}</PageHeader>
  <div className="h-full flex flex-col overflow-hidden">
    <div className="flex-1 overflow-y-auto pb-safe">{/* scroll area */}</div>
  </div>
</>

// ✅ Per-panel clearance when static top UI must sit below the glass header
<div className="h-full overflow-auto pt-[var(--shell-header-h)]">{/* file tabs, titles */}</div>

// ❌ Double-subtracts header inside shell-main → empty band at bottom
<div className="h-[calc(100dvh-var(--header-height))]">
<div className="h-page">

// ❌ Hardcoded
<div className="h-[calc(100vh-40px)]">

When header subtraction IS correct: /administration/* and (transitional) (whose layout sets --header-height to the shell header and starts content below it) — content below the header, not behind it. Use .h-page or calc(100dvh - var(--header-height)) there only.

Full pattern: features/shell/components/header/variants/USAGE.md


Dialog = Desktop, Drawer = Mobile

The base DialogContent (@/components/ui/dialog) AUTO-converts to a bottom sheet on mobile — full width, bottom-anchored, max-h-[90dvh], internally scrollable, pb-safe. So a plain <Dialog> is already mobile-safe: its confirm/submit control can never be pushed off-screen. This is the systematic guard against mobile "lockout" popups. Opt out with <DialogContent mobileSheet={false}> only for a surface that must stay centered on mobile.

Hand-roll the explicit Drawer branch below only when you need more than the auto-sheet — a vaul drag-to-close handle, or a genuinely different mobile layout (not just a reflow). For a normal dialog, do nothing extra.

A sheet whose content varies gets ONE fixed height, not an adaptive range. A min-h/max-h sheet re-measures on every drill-in, filter keystroke and late load, so the panel grows and shrinks under the user's thumb — read as jumping, not as fitting. Multi-level or tabbed sheets (BottomSheet from @ai-matrx/design-system → size="full", what TabbedBottomSheet uses) pin h-[92dvh] and scroll inside it. Adaptive is right only for a single short, static list. (A sub-16px field is the same bug by another route: iOS zooms the page on focus.)

DropdownMenuContent and PopoverContent cap to the viewport and scroll (max-height of var(--radix-<component>-content-available-height) plus overflow-y-auto — e.g. --radix-dropdown-menu-content-available-height), so a long menu can never grow off-screen and lock the user out. Any custom popup panel you build MUST do the same: max-height + overflow-y-auto, never an unbounded height.

import { useIsMobile } from "@/hooks/use-mobile";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Drawer, DrawerContent, DrawerTitle } from "@/components/ui/drawer";

function MyComponent() {
  const isMobile = useIsMobile();
  const [isOpen, setIsOpen] = useState(false);

  if (isMobile) {
    return (
      <Drawer open={isOpen} onOpenChange={setIsOpen}>
        <DrawerContent className="max-h-[85dvh]">
          <DrawerTitle className="sr-only">Title</DrawerTitle>
          <div className="flex-1 overflow-y-auto overscroll-contain pb-safe">
            {/* Content — single scroll area, no nesting */}
          </div>
        </DrawerContent>
      </Drawer>
    );
  }

  return (
    <Dialog open={isOpen} onOpenChange={setIsOpen}>
      <DialogContent className="max-w-[95vw] w-full lg:max-w-[1400px] max-h-[90dvh] overflow-hidden flex flex-col">
        <DialogHeader><DialogTitle>Title</DialogTitle></DialogHeader>
        <div className="flex-1 overflow-y-auto">{/* Content */}</div>
      </DialogContent>
    </Dialog>
  );
}
ElementMobile (Drawer)Desktop (Dialog)
Max heightmax-h-[85dvh]max-h-[90dvh]
Max widthFull widthmax-w-[95vw] / lg:max-w-[1400px]
Scrolloverflow-y-auto overscroll-containoverflow-y-auto
Safe areapb-safeNot needed
LayoutNatural flowflex flex-col overflow-hidden

Tables on phones — THE PHONE-STACK TABLE

A hand-rolled <Table> with TableHead className="w-[120px]" columns is a 1,200px sideways scroller on a 390px phone: every value an admin needs is off-screen and the row is un-tappable without pinch-zoom (the 2026-09-12 feedback-console report). Do not build a second card list for it, and do not hide it behind md:. Opt the SAME markup into the reflow that app/globals.css ships (search THE PHONE-STACK TABLE):

<Table wrapperClassName="phone-stack">
  <TableHeader>…unchanged…</TableHeader>
  <TableBody>
    <TableRow>
      <TableCell data-phone="lead">{row.title}</TableCell>                 {/* headline: first, full width — exactly one */}
      <TableCell data-phone="inline">{statusBadge}</TableCell>             {/* self-naming chip, no label */}
      <TableCell data-label="Owner" data-phone="inline">{row.owner}</TableCell>
      <TableCell data-label="Created" data-phone="inline">{ago}</TableCell> {/* a bare date needs its label */}
      <TableCell data-phone="hidden">{row.id}</TableCell>                  {/* desktop-only detail */}
      <TableCell data-phone="actions">{buttons}</TableCell>                {/* last, right-aligned */}
    </TableRow>
  </TableBody>
</Table>

Below 768px the <thead> hides, each row becomes a flex-wrapped card, and data-label prints a small-caps label above the value. Desktop is untouched. Header-borne sort controls are not reachable on the card list — a table whose ordering matters on a phone puts its sort in a toolbar. MatrxDataTable callers use its mobileCards prop instead; the two are the same breakpoint.

Guard: pnpm check:phone-layout (--strict in the release gates; --self-test proves it can fail) reports every fixed-width table with no reflow (phone-stack / mobileCards / useIsMobile branch), every h-screen / *-[Nvh] unit, and every ≥3-column grid inside a dialog or sheet with no sm: variant (mark a grid that is right N-up — swatches, icons — with a phone-ok: <reason> comment on the line above).

Also in globals.css: the base mobile rule that turned every <table> into display:block now excludes design-system tables (data-slot="table"), which own their scroll wrapper; the block rule made them shrink-to-fit and double-scroll.


MANDATORY: Tabs = Desktop Only

Never use tabs on mobile. They cause UX friction, nested scroll trapping, and hidden content.

Stack all sections vertically with visual dividers:

import { useIsMobile } from "@/hooks/use-mobile";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";

function MyForm() {
  const isMobile = useIsMobile();
  const [activeTab, setActiveTab] = useState("basic");

  if (isMobile) {
    return (
      <div className="space-y-6 p-4">
        {/* Section 1 */}
        <div className="space-y-4">
          <h3 className="text-sm font-medium flex items-center gap-2">
            <div className="h-6 w-1 bg-primary rounded-full" />
            Basic Info
          </h3>
          {/* Fields */}
        </div>

        {/* Section 2 */}
        <div className="space-y-4 pt-4 border-t border-border">
          <h3 className="text-sm font-medium flex items-center gap-2">
            <div className="h-6 w-1 bg-primary rounded-full" />
            Advanced
          </h3>
          {/* Fields */}
        </div>

        {/* Full-width actions */}
        <div className="flex flex-col gap-3 pt-4 border-t border-border pb-safe">
          <Button className="w-full">Save</Button>
          <Button variant="outline" className="w-full">Cancel</Button>
        </div>
      </div>
    );
  }

  return (
    <Tabs value={activeTab} onValueChange={setActiveTab}>
      <TabsList>
        <TabsTrigger value="basic">Basic Info</TabsTrigger>
        <TabsTrigger value="advanced">Advanced</TabsTrigger>
      </TabsList>
      <TabsContent value="basic">{/* Fields */}</TabsContent>
      <TabsContent value="advanced">{/* Fields */}</TabsContent>
    </Tabs>
  );
}

Mobile stacking features: accent bars (h-6 w-1 bg-primary), border separators, single scroll area, full-width buttons, pb-safe.


iOS Zoom Prevention

This is handled globally — you do not have to remember it at every call site. app/globals.css ends with THE iOS ZOOM FLOOR: an unlayered @media (pointer: coarse) rule that raises every input / textarea / select to max(16px, 1em) on touch devices. It is unlayered on purpose — Tailwind utilities live in @layer utilities, and only unlayered CSS can out-rank a text-sm utility. Desktop density is untouched.

What that means for you:

  • A text-sm / text-xs field is no longer an iOS zoom bug. It is still desktop-density text that a phone user has to squint at, so author fields as text-base when the surface is used on mobile.
  • Do not "fix" a zoom report by re-disabling pinch-zoom. See the paragraph below.
  • The floor loses only to an inline style={{ fontSize }} (deliberate — it keeps code editors that size their own hidden textarea working). If you set an inline font size on a real text field, it must be ≥16px.
  • [contenteditable] is NOT covered — forcing a size there re-scales a whole rich-text document. A contenteditable composer sets text-base itself.
  • data-no-zoom-floor opts a field out. Only for a readonly/display field that can never take text-entry focus, and say why at the call site.
// ✅ Authored for mobile — reads well AND never zooms
<Input className="text-base" />
<Textarea className="text-base" />

// ⚠️  Desktop density: the global floor stops the zoom, but this is small on a phone
<Input className="text-sm" />

// ❌ Inline size below the floor — beats the global rule, zooms the page
<Input style={{ fontSize: '13px' }} />

History — why this exists: the app shipped userScalable: false, maximumScale: 1 from Nov 2025, which suppresses iOS focus-zoom as a side effect and hid ~450 sub-16px fields. Commit d7ef647e (2026-07-01) correctly restored pinch-zoom, and every one of those fields surfaced at once as "the page randomly zooms in". The floor is the fix; the viewport setting is not.

Prevent input auto-zoom with ≥16px fonts ONLY — never by disabling pinch-zoom. app/config/viewport.ts keeps userScalable: true (maximumScale: 5) on purpose: pinch-zoom is the user's universal escape hatch when any surface overflows. Never set userScalable: false / maximumScale: 1 — it removes the only way out of a mobile lockout.


Responsive Components

Flex Layouts

<div className="flex flex-col sm:flex-row gap-4">
<div className="flex flex-col sm:flex-row items-start sm:items-center">
<div className="flex flex-wrap gap-2">

Conditional Display

// Icon-only on mobile, icon+text on desktop
<Button>
  <Icon className="h-4 w-4 sm:mr-2" />
  <span className="hidden sm:inline">Label</span>
</Button>

Touch Targets (44pt minimum)

// ✅ Proper touch sizing
<Button variant="ghost" className="h-10 w-10 p-0">
  <Icon className="h-5 w-5" />
</Button>
<Switch className="scale-90 sm:scale-100" />

// ❌ Too small
<Button className="h-6 w-6 p-0">

Responsive Widths

<div className="w-full sm:w-48">
<SelectTrigger className="w-full sm:w-48">

Spacing

<div className="space-y-4 sm:space-y-6">
<div className="p-4 sm:p-6">
<div className="container mx-auto px-4 sm:px-6 lg:px-8">

Grids

<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="grid grid-cols-[repeat(auto-fit,minmax(280px,1fr))] gap-4">

Typography

<h1 className="text-[clamp(2rem,1.5rem+2vw,3.5rem)]">
<p className="text-[clamp(1rem,0.95rem+0.25vw,1.125rem)]">
<span className="truncate max-w-[150px] sm:max-w-none">
<p className="line-clamp-2">

Nested Scrolling Prevention

// ❌ Scroll trapping — user gets stuck in inner area
<div className="overflow-y-auto">
  <div className="overflow-y-auto max-h-[300px]">{/* Trap */}</div>
</div>

// ✅ Single scroll area — content flows naturally
<div className="overflow-y-auto">
  <div className="space-y-4">{/* All content */}</div>
</div>

Component Audit Checklist

Layout

  • dvh not vh/screen; fixed bottom has pb-safe; header uses --header-height
  • No nested scrolling; proper overflow management

Dialogs & Modals

  • Plain <Dialog> (it becomes a bottom sheet on mobile by itself); mobileSheet={false} only with a reason at the call site
  • Hand-rolled Drawer (only when needed): max-h-[85dvh], overscroll-contain, pb-safe
  • Its DialogContent carries matrx-touch-targets (it portals out of the route subtree)

Tabs & Sections

  • Mobile: vertical stack with accent bars; Desktop: tabs OK (max 5)

Inputs & Forms

  • Mobile-facing inputs/textareas authored text-base; no inline fontSize below 16px (THE iOS ZOOM FLOOR handles zoom — never add an inline 16px just for it)

Touch & Interaction

  • Touch targets ≥44px via matrx-touch-targets on the section root (never h-10, which is 40px); hover-revealed controls also carry pointer-coarse:opacity-100
  • Action buttons full-width on mobile

Responsive

  • flex-col sm:flex-row; icon-only buttons on mobile; spacing adjusts

Decision Tree

Modal content needed?
├── Plain <Dialog> → centered card on desktop, bottom sheet on mobile, automatically
└── Drag handle or different mobile layout needed → explicit Drawer branch (max-h-[85dvh], pb-safe, overscroll-contain)

Multiple sections?
├── Mobile → Stack vertically (accent bars + border separators)
└── Desktop → Tabs OK

Scrollable content?
├── Mobile → Single scroll area only
└── Desktop → Nested OK but avoid when possible

Project Conventions

// Design tokens
<div className="bg-textured">    // Main backgrounds
<div className="bg-card">         // Cards

// Layout components
// The frame is AppShell (features/shell/components/AppShell.tsx) — every route group renders it.
import { FloatingSheet } from "@/components/official/FloatingSheet";
import { useIsMobile } from "@/hooks/use-mobile";

// Animations — CSS-first
<div className="transition-all duration-300 [@starting-style]:opacity-0 [@starting-style]:translate-y-4">
// Framer Motion only for gestures/physics

レビュー

まだレビューはありません。使ってみた感想をお寄せください。

同じリポジトリのスキル

概要と使いどころ

Admin debug system wiring for the floating AdminIndicator's live debug data and Copy Full Context. Use when adding debug visibility to a route or feature, wiring useDebugContext or the debug panel, capturing console errors, or enabling the copy-context workflow for a page.

日本語の概要は準備中です。原文の説明を表示しています。

armanisadeghi/ai-matrx32026年10月13日 更新

Compact two-icon Copy / Copy-for-AI controls (components/agent-copy). Use when adding copy buttons to a row, card, list, or record; merging duplicate Copy/JSON/AI controls; continuing the copy rollout; or writing a Copy-for-AI payload. NOT for markdown content actions (use rich-document-actions).

日本語の概要は準備中です。原文の説明を表示しています。

armanisadeghi/ai-matrx32026年10月13日 更新

Disclosing a surface's existing fixed AI jobs in the shell's top Agents menu. Use when a page, panel, overlay, or window already runs a mandate behind a button, assist, automatic action, or mode; when the agent-disclosure guard names a file; or during a surface check.

日本語の概要は準備中です。原文の説明を表示しています。

armanisadeghi/ai-matrx32026年10月13日 更新

The watch-fix-rerun method for making an existing platform agent or automated process efficient and correct: baseline its ledger, run one unit yourself, fix the class behind every wasted call, rerun, record. Use when asked to improve, optimize, watch, or 'make efficient' an agent, a sandbox session, a sync job, or any recurring automated process.

日本語の概要は準備中です。原文の説明を表示しています。

armanisadeghi/ai-matrx32026年10月13日 更新

Redux state for agent execution and firing agent shortcuts. Use when editing features/agents/redux/, building agent UI, creating a conversation, touching assembleRequest or NDJSON stream state, wiring agent-state selectors, adding a per-conversation capability, or triggering a shortcut from a button, menu, or code (useShortcutTrigger, launchAgentExecution).

日本語の概要は準備中です。原文の説明を表示しています。

armanisadeghi/ai-matrx32026年10月13日 更新

The provision for a call site: the exhaustive menu of values that place in the code can realistically produce. Use before creating or fixing a mandate or agent that reasons about more than its own input, when deciding whether an agent can answer at all with what it is sent, or when its output 'looks right' but nobody checked it could be. NOT for building the agent (use create-agent).

日本語の概要は準備中です。原文の説明を表示しています。

armanisadeghi/ai-matrx32026年10月13日 更新

armanisadeghi のスキルをすべて見る

このスキルの問題を報告する