feat(ui): double-click a step to start the picture there; double-click a screen for what happens on it

- StepsView / StepNode: a double-click on any step with a symbol re-anchors the Steps picture on it (the panel's Start here) — an endpoint or another screen drawn as a boundary opens as its own chapter in one gesture; detected in the view's click path (two clicks on one box within 400 ms) since the flow canvas does not reliably pass dblclick on, with ondblclick kept
- ScreensView / ScreenNode: a double-click on a screen (or an origin) opens its Steps picture
- a boundary's panel says it is not entered instead of "nothing leaves this step"; tooltips and the boundary notes mention the gesture
- spec §3.13, CHANGELOG

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REFyW9hmNrxhwN5wxRoAkC
This commit is contained in:
Colby McHenry
2026-08-29 11:09:09 -05:00
co-authored by Claude Fable 5
parent dff4e50e98
commit 435a7fd37a
6 changed files with 64 additions and 9 deletions
+2
View File
@@ -16,6 +16,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- **A Next.js app lands on the Screens tab like a mobile app.** App Router pages (`app/(group)/blog/[slug]/page.tsx``/blog/:slug`) and Pages Router pages are screens bound to the component they export; `<Link href>`, an internal `<a href>`, `router.push` / `router.replace` (`next/navigation` and `next/router`), `redirect()` / `permanentRedirect()` in a server action or a page, and the middleware's `NextResponse.redirect(new URL('/login', req.url))` are the transitions between them — each attributed back to the page it starts on with the plumbing folded and the condition on the arrow, a link written in markup drawn dashed as an inferred hop. `app/api/**/route.ts` exports (`GET`, `POST`, …) are endpoints bound to their functions, `pages/api/*` handlers are `ANY /api/…`, and a page's Steps picture fires from its load (`FIRES FROM page load · /users`), draws the data it reads, the handlers it wires, the server actions it crosses to and the pages it leads to as boundaries. A response's status written as `{ status: 201 }` is read too. Re-index after upgrading. - **A Next.js app lands on the Screens tab like a mobile app.** App Router pages (`app/(group)/blog/[slug]/page.tsx``/blog/:slug`) and Pages Router pages are screens bound to the component they export; `<Link href>`, an internal `<a href>`, `router.push` / `router.replace` (`next/navigation` and `next/router`), `redirect()` / `permanentRedirect()` in a server action or a page, and the middleware's `NextResponse.redirect(new URL('/login', req.url))` are the transitions between them — each attributed back to the page it starts on with the plumbing folded and the condition on the arrow, a link written in markup drawn dashed as an inferred hop. `app/api/**/route.ts` exports (`GET`, `POST`, …) are endpoints bound to their functions, `pages/api/*` handlers are `ANY /api/…`, and a page's Steps picture fires from its load (`FIRES FROM page load · /users`), draws the data it reads, the handlers it wires, the server actions it crosses to and the pages it leads to as boundaries. A response's status written as `{ status: 201 }` is read too. Re-index after upgrading.
- **Double-click a box to go there.** On the Steps tab a double-click on any step starts the picture from it — the same as the panel's *Start here* — so an endpoint the page calls, or another screen drawn as a boundary, opens as its own chapter in one gesture; on the Screens tab a double-click on a screen opens what happens from it. A boundary's panel now says it is not entered rather than that nothing leaves it.
- **The Steps tab follows a web app across its tiers.** A page's `fetch('/api/users', { method: 'POST' })` (or an `axios` / `ky` / `got` / `$fetch` call, including one through a project instance made with `axios.create({ baseURL })`) now reaches the route that serves it in the same index — drawn as a crossing to the server (`⇢ POST /api/users`) with the handler named on the box and the registration site in the panel, a boundary by default and entered with *Continue through*, so the picture reads page → handler → the endpoint → its database write → its response. A job put on a BullMQ / Bull queue lands on the `@Process` method, `Worker` or `queue.process` handler that consumes it; a NestJS `EventEmitter2` event lands on its `@OnEvent` listeners (globs included); a socket message crosses from a client's `socket.emit` to the gateway's `@SubscribeMessage` and back from the server's `emit` to the component that registered `socket.on`; and a Next.js server action called from a client file is a crossing to the server by its `'use server'` directive. Each of these is a synthesized hop — dashed, with where it was wired up — and `codegraph_explore`'s Flow section names them too. Only a literal path or event name pairs: a variable url, a path no route serves, or one two routes serve alike produce nothing. Re-index to pick the new edges up. - **The Steps tab follows a web app across its tiers.** A page's `fetch('/api/users', { method: 'POST' })` (or an `axios` / `ky` / `got` / `$fetch` call, including one through a project instance made with `axios.create({ baseURL })`) now reaches the route that serves it in the same index — drawn as a crossing to the server (`⇢ POST /api/users`) with the handler named on the box and the registration site in the panel, a boundary by default and entered with *Continue through*, so the picture reads page → handler → the endpoint → its database write → its response. A job put on a BullMQ / Bull queue lands on the `@Process` method, `Worker` or `queue.process` handler that consumes it; a NestJS `EventEmitter2` event lands on its `@OnEvent` listeners (globs included); a socket message crosses from a client's `socket.emit` to the gateway's `@SubscribeMessage` and back from the server's `emit` to the component that registered `socket.on`; and a Next.js server action called from a client file is a crossing to the server by its `'use server'` directive. Each of these is a synthesized hop — dashed, with where it was wired up — and `codegraph_explore`'s Flow section names them too. Only a literal path or event name pairs: a variable url, a path no route serves, or one two routes serve alike produce nothing. Re-index to pick the new edges up.
- **The Steps tab now draws an API as well as an app.** Anchor on an endpoint — `POST /users` in Express, NestJS, Fastify, FastAPI, Flask, Django, Spring (Java or Kotlin), ASP.NET, Vapor or Gin — and the viewer starts at the handler the route runs (or at the route itself when the handler is an inline arrow), says what fires it (`FIRES FROM POST /users · after authenticate, validate(…)` — the middleware arguments at the registration, or the guard decorators on the method and its class, or a FastAPI `dependencies=[…]`), and draws what the request sets in motion: the database calls with the model and whether they read or write (`prisma.user.create({ data })` · `database · user · write`), jobs put on a queue, emails, payments, cache reads, token checks, calls to other services, files and processes — and the **responses**, one box per handler whose label is the status codes it can send (`201 · 404`) and whose panel rows are the endpoint's contract as the code has it: `WHEN NOT user → 404 · NotFoundException('no such user')`, `always → 201 · res.status(201).json(user)`. A queue consumer or a scheduled job anchored by name says the decorator that fires it (`@Process('email')`). The legend, the panel and the chooser use the project's own words — endpoint, data call, another tier — and the bare Steps tab lists an API's endpoints by router file when there are no screens. Re-index is not needed: everything new is read from the source at request time. - **The Steps tab now draws an API as well as an app.** Anchor on an endpoint — `POST /users` in Express, NestJS, Fastify, FastAPI, Flask, Django, Spring (Java or Kotlin), ASP.NET, Vapor or Gin — and the viewer starts at the handler the route runs (or at the route itself when the handler is an inline arrow), says what fires it (`FIRES FROM POST /users · after authenticate, validate(…)` — the middleware arguments at the registration, or the guard decorators on the method and its class, or a FastAPI `dependencies=[…]`), and draws what the request sets in motion: the database calls with the model and whether they read or write (`prisma.user.create({ data })` · `database · user · write`), jobs put on a queue, emails, payments, cache reads, token checks, calls to other services, files and processes — and the **responses**, one box per handler whose label is the status codes it can send (`201 · 404`) and whose panel rows are the endpoint's contract as the code has it: `WHEN NOT user → 404 · NotFoundException('no such user')`, `always → 201 · res.status(201).json(user)`. A queue consumer or a scheduled job anchored by name says the decorator that fires it (`@Process('email')`). The legend, the panel and the chooser use the project's own words — endpoint, data call, another tier — and the bare Steps tab lists an API's endpoints by router file when there are no screens. Re-index is not needed: everything new is read from the source at request time.
+3 -1
View File
@@ -553,7 +553,9 @@ the §3.12 screen box for a screen or a handler; **bridge / event** add a 3px `-
changes under the code) and lead with `⇢` / `⇠ <event name>`; **store** sits on `--paper-2`; **effect** is dashed changes under the code) and lead with `⇢` / `⇠ <event name>`; **store** sits on `--paper-2`; **effect** is dashed
`--ink-3` (a place the graph cannot follow into), labelled by the API (`client.post`) over `category · caller`. Edges, `--ink-3` (a place the graph cannot follow into), labelled by the API (`client.post`) over `category · caller`. Edges,
pills, tooltip and the panel's hover contract are §3.12's verbatim; the panel adds *Start here →* (re-anchor) on any pills, tooltip and the panel's hover contract are §3.12's verbatim; the panel adds *Start here →* (re-anchor) on any
step with a symbol, *Open as a flow →* on any link whose ends are both symbols (`#/flow?from=&to=`), a depth `<select>` step with a symbol — and a **double-click on a step's box does the same**, so an endpoint or another screen reached as a
boundary opens as its own chapter without a trip to the panel (a double-click on a Screens-tab box opens that screen's
Steps picture likewise) — *Open as a flow →* on any link whose ends are both symbols (`#/flow?from=&to=`), a depth `<select>`
(412) that rewrites the URL, per-kind counts, and the `truncated` notes. The bare tab (`#/steps`) is a chooser: the (412) that rewrites the URL, per-kind counts, and the `truncated` notes. The bare tab (`#/steps`) is a chooser: the
project's screens by connectivity, else its endpoints by router file, or a hint to search. A picture of at most 24 boxes project's screens by connectivity, else its endpoints by router file, or a hint to search. A picture of at most 24 boxes
is fitted to the right of the key (a per-side `fitView` padding) so its second row never sits under the legend; a larger is fitted to the right of the key (a per-side `fitView` padding) so its second row never sits under the legend; a larger
+5 -2
View File
@@ -25,6 +25,8 @@
selected: boolean; selected: boolean;
dimmed: boolean; dimmed: boolean;
onSelect: (id: string) => void; onSelect: (id: string) => void;
/** Open what happens from this screen (the Steps picture) — a double-click. */
onOpen?: (id: string) => void;
} }
); );
const layout = $derived(node.layout); const layout = $derived(node.layout);
@@ -54,12 +56,13 @@
class:unreached={info.unreached} class:unreached={info.unreached}
style={`width:${layout.width}px;height:${layout.height}px`} style={`width:${layout.width}px;height:${layout.height}px`}
onclick={() => node.onSelect(info.id)} onclick={() => node.onSelect(info.id)}
ondblclick={() => node.onOpen?.(info.id)}
aria-pressed={node.selected} aria-pressed={node.selected}
title={info.origin title={(info.origin
? `${info.label} — navigates, but no screen reaches it within the walk. In ${info.sub}.` ? `${info.label} — navigates, but no screen reaches it within the walk. In ${info.sub}.`
: `${info.label} — rendered by ${info.sub}${info.entry ? '. The entry screen.' : ''}${ : `${info.label} — rendered by ${info.sub}${info.entry ? '. The entry screen.' : ''}${
info.unreached ? '. No transition in the graph reaches it from the entry.' : '' info.unreached ? '. No transition in the graph reaches it from the entry.' : ''
}`} }`) + (node.onOpen ? ' Double-click for what happens here.' : '')}
> >
<span class="name">{#if info.entry}<span class="mark" aria-hidden="true"></span>{/if}{info.label}</span> <span class="name">{#if info.entry}<span class="mark" aria-hidden="true"></span>{/if}{info.label}</span>
<span class="sub">{info.sub}</span> <span class="sub">{info.sub}</span>
+7 -2
View File
@@ -12,7 +12,9 @@
* ellipsis, and its tooltip says which cap. * ellipsis, and its tooltip says which cap.
* *
* Hidden handles along the top and bottom, one per port the layout decided * Hidden handles along the top and bottom, one per port the layout decided
* (`directional` ports), exactly as the screen box. * (`directional` ports), exactly as the screen box. A click selects the step;
* a double-click starts the picture there (the panel's *Start here →*) — an
* endpoint or another screen reached as a boundary opens as its own chapter.
*/ */
import { Handle, Position, type NodeProps } from '@xyflow/svelte'; import { Handle, Position, type NodeProps } from '@xyflow/svelte';
import type { MapNodeLayout } from '../../lib/map-model'; import type { MapNodeLayout } from '../../lib/map-model';
@@ -28,6 +30,8 @@
selected: boolean; selected: boolean;
dimmed: boolean; dimmed: boolean;
onSelect: (id: string) => void; onSelect: (id: string) => void;
/** Re-anchor the picture on this step — a double-click; absent for a step with no symbol. */
onStart?: (id: string) => void;
} }
); );
const layout = $derived(node.layout); const layout = $derived(node.layout);
@@ -75,8 +79,9 @@
class:anchor={step.anchor} class:anchor={step.anchor}
style={`width:${layout.width}px;height:${layout.height}px`} style={`width:${layout.width}px;height:${layout.height}px`}
onclick={() => node.onSelect(info.id)} onclick={() => node.onSelect(info.id)}
ondblclick={() => node.onStart?.(info.id)}
aria-pressed={node.selected} aria-pressed={node.selected}
title={`${info.label}${step.anchor ? 'where this picture starts; ' : ''}${kindWord(step.kind, node.project, step)}. ${info.sub}.${cutNote}`} title={`${info.label}${step.anchor ? 'where this picture starts; ' : ''}${kindWord(step.kind, node.project, step)}. ${info.sub}.${cutNote}${node.onStart && !step.anchor ? ' Double-click to start here.' : ''}`}
> >
<span class="name" <span class="name"
>{#if step.anchor}<span class="mark" aria-hidden="true"></span>{/if}{info.label}{#if step.cut !== null}<span >{#if step.anchor}<span class="mark" aria-hidden="true"></span>{/if}{info.label}{#if step.cut !== null}<span
+16 -1
View File
@@ -24,7 +24,7 @@
import KindGlyph from '../components/KindGlyph.svelte'; import KindGlyph from '../components/KindGlyph.svelte';
import { fetchScreens, type WireScreensPayload, type WireScreenLink } from '../lib/api'; import { fetchScreens, type WireScreensPayload, type WireScreenLink } from '../lib/api';
import { live } from '../lib/live.svelte'; import { live } from '../lib/live.svelte';
import { symbolHref, fileHref, stepsHref } from '../lib/navigation'; import { symbolHref, fileHref, navigate, stepsHref } from '../lib/navigation';
import { isEdgeVisible, type MapEdgeLayout } from '../lib/map-model'; import { isEdgeVisible, type MapEdgeLayout } from '../lib/map-model';
import { commonTokens, conditionTokens, restTokens, scenarios, whenWords, type WordToken } from '../lib/conditions'; import { commonTokens, conditionTokens, restTokens, scenarios, whenWords, type WordToken } from '../lib/conditions';
import { import {
@@ -71,6 +71,9 @@
}); });
const FIT = { fitViewOptions: { padding: 0.1, maxZoom: 1, minZoom: 0.4 } }; const FIT = { fitViewOptions: { padding: 0.1, maxZoom: 1, minZoom: 0.4 } };
/** Two clicks on one box closer than this are a double-click. */
const DOUBLE_CLICK_MS = 400;
let lastClick: { id: string; at: number } | null = null;
const nodeTypes = { screen: ScreenNode }; const nodeTypes = { screen: ScreenNode };
const edgeTypes = { screen: ScreenEdge }; const edgeTypes = { screen: ScreenEdge };
@@ -133,10 +136,22 @@
selected: selected === node.id, selected: selected === node.id,
dimmed: neighbours !== null && !neighbours.has(node.id), dimmed: neighbours !== null && !neighbours.has(node.id),
onSelect: (id: string) => { onSelect: (id: string) => {
// Two clicks on the same box within a beat are a double-click: what
// happens from here. Read here rather than off the DOM's `dblclick`,
// which the flow canvas does not always pass on.
const now = performance.now();
if (lastClick !== null && lastClick.id === id && now - lastClick.at < DOUBLE_CLICK_MS) {
lastClick = null;
navigate(stepsHref({ anchor: id }));
return;
}
lastClick = { id, at: now };
selected = selected === id ? null : id; selected = selected === id ? null : id;
hovered = null; hovered = null;
panelHot = null; panelHot = null;
}, },
// Double-click: what happens from here — the screen's (or an origin's) Steps picture.
onOpen: (id: string) => navigate(stepsHref({ anchor: id })),
}, },
})); }));
}); });
+31 -3
View File
@@ -118,6 +118,17 @@
: { padding: 0.1, maxZoom: 1, minZoom: 0.4 } : { padding: 0.1, maxZoom: 1, minZoom: 0.4 }
); );
const nodeTypes = { step: StepNode }; const nodeTypes = { step: StepNode };
/** Two clicks on one box closer than this are a double-click. */
const DOUBLE_CLICK_MS = 400;
let lastClick: { id: string; at: number } | null = null;
/** Start the picture at a step — the panel's *Start here →*. False for a step with no symbol, or the anchor. */
function startHere(id: string): boolean {
const step = model?.nodes.get(id)?.step;
if (!step || !step.node || step.anchor) return false;
navigate(stepsHref({ anchor: step.node.id }));
return true;
}
const edgeTypes = { screen: ScreenEdge }; const edgeTypes = { screen: ScreenEdge };
const DEPTHS = [4, 6, 8, 10, 12]; const DEPTHS = [4, 6, 8, 10, 12];
@@ -208,10 +219,23 @@
selected: selected === node.id, selected: selected === node.id,
dimmed: neighbours !== null && !neighbours.has(node.id), dimmed: neighbours !== null && !neighbours.has(node.id),
onSelect: (id: string) => { onSelect: (id: string) => {
// Two clicks on the same box within a beat are a double-click:
// the picture starts there. Read here rather than off the DOM's
// `dblclick`, which the flow canvas does not always pass on.
const now = performance.now();
if (lastClick !== null && lastClick.id === id && now - lastClick.at < DOUBLE_CLICK_MS) {
lastClick = null;
if (startHere(id)) return;
}
lastClick = { id, at: now };
selected = selected === id ? null : id; selected = selected === id ? null : id;
hovered = null; hovered = null;
panelHot = null; panelHot = null;
}, },
// Double-click: the picture starts here — an endpoint or another
// screen drawn as a boundary opens as its own chapter. An effect has
// no symbol to start from.
...(model.nodes.get(node.id)?.step.node && !model.nodes.get(node.id)?.step.anchor ? { onStart: startHere } : {}),
}, },
})); }));
}); });
@@ -593,9 +617,9 @@
<button class="clear" onclick={() => (selected = null)}>clear</button> <button class="clear" onclick={() => (selected = null)}>clear</button>
</div> </div>
{#if selectedInfo.step.cut === 'screen'} {#if selectedInfo.step.cut === 'screen'}
<p class="dim note">Another {kindWord('screen', payload.project, selectedInfo.step)} — a chapter of its own. Start here to see what happens on it, or continue through {kindWords('screen', payload.project)[1]} from the summary.</p> <p class="dim note">Another {kindWord('screen', payload.project, selectedInfo.step)} — a chapter of its own. Start here (or double-click its box) to see what happens on it, or continue through {kindWords('screen', payload.project)[1]} from the summary.</p>
{:else if selectedInfo.step.cut === 'component'} {:else if selectedInfo.step.cut === 'component'}
<p class="dim note">The event lands in a component of another screen — a picture of its own. Start here to see it, or continue through screens from the summary.</p> <p class="dim note">The event lands in a component of another screen — a picture of its own. Start here (or double-click its box) to see it, or continue through screens from the summary.</p>
{:else if selectedInfo.step.cut !== null} {:else if selectedInfo.step.cut !== null}
<p class="dim note"> <p class="dim note">
The walk was cut at this step ({selectedInfo.step.cut === 'depth' The walk was cut at this step ({selectedInfo.step.cut === 'depth'
@@ -666,7 +690,11 @@
<h4>Leads to <span class="dim">{lists.leadsTo.length}</span></h4> <h4>Leads to <span class="dim">{lists.leadsTo.length}</span></h4>
{#if lists.leadsTo.length === 0} {#if lists.leadsTo.length === 0}
<p class="dim"> <p class="dim">
{selectedInfo.step.kind === 'effect' ? 'Outside the index: the graph cannot follow it further.' : 'Nothing the walk follows leaves this step.'} {selectedInfo.step.kind === 'effect'
? 'Outside the index: the graph cannot follow it further.'
: selectedInfo.step.cut === 'screen' || selectedInfo.step.cut === 'component'
? 'Not entered — a boundary. Start here for its own picture, or continue through from the summary.'
: 'Nothing the walk follows leaves this step.'}
</p> </p>
{/if} {/if}
{#each lists.leadsTo as link (link.id)} {#each lists.leadsTo as link (link.id)}