feat(ui): Steps for servers — route roots, server effects, request/decorator triggers, guards for Python/Java/Kotlin/C#/Go/C

- api/route-roots.ts: the symbol a route runs (references-edge handler, exported page component, or the route itself for an inline handler), shared by steps and screens; the bare Steps tab lists an API's endpoints by router file
- api/effects.ts: database / response / queue / email / payments / cache / auth / process / network / storage / device / telemetry, matched on the call as written per language family, with model + read/write and the literal status on a response site
- graph/branch-guards.ts: callSitesForFile (the whole member chain), memberTypesInTree, decoratorsForFile, request/decorator triggers with the middleware/guard chain; guard + argument rules for Python, Java, Kotlin, C#, Go and C
- steps.ts: classify on the chain before trusting a name match, retarget this.x.y() by declared type, skip test doubles after the effect pre-check, project kind on the wire
- viewer: kindWord/kindWords per project kind, endpoint chooser, response boxes labelled by status codes
- python.ts: FastAPI detected from a monorepo sub-directory; is-test-file: samples/examples package paths are not tests
- tests: ui-steps-api-servers, ui-effects, branch-guards-languages; spec §3.13 Servers paragraph, CHANGELOG, plan doc

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-28 13:45:38 -05:00
co-authored by Claude Fable 5
parent 5e06204deb
commit 950686def4
22 changed files with 3869 additions and 190 deletions
+3 -2
View File
@@ -14,7 +14,7 @@
*/
import { Handle, Position, type NodeProps } from '@xyflow/svelte';
import type { MapNodeLayout } from '../../lib/map-model';
import { kindWord, type StepNodeInfo } from '../../lib/steps-model';
import { kindWord, type ProjectKind, type StepNodeInfo } from '../../lib/steps-model';
let { data }: NodeProps = $props();
@@ -22,6 +22,7 @@
data as unknown as {
layout: MapNodeLayout;
info: StepNodeInfo;
project: ProjectKind;
selected: boolean;
dimmed: boolean;
onSelect: (id: string) => void;
@@ -73,7 +74,7 @@
style={`width:${layout.width}px;height:${layout.height}px`}
onclick={() => node.onSelect(info.id)}
aria-pressed={node.selected}
title={`${info.label}${step.anchor ? 'where this picture starts; ' : ''}${kindWord(step.kind)}. ${info.sub}.${cutNote}`}
title={`${info.label}${step.anchor ? 'where this picture starts; ' : ''}${kindWord(step.kind, node.project, step)}. ${info.sub}.${cutNote}`}
>
<span class="name"
>{#if step.anchor}<span class="mark" aria-hidden="true"></span>{/if}{info.label}{#if step.cut !== null}<span
+46 -15
View File
@@ -63,36 +63,66 @@ const HIT_SAMPLES = 24;
/* ---------------------------------------------------------------- words -- */
/** A short word for a step's kind, as the panel and the legend say it. */
export function kindWord(kind: WireStep['kind']): string {
/** What the index is a picture of; the server decides it from the routes (`WireStepsPayload.project`). */
export type ProjectKind = WireStepsPayload['project'];
/**
* A short word for a step's kind, as the panel and the legend say it — in the
* project's own vocabulary. The same box is a screen in an app, a page in a
* web app and an endpoint in an API; a route that leads with an HTTP verb is
* an endpoint wherever it is. One place decides, so the legend, the panel
* and the tooltip never disagree.
*/
export function kindWord(kind: WireStep['kind'], project: ProjectKind = 'app', step?: WireStep): string {
return kindWords(kind, project, step)[0];
}
/** The singular and the plural, for counts: `1 endpoint`, `3 outside the index`. */
export function kindWords(kind: WireStep['kind'], project: ProjectKind = 'app', step?: WireStep): [string, string] {
switch (kind) {
case 'screen':
return 'screen';
if (step?.screen?.endpoint) return ['endpoint', 'endpoints'];
return project === 'api' ? ['endpoint', 'endpoints'] : project === 'web' ? ['page', 'pages'] : ['screen', 'screens'];
case 'trigger':
return 'handler';
return ['handler', 'handlers'];
case 'bridge':
return 'native call';
return project === 'app' ? ['native call', 'native calls'] : project === 'web' ? ['call to the server', 'calls to the server'] : ['call to another tier', 'calls to another tier'];
case 'event':
return 'native event';
return project === 'app' ? ['native event', 'native events'] : project === 'web' ? ['arrives from the server', 'arrive from the server'] : ['arrives from a queue or bus', 'arrive from a queue or bus'];
case 'store':
return 'store action';
return project === 'api' ? ['data call', 'data calls'] : ['store action', 'store actions'];
case 'effect':
return 'outside the index';
return ['outside the index', 'outside the index'];
default:
return 'start';
return ['start', 'start'];
}
}
/** `3 handlers`, `1 endpoint`, `11 outside the index`. */
export function countWords(n: number, kind: WireStep['kind'], project: ProjectKind = 'app'): string {
const [one, many] = kindWords(kind, project);
return `${n} ${n === 1 ? one : many}`;
}
/**
* What fires something, in a few characters: `onPress · <Button>`,
* `onSubmit · useFormik(…)`, `addListener('onZipComplete')`, `useEffect`.
* `onSubmit · useFormik(…)`, `addListener('onZipComplete')`, `useEffect`;
* for a server, `POST /users · after authenticate, validate(…)`,
* `@Process('email')`, `page load · /blog/[slug]`.
*/
export function triggerWords(t: WireStepTrigger): string {
const after = t.after && t.after.length > 0 ? ` · after ${t.after.join(', ')}` : '';
switch (t.kind) {
case 'prop':
return t.of ? `${t.name} · <${t.of}>` : t.name;
case 'option':
return t.of ? `${t.name} · ${t.of}(…)` : t.name;
case 'request':
return `${t.name} ${t.of ?? ''}`.trim() + after;
case 'decorator':
return `@${t.name}(${t.of ?? ''})` + after;
case 'load':
return `page load · ${t.of ?? t.name}` + after;
default:
return t.of ? `${t.name}(${t.of})` : t.name;
}
@@ -114,7 +144,7 @@ export function stepLabel(step: WireStep): string {
}
/** The second line: what the step is, then where it is. */
export function stepSub(step: WireStep): string {
export function stepSub(step: WireStep, project: ProjectKind = 'app'): string {
const file = step.node ? step.node.file.slice(step.node.file.lastIndexOf('/') + 1) : '';
switch (step.kind) {
case 'screen':
@@ -123,15 +153,16 @@ export function stepSub(step: WireStep): string {
// The event before the file: `onPress · <Button> · index.tsx`.
return step.trigger ? `${triggerWords(step.trigger)} · ${file}` : `handler · ${file}`;
case 'bridge':
return `native · ${file}`;
return `${project === 'app' ? 'native' : project === 'web' ? 'server' : 'another tier'} · ${file}`;
case 'event':
return `${step.label} · ${file}`;
case 'store':
return `store · ${file}`;
return `${project === 'api' ? 'data' : 'store'} · ${file}`;
case 'effect':
return step.sub;
default:
return step.sub;
// The anchor: its file, at the size of a box; the panel prints the whole path.
return step.node && step.sub === step.node.file ? file : step.sub;
}
}
@@ -156,7 +187,7 @@ export function buildStepsModel(payload: WireStepsPayload): StepsModel {
}
for (const step of payload.steps) {
counts[step.kind]++;
const info: StepNodeInfo = { id: step.id, step, label: stepLabel(step), sub: stepSub(step) };
const info: StepNodeInfo = { id: step.id, step, label: stepLabel(step), sub: stepSub(step, payload.project) };
nodes.set(step.id, info);
modules.push({
id: step.id,
+36 -5
View File
@@ -712,17 +712,28 @@ export interface WireStepSite {
when: string;
/** What fires THIS site, when it differs from the link's first. */
trigger?: WireStepTrigger;
/** For a response site: the status code it sends, when literal. */
status?: number;
}
/** What fires a step or a link: the event it is written under, and the function that writes it there. */
export interface WireStepTrigger {
kind: 'prop' | 'option' | 'callback';
/** `onPress`, `onSubmit`, `useEffect`, `addListener`. */
/**
* `prop` / `option` / `callback`: a binding at the call site (JSX attribute,
* `on*` key, runs-later argument). `request`: the route a handler serves —
* `name` the verb, `of` the path. `decorator`: a decorator on the handler —
* `name` its name, `of` its literal argument (`@Process('email')`). `load`:
* a page's own load-time work — `of` the page path.
*/
kind: 'prop' | 'option' | 'callback' | 'request' | 'decorator' | 'load';
/** `onPress`, `onSubmit`, `useEffect`, `addListener`, `POST`, `Process`. */
name: string;
/** `Button` for a prop, `useFormik` for an option, the first string argument for a callback; null when unknown. */
of: string | null;
/** The function the binding is written in. */
in: string;
/** What runs before it fires: the middleware / guard chain, in order (`authenticate`, `validate(…)`). */
after?: string[];
}
export interface WireStep {
@@ -748,9 +759,27 @@ export interface WireStep {
events?: string[];
/** For a handler: what fires it. */
trigger?: WireStepTrigger;
screen?: { path: string; component: WireNodeRef | null };
/** The calls one function makes into one category, and the function. */
effect?: { api: string; apis: string[]; category: string; by: WireNodeRef; line: number };
/**
* For a screen or an endpoint: its path and the symbol that serves it.
* `endpoint` when the route leads with an HTTP verb; `inline` when the
* handler is anonymous at the registration site (component is null).
*/
screen?: { path: string; component: WireNodeRef | null; endpoint: boolean; inline: boolean };
/**
* The calls one function makes into one category, and the function. A
* database call names its model / table and read vs write when the call
* says; a response box lists the status codes its sites send.
*/
effect?: {
api: string;
apis: string[];
category: string;
by: WireNodeRef;
line: number;
model?: string;
access?: 'read' | 'write';
statuses?: number[];
};
}
export interface WireStepLink {
@@ -775,6 +804,8 @@ export interface WireStepsPayload {
anchor: WireNodeRef;
/** Other symbols that share the anchor's name, when it was given by name. */
ambiguous: WireNodeRef[];
/** An `app` of screens, an `api` of endpoints, or a `web` app with both — the viewer's words follow it. */
project: 'app' | 'api' | 'web';
steps: WireStep[];
links: WireStepLink[];
depth: number;
+165 -44
View File
@@ -21,8 +21,10 @@
import KindGlyph from '../components/KindGlyph.svelte';
import {
canDrawSteps,
fetchRoutes,
fetchScreens,
fetchSteps,
type WireRoute,
type WireScreen,
type WireStepLink,
type WireStepsPayload,
@@ -35,6 +37,7 @@
import {
buildStepsModel,
kindWord,
kindWords,
stepNeighbourhood,
stepPairId,
stepViaText,
@@ -62,8 +65,30 @@
let viewport = $state<Viewport | undefined>(undefined);
const HOVER_REACH = 10;
/** The chooser's list, when the view opens without an anchor. */
/** The chooser's lists, when the view opens without an anchor: the screens of an app, else the endpoints of an API. */
let screens = $state<WireScreen[] | null>(null);
let routes = $state<WireRoute[] | null>(null);
/** What the chooser offers: null while reading. */
const chooser = $derived.by<'screens' | 'routes' | 'none' | null>(() => {
if (screens === null) return null;
if (screens.length > 0) return 'screens';
if (routes === null) return null;
return routes.length > 0 ? 'routes' : 'none';
});
/** Endpoints by the file they are registered in — the router file is how a reader groups them — biggest first, in registration order within. */
function routeGroups(list: WireRoute[]): Array<{ file: string; entries: WireRoute[] }> {
const byFile = new Map<string, WireRoute[]>();
for (const r of list) {
const group = byFile.get(r.routeFile) ?? [];
group.push(r);
byFile.set(r.routeFile, group);
}
return [...byFile]
.map(([file, entries]) => ({ file, entries: [...entries].sort((a, b) => a.routeLine - b.routeLine) }))
.sort((a, b) => b.entries.length - a.entries.length || a.file.localeCompare(b.file));
}
const LEGEND_KEY = 'codegraph-ui:steps-legend';
let legendOpen = $state(readLegendOpen());
@@ -82,7 +107,16 @@
}
});
const FIT = { fitViewOptions: { padding: 0.1, maxZoom: 1, minZoom: 0.4 } };
/**
* The fit. A picture of a few boxes is centred — and the key, bottom left,
* would sit on its second row; it is fitted to the right of the key instead.
* A picture of many boxes is fitted to the whole stage, as the Screens view's.
*/
const fitOptions = $derived(
model !== null && model.layout.nodes.length <= 24 && legendOpen
? { padding: { left: '440px', top: '32px', right: '32px', bottom: '32px' }, maxZoom: 1, minZoom: 0.4 }
: { padding: 0.1, maxZoom: 1, minZoom: 0.4 }
);
const nodeTypes = { step: StepNode };
const edgeTypes = { screen: ScreenEdge };
const DEPTHS = [4, 6, 8, 10, 12];
@@ -107,11 +141,19 @@
loading = false;
error = null;
fetchScreens(controller.signal)
.then((next) => {
.then(async (next) => {
screens = next.routed ? next.screens : [];
// No screens: an API's endpoints are its places to start from.
if (next.routed) {
routes = [];
return;
}
const found = await fetchRoutes({ limit: 300 }, controller.signal);
routes = found.routed ? found.entries : [];
})
.catch(() => {
screens = [];
screens = screens ?? [];
routes = routes ?? [];
});
return () => controller.abort();
}
@@ -162,6 +204,7 @@
data: {
layout: node,
info: model.nodes.get(node.id)!,
project: payload?.project ?? 'app',
selected: selected === node.id,
dimmed: neighbours !== null && !neighbours.has(node.id),
onSelect: (id: string) => {
@@ -333,20 +376,29 @@
{:else if !asked}
<div class="state chooser">
<h2>What happens from where?</h2>
<p>
Pick a screen and this view draws everything it sets in motion — its handlers, the calls that
cross into native code, the events that come back, the state it writes, the requests that leave
the app — one box per step, an arrow for every way one leads to the next, and on each arrow the
condition under which it happens. Or search a symbol and choose <i>What happens from here</i>.
</p>
{#if screens === null}
<p class="dim">Reading screens…</p>
{:else if screens.length === 0}
<p class="dim">
No screens in this graph. Open a symbol from the search box and follow <i>What happens from here</i>,
or link here directly with <span class="mono">#/steps?symbol=&lt;name&gt;</span>.
{#if chooser === 'routes'}
<p>
Pick an endpoint and this view draws everything it sets in motion — its handler and what runs
before it, the calls into the database, a queue, another service, and every response it can
send — one box per step, an arrow for every way one leads to the next, and on each arrow the
condition under which it happens. Or search a symbol and choose <i>What happens from here</i>.
</p>
{:else}
<p>
Pick a screen and this view draws everything it sets in motion — its handlers, the calls that
cross into native code, the events that come back, the state it writes, the requests that leave
the app — one box per step, an arrow for every way one leads to the next, and on each arrow the
condition under which it happens. Or search a symbol and choose <i>What happens from here</i>.
</p>
{/if}
{#if chooser === null}
<p class="dim">Reading {screens === null ? 'screens' : 'endpoints'}</p>
{:else if chooser === 'none'}
<p class="dim">
No screens or endpoints in this graph. Open a symbol from the search box and follow <i>What happens from here</i>,
or link here directly with <span class="mono">#/steps?symbol=&lt;name&gt;</span>.
</p>
{:else if chooser === 'screens' && screens !== null}
<div class="chooser-list">
{#each [...screens].sort((a, b) => b.outgoing + b.incoming - (a.outgoing + a.incoming) || a.path.localeCompare(b.path)) as screen (screen.id)}
<a class="pick mono" href={stepsHref({ anchor: screen.id })}
@@ -354,6 +406,17 @@
>
{/each}
</div>
{:else if routes !== null}
{#each routeGroups(routes) as group (group.file)}
<div class="group-h"><span class="mono">{group.file}</span><span class="dim">{group.entries.length}</span></div>
<div class="chooser-list">
{#each group.entries as route (route.routeId)}
<a class="pick mono" href={stepsHref({ anchor: route.routeId })}
>{route.url} <span class="dim sans">{route.handler}</span></a
>
{/each}
</div>
{/each}
{/if}
</div>
{:else if error !== null}
@@ -370,7 +433,7 @@
{nodeTypes}
{edgeTypes}
fitView
{...FIT}
fitViewOptions={fitOptions}
bind:viewport
minZoom={0.2}
maxZoom={3}
@@ -398,22 +461,58 @@
<span class="k-box k-anchor mono"><span class="mark"></span>start</span>
<span>Where the picture starts; each row down is one more step away</span>
</div>
<div class="lrow">
<span class="k-box mono">/path</span>
<span>A screen, or a handler — a function fired from a tap, an option, a listener; its line says the event</span>
</div>
<div class="lrow">
<span class="k-box k-cross mono">⇢ fn</span>
<span>The code crosses into native (⇢ a bridge call) or comes back from it (⇠ an event)</span>
</div>
<div class="lrow">
<span class="k-box k-store mono">set</span>
<span>A store action — a function in a store file</span>
</div>
<div class="lrow">
<span class="k-box k-effect mono">api</span>
<span>A call that leaves the index: the network, storage, the device, telemetry</span>
</div>
{#if payload.project === 'api'}
<div class="lrow">
<span class="k-box mono">POST /x</span>
<span>An endpoint — its verb and path — or a handler: a function a request, a job, an event or a schedule fires; its line says which</span>
</div>
<div class="lrow">
<span class="k-box k-cross mono">⇢ fn</span>
<span>The code crosses a tier: a call into another service or a job put on a queue (⇢), or a job, an event, a message arriving (⇠)</span>
</div>
<div class="lrow">
<span class="k-box k-store mono">set</span>
<span>A data call — a function in a store or state file</span>
</div>
<div class="lrow">
<span class="k-box k-effect mono">db</span>
<span>A call that leaves the index: the database, the response, a queue, email, payments, a cache, auth, the network</span>
</div>
{:else if payload.project === 'web'}
<div class="lrow">
<span class="k-box mono">/path</span>
<span>A page, an endpoint, or a handler — a function an event, a request or a page load fires; its line says which</span>
</div>
<div class="lrow">
<span class="k-box k-cross mono">⇢ fn</span>
<span>The code crosses to the server (⇢ a request, a server action) or comes back from it (⇠ a push, a stream)</span>
</div>
<div class="lrow">
<span class="k-box k-store mono">set</span>
<span>A store action — a function in a store file</span>
</div>
<div class="lrow">
<span class="k-box k-effect mono">api</span>
<span>A call that leaves the index: the network, the database, the response, storage, a queue, email</span>
</div>
{:else}
<div class="lrow">
<span class="k-box mono">/path</span>
<span>A screen, or a handler — a function fired from a tap, an option, a listener; its line says the event</span>
</div>
<div class="lrow">
<span class="k-box k-cross mono">⇢ fn</span>
<span>The code crosses into native (⇢ a bridge call) or comes back from it (⇠ an event)</span>
</div>
<div class="lrow">
<span class="k-box k-store mono">set</span>
<span>A store action — a function in a store file</span>
</div>
<div class="lrow">
<span class="k-box k-effect mono">api</span>
<span>A call that leaves the index: the network, storage, the device, telemetry</span>
</div>
{/if}
<div class="lrow">
<svg width="44" height="12" aria-hidden="true"><path d="M2 6 H42" class="k-line" /></svg>
<span>Leads to — the plumbing between the two is folded into the line</span>
@@ -448,7 +547,7 @@
{#if link.sites.length > 1}<span class="dim">{link.sites.length} ways</span>{/if}
<span class="when">{@render words(conditionTokens(link.when))}</span>
{#if link.label}<span class="dim">{link.label}</span>{/if}
{#if link.sites[0]}<span class="mono">{siteWords(link.sites[0])}</span>{/if}
{#if link.sites[0]}<span class="mono">{#if link.sites[0].status}<b class="status">{link.sites[0].status}</b> · {/if}{siteWords(link.sites[0])}</span>{/if}
</div>
{/each}
{#if hoveredInfo.links.length > 5}<div class="dim">+{hoveredInfo.links.length - 5} more</div>{/if}
@@ -463,7 +562,7 @@
<div class="head">
<div>
<div class="mono big">{selectedInfo.label}</div>
<div class="sub dim">{kindWord(selectedInfo.step.kind)}{#if selectedInfo.step.anchor} · where the picture starts{/if}</div>
<div class="sub dim">{kindWord(selectedInfo.step.kind, payload.project, selectedInfo.step)}{#if selectedInfo.step.anchor} · where the picture starts{/if}</div>
{#if selectedInfo.step.trigger}
<div class="fires"><b class="kw">FIRES FROM</b> {triggerWords(selectedInfo.step.trigger)} <span class="dim">in {selectedInfo.step.trigger.in}</span></div>
{/if}
@@ -494,7 +593,7 @@
<button class="clear" onclick={() => (selected = null)}>clear</button>
</div>
{#if selectedInfo.step.cut === 'screen'}
<p class="dim note">Another screen — a chapter of its own. Start here to see what happens on it, or continue through screens from the summary.</p>
<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>
{: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>
{:else if selectedInfo.step.cut !== null}
@@ -511,6 +610,9 @@
{#if selectedInfo.step.effect && selectedInfo.step.effect.apis.length > 1}
<p class="dim note mono">{selectedInfo.step.effect.apis.join(' · ')}</p>
{/if}
{#if selectedInfo.step.effect?.category === 'response'}
<p class="dim note">The endpoints contract as the code has it: each row below is one way it answers, with the condition it answers under.</p>
{/if}
{#if selectedInfo.step.events && selectedInfo.step.events.length > 1}
<p class="dim note mono">{selectedInfo.step.events.join(' · ')}</p>
{/if}
@@ -551,9 +653,9 @@
{/if}
{#if sc.rows.length > 1}<div class="when">{@render words(restTokens(row.rest, sc.common.length > 0))}</div>{/if}
{#if href}
<a class="site" {href}>{siteWords(row.site)} <span class="dim">· {basename(row.site.file)}:{row.site.line}</span></a>
<a class="site" {href}>{#if row.site.status}<b class="status">{row.site.status}</b> · {/if}{siteWords(row.site)} <span class="dim">· {basename(row.site.file)}:{row.site.line}</span></a>
{:else}
<span class="site">{siteWords(row.site)} <span class="dim">· {basename(row.site.file)}:{row.site.line}</span></span>
<span class="site">{#if row.site.status}<b class="status">{row.site.status}</b> · {/if}{siteWords(row.site)} <span class="dim">· {basename(row.site.file)}:{row.site.line}</span></span>
{/if}
</div>
{/each}
@@ -593,9 +695,9 @@
{/if}
{#if sc.rows.length > 1}<div class="when">{@render words(restTokens(row.rest, sc.common.length > 0))}</div>{/if}
{#if href}
<a class="site" {href}>{siteWords(row.site)} <span class="dim">· {basename(row.site.file)}:{row.site.line}</span></a>
<a class="site" {href}>{#if row.site.status}<b class="status">{row.site.status}</b> · {/if}{siteWords(row.site)} <span class="dim">· {basename(row.site.file)}:{row.site.line}</span></a>
{:else}
<span class="site">{siteWords(row.site)} <span class="dim">· {basename(row.site.file)}:{row.site.line}</span></span>
<span class="site">{#if row.site.status}<b class="status">{row.site.status}</b> · {/if}{siteWords(row.site)} <span class="dim">· {basename(row.site.file)}:{row.site.line}</span></span>
{/if}
</div>
{/each}
@@ -638,14 +740,15 @@
<p>
<label class="opt">
<input type="checkbox" checked={payload.through} onchange={(e) => navigate(rewrite({ through: (e.currentTarget as HTMLInputElement).checked }))} />
Continue through screens
Continue through {kindWords('screen', payload.project)[1]}
</label>
<span class="dim">— otherwise another screen is drawn as a boundary, and is a click from being the next anchor.</span>
<span class="dim">— otherwise another {kindWord('screen', payload.project)} is drawn as a boundary, and is a click from being the next anchor.</span>
</p>
<p class="counts">
{#each ['screen', 'trigger', 'bridge', 'event', 'store', 'effect'] as const as kind (kind)}
{#if model.counts[kind] > 0}
<span><b>{model.counts[kind]}</b> {kindWord(kind)}{model.counts[kind] === 1 ? '' : 's'}</span>
{@const words = kindWords(kind, payload.project)}
<span><b>{model.counts[kind]}</b> {model.counts[kind] === 1 ? words[0] : words[1]}</span>
{/if}
{/each}
</p>
@@ -665,7 +768,7 @@
{/if}
<h4>Most connected</h4>
{#each [...payload.steps].sort((a, b) => (model.layout.nodes.find((n) => n.id === b.id)?.ports.top.length ?? 0) + (model.layout.nodes.find((n) => n.id === b.id)?.ports.bottom.length ?? 0) - ((model.layout.nodes.find((n) => n.id === a.id)?.ports.top.length ?? 0) + (model.layout.nodes.find((n) => n.id === a.id)?.ports.bottom.length ?? 0))).slice(0, 8) as step (step.id)}
<button class="peer mono" onclick={() => (selected = step.id)}>{model.nodes.get(step.id)?.label ?? step.label} <span class="dim sans">{kindWord(step.kind)}</span></button>
<button class="peer mono" onclick={() => (selected = step.id)}>{model.nodes.get(step.id)?.label ?? step.label} <span class="dim sans">{kindWord(step.kind, payload.project, step)}</span></button>
{/each}
{/if}
</aside>
@@ -730,6 +833,19 @@
margin-top: 12px;
border-top: 1px solid var(--rule-soft);
}
/* A router file heading over its endpoints; the list under it keeps its own top rule. */
.group-h {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
margin-top: 18px;
font-size: 11.5px;
color: var(--ink-2);
}
.group-h + .chooser-list {
margin-top: 6px;
}
.pick {
display: block;
padding: 7px 8px;
@@ -984,6 +1100,11 @@
text-decoration: none;
overflow-wrap: anywhere;
}
/* A response's status code leads its row: the number is the fact. */
.status {
color: var(--ink);
font-weight: 600;
}
a.site:hover {
text-decoration: underline;
}