feat(expo-router): add Expo Router support for Screens and navigations and introduce Steps API

- Adds Expo Router integration with a new Screens view and a Steps API to surface screens and their transitions.
- Extends codegraph extraction/resolution to handle namespace objects, React hook bindings for handlers, and Swift RN bridge evidence; introduces per-site guard arguments and trigger metadata, enabling richer flow analysis across JS ↔ native boundaries.
- Introduces UI and data-model changes to represent conditions as words (WHEN/AND/OR/NOT), display per-site call arguments, and show what fires a site (triggers). Adds new utilities (ui/conditions.ts) and updates ScreensView and StepsView to render scenarios with multiple sites and “ways” counts.
- Implements site readers for WHEN/ARGS/TRIGGER, and wiring to expose steps via API endpoints (including /api/steps); enhances tests to cover namespace resolution, useCallback-driven handlers, and inline RN event listeners.
- Updates styling and templates to reflect the new wording, scenario rows, and per-site details, including NOT instead of leading negation strings and multi-way links.
- Documents and reflects changes in changelog and design docs to describe Expo Router integration and the Steps surface.
This commit is contained in:
Colby McHenry
2026-08-28 10:21:46 -05:00
parent 873f133c96
commit e288d7645b
16 changed files with 1028 additions and 127 deletions
+13 -50
View File
@@ -29,6 +29,7 @@
*/
import type { WireMapLink, WireMapModule, WireScreen, WireScreenLink, WireScreensPayload } from './wire';
import { clauseWords, clauses } from './conditions';
import {
buildMapLayout,
linkId,
@@ -72,9 +73,10 @@ const BAND_MARGIN = 2;
/**
* The longest label a pill prints before an ellipsis; the tooltip and the
* panel have the rest. Sized so the innermost clause of a typical guard
* (`guide.dontShowAgain.captureGuide`, 32 characters) fits whole.
* (`guide.dontShowAgain.captureGuide`, 32 characters) fits whole even as
* `…not guide.dontShowAgain.captureGuide` — the negation is a word now.
*/
export const EDGE_LABEL_MAX = 36;
export const EDGE_LABEL_MAX = 40;
/* ---------------------------------------------------------------- model -- */
@@ -246,48 +248,7 @@ export function entryLayering(
/* --------------------------------------------------------------- labels -- */
/**
* The top-level `&&` terms of a condition, in the order they were tested —
* the outermost guard first, the one decided at the navigation call last.
* Brackets and strings are respected; a condition joined by a top-level `||`
* (two transitions between one pair that merged) has no innermost term and
* comes back whole.
*/
export function clauses(when: string): string[] {
const out: string[] = [];
let depth = 0;
let quote: string | null = null;
let start = 0;
for (let i = 0; i < when.length; i++) {
const ch = when[i]!;
if (quote !== null) {
if (ch === '\\') i++;
else if (ch === quote) quote = null;
continue;
}
if (ch === "'" || ch === '"' || ch === '`') {
quote = ch;
continue;
}
if (ch === '(' || ch === '[' || ch === '{') {
depth++;
continue;
}
if (ch === ')' || ch === ']' || ch === '}') {
depth = Math.max(0, depth - 1);
continue;
}
if (depth !== 0) continue;
if (when.startsWith(' || ', i)) return [when.trim()];
if (when.startsWith(' && ', i)) {
out.push(when.slice(start, i).trim());
start = i + 4;
i += 3;
}
}
out.push(when.slice(start).trim());
return out.filter((c) => c.length > 0);
}
export { clauses } from './conditions';
/**
* What the connector says. Empty when unconditional and single.
@@ -299,17 +260,19 @@ export function clauses(when: string): string[] {
* …` on both arms of a fork); the last clause is the one that tells the two
* apart, and the full text is a hover away.
*/
export function edgeLabel(links: ReadonlyArray<{ when: string }>): string {
if (links.length === 1) {
const when = links[0]!.when;
export function edgeLabel(links: ReadonlyArray<{ when: string; sites?: ReadonlyArray<{ when: string }> }>): string {
// A link with several call sites is several scenarios: count them as ways.
const ways = links.flatMap((l) => (l.sites && l.sites.length > 1 ? l.sites.map((s) => s.when) : [l.when]));
if (ways.length === 1) {
const when = ways[0]!;
if (!when) return '';
const parts = clauses(when);
const last = parts[parts.length - 1] ?? when;
const last = clauseWords(parts[parts.length - 1] ?? when);
const text = parts.length > 1 ? `${last}` : last;
return text.length > EDGE_LABEL_MAX ? `${text.slice(0, EDGE_LABEL_MAX - 1)}` : text;
}
const conditional = links.filter((l) => l.when).length;
return conditional > 0 ? `${links.length} ways · ${conditional} conditional` : `${links.length} ways`;
const conditional = ways.filter((w) => w).length;
return conditional > 0 ? `${ways.length} ways · ${conditional} conditional` : `${ways.length} ways`;
}
/* ---------------------------------------------------------------- build -- */