DESIGN.md@mobile · git:20260918.829fbd8 · 2026-09-18 · sha256 6e76a668fc3578f2
DESIGN.md@mobile git:20260918.829fbd8A
Immutable. This exact content never changes and is served at /api/v1/blob/6e76a668fc3578f2.
# 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 is named and
changed** (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 = "this list"; ⌕ = the palette
├────────────────────────────┤
│ (what the owner draws) │ scope list · documents plate · shell
└────────────────────────────┘
├─► This list (sheet, from the chevron) — the scope row (→ Palette) ·
│ the narrowing toggles · the cut · the order · clear
├─► 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).
**Two doors, two bodies** (GDK-1994, deciding the doubt GDK-1990 left open).
The header's magnifier opens the **palette**, which is for finding one thing
in the whole snapshot. The heading's chevron opens **this list**, which is
about the list already on screen. Until GDK-1994 both opened the palette and
differed by a single bit — whether the field took focus — so once the
keyboard dropped the two states were pixel-identical, which is one room with
two doors on it. 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. A second tap on either door closes what it
opened.
**The palette is dormant on boot.** Opening it turns the list body into the
palette, the field riding the head of the body. From the magnifier the field
is focused, because a person who reached for a magnifier has said they want
to type; reached any other way (the "this list" sheet's scope row) it is not,
so the owner list does not sit under a keyboard nobody asked for. Nothing is
ever focused on boot: 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). 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.
**"This list" is a sheet, and its first row is the picker.** The scope's own
name, its count, and a chevron: the owner is still one tap from the heading,
it just arrives as a choice rather than as the only thing behind the glyph.
Under it are the toggles that **narrow the list on screen** — the phone had
no such control before GDK-1994, and search was not one (it runs over the
whole snapshot, not the scope), so "just the reopened ones" meant going back
to the desk to save a view. The shape is iOS Mail's filter, which §1 already
cites for what the phone does *not* author: toggle rows, live behind the
scrim, no apply button, no chips, no save. The rows are discovered from the
list in hand and one rule decides every one of them — **a toggle that cannot
change the list is not offered** (it matches nothing, or it matches
everything) — which is what keeps the sheet Mail-short instead of the desk's
facet browser. Under the toggles are the desk's other two view controls as
rows (GDK-1993): **what the list is cut by**, and **what it is ordered by**,
with direction as one row under the keys. Every word in them is the desk's —
axes that are fields wear `fieldLabel`, the same owner the desk's own
breakdown bar reads — so the two surfaces cannot end up calling one axis two
names. All three are session RAM: they are cleared when the owner changes and
never stored, because a triage phone narrows to find something and then
leaves, and because a narrow carried into another view would disagree with
the count the picker just showed for it. Authoring the result as a view is
the desk's (§1). The heading says a narrow is on with one dot beside the
chevron and with the count, which is already the narrowed number; a chip row
would cost the list a row of density, and §3.3 spends that on the list.
**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) |
| This list (sheet) | the heading's chevron (44pt) | the chevron again · Cancel (44pt) · scrim · system back · its scope row (→ Palette) |
| Palette | the header magnifier (44pt) · the sheet's scope row | the magnifier again · the heading · 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 the way that scope's view
asked (GDK-1992; the catalog default is `priority` asc, unset ranks last,
ties by newest update), grouped by priority with the display name as the
section label (display-only; logic never keys on it). The band is a bucket,
not a run: a list ordered on any other axis interleaves the ranks, and the
band is a keyed `#each`. 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`), and `labels` (+`_not` —
the array has ridden the row since GDK-1870). `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 — 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).
**The order is the view's** (GDK-1992): `display.sort`/`dir` go straight to
the desk's own comparator (`web/src/lib/issue-sort.ts`), so a view written
to read oldest-first reads oldest-first here too. It used to be fixed —
priority asc, then updated desc — which silently reversed *Handed off* and
made *Reopened* ignore the axis it is named for. The catalog default moved
to priority asc the same day so the two surfaces agree on what a view that
chose nothing means; a `sort` the phone cannot answer (`relevance` needs
search scores, `keys` a server order) falls back to that default rather
than pretending. **The cut is the view's too** (GDK-1993): `display.group_by`
runs through the desk's own grouper (`web/src/lib/issue-group.ts`), which
left the desk's rune store for the same reason the comparator did. The
phone used to bucket by priority everywhere but the sprint scope and ignore
the axis entirely, so the two surfaces disagreed about what a view *is*.
An axis the row cannot bucket (`severity`, `team_group`, `product`,
`qa_impact`, `development_test_result`, `actor`) falls to the catalog
default rather than painting a screen of "(none)", the same stance the
order takes. Two consequences worth naming: the section key is the desk's
group key, a string, because an assignee or an epic has no rank and a
numeric key across eight axes is the `each_key_duplicate` crash with more
ways in; and the sprint scope's headers read the desk's order — in
progress, then new, then done — which is the stronger reading of GDK-1867's
own sentence anyway. **The reader can override both**, for as long as the
scope is up, from the rows in the "this list" sheet (§2).
- **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`.