# gadak mobile — design

The phone companion to a desktop `gadak serve`. One user: the developer who
runs gadak, away from the desk. The job is triage-glance, not project
management: **read fast, write one line, put the phone away.** Every decision
below answers to that sentence.

Design language in one line: **a pocket ledger** — the paper-and-indigo
identity of gadak (web tokens), carried by density, a serif ledger heading,
mono issue keys, and one signature mark: the *ink spine*, a status-colored
edge that follows an issue from its row into its detail page.

---

## 1. Users and jobs

| Job | Screen | Budget |
|---|---|---|
| "What's on my plate?" | the list, owner **My issues** (boot default) | glance — no taps |
| "What moved while I was away?" | Issues, glance strip above the queue (GDK-871) | 0 taps |
| "Show me the view I named at the desk" | the palette, from the heading | 2 taps |
| "What's in this space / what changed in the wiki?" | the palette, Documents section | 2 taps |
| "Where was that issue or page about X?" | the palette with a query | 1 tap + typing |
| "What happened here?" | Detail (comments-first) | 1 tap from any row |
| "What does this page say?" | Page detail (body, then comments) | 1 tap from a doc row or a search hit |
| "One-line reply / push the status" | Detail composer · transition sheet | thumb only |
| "Is this thing still connected?" | Settings (gear) · the offline dot on the gear | rarely visited, always honest |

**Where the phone stops** (rewritten 2026-09-14, user decision, GDK-1875).
The old list here named creating issues, editing fields, attachments and
wiki writes as non-jobs. The complaint corpus (272 quotes, wiki page 20002)
showed the loudest class was "the app is a subset of the web and you find out
by searching" — and the bootstrap payload already carried most of what the
phone was hiding. The rule is now one sentence with three clauses:

- **Whatever the mirror holds, the phone shows.** Labels, components, fix
  versions, due date, parent and epic, custom fields under their
  `field_specs` display name, linked issues both ways, sprint — one Fields
  section on Detail, ledger rows, nothing invented. If a row exists in
  `issues_full`, hiding it on the phone is the defect (GDK-1870).
- **Whatever is one line to say, the phone writes.** Comment, status, assignee,
  priority, title, description, create (also under a parent), labels, due
  date, a photo on a comment or a new issue, a comment on a wiki page. The
  server route exists for each; the phone adds the control and the draft.
- **Whatever needs a form, a layout or an editor stays on the desk — and the
  phone says so on the spot.** Wiki page edit (the server's `format_loss`
  refusal is the exact data-loss trap the Confluence app is blamed for),
  custom-field edit, view and JQL authoring, dashboards, board drag. Each gets
  a disabled row that reads "Open on the desktop", never a silent absence
  (GDK-1874). A feed *tab* stays out for the same reason as before: the glance
  strip is the phone's whole feed surface (GDK-871).

**Authoring** a view is on the desk side of that line: the phone consumes the
names the desk wrote and never `POST`s one (iOS Mail does not author smart
mailboxes either). History timeline and plugin enrichments are read on the
desk too.

## 2. Screen map & navigation model

**One column, one owner, and the heading is where the owner changes**
(GDK-902, decided 2026-09-15 after the tab-bar research in
`scratch/gdk-mobile/nav-research-*-2026-09-15.md`). No tab bar, no drawer,
no nested stacks.

The desktop already works this way: its main column has one owner at a
time — a view, a filter, a space, a dashboard, a shell — and the palette is
the device that changes the owner (`web/src/lib/commands.ts:140`). The phone
is a device whose whole screen is that one column, so it adopts the model
rather than inventing one. Tabs were wrong for a specific reason, not for
taste: a tab bar is a *fixed* set of owners, and gadak's owners are made by
the user (saved views, Jira filters, spaces, a shell). An unbounded set does
not fit fixed slots, which is why Pairing ended up a peer of Issues and the
shell tab appeared and disappeared.

What the research settled, so it is not re-argued: the tab bar cost 87px
(1.55 issue rows), not a layout; of 39 navigation complaints about the
vendor apps none said "too many tabs" — all said "I cannot get to the thing"
— and the only composition that closes that is the one where the owner list
*is* the palette. A drawer needs an edge gesture Tauri iOS does not have.
User-owned tabs (Linear, 2026-01) keep the fixed-slot disease. A bottom
trigger would spend ~78px back; the heading slot spends none.

```
┌────────────────────────────┐
│ PairGate (only when        │  not an owner — replaces the app
│ unpaired / token rejected) │  until pairing succeeds
└────────────────────────────┘
┌────────────────────────────┐
│ My issues · 42 ⌄   ⌕ + ⚙   │  heading = owner's name = the palette's trigger
├────────────────────────────┤
│ (what the owner draws)     │  scope list · documents plate · shell
└────────────────────────────┘
     ├─► Palette (in place: the list body becomes the ranking)
     │     empty query: recent issues · Built-in views · My views ·
     │                  Jira filters · Documents · Terminal
     │     typed query: matching owners · issues (local, then server) · pages
     ├─► Settings (push layer, from the gear) — mirror pairing · shell pairing · hosts · cache · identity
     └─► Detail (push layer) — issue or page
            └─► Transition sheet (issues only)
            └─► linked issue / mentioned issue (replaces, back → owner)
```

