feat(expo-router): add Expo Router support for Screens and navigations and introduce Steps API
Introduce Expo Router integration with a new Screens view and API to surface screens and transitions, plus a new Steps API and UI to depict typed steps from anchors or symbols. Extend codegraph’s extraction and resolution to handle namespace objects (export default NAME, two-statement forms, and default bindings) and React hook bindings for handlers, improving accuracy of flows across JS ↔ native boundaries. Add Swift/React Native bridge receiver evidence (RCT_EXTERN_MODULE, RCT_EXTERN_METHOD) and related resolution logic, with tests covering namespace-object resolution, useCallback-driven handlers, and inline RN event listeners. Update UI to include a Steps tab and associated components (StepsView, StepNode, ScreenEdge) and wire navigation to expose steps-based exploration via /api/steps and UI routes. Documentation and changelog reflect the new Expo Router integration and steps surface capabilities.
This commit is contained in:
@@ -8,6 +8,7 @@
|
||||
import FileCodeView from './views/FileCodeView.svelte';
|
||||
import MapView from './views/MapView.svelte';
|
||||
import ScreensView from './views/ScreensView.svelte';
|
||||
import StepsView from './views/StepsView.svelte';
|
||||
import FlowView from './views/FlowView.svelte';
|
||||
import EntryView from './views/EntryView.svelte';
|
||||
import DeadCodeView from './views/DeadCodeView.svelte';
|
||||
@@ -175,6 +176,8 @@
|
||||
<EntryView project={project.name} />
|
||||
{:else if route.view === 'screens' || (route.view === 'home' && hasScreens)}
|
||||
<ScreensView />
|
||||
{:else if route.view === 'steps'}
|
||||
<StepsView anchor={route.anchor} symbol={route.symbol} depth={route.depth} through={route.through} />
|
||||
{:else if route.view === 'dead'}
|
||||
<DeadCodeView exported={route.exported} />
|
||||
{:else if route.view === 'unknown'}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { router, mapHref, flowHref, entryHref, screensHref, deadHref, symbolHref } from '../lib/router.svelte';
|
||||
import { router, mapHref, flowHref, entryHref, screensHref, stepsHref, deadHref, symbolHref } from '../lib/router.svelte';
|
||||
import { trail } from '../lib/trail.svelte';
|
||||
import SearchPalette from './SearchPalette.svelte';
|
||||
import { live } from '../lib/live.svelte';
|
||||
@@ -68,6 +68,7 @@
|
||||
|
||||
<nav class="views" aria-label="Views">
|
||||
{#if showScreens}<a href={screensHref()} class:active={view === 'screens' || view === 'home'}>Screens</a>{/if}
|
||||
<a href={stepsHref()} class:active={view === 'steps'}>Steps</a>
|
||||
<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' && !showScreens)}>Symbol</a>
|
||||
|
||||
@@ -22,14 +22,15 @@
|
||||
*/
|
||||
import { BaseEdge, EdgeLabel, type EdgeProps } from '@xyflow/svelte';
|
||||
import type { MapEdgeLayout } from '../../lib/map-model';
|
||||
import { pathOf, type Curve, type PillPlacement, type ScreenEdgeInfo } from '../../lib/screens-model';
|
||||
import { pathOf, type Curve, type PillPlacement } from '../../lib/screens-model';
|
||||
|
||||
let { data }: EdgeProps = $props();
|
||||
|
||||
const d = $derived(
|
||||
data as unknown as {
|
||||
edge: MapEdgeLayout;
|
||||
info: ScreenEdgeInfo;
|
||||
/** The Screens view's edge info, or the Steps view's — only `synthesized` is read. */
|
||||
info: { synthesized: boolean };
|
||||
curve: Curve;
|
||||
/** One of the selected screen's, or under the pointer. */
|
||||
hot: boolean;
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* One step on the Steps view. The box is the Screens view's screen box with
|
||||
* a kind: a screen is drawn exactly as there; a handler is a plain box; a
|
||||
* native call or a native event carries an accent rule on its left, where
|
||||
* the language changes under the code; a store action sits on `--paper-2`;
|
||||
* a call that leaves the index is dashed, like a trigger no screen reaches
|
||||
* on the Screens view — a place the graph cannot follow into. The anchor
|
||||
* carries the entry mark. A step the walk was cut at ends its name with an
|
||||
* ellipsis, and its tooltip says which cap.
|
||||
*
|
||||
* Hidden handles along the top and bottom, one per port the layout decided
|
||||
* (`directional` ports), exactly as the screen box.
|
||||
*/
|
||||
import { Handle, Position, type NodeProps } from '@xyflow/svelte';
|
||||
import type { MapNodeLayout } from '../../lib/map-model';
|
||||
import { kindWord, type StepNodeInfo } from '../../lib/steps-model';
|
||||
|
||||
let { data }: NodeProps = $props();
|
||||
|
||||
const node = $derived(
|
||||
data as unknown as {
|
||||
layout: MapNodeLayout;
|
||||
info: StepNodeInfo;
|
||||
selected: boolean;
|
||||
dimmed: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
);
|
||||
const layout = $derived(node.layout);
|
||||
const info = $derived(node.info);
|
||||
const step = $derived(info.step);
|
||||
|
||||
const cutNote = $derived.by(() => {
|
||||
switch (step.cut) {
|
||||
case 'depth':
|
||||
return ' More happens past the depth of this picture — start here to see it.';
|
||||
case 'fan-out':
|
||||
return ' It reaches more than the walk follows from one node.';
|
||||
case 'folded':
|
||||
return ' The walk folded as much plumbing as it allows from one step.';
|
||||
case 'steps':
|
||||
return ' The picture reached its size limit here.';
|
||||
case 'screen':
|
||||
return ' Another screen — a chapter of its own. Start here to see what happens on it.';
|
||||
case 'component':
|
||||
return ' The event lands in a component of another screen — a picture of its own. Start here to see it.';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
});
|
||||
|
||||
function portStyle(index: number, total: number): string {
|
||||
return `left:${((index + 1) / (total + 1)) * 100}%`;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#each layout.ports.top as port, i (`${port.type}:${port.id}`)}
|
||||
<Handle
|
||||
type={port.type}
|
||||
id={`${port.type === 'source' ? 's' : 't'}:${port.id}`}
|
||||
position={Position.Top}
|
||||
style={portStyle(i, layout.ports.top.length)}
|
||||
isConnectable={false}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
<button
|
||||
class={`snode k-${step.kind}`}
|
||||
class:sel={node.selected}
|
||||
class:dimmed={node.dimmed}
|
||||
class:anchor={step.anchor}
|
||||
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}`}
|
||||
>
|
||||
<span class="name"
|
||||
>{#if step.anchor}<span class="mark" aria-hidden="true">●</span>{/if}{info.label}{#if step.cut !== null}<span
|
||||
class="more"
|
||||
aria-hidden="true"> …</span
|
||||
>{/if}</span
|
||||
>
|
||||
<span class="sub">{info.sub}</span>
|
||||
</button>
|
||||
|
||||
{#each layout.ports.bottom as port, i (`${port.type}:${port.id}`)}
|
||||
<Handle
|
||||
type={port.type}
|
||||
id={`${port.type === 'source' ? 's' : 't'}:${port.id}`}
|
||||
position={Position.Bottom}
|
||||
style={portStyle(i, layout.ports.bottom.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);
|
||||
}
|
||||
/* The language changes under the code: a rule where it does. */
|
||||
.snode.k-bridge,
|
||||
.snode.k-event {
|
||||
border-left: 3px solid var(--accent);
|
||||
padding-left: 7px;
|
||||
}
|
||||
.snode.k-bridge:hover,
|
||||
.snode.k-bridge.sel,
|
||||
.snode.k-event:hover,
|
||||
.snode.k-event.sel {
|
||||
border-left-width: 3px;
|
||||
padding-left: 7px;
|
||||
}
|
||||
.snode.k-bridge.dimmed,
|
||||
.snode.k-event.dimmed {
|
||||
border-left-color: var(--accent-line);
|
||||
}
|
||||
.snode.k-store {
|
||||
background: var(--paper-2);
|
||||
}
|
||||
.snode.k-store:hover,
|
||||
.snode.k-store.sel {
|
||||
background: var(--press);
|
||||
}
|
||||
/* Outside the index: a place the graph cannot follow into. */
|
||||
.snode.k-effect {
|
||||
border-style: dashed;
|
||||
border-color: var(--ink-3);
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.more {
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.sub {
|
||||
font: 400 11px var(--sans);
|
||||
line-height: 13px;
|
||||
color: var(--ink-3);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
@@ -36,6 +36,7 @@ import type {
|
||||
WireFlowPayload,
|
||||
WireMapPayload,
|
||||
WireScreensPayload,
|
||||
WireStepsPayload,
|
||||
WireNodeRefs,
|
||||
WireRoutes,
|
||||
WireSearch,
|
||||
@@ -178,6 +179,16 @@ export interface LiveHandlers {
|
||||
error(): void;
|
||||
}
|
||||
|
||||
/** What happens from an anchor: by id, or by name (the first screen-like match). */
|
||||
export interface StepsRequest {
|
||||
anchor?: string;
|
||||
symbol?: string;
|
||||
depth?: number;
|
||||
limit?: number;
|
||||
/** Enter the screens the walk reaches, instead of drawing them as boundaries. */
|
||||
through?: boolean;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- adapter -- */
|
||||
|
||||
/**
|
||||
@@ -213,6 +224,11 @@ export interface GraphAdapter {
|
||||
map(request?: MapRequest, signal?: AbortSignal): Promise<WireMapPayload>;
|
||||
/** The app's screens and the transitions between them, with their conditions. */
|
||||
screens(signal?: AbortSignal): Promise<WireScreensPayload>;
|
||||
/**
|
||||
* What happens from a screen or a symbol, as typed steps. Optional: a host
|
||||
* that has not wired it renders the Steps view as absent-and-explained.
|
||||
*/
|
||||
steps?(request: StepsRequest, signal?: AbortSignal): Promise<WireStepsPayload>;
|
||||
/** The URL → handler map. */
|
||||
routes(request?: RoutesRequest, signal?: AbortSignal): Promise<WireRoutes>;
|
||||
/** Where a reader starts: routes, files that run something, tests, hubs. */
|
||||
@@ -400,6 +416,16 @@ export function createHttpAdapter(options: HttpAdapterOptions = {}): GraphAdapte
|
||||
return getJson<WireScreensPayload>('api/screens', signal);
|
||||
},
|
||||
|
||||
steps(request = {}, signal) {
|
||||
const params = new URLSearchParams();
|
||||
if (request.anchor) params.set('anchor', request.anchor);
|
||||
else if (request.symbol) params.set('symbol', request.symbol);
|
||||
if (request.depth) params.set('depth', String(request.depth));
|
||||
if (request.limit) params.set('limit', String(request.limit));
|
||||
if (request.through) params.set('through', '1');
|
||||
return getJson<WireStepsPayload>(`api/steps${query(params)}`, signal);
|
||||
},
|
||||
|
||||
entryPoints(request = {}, signal) {
|
||||
const params = new URLSearchParams();
|
||||
if (request.limit) params.set('limit', String(request.limit));
|
||||
|
||||
+21
-1
@@ -20,6 +20,7 @@ import type {
|
||||
WireFlowPayload,
|
||||
WireMapPayload,
|
||||
WireScreensPayload,
|
||||
WireStepsPayload,
|
||||
WireNodeRefs,
|
||||
WireRoutes,
|
||||
WireSearch,
|
||||
@@ -28,7 +29,7 @@ import type {
|
||||
WireSymbolPayload,
|
||||
WireTrails,
|
||||
} from './wire';
|
||||
import type { SaveTrailRequest } from './adapter';
|
||||
import type { SaveTrailRequest, StepsRequest } from './adapter';
|
||||
|
||||
export * from './wire';
|
||||
export { ApiFailure } from './adapter';
|
||||
@@ -44,6 +45,7 @@ export type {
|
||||
SaveTrailRequest,
|
||||
SearchRequest,
|
||||
SourceRequest,
|
||||
StepsRequest,
|
||||
} from './adapter';
|
||||
|
||||
export function fetchStats(signal?: AbortSignal): Promise<WireStats> {
|
||||
@@ -146,6 +148,24 @@ export function fetchScreens(signal?: AbortSignal): Promise<WireScreensPayload>
|
||||
return getGraphAdapter().screens(signal);
|
||||
}
|
||||
|
||||
/**
|
||||
* What happens from an anchor — a screen, a handler, any symbol — as typed
|
||||
* steps with the conditions between them. Refused, not thrown at random, by
|
||||
* an adapter that never offered it (see {@link canDrawSteps}).
|
||||
*/
|
||||
export function fetchSteps(request: StepsRequest, signal?: AbortSignal): Promise<WireStepsPayload> {
|
||||
const adapter = getGraphAdapter();
|
||||
if (typeof adapter.steps !== 'function') {
|
||||
return Promise.reject(new ApiFailure(0, 'refused', 'This viewer cannot draw steps.', null));
|
||||
}
|
||||
return adapter.steps(request, signal);
|
||||
}
|
||||
|
||||
/** Whether the installed adapter can answer {@link fetchSteps} at all. */
|
||||
export function canDrawSteps(): boolean {
|
||||
return typeof getGraphAdapter().steps === 'function';
|
||||
}
|
||||
|
||||
export function fetchMap(
|
||||
opts: { root?: string | null; depth?: number } = {},
|
||||
signal?: AbortSignal
|
||||
|
||||
@@ -55,6 +55,16 @@ export interface FlowHrefOptions {
|
||||
trail?: string;
|
||||
}
|
||||
|
||||
export interface StepsHrefOptions {
|
||||
/** A node id — a screen's route, a handler, any symbol. */
|
||||
anchor?: string;
|
||||
/** A name, when no id is at hand; the answering side picks the most screen-like match. */
|
||||
symbol?: string;
|
||||
depth?: number;
|
||||
/** Enter the screens the walk reaches, instead of drawing them as boundaries. */
|
||||
through?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the components send the reader.
|
||||
*
|
||||
@@ -69,6 +79,7 @@ export interface NavigationDriver {
|
||||
flowHref(opts?: FlowHrefOptions): string;
|
||||
entryHref(): string;
|
||||
screensHref(): string;
|
||||
stepsHref(opts?: StepsHrefOptions): string;
|
||||
deadHref(opts?: DeadCodeHrefOptions): string;
|
||||
/** Go to an href this driver built. */
|
||||
navigate(href: string, opts?: { replace?: boolean }): void;
|
||||
@@ -138,6 +149,15 @@ export const hashNavigation: NavigationDriver = {
|
||||
return '#/screens';
|
||||
},
|
||||
|
||||
stepsHref(opts = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (opts.anchor) params.set('anchor', opts.anchor);
|
||||
else if (opts.symbol) params.set('symbol', opts.symbol);
|
||||
if (opts.depth) params.set('depth', String(opts.depth));
|
||||
if (opts.through) params.set('through', '1');
|
||||
return `#/steps${query(params)}`;
|
||||
},
|
||||
|
||||
deadHref(opts = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (opts.exported) params.set('exported', '1');
|
||||
@@ -221,6 +241,10 @@ export function screensHref(): string {
|
||||
return driver.screensHref();
|
||||
}
|
||||
|
||||
export function stepsHref(opts: StepsHrefOptions = {}): string {
|
||||
return driver.stepsHref(opts);
|
||||
}
|
||||
|
||||
export function deadHref(opts: DeadCodeHrefOptions = {}): string {
|
||||
return driver.deadHref(opts);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
* #/flow flow strip (?from=&to= | ?symbols= | ?t=<trail>)
|
||||
* #/entry entry points (where a flow starts)
|
||||
* #/screens screens (the app's screens and transitions)
|
||||
* #/steps steps (?anchor=<id> | ?symbol=<name>: what happens from there)
|
||||
* #/dead dead code (?exported=1 widens the claim)
|
||||
*
|
||||
* Node ids are opaque engine strings shaped `<kind>:<hash>` or
|
||||
@@ -43,6 +44,7 @@ export {
|
||||
navigate,
|
||||
screensHref,
|
||||
setNavigationDriver,
|
||||
stepsHref,
|
||||
symbolHref,
|
||||
} from './navigation';
|
||||
export type {
|
||||
@@ -51,6 +53,7 @@ export type {
|
||||
FlowHrefOptions,
|
||||
MapHrefOptions,
|
||||
NavigationDriver,
|
||||
StepsHrefOptions,
|
||||
SymbolHrefOptions,
|
||||
} from './navigation';
|
||||
|
||||
@@ -77,6 +80,14 @@ export type Route =
|
||||
}
|
||||
| { view: 'entry' }
|
||||
| { view: 'screens' }
|
||||
| {
|
||||
view: 'steps';
|
||||
/** The anchor by id; null with `symbol` set, or on the bare tab. */
|
||||
anchor: string | null;
|
||||
symbol: string | null;
|
||||
depth: number | null;
|
||||
through: boolean;
|
||||
}
|
||||
| {
|
||||
view: 'dead';
|
||||
/** Symbols reachable from outside the index are on the list. */
|
||||
@@ -141,6 +152,17 @@ export function parseHash(hash: string): RouterLocation {
|
||||
route = { view: 'entry' };
|
||||
} else if (head === 'screens' && rest.length === 0) {
|
||||
route = { view: 'screens' };
|
||||
} else if (head === 'steps' && rest.length === 0) {
|
||||
// The anchor travels in the URL, so "what happens on the review screen"
|
||||
// is a link that reopens as the same picture.
|
||||
const depth = Number.parseInt(params.get('depth') ?? '', 10);
|
||||
route = {
|
||||
view: 'steps',
|
||||
anchor: params.get('anchor'),
|
||||
symbol: params.get('symbol'),
|
||||
depth: Number.isFinite(depth) && depth >= 1 && depth <= 14 ? depth : null,
|
||||
through: params.get('through') === '1',
|
||||
};
|
||||
} 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.
|
||||
|
||||
@@ -123,6 +123,19 @@ export interface Point {
|
||||
y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the label placement and the pointer need from a picture: the Screens
|
||||
* view's model, or any other drawn with its machinery (the Steps view draws
|
||||
* typed steps with the same layout, curves, pills and hit-testing).
|
||||
*/
|
||||
export interface Picture {
|
||||
layout: MapLayout;
|
||||
layerGap: number;
|
||||
edges: Map<string, { label: string }>;
|
||||
curves: Map<string, Curve>;
|
||||
polylines: Map<string, Point[]>;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- layering -- */
|
||||
|
||||
/**
|
||||
@@ -286,7 +299,7 @@ export function clauses(when: string): string[] {
|
||||
* …` on both arms of a fork); the last clause is the one that tells the two
|
||||
* apart, and the full text is a hover away.
|
||||
*/
|
||||
export function edgeLabel(links: readonly WireScreenLink[]): string {
|
||||
export function edgeLabel(links: ReadonlyArray<{ when: string }>): string {
|
||||
if (links.length === 1) {
|
||||
const when = links[0]!.when;
|
||||
if (!when) return '';
|
||||
@@ -630,7 +643,7 @@ export interface EdgeHit {
|
||||
* the smaller id, so two visits agree.
|
||||
*/
|
||||
export function nearestEdge(
|
||||
model: ScreensModel,
|
||||
model: Picture,
|
||||
point: Point,
|
||||
among: ReadonlySet<string> | null,
|
||||
reach: number
|
||||
@@ -713,7 +726,7 @@ export function laneCount(layerGap: number): number {
|
||||
* the selected screen — `→` leaving it, `←` arriving — and the edge's label.
|
||||
* Empty when the edge has nothing to say (a single, unconditional transition).
|
||||
*/
|
||||
export function pillText(info: ScreenEdgeInfo, edge: MapEdgeLayout, selected: string | null): string {
|
||||
export function pillText(info: { label: string }, edge: MapEdgeLayout, selected: string | null): string {
|
||||
if (!info.label) return '';
|
||||
const arriving = selected !== null && edge.target === selected && edge.source !== selected;
|
||||
return `${arriving ? '←' : '→'} ${info.label}`;
|
||||
@@ -737,7 +750,7 @@ function intersects(a: Rect, b: Rect, gapX: number): boolean {
|
||||
* lane is free — or when `lanes` is 1 and that lane is taken.
|
||||
*/
|
||||
function layPill(
|
||||
model: ScreensModel,
|
||||
model: Picture,
|
||||
edge: MapEdgeLayout,
|
||||
end: 'source' | 'target',
|
||||
text: string,
|
||||
@@ -781,7 +794,7 @@ function layPill(
|
||||
* pill: the pill for a hovered edge that is not the selected screen's is
|
||||
* placed separately by {@link hoverPill}.
|
||||
*/
|
||||
export function placeLabels(model: ScreensModel, selected: string | null): PillLayout {
|
||||
export function placeLabels(model: Picture, selected: string | null): PillLayout {
|
||||
const pills = new Map<string, PillPlacement>();
|
||||
if (selected === null) return { pills, hidden: 0 };
|
||||
const nodes = new Map(model.layout.nodes.map((n) => [n.id, n]));
|
||||
@@ -827,7 +840,7 @@ export function placeLabels(model: ScreensModel, selected: string | null): PillL
|
||||
* with the whole condition, not the connector's short label.
|
||||
*/
|
||||
export function hoverPill(
|
||||
model: ScreensModel,
|
||||
model: Picture,
|
||||
edgeId: string,
|
||||
selected: string | null,
|
||||
text?: string,
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* The Steps view's model — what happens from an anchor, as typed steps laid
|
||||
* out so that a step sits above the steps it sets in motion.
|
||||
*
|
||||
* Everything geometric is the Screens view's (`screens-model.ts`): the Map's
|
||||
* layout with directional ports, a curve per edge on a track of its own, the
|
||||
* pills that label a selected step's links at the far end of each line, and
|
||||
* the nearest-line pointer. What is this file's own is small: the row a step
|
||||
* sits on is its distance from the anchor, which the server already counted
|
||||
* (`WireStep.depth`), so the layering is a lookup rather than a search; the
|
||||
* words in a box come from the step's kind; and the side panel's two lists
|
||||
* are the links into and out of the selected step.
|
||||
*/
|
||||
|
||||
import type { WireMapLink, WireMapModule, WireStep, WireStepLink, WireStepsPayload } from './wire';
|
||||
import { buildMapLayout, linkId, PORT_PITCH, type MapLayout } from './map-model';
|
||||
import {
|
||||
edgeLabel,
|
||||
samplePolyline,
|
||||
trackedCurves,
|
||||
SCREEN_LAYER_GAP,
|
||||
type Curve,
|
||||
type Picture,
|
||||
type Point,
|
||||
} from './screens-model';
|
||||
|
||||
export interface StepNodeInfo {
|
||||
id: string;
|
||||
step: WireStep;
|
||||
/** What the box prints on its first line. */
|
||||
label: string;
|
||||
/** …and on its second. */
|
||||
sub: string;
|
||||
}
|
||||
|
||||
export interface StepEdgeInfo {
|
||||
id: string;
|
||||
from: string;
|
||||
to: string;
|
||||
/** Every link between the pair — one connector, several stories. */
|
||||
links: WireStepLink[];
|
||||
/** The connector's short label: the innermost condition, or how many links. */
|
||||
label: string;
|
||||
/** Every link behind it was synthesized (a dynamic-dispatch bridge). */
|
||||
synthesized: boolean;
|
||||
/** The kind the links agree on, or `calls` when they differ. */
|
||||
kind: WireStepLink['kind'];
|
||||
}
|
||||
|
||||
export interface StepsModel extends Picture {
|
||||
layout: MapLayout;
|
||||
nodes: Map<string, StepNodeInfo>;
|
||||
edges: Map<string, StepEdgeInfo>;
|
||||
layerGap: number;
|
||||
curves: Map<string, Curve>;
|
||||
polylines: Map<string, Point[]>;
|
||||
/** Steps per kind, for the panel's summary. */
|
||||
counts: Record<WireStep['kind'], number>;
|
||||
}
|
||||
|
||||
/** Points a curve is sampled at for hit-testing (as the Screens view's). */
|
||||
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 {
|
||||
switch (kind) {
|
||||
case 'screen':
|
||||
return 'screen';
|
||||
case 'trigger':
|
||||
return 'handler';
|
||||
case 'bridge':
|
||||
return 'native call';
|
||||
case 'event':
|
||||
return 'native event';
|
||||
case 'store':
|
||||
return 'store action';
|
||||
case 'effect':
|
||||
return 'outside the index';
|
||||
default:
|
||||
return 'start';
|
||||
}
|
||||
}
|
||||
|
||||
/** The first line of a step's box. Boundary crossings carry an arrow for which way the code goes. */
|
||||
export function stepLabel(step: WireStep): string {
|
||||
switch (step.kind) {
|
||||
case 'bridge':
|
||||
return `⇢ ${step.label}`;
|
||||
case 'event': {
|
||||
const events = step.events ?? (step.event ? [step.event] : []);
|
||||
if (events.length === 0) return `⇠ ${step.label}`;
|
||||
return events.length === 1 ? `⇠ ${events[0]}` : `⇠ ${events[0]} +${events.length - 1}`;
|
||||
}
|
||||
default:
|
||||
return step.label;
|
||||
}
|
||||
}
|
||||
|
||||
/** The second line: what the step is, then where it is. */
|
||||
export function stepSub(step: WireStep): string {
|
||||
const file = step.node ? step.node.file.slice(step.node.file.lastIndexOf('/') + 1) : '';
|
||||
switch (step.kind) {
|
||||
case 'screen':
|
||||
return step.sub;
|
||||
case 'trigger':
|
||||
return `handler · ${file}`;
|
||||
case 'bridge':
|
||||
return `native · ${file}`;
|
||||
case 'event':
|
||||
return `${step.label} · ${file}`;
|
||||
case 'store':
|
||||
return `store · ${file}`;
|
||||
case 'effect':
|
||||
return step.sub;
|
||||
default:
|
||||
return step.sub;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- build -- */
|
||||
|
||||
export function buildStepsModel(payload: WireStepsPayload): StepsModel {
|
||||
const nodes = new Map<string, StepNodeInfo>();
|
||||
const modules: WireMapModule[] = [];
|
||||
const counts: Record<WireStep['kind'], number> = {
|
||||
anchor: 0,
|
||||
screen: 0,
|
||||
trigger: 0,
|
||||
bridge: 0,
|
||||
event: 0,
|
||||
store: 0,
|
||||
effect: 0,
|
||||
};
|
||||
const degree = new Map<string, number>();
|
||||
for (const link of payload.links) {
|
||||
degree.set(link.from, (degree.get(link.from) ?? 0) + 1);
|
||||
degree.set(link.to, (degree.get(link.to) ?? 0) + 1);
|
||||
}
|
||||
for (const step of payload.steps) {
|
||||
counts[step.kind]++;
|
||||
const info: StepNodeInfo = { id: step.id, step, label: stepLabel(step), sub: stepSub(step) };
|
||||
nodes.set(step.id, info);
|
||||
modules.push({
|
||||
id: step.id,
|
||||
label: info.label,
|
||||
files: 1,
|
||||
symbols: degree.get(step.id) ?? 0,
|
||||
languages: [],
|
||||
test: false,
|
||||
generated: 0,
|
||||
generatedFiles: [],
|
||||
facade: false,
|
||||
fileList: { total: 1, shown: 1, truncated: false, items: [step.node?.file ?? step.sub] },
|
||||
});
|
||||
}
|
||||
|
||||
// One layout link per (from, to); the links behind it stay listed.
|
||||
const byPair = new Map<string, WireStepLink[]>();
|
||||
for (const link of payload.links) {
|
||||
if (!nodes.has(link.from) || !nodes.has(link.to) || link.from === link.to) continue;
|
||||
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, StepEdgeInfo>();
|
||||
for (const [key, group] of byPair) {
|
||||
const first = group[0]!;
|
||||
links.push({
|
||||
source: first.from,
|
||||
target: first.to,
|
||||
count: group.length,
|
||||
declared: group.length,
|
||||
byKind: [{ kind: 'calls', 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),
|
||||
kind: group.every((l) => l.kind === first.kind) ? first.kind : 'calls',
|
||||
});
|
||||
}
|
||||
|
||||
// Layer = distance from the anchor, counted by the server. Layer 0 is the
|
||||
// bottom, so the deepest row is 0 and the anchor is on top.
|
||||
const depthOf = new Map(payload.steps.map((s) => [s.id, s.depth]));
|
||||
const deepest = Math.max(0, ...payload.steps.map((s) => s.depth));
|
||||
const layering = (ids: string[]): Map<string, number> =>
|
||||
new Map(ids.map((id) => [id, deepest - (depthOf.get(id) ?? deepest)]));
|
||||
|
||||
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,
|
||||
layerGap: SCREEN_LAYER_GAP,
|
||||
portPitch: PORT_PITCH,
|
||||
ports: 'directional',
|
||||
}
|
||||
);
|
||||
const curves = trackedCurves(layout, SCREEN_LAYER_GAP);
|
||||
const polylines = new Map<string, Point[]>();
|
||||
for (const [id, curve] of curves) polylines.set(id, samplePolyline(curve, HIT_SAMPLES));
|
||||
return { layout, nodes, edges, layerGap: SCREEN_LAYER_GAP, curves, polylines, counts };
|
||||
}
|
||||
|
||||
/** The side panel's two lists for a selected step. */
|
||||
export function stepNeighbourhood(
|
||||
payload: WireStepsPayload,
|
||||
id: string
|
||||
): { arrivesFrom: WireStepLink[]; leadsTo: WireStepLink[] } {
|
||||
return {
|
||||
arrivesFrom: payload.links.filter((l) => l.to === id),
|
||||
leadsTo: payload.links.filter((l) => l.from === id),
|
||||
};
|
||||
}
|
||||
|
||||
/** `useReviewHandlers → handleApproveAllImages`, or '' when nothing was folded. */
|
||||
export function stepViaText(link: WireStepLink): string {
|
||||
return link.via.map((v) => v.name).join(' → ');
|
||||
}
|
||||
|
||||
/** The layout edge a link draws as, or null when it is a self-loop. */
|
||||
export function stepPairId(link: WireStepLink): string | null {
|
||||
return link.from === link.to ? null : linkId({ source: link.from, target: link.to });
|
||||
}
|
||||
@@ -694,6 +694,76 @@ export interface WireScreensPayload {
|
||||
timing: { elapsedMs: number };
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ steps -- */
|
||||
|
||||
export type WireStepKind = 'anchor' | 'screen' | 'trigger' | 'bridge' | 'event' | 'store' | 'effect';
|
||||
|
||||
export type WireStepLinkKind = 'calls' | 'navigates' | 'handler' | 'bridge' | 'event' | 'store' | 'effect';
|
||||
|
||||
export interface WireStepSite {
|
||||
file: string;
|
||||
line: number;
|
||||
/** `push /capture`, `calls`, `client.post` — what the site does, in a word or two. */
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface WireStep {
|
||||
/** The node's id, or `effect:<function id>:<api>` for a call leaving the index. */
|
||||
id: string;
|
||||
kind: WireStepKind;
|
||||
/** The step the picture starts from. A screen anchor keeps `kind: 'screen'`. */
|
||||
anchor: boolean;
|
||||
/** Null only for an effect, which is a call site rather than a symbol. */
|
||||
node: WireNodeRef | null;
|
||||
label: string;
|
||||
sub: string;
|
||||
/** Steps from the anchor: the row. */
|
||||
depth: number;
|
||||
/**
|
||||
* Why the walk did not go on from this step: a cap (`depth`, `fan-out`,
|
||||
* `folded`, `steps`), or `screen` — another screen, drawn as a boundary.
|
||||
*/
|
||||
cut: 'depth' | 'fan-out' | 'folded' | 'steps' | 'screen' | 'component' | null;
|
||||
/** The event name a native event step arrived on — the first, when several land here. */
|
||||
event?: string;
|
||||
/** Every event that lands on this step. */
|
||||
events?: string[];
|
||||
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 };
|
||||
}
|
||||
|
||||
export interface WireStepLink {
|
||||
id: string;
|
||||
from: string;
|
||||
to: string;
|
||||
kind: WireStepLinkKind;
|
||||
/** The symbols folded between the two steps, in order. */
|
||||
via: WireNodeRef[];
|
||||
/** Conditions along the whole chain, joined; '' when unconditional. */
|
||||
when: string;
|
||||
/** How the last hop was established when it was not a plain call. */
|
||||
label: string;
|
||||
synthesized: boolean;
|
||||
uncertain: boolean;
|
||||
sites: WireStepSite[];
|
||||
}
|
||||
|
||||
export interface WireStepsPayload {
|
||||
anchor: WireNodeRef;
|
||||
/** Other symbols that share the anchor's name, when it was given by name. */
|
||||
ambiguous: WireNodeRef[];
|
||||
steps: WireStep[];
|
||||
links: WireStepLink[];
|
||||
depth: number;
|
||||
limit: number;
|
||||
/** Screens reached from the anchor were entered rather than drawn as boundaries. */
|
||||
through: boolean;
|
||||
truncated: { steps: number; hubs: number; chrome: number };
|
||||
index: { lastIndexedAt: number | null; edges: number; files: number };
|
||||
timing: { elapsedMs: number };
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- dead code -- */
|
||||
|
||||
/** One symbol nothing in the index reaches. */
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
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 { symbolHref, fileHref, stepsHref } from '../lib/navigation';
|
||||
import { isEdgeVisible, type MapEdgeLayout } from '../lib/map-model';
|
||||
import {
|
||||
buildScreensModel,
|
||||
@@ -392,6 +392,7 @@
|
||||
{#if selectedInfo.screen}
|
||||
<a class="sub dim" href={fileHref(selectedInfo.screen.file)}>{selectedInfo.screen.file}</a>
|
||||
{/if}
|
||||
<a class="sub act" href={stepsHref({ anchor: selectedInfo.id })}>What happens here →</a>
|
||||
</div>
|
||||
<button class="clear" onclick={() => (selected = null)}>clear</button>
|
||||
</div>
|
||||
@@ -664,6 +665,9 @@
|
||||
.sub:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.act {
|
||||
color: var(--accent);
|
||||
}
|
||||
.clear {
|
||||
border: 1px solid var(--rule);
|
||||
background: transparent;
|
||||
|
||||
@@ -0,0 +1,928 @@
|
||||
<!--
|
||||
The Steps view (`#/steps?anchor=…`): what happens from here. One box per
|
||||
step — a screen, a handler, a call into native code, a native event landing
|
||||
back in JS, a store action, a call that leaves the index — an arrow for
|
||||
every way one leads to the next, and on each arrow the condition under
|
||||
which it happens, with the plumbing between two steps folded into the arrow
|
||||
and listed in the panel.
|
||||
|
||||
Everything drawn comes from `/api/steps`: the anchor's forward walk through
|
||||
calls, renders, handler bindings and navigations, classified as it goes, and
|
||||
branch guards read from the source. The canvas is the Screens view's
|
||||
machinery with a different node universe (see `steps-model.ts`); the side
|
||||
panel is where the sentences are, and where a step becomes the next anchor
|
||||
or a Flow strip between two steps.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { SvelteFlow, Controls, type Node, type Edge, type Viewport } from '@xyflow/svelte';
|
||||
import '@xyflow/svelte/dist/style.css';
|
||||
import StepNode from '../components/steps/StepNode.svelte';
|
||||
import ScreenEdge from '../components/screens/ScreenEdge.svelte';
|
||||
import KindGlyph from '../components/KindGlyph.svelte';
|
||||
import {
|
||||
canDrawSteps,
|
||||
fetchScreens,
|
||||
fetchSteps,
|
||||
type WireScreen,
|
||||
type WireStepLink,
|
||||
type WireStepsPayload,
|
||||
} from '../lib/api';
|
||||
import { live } from '../lib/live.svelte';
|
||||
import { fileHref, flowHref, navigate, stepsHref, symbolHref } from '../lib/navigation';
|
||||
import { isEdgeVisible, type MapEdgeLayout } from '../lib/map-model';
|
||||
import { hoverPill, nearestEdge, placeLabels } from '../lib/screens-model';
|
||||
import {
|
||||
buildStepsModel,
|
||||
kindWord,
|
||||
stepNeighbourhood,
|
||||
stepPairId,
|
||||
stepViaText,
|
||||
type StepsModel,
|
||||
} from '../lib/steps-model';
|
||||
|
||||
interface Props {
|
||||
anchor: string | null;
|
||||
symbol: string | null;
|
||||
depth: number | null;
|
||||
/** Enter the screens the walk reaches, instead of drawing them as boundaries. */
|
||||
through: boolean;
|
||||
}
|
||||
let { anchor, symbol, depth, through }: Props = $props();
|
||||
|
||||
let payload = $state<WireStepsPayload | 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);
|
||||
/** The panel row under the pointer: its edge on the canvas, and the one link it names. */
|
||||
let panelHot = $state<{ edge: string; link: WireStepLink } | null>(null);
|
||||
let stage = $state<HTMLDivElement | null>(null);
|
||||
let viewport = $state<Viewport | undefined>(undefined);
|
||||
const HOVER_REACH = 10;
|
||||
|
||||
/** The chooser's list, when the view opens without an anchor. */
|
||||
let screens = $state<WireScreen[] | null>(null);
|
||||
|
||||
const LEGEND_KEY = 'codegraph-ui:steps-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 = { step: StepNode };
|
||||
const edgeTypes = { screen: ScreenEdge };
|
||||
const DEPTHS = [4, 6, 8, 10, 12];
|
||||
|
||||
const asked = $derived(anchor !== null || symbol !== null);
|
||||
const supported = canDrawSteps();
|
||||
|
||||
$effect(() => {
|
||||
void live.indexTick;
|
||||
const request =
|
||||
anchor !== null
|
||||
? { anchor, depth: depth ?? undefined, through }
|
||||
: symbol !== null
|
||||
? { symbol, depth: depth ?? undefined, through }
|
||||
: null;
|
||||
const controller = new AbortController();
|
||||
selected = null;
|
||||
hovered = null;
|
||||
panelHot = null;
|
||||
if (request === null) {
|
||||
payload = null;
|
||||
loading = false;
|
||||
error = null;
|
||||
fetchScreens(controller.signal)
|
||||
.then((next) => {
|
||||
screens = next.routed ? next.screens : [];
|
||||
})
|
||||
.catch(() => {
|
||||
screens = [];
|
||||
});
|
||||
return () => controller.abort();
|
||||
}
|
||||
loading = true;
|
||||
error = null;
|
||||
fetchSteps(request, 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<StepsModel | null>(payload === null ? null : buildStepsModel(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 pills = $derived(model === null ? null : placeLabels(model, selected));
|
||||
const focusId = $derived(hovered?.edge.id ?? panelHot?.edge ?? null);
|
||||
const focusPill = $derived.by(() => {
|
||||
if (model === null || focusId === null || pills?.pills.has(focusId)) return null;
|
||||
const full = panelHot?.edge === focusId ? fullText(panelHot.link) : undefined;
|
||||
return hoverPill(model, focusId, selected, full, pills ?? undefined);
|
||||
});
|
||||
|
||||
const nodes = $derived.by<Node[]>(() => {
|
||||
if (model === null) return [];
|
||||
return model.layout.nodes.map((node) => ({
|
||||
id: node.id,
|
||||
type: 'step',
|
||||
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;
|
||||
panelHot = null;
|
||||
},
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
const edges = $derived.by<Edge[]>(() => {
|
||||
if (model === null) return [];
|
||||
const focus = focusId;
|
||||
return model.layout.edges
|
||||
.filter((edge) => isEdgeVisible(edge, selected))
|
||||
.map((edge) => {
|
||||
const touches = selected !== null && (edge.source === selected || edge.target === selected);
|
||||
const isFocus = focus === edge.id;
|
||||
const hot = isFocus || touches;
|
||||
return {
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
sourceHandle: edge.sourceHandle,
|
||||
targetHandle: edge.targetHandle,
|
||||
type: 'screen',
|
||||
selectable: false,
|
||||
deletable: false,
|
||||
zIndex: isFocus ? 3 : hot ? 2 : 1,
|
||||
data: {
|
||||
edge,
|
||||
info: model.edges.get(edge.id)!,
|
||||
curve: model.curves.get(edge.id)!,
|
||||
hot,
|
||||
soft: hot && focus !== null && !isFocus,
|
||||
focus: isFocus,
|
||||
dimmed: selected !== null && !touches,
|
||||
pill: pills?.pills.get(edge.id) ?? (isFocus ? focusPill : null),
|
||||
full: panelHot?.edge === edge.id ? fullText(panelHot.link) : null,
|
||||
onHover: onEdgeHover,
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const selectedInfo = $derived(selected === null || model === null ? null : (model.nodes.get(selected) ?? null));
|
||||
const lists = $derived(selected === null || payload === null ? null : stepNeighbourhood(payload, selected));
|
||||
const hoveredInfo = $derived(hovered === null || model === null ? null : (model.edges.get(hovered.edge.id) ?? null));
|
||||
const edgeById = $derived(
|
||||
model === null ? new Map<string, MapEdgeLayout>() : new Map(model.layout.edges.map((e) => [e.id, e]))
|
||||
);
|
||||
const visibleIds = $derived(new Set(edges.map((e) => e.id)));
|
||||
|
||||
/** The same picture with one setting changed: the anchor as the URL asked for it, the rest kept. */
|
||||
function rewrite(changes: { depth?: number; through?: boolean }): string {
|
||||
const opts = {
|
||||
anchor: anchor ?? undefined,
|
||||
symbol: anchor === null ? (symbol ?? undefined) : undefined,
|
||||
depth: changes.depth ?? depth ?? undefined,
|
||||
through: changes.through ?? through,
|
||||
};
|
||||
return stepsHref(opts);
|
||||
}
|
||||
|
||||
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 onStageMove(event: MouseEvent): void {
|
||||
if (model === null || stage === null) return;
|
||||
const target = event.target as Element | null;
|
||||
if (target?.closest('.spill')) return;
|
||||
if (target?.closest('.snode, .legend, .tip, .svelte-flow__controls')) {
|
||||
hovered = null;
|
||||
return;
|
||||
}
|
||||
const view = viewport ?? readViewport();
|
||||
if (!view) return;
|
||||
const box = stage.getBoundingClientRect();
|
||||
const point = {
|
||||
x: (event.clientX - box.left - view.x) / view.zoom,
|
||||
y: (event.clientY - box.top - view.y) / view.zoom,
|
||||
};
|
||||
const hit = nearestEdge(model, point, visibleIds, HOVER_REACH / view.zoom);
|
||||
const edge = hit === null ? undefined : edgeById.get(hit.id);
|
||||
if (!edge) {
|
||||
hovered = null;
|
||||
return;
|
||||
}
|
||||
hovered = {
|
||||
edge,
|
||||
x: Math.min(event.clientX - box.left + 14, box.width - 360),
|
||||
y: event.clientY - box.top + 14,
|
||||
};
|
||||
}
|
||||
|
||||
function readViewport(): Viewport | null {
|
||||
const el = stage?.querySelector<HTMLElement>('.svelte-flow__viewport');
|
||||
const m = el?.style.transform.match(/translate\(([-\d.]+)px,\s*([-\d.]+)px\)\s*scale\(([-\d.]+)\)/);
|
||||
return m ? { x: Number(m[1]), y: Number(m[2]), zoom: Number(m[3]) } : null;
|
||||
}
|
||||
|
||||
function onRowHover(link: WireStepLink | null): void {
|
||||
const edge = link === null ? null : stepPairId(link);
|
||||
panelHot = link === null || edge === null ? null : { edge, link };
|
||||
}
|
||||
|
||||
/** The words a panel row puts on its line: the arrow, and the whole condition. */
|
||||
function fullText(link: WireStepLink): string {
|
||||
const arriving = selected !== null && link.to === selected && link.from !== selected;
|
||||
return `${arriving ? '←' : '→'} ${link.when || 'always'}`;
|
||||
}
|
||||
|
||||
function rowHot(link: WireStepLink): boolean {
|
||||
if (panelHot !== null) return panelHot.link.id === link.id;
|
||||
return hovered !== null && stepPairId(link) === hovered.edge.id;
|
||||
}
|
||||
|
||||
function nameOf(id: string): string {
|
||||
return model?.nodes.get(id)?.label ?? id;
|
||||
}
|
||||
|
||||
/** A Flow strip between the two symbols of a link, when both are symbols. */
|
||||
function stripHref(link: WireStepLink): string | null {
|
||||
const from = payload?.steps.find((s) => s.id === link.from)?.node;
|
||||
const to = payload?.steps.find((s) => s.id === link.to)?.node;
|
||||
if (!from || !to) return null;
|
||||
return flowHref({ from: from.name, to: to.name });
|
||||
}
|
||||
|
||||
/** The symbol a site's line belongs to: the last folded symbol, else the step's own. */
|
||||
function siteHref(link: WireStepLink, site: { file: string; line: number }, fallback: string | null): string | null {
|
||||
const last = link.via[link.via.length - 1];
|
||||
const id = last?.id ?? fallback;
|
||||
return id === null ? null : symbolHref(id, { line: site.line });
|
||||
}
|
||||
|
||||
function basename(file: string): string {
|
||||
return file.slice(file.lastIndexOf('/') + 1);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="steps">
|
||||
<div class="stage" bind:this={stage} role="presentation" onmousemove={onStageMove} onmouseleave={() => (hovered = null)}>
|
||||
{#if !supported}
|
||||
<div class="state">
|
||||
<h2>This viewer cannot draw steps</h2>
|
||||
<p>The host it runs in has not wired the steps question. The Screens and Flow views still work.</p>
|
||||
</div>
|
||||
{: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=<name></span>.
|
||||
</p>
|
||||
{:else}
|
||||
<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 })}
|
||||
>{screen.path} <span class="dim sans">{screen.component?.name ?? basename(screen.file)}</span></a
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if error !== null}
|
||||
<div class="state">
|
||||
<h2>The steps could not be read</h2>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
{:else if loading && payload === null}
|
||||
<div class="state"><p class="dim">Walking from the anchor…</p></div>
|
||||
{:else if model !== null && payload !== null}
|
||||
<SvelteFlow
|
||||
{nodes}
|
||||
{edges}
|
||||
{nodeTypes}
|
||||
{edgeTypes}
|
||||
fitView
|
||||
{...FIT}
|
||||
bind:viewport
|
||||
minZoom={0.2}
|
||||
maxZoom={3}
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
elementsSelectable={false}
|
||||
panOnDrag
|
||||
proOptions={{ hideAttribution: true }}
|
||||
onpaneclick={() => {
|
||||
selected = null;
|
||||
hovered = null;
|
||||
panelHot = null;
|
||||
}}
|
||||
>
|
||||
<Controls position="bottom-right" showLock={false} />
|
||||
</SvelteFlow>
|
||||
|
||||
<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">
|
||||
<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 wired to a tap or a listener</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>
|
||||
<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>
|
||||
</div>
|
||||
<div class="lrow">
|
||||
<svg width="44" height="12" aria-hidden="true"><path d="M2 6 H42" class="k-line k-synth" /></svg>
|
||||
<span>Established by a synthesized hop (an event channel, a callback, 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 — leaves the top of its box, arrives at the bottom of the other</span>
|
||||
</div>
|
||||
<div class="lrow">
|
||||
<span class="k-label mono">→ …x</span>
|
||||
<span>The last condition checked before the step, beside the box at the other end of the selected step's line; ← when it arrives there. None = always</span>
|
||||
</div>
|
||||
<div class="lrow">
|
||||
<span class="k-label mono">name …</span>
|
||||
<span>Not entered: another screen (a chapter of its own), or a cap the walk hit — start there to see on</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 {stepViaText(link)}</span>{/if}
|
||||
{#if link.label}<span class="dim">{link.label}</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>
|
||||
<div class="sub dim">{kindWord(selectedInfo.step.kind)}{#if selectedInfo.step.anchor} · where the picture starts{/if}</div>
|
||||
{#if selectedInfo.step.screen?.component}
|
||||
<a class="sub" href={symbolHref(selectedInfo.step.screen.component.id)}>
|
||||
<KindGlyph kind={selectedInfo.step.screen.component.kind} />
|
||||
{selectedInfo.step.screen.component.name}
|
||||
</a>
|
||||
{:else if selectedInfo.step.node && selectedInfo.step.kind !== 'screen'}
|
||||
<a class="sub" href={symbolHref(selectedInfo.step.node.id)}>
|
||||
<KindGlyph kind={selectedInfo.step.node.kind} />
|
||||
{selectedInfo.step.node.name}
|
||||
</a>
|
||||
{/if}
|
||||
{#if selectedInfo.step.effect}
|
||||
<a class="sub" href={symbolHref(selectedInfo.step.effect.by.id, { line: selectedInfo.step.effect.line })}>
|
||||
<KindGlyph kind={selectedInfo.step.effect.by.kind} />
|
||||
{selectedInfo.step.effect.by.name} · line {selectedInfo.step.effect.line}
|
||||
</a>
|
||||
{/if}
|
||||
{#if selectedInfo.step.node}
|
||||
<a class="sub dim" href={fileHref(selectedInfo.step.node.file)}>{selectedInfo.step.node.file}</a>
|
||||
{/if}
|
||||
{#if selectedInfo.step.node && !selectedInfo.step.anchor}
|
||||
<a class="sub act" href={stepsHref({ anchor: selectedInfo.step.node.id })}>Start here →</a>
|
||||
{/if}
|
||||
</div>
|
||||
<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>
|
||||
{: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}
|
||||
<p class="dim note">
|
||||
The walk was cut at this step ({selectedInfo.step.cut === 'depth'
|
||||
? 'the picture’s depth'
|
||||
: selectedInfo.step.cut === 'fan-out'
|
||||
? 'more calls than the walk follows from one node'
|
||||
: selectedInfo.step.cut === 'folded'
|
||||
? 'as much plumbing as it folds from one step'
|
||||
: 'the picture’s size'}). Start here to see on.
|
||||
</p>
|
||||
{/if}
|
||||
{#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.events && selectedInfo.step.events.length > 1}
|
||||
<p class="dim note mono">⇠ {selectedInfo.step.events.join(' · ')}</p>
|
||||
{/if}
|
||||
{#if pills !== null && pills.hidden > 0}
|
||||
<p class="dim note">
|
||||
{pills.hidden} condition{pills.hidden === 1 ? '' : 's'} not drawn on the picture for want of
|
||||
room — hover a row below to see {pills.hidden === 1 ? 'it' : 'each'} on its line.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<h4>Arrives from <span class="dim">{lists.arrivesFrom.length}</span></h4>
|
||||
{#if lists.arrivesFrom.length === 0}
|
||||
<p class="dim">{selectedInfo.step.anchor ? 'The anchor — the picture starts here.' : 'Nothing in the picture leads here.'}</p>
|
||||
{/if}
|
||||
{#each lists.arrivesFrom as link (link.id)}
|
||||
<div
|
||||
class="row"
|
||||
class:hot={rowHot(link)}
|
||||
role="presentation"
|
||||
onmouseenter={() => onRowHover(link)}
|
||||
onmouseleave={() => onRowHover(null)}
|
||||
onfocusin={() => onRowHover(link)}
|
||||
onfocusout={() => onRowHover(null)}
|
||||
>
|
||||
<button class="peer mono" onclick={() => (selected = link.from)}>{nameOf(link.from)}</button>
|
||||
{#if link.when}<div class="when">when {link.when}</div>{/if}
|
||||
{#if link.via.length > 0}<div class="via dim">via {stepViaText(link)}</div>{/if}
|
||||
{#if link.label}<div class="via dim">{link.label}</div>{/if}
|
||||
{#each link.sites as site (site.file + site.line)}
|
||||
{@const href = siteHref(link, site, payload.steps.find((s) => s.id === link.from)?.node?.id ?? null)}
|
||||
{#if href}
|
||||
<a class="site dim" {href}>{site.text} · {basename(site.file)}:{site.line}</a>
|
||||
{:else}
|
||||
<span class="site dim">{site.text} · {basename(site.file)}:{site.line}</span>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if stripHref(link)}<a class="site act" href={stripHref(link)}>Open as a flow →</a>{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<h4>Leads to <span class="dim">{lists.leadsTo.length}</span></h4>
|
||||
{#if lists.leadsTo.length === 0}
|
||||
<p class="dim">
|
||||
{selectedInfo.step.kind === 'effect' ? 'Outside the index: the graph cannot follow it further.' : 'Nothing the walk follows leaves this step.'}
|
||||
</p>
|
||||
{/if}
|
||||
{#each lists.leadsTo as link (link.id)}
|
||||
<div
|
||||
class="row"
|
||||
class:hot={rowHot(link)}
|
||||
role="presentation"
|
||||
onmouseenter={() => onRowHover(link)}
|
||||
onmouseleave={() => onRowHover(null)}
|
||||
onfocusin={() => onRowHover(link)}
|
||||
onfocusout={() => onRowHover(null)}
|
||||
>
|
||||
<button class="peer mono" onclick={() => (selected = link.to)}>{nameOf(link.to)}</button>
|
||||
{#if link.when}<div class="when">when {link.when}</div>{/if}
|
||||
{#if link.via.length > 0}<div class="via dim">via {stepViaText(link)}</div>{/if}
|
||||
{#if link.label}<div class="via dim">{link.label}</div>{/if}
|
||||
{#each link.sites as site (site.file + site.line)}
|
||||
{@const href = siteHref(link, site, selectedInfo.step.screen?.component?.id ?? selectedInfo.step.node?.id ?? null)}
|
||||
{#if href}
|
||||
<a class="site dim" {href}>{site.text} · {basename(site.file)}:{site.line}</a>
|
||||
{:else}
|
||||
<span class="site dim">{site.text} · {basename(site.file)}:{site.line}</span>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if stripHref(link)}<a class="site act" href={stripHref(link)}>Open as a flow →</a>{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="head">
|
||||
<div>
|
||||
<div class="big">What happens from <span class="mono">{payload.anchor.name}</span></div>
|
||||
<a class="sub" href={symbolHref(payload.anchor.id)}>
|
||||
<KindGlyph kind={payload.anchor.kind} />
|
||||
{payload.anchor.qualifiedName}
|
||||
</a>
|
||||
<a class="sub dim" href={fileHref(payload.anchor.file)}>{payload.anchor.file}</a>
|
||||
</div>
|
||||
</div>
|
||||
{#if payload.ambiguous.length > 0}
|
||||
<p class="dim note">
|
||||
{payload.ambiguous.length} other symbol{payload.ambiguous.length === 1 ? '' : 's'} share this name:
|
||||
{#each payload.ambiguous as other, i (other.id)}
|
||||
{#if i > 0},{/if}
|
||||
<a href={stepsHref({ anchor: other.id })}>{other.kind} in {basename(other.file)}</a>
|
||||
{/each}
|
||||
</p>
|
||||
{/if}
|
||||
<p>
|
||||
<b>{payload.steps.length}</b> steps · <b>{payload.links.length}</b> links · depth
|
||||
<select
|
||||
class="depth"
|
||||
value={String(payload.depth)}
|
||||
onchange={(e) => navigate(rewrite({ depth: Number((e.currentTarget as HTMLSelectElement).value) }))}
|
||||
>
|
||||
{#each DEPTHS as d (d)}
|
||||
<option value={String(d)}>{d}</option>
|
||||
{/each}
|
||||
{#if !DEPTHS.includes(payload.depth)}<option value={String(payload.depth)}>{payload.depth}</option>{/if}
|
||||
</select>
|
||||
</p>
|
||||
<p>
|
||||
<label class="opt">
|
||||
<input type="checkbox" checked={payload.through} onchange={(e) => navigate(rewrite({ through: (e.currentTarget as HTMLInputElement).checked }))} />
|
||||
Continue through screens
|
||||
</label>
|
||||
<span class="dim">— otherwise another screen 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>
|
||||
{/if}
|
||||
{/each}
|
||||
</p>
|
||||
<p class="dim">
|
||||
<span class="mark">●</span> The anchor is at the top; each row down is one more step away from
|
||||
it. Click a step and each of its links is labelled at the far end of its line with the last
|
||||
condition checked before it happens; hover the line, or its row here, for the whole chain and the
|
||||
plumbing it travels through. A step is the next anchor, and any link opens as a Flow strip.
|
||||
</p>
|
||||
{#if payload.truncated.steps > 0 || payload.truncated.hubs > 0 || payload.truncated.chrome > 0}
|
||||
<p class="dim">
|
||||
Not drawn:
|
||||
{#if payload.truncated.steps > 0}<b>{payload.truncated.steps}</b> step{payload.truncated.steps === 1 ? '' : 's'} past the picture’s size limit;{/if}
|
||||
{#if payload.truncated.hubs > 0}<b>{payload.truncated.hubs}</b> walk{payload.truncated.hubs === 1 ? '' : 's'} that reached a hub;{/if}
|
||||
{#if payload.truncated.chrome > 0}<b>{payload.truncated.chrome}</b> into shared chrome.{/if}
|
||||
</p>
|
||||
{/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>
|
||||
{/each}
|
||||
{/if}
|
||||
</aside>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.steps {
|
||||
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__edge-labels) {
|
||||
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;
|
||||
}
|
||||
.chooser {
|
||||
max-width: 720px;
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.chooser-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 0;
|
||||
margin-top: 12px;
|
||||
border-top: 1px solid var(--rule-soft);
|
||||
}
|
||||
.pick {
|
||||
display: block;
|
||||
padding: 7px 8px;
|
||||
border-bottom: 1px solid var(--rule-soft);
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.pick:hover {
|
||||
background: var(--press);
|
||||
}
|
||||
.legend {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
bottom: 12px;
|
||||
z-index: 4;
|
||||
max-width: 400px;
|
||||
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-cross {
|
||||
border-left: 3px solid var(--accent);
|
||||
}
|
||||
.k-box.k-store {
|
||||
background: var(--paper-2);
|
||||
}
|
||||
.k-box.k-effect {
|
||||
border-style: dashed;
|
||||
border-color: var(--ink-3);
|
||||
}
|
||||
.k-anchor .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;
|
||||
}
|
||||
a.sub:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.act {
|
||||
color: var(--accent);
|
||||
}
|
||||
.clear {
|
||||
border: 1px solid var(--rule);
|
||||
background: transparent;
|
||||
color: var(--ink-2);
|
||||
font: inherit;
|
||||
font-size: 11.5px;
|
||||
padding: 1px 7px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.note {
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
.opt {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.opt input {
|
||||
margin: 0;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
.depth {
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
border: 1px solid var(--rule-soft);
|
||||
background: var(--paper-2);
|
||||
color: var(--ink);
|
||||
padding: 0 4px;
|
||||
}
|
||||
.counts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 12px;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
h4 {
|
||||
margin: 16px 0 6px;
|
||||
font: 600 12.5px var(--sans);
|
||||
}
|
||||
.row {
|
||||
padding: 7px 8px;
|
||||
margin: 0 -8px;
|
||||
border-top: 1px solid var(--rule-soft);
|
||||
transition: background 90ms linear;
|
||||
}
|
||||
.row.hot {
|
||||
background: var(--press);
|
||||
}
|
||||
.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;
|
||||
}
|
||||
a.site:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.mono {
|
||||
font-family: var(--mono);
|
||||
}
|
||||
.sans {
|
||||
font-family: var(--sans);
|
||||
}
|
||||
.dim {
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.mark {
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user