# APP.md Specification

**Status**: every field below is backed by the current code. Fields that are parsed but not yet read by any consumer are flagged inline as `(parsed only)` so you know not to rely on them.

APP.md is a per-app description the developer writes once to give mirroir a map before exploration. It is NOT generated by the explorer — it's the developer's source of truth for what the app looks like and how it behaves.

## Conceptual Model

Before the field list, a word on vocabulary. APP.md talks about a few distinct window regions, and the word "tab" is ambiguous in iOS literature. This spec uses these terms precisely:

| Term | What it means in APP.md | Examples |
|---|---|---|
| **Status bar** | OS chrome at the top — time, battery, signal. Never tappable by the explorer. | iOS status bar; iPadOS top strip. |
| **Nav bar** | Per-screen bar at the top holding the screen title and navigation chrome (back chevron, edit buttons). One per screen. | iOS UINavigationBar; "< Back  Settings  Edit" strip. |
| **Tabs / edge controls** | A cluster of **primary entry points** arranged along one window edge. The explorer treats them as high-priority breadth targets — first things to try, known to switch screens or trigger core actions. | iPhone bottom tab bar (Messages, Calendar, Safari); iPad sidebar; Tapo's right-side camera-control rail; Instagram's 5 bottom icons. |
| **Content** | Everything else inside the window. Cards, rows, charts, chat threads, etc. The explorer plans these per-screen. | Lists of chats, grids of photos, settings rows. |

When APP.md says `## Tabs` or `## Tab Layout`, it's describing the **edge-control cluster** — not strictly an iOS `UITabBar`. The field covers:

- A horizontal strip of 3–5 icons at the bottom (iPhone standard).
- A vertical column of icons on a window edge (Tapo camera controls, iPad sidebar).
- A top-edge toolbar with a fixed set of action buttons (some editing apps).

What it explicitly is **not**:
- Arbitrary chevron rows in a settings list — those are `content` (table rows), detected per-screen.
- The back button in the nav bar — that's chrome, not a tab.
- Transient overlay actions (floating share sheet, keyboard quick-actions) — those aren't geometry-stable enough to name.

The explorer uses the declared edge-control cluster for two things:
1. **Priority targets** — inject into the per-screen plan as breadth-navigation items so they're visited before deeper content.
2. **Geometric fill-in** — when OCR/YOLO only detects a subset of the expected N icons, extrapolate the missing positions along the declared axis and edge.

If your app has no edge-control cluster (full-screen game, single-view utility), you omit `## Tabs` and `## Tab Layout` entirely.

## File Format

UTF-8 Markdown with an optional YAML front-matter block. File name must be `APP.md` (case-insensitive match during loading, but `APP.md` is canonical). Recommended location:

- **Project-local**: `<cwd>/.mirroir-mcp/skills/patterns/apps/<AppName>/APP.md`
- **Global**: `~/.mirroir-mcp/skills/patterns/apps/<AppName>/APP.md`
- **Sibling skills repo**: `<cwd>/../mirroir-skills/patterns/apps/<AppName>/APP.md`

The loader matches the `app:` front-matter field against the `app_name` parameter of `generate_skill` using diacritics-insensitive, case-insensitive comparison ("Sante" → "Santé").

## File Structure

```markdown
---
# Front matter (YAML-ish, flat key:value)
app: AppName           # required
version: 1             # reserved for future versioning
locale: fr_CA          # optional — prefer matching locales
archetype: dashboard   # optional — screen recipe name
reset_before_explore: true   # optional — force-quit before explore
obstacle_mode: auto    # optional — auto | hint | off
---

## Structure            # required (or at least one of Obstacles / Skip)

Free-form human prose describing the app. Becomes AI context in generated skills.

## [TabName] Tab        # optional, one per tab

Per-tab description. Body is injected into the generated skill's context.

## Tabs                 # optional — explicit tab list, alternative to "N tabs: Tab1, Tab2"

- Tab One
- Tab Two

## Tab Layout           # optional — see §Tab Layout

- orientation: vertical
- edge: right

## Obstacles            # optional — auto-dismissal rules

- Permission dialog → tap "Allow"
- Paywall → dismiss via "Not Now"

## Skip                 # optional — element text never to tap

- Delete Account
- Sign Out

## Credentials          # optional — parsed only, no consumer today

- email: ${TEST_EMAIL:-test@example.com}
- password: ${TEST_PASSWORD}

## Tips                 # optional — AI guidance, goes into skill context

- Dashboard cards can show empty state on test devices
- Swipe down on chart detail view to return
```