The heading's name is 26px and steps down to 22 then 19 when a language's
view name would not fit whole; if 19 is still not enough the count hides
(the palette rows carry every count), and it ellipsizes only past that
(GDK-1974).

**The palette is the heading, dormant on boot.** The 44pt control that is
the list's `<h1>` is the trigger; tapping it turns the list body into the
palette, the field riding the head of the body with the keyboard down — a
person who wants to type taps the field. Nothing is ever focused on open:
the first paint is the owner's rows, so "what's on my plate" stays a glance
with no taps (both research tracks: an autofocused field puts the keyboard
over the first screen and kills the glance). Two controls open it and they
land differently (GDK-1990): the heading opens the owner list exactly as
above, and the header's magnifier — the one the field wears — opens the
same body with the field already focused, because a person who reached for
a magnifier has said they want to type. A second tap on either closes what
it opened. The magnifier is a control rather than a mark on the heading
because the row carries three 44pt squares and not four: measured at 402, a
fourth takes 46px from the heading's slot, which drops every Japanese view
name a size step and takes the count off three of five. It fits by
replacing, not by adding — the manual refresh glyph gave up the slot and
keeps its door in Settings. The body swapping
in place is the point: switching owners is not a screen change but a change
in what the list shows. Search is not a screen any more — it is the palette
with a query, drawing the same `Row`/`DocRow` into the same body and
opening the same Detail.

**Owners.** A scope (the desk's built-in views, My views, Jira filters,
Documents — the whole-mirror plate named **Updated** (`docs.tabUpdated`),
then one row per space) or the shell (present in the palette only once a
terminal pairing is stored, §10). The shell owns the whole column when it is
the owner — full screen is not a compromise but the model, since the
keyboard covers the screen anyway. Its exit is its own header's back
control; there is no bar to return to.

**Settings is not an owner.** It is a push layer opened from the gear in the
heading (pairing for the mirror and for the shell first, then hosts, the
cache and identity; terminal options are GDK-901's remaining half and are
not on the screen yet).
The offline dot lives on the gear. §1 calls pairing "rarely visited, always
honest" — that is the description of a settings screen, and it now has one.

**Entry / exit contract** (dead-end rule: every screen has an explicit way
out; system back = the same edge; the root has no exit):

| Screen | Enter from | Exit |
|---|---|---|
| PairGate | boot (no pairing) · token rejected | successful pair → the list |
| List (owner = scope) | boot default · palette pick | — (root) |
| Palette | the heading (44pt) | the heading again · Cancel (44pt) · system back · picking an owner or a row |
| Shell (owner) | palette row *Terminal* | ← back in its header → the last scope |
| Settings | gear in the list heading (44pt); from the shell, back to the list first | ← back button (top-left, 44pt) → the list |
| Detail | row tap (list or palette results) · linked-issue tap · deep link | ← back button (top-left, 44pt) → what opened it |
| Page detail | doc-row tap · palette page hit | ← back button (top-left, 44pt) → what opened it |
| Transition sheet | status chip in Detail | scrim tap · Cancel · apply |

The palette's sections are the desk's own, in the desk's order: recent
issues (the visit ledger, at most five) · Built-in views (the desk's five —
My issues, Handed off, All open, Unassigned new, Reopened, under the desk's
two stance sub-labels) · My views · Jira filters · Documents · Terminal.
Every heading and both hardcoded scope names come from the desktop catalog;
the phone owns none of the vocabulary here (§3.6).

The heading must never wear a name the list is not showing (the fallback
rule in `Issues.svelte` stays). The list keeps its scroll position across a
palette open-and-cancel. Detail is a single layer: opening a linked issue
swaps the key in place (back still returns to the owner — one tap out from
anywhere, matching "put the phone away").

## 3. Design language

### 3.1 Color — web tokens are the single owner
`mobile/src/app.css` imports `web/src/app.css` and consumes only
`var(--color-*)`. No mobile hex. Dark mode is therefore free and identical in
temperament to desktop (paper → ink-dark, same 쪽빛 accent).

60/30/10: paper grounds (`bg-base/panel/elevated`) ~60, ink text ~30, one
accent thread (active tab, links, send button) + status inks ≤10. Status
color appears **only** where it is data: the ink spine, the status chip dot,
the transition sheet dots. Never as decoration.

### 3.2 Type — 4 sizes, 3 faces, each earning its place
Dimensions are mobile-owned (`@theme` override after the import):

| Token | Size | Use |
|---|---|---|
| `--text-micro` | 12px | metadata line, section labels, chips |
| `--text-body` | 16px | row summaries, paragraphs, **every input** |
| `--text-title` | 19px | issue title in Detail |
| `--text-heading` | 26px | ledger heading of each tab |

- Body = 16px is a **structural close for iOS input zoom**: no text style an
  input could inherit sits under 16px, so the zoom trigger cannot exist.
