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
+188
View File
@@ -0,0 +1,188 @@
/**
* Conditions, as a reader says them.
*
* A `when` arrives from the graph as code joined by our own operators: the
* guards along a chain joined with ` && `, a negated one wrapped as `!(…)`,
* and — on a link with several call sites — the sites' conditions joined
* with ` || `. The code inside one guard stays code (`isUploadInProgress ||
* elapsed < 5000` is what the source says); the joins are ours, and ours read
* as words — WHEN, AND, OR, NOT — set in capitals and a little bolder.
*
* A link with several sites is several scenarios, not one long condition:
* four early returns that each go home are four rows, and the clauses every
* row shares — the same first guard on all four — are said once above them.
*/
/**
* The top-level terms of `text` around `sep`, respecting brackets and
* strings. `splitTop('a && (b || c)', ' && ')` → `['a', '(b || c)']`.
*/
export function splitTop(text: string, sep: ' && ' | ' || '): string[] {
const out: string[] = [];
let depth = 0;
let quote: string | null = null;
let start = 0;
for (let i = 0; i < text.length; i++) {
const ch = text[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 && text.startsWith(sep, i)) {
out.push(text.slice(start, i).trim());
start = i + sep.length;
i += sep.length - 1;
}
}
out.push(text.slice(start).trim());
return out.filter((c) => c.length > 0);
}
/**
* The top-level `&&` terms of a condition, in the order they were tested —
* the outermost guard first, the one decided at the call last. A condition
* joined by a top-level `||` (several scenarios merged) has no single
* innermost term and comes back whole.
*/
export function clauses(when: string): string[] {
if (splitTop(when, ' || ').length > 1) return [when.trim()];
return splitTop(when, ' && ');
}
/**
* One word of a condition: a keyword we add (WHEN, AND, OR, NOT — set in
* capitals and a little bolder, so the joins read at a glance and the code
* between them reads as code), or a run of the code itself.
*/
export type WordToken = { kw: true; text: 'WHEN' | 'AND' | 'OR' | 'NOT' } | { kw: false; text: string };
const KW = (text: 'WHEN' | 'AND' | 'OR' | 'NOT'): WordToken => ({ kw: true, text });
const CODE = (text: string): WordToken => ({ kw: false, text });
/** `!(a || b)` → NOT `(a || b)`; `!busy` → NOT `busy`; code otherwise untouched. */
export function clauseTokens(clause: string): WordToken[] {
const text = clause.trim();
if (text.startsWith('!(') && closesAtEnd(text, 1)) return [KW('NOT'), CODE(text.slice(1))];
if (/^![A-Za-z_$][\w$.?]*$/.test(text)) return [KW('NOT'), CODE(text.slice(1))];
return [CODE(text)];
}
/** {@link clauseTokens} as one string — for a pill, which has no markup. */
export function clauseWords(clause: string): string {
return joinTokens(clauseTokens(clause));
}
/** Tokens joined by a keyword: `a AND b AND c`. */
function joinWith(groups: readonly WordToken[][], kw: 'AND' | 'OR'): WordToken[] {
const out: WordToken[] = [];
groups.forEach((g, i) => {
if (i > 0) out.push(KW(kw));
out.push(...g);
});
return out;
}
export function joinTokens(tokens: readonly WordToken[]): string {
return tokens.map((t) => t.text).join(' ');
}
/** Whether the bracket opened at `open` closes on the last character. */
function closesAtEnd(text: string, open: number): boolean {
let depth = 0;
let quote: string | null = null;
for (let i = open; i < text.length; i++) {
const ch = text[i]!;
if (quote !== null) {
if (ch === '\\') i++;
else if (ch === quote) quote = null;
continue;
}
if (ch === "'" || ch === '"' || ch === '`') quote = ch;
else if (ch === '(') depth++;
else if (ch === ')') {
depth--;
if (depth === 0) return i === text.length - 1;
}
}
return false;
}
/** The same clause tested twice along a chain (two early returns with one condition) is said once. */
function distinct(list: readonly string[]): string[] {
return list.filter((c, i) => list.indexOf(c) === i);
}
/** A whole `when` as tokens: scenarios joined by OR, each its guards joined by AND. Empty when unconditional. */
export function whenTokens(when: string): WordToken[] {
if (!when) return [];
return joinWith(
splitTop(when, ' || ').map((scenario) => joinWith(distinct(splitTop(scenario, ' && ')).map(clauseTokens), 'AND')),
'OR'
);
}
/** {@link whenTokens} led by WHEN, or `always` when there is nothing to say. */
export function conditionTokens(when: string): WordToken[] {
const tokens = whenTokens(when);
return tokens.length === 0 ? [CODE('always')] : [KW('WHEN'), ...tokens];
}
/** A whole `when` as one string — for a pill, a tooltip title, a test. */
export function whenWords(when: string): string {
return joinTokens(whenTokens(when));
}
/** The clauses every scenario shares, led by WHEN. */
export function commonTokens(common: readonly string[]): WordToken[] {
return common.length === 0 ? [] : [KW('WHEN'), ...joinWith(common.map(clauseTokens), 'AND')];
}
export interface ScenarioRows<T> {
/** The clauses every site shares, in chain order — said once. */
common: string[];
/** One row per site with what remains after the shared clauses; `rest` empty = always, given the shared ones. */
rows: Array<{ site: T; rest: string[] }>;
}
/**
* A link's sites as scenarios. One site: its whole condition is `common` and
* the one row has nothing left to say. Several: the longest common prefix of
* their clause lists is `common`, and each row keeps its own tail.
*/
export function scenarios<T extends { when: string }>(sites: readonly T[]): ScenarioRows<T> {
const lists = sites.map((site) => ({ site, all: site.when ? distinct(clauses(site.when)) : [] }));
if (lists.length === 0) return { common: [], rows: [] };
let common = lists[0]!.all.slice();
for (const { all } of lists.slice(1)) {
let i = 0;
while (i < common.length && i < all.length && common[i] === all[i]) i++;
common = common.slice(0, i);
}
return {
common,
rows: lists.map(({ site, all }) => ({ site, rest: all.slice(common.length) })),
};
}
/** The words a scenario row prints under a shared prefix: `AND x AND y` (`WHEN x` with no prefix), or `always`. */
export function restTokens(rest: readonly string[], hasCommon: boolean): WordToken[] {
if (rest.length === 0) return [CODE('always')];
return [KW(hasCommon ? 'AND' : 'WHEN'), ...joinWith(rest.map(clauseTokens), 'AND')];
}
export function restWords(rest: readonly string[], hasCommon: boolean): string {
return joinTokens(restTokens(rest, hasCommon));
}
+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 -- */
+5
View File
@@ -669,6 +669,7 @@ export interface WireScreenSite {
line: number;
href: string;
method: string;
/** The conditions THIS site runs under (the whole chain's plus its own); '' when unconditional. */
when: string;
}
@@ -705,6 +706,10 @@ export interface WireStepSite {
line: number;
/** `push /capture`, `calls`, `client.post` — what the site does, in a word or two. */
text: string;
/** What the site passes, abbreviated (`'userEmail', values.email`); '' for none; absent when unreadable. */
args?: string;
/** The conditions THIS site runs under (the whole chain's); '' when unconditional. */
when: string;
}
export interface WireStep {