## Front-Matter Fields

| Field | Type | Required | Behavior |
|---|---|---|---|
| `app` | string | ✅ yes | Display name. Matching is diacritics-insensitive + case-insensitive. |
| `version` | int | reserved | Schema version. No consumer today; reserved for future migrations. |
| `locale` | string | optional | e.g. `fr_CA`. When multiple APP.md files match the same app, a locale match takes priority over a locale-less file. |
| `archetype` | string | optional | Name of a screen recipe (e.g. `dashboard`, `social-feed`, `settings-list`). When set, bypasses recipe auto-detection. |
| `reset_before_explore` | bool | optional | `true` / `yes` / `1` → explorer force-quits the app via App Switcher before launching. Used for apps that retain stateful overlays (TikTok, Instagram). Default: `false`. |
| `obstacle_mode` | enum | optional | `auto` (default): dismiss matching obstacles. `hint`: surface to AI but don't act. `off`: ignore rules. |

## Sections

All section headings use `##` (H2). H1 (`#`) is treated as a title and skipped. Any unrecognized `##` heading is ignored unless it matches a `## [Name] Tab` pattern (see Tabs).

### `## Structure` (required-ish)

Free-form prose. Included verbatim in the AI "App Context" block of generated skills. This is where you describe the app's overall shape: what screen appears on launch, what tabs exist, what kinds of content each tab holds.

If you use the inline convention `tabs: A, B, C` (or `N tabs: A, B, C`), mirroir extracts those names and treats them as high-priority navigation targets.

### `## [Name] Tab` (optional, repeated)

Per-tab descriptions. Any heading that ends with `Tab` or `tab` contributes its body to AI context and its name to the tabs list (e.g., `## Résumé Tab` → tab name `Résumé`).

### `## Tabs` (optional, recommended over inline)

Explicit bullet list of tab names:
```markdown
## Tabs
- Pour toi
- Explorer
- Profil
```

Equivalent to `tabs: Pour toi, Explorer, Profil` in the Structure section, but clearer.

### `## Tab Layout`

Geometric hints for the edge-control finder. Without this section, the explorer assumes a horizontal bottom tab bar (iOS convention). Apps with a vertical side rail (Tapo camera controls, iPad sidebar) declare their geometry here.

```markdown
## Tab Layout
- orientation: vertical     # vertical | horizontal (default: horizontal)
- edge: right               # top | bottom | left | right (default: bottom)
```

When declared, `findTabTargets` in `BFSExplorerHelpers` samples the declared edge (a 12%-wide band) and sorts candidates along the declared axis before ordinal-mapping them to the tab names. Text-match remains the primary strategy; the layout hint only changes the fallback sampling when OCR text matching fails.