- Faces: `--font-display` (New York serif — already the product's display
  face) for the ledger headings and issue titles via the shared
  `.type-subject`; `--font-mono` for issue keys and counts (identifiers look
  like identifiers); `--font-sans` for everything else.
- The aesthetic risk, named: a serif ledger heading with a mono folio count
  ("My issues ·42") on a *phone tool*. It is what makes a capture of this
  app unmistakably gadak and not a Tailwind template; if it reads as a
  newspaper gimmick in captures, the fallback is `.type-subject` on Detail
  titles only. The heading is also a control, so it carries a chevron after
  the count, a rule under it, and measures 44pt — the padding that used to
  give the header its height now lives inside the button, and the row count
  below is unchanged. The rule is what says "control" (GDK-1989: the glyph
  alone failed twice), and it is drawn in the strong border ink, never the
  subtle one the list rows are ruled with, or it reads as a divider that
  stopped early.

### 3.3 Space & density
4pt grid (4/8/12/16/24/32), 16px screen gutter. Issue rows are **56px**
minimum (≥44pt target, summary + meta line); a summary may take a second line
before it clamps (GDK-1543 — the date left the summary's line and width alone
still cut 10 of 12 first-screen titles on the demo fixture). On the iPhone 17
Pro viewport that yields 9 rows per screen when every title wraps, 12 when
none does — still a ledger, not a card feed. No
cards, no shadows in lists; hairline `border-subtle` separators only. The
scope picker is the same dialect: section labels in the list's micro caps,
44pt rows, counts right-aligned in mono, no panel fills.

### 3.4 The signature: ink spine
Every issue row carries a 3px left spine in its `status_category` color
(new / inprogress / done tokens; reopened rows use the reopen token when
`reopen_count > 0`). On cream, `new` uses `--color-accent` (same 쪽빛
thread, darker than `--color-status-new`) via `--color-spine-new` so the
mark photographs as a column, not a whisper; dark mode keeps
`--color-status-new`. In Detail the same spine runs down the left of the
header block — the row visually *is* the page you landed on. This is the one
memorable mark; everything else stays quiet.

### 3.5 Motion
Two moves, both under 250ms, both disabled under `prefers-reduced-motion`:
Detail slides in from the right (200ms ease-out), sheets rise from the bottom
(240ms cubic). Nothing on the scroll path animates. No spinners — loading is
skeleton rows (paper rectangles), writes show state in the pressed control
itself ("Sending…" in the send button, progress dot in the transition row).

### 3.6 Vocabulary — the desktop catalog is the single owner
The same rule as §3.1, applied to words. `mobile/src/lib/i18n.ts` re-exports
`web/src/lib/i18n` and is the **only** file in the phone that reaches across
the tree; every screen calls `t(key)`. If the desk already has a word for a
thing, the phone does not author a second one — a different word for the same
thing *is* the defect, not a style preference.

