simframe 0.4.2 → 0.6.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +334 -85
  2. package/native/simframed/Package.swift +16 -0
  3. package/native/simframed/Sources/PrivateAPI/AccessibilityBridge.swift +379 -0
  4. package/native/simframed/Sources/PrivateAPI/CoreSimulatorPlatform.swift +523 -0
  5. package/native/simframed/Sources/PrivateAPI/HIDKeyboard.swift +70 -0
  6. package/native/simframed/Sources/PrivateAPI/IndigoHID.swift +121 -0
  7. package/native/simframed/Sources/PrivateAPI/PrivateAPI.swift +149 -0
  8. package/native/simframed/Sources/PrivateAPI/StubPlatform.swift +112 -0
  9. package/native/simframed/Sources/SimframeCore/Bitmap.swift +61 -0
  10. package/native/simframed/Sources/SimframeCore/ControlSocket.swift +122 -0
  11. package/native/simframed/Sources/SimframeCore/CoreGraphicsScaler.swift +70 -0
  12. package/native/simframed/Sources/SimframeCore/Element.swift +148 -0
  13. package/native/simframed/Sources/SimframeCore/FrameStore.swift +303 -0
  14. package/native/simframed/Sources/SimframeCore/Hashing.swift +119 -0
  15. package/native/simframed/Sources/SimframeCore/Motion.swift +431 -0
  16. package/native/simframed/Sources/SimframeCore/PNGWriter.swift +40 -0
  17. package/native/simframed/Sources/SimframeCore/VisionOCR.swift +75 -0
  18. package/native/simframed/Sources/simframed/main.swift +485 -0
  19. package/native/simframed/Tests/SimframeCoreTests/HashingTests.swift +270 -0
  20. package/package.json +12 -4
  21. package/scripts/bench-flow.mjs +54 -0
  22. package/scripts/bench.sh +98 -0
  23. package/scripts/check-package.mjs +99 -0
  24. package/scripts/ci-memory.mjs +416 -0
  25. package/scripts/eval-fingerprint.mjs +192 -0
  26. package/scripts/smoke.mjs +76 -0
  27. package/scripts/sync-server-version.mjs +39 -0
  28. package/scripts/verify-baseline.mjs +65 -0
  29. package/skills/simframe/SKILL.md +173 -0
  30. package/src/actions.js +264 -18
  31. package/src/cli.js +561 -89
  32. package/src/control.js +77 -0
  33. package/src/daemon.js +8 -1
  34. package/src/engine.js +99 -0
  35. package/src/fingerprint.js +183 -0
  36. package/src/graph.js +411 -0
  37. package/src/index.js +351 -24
  38. package/src/input.js +179 -2
  39. package/src/matching.js +265 -0
  40. package/src/mcp.js +425 -112
  41. package/src/navigate.js +120 -0
  42. package/src/refs.js +141 -0
  43. package/src/regions.js +267 -0
  44. package/src/screenmap.js +119 -22
  45. package/src/simctl.js +74 -5
  46. package/src/store.js +8 -0
  47. package/src/view.js +342 -0
