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

Introduce Expo Router integration by adding a new Screens view and API to surface screens and their transitions. Implement a map-based layout with directional ports, extended layering and port pitch to accommodate edge labels, and a pill-based labeling system for transition conditions. Include tests for the new map/screens models, updates to the UI components, and design/docs changes reflecting the Screens design. Merge CodeGraph UI viewer changes to render and interact with Expo Router-based screen graphs. This enables CodeGraph UI to surface screens and navigations from Expo Router apps.
This commit is contained in:
Colby McHenry
2026-08-28 00:19:46 -05:00
parent 70fd5fefc2
commit f0eafe31f9
10 changed files with 1893 additions and 194 deletions
+74
View File
@@ -24,6 +24,7 @@ import {
linkId,
moduleMetaLabel,
nodeWidth,
portPoint,
strokeWidthFor,
LAYER_GAP,
MIN_WEIGHT,
@@ -399,3 +400,76 @@ describe('empty and degenerate inputs', () => {
expect(layout.layers[0]!.label).toBeNull();
});
});
describe('directional ports and room', () => {
const modules = [mod('src/a'), mod('src/b')];
it('draws the Map exactly as before: bottoms leave, tops arrive, every link runs down', () => {
const layout = buildMapLayout(
{ modules: [mod('src/bin'), mod('src/core'), mod('src/db')], links: [link('src/bin', 'src/core', 10), link('src/core', 'src/db', 10)] },
OPTS
);
for (const node of layout.nodes) {
expect(node.ports.bottom.map((p) => p.id)).toEqual(node.sourceHandles);
expect(node.ports.top.map((p) => p.id)).toEqual(node.targetHandles);
expect(node.ports.bottom.every((p) => p.type === 'source')).toBe(true);
expect(node.ports.top.every((p) => p.type === 'target')).toBe(true);
}
expect(layout.edges.every((e) => e.route === 'down')).toBe(true);
});
it('keeps a back-edge bottom-to-top under layered ports, and top-to-bottom under directional ones', () => {
const payload = { modules, links: [link('src/a', 'src/b', 10), link('src/b', 'src/a', 2)] };
const layered = buildMapLayout(payload, OPTS);
const directional = buildMapLayout(payload, { ...OPTS, ports: 'directional' });
for (const layout of [layered, directional]) {
const back = layout.edges.find((e) => e.source === 'src/b')!;
expect(back.route).toBe('up');
expect(back.back).toBe(true);
}
const a = (l: MapLayout) => l.nodes.find((n) => n.id === 'src/a')!;
const b = (l: MapLayout) => l.nodes.find((n) => n.id === 'src/b')!;
const id = linkId({ source: 'src/b', target: 'src/a' });
// The Map: leaves b's bottom, arrives at a's top — through both boxes, as it always has.
expect(portPoint(b(layered), id, 'source').y).toBe(b(layered).y + NODE_HEIGHT);
expect(portPoint(a(layered), id, 'target').y).toBe(a(layered).y);
// Directional: leaves b's top, arrives at a's bottom — around them.
expect(portPoint(b(directional), id, 'source').y).toBe(b(directional).y);
expect(portPoint(a(directional), id, 'target').y).toBe(a(directional).y + NODE_HEIGHT);
});
it('joins two modules on one layer over the top, under directional ports', () => {
const layout = buildMapLayout(
{ modules, links: [link('src/a', 'src/b', 10)] },
{ ...OPTS, ports: 'directional', layering: (ids) => new Map(ids.map((id) => [id, 0])) }
);
const edge = layout.edges[0]!;
expect(edge.route).toBe('level');
const a = layout.nodes.find((n) => n.id === 'src/a')!;
const b = layout.nodes.find((n) => n.id === 'src/b')!;
expect(portPoint(a, edge.id, 'source').y).toBe(a.y);
expect(portPoint(b, edge.id, 'target').y).toBe(b.y);
});
it('widens a box to keep its ports apart, and only then', () => {
const leaves = Array.from({ length: 20 }, (_, i) => mod(`src/leaf${i}`));
const payload = { modules: [mod('src/hub'), ...leaves], links: leaves.map((l) => link('src/hub', l.id, 5)) };
const plain = buildMapLayout(payload, OPTS).nodes.find((n) => n.id === 'src/hub')!;
const pitched = buildMapLayout(payload, { ...OPTS, portPitch: 12 }).nodes.find((n) => n.id === 'src/hub')!;
// Nothing depends on the hub, so its second line is the island's.
expect(plain.width).toBe(nodeWidth('src/hub', moduleMetaLabel(mod('src/hub'), true)));
expect(pitched.width).toBe(Math.max(plain.width, 21 * 12));
expect(pitched.width).toBeGreaterThan(plain.width);
});
it('spaces layers by the gap a view asks for', () => {
const payload = { modules, links: [link('src/a', 'src/b', 10)] };
const wide = buildMapLayout(payload, { ...OPTS, layerGap: 116 });
const a = wide.nodes.find((n) => n.id === 'src/a')!;
const b = wide.nodes.find((n) => n.id === 'src/b')!;
expect(b.y - a.y).toBe(NODE_HEIGHT + 116);
expect(wide.height).toBe(buildMapLayout(payload, OPTS).height + (116 - LAYER_GAP));
// Layer 0 is the bottom, so it has the larger y.
expect(wide.layers[0]!.y - wide.layers[1]!.y).toBe(NODE_HEIGHT + 116);
});
});