The consequence is concrete: the tab is `doc.issues`, the default heading is
`view.myWork.name`, the fallback heading is `view.allOpen.name`, the picker
sections are `sidebar.builtinViews` / `sidebar.myViews` /
`sidebar.jiraFilters` / `sidebar.docs`, the whole-mirror documents plate is
`docs.tabUpdated` (desk structure → phone row: Updated, not an invented "All
documents"), sync is `sync.now`, and every sheet's dismiss is
`common.cancel`. Korean and Japanese therefore come free, exactly as dark mode
comes free from §3.1.

Strings with **no** catalog equivalent stay phone-authored and are listed as
such: the offline and fallback notes, the
empty state, the picker's "Open on the desktop" refusal, and the pairing copy.
Adding a key is the desktop's edit, not the phone's — the phone never writes
into `web/src/lib/i18n/messages/`.

## 4. iOS contract (the §4 requirements, structurally closed)

1. **Safe area — single owner.** Only two files may mention
   `env(safe-area-inset-*)`: `app.css` (utility classes `.safe-top`,
   `.safe-bottom`) and nothing else. `Screen.svelte` applies `.safe-top` to
   every header; the Detail composer, sheets and the shell's key bar
   (while the keyboard is down — `lib/keyboard.ts` stamps
   `data-keyboard-inset` while a band is up and the rule stops matching)
   take the bottom inset from the one rule in `app.css`. Screens receive an already-inset frame, so a touch target
   under the status bar cannot be authored.
2. **Vertical geometry.** No `vh`/`dvh` anywhere (lint-able: `grep -r "dvh\|100vh" src` is empty).
   The root is `#app { position: fixed; inset: 0 }`; the column is a normal
   flex child of that fixed frame, so its bottom-most surface sits on the
   physical bottom and cannot move when the keyboard animates. The Detail composer lifts with the
   keyboard via the VisualViewport API (measured, not assumed — see capture
   log).
3. **Horizontal overflow 0.** `#app { overflow: hidden }` backstop + every
   flex row uses `min-width: 0` + `truncate`. Meta lines are single-line
   ellipsis; chips never wrap out of the gutter.
4. **Density.** 56px rows → 12+ visible on one screen (measured in capture).
5. **No dead ends.** Table in §2; back button is 44pt and top-left per HIG.
6. **Every state designed.** Unpaired (PairGate with paste-first flow),
   syncing-first-time (skeleton rows), offline (banner with last-sync age,
   cached data stays usable), token-rejected (PairGate with explanation),
   empty plate (falls back to All open and says why, one tap to any other
   scope), a view the phone cannot honor (offered disabled, never silently
   painted as the full list), empty search (recent searches, never blank),
   write failure (inline in the control that acted, cached read state
   untouched).
7. **Dark mode** is the same code path — token flip only, verified with
   light/dark captures of every screen.
8. **44pt tap floor (GDK-867).** Every control — not only the back button —
   measures at least 44pt on its bounding box. `--spacing-control` (44px) is
   the single owner (`button { min-height }` in `app.css`). `--spacing-control-sm`
   (32px) is the visual chip only and must not set a button's tap size.
   Growing the hit area without growing a filled chip is the move that keeps
   §3.3 (12+ rows) and this floor both true.

Thumb zone: the three write actions (compose, send, transition) and the tab
bar all live in the bottom third. The top third holds only reading and the
back button. Status is *visible* in the header as data (spine + chip dot);
the transition *action* lives with compose and send.

## 5. Data & protocol decisions

- **Snapshot model.** `GET issues/bootstrap/` with `If-None-Match` is the
  read path; the full lite snapshot is the phone's mirror. Cached in
  `localStorage` (guarded try/catch — over-quota mirrors simply skip the
  cache and refetch). Sync on boot, on foreground (`visibilitychange`), and
  on freshness-chip tap. The view list is cached the same way and under the
  same guard, so a restored scope paints its own name on the first frame
  instead of flashing the default. `issues/delta/` is a v2 optimization once a mirror
  large enough to hurt is measured.
- **Issues** is one list under one scope, sorted `priority_rank` asc (unset
  ranks last), then `updated_at` desc, grouped by priority with the display
  name as the section label (display-only; logic never keys on it). Scopes:
  the desk's five built-in views, applied in memory from the shared catalog
  (`web/src/lib/builtin-views.ts`) · the desk's saved views · the desk's
  imported Jira filters, read from `GET issues/views/` alongside bootstrap.
  The phone authors no scope of its own — a phone-written *Assigned to me*
  asked `my-work`'s question a second time and the picker showed both rows
  with the same count (GDK-1542). The last-used scope id lives in
  `localStorage`; a scope that has since been deleted falls back silently,
  and an empty *My issues* — or no identity at all — falls back to *All open*
  **and says why**.
- **Applying a view is in-memory over the snapshot.** The phone honors only
  the axes an `IssueLite` can answer: `status_category` (+`_not`),
  `assignee_email` (+`_not`, account id first then email), `unassigned`,
  `issue_type`, `priority`, `jira_project` (+`_not`). `issue_type` and
  `priority` compare against the stored id first and the stored name second —
  the desk's own `matchesIdFirst` contract, which is consuming a stored value,
  not keying logic on a display name. Any other axis a view sets — labels,
  actor, reporter, components, fix_versions, team_group, severity, qa_*,
  deploy_*, source_project, date ranges, the text query, dynamic fields — and
  any Jira clause the desk's importer left in `unsupported[]` means the phone
  **cannot** honor that view: the row is offered disabled with a reason,
  never painted as the full list under someone else's name (decision 0007).
  Sort and grouping stay the phone's priority sections in this round; a view's
  `display` block is deliberately ignored.
- **Picker counts** (GDK-886) are one in-memory pass per row, taken when the
  sheet opens — never on the list's scroll path. A view matching zero issues
  shows `0` and stays selectable; a disabled row shows no count. The endpoint
  returns no counts and must not be extended to.
- **Pages.** `GET issues/pages/` runs in `sync()` beside the view list,
  cached as `gadak.pages`, dropped on unpair. No `If-None-Match` (the desk's
  `getPages()` does not send one). An empty list or a fetch error hides the
  Documents section — no error chrome, no empty section. Applying a documents
  scope is in-memory over that list, sorted `updated_at` desc. Page detail is
  `GET issues/pages/{key}/`; the phone paints `body_text` (plain text, filled
  from ADF by the same walker FTS indexes) and never ADF. A page is read for
  its body, so comments follow the body here (issue Detail stays comments-first).
  No composer, no page writes.
- **Search** is local-first: instant key/summary substring over the snapshot
  while typing, merged with debounced server `issues/search/` results (body
  and comment matches the snapshot cannot see). Page hits arrive with that
  reply in `pages[]` and paint a Documents section below the issue rows;
  they are not merged into one undifferentiated list.
- **Writes** (`comment`, `transition`) go through origin and re-sync after —
  the app never mutates the snapshot except as an optimistic overlay that a
  failed write rolls back. Frequent writes get no confirm dialog (§7 of
  UX_PRINCIPLES); the one destructive rarity, Unpair, uses the house two-step
  arm (tap → armed red "Tap again" → 3s auto-disarm).
- **Serve-scope pairing.** A serve-scope token opens the whole mirror REST
  (everything the local web UI can call); only the origin passthrough stays
  origin-scope, and non-API paths stay behind the host guard. A leaked
  serve token cannot reach raw REST; a paired laptop cannot dump the mirror.
- **Transitions with required fields** are listed but disabled, labeled
  "needs fields — use desktop": honest states over silent omission.
- **Errors** are `{"error": code}` → mapped copy; the body is never echoed.
  Token/offer values never appear in UI, logs, or errors (offer decode
  errors describe, never quote).
- **Token** lives in the Keychain via the Rust `token_get/set/del` commands;
  endpoint/label/expiry metadata in `localStorage`. Transport is split in one
  module (`lib/api.ts`): packaged app → `@tauri-apps/plugin-http` fetch
  against the paired endpoint; dev webview → same-origin `/api` through the
  vite proxy.

## 6. Measured on the dev shell (2026-08-25, iPhone 17 Pro simulator)

**The numbers in this first block are the pre-fix ones**, kept because they
name the failure mode; the fix and its 2026-08-26 re-measurement are below.
Before it, the native shell (mobile/src-tauri build installed as
dev.gadak.mobile) laid the WKWebView's layout viewport out INSIDE the safe
area while still reporting env() insets — both were measured with the
dev viewport probe on the Pairing tab:

    inner 402x778 · screen 402x874 · env safe-t 62px · env safe-b 34px
    (874 − 778 = 96 = 62 + 34: the insets are subtracted AND reported)

Consequences and the division of labor:

- The app honors env() unconditionally (§4.1). On this shell that pays the
  top inset twice (62pt of cream above the header) and leaves a ~96pt
  native band under the column. Cosmetic waste, dev shell only — but a
  touch target can never land under the status bar or home indicator on
  any shell, which is the §4.1 guarantee.
- The structural fix is native and one line
  (`webView.scrollView.contentInsetAdjustmentBehavior = .never`): the layout
  viewport becomes 402x874, env() stays 62/34, and this CSS lands the column
  on the physical bottom with no code change.

  **Landed** in `src-tauri/src/lib.rs` — an `objc2` `msg_send` pair in the
  Tauri `setup` hook, behind `#[cfg(target_os = "ios")]`, so it cannot reach
  any other target.

  **Re-measured 2026-08-26** on a shell built from the fixed source
  (`tauri ios dev "iPhone 17 Pro"`), from a `simctl io … screenshot` of the
  running app rather than the probe — the installed bundle at the time of
  the note above predated the fix by five hours, and measuring that one
  would have reported the bug as unfixed. Ink coverage per row on the
  1206×2622 PNG (402×874 @3×) puts the three bands where a correct inset
  puts them:

  | y (px) | y (pt) | what |
  | ---: | ---: | --- |
  | 78–114 | 26–38 | iOS status bar (clock, indicators) |
  | 120–198 | 40–66 | empty — zero ink |
  | 204 → | 68 → | first app content |

  Top inset is 186px/62pt; the app's first pixel is at 204px/68pt, so the
  header pays the inset **once** and adds 6pt of its own. Neither 0
  (`env()` ignored) nor ~372px (paid twice). The 90px of empty space
  between the status-bar band and the app band is why no touch target can
  land under the status bar here. Bottom: lowest app content sits
  122–130px (41–43pt) above the edge, clear of the 102px/34pt home
  indicator — not the ~96pt native band described above. A blind vision
  verdict on the same capture independently read the first app content at
  204px, which is the cross-check for the ink measurement.

  Still not measured: what the column's bottom does when the keyboard is
  dismissed in the palette. That needs driving, not a screenshot — GDK-838.

  Building the shell needs **rustup's** toolchain, not the one first on
  `PATH`: this machine has Homebrew `rustc` ahead of it, and Homebrew's Rust
  ships no iOS `core`, so `cargo check --target aarch64-apple-ios-sim` fails
  with `can't find crate for core` while `rustup target list --installed`
  swears the target is there. Prefix the toolchain bin dir:

      PATH="$HOME/.rustup/toolchains/stable-aarch64-apple-darwin/bin:$PATH" \
        cargo check --target aarch64-apple-ios-sim
- The same scrollview auto-inset also makes the shell's outer scroll
  offset drift, which shifted synthetic-touch coordinates ±62pt during
  simulator driving. Real fingers and a fixed shell do not have this
  problem; it is why some flows below carry a "not driven live" boundary.

## 7. Decisions that changed during build (measured, not planned)

- **Paste & pair.** The long-press paste callout is hostile on a first-run
  screen. When the offer field is empty the primary button reads
  `navigator.clipboard.readText()` (the OS shows its own paste-permission
  pill) and submits in one tap: `gadak pairing mint | pbcopy` on the
  desktop, one thumb on the phone.
- **Dev adoption.** In dev the vite proxy is the trust boundary (it only
  exists because the developer pointed it at their own loopback serve,
  which decision 0003 keeps unauthenticated). First boot with no stored
  meta probes `auth/me/` through the proxy and adopts it on success. Dev
  builds never mutate the device Keychain (reads fall back to it;
  writes go to a dev-only localStorage slot).
- **credential_required.** A serve without an origin credential (the demo
  workspace) refuses writes with 409; the copy says so instead of a
  generic failure, and reads stay fully usable.
- **Debuggability kept.** The phone has no console: dev builds ship an
  on-screen error overlay (main.ts) and a viewport telemetry line on the
  Pairing tab. Both are `import.meta.env.DEV`-gated. The probe is prefixed
  `DEV` and sits under a hairline so it reads as instrumentation, not as
  product chrome (GDK-879).
- **Pairing is a ledger, not grouped fills (GDK-879).** Same hairline
  separators and section labels as Issues/Search; no panel-fill cards.
  Unpair keeps the two-step arm and expresses danger with ink weight/shape
  (`--color-text-primary`), never a status token.

## 8. Copy

Product voice, sentence case, verbs on buttons ("Pair", "Send", "Unpair").
Jira vocabulary only (§8 UX_PRINCIPLES): status, priority, comment,
transition — no invented nouns. **Names come from the catalog, not from
here** (§3.6): "Issues", "My issues", "All open", "Sync now", "Cancel",
the picker's section labels — and, since GDK-1150, every control label,
placeholder, aria-label and connective sentence this app draws (the old
English-debt list — tab labels, the offline banner, the empty states, the
pairing screen — is all keys now). The only literals a template may carry
outside `t()` are the ones a locale cannot change: the `gadak` brand token,
CLI commands shown verbatim (`gadak pairing mint`), the version footer, and
`DEV`-gated instrumentation. Errors say what to do next ("Pairing was
refused — mint a new offer on your desktop and pair again."), never
apologize, never quote server internals.

## 9. Gates

- **Dev demo tour** is opt-in. Open the DEV origin with `?demo-tour` (any
  value). A file probe at `/__demo-tour__` was abandoned: `mobile/public/`
  did not exist then (it holds the demo snapshot now), and vite's SPA
  fallback answers every unmatched path with `index.html` and 200, so the
  tour ran on every `npm run dev` boot. Packaged
  builds never run it (`import.meta.env.DEV` in `lib/demo-tour.ts`). `npm test`
  covers the disarmed path; the viewport gate walks a boot with nothing armed.
- **iOS contract (§4.1–4.2)** — `npm run lint:ios`. Only `src/app.css` may
  mention `env(safe-area-inset-*)`. No CSS `vh` / `dvh` / `svh` / `lvh` units
  under `src/` (a comment that names the ban is not a unit).
- **Viewport geometry** — `npm run viewport-gate` (from `mobile/`; also
  `bash mobile/scripts/viewport-gate.sh` from the repo root). Playwright at
  402×874 against `gadak demo` and a **built** bundle: the gate runs
  `vite build --mode development` into `$TMPDIR/gadak-mobile-gate-<ui port>`
  and serves it with `vite preview` (`mobile/e2e/gate-serve.sh`), which
  inherits the `/api` proxy from `server.proxy` but has no file watcher
  (GDK-1540). A dev server reloads the page whenever `mobile/src` or the
  `web/src` modules the phone imports change, and in a tree where more than
  one round is working that reload lands mid-spec and kills it on a click
  timeout that names nothing; a built bundle cannot. `npm run dev` is
  untouched — the developer loop still wants the watcher. Ports are
  `GADAK_MOBILE_E2E_PORT` (UI, default 5182) and `GADAK_MOBILE_API_PORT`
  (demo, default 7899, still readable as `GADAK_SERVE_PORT`), owned by
  `mobile/e2e/serve.ts` and read by both `playwright.config.ts` and
  `shots.config.ts`, so two gates run side by side by giving one of them its
  own pair. `reuseExistingServer` stays on but the bundle carries a stamp
  (`gadak-gate-stamp.json`: worktree, commit, dirty flag, source digest) and
  `globalSetup` refuses a server from another tree or commit rather than
  photographing it — the same contract as `e2e/helpers.ts`
  `assertServedArtifact`. The gate prints one line at start:
  `served <outDir> stamp <sha>[+dirty] ui :<port> api :<port>`. Not `e2e/`'s
  `127.0.0.1:7877` and not `e2e/.tmp/home` — demo makes its own temp home.
  Asserts horizontal overflow
  0, `nav.safe-bottom` flush to the viewport bottom, no input/textarea under
  16px, ≥9 issue rows per screen — the two-line clamp's bound, GDK-1543 — and
  ≥12 page rows (before **and** after the scope picker has
  been opened), a visible escape (`button.back`, sheet Cancel, or the palette's Cancel;
  the three root readings are exempt — the root has no exit),
  and no visible button under 44pt (GDK-867). Sheets are measured after their
  rise finishes: a rect read off a mid-transform composited layer at DPR 3
  comes back a hair under the laid-out size (a 44px control read 43.99994),
  which is an artifact of when we looked, not of what shipped.
- **Vocabulary (§3.6)** — `src/lib/vocabulary.test.ts` in `npm test`. Fails if
  "Queue" / "Mine" / "All" reappear in shipped source, if any file other than
  `lib/i18n.ts` reaches into `web/src/lib/i18n`, if the picker stops using the
  `sidebar.*` section keys, if a `POST`/`DELETE` is aimed at `issues/views/`,
  or — the one that catches a hardcoded English string an English eye would
  pass — if the scope names are not 내 담당 / 전체 미해결 under `gadak_locale=ko`.
  A parity case also re-reads the desktop's `effectiveCategory` and fails if
  the phone's status-category folding drifts from it. The by-hand form of the
  same check must exclude that file, which necessarily spells the banned words:
  `grep -rn --exclude=vocabulary.test.ts "Queue\|'Mine'\|>Mine<\|>All<" src e2e`
  → no hits.

- **Cataloged copy (§8, GDK-1150)** — `src/lib/template-copy.test.ts` in
  `npm test`. Walks every `.svelte` template with a real tokenizer (handler
  attributes contain `=>` and quotes; `t('key', {n})` spans braces — a
  naive tag-split reads those as prose) and fails on any English literal in
  a copy attribute (`aria-label`, `placeholder`, `title`) or text node,
  including quoted literals inside `{…}` text expressions (`{sending ?
  'Posting…' : t('write.commentButton')}` scans the first arm). Brand
  tokens, CLI commands shown verbatim, the version footer and `DEV`-gated
  probes sit in the test's allowlist with reasons; everything user-facing
  belongs in the catalog. A second case in the same file closes the other
  half: no `.ts` module may return an English *sentence* (three-plus words
  ending in sentence punctuation) — `api.ts` errorMessage's five-arm table
  and the two store-thrown offer-scope refusals became keys with this
  round. Fragments and single words in `.ts` are the honest limit; the
  template case covers their rendered form.

- **Visits are fed, not only read (GDK-1538)** — `src/lib/visit.test.ts`
  in `npm test` and `e2e/visit.spec.ts` in the viewport gate. The phone
  reads `last_visited_at` / `previous_visit_at` off the detail response and
  now writes the rows they are computed from: `store.recordVisit` posts
  every open to the desk's own route (POST
  `/api/v1/issues/history/visits/`), debounced by the desk's own constant.
  The unit measures the route, the body and the debounce; the e2e measures
  the half a mock cannot — that this serve accepts what the phone sends and
  hands the boundary back on the next read. (It caught a real one: the
  first path was missing the `issues/` base every serve route sits under
  and answered 404, invisibly, because a visit never blocks a screen.)

- **Webview CSP `connect-src` (GDK-1137)** — `src/lib/csp.test.ts` in
  `npm test`. The packaged webview may only `connect` to its own origin:
  `'self'` and nothing else. Every network path is either same-origin
  (the demo bundle's `window.fetch`, the dev `/api` proxy) or native and
  outside CSP entirely (`plugin-http`, `plugin-websocket`); tauri's own iOS
  IPC tries an `ipc://localhost` fetch, is refused by this same policy, and
  falls back to `window.ipc.postMessage` — measured in the tauri 2.11.5
  crate's `scripts/ipc-protocol.js`, so `'self'` costs nothing there. No dev
  exception exists because none is needed: on iOS dev,
  `PROXY_DEV_SERVER` (`all(dev, mobile)`) routes the page through
  `tauri://localhost` and hands vite's responses to the webview verbatim —
  dev HTML carries no CSP header at all, so HMR's `ws://localhost:5180` and
  the dev terminal socket never consult this value. The capability file
  (`http:default` → ts.net + loopback) was already narrow; this closes the
  gap where the CSP alone would have let the webview fetch any origin.

## 10. Shell — the terminal owner (GDK-865)

The phone attaches to a PTY session running on the paired `gadak serve`.
Not a new capability on the phone: the same shell the desktop pane opens
(`internal/term`), reached from the couch. There is no relay and never will
be — the phone dials the endpoint it paired with, directly, over whatever
network already carries the mirror (a tailnet, usually).

**Where it lives (GDK-902, 2026-09-15): the shell is an owner of the column.**
It is entered from the palette's *Terminal* row, which is **absent until a
terminal pairing is stored** (absence, not a greyed-out row, matching how
PairGate replaces the app rather than disabling it), and it owns the whole
screen while it is the owner — which is what the keyboard forces anyway, and
therefore not a compromise but the model. Its exit is the back control in its
own header. Everything below this line is independent of that and survives
it.

### 10.1 Its own token, and why

The terminal gate (`internal/server/terminal.go`) admits a Bearer whose
pairing scope is exactly `terminal`. The phone's existing token is `serve`
scope, and reusing it was explicitly ruled out: a token that reads the
mirror must not also be able to open a shell on the machine.

So the phone holds **two** tokens in the Keychain, in two slots. Pairing the
shell is its own scan of its own QR, minted at the desk with
`gadak pairing mint --scope terminal --label "<phone>"`.

An offer does not carry its scope, so a mis-scanned QR is indistinguishable
until it is used. The phone therefore **probes before storing**:
`GET /api/v1/terminal/sessions/` with the scanned token. The gate answers
`scope_rejected` for a serve token, `pairing_rejected` for a revoked one —
both honest, both shown as copy. A token is written to the Keychain only
after the probe passes.

### 10.2 Transport — two branches, one seam

| Build | Socket | Why |
|---|---|---|
| dev | plain `WebSocket` through vite's `/api` proxy | the proxy makes the serve loopback and same-origin, and a loopback peer needs no Bearer at all (`terminalGate`) |
| packaged | native WebSocket (`tauri-plugin-websocket`) | a webview `WebSocket` cannot set `Authorization`, and its `tauri://localhost` origin is not the serve's — `browser_guard.go` origin-checks upgrades. A native client sends no `Origin`, which the guard allows, and can set the header. |

The same split, for the same reason, as `lib/api.ts`'s `fetch`. The wire
vocabulary is not re-spelled here: `web/src/lib/terminal/protocol.ts` is
imported, the way `app.css` imports the web tokens.

**The endpoint check is ours, not the platform's.** `http:default` in
`capabilities/default.json` is URL-scoped to `https://*.ts.net` and
loopback; the websocket permission has no such allowlist. The replacement is
`assertPairedWsUrl(endpoint, url)` in `lib/terminal/transport.ts` — scheme,
host and port must equal the stored pairing endpoint, so a shell can be
opened only on the machine this phone paired with. It is a tested pure
function rather than an inline `if` because it is the only thing standing
where a platform allowlist would otherwise stand.

### 10.3 Input — the part that is phone-only

- **IME.** Nothing reaches the PTY between `compositionstart` and
  `compositionend`; the composed string is sent once, as UTF-8, on
  `compositionend`. A shell that receives half-assembled 자모 is receiving
  garbage, and no amount of terminal-side cleverness fixes it afterwards.
  The gate is a pure state machine with a Korean event sequence in its tests
  — not a screenshot.
- **The phone keyboard has no Esc, no Ctrl, no arrows.** A key bar sits
  above the keyboard carrying them, riding the existing `keyboardInset`
  action (§4.2) — the VisualViewport measurement is already solved here and
  is not solved twice. Ctrl is *sticky*: tap it, then a letter, and the
  control byte goes out (`Ctrl`+`c` → `0x03`).
- **A sticky modifier has three states, not two** (GDK-898). idle, *armed*
  for the next key, and *locked* until you tap it again — a second tap
  within 400 ms promotes armed to locked. A toggle cannot hold Ctrl across
  several keys, and it cannot tell "armed for one" apart from "held". The
  bar shows all three with shape as well as colour, because a modifier whose
  state you cannot see is worse than no modifier at all.
- **The decisions are shared; the bytes are ours.** Sticky slots, the
  composition gate, the key-repeat cadence and the flush barrier live in
  `glasskeys`, which naru-remote runs the same golden vectors
  against. What stays here is the encoder — control bytes, CSI sequences,
  UTF-8 — because naru wraps the same decision in an X11 keysym over VNC and
  there is no shared encoding of "Ctrl-C". `src/lib/terminal/keys.ts` is the
  adapter between the two, and it is still a pure function over plain data.
  New input *behaviour* belongs in the library, where both apps get it; new
  *encoding* belongs here.
- **Autocorrect is off** on whatever element takes the keystrokes.
  Smart quotes in a shell command are a defect, not a nicety.

### 10.4 Reconnect is the normal case here

On the desktop a dropped socket is an event; on a phone it is what happens
every time the screen locks. iOS freezes the webview when the app
backgrounds, so the socket dies and the 60 s server-side grace starts. The
phone therefore reattaches on `visibilitychange → visible` **immediately**,
without waiting out the backoff schedule, and the ring replay is the first
binary frame. Past the grace the session is gone and the phone says so and
offers a new one — the same ended-state contract the desktop pane has, just
reached far more often.

### 10.5 A refusal keeps its own sentence (GDK-1121)

The shared classifier (`web/src/lib/terminal/protocol.ts`) folds every
401/403 into one `forbidden` cause — right for the web, which never meets
`forbidden_origin`; wrong here, because the phone is the host whose packaged
requests the origin guard refuses (GDK-1120: every POST answered 403
`forbidden_origin` while the strip said the network line, and the server
side keeps one guard log line — the screen is the only user-visible
diagnostic). So `lib/terminal/refusal.ts` is a second, phone-side owner:
`(status, code)` → one of four catalog sentences (origin / pairing / scope
/ other). The terminal strip renders it ahead of the cause's generic line,
and `errorMessage()` reads the same map, so a refusal reads identically on
every surface. Measured per status by `src/lib/terminal/refusal.test.ts`.
Honest limit: only the REST create path carries the status — a browser
WebSocket close event has none, so a 403 on the upgrade itself still reads
as `network`.