@@ -0,0 +1,120 @@
1
+ // Navigating by memory.
2
+ //
3
+ // Once the graph knows which screens exist and which action leads from one to
4
+ // the next, getting somewhere is a search over known edges rather than a
5
+ // question for a model. `goto` plans a path and walks it; a flow is a path
6
+ // somebody already walked, saved so it can be walked again.
7
+ import { runScript } from './actions.js';
8
+ import * as graph from './graph.js';
9
+ import * as api from './index.js';
10
+ import * as store from './store.js';
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+
14
+ const flowDir = (udid) => path.join(store.deviceDir(udid), 'flows');
15
+
16
+ /** The signature is lossy; older edges predate `step` and have to be reconstructed. */
17
+ export function stepFor(edge) {
18
+ if (edge.step) return edge.step;
19
+ const [kind, rest] = [edge.action.slice(0, edge.action.indexOf(':')), edge.action.slice(edge.action.indexOf(':') + 1)];
20
+ if (kind === 'tap') return { tap: rest };
21
+ if (kind === 'scroll') return { scroll: rest };
22
+ if (kind === 'button') return { button: rest };
23
+ if (kind === 'tapAt') {
24
+ const [x, y] = rest.split(',').map(Number);
25
+ return Number.isFinite(x) && Number.isFinite(y) ? { tapAt: { x, y } } : null;
26
+ }
27
+ if (kind === 'swipe') {
28
+ const [from, to] = rest.split('->').map((p) => p.split(',').map(Number));
29
+ return from?.length === 2 && to?.length === 2 ? { swipe: { from, to } } : null;
30
+ }
31
+ return null;
32
+ }
33
+
34
+ /**
35
+ * Walk to a known screen.
36
+ *
37
+ * Fails rather than guesses: if the destination is not in the graph, or the
38
+ * query fits two screens equally, or no path of known edges reaches it, that is
39
+ * reported. A wrong route is worse than no route, because it taps things.
40
+ */
41
+ export async function goto(deviceQuery, target, { options, ...runOptions } = {}) {
42
+ const { device } = await api.ensureDaemon(deviceQuery, options);
43
+ const udid = device.udid;
44
+
45
+ const found = graph.findScreen(udid, target);
46
+ if (!found) return { ok: false, reason: 'unknown-screen', known: knownScreens(udid) };
47
+ if (found.ambiguous) return { ok: false, reason: 'ambiguous', candidates: found.ambiguous };
48
+
49
+ const here = await api.screenIdentity(udid, {});
50
+ if (here.hash === found.node.hash) {
51
+ return { ok: true, already: true, screen: found.name, steps: [] };
52
+ }
53
+
54
+ const path_ = graph.route(udid, { hash: here.hash, tokens: here.tokens }, found.node.hash);
55
+ if (!path_) return { ok: false, reason: 'no-route', from: here.hash.slice(0, 8), to: found.name };
56
+
57
+ const steps = path_.map(stepFor);
58
+ if (steps.some((s) => !s)) return { ok: false, reason: 'unreplayable-edge', to: found.name };
59
+
60
+ const result = await runScript(udid, { steps, stopOnUnexpected: true, ...runOptions });
61
+ const arrived = await api.screenIdentity(udid, {});
62
+ return {
63
+ ok: arrived.hash === found.node.hash,
64
+ screen: found.name,
65
+ steps,
66
+ ranSteps: result.ranSteps,
67
+ results: result.results,
68
+ arrived: arrived.hash.slice(0, 8),
69
+ };
70
+ }
71
+
72
+ export function knownScreens(udid) {
73
+ return graph.allNodes(udid).map((n) => ({ name: graph.describe(n), hash: n.hash.slice(0, 8), edges: n.edges.length }));
74
+ }
75
+
76
+ /**
77
+ * Save a flow.
78
+ *
79
+ * Only flows that verified end to end are worth saving: a flow with an
80
+ * unverified step in it is a recording of something that may not have worked,
81
+ * and replaying it faithfully reproduces the doubt.
82
+ */
83
+ export function saveFlow(udid, name, script, { force = false } = {}) {
84
+ const verdicts = (script.results ?? []).map((r) => r.verification?.verdict);
85
+ if (!force && verdicts.some((v) => v && v !== 'ok')) {
86
+ return { ok: false, reason: 'unverified-steps', verdicts };
87
+ }
88
+ const dir = flowDir(udid);
89
+ fs.mkdirSync(dir, { recursive: true });
90
+ const body = {
91
+ name,
92
+ savedAt: Date.now(),
93
+ steps: script.steps ?? (script.results ?? []).map((r) => r.step).filter(Boolean),
94
+ startScreen: script.startScreen ?? null,
95
+ };
96
+ store.writeAtomic(path.join(dir, `${encodeURIComponent(name)}.json`), JSON.stringify(body, null, 2));
97
+ return { ok: true, name, steps: body.steps.length };
98
+ }
99
+
100
+ export function loadFlow(udid, name) {
101
+ return store.readJson(path.join(flowDir(udid), `${encodeURIComponent(name)}.json`));
102
+ }
103
+
104
+ export function listFlows(udid) {
105
+ const dir = flowDir(udid);
106
+ if (!fs.existsSync(dir)) return [];
107
+ return fs.readdirSync(dir)
108
+ .filter((f) => f.endsWith('.json'))
109
+ .map((f) => store.readJson(path.join(dir, f)))
110
+ .filter(Boolean)
111
+ .map((f) => ({ name: f.name, steps: f.steps?.length ?? 0, savedAt: f.savedAt }));
112
+ }
113
+
114
+ export async function runFlow(deviceQuery, name, { options, ...runOptions } = {}) {
115
+ const { device } = await api.ensureDaemon(deviceQuery, options);
116
+ const flow = loadFlow(device.udid, name);
117
+ if (!flow) return { ok: false, reason: 'unknown-flow', known: listFlows(device.udid).map((f) => f.name) };
118
+ const result = await runScript(device.udid, { steps: flow.steps, stopOnUnexpected: true, ...runOptions });
119
+ return { ok: result.ranSteps === flow.steps.length, name, ...result };
120
+ }
package/src/refs.js ADDED
@@ -0,0 +1,141 @@
1
+ // Element refs and selectors.
2
+ //
3
+ // A number in front of every element is what lets the next call name one
4
+ // without describing it: `#3` instead of "the second Save button, the one in
5
+ // the nav bar". The table lives on disk because the call that numbered the
6
+ // elements and the call that acts on one are separate MCP round trips.
7
+ //
8
+ // Kept apart from view.js so that index.js can resolve a selector without
9
+ // importing the renderer that in turn imports index.js.
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { hashDistance } from './analyze.js';
13
+ import * as store from './store.js';
14
+
15
+ /**
16
+ * How far the pixel layout may drift before a ref is no longer trustworthy.
17
+ *
18
+ * The same number screen memory uses to decide two frames are the same screen,
19
+ * and for the same reason: a list that gained a row is still the screen the
20
+ * elements were numbered on, but a different screen is not.
21
+ */
22
+ export const REF_TOLERANCE = 20;
23
+
24
+ /**
25
+ * Does this layout hash carry enough signal to compare?
26
+ *
27
+ * A blank, dark or near-uniform screen hashes to almost all zeros, and the
28
+ * Hamming distance between two such hashes is tiny however different the
29
+ * screens are. Below this many set bits the hash is not evidence.
30
+ */
31
+ const MIN_SET_BITS = 16;
32
+
33
+ export function informative(hex) {
34
+ let bits = 0;
35
+ for (const ch of String(hex ?? '')) {
36
+ const v = parseInt(ch, 16);
37
+ if (Number.isNaN(v)) continue;
38
+ bits += (v & 1) + ((v >> 1) & 1) + ((v >> 2) & 1) + ((v >> 3) & 1);
39
+ if (bits >= MIN_SET_BITS) return true;
40
+ }
41
+ return false;
42
+ }
43
+
44
+ const refsFile = (udid) => path.join(store.deviceDir(udid), 'refs.json');
45
+
46
+ /**
47
+ * Number the elements and write the table down.
48
+ *
49
+ * A ref is only meaningful while the screen it was numbered on is still
50
+ * showing, so the table records the screen's structural hash and resolving a
51
+ * ref against a different screen is an error rather than a tap somewhere
52
+ * unintended.
53
+ */
54
+ export function writeRefs(udid, { structuralHash, layoutHash, rows }) {
55
+ const body = {
56
+ structuralHash: structuralHash ?? null,
57
+ layoutHash: layoutHash ?? null,
58
+ at: Date.now(),
59
+ refs: rows.map((r) => ({
60
+ ref: r.ref,
61
+ label: r.label ?? null,
62
+ x: r.x,
63
+ y: r.y,
64
+ type: r.type ?? null,
65
+ region: r.region ?? 'content',
66
+ source: r.source ?? null,
67
+ })),
68
+ };
69
+ try {
70
+ fs.mkdirSync(path.dirname(refsFile(udid)), { recursive: true });
71
+ store.writeAtomic(refsFile(udid), JSON.stringify(body));
72
+ } catch {
73
+ /* refs are a convenience; failing to cache them must not fail the call */
74
+ }
75
+ return body;
76
+ }
77
+
78
+ export function readRefs(udid) {
79
+ return store.readJson(refsFile(udid));
80
+ }
81
+
82
+ /**
83
+ * `#3` | `@120,400` | anything else.
84
+ *
85
+ * Parsing is separate from resolving so a caller can tell a selector from a
86
+ * label without touching the device.
87
+ */
88
+ export function parseSelector(query) {
89
+ const raw = String(query ?? '').trim();
90
+ const ref = /^#(\d+)$/.exec(raw);
91
+ if (ref) return { kind: 'ref', ref: Number(ref[1]) };
92
+ const at = /^@\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)$/.exec(raw);
93
+ if (at) return { kind: 'point', x: Math.round(Number(at[1])), y: Math.round(Number(at[2])) };
94
+ // A quoted label is an explicit "this exact text", not an intent.
95
+ const quoted = /^"(.*)"$/.exec(raw) || /^'(.*)'$/.exec(raw);
96
+ if (quoted) return { kind: 'label', label: quoted[1], exact: true };
97
+ return { kind: 'label', label: raw, exact: false };
98
+ }
99
+
100
+ /**
101
+ * Turn a `#n` back into a point.
102
+ *
103
+ * Refuses when the screen has moved on. A stale ref is the one failure mode
104
+ * numbering introduces that labels do not have, and a ref resolved against the
105
+ * wrong screen taps whatever now happens to sit at those coordinates.
106
+ */
107
+ export function resolveRef(udid, n, { structuralHash, layoutHash, screenKnown, tolerance = REF_TOLERANCE } = {}) {
108
+ const table = readRefs(udid);
109
+ if (!table) throw new Error(`#${n} means nothing yet — read the screen first (sim_ui, or simframe ui)`);
110
+ const stale = (was, now) =>
111
+ `#${n} was numbered on a different screen (${was} → ${now}) — read the screen again before using refs`;
112
+
113
+ // Structural identity first, because it is the question actually being asked:
114
+ // is this the screen those numbers were assigned on? The caller gets it
115
+ // cheaply — screen memory is a file read, not a perception pass.
116
+ if (table.structuralHash && structuralHash && table.structuralHash !== structuralHash) {
117
+ throw new Error(stale(table.structuralHash.slice(0, 8), structuralHash.slice(0, 8)));
118
+ }
119
+ // Nothing recognises the screen we are on, so nothing can vouch for the
120
+ // numbers. Refusing costs a re-read; guessing taps whatever is at those
121
+ // coordinates now.
122
+ if (screenKnown === false) {
123
+ throw new Error(`#${n} cannot be trusted here — simframe does not recognise this screen. Read it again (sim_ui) to renumber.`);
124
+ }
125
+ // The pixel check stays, but only as a backstop, and only where it means
126
+ // something. A dark or near-uniform screen produces a layout hash of almost
127
+ // all zeros, and two such screens sit within any sane tolerance of each
128
+ // other — measured: refs numbered on the springboard resolved happily on a
129
+ // different screen because both hashes were degenerate. A hash with almost
130
+ // no bits set is not evidence of anything.
131
+ if (layoutHash && table.layoutHash && informative(table.layoutHash) && informative(layoutHash)
132
+ && hashDistance(table.layoutHash, layoutHash) > tolerance) {
133
+ throw new Error(stale(table.layoutHash.slice(0, 8), layoutHash.slice(0, 8)));
134
+ }
135
+ const hit = table.refs.find((r) => r.ref === n);
136
+ if (!hit) {
137
+ const available = table.refs.length ? `#1-#${table.refs.length}` : 'none';
138
+ throw new Error(`#${n} is not on this screen (numbered: ${available})`);
139
+ }
140
+ return hit;
141
+ }
package/src/regions.js ADDED
@@ -0,0 +1,267 @@
1
+ // Where on the screen something is, in the terms iOS itself uses.
2
+ //
3
+ // A label alone is ambiguous — "Assets" is both a screen title and a tab — but
4
+ // a label plus a region rarely is. And the region matters more than it looks:
5
+ // chrome labels are the only text that enters a screen's structural
6
+ // fingerprint, so anything misfiled as chrome lands directly in that screen's
7
+ // identity.
8
+ //
9
+ // This used to be fractions of screen height, and it cost three bugs in three
10
+ // phases: a nav button read as a title, an identity containing "sep 08, 2026"
11
+ // that would have expired at midnight, and a springboard whose identity was the
12
+ // name of the city in its weather widget. Each was patched with another rule.
13
+ //
14
+ // The bands are now derived from where the elements themselves sit, because the
15
+ // thing that actually distinguishes chrome from content is not height on the
16
+ // screen — it is separation. A nav bar sits above a gap; a tab bar sits below
17
+ // one; and a springboard, whose icon rows are evenly spaced from top to bottom,
18
+ // has neither and should be told it has neither.
19
+ export const REGIONS = [
20
+ 'status-bar',
21
+ 'nav-bar',
22
+ 'tab-bar',
23
+ 'keyboard',
24
+ 'content',
25
+ ];
26
+
27
+ /**
28
+ * The status bar stays positional, and deliberately.
29
+ *
30
+ * It is a device inset — the notch or dynamic island — not app layout, so its
31
+ * position is a property of the hardware rather than of the screen. It has
32
+ * never been the source of a misclassification, and clustering it would mean
33
+ * inferring a constant from noise.
34
+ */
35
+ const STATUS_BAR_FRACTION = 0.065;
36
+
37
+ /** Keyboards occupy the bottom of the screen and are unusually tall. */
38
+ const KEYBOARD_MIN_FRACTION = 0.28;
39
+
40
+ /** Chrome is short. A 90pt list cell is not a tab item however low it sits. */
41
+ const CHROME_MAX_HEIGHT_FRACTION = 0.075;
42
+
43
+ /**
44
+ * How far into the screen chrome may reach.
45
+ *
46
+ * Not the decision — the gap is the decision — but a precondition, because a
47
+ * gap in the middle of a screen separates two pieces of content and nothing
48
+ * else. Generous on both ends so that a tall nav bar with a search field in it,
49
+ * or a tab bar above a home indicator, still qualifies.
50
+ */
51
+ const TOP_CHROME_LIMIT = 0.28;
52
+ const BOTTOM_CHROME_LIMIT = 0.82;
53
+
54
+ /**
55
+ * What makes a gap a boundary rather than spacing.
56
+ *
57
+ * Both conditions, because either alone is wrong. An absolute floor, since two
58
+ * rows 4pt apart are one visual group whatever the rest of the screen does; and
59
+ * a multiple of the screen's own median row gap, since 16pt is a boundary on a
60
+ * dense list and ordinary spacing on a sparse one. This is the whole idea: the
61
+ * screen sets its own scale.
62
+ */
63
+ const MIN_BOUNDARY_GAP_PT = 10;
64
+ const BOUNDARY_GAP_FACTOR = 1.9;
65
+
66
+ /** A tab bar is several things spread across the width, not one thing at the bottom. */
67
+ const TAB_BAR_MIN_ITEMS = 2;
68
+ const TAB_BAR_MIN_SPREAD = 0.4;
69
+
70
+ /** Below this many elements there is no distribution to cluster; fall back. */
71
+ const MIN_ELEMENTS_TO_CLUSTER = 6;
72
+
73
+ /**
74
+ * Fractions of screen height, used only when clustering has nothing to work
75
+ * with. These are the old rules, kept because a screen with four elements on it
76
+ * still needs an answer and the HIG is a better guess than none.
77
+ */
78
+ const FALLBACK = { navBar: 0.14, tabBar: 0.86 };
79
+
80
+ const heightOf = (frame) => frame?.height ?? 0;
81
+ const midY = (frame) => frame.y + heightOf(frame) / 2;
82
+
83
+ /** Group elements into horizontal rows: a sweep down the screen, joining anything that overlaps. */
84
+ export function rowsOf(elements) {
85
+ const items = elements
86
+ .filter((e) => e.frame && Number.isFinite(e.frame.y))
87
+ .map((e) => ({ e, top: e.frame.y, bottom: e.frame.y + heightOf(e.frame) }))
88
+ .sort((a, b) => midY(a.e.frame) - midY(b.e.frame));
89
+ const rows = [];
90
+ for (const it of items) {
91
+ const row = rows[rows.length - 1];
92
+ // Overlapping vertically means side by side, which means one row.
93
+ if (row && it.top < row.bottom) {
94
+ row.items.push(it.e);
95
+ row.top = Math.min(row.top, it.top);
96
+ row.bottom = Math.max(row.bottom, it.bottom);
97
+ } else {
98
+ rows.push({ items: [it.e], top: it.top, bottom: it.bottom });
99
+ }
100
+ }
101
+ return rows;
102
+ }
103
+
104
+ function medianOf(values) {
105
+ if (!values.length) return 0;
106
+ const v = [...values].sort((a, b) => a - b);
107
+ return v[v.length >> 1];
108
+ }
109
+
110
+ /**
111
+ * The horizontal reach of a row, as a fraction of screen width.
112
+ *
113
+ * A tab bar spans the screen; a single centred label does not. Measured between
114
+ * the outermost centres rather than the outermost edges, so one wide element
115
+ * cannot fake a spread on its own.
116
+ */
117
+ function spreadOf(row, screen) {
118
+ if (!screen?.width || row.items.length < 2) return 0;
119
+ const centres = row.items.map((e) => e.frame.x + (e.frame.width ?? 0) / 2);
120
+ return (Math.max(...centres) - Math.min(...centres)) / screen.width;
121
+ }
122
+
123
+ const allShort = (row, screen) =>
124
+ row.items.every((e) => heightOf(e.frame) <= screen.height * CHROME_MAX_HEIGHT_FRACTION);
125
+
126
+ /**
127
+ * Where this screen's bands actually are.
128
+ *
129
+ * Returns boundaries in points, so `regionFor` stays a cheap comparison and the
130
+ * clustering is paid for once per screen rather than once per element.
131
+ */
132
+ export function bands(elements, screen) {
133
+ const keyboardTop = detectKeyboardTop(elements, screen);
134
+ const statusBarBottom = screen?.height ? screen.height * STATUS_BAR_FRACTION : 0;
135
+ if (!screen?.width || !screen?.height) {
136
+ return { statusBarBottom: 0, navBarBottom: 0, tabBarTop: Infinity, keyboardTop, clustered: false };
137
+ }
138
+
139
+ // Everything the bands are inferred from: on-screen, below the status bar,
140
+ // and above the keyboard if one is up. The status bar is a clock and a battery
141
+ // icon, and letting them vote on where the nav bar ends is how a nav bar came
142
+ // to include the clock.
143
+ const considered = elements.filter((e) => {
144
+ if (!e.frame || !Number.isFinite(e.frame.y)) return false;
145
+ if (e.frame.y + heightOf(e.frame) <= statusBarBottom) return false;
146
+ if (keyboardTop != null && e.frame.y >= keyboardTop) return false;
147
+ return e.frame.y < screen.height && e.frame.y + heightOf(e.frame) > 0;
148
+ });
149
+
150
+ const rows = rowsOf(considered);
151
+ if (rows.length < 3 || considered.length < MIN_ELEMENTS_TO_CLUSTER) {
152
+ return {
153
+ statusBarBottom,
154
+ navBarBottom: screen.height * FALLBACK.navBar,
155
+ tabBarTop: screen.height * FALLBACK.tabBar,
156
+ keyboardTop,
157
+ clustered: false,
158
+ };
159
+ }
160
+
161
+ const gaps = rows.slice(1).map((row, i) => row.top - rows[i].bottom);
162
+ const typical = medianOf(gaps.filter((g) => g > 0));
163
+ const isBoundary = (gap) => gap >= Math.max(MIN_BOUNDARY_GAP_PT, typical * BOUNDARY_GAP_FACTOR);
164
+
165
+ // --- top chrome. Up to two rows, because a nav bar can be a title above a
166
+ // search field, and no more, because three rows of anything is content.
167
+ let navBarBottom = 0;
168
+ for (let i = 0; i < Math.min(2, rows.length - 1); i += 1) {
169
+ const gap = rows[i + 1].top - rows[i].bottom;
170
+ const withinReach = rows[i].bottom <= screen.height * TOP_CHROME_LIMIT;
171
+ if (!withinReach) break;
172
+ if (isBoundary(gap) && rows.slice(0, i + 1).every((r) => allShort(r, screen))) {
173
+ navBarBottom = rows[i].bottom;
174
+ break;
175
+ }
176
+ }
177
+
178
+ // --- bottom chrome. One row: a tab bar is one row by construction, and the
179
+ // gap above it is what separates it from the list it floats over.
180
+ let tabBarTop = Infinity;
181
+ const last = rows[rows.length - 1];
182
+ const gapAbove = last.top - rows[rows.length - 2].bottom;
183
+ if (
184
+ last.top >= screen.height * BOTTOM_CHROME_LIMIT
185
+ && allShort(last, screen)
186
+ && last.items.length >= TAB_BAR_MIN_ITEMS
187
+ && spreadOf(last, screen) >= TAB_BAR_MIN_SPREAD
188
+ && isBoundary(gapAbove)
189
+ ) {
190
+ tabBarTop = last.top;
191
+ }
192
+
193
+ return { statusBarBottom, navBarBottom, tabBarTop, keyboardTop, clustered: true, typicalGap: typical };
194
+ }
195
+
196
+ /**
197
+ * Which band this frame falls in.
198
+ *
199
+ * `band` comes from `bands()`. Passing only `{keyboardTop}` still works and
200
+ * falls back to the HIG fractions, which is what callers that have one element
201
+ * and no screen context get.
202
+ */
203
+ export function regionFor(frame, screen, band = {}) {
204
+ if (!frame || !screen?.height) return 'content';
205
+ const { keyboardTop } = band;
206
+ if (keyboardTop != null && frame.y >= keyboardTop) return 'keyboard';
207
+
208
+ const top = frame.y;
209
+ const bottom = frame.y + heightOf(frame);
210
+ const statusBarBottom = band.statusBarBottom ?? screen.height * STATUS_BAR_FRACTION;
211
+ if (bottom <= statusBarBottom) return 'status-bar';
212
+
213
+ const short = heightOf(frame) <= screen.height * CHROME_MAX_HEIGHT_FRACTION;
214
+ const navBarBottom = band.navBarBottom ?? screen.height * FALLBACK.navBar;
215
+ const tabBarTop = band.tabBarTop ?? screen.height * FALLBACK.tabBar;
216
+ // A screen with no top chrome has navBarBottom 0, so nothing is a nav bar —
217
+ // which is the correct answer for a springboard, and the answer the old
218
+ // positional rule could not give.
219
+ if (short && navBarBottom > 0 && bottom <= navBarBottom + 1) return 'nav-bar';
220
+ if (short && top >= tabBarTop - 1) return 'tab-bar';
221
+ return 'content';
222
+ }
223
+
224
+ /**
225
+ * Where a nav-bar control sits across the bar: leading, title or trailing.
226
+ * A back button is leading; an edit or done button is trailing. Callers use it
227
+ * to disambiguate two controls that share a label.
228
+ */
229
+ export function navSlot(frame, screen) {
230
+ if (!frame || !screen?.width) return null;
231
+ const centre = frame.x + (frame.width ?? 0) / 2;
232
+ const third = screen.width / 3;
233
+ if (centre < third) return 'leading';
234
+ if (centre > third * 2) return 'trailing';
235
+ return 'title';
236
+ }
237
+
238
+ /**
239
+ * The top of the keyboard, if one appears to be up.
240
+ *
241
+ * Inferred from a dense band of similar-height elements filling the bottom of
242
+ * the screen — keys. Returns null when nothing looks like one, which is the
243
+ * common case and must stay cheap. This was the first band derived from the
244
+ * elements rather than from a fraction, and it is the model the rest now follow.
245
+ */
246
+ export function detectKeyboardTop(elements, screen) {
247
+ if (!screen?.height || elements.length < 12) return null;
248
+ const threshold = screen.height * (1 - KEYBOARD_MIN_FRACTION);
249
+ const low = elements.filter((e) => e.frame && e.frame.y > threshold);
250
+ if (low.length < 12) return null;
251
+ const heights = low.map((e) => heightOf(e.frame)).sort((a, b) => a - b);
252
+ const median = heights[heights.length >> 1];
253
+ // Keys are small and uniform; a list of cells down there is not.
254
+ const uniform = heights.filter((h) => Math.abs(h - median) <= Math.max(3, median * 0.4)).length;
255
+ if (uniform / low.length < 0.7 || median > screen.height * 0.07) return null;
256
+ return Math.min(...low.map((e) => e.frame.y));
257
+ }
258
+
259
+ /** Annotate a target list with region and nav slot. Mutates and returns it. */
260
+ export function annotate(targets, screen) {
261
+ const band = bands(targets, screen);
262
+ for (const t of targets) {
263
+ t.region = regionFor(t.frame, screen, band);
264
+ if (t.region === 'nav-bar') t.navSlot = navSlot(t.frame, screen);
265
+ }
266
+ return targets;
267
+ }