feat(expo-router): add Expo Router support for screens and navigates

Introduce Expo Router integration: a new framework resolver, route-based screen nodes, and navigates edges, plus a /api/screens endpoint and a Screens UI view. Adds branch-guard-driven labeling of edges, resolution logic, and tests to cover extraction, resolution, and end-to-end flow. This enables CodeGraph UI to surface screens and transitions from Expo Router apps.
This commit is contained in:
Colby McHenry
2026-08-27 22:52:18 -05:00
parent ac9580544b
commit 70fd5fefc2
42 changed files with 4257 additions and 33 deletions
+14 -1
View File
@@ -7,6 +7,7 @@
import FileView from './views/FileView.svelte';
import FileCodeView from './views/FileCodeView.svelte';
import MapView from './views/MapView.svelte';
import ScreensView from './views/ScreensView.svelte';
import FlowView from './views/FlowView.svelte';
import EntryView from './views/EntryView.svelte';
import DeadCodeView from './views/DeadCodeView.svelte';
@@ -19,6 +20,7 @@
mapHref,
flowHref,
entryHref,
screensHref,
deadHref,
} from './lib/router.svelte';
import { palette } from './lib/palette.svelte';
@@ -66,6 +68,11 @@
let route = $derived(router.route);
// An app with screens opens on them. The Symbol tab's empty state is for a
// library, where there is nothing to draw until a name is typed; a project
// whose graph holds screen navigation has a picture worth landing on.
let hasScreens = $derived((project.stats?.graph.edgesByKind.navigates ?? 0) > 0);
// Keep the in-memory trail and the `t` param in step. untrack() because the
// body writes the same store it would otherwise read itself into a loop.
$effect(() => {
@@ -127,6 +134,10 @@
event.preventDefault();
navigate(entryHref());
break;
case 's':
event.preventDefault();
navigate(screensHref());
break;
case 'd':
event.preventDefault();
navigate(deadHref());
@@ -142,7 +153,7 @@
<svelte:window {onkeydown} />
<TopBar bind:this={topbar} project={project.name} stats={project.summary} />
<TopBar bind:this={topbar} project={project.name} stats={project.summary} showScreens={hasScreens} />
<TrailBar />
<main>
{#if route.view === 'symbol'}
@@ -162,6 +173,8 @@
/>
{:else if route.view === 'entry'}
<EntryView project={project.name} />
{:else if route.view === 'screens' || (route.view === 'home' && hasScreens)}
<ScreensView />
{:else if route.view === 'dead'}
<DeadCodeView exported={route.exported} />
{:else if route.view === 'unknown'}
+6 -3
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { router, mapHref, flowHref, entryHref, deadHref, symbolHref } from '../lib/router.svelte';
import { router, mapHref, flowHref, entryHref, screensHref, deadHref, symbolHref } from '../lib/router.svelte';
import { trail } from '../lib/trail.svelte';
import SearchPalette from './SearchPalette.svelte';
import { live } from '../lib/live.svelte';
@@ -9,9 +9,11 @@
project?: string | null;
/** "13,060 symbols · 46,004 edges · 593 files indexed". Null until loaded. */
stats?: string | null;
/** The project's graph holds screen navigation: show the Screens tab and land on it. */
showScreens?: boolean;
}
let { project = null, stats = null }: Props = $props();
let { project = null, stats = null, showScreens = false }: Props = $props();
let search: SearchPalette | null = $state(null);
@@ -65,9 +67,10 @@
</a>
<nav class="views" aria-label="Views">
{#if showScreens}<a href={screensHref()} class:active={view === 'screens' || view === 'home'}>Screens</a>{/if}
<a href={entryHref()} class:active={view === 'entry'}>Entry points</a>
<a href={mapHref()} class:active={view === 'map'}>Map</a>
<a href={symbolTabHref} class:active={view === 'symbol' || view === 'home'}>Symbol</a>
<a href={symbolTabHref} class:active={view === 'symbol' || (view === 'home' && !showScreens)}>Symbol</a>
<a href={flowHref()} class:active={view === 'flow'}>Flow</a>
<a href={deadHref()} class:active={view === 'dead'}>Dead code</a>
</nav>
+113
View File
@@ -0,0 +1,113 @@
<script lang="ts">
/**
* One transition on the Screens view — the Map's cubic, plus a label at the
* midpoint saying under what condition it happens. A pair with several
* transitions draws once and counts them; the tooltip and the side panel
* tell them apart. Dashed when every transition behind it rides a
* synthesized hop (a helper's return value); accent-dashed when it points
* back up the layering (Capture → Home).
*/
import { BaseEdge, type EdgeProps } from '@xyflow/svelte';
import type { MapEdgeLayout } from '../../lib/map-model';
import type { ScreenEdgeInfo } from '../../lib/screens-model';
let { sourceX, sourceY, targetX, targetY, data }: EdgeProps = $props();
const d = $derived(
data as unknown as {
edge: MapEdgeLayout;
info: ScreenEdgeInfo;
hot: boolean;
dimmed: boolean;
/** Show the label; only the selected screen's edges and the hovered one do. */
labelled: boolean;
/**
* Where along the curve the label sits, 0 = source end, 1 = target end.
* Close to the selected screen, where its lines are still apart, so a
* label sits beside the one line it belongs to.
*/
labelAt: number;
onHover: (edge: MapEdgeLayout | null, event: MouseEvent | null) => void;
}
);
const midY = $derived((sourceY + targetY) / 2);
const path = $derived(`M${sourceX},${sourceY} C${sourceX},${midY} ${targetX},${midY} ${targetX},${targetY}`);
/** The point at `t` on the same cubic the path draws. */
const label = $derived.by(() => {
const t = d.labelAt;
const u = 1 - t;
const x = u * u * u * sourceX + 3 * u * u * t * sourceX + 3 * u * t * t * targetX + t * t * t * targetX;
const y = u * u * u * sourceY + 3 * u * u * t * midY + 3 * u * t * t * midY + t * t * t * targetY;
return { x, y };
});
/** IBM Plex Mono at 10.5px: ~6.3px per character, plus the pill's padding. */
const pillWidth = $derived(d.info.label.length * 6.3 + 12);
</script>
<BaseEdge
{path}
class={`sedge${d.edge.back ? ' back' : ''}${d.hot ? ' hot' : ''}${d.dimmed ? ' dimmed' : ''}${d.info.synthesized ? ' synth' : ''}`}
style={`stroke-width:${Math.min(3, d.edge.width)}px`}
/>
<path
class="hit"
d={path}
role="presentation"
onmousemove={(event) => d.onHover(d.edge, event)}
onmouseleave={() => d.onHover(null, null)}
/>
{#if d.info.label && d.labelled}
<g class="epill" class:hot={d.hot}>
<rect x={label.x - pillWidth / 2} y={label.y - 9} width={pillWidth} height={17} rx="2" />
<text x={label.x} y={label.y + 3.5} text-anchor="middle">{d.info.label}</text>
</g>
{/if}
<style>
:global(.svelte-flow__edge-path.sedge) {
stroke: var(--ink);
stroke-opacity: 0.32;
fill: none;
}
:global(.svelte-flow__edge-path.sedge.hot) {
stroke-opacity: 0.95;
}
:global(.svelte-flow__edge-path.sedge.dimmed) {
stroke-opacity: 0.06;
}
:global(.svelte-flow__edge-path.sedge.synth) {
stroke-dasharray: 5 3;
}
:global(.svelte-flow__edge-path.sedge.back) {
stroke: var(--accent);
stroke-opacity: 0.6;
stroke-dasharray: 4 3;
}
.hit {
stroke: transparent;
stroke-width: 12;
fill: none;
pointer-events: stroke;
cursor: crosshair;
}
.epill {
pointer-events: none;
}
.epill rect {
fill: var(--paper);
stroke: var(--rule);
stroke-width: 1px;
}
.epill text {
font: 400 10.5px var(--mono);
fill: var(--ink-2);
}
.epill.hot rect {
stroke: var(--ink-3);
}
.epill.hot text {
fill: var(--ink);
}
</style>
+137
View File
@@ -0,0 +1,137 @@
<script lang="ts">
/**
* One screen on the Screens view: its path, and the component that renders
* it. An origin — a function that navigates but belongs to no screen (a
* store action after login) — draws dashed, so it reads as a trigger rather
* than a place. The entry screen (`/`) carries a mark.
*
* Hidden handles along the top and bottom, one per link, exactly as the
* Map's module box does: the layout decided the ports, this only draws them.
*/
import { Handle, Position, type NodeProps } from '@xyflow/svelte';
import type { MapNodeLayout } from '../../lib/map-model';
import type { ScreenNodeInfo } from '../../lib/screens-model';
let { data }: NodeProps = $props();
const node = $derived(
data as unknown as {
layout: MapNodeLayout;
info: ScreenNodeInfo;
selected: boolean;
dimmed: boolean;
onSelect: (id: string) => void;
}
);
const layout = $derived(node.layout);
const info = $derived(node.info);
function portStyle(index: number, total: number): string {
return `left:${((index + 1) / (total + 1)) * 100}%`;
}
</script>
{#each layout.targetHandles as handle, i (handle)}
<Handle
type="target"
id={`t:${handle}`}
position={Position.Top}
style={portStyle(i, layout.targetHandles.length)}
isConnectable={false}
/>
{/each}
<button
class="snode"
class:sel={node.selected}
class:dimmed={node.dimmed}
class:origin={info.origin}
class:entry={info.entry}
class:unreached={info.unreached}
style={`width:${layout.width}px;height:${layout.height}px`}
onclick={() => node.onSelect(info.id)}
aria-pressed={node.selected}
title={info.origin
? `${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.unreached ? '. No transition in the graph reaches it from the entry.' : ''
}`}
>
<span class="name">{#if info.entry}<span class="mark" aria-hidden="true">●</span>{/if}{info.label}</span>
<span class="sub">{info.sub}</span>
</button>
{#each layout.sourceHandles as handle, i (handle)}
<Handle
type="source"
id={`s:${handle}`}
position={Position.Bottom}
style={portStyle(i, layout.sourceHandles.length)}
isConnectable={false}
/>
{/each}
<style>
.snode {
display: flex;
flex-direction: column;
justify-content: center;
gap: 1px;
box-sizing: border-box;
padding: 0 9px;
border: 1px solid var(--ink);
border-radius: 0;
background: var(--paper);
text-align: left;
cursor: pointer;
font: inherit;
color: var(--ink);
transition: background 90ms linear;
}
.snode:hover,
.snode.sel {
border-width: 2px;
padding: 0 8px;
background: var(--press);
}
.snode.dimmed {
border-color: var(--ink-4);
color: var(--ink-4);
}
.snode.dimmed .sub {
color: var(--ink-4);
}
.snode.origin {
border-style: dashed;
border-color: var(--ink-3);
}
.snode.unreached {
border-color: var(--ink-4);
color: var(--ink-2);
}
.snode:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.name {
font: 500 13px var(--mono);
line-height: 15px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mark {
color: var(--accent);
margin-right: 5px;
font-size: 9px;
vertical-align: 1px;
}
.sub {
font: 400 11px var(--sans);
line-height: 13px;
color: var(--ink-3);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
@@ -81,6 +81,9 @@
{#if row.via}<span class="tag" title="A synthesized edge — dynamic dispatch the parser cannot see"
>via {row.via}</span
>{/if}
{#each row.when as w (w)}<span class="tag when" title="The call runs only under this condition — read from the source as it is now"
>when {w}</span
>{/each}
</div>
</div>
</div>
@@ -249,6 +252,10 @@
font-size: 10.5px;
}
.tag.when {
color: var(--ink-2);
}
.rfold {
position: absolute;
right: 12px;
@@ -96,6 +96,9 @@
<div class="nm">{rowName(node)}</div>
<div class="meta">
{#if row.words.length > 0}<span class="kindlbl">{row.words.join(', ')}</span>{/if}
{#each row.when as w (w)}<span class="tag when" title="The call runs only under this condition — read from the source as it is now"
>when {w}</span
>{/each}
{#each row.lines as line (line)}
<button
type="button"
@@ -299,6 +302,18 @@
font-size: 11px;
}
.tag {
flex: 0 0 auto;
padding: 0 4px;
border: 1px solid var(--rule-soft);
color: var(--ink-3);
font-size: 10.5px;
}
.tag.when {
color: var(--ink-2);
}
.kindlbl {
color: var(--ink-3);
}
+7
View File
@@ -35,6 +35,7 @@ import type {
WireFileCodePayload,
WireFlowPayload,
WireMapPayload,
WireScreensPayload,
WireNodeRefs,
WireRoutes,
WireSearch,
@@ -210,6 +211,8 @@ export interface GraphAdapter {
flow(request: FlowRequest, signal?: AbortSignal): Promise<WireFlowPayload>;
/** The repository at module granularity, layered. */
map(request?: MapRequest, signal?: AbortSignal): Promise<WireMapPayload>;
/** The app's screens and the transitions between them, with their conditions. */
screens(signal?: AbortSignal): Promise<WireScreensPayload>;
/** The URL → handler map. */
routes(request?: RoutesRequest, signal?: AbortSignal): Promise<WireRoutes>;
/** Where a reader starts: routes, files that run something, tests, hubs. */
@@ -393,6 +396,10 @@ export function createHttpAdapter(options: HttpAdapterOptions = {}): GraphAdapte
return getJson<WireRoutes>(`api/routes${query(params)}`, signal);
},
screens(signal) {
return getJson<WireScreensPayload>('api/screens', signal);
},
entryPoints(request = {}, signal) {
const params = new URLSearchParams();
if (request.limit) params.set('limit', String(request.limit));
+5
View File
@@ -19,6 +19,7 @@ import type {
WireFileCodePayload,
WireFlowPayload,
WireMapPayload,
WireScreensPayload,
WireNodeRefs,
WireRoutes,
WireSearch,
@@ -141,6 +142,10 @@ export function fetchSource(
* is how many path segments under it name a module. Omitting `root` lets the
* adapter pick the repository's source directory.
*/
export function fetchScreens(signal?: AbortSignal): Promise<WireScreensPayload> {
return getGraphAdapter().screens(signal);
}
export function fetchMap(
opts: { root?: string | null; depth?: number } = {},
signal?: AbortSignal
+3 -1
View File
@@ -243,7 +243,9 @@ export function buildEntryPanel(entries: WireEntryPoints | null): EntryPanel {
section(
'routes',
'Routes',
'A request from outside arrives here — the URL, and the symbol that serves it.',
entries.routes.items.items.every((r) => !r.method)
? 'A screen of the app — its path, and the component that renders it.'
: 'A request from outside arrives here — the URL, and the symbol that serves it.',
entries.routes.items,
groupRows(
entries.routes.items.items.map((route) => ({
+28 -3
View File
@@ -186,6 +186,22 @@ export interface MapLayout {
export interface MapLayoutOptions {
includeTests: boolean;
/** Override the hidden-link floor; 0 draws every link (the Screens view). */
minWeight?: number;
/**
* The two lines a box is sized for. The Map's boxes show the module id and
* its counts; a view that shows something else (a screen's path and its
* component) must size for what it draws, or an opaque id decides the width.
*/
sizing?: (module: WireMapModule, island: boolean) => { label: string; meta: string };
/**
* Replace longest-path layering. Receives every module id and the acyclic
* links (mutual pairs already broken); returns each id's layer, 0 at the
* BOTTOM. The Screens view lays out by distance from the entry screen,
* where "one layer above what it depends on" would put the head of the
* longest chain of screens above the login page.
*/
layering?: (ids: string[], links: ReadonlyArray<{ source: string; target: string }>) => Map<string, number>;
}
export function strokeWidthFor(count: number): number {
@@ -212,7 +228,7 @@ export function buildMapLayout(
// depends on is depended on, whatever this screen is currently showing.
const depended = new Set(payload.links.map((l) => l.target));
const links = payload.links.filter((l) => present.has(l.source) && present.has(l.target));
const minWeight = options.includeTests ? MIN_WEIGHT_WITH_TESTS : MIN_WEIGHT;
const minWeight = options.minWeight ?? (options.includeTests ? MIN_WEIGHT_WITH_TESTS : MIN_WEIGHT);
const declaredLinks = links.filter((l) => l.declared > 0);
const useDeclared =
@@ -246,7 +262,12 @@ export function buildMapLayout(
for (const list of out.values()) list.sort();
const layer = new Map<string, number>();
for (const module of modules) longestPath(module.id, out, layer, new Set());
if (options.layering) {
for (const [id, value] of options.layering(modules.map((m) => m.id), acyclic)) layer.set(id, value);
for (const module of modules) if (!layer.has(module.id)) layer.set(module.id, 0);
} else {
for (const module of modules) longestPath(module.id, out, layer, new Set());
}
const layerCount = Math.max(1, ...[...layer.values()].map((v) => v + 1));
const rows: string[][] = Array.from({ length: layerCount }, () => []);
@@ -282,7 +303,11 @@ export function buildMapLayout(
// --- placement -----------------------------------------------------------
const islands = new Set(modules.filter((m) => !depended.has(m.id)).map((m) => m.id));
const widths = new Map(
modules.map((m) => [m.id, nodeWidth(m.id, moduleMetaLabel(m, islands.has(m.id)))])
modules.map((m) => {
const island = islands.has(m.id);
const lines = options.sizing?.(m, island) ?? { label: m.id, meta: moduleMetaLabel(m, island) };
return [m.id, nodeWidth(lines.label, lines.meta)];
})
);
const rowSums = rows.map((row) => row.reduce((sum, id) => sum + (widths.get(id) ?? 0), 0));
// Natural span = the boxes shoulder to shoulder. The content width is the
+9
View File
@@ -68,6 +68,7 @@ export interface NavigationDriver {
mapHref(opts?: MapHrefOptions): string;
flowHref(opts?: FlowHrefOptions): string;
entryHref(): string;
screensHref(): string;
deadHref(opts?: DeadCodeHrefOptions): string;
/** Go to an href this driver built. */
navigate(href: string, opts?: { replace?: boolean }): void;
@@ -133,6 +134,10 @@ export const hashNavigation: NavigationDriver = {
return '#/entry';
},
screensHref() {
return '#/screens';
},
deadHref(opts = {}) {
const params = new URLSearchParams();
if (opts.exported) params.set('exported', '1');
@@ -212,6 +217,10 @@ export function entryHref(): string {
return driver.entryHref();
}
export function screensHref(): string {
return driver.screensHref();
}
export function deadHref(opts: DeadCodeHrefOptions = {}): string {
return driver.deadHref(opts);
}
+5
View File
@@ -11,6 +11,7 @@
* #/map module map (?root=&depth=&tests=1)
* #/flow flow strip (?from=&to= | ?symbols= | ?t=<trail>)
* #/entry entry points (where a flow starts)
* #/screens screens (the app's screens and transitions)
* #/dead dead code (?exported=1 widens the claim)
*
* Node ids are opaque engine strings shaped `<kind>:<hash>` or
@@ -40,6 +41,7 @@ export {
hashNavigation,
mapHref,
navigate,
screensHref,
setNavigationDriver,
symbolHref,
} from './navigation';
@@ -74,6 +76,7 @@ export type Route =
trail: string | null;
}
| { view: 'entry' }
| { view: 'screens' }
| {
view: 'dead';
/** Symbols reachable from outside the index are on the list. */
@@ -136,6 +139,8 @@ export function parseHash(hash: string): RouterLocation {
};
} else if (head === 'entry' && rest.length === 0) {
route = { view: 'entry' };
} else if (head === 'screens' && rest.length === 0) {
route = { view: 'screens' };
} else if (head === 'dead' && rest.length === 0) {
// The widening travels in the URL like the map's shape does: a link to
// "including exported symbols" has to reopen the same list.
+277
View File
@@ -0,0 +1,277 @@
/**
* The Screens view's model — the app's screens and the transitions between
* them, laid out so that a screen sits above the screens it opens.
*
* The layout is the Map's (`buildMapLayout`): the same longest-path layering,
* the same barycenter ordering, the same ports, the same determinism. A
* screen graph is a module graph with different words — nodes with names,
* weighted links that mostly point one way, a few cycles (Home ↔ Capture)
* that become dashed back-edges rather than being straightened into a lie.
* Reusing it means a reader who learned the Map reads this without learning
* anything new, and means this file is mostly translation, not geometry.
*
* What is this file's own: which links share a pair (several transitions from
* Home to Capture, each with its own condition, draw as ONE edge whose label
* counts them), the words on that edge, and the two lists the side panel
* shows for a selected screen.
*/
import type { WireMapLink, WireMapModule, WireScreen, WireScreenLink, WireScreensPayload } from './wire';
import { buildMapLayout, linkId, type MapLayout } from './map-model';
/** The longest `when` a connector prints before an ellipsis; the tooltip has the rest. */
const EDGE_LABEL_MAX = 30;
export interface ScreenNodeInfo {
id: string;
/** `/object-detail`, or a function name for an origin. */
label: string;
/** The component's name for a screen; the file for an origin. */
sub: string;
screen: WireScreen | null;
/** A navigation that could not be attributed to a screen. */
origin: boolean;
entry: boolean;
/** No path of transitions leads here from the entry screen. */
unreached: boolean;
}
export interface ScreenEdgeInfo {
id: string;
from: string;
to: string;
/** Every transition between the pair — one connector, several stories. */
links: WireScreenLink[];
/** The connector's short label: the condition, or how many transitions. */
label: string;
synthesized: boolean;
}
export interface ScreensModel {
layout: MapLayout;
nodes: Map<string, ScreenNodeInfo>;
/** Keyed by the layout edge's id (see `linkId`). */
edges: Map<string, ScreenEdgeInfo>;
/** Screens no chain of transitions reaches from the entry. */
unreached: number;
}
/**
* Layer = distance from the entry screen: the entry on top, each row down one
* more transition away. Origins (chrome, triggers outside any screen) count
* as reachable seeds too, so what they open is placed below them. Whatever
* nothing reaches sits in a band at the bottom, layered among itself by the
* same rule from its own sources — a screen the graph cannot see anyone open
* is a fact worth a place, not a crash.
*/
export function entryLayering(entry: string | null, seeds: readonly string[]) {
return (ids: string[], links: ReadonlyArray<{ source: string; target: string }>): Map<string, number> => {
const out = new Map<string, string[]>(ids.map((id) => [id, []]));
const indeg = new Map<string, number>(ids.map((id) => [id, 0]));
for (const l of links) {
out.get(l.source)?.push(l.target);
indeg.set(l.target, (indeg.get(l.target) ?? 0) + 1);
}
const depth = new Map<string, number>();
const bfs = (starts: string[]) => {
let frontier = starts.filter((s) => !depth.has(s));
for (const s of frontier) depth.set(s, 0);
let d = 0;
while (frontier.length > 0) {
d++;
const next: string[] = [];
for (const id of frontier) {
for (const t of out.get(id) ?? []) {
if (depth.has(t)) continue;
depth.set(t, d);
next.push(t);
}
}
frontier = next;
}
};
const roots = [entry, ...seeds].filter((s): s is string => s !== null && ids.includes(s));
bfs(roots);
const reachedMax = Math.max(0, ...[...depth.values()]);
// The unreached band: its own sources first, then whatever they open.
const rest = ids.filter((id) => !depth.has(id));
const restDepth = new Map<string, number>();
if (rest.length > 0) {
const restSources = rest.filter((id) => (indeg.get(id) ?? 0) === 0);
const seedsRest = restSources.length > 0 ? restSources : [rest[0]!];
let frontier = seedsRest;
for (const s of frontier) restDepth.set(s, 0);
let d = 0;
while (frontier.length > 0) {
d++;
const next: string[] = [];
for (const id of frontier) {
for (const t of out.get(id) ?? []) {
if (restDepth.has(t) || depth.has(t)) continue;
restDepth.set(t, d);
next.push(t);
}
}
frontier = next;
}
for (const id of rest) if (!restDepth.has(id)) restDepth.set(id, 0);
}
const restMax = Math.max(0, ...[...restDepth.values()]);
// Layer 0 is the bottom. Unreached band occupies [0, restMax]; reached
// screens sit above it, the entry highest, with one empty row between.
const base = rest.length > 0 ? restMax + 2 : 0;
const layer = new Map<string, number>();
for (const [id, d] of depth) layer.set(id, base + reachedMax - d);
for (const [id, d] of restDepth) layer.set(id, restMax - d);
return layer;
};
}
/** What the connector says. Empty when unconditional and single. */
export function edgeLabel(links: readonly WireScreenLink[]): string {
if (links.length === 1) {
const when = links[0]!.when;
if (!when) return '';
return when.length > EDGE_LABEL_MAX ? `${when.slice(0, EDGE_LABEL_MAX - 1)}` : when;
}
const conditional = links.filter((l) => l.when).length;
return conditional > 0 ? `${links.length} ways · ${conditional} conditional` : `${links.length} ways`;
}
export function buildScreensModel(payload: WireScreensPayload): ScreensModel {
const nodes = new Map<string, ScreenNodeInfo>();
const modules: WireMapModule[] = [];
const used = new Set<string>();
for (const link of payload.links) {
used.add(link.from);
used.add(link.to);
}
for (const screen of payload.screens) {
const info: ScreenNodeInfo = {
id: screen.id,
label: screen.path,
sub: screen.component?.name ?? screen.file,
screen,
origin: false,
entry: payload.entry === screen.id,
unreached: false,
};
nodes.set(screen.id, info);
modules.push(moduleFor(info, screen.incoming + screen.outgoing));
}
for (const origin of payload.origins) {
const info: ScreenNodeInfo = {
id: origin.id,
label: origin.node.kind === 'component' ? `<${origin.node.name}>` : `${origin.node.name}()`,
sub: origin.sharedBy ? `on ${origin.sharedBy} screens` : origin.node.file,
screen: null,
origin: true,
entry: false,
unreached: false,
};
nodes.set(origin.id, info);
modules.push(moduleFor(info, origin.outgoing));
}
// One layout link per (from, to); the transitions behind it stay listed.
const byPair = new Map<string, WireScreenLink[]>();
for (const link of payload.links) {
const key = linkId({ source: link.from, target: link.to });
const list = byPair.get(key) ?? [];
list.push(link);
byPair.set(key, list);
}
const links: WireMapLink[] = [];
const edges = new Map<string, ScreenEdgeInfo>();
for (const [key, group] of byPair) {
const first = group[0]!;
if (!nodes.has(first.from) || !nodes.has(first.to)) continue;
// A screen that reopens itself (a retry) is a fact for the panel, not an
// arrow the layout can draw.
if (first.from === first.to) continue;
links.push({
source: first.from,
target: first.to,
count: group.length,
declared: group.length,
byKind: [{ kind: 'navigates', count: group.length }],
topPairs: [],
});
edges.set(key, {
id: key,
from: first.from,
to: first.to,
links: group,
label: edgeLabel(group),
synthesized: group.every((l) => l.synthesized),
});
}
// Reachability from the entry (and from the origins, which are entries of
// a kind: chrome is on the screen the user is on).
const seeds = payload.origins.map((o) => o.id);
const reachable = new Set<string>();
{
const out = new Map<string, string[]>();
for (const l of payload.links) out.set(l.from, [...(out.get(l.from) ?? []), l.to]);
const stack = [payload.entry, ...seeds].filter((s): s is string => s !== null);
while (stack.length > 0) {
const id = stack.pop()!;
if (reachable.has(id)) continue;
reachable.add(id);
for (const t of out.get(id) ?? []) stack.push(t);
}
}
let unreached = 0;
for (const info of nodes.values()) {
if (!info.origin && !reachable.has(info.id)) {
info.unreached = true;
unreached++;
}
}
const layout = buildMapLayout(
{ modules, links },
{
includeTests: true,
minWeight: 0,
sizing: (m) => {
const info = nodes.get(m.id);
return { label: info?.label ?? m.id, meta: info?.sub ?? '' };
},
layering: entryLayering(payload.entry, seeds),
}
);
return { layout, nodes, edges, unreached };
}
function moduleFor(info: ScreenNodeInfo, symbols: number): WireMapModule {
return {
id: info.id,
label: info.label,
files: 1,
symbols,
languages: [],
test: false,
generated: 0,
generatedFiles: [],
facade: false,
fileList: { total: 1, shown: 1, truncated: false, items: [info.screen?.file ?? info.sub] },
};
}
/** The side panel's two lists for a selected node. */
export function neighbourhood(
payload: WireScreensPayload,
id: string
): { opensFrom: WireScreenLink[]; goesTo: WireScreenLink[] } {
const opensFrom = payload.links.filter((l) => l.to === id);
const goesTo = payload.links.filter((l) => l.from === id);
return { opensFrom, goesTo };
}
/** `ItemCard → openObjectDetail`, or '' when the screen's own component navigates. */
export function viaText(link: WireScreenLink): string {
return link.via.map((v) => v.name).join(' → ');
}
+3 -1
View File
@@ -269,7 +269,9 @@ export function buildEntryPalette(
if (entries.routes.routed && entries.routes.items.items.length > 0) {
sections.push({
title: 'Routes',
note: 'A request from outside arrives here.',
note: entries.routes.items.items.every((r) => !r.method)
? 'A screen of the app.'
: 'A request from outside arrives here.',
items: take(entries.routes.items.items).map((route) => ({
type: 'route' as const,
id: `route:${route.routeId}`,
+18
View File
@@ -65,6 +65,8 @@ export function edgeWord(edge: WireEdge): string {
return '';
case 'instantiates':
return 'creates';
case 'navigates':
return 'navigates to';
case 'references':
return edge.valueRef ? 'passes as value' : 'uses type';
default:
@@ -82,6 +84,16 @@ export function relationWords(relation: WireRelation): string[] {
return words;
}
/** The distinct branch conditions across a relation's edges, at most three. */
export function relationWhens(relation: WireRelation): string[] {
const out: string[] = [];
for (const edge of relation.edges) {
if (edge.when && !out.includes(edge.when)) out.push(edge.when);
if (out.length === 3) break;
}
return out;
}
/** The synthesizer that produced this relation's edge, when one did. */
export function synthesizedBy(relation: WireRelation): string | null {
if (!relation.synthesized) return null;
@@ -335,6 +347,8 @@ export interface CalleeRow {
lines: number[];
words: string[];
via: string | null;
/** `when` conditions, distinct, for the meta line. */
when: string[];
}
export interface CalleeRailModel {
@@ -357,6 +371,7 @@ export function buildCalleeRail(payload: WireSymbolPayload): CalleeRailModel {
lines: relation.lines,
words: relationWords(relation),
via: synthesizedBy(relation),
when: relationWhens(relation),
};
if (relation.uncertain) uncertain.push(row);
else rows.push(row);
@@ -380,6 +395,8 @@ export interface CallerRow {
/** Call-site lines in the CALLER's file — the `:4657` chips. */
lines: number[];
via: string | null;
/** `when` conditions, distinct, for the meta line. */
when: string[];
}
export interface CallerFileGroup {
@@ -420,6 +437,7 @@ export function buildCallerRail(payload: WireSymbolPayload): CallerRailModel {
words: relationWords(relation),
lines: relation.lines,
via: synthesizedBy(relation),
when: relationWhens(relation),
};
// Uncertainty wins over test-ness: a name-only guess is a claim about the
// edge, and burying it in the tests fold would present it as established.
+52
View File
@@ -117,6 +117,8 @@ export interface WireEdge {
via?: string;
registeredAt?: string;
valueRef?: boolean;
/** Branch conditions the call site runs under — `!isUploading && isCollected`. */
when?: string;
}
/** Every edge between the focal symbol and ONE other symbol, as a single row. */
@@ -642,6 +644,56 @@ export interface WireMapPayload {
timing: { elapsedMs: number; cached: boolean };
}
/* ---------------------------------------------------------------- screens -- */
export interface WireScreen {
id: string;
path: string;
file: string;
line: number;
component: WireNodeRef | null;
incoming: number;
outgoing: number;
}
export interface WireScreenOrigin {
id: string;
node: WireNodeRef;
outgoing: number;
/** Shared chrome: how many screens render it. */
sharedBy?: number;
}
export interface WireScreenSite {
file: string;
line: number;
href: string;
method: string;
when: string;
}
export interface WireScreenLink {
id: string;
from: string;
to: string;
fromOrigin: boolean;
via: WireNodeRef[];
when: string;
sites: WireScreenSite[];
synthesized: boolean;
}
export interface WireScreensPayload {
routed: boolean;
entry: string | null;
screens: WireScreen[];
origins: WireScreenOrigin[];
links: WireScreenLink[];
dropped: number;
index: { lastIndexedAt: number | null; edges: number; files: number };
timing: { elapsedMs: number };
}
/* -------------------------------------------------------------- dead code -- */
/** One symbol nothing in the index reaches. */
+588
View File
@@ -0,0 +1,588 @@
<!--
The Screens view (`#/screens`): the app as its user meets it — one box per
screen, an arrow for every way of getting from one to another, and on each
arrow the condition under which it happens.
Everything drawn comes from `/api/screens`: routes the framework resolver
found, `navigates` edges the extractor bound, attribution back through the
render/call chain to the screen a transition starts on, and branch guards
read from the source. The canvas is the Map's machinery with different
words (see `screens-model.ts`); the side panel is where the sentences are.
-->
<script lang="ts">
import { SvelteFlow, Controls, type Node, type Edge } from '@xyflow/svelte';
import '@xyflow/svelte/dist/style.css';
import ScreenNode from '../components/screens/ScreenNode.svelte';
import ScreenEdge from '../components/screens/ScreenEdge.svelte';
import KindGlyph from '../components/KindGlyph.svelte';
import { fetchScreens, type WireScreensPayload, type WireScreenLink } from '../lib/api';
import { live } from '../lib/live.svelte';
import { symbolHref, fileHref } from '../lib/navigation';
import { isEdgeVisible, type MapEdgeLayout } from '../lib/map-model';
import { buildScreensModel, neighbourhood, viaText, type ScreensModel } from '../lib/screens-model';
let payload = $state<WireScreensPayload | null>(null);
let error = $state<string | null>(null);
let loading = $state(true);
let selected = $state<string | null>(null);
let hovered = $state<{ edge: MapEdgeLayout; x: number; y: number } | null>(null);
let stage = $state<HTMLDivElement | null>(null);
// The key stays open until the reader closes it; the choice survives a
// reload but is per browser — a preference, not a fact about the project.
const LEGEND_KEY = 'codegraph-ui:screens-legend';
let legendOpen = $state(readLegendOpen());
function readLegendOpen(): boolean {
try {
return localStorage.getItem(LEGEND_KEY) !== 'closed';
} catch {
return true;
}
}
$effect(() => {
try {
localStorage.setItem(LEGEND_KEY, legendOpen ? 'open' : 'closed');
} catch {
// Storage refused (private mode): the key simply reopens next time.
}
});
const FIT = { fitViewOptions: { padding: 0.1, maxZoom: 1, minZoom: 0.4 } };
const nodeTypes = { screen: ScreenNode };
const edgeTypes = { screen: ScreenEdge };
$effect(() => {
void live.indexTick;
const controller = new AbortController();
loading = true;
error = null;
fetchScreens(controller.signal)
.then((next) => {
payload = next;
loading = false;
})
.catch((err: unknown) => {
if (controller.signal.aborted) return;
error = err instanceof Error ? err.message : String(err);
loading = false;
});
return () => controller.abort();
});
const model = $derived<ScreensModel | null>(
payload === null || !payload.routed ? null : buildScreensModel(payload)
);
const neighbours = $derived.by(() => {
if (model === null || selected === null) return null;
const set = new Set<string>([selected]);
for (const edge of model.layout.edges) {
if (edge.source === selected) set.add(edge.target);
if (edge.target === selected) set.add(edge.source);
}
return set;
});
const nodes = $derived.by<Node[]>(() => {
if (model === null) return [];
return model.layout.nodes.map((node) => ({
id: node.id,
type: 'screen',
position: { x: node.x, y: node.y },
draggable: false,
selectable: false,
connectable: false,
data: {
layout: node,
info: model.nodes.get(node.id)!,
selected: selected === node.id,
dimmed: neighbours !== null && !neighbours.has(node.id),
onSelect: (id: string) => {
selected = selected === id ? null : id;
hovered = null;
},
},
}));
});
const edges = $derived.by<Edge[]>(() => {
if (model === null) return [];
return model.layout.edges
.filter((edge) => isEdgeVisible(edge, selected))
.map((edge) => ({
id: edge.id,
source: edge.source,
target: edge.target,
sourceHandle: edge.sourceHandle,
targetHandle: edge.targetHandle,
type: 'screen',
selectable: false,
deletable: false,
data: {
edge,
info: model.edges.get(edge.id)!,
hot: hovered?.edge.id === edge.id || (selected !== null && (edge.source === selected || edge.target === selected)),
dimmed: selected !== null && edge.source !== selected && edge.target !== selected,
// A label only where it can be read as belonging to one line: the
// selected screen's own edges (near that screen) and the hovered
// edge (near its source). Unselected, the picture is lines and
// boxes; the conditions are one click away.
labelled: hovered?.edge.id === edge.id || (selected !== null && (edge.source === selected || edge.target === selected)),
labelAt: selected !== null && edge.target === selected && edge.source !== selected ? 0.72 : 0.28,
onHover: onEdgeHover,
},
}));
});
const selectedInfo = $derived(selected === null || model === null ? null : (model.nodes.get(selected) ?? null));
const lists = $derived(
selected === null || payload === null ? null : neighbourhood(payload, selected)
);
const hoveredInfo = $derived(hovered === null || model === null ? null : (model.edges.get(hovered.edge.id) ?? null));
function onEdgeHover(edge: MapEdgeLayout | null, event: MouseEvent | null): void {
if (edge === null || event === null || stage === null) {
hovered = null;
return;
}
const box = stage.getBoundingClientRect();
hovered = {
edge,
x: Math.min(event.clientX - box.left + 14, box.width - 360),
y: event.clientY - box.top + 14,
};
}
function nameOf(id: string): string {
return model?.nodes.get(id)?.label ?? id;
}
/** The row the panel prints for one transition, seen from `side`. */
function sentence(link: WireScreenLink, side: 'from' | 'to'): string {
const other = side === 'from' ? nameOf(link.from) : nameOf(link.to);
return other;
}
</script>
<div class="screens">
<div class="stage" bind:this={stage}>
{#if error !== null}
<div class="state">
<h2>The screens could not be read</h2>
<p>{error}</p>
</div>
{:else if loading && payload === null}
<div class="state"><p class="dim">Reading screens and transitions…</p></div>
{:else if payload !== null && !payload.routed}
<div class="state">
<h2>No screen navigation in this graph</h2>
<p>
This view draws the routes a UI framework binds to components and the navigation calls
that reach them. The index has {payload.screens.length === 0 ? 'no routes' : 'routes'} but no
navigation between them — it is not an app with screens, or its router is one CodeGraph
does not read yet.
</p>
</div>
{:else if model !== null}
<SvelteFlow
{nodes}
{edges}
{nodeTypes}
{edgeTypes}
fitView
{...FIT}
minZoom={0.2}
maxZoom={1.6}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={false}
panOnDrag
proOptions={{ hideAttribution: true }}
onpaneclick={() => {
selected = null;
hovered = null;
}}
>
<Controls position="bottom-right" showLock={false} />
</SvelteFlow>
<!-- The key, on the picture it explains. Each row draws the actual
stroke or box, not a word for it — a reader matches shapes, not
descriptions. Collapsible, remembered per browser. -->
<div class="legend" class:open={legendOpen}>
<button class="legend-h" onclick={() => (legendOpen = !legendOpen)} aria-expanded={legendOpen}>
Key <span class="dim">{legendOpen ? '▾' : '▸'}</span>
</button>
{#if legendOpen}
<div class="legend-body">
<div class="lrow">
<svg width="44" height="12" aria-hidden="true"><path d="M2 6 H42" class="k-line" /></svg>
<span>Transition — the destination is written at the call</span>
</div>
<div class="lrow">
<svg width="44" height="12" aria-hidden="true"><path d="M2 6 H42" class="k-line k-synth" /></svg>
<span>Destination inferred from a helper's return value</span>
</div>
<div class="lrow">
<svg width="44" height="12" aria-hidden="true"><path d="M2 6 H42" class="k-line k-back" /></svg>
<span>Goes back up the picture (returning)</span>
</div>
<div class="lrow">
<span class="k-label mono">when x</span>
<span>Runs only under that condition (shown on the selected screen's lines); none = always</span>
</div>
<div class="lrow">
<span class="k-box mono">/path</span>
<span>A screen — its path and the component that renders it</span>
</div>
<div class="lrow">
<span class="k-box k-entry mono"><span class="mark"></span>/</span>
<span>The entry screen; each row down is one more transition away</span>
</div>
<div class="lrow">
<span class="k-box k-origin mono">fn()</span>
<span>Not a screen: shared chrome, or a trigger no screen reaches</span>
</div>
<div class="lrow">
<span class="k-box k-unreached mono">/path</span>
<span>Nothing reaches it from the entry (bottom band)</span>
</div>
</div>
{/if}
</div>
{#if hovered !== null && hoveredInfo !== null}
<div class="tip" style={`left:${hovered.x}px;top:${hovered.y}px`}>
<div class="mono"><b>{nameOf(hoveredInfo.from)}</b>{nameOf(hoveredInfo.to)}</div>
{#each hoveredInfo.links.slice(0, 5) as link (link.id)}
<div class="tiprow">
{#if link.when}<span class="when">when {link.when}</span>{:else}<span class="dim">always</span>{/if}
{#if link.via.length > 0}<span class="mono dim">via {viaText(link)}</span>{/if}
</div>
{/each}
{#if hoveredInfo.links.length > 5}<div class="dim">+{hoveredInfo.links.length - 5} more</div>{/if}
</div>
{/if}
{/if}
</div>
{#if payload !== null && model !== null}
<aside class="side">
{#if selectedInfo !== null && lists !== null}
<div class="head">
<div>
<div class="mono big">{selectedInfo.label}</div>
{#if selectedInfo.screen?.component}
<a class="sub" href={symbolHref(selectedInfo.screen.component.id)}>
<KindGlyph kind={selectedInfo.screen.component.kind} />
{selectedInfo.screen.component.name}
</a>
{:else if selectedInfo.origin}
<span class="sub dim">navigates from outside any screen</span>
{/if}
{#if selectedInfo.screen}
<a class="sub dim" href={fileHref(selectedInfo.screen.file)}>{selectedInfo.screen.file}</a>
{/if}
</div>
<button class="clear" onclick={() => (selected = null)}>clear</button>
</div>
<h4>Opens from <span class="dim">{lists.opensFrom.length}</span></h4>
{#if lists.opensFrom.length === 0}
<p class="dim">
{selectedInfo.entry ? 'The entry screen — the app starts here.' : 'Nothing in the graph navigates here.'}
</p>
{/if}
{#each lists.opensFrom as link (link.id)}
<div class="row">
<button class="peer mono" onclick={() => (selected = link.from)}>{sentence(link, 'from')}</button>
{#if link.when}<div class="when">when {link.when}</div>{/if}
{#if link.via.length > 0}<div class="via dim">via {viaText(link)}</div>{/if}
{#each link.sites as site (site.file + site.line)}
<a class="site dim" href={symbolHref(link.via[link.via.length - 1]?.id ?? selectedInfo.id, { line: site.line })}
>{site.method} {site.href} · {site.file.slice(site.file.lastIndexOf('/') + 1)}:{site.line}</a
>
{/each}
</div>
{/each}
<h4>Goes to <span class="dim">{lists.goesTo.length}</span></h4>
{#if lists.goesTo.length === 0}<p class="dim">No navigation leaves this screen.</p>{/if}
{#each lists.goesTo as link (link.id)}
<div class="row">
<button class="peer mono" onclick={() => (selected = link.to)}>{sentence(link, 'to')}</button>
{#if link.when}<div class="when">when {link.when}</div>{/if}
{#if link.via.length > 0}<div class="via dim">via {viaText(link)}</div>{/if}
{#each link.sites as site (site.file + site.line)}
<a
class="site dim"
href={symbolHref(link.via[link.via.length - 1]?.id ?? selectedInfo.screen?.component?.id ?? selectedInfo.id, { line: site.line })}
>{site.method} {site.href} · {site.file.slice(site.file.lastIndexOf('/') + 1)}:{site.line}</a
>
{/each}
</div>
{/each}
{:else}
<div class="head"><div class="big">Screens</div></div>
<p>
<b>{payload.screens.length}</b> screens · <b>{payload.links.length}</b> transitions{#if payload.origins.length > 0}
· <b>{payload.origins.length}</b> triggered outside a screen{/if}.
</p>
<p class="dim">
<span class="mark"></span> The entry screen is at the top; each row down is one more
transition away from it. An arrow's label is the condition under which that transition
happens, read from the code around the navigation call; hover it for the chain the call
travels through, click a screen for everything in and out of it.
</p>
<p class="dim">
Solid: the destination is written at the call. Dashed grey: it comes back from a
helper's return value (inferred). Dashed accent: a transition back up the picture
(returning). Dashed box: a trigger that is not a screen — shared chrome, or code no
screen's render chain reaches.
</p>
{#if model.unreached > 0}
<p class="dim">
<b>{model.unreached}</b> screen{model.unreached === 1 ? '' : 's'} in the band at the bottom: no
transition in the graph reaches {model.unreached === 1 ? 'it' : 'them'} from the entry — opened
by something the graph cannot see (a layout's initial route, a deep link), or unused.
</p>
{/if}
{#if payload.dropped > 0}
<p class="dim">{payload.dropped} navigation{payload.dropped === 1 ? '' : 's'} could not be attributed: the walk back to a screen hit a hub.</p>
{/if}
<h4>Most connected</h4>
{#each [...payload.screens].sort((a, b) => b.incoming + b.outgoing - (a.incoming + a.outgoing)).slice(0, 8) as screen (screen.id)}
<button class="peer mono" onclick={() => (selected = screen.id)}
>{screen.path} <span class="dim">{screen.incoming}{screen.outgoing}</span></button
>
{/each}
{/if}
</aside>
{/if}
</div>
<style>
.screens {
display: grid;
grid-template-columns: minmax(600px, 1fr) 340px;
height: 100%;
min-height: 0;
}
.stage {
position: relative;
overflow: hidden;
background: var(--paper);
}
.stage :global(.svelte-flow) {
background: var(--paper);
}
.stage :global(.svelte-flow__handle) {
opacity: 0;
width: 1px;
height: 1px;
min-width: 0;
min-height: 0;
border: 0;
pointer-events: none;
}
.stage :global(.svelte-flow__controls-button) {
background: var(--paper);
border: 0;
border-bottom: 1px solid var(--rule-soft);
border-radius: 0;
color: var(--ink-2);
}
.stage :global(.svelte-flow__controls-button svg) {
fill: var(--ink-2);
}
.state {
padding: 48px 40px;
max-width: 560px;
}
.state h2 {
font: 600 20px var(--sans);
margin: 0 0 8px;
}
.legend {
position: absolute;
left: 12px;
bottom: 12px;
z-index: 4;
max-width: 380px;
border: 1px solid var(--rule);
background: var(--paper);
font-size: 11.5px;
color: var(--ink-2);
}
.legend-h {
display: block;
width: 100%;
border: 0;
background: transparent;
padding: 5px 10px;
text-align: left;
color: var(--ink);
font: 600 12px var(--sans);
cursor: pointer;
}
.legend-body {
padding: 2px 10px 8px;
border-top: 1px solid var(--rule-soft);
}
.lrow {
display: flex;
align-items: center;
gap: 10px;
padding: 3px 0;
}
.lrow > :first-child {
flex: 0 0 44px;
display: inline-flex;
justify-content: center;
}
.k-line {
stroke: var(--ink);
stroke-opacity: 0.6;
stroke-width: 1.5;
fill: none;
}
.k-line.k-synth {
stroke-dasharray: 5 3;
}
.k-line.k-back {
stroke: var(--accent);
stroke-opacity: 0.8;
stroke-dasharray: 4 3;
}
.k-label {
font-size: 10.5px;
color: var(--ink-3);
}
.k-box {
box-sizing: border-box;
padding: 1px 5px;
border: 1px solid var(--ink);
font-size: 10.5px;
color: var(--ink);
line-height: 14px;
}
.k-box.k-origin {
border-style: dashed;
border-color: var(--ink-3);
}
.k-box.k-unreached {
border-color: var(--ink-4);
color: var(--ink-2);
}
.k-entry .mark {
font-size: 8px;
margin-right: 3px;
vertical-align: 1px;
}
.tip {
position: absolute;
z-index: 5;
width: 340px;
padding: 8px 10px;
border: 1px solid var(--ink);
background: var(--paper);
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
font-size: 12px;
pointer-events: none;
}
.tiprow {
display: flex;
flex-direction: column;
gap: 1px;
margin-top: 6px;
padding-top: 6px;
border-top: 1px solid var(--rule-soft);
}
.side {
border-left: 1px solid var(--rule);
padding: 14px 16px;
overflow: auto;
font-size: 12.5px;
}
.head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8px;
margin-bottom: 10px;
}
.big {
font-size: 15px;
font-weight: 600;
}
.sub {
display: flex;
align-items: center;
gap: 5px;
margin-top: 3px;
color: var(--ink-2);
text-decoration: none;
}
.sub:hover {
text-decoration: underline;
}
.clear {
border: 1px solid var(--rule);
background: transparent;
color: var(--ink-2);
font: inherit;
font-size: 11.5px;
padding: 1px 7px;
cursor: pointer;
}
h4 {
margin: 16px 0 6px;
font: 600 12.5px var(--sans);
}
.row {
padding: 7px 0;
border-top: 1px solid var(--rule-soft);
}
.peer {
display: block;
width: 100%;
border: 0;
background: transparent;
padding: 2px 0;
text-align: left;
color: var(--ink);
font: 500 12.5px var(--mono);
cursor: pointer;
}
.peer:hover {
text-decoration: underline;
}
.when {
color: var(--ink);
font: 400 11.5px var(--mono);
margin-top: 2px;
}
.via {
font: 400 11px var(--mono);
margin-top: 2px;
}
.site {
display: block;
font: 400 11px var(--mono);
margin-top: 2px;
text-decoration: none;
}
.site:hover {
text-decoration: underline;
}
.mono {
font-family: var(--mono);
}
.dim {
color: var(--ink-3);
}
.mark {
color: var(--accent);
}
</style>