`IconDetector.interpolateEvenSpacing` also auto-detects the dominant axis. If detections span a larger Y range than X range, it extrapolates vertically (clamped to the zone's Y bounds); otherwise it walks horizontally. No explicit axis argument is needed from APP.md — the cluster geometry decides.

### `## Obstacles` (optional)

Rules for auto-dismissing interstitials (permission dialogs, paywalls, onboarding overlays):

```markdown
- <trigger text> → <action text>
```

Both `→` and `->` are accepted as separators. Common action prefixes (`tap `, `dismiss via `, `dismiss `) are stripped. Quoted action text is unwrapped.

**Example:**
```markdown
- Health Access permission → tap "Autoriser"
- Notification permission → tap "Ne pas autoriser"
- Paywall → dismiss "Not Now"
```

When `obstacle_mode: auto` (default), the explorer checks these rules after each OCR pass and taps the action before continuing. `obstacle_mode: hint` surfaces rules as AI context without acting; `off` disables the mechanism.

### `## Skip` (optional)

Element text patterns the explorer must never tap. Merged with `permissions.json.skipElements` into a single global skip list for the session.

```markdown
- Delete Account
- Sign Out
- Réinitialiser les données
```

### `## Credentials` (parsed only)

Key-value pairs with `${VAR}` / `${VAR:-default}` substitution from the process environment. `AppDescriptionLoader.resolveVariables` applies the substitution at load time. **No code in the server reads the resulting map** — the field is parsed and carried on `AppDescription.credentials` but every consumer ignores it. Write it if you plan to use it externally; don't rely on the server acting on it.

```markdown
- email: ${TEST_EMAIL:-test@example.com}
- password: ${TEST_PASSWORD}
```

### `## Tips` (optional)

Free-form exploration hints. Appended to the generated skill's context so the AI has extra guidance.

```markdown
- Swipe down on detail views to return to the summary
- Some cards show "Aucune donnée" on test devices — treat as normal
```

## Loading & Resolution

1. `AppDescriptionLoader.load(appName:)` is called when `generate_skill(action:"explore", app_name:"X")` starts.
2. Search paths, in the order `AppDescriptionLoader.searchPaths()` walks them (recursive file search per path):
   1. `<cwd>/.mirroir-mcp/skills/**/APP.md`
   2. `~/.mirroir-mcp/skills/**/APP.md`
   3. `<cwd>/../mirroir-skills/patterns/apps/**/APP.md`
   4. `<cwd>/.mirroir-mcp/skills/patterns/apps/**/APP.md`
   5. `~/.mirroir-mcp/skills/patterns/apps/**/APP.md`
   6. `<cwd>/../mirroir-skills/apps/**/APP.md` *(legacy fallback)*
   7. `<cwd>/../mirroir-skills/**/APP.md` *(legacy fallback)*
3. Each file is parsed and its `app:` field matched against the requested app name (diacritics-insensitive, case-insensitive).
4. Locale precedence:
   - If any APP.md's `locale:` matches `Locale.current.identifier` exactly (case-insensitive), **the first such match encountered during the walk wins and is returned immediately**.
   - Otherwise the loader keeps a fallback: the first matching description is tentatively chosen, and any later locale-less description replaces a locale-specific fallback (so a generic APP.md wins over a mismatched localized one). All other cases keep the earliest fallback.
5. No match → exploration proceeds without a description (no auto-obstacles, no tab hints, no archetype override).

## Permission System Bridge

APP.md and `permissions.json` are complementary. `permissions.json` governs what the MCP server may do at all (which tools are exposed, which apps may be touched); APP.md describes what a specific app looks like. They overlap in exactly two places today.

| Concern | APP.md | permissions.json |
|---|---|---|
| Global MCP tool allow/deny (tap, swipe, …) | — | ✅ `allow` / `deny` |
| Per-app tool allow/deny (during explore) | — | ✅ `perApp` |
| App-level kill-switch (no launch / no explore) | — | ✅ `blockedApps` |
| Element skip patterns (global) | — | ✅ `skipElements` |
| Element skip patterns (per-app) | ✅ `## Skip` | — |
| Auto-dismiss obstacle rules | ✅ `## Obstacles` | — |
| Force-quit before explore | ✅ `reset_before_explore` | — |
| Archetype recipe override | ✅ `archetype` | — |
| Edge-control layout hints | ✅ `## Tab Layout` | — |

**The three enforced bridge points:**

1. **Blocked-app guard — shared with `launch_app`.** `generate_skill(action:"explore", app_name:"X")` calls `policy.checkAppLaunch(appName)` before doing anything with the target. If `permissions.json.blockedApps` contains the name, the call returns the same error `launch_app` would return. Any APP.md for a blocked app is loaded but never used because exploration refuses to start.
   - **Scope note**: `generate_skill(action:"start")` does not call this guard today. Only `explore`.
2. **Per-app tool gate at explore start.** `permissions.json.perApp` can scope tool allow/deny to a specific app. Before exploration begins, `policy.toolsDenied(for:requiredTools:)` checks that the minimum explorer tools (`tap`, `swipe`, `type_text`, `press_key`) are permitted for the app. If any are denied, the call refuses with a message naming the specific denied tools.
   ```json
   {
     "allow": ["*"],
     "perApp": {
       "Banking": { "deny": ["type_text", "open_url"] }
     }
   }
   ```
   Precedence: per-app `deny` always wins, even over global `allow`. Per-app `allow` can open a tool that global rules would close.
3. **Skip-list merge with dedup.** At explore start, `GenerateSkillHandlers.mergeSkipPatterns` flattens three layers into one `ExplorationBudget.skipPatterns`:
   - Per-app (APP.md `## Skip`) — prepended first, so its casing wins on duplicates.
   - Global (`permissions.json.skipElements`).
   - Built-ins (`ExplorationBudget.builtInSkipPatterns`).

   Case-insensitive duplicates are collapsed; the first occurrence keeps its casing. Source counts are emitted to the debug log at session start.

Outside those three points, the systems are disjoint. `AppDescription.credentials` has no counterpart in `permissions.json` and no consumer beyond surfacing key names in the generated skill (see the Credentials section).

## Consumers (what today's code actually uses)

| APP.md field | Consumer |
|---|---|
| `app`, `locale` | `AppDescriptionLoader` for matching |
| `archetype` | `GenerateSkillHandlers.handleExplore` — looks up recipe by name, sets on session |
| `reset_before_explore` | `GenerateSkillHandlers.handleExplore` — force-quit via App Switcher |
| `obstacle_mode`, `obstacles` | `BFSExplorerExploring` — passed to `ExplorerUtilities.dismissAlertIfPresent` |
| `skipElements` | `GenerateSkillHandlers.handleExplore` — merged into `ExplorationBudget.skipPatterns` |
| `tabs` | `BFSExplorerHelpers.findTabTargets` — injected as high-priority targets |
| `tabLayout` | `BFSExplorerHelpers.findTabTargets` — picks which edge to sample and which axis to sort on for ordinal fallback |
| `context` | `SkillMdGenerator` — written verbatim into generated skill's "App Context" section |
| `credentials` | **not consumed** (parsed, variables resolved, but no reader) |
| `hints` | **not consumed** (duplicative with `context` which already includes Tips body) |

## Examples

See `../mirroir-skills/patterns/apps/` for live examples (Santé, Settings, Weather, TikTok, Instagram). A minimal example showing a vertical edge-control rail (Tapo's camera controls):

```markdown
---
app: Tapo
archetype: dashboard
---

## Structure

Landscape camera-viewer app. The live feed fills the window; a vertical
column of 5 controls hugs the right edge (camera snapshot, video record,
pan joystick, mic, phone handoff).

## Tabs

- Camera
- Video
- Joystick
- Mic
- Phone

## Tab Layout

- orientation: vertical
- edge: right

## Obstacles

- Permission dialog → tap "Allow"

## Skip

- Delete device
```

What the code does with this file:
- Text matching runs first. If any OCR element's text matches a tab name (e.g. "Camera") it becomes a breadth-navigation target.
- `IconDetector.interpolateEvenSpacing` detects the cluster spans more along Y than X and extrapolates along the Y axis (within the declared zone bounds), so a 3-of-5 detection backfills the missing 2 positions before `findTabTargets` runs.
- For names that didn't match text, `findTabTargets` samples the right-edge 12% band, sorts candidates by Y, and maps them in declared order. With 5 candidates (detected + interpolated) the ordinal fallback activates.
- The `Permission dialog → tap "Allow"` rule auto-dismisses the iOS privacy prompt on first launch via `ExplorerUtilities.dismissAlertIfPresent`.

## Known Gaps

These are observations about the current code, not commitments to change it.

- `AppDescription.locale` is used at load time for matching the system locale but is not inspected during exploration.
