feat(steps): the rail — a handler read top to bottom, forks and all
The reading the walk records now has a picture. `#/steps?…&view=order` draws the anchor, then its body: a box per step in the order the code writes them, a fork where the code forks with its arms side by side under the condition, a helper drawn where it is called, and an arm that answers, returns or throws ending there — so proshop's login reads *look the user up · if the password matches, sign a token inside the reply and answer 200 · otherwise 401*, which is what the code says and what a row of four boxes could not. - `program-model.ts` decides the words: the fork carries the decision once and its arms say only which side they are (WHEN / WHEN NOT), except a `switch`, whose arms each have a case to say, and a `try`, which says `on error` once. - `StepBox.svelte` is the box both readings draw — the canvas wraps it in handles, the rail lets it size to its words. Same look, same click, same double-click-to-start-here. - `StepsKey.svelte` is the key, floating over the canvas as before and last in the document on the rail, which scrolls and cannot have things sitting on it. - The reading travels in the URL (`view=order` / `view=tree`) and the summary offers both; without one, the answer's own default decides — the code's order for a handler or an endpoint, the tree for a screen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01REFyW9hmNrxhwN5wxRoAkC
This commit is contained in:
co-authored by
Claude Opus 5
parent
b02e192ffa
commit
9acab0020f
@@ -0,0 +1,132 @@
|
|||||||
|
/**
|
||||||
|
* What the rail SAYS. The block tree the server sends is turned into rows of
|
||||||
|
* boxes and words here (`ui/src/lib/program-model.ts`); this pins the words —
|
||||||
|
* which is the part a reader actually meets.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { joinTokens } from '../ui/src/lib/conditions';
|
||||||
|
import { armWords, buildRailModel, endWords, groupLabel } from '../ui/src/lib/program-model';
|
||||||
|
import type { WireArm, WireBlock, WireItem, WireStep, WireStepsPayload } from '../ui/src/lib/wire';
|
||||||
|
|
||||||
|
const step = (id: string, over: Partial<WireStep> = {}): WireStep => ({
|
||||||
|
id,
|
||||||
|
kind: 'effect',
|
||||||
|
anchor: false,
|
||||||
|
node: null,
|
||||||
|
label: id,
|
||||||
|
sub: `response · handler`,
|
||||||
|
depth: 1,
|
||||||
|
cut: null,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
function payload(steps: WireStep[], root: WireBlock): WireStepsPayload {
|
||||||
|
return {
|
||||||
|
anchor: { id: 'a', kind: 'route', name: 'POST /login', qualifiedName: 'POST /login', file: 'r.js', line: 1, endLine: 1, language: 'javascript', test: false },
|
||||||
|
ambiguous: [],
|
||||||
|
project: 'api',
|
||||||
|
steps,
|
||||||
|
links: [],
|
||||||
|
program: { root, truncated: 0 },
|
||||||
|
defaultView: 'order',
|
||||||
|
depth: 8,
|
||||||
|
limit: 120,
|
||||||
|
through: false,
|
||||||
|
truncated: { steps: 0, hubs: 0, chrome: 0 },
|
||||||
|
index: { lastIndexedAt: null, edges: 0, files: 0 },
|
||||||
|
timing: { elapsedMs: 1 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const arm = (when: string, over: Partial<WireArm> = {}): WireArm => ({ when, ends: null, body: [], ...over });
|
||||||
|
|
||||||
|
describe('the rail’s words', () => {
|
||||||
|
it('says the decision once, and each arm only which side it is', () => {
|
||||||
|
const on = 'user && (await user.matchPassword(password))';
|
||||||
|
const fork: WireItem = {
|
||||||
|
kind: 'fork',
|
||||||
|
form: 'if',
|
||||||
|
on,
|
||||||
|
arms: [arm(on, { ends: 'reply', body: [{ kind: 'step', step: '200' }] }), arm(`!(${on})`, { not: true, ends: 'reply', body: [{ kind: 'step', step: '401' }] })],
|
||||||
|
};
|
||||||
|
const model = buildRailModel(payload([step('200'), step('401')], [fork]));
|
||||||
|
expect(model).toHaveLength(1);
|
||||||
|
const rail = model[0]!;
|
||||||
|
if (rail.kind !== 'fork') throw new Error('expected a fork');
|
||||||
|
expect(joinTokens(rail.words)).toBe('user AND (await user.matchPassword(password))');
|
||||||
|
expect(rail.arms.map((a) => joinTokens(a.words))).toEqual(['WHEN', 'WHEN NOT']);
|
||||||
|
expect(rail.arms.map((a) => a.ends)).toEqual(['answers here', 'answers here']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a disjunction whole rather than reading it as two ways of arriving', () => {
|
||||||
|
// `!image || unlimitedCollection` is ONE condition, and the parentheses
|
||||||
|
// `guardLabel` puts round it are what stop the OR from splitting it.
|
||||||
|
const on = '(!image || unlimitedCollection)';
|
||||||
|
const model = buildRailModel(payload([], [{ kind: 'fork', form: 'if', on, arms: [arm(on)] }]));
|
||||||
|
const rail = model[0]!;
|
||||||
|
if (rail.kind !== 'fork') throw new Error('expected a fork');
|
||||||
|
expect(joinTokens(rail.words)).toBe('(!image || unlimitedCollection)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives a switch’s arms their own conditions', () => {
|
||||||
|
const fork: WireItem = {
|
||||||
|
kind: 'fork',
|
||||||
|
form: 'switch',
|
||||||
|
on: 'kind',
|
||||||
|
arms: [arm("kind === 'a'"), arm('kind: default')],
|
||||||
|
};
|
||||||
|
const model = buildRailModel(payload([], [fork]));
|
||||||
|
const rail = model[0]!;
|
||||||
|
if (rail.kind !== 'fork') throw new Error('expected a fork');
|
||||||
|
expect(joinTokens(rail.words)).toBe('kind');
|
||||||
|
expect(rail.arms.map((a) => joinTokens(a.words))).toEqual(["WHEN kind === 'a'", 'WHEN kind: default']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets a try say `on error` once', () => {
|
||||||
|
expect(armWords('try', 'on error', arm('on error'))).toEqual([]);
|
||||||
|
const model = buildRailModel(payload([], [{ kind: 'fork', form: 'try', on: 'on error', arms: [arm('on error')] }]));
|
||||||
|
const rail = model[0]!;
|
||||||
|
if (rail.kind !== 'fork') throw new Error('expected a fork');
|
||||||
|
expect(joinTokens(rail.words)).toBe('on error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says how each arm leaves', () => {
|
||||||
|
expect(endWords('reply')).toBe('answers here');
|
||||||
|
expect(endWords('return')).toBe('returns here');
|
||||||
|
expect(endWords('throw')).toBe('throws here');
|
||||||
|
expect(endWords('exit')).toBe('leaves here');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names each kind of bracketed run', () => {
|
||||||
|
const via = { id: 'f', kind: 'function' as const, name: 'generateToken', qualifiedName: 'generateToken', file: 'a.js', line: 1, endLine: 2, language: 'javascript', test: false };
|
||||||
|
expect(groupLabel({ kind: 'block', block: 'inline', via, body: [] })).toBe('via generateToken');
|
||||||
|
expect(groupLabel({ kind: 'block', block: 'inline', body: [] })).toBe('via a helper');
|
||||||
|
expect(groupLabel({ kind: 'block', block: 'later', by: 'then', body: [] })).toBe('later · then');
|
||||||
|
expect(groupLabel({ kind: 'block', block: 'loop', by: 'item of items', body: [] })).toBe('for each item of items');
|
||||||
|
expect(groupLabel({ kind: 'block', block: 'together', by: 'Promise.all', body: [] })).toBe('together · Promise.all');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries a box’s two lines and where the call is written', () => {
|
||||||
|
const model = buildRailModel(
|
||||||
|
payload([step('200', { label: '200', sub: 'response · authUser' })], [{ kind: 'step', step: '200', within: 'res.json' }])
|
||||||
|
);
|
||||||
|
const rail = model[0]!;
|
||||||
|
if (rail.kind !== 'step') throw new Error('expected a step');
|
||||||
|
expect(rail.info?.label).toBe('200');
|
||||||
|
expect(rail.info?.sub).toBe('response · authUser');
|
||||||
|
expect(rail.within).toBe('res.json');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says where the reading stopped', () => {
|
||||||
|
const model = buildRailModel(payload([], [{ kind: 'cut', why: 'folded' }, { kind: 'cut', why: 'depth' }]));
|
||||||
|
expect(model.map((i) => (i.kind === 'cut' ? i.text : ''))).toEqual([
|
||||||
|
'reads back into itself — the rest is the same code again',
|
||||||
|
'as deep as this reading goes — start at a step below to read on',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is empty when there is no body to read', () => {
|
||||||
|
expect(buildRailModel({ ...payload([], []), program: null })).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -58,7 +58,7 @@ function shape(block: WireBlock, indent = ''): string[] {
|
|||||||
out.push(...shape(arm.body, `${indent} `));
|
out.push(...shape(arm.body, `${indent} `));
|
||||||
}
|
}
|
||||||
} else if (item.kind === 'block') {
|
} else if (item.kind === 'block') {
|
||||||
out.push(`${indent}${item.block} ${item.label}${item.again ? ' (again)' : ''}`);
|
out.push(`${indent}${item.block}${item.via ? ` via ${item.via.name}` : item.by ? ` ${item.by}` : ''}${item.again ? ' (again)' : ''}`);
|
||||||
out.push(...shape(item.body, `${indent} `));
|
out.push(...shape(item.body, `${indent} `));
|
||||||
} else out.push(`${indent}cut ${item.why}`);
|
} else out.push(`${indent}cut ${item.why}`);
|
||||||
}
|
}
|
||||||
@@ -157,8 +157,9 @@ describe('buildProgram', () => {
|
|||||||
at('d', [g('kind: default', { form: 'case', branch })]),
|
at('d', [g('kind: default', { form: 'case', branch })]),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
// The head says what is being decided on; each arm its own case.
|
||||||
expect(shape(p!.root)).toEqual([
|
expect(shape(p!.root)).toEqual([
|
||||||
"switch kind === 'a'",
|
'switch kind',
|
||||||
" arm kind === 'a'",
|
" arm kind === 'a'",
|
||||||
' a',
|
' a',
|
||||||
" arm kind === 'b'",
|
" arm kind === 'b'",
|
||||||
@@ -224,14 +225,14 @@ describe('buildProgram', () => {
|
|||||||
const p = program({
|
const p = program({
|
||||||
root: [at('now'), at('afterwards', [], { trigger: { kind: 'callback', name: 'then', of: null } })],
|
root: [at('now'), at('afterwards', [], { trigger: { kind: 'callback', name: 'then', of: null } })],
|
||||||
});
|
});
|
||||||
expect(shape(p!.root)).toEqual(['now', 'later later · then', ' afterwards']);
|
expect(shape(p!.root)).toEqual(['now', 'later then', ' afterwards']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('puts calls started together in one block', () => {
|
it('puts calls started together in one block', () => {
|
||||||
const p = program({
|
const p = program({
|
||||||
root: [at('a', [], { within: 'Promise.all' }), at('b', [], { within: 'Promise.all' }), at('c')],
|
root: [at('a', [], { within: 'Promise.all' }), at('b', [], { within: 'Promise.all' }), at('c')],
|
||||||
});
|
});
|
||||||
expect(shape(p!.root)).toEqual(['together together', ' a inside Promise.all', ' b inside Promise.all', 'c']);
|
expect(shape(p!.root)).toEqual(['together Promise.all', ' a inside Promise.all', ' b inside Promise.all', 'c']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('closes a fork when the code leaves it', () => {
|
it('closes a fork when the code leaves it', () => {
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ export type WireArmEnd = 'reply' | 'return' | 'throw' | 'exit';
|
|||||||
export interface WireArm {
|
export interface WireArm {
|
||||||
/** This arm's own condition, in the words the rest of the view uses. */
|
/** This arm's own condition, in the words the rest of the view uses. */
|
||||||
when: string;
|
when: string;
|
||||||
|
/** The arm taken when the fork's condition does NOT hold — the `else` side. */
|
||||||
|
not?: true;
|
||||||
/** How it leaves: it answers the request, returns, or throws. Null = it runs on. */
|
/** How it leaves: it answers the request, returns, or throws. Null = it runs on. */
|
||||||
ends: WireArmEnd | null;
|
ends: WireArmEnd | null;
|
||||||
body: WireBlock;
|
body: WireBlock;
|
||||||
@@ -61,7 +63,7 @@ export type WireItem =
|
|||||||
* after this function returns (`later`), or calls started together
|
* after this function returns (`later`), or calls started together
|
||||||
* (`together`).
|
* (`together`).
|
||||||
*/
|
*/
|
||||||
| { kind: 'block'; block: 'inline' | 'loop' | 'later' | 'together'; label: string; via?: WireNodeRef; within?: string; body: WireBlock; again?: true }
|
| { kind: 'block'; block: 'inline' | 'loop' | 'later' | 'together'; by?: string; via?: WireNodeRef; within?: string; body: WireBlock; again?: true }
|
||||||
/** Where the reading stopped: a helper that calls itself, or a cap the walk hit. */
|
/** Where the reading stopped: a helper that calls itself, or a cap the walk hit. */
|
||||||
| { kind: 'cut'; why: 'folded' | 'depth' };
|
| { kind: 'cut'; why: 'folded' | 'depth' };
|
||||||
|
|
||||||
@@ -175,7 +177,10 @@ function blockFor(input: ProgramInput, fn: string, path: readonly string[], stat
|
|||||||
}
|
}
|
||||||
for (let i = keep; i < guards.length; i++) {
|
for (let i = keep; i < guards.length; i++) {
|
||||||
const g = guards[i]!;
|
const g = guards[i]!;
|
||||||
const fork: Extract<WireItem, { kind: 'fork' }> = { kind: 'fork', on: g.text, form: formOf(g), arms: [] };
|
// The decision as a reader says it, not as the guard stores it: a
|
||||||
|
// disjunction keeps the parentheses that stop `a || b` from reading as
|
||||||
|
// two ways of arriving (`guardLabel` is the one place that decides).
|
||||||
|
const fork: Extract<WireItem, { kind: 'fork' }> = { kind: 'fork', on: guardLabel([{ ...g, negated: false }]), form: formOf(g), arms: [] };
|
||||||
// An early exit is a fork whose OTHER arm left before this site could
|
// An early exit is a fork whose OTHER arm left before this site could
|
||||||
// run: `if (!product) throw` — the throw is written first, so it is the
|
// run: `if (!product) throw` — the throw is written first, so it is the
|
||||||
// first arm, and it is empty because nothing in the picture happens there.
|
// first arm, and it is empty because nothing in the picture happens there.
|
||||||
@@ -206,7 +211,6 @@ function itemFor(input: ProgramInput, site: ProgramSite, path: readonly string[]
|
|||||||
const block: Extract<WireItem, { kind: 'block' }> = {
|
const block: Extract<WireItem, { kind: 'block' }> = {
|
||||||
kind: 'block',
|
kind: 'block',
|
||||||
block: 'inline',
|
block: 'inline',
|
||||||
label: via ? `via ${via.name}` : 'via a helper',
|
|
||||||
...(via ? { via } : {}),
|
...(via ? { via } : {}),
|
||||||
...(site.within ? { within: site.within } : {}),
|
...(site.within ? { within: site.within } : {}),
|
||||||
body: [],
|
body: [],
|
||||||
@@ -265,18 +269,18 @@ function place(block: WireBlock, item: WireItem, site: ProgramSite): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const last = block[block.length - 1];
|
const last = block[block.length - 1];
|
||||||
if (last && last.kind === 'block' && last.block === run.block && last.label === run.label) {
|
if (last && last.kind === 'block' && last.block === run.block && last.by === run.by) {
|
||||||
last.body.push(item);
|
last.body.push(item);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
block.push({ kind: 'block', block: run.block, label: run.label, body: [item] });
|
block.push({ kind: 'block', block: run.block, ...(run.by ? { by: run.by } : {}), body: [item] });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The run a site belongs to — `later · then`, `together` — or null for plain sequence. */
|
/** The run a site belongs to — registered to run later, started together — or null for plain sequence. */
|
||||||
function runFor(site: ProgramSite): { block: 'later' | 'together'; label: string } | null {
|
function runFor(site: ProgramSite): { block: 'later' | 'together'; by?: string } | null {
|
||||||
const fires = site.trigger;
|
const fires = site.trigger;
|
||||||
if (fires && fires.kind === 'callback' && LATER_OF.test(fires.name)) return { block: 'later', label: `later · ${fires.name}` };
|
if (fires && fires.kind === 'callback' && LATER_OF.test(fires.name)) return { block: 'later', by: fires.name };
|
||||||
if (site.within && TOGETHER.test(site.within)) return { block: 'together', label: 'together' };
|
if (site.within && TOGETHER.test(site.within)) return { block: 'together', by: site.within };
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,7 +289,7 @@ function armFor(fork: Extract<WireItem, { kind: 'fork' }>, g: BranchGuard): Wire
|
|||||||
const when = guardLabel([g]);
|
const when = guardLabel([g]);
|
||||||
const found = fork.arms.find((a) => a.when === when);
|
const found = fork.arms.find((a) => a.when === when);
|
||||||
if (found) return found;
|
if (found) return found;
|
||||||
const arm: WireArm = { when, ends: g.armExit ?? null, body: [] };
|
const arm: WireArm = { when, ...(g.negated ? { not: true as const } : {}), ends: g.armExit ?? null, body: [] };
|
||||||
fork.arms.push(arm);
|
fork.arms.push(arm);
|
||||||
return arm;
|
return arm;
|
||||||
}
|
}
|
||||||
@@ -317,6 +321,9 @@ function formOf(g: BranchGuard): 'if' | 'switch' | 'ternary' | 'try' {
|
|||||||
function seal(input: ProgramInput, block: WireBlock): void {
|
function seal(input: ProgramInput, block: WireBlock): void {
|
||||||
for (const item of block) {
|
for (const item of block) {
|
||||||
if (item.kind === 'fork') {
|
if (item.kind === 'fork') {
|
||||||
|
// A switch's head is the thing being decided on, which only its arms
|
||||||
|
// together say: every case was written `<subject> === <value>`.
|
||||||
|
if (item.form === 'switch') item.on = subjectOf(item.arms.map((a) => a.when));
|
||||||
for (const arm of item.arms) {
|
for (const arm of item.arms) {
|
||||||
seal(input, arm.body);
|
seal(input, arm.body);
|
||||||
if (repliesLast(input, arm.body)) arm.ends = 'reply';
|
if (repliesLast(input, arm.body)) arm.ends = 'reply';
|
||||||
@@ -326,6 +333,22 @@ function seal(input: ProgramInput, block: WireBlock): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What every arm of a switch is deciding on: the longest start they share, cut
|
||||||
|
* at a word. '' when they share nothing — then the head says nothing and each
|
||||||
|
* arm says its own condition, which is never wrong.
|
||||||
|
*/
|
||||||
|
function subjectOf(arms: readonly string[]): string {
|
||||||
|
if (arms.length < 2) return '';
|
||||||
|
let common = arms[0]!;
|
||||||
|
for (const arm of arms.slice(1)) {
|
||||||
|
let i = 0;
|
||||||
|
while (i < common.length && i < arm.length && common[i] === arm[i]) i++;
|
||||||
|
common = common.slice(0, i);
|
||||||
|
}
|
||||||
|
return /^[\w$.?[\]'"]+/.exec(common.trim())?.[0] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
/** Whether the last thing a block does is answer the request. */
|
/** Whether the last thing a block does is answer the request. */
|
||||||
function repliesLast(input: ProgramInput, block: WireBlock): boolean {
|
function repliesLast(input: ProgramInput, block: WireBlock): boolean {
|
||||||
const last = block[block.length - 1];
|
const last = block[block.length - 1];
|
||||||
|
|||||||
+1
-1
@@ -177,7 +177,7 @@
|
|||||||
{:else if route.view === 'screens' || (route.view === 'home' && hasScreens)}
|
{:else if route.view === 'screens' || (route.view === 'home' && hasScreens)}
|
||||||
<ScreensView />
|
<ScreensView />
|
||||||
{:else if route.view === 'steps'}
|
{:else if route.view === 'steps'}
|
||||||
<StepsView anchor={route.anchor} symbol={route.symbol} depth={route.depth} through={route.through} />
|
<StepsView anchor={route.anchor} symbol={route.symbol} depth={route.depth} through={route.through} reading={route.reading} />
|
||||||
{:else if route.view === 'dead'}
|
{:else if route.view === 'dead'}
|
||||||
<DeadCodeView exported={route.exported} />
|
<DeadCodeView exported={route.exported} />
|
||||||
{:else if route.view === 'unknown'}
|
{:else if route.view === 'unknown'}
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* A run of the rail: the items of one block, top to bottom, on a hairline.
|
||||||
|
*
|
||||||
|
* Recursive, because the code is: a fork is a row of arm columns, each arm a
|
||||||
|
* block of its own; a helper drawn where it is called, a loop's body, work
|
||||||
|
* that runs later and calls started together are bracketed blocks with a
|
||||||
|
* label in `--ink-3`. No layout engine and no measuring — the browser lays a
|
||||||
|
* column of boxes out, which is all a rail is.
|
||||||
|
*/
|
||||||
|
import StepBox from './StepBox.svelte';
|
||||||
|
import Self from './RailBlock.svelte';
|
||||||
|
import type { RailItem } from '../../lib/program-model';
|
||||||
|
import type { ProjectKind } from '../../lib/steps-model';
|
||||||
|
import type { WordToken } from '../../lib/conditions';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
items: RailItem[];
|
||||||
|
project: ProjectKind;
|
||||||
|
selected: string | null;
|
||||||
|
/** Steps not on the selected step's line, dimmed; null = nothing is selected. */
|
||||||
|
lit: Set<string> | null;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
onStart: (id: string) => void;
|
||||||
|
/** Whether a step may become the next anchor — false for an effect. */
|
||||||
|
canStart: (id: string) => boolean;
|
||||||
|
}
|
||||||
|
let { items, project, selected, lit, onSelect, onStart, canStart }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet words(tokens: WordToken[])}
|
||||||
|
{#each tokens as t, i (i)}{#if i > 0}{' '}{/if}{#if t.kw}<b class="kw">{t.text}</b>{:else}{t.text}{/if}{/each}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<div class="run">
|
||||||
|
{#each items as item, i (i)}
|
||||||
|
{#if item.kind === 'step'}
|
||||||
|
<div class="line">
|
||||||
|
{#if item.within}<span class="note">inside {item.within}(…)</span>{/if}
|
||||||
|
{#if item.info}
|
||||||
|
<StepBox
|
||||||
|
info={item.info}
|
||||||
|
{project}
|
||||||
|
selected={selected === item.id}
|
||||||
|
dimmed={lit !== null && !lit.has(item.id)}
|
||||||
|
note={item.again ? 'It happens here too; what it does is read above.' : ''}
|
||||||
|
onSelect={() => onSelect(item.id)}
|
||||||
|
onStart={canStart(item.id) ? () => onStart(item.id) : undefined}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<span class="note">a step the picture left out</span>
|
||||||
|
{/if}
|
||||||
|
{#if item.again}<span class="note">as above</span>{/if}
|
||||||
|
</div>
|
||||||
|
{#if item.body.length > 0}
|
||||||
|
<div class="nested">
|
||||||
|
<Self items={item.body} {project} {selected} {lit} {onSelect} {onStart} {canStart} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else if item.kind === 'fork'}
|
||||||
|
<div class="fork">
|
||||||
|
<div class="cond mono">{@render words(item.words)}</div>
|
||||||
|
<div class="arms">
|
||||||
|
{#each item.arms as arm, a (a)}
|
||||||
|
<div class="arm">
|
||||||
|
<div class="armh mono">{@render words(arm.words)}</div>
|
||||||
|
{#if arm.body.length > 0}
|
||||||
|
<Self items={arm.body} {project} {selected} {lit} {onSelect} {onStart} {canStart} />
|
||||||
|
{/if}
|
||||||
|
{#if arm.ends}<div class="ends">{arm.ends}</div>{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if item.kind === 'group'}
|
||||||
|
<div class="group" class:again={item.again}>
|
||||||
|
<div class="label">
|
||||||
|
<span>{item.label}</span>{#if item.within}<span class="note"> · inside {item.within}(…)</span>{/if}{#if item.again}<span class="note"> · read above</span>{/if}
|
||||||
|
</div>
|
||||||
|
{#if item.body.length > 0}
|
||||||
|
<Self items={item.body} {project} {selected} {lit} {onSelect} {onStart} {canStart} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="line"><span class="note">{item.text}</span></div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.run {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
/* The rail: a hairline down the left of every run but the outermost. */
|
||||||
|
.line {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 2px;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.nested,
|
||||||
|
.group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
padding-left: 12px;
|
||||||
|
border-left: 1px solid var(--rule-soft);
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.group.again {
|
||||||
|
border-left-style: dashed;
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
font: 400 11px var(--sans);
|
||||||
|
color: var(--ink-3);
|
||||||
|
}
|
||||||
|
.note {
|
||||||
|
font: 400 11px var(--sans);
|
||||||
|
color: var(--ink-3);
|
||||||
|
}
|
||||||
|
.fork {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 6px;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.cond {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 16px;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border: 1px solid var(--rule-soft);
|
||||||
|
background: var(--paper-2);
|
||||||
|
color: var(--ink-2);
|
||||||
|
max-width: 640px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.arms {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 18px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.arm {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 0 0 12px;
|
||||||
|
border-left: 1px solid var(--rule-soft);
|
||||||
|
border-top: 1px solid var(--rule-soft);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.armh {
|
||||||
|
font-size: 11.5px;
|
||||||
|
line-height: 15px;
|
||||||
|
color: var(--ink-2);
|
||||||
|
max-width: 520px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ends {
|
||||||
|
font: 400 11px var(--sans);
|
||||||
|
color: var(--ink-3);
|
||||||
|
border-top: 1px solid var(--rule-faint);
|
||||||
|
padding-top: 4px;
|
||||||
|
align-self: stretch;
|
||||||
|
}
|
||||||
|
.kw {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* One step's box — the same box on the canvas and on the rail.
|
||||||
|
*
|
||||||
|
* It 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 — and
|
||||||
|
* so does an endpoint the code crosses to (`⇢ POST /api/users`) or a job, an
|
||||||
|
* event, a message arriving; a store action sits on `--paper-2`; a call that
|
||||||
|
* leaves the index is dashed, 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.
|
||||||
|
*
|
||||||
|
* A click selects the step; a double-click starts the picture there (the
|
||||||
|
* panel's *Start here →*). On the canvas the box is sized by the layout; on
|
||||||
|
* the rail it sizes to its own words.
|
||||||
|
*/
|
||||||
|
import type { MapNodeLayout } from '../../lib/map-model';
|
||||||
|
import { kindWord, type ProjectKind, type StepNodeInfo } from '../../lib/steps-model';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
info: StepNodeInfo;
|
||||||
|
project: ProjectKind;
|
||||||
|
selected: boolean;
|
||||||
|
dimmed: boolean;
|
||||||
|
/** The canvas sizes its boxes; the rail lets them size to content. */
|
||||||
|
size?: Pick<MapNodeLayout, 'width' | 'height'> | null;
|
||||||
|
/** Said before the tooltip's own words — the rail says where the call is written. */
|
||||||
|
note?: string;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
/** Re-anchor the picture on this step — a double-click; absent for a step with no symbol. */
|
||||||
|
onStart?: (id: string) => void;
|
||||||
|
}
|
||||||
|
let { info, project, selected, dimmed, size = null, note = '', onSelect, onStart }: Props = $props();
|
||||||
|
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 ${kindWord('screen', project, step)} — 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 '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class={`snode k-${step.kind}`}
|
||||||
|
class:sel={selected}
|
||||||
|
class:dimmed
|
||||||
|
class:anchor={step.anchor}
|
||||||
|
class:rail={size === null}
|
||||||
|
style={size === null ? undefined : `width:${size.width}px;height:${size.height}px`}
|
||||||
|
onclick={() => onSelect(info.id)}
|
||||||
|
ondblclickcapture={(e) => {
|
||||||
|
// The flow canvas zooms on a double-click that reaches its pane; a
|
||||||
|
// double-click on a box is a navigation, not a zoom — stop it here,
|
||||||
|
// at the target, before it bubbles. The pane's own double-click keeps zooming.
|
||||||
|
e.stopPropagation();
|
||||||
|
onStart?.(info.id);
|
||||||
|
}}
|
||||||
|
aria-pressed={selected}
|
||||||
|
title={`${info.label} — ${step.anchor ? 'where this picture starts; ' : ''}${kindWord(step.kind, project, step)}. ${info.sub}.${note ? ` ${note}` : ''}${cutNote}${onStart && !step.anchor ? ' Double-click to start here.' : ''}`}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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;
|
||||||
|
}
|
||||||
|
/* On the rail a box sizes to its words, and wears its padding itself. */
|
||||||
|
.snode.rail {
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 5px 9px;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.snode:hover,
|
||||||
|
.snode.sel {
|
||||||
|
border-width: 2px;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
.snode.rail:hover,
|
||||||
|
.snode.rail.sel {
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
.snode:hover,
|
||||||
|
.snode.sel {
|
||||||
|
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;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.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;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,24 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
/**
|
/**
|
||||||
* One step on the Steps view. The box is the Screens view's screen box with
|
* One step on the Steps view's canvas: the shared box ({@link StepBox}) with
|
||||||
* a kind: a screen is drawn exactly as there; a handler is a plain box; a
|
* hidden handles along its top and bottom, one per port the layout decided
|
||||||
* native call or a native event carries an accent rule on its left, where
|
* (`directional` ports), exactly as the screen box has.
|
||||||
* the language changes under the code — and so does an endpoint the code
|
|
||||||
* crosses to (`⇢ POST /api/users`) or a job, an event, a message arriving;
|
|
||||||
* 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. A click selects the step;
|
|
||||||
* a double-click starts the picture there (the panel's *Start here →*) — an
|
|
||||||
* endpoint or another screen reached as a boundary opens as its own chapter.
|
|
||||||
*/
|
*/
|
||||||
import { Handle, Position, type NodeProps } from '@xyflow/svelte';
|
import { Handle, Position, type NodeProps } from '@xyflow/svelte';
|
||||||
|
import StepBox from './StepBox.svelte';
|
||||||
import type { MapNodeLayout } from '../../lib/map-model';
|
import type { MapNodeLayout } from '../../lib/map-model';
|
||||||
import { kindWord, type ProjectKind, type StepNodeInfo } from '../../lib/steps-model';
|
import type { ProjectKind, StepNodeInfo } from '../../lib/steps-model';
|
||||||
|
|
||||||
let { data }: NodeProps = $props();
|
let { data }: NodeProps = $props();
|
||||||
|
|
||||||
@@ -35,27 +24,6 @@
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
const layout = $derived(node.layout);
|
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 ${kindWord('screen', node.project, step)} — 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 {
|
function portStyle(index: number, total: number): string {
|
||||||
return `left:${((index + 1) / (total + 1)) * 100}%`;
|
return `left:${((index + 1) / (total + 1)) * 100}%`;
|
||||||
@@ -72,31 +40,15 @@
|
|||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
<button
|
<StepBox
|
||||||
class={`snode k-${step.kind}`}
|
info={node.info}
|
||||||
class:sel={node.selected}
|
project={node.project}
|
||||||
class:dimmed={node.dimmed}
|
selected={node.selected}
|
||||||
class:anchor={step.anchor}
|
dimmed={node.dimmed}
|
||||||
style={`width:${layout.width}px;height:${layout.height}px`}
|
size={layout}
|
||||||
onclick={() => node.onSelect(info.id)}
|
onSelect={node.onSelect}
|
||||||
ondblclickcapture={(e) => {
|
onStart={node.onStart}
|
||||||
// The flow canvas zooms on a double-click that reaches its pane; a
|
/>
|
||||||
// double-click on a box is a navigation, not a zoom — stop it here,
|
|
||||||
// at the target, before it bubbles. The pane's own double-click keeps zooming.
|
|
||||||
e.stopPropagation();
|
|
||||||
node.onStart?.(info.id);
|
|
||||||
}}
|
|
||||||
aria-pressed={node.selected}
|
|
||||||
title={`${info.label} — ${step.anchor ? 'where this picture starts; ' : ''}${kindWord(step.kind, node.project, step)}. ${info.sub}.${cutNote}${node.onStart && !step.anchor ? ' Double-click to start here.' : ''}`}
|
|
||||||
>
|
|
||||||
<span class="name"
|
|
||||||
>{#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}`)}
|
{#each layout.ports.bottom as port, i (`${port.type}:${port.id}`)}
|
||||||
<Handle
|
<Handle
|
||||||
@@ -107,92 +59,3 @@
|
|||||||
isConnectable={false}
|
isConnectable={false}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/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>
|
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* The Steps view's key.
|
||||||
|
*
|
||||||
|
* The same rows for both readings, worded for the one on screen: the kinds of
|
||||||
|
* box are the same either way, and only the last rows differ — the canvas
|
||||||
|
* explains its lines and pills, the rail its forks and terminals. On the
|
||||||
|
* canvas it floats over the picture, bottom left; on the rail it is the last
|
||||||
|
* thing in the document, because a rail scrolls and an overlay would sit on
|
||||||
|
* top of the code it is explaining.
|
||||||
|
*/
|
||||||
|
import { kindWord, type ProjectKind } from '../../lib/steps-model';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
project: ProjectKind;
|
||||||
|
/** The reading on screen: the rail's rows, or the canvas's. */
|
||||||
|
order: boolean;
|
||||||
|
/** In the flow of a scrolling rail rather than floating over a canvas. */
|
||||||
|
flow: boolean;
|
||||||
|
open: boolean;
|
||||||
|
onToggle: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
let { project, order, flow, open, onToggle }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="legend" class:open class:flow>
|
||||||
|
<button class="legend-h" onclick={() => onToggle(!open)} aria-expanded={open}>
|
||||||
|
Key <span class="dim">{open ? '▾' : '▸'}</span>
|
||||||
|
</button>
|
||||||
|
{#if open}
|
||||||
|
<div class="legend-body">
|
||||||
|
<div class="lrow">
|
||||||
|
<span class="k-box k-anchor mono"><span class="mark">●</span>start</span>
|
||||||
|
<span>{order ? 'Where the picture starts; below it, what it does in the code’s own order' : 'Where the picture starts; each row down is one more step away'}</span>
|
||||||
|
</div>
|
||||||
|
{#if project === 'api'}
|
||||||
|
<div class="lrow">
|
||||||
|
<span class="k-box mono">POST /x</span>
|
||||||
|
<span>An endpoint — its verb and path — or a handler: a function a request, a job, an event or a schedule fires; its line says which</span>
|
||||||
|
</div>
|
||||||
|
<div class="lrow">
|
||||||
|
<span class="k-box k-cross mono">⇢ fn</span>
|
||||||
|
<span>The code crosses a tier: a call into another service or a job put on a queue (⇢), or a job, an event, a message arriving (⇠)</span>
|
||||||
|
</div>
|
||||||
|
<div class="lrow">
|
||||||
|
<span class="k-box k-store mono">set</span>
|
||||||
|
<span>A data call — a function in a store or state file</span>
|
||||||
|
</div>
|
||||||
|
<div class="lrow">
|
||||||
|
<span class="k-box k-effect mono">db</span>
|
||||||
|
<span>A call that leaves the index: the database, the response, a queue, email, payments, a cache, auth, the network</span>
|
||||||
|
</div>
|
||||||
|
{:else if project === 'web'}
|
||||||
|
<div class="lrow">
|
||||||
|
<span class="k-box mono">/path</span>
|
||||||
|
<span>A page, an endpoint, or a handler — a function an event, a request or a page load fires; its line says which</span>
|
||||||
|
</div>
|
||||||
|
<div class="lrow">
|
||||||
|
<span class="k-box k-cross mono">⇢ fn</span>
|
||||||
|
<span>The code crosses to the server (⇢ a request, a server action) or comes back from it (⇠ a push, a stream)</span>
|
||||||
|
</div>
|
||||||
|
<div class="lrow">
|
||||||
|
<span class="k-box k-store mono">set</span>
|
||||||
|
<span>A store action — a function in a store file</span>
|
||||||
|
</div>
|
||||||
|
<div class="lrow">
|
||||||
|
<span class="k-box k-effect mono">api</span>
|
||||||
|
<span>A call that leaves the index: the network, the database, the response, storage, a queue, email</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="lrow">
|
||||||
|
<span class="k-box mono">/path</span>
|
||||||
|
<span>A screen, or a handler — a function fired from a tap, an option, a listener; its line says the event</span>
|
||||||
|
</div>
|
||||||
|
<div class="lrow">
|
||||||
|
<span class="k-box k-cross mono">⇢ fn</span>
|
||||||
|
<span>The code crosses into native (⇢ a bridge call) or comes back from it (⇠ an event)</span>
|
||||||
|
</div>
|
||||||
|
<div class="lrow">
|
||||||
|
<span class="k-box k-store mono">set</span>
|
||||||
|
<span>A store action — a function in a store file</span>
|
||||||
|
</div>
|
||||||
|
<div class="lrow">
|
||||||
|
<span class="k-box k-effect mono">api</span>
|
||||||
|
<span>A call that leaves the index: the network, storage, the device, telemetry</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if order}
|
||||||
|
<div class="lrow">
|
||||||
|
<span class="k-label mono">WHEN x</span>
|
||||||
|
<span>A fork — an <span class="mono">if</span>, a <span class="mono">switch</span>, a <span class="mono">try</span> or an early exit — with its arms side by side under the condition</span>
|
||||||
|
</div>
|
||||||
|
<div class="lrow">
|
||||||
|
<span class="k-label">answers here</span>
|
||||||
|
<span>The arm stops there: it answers the request, returns or throws, and nothing below it runs</span>
|
||||||
|
</div>
|
||||||
|
<div class="lrow">
|
||||||
|
<span class="k-label">via x</span>
|
||||||
|
<span>A helper drawn where it is called; <span class="mono">later</span> runs after this returns, <span class="mono">together</span> starts at once, <span class="mono">for each</span> repeats</span>
|
||||||
|
</div>
|
||||||
|
<div class="lrow">
|
||||||
|
<span class="k-label mono">name …</span>
|
||||||
|
<span>Not entered: another {kindWord('screen', project)} (a chapter of its own), or a cap the walk hit — start there to see on</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<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>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
/* On the rail the key is the document's last block, not an overlay. */
|
||||||
|
.legend.flow {
|
||||||
|
position: static;
|
||||||
|
margin: 28px 0 0;
|
||||||
|
max-width: 520px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* The Steps view read in the code's ORDER: the anchor at the top, then its
|
||||||
|
* body top to bottom — the calls in the order they are written, a fork where
|
||||||
|
* the code forks, its arms side by side, an arm that answers or leaves ending
|
||||||
|
* there. It is the same walk the canvas draws, folded by
|
||||||
|
* `api/program.ts` and worded by `program-model.ts`; a click selects a step
|
||||||
|
* and fills the same panel, a double-click starts the picture there.
|
||||||
|
*/
|
||||||
|
import StepBox from './StepBox.svelte';
|
||||||
|
import RailBlock from './RailBlock.svelte';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import type { RailItem } from '../../lib/program-model';
|
||||||
|
import { triggerWords, type ProjectKind, type StepNodeInfo } from '../../lib/steps-model';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
anchor: StepNodeInfo;
|
||||||
|
items: RailItem[];
|
||||||
|
project: ProjectKind;
|
||||||
|
selected: string | null;
|
||||||
|
lit: Set<string> | null;
|
||||||
|
/** Items the reading could not place — a recursion or a cap it hit. */
|
||||||
|
truncated: number;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
onStart: (id: string) => void;
|
||||||
|
canStart: (id: string) => boolean;
|
||||||
|
/** The key, last in the document — a rail scrolls, so it cannot float over it. */
|
||||||
|
children?: Snippet;
|
||||||
|
}
|
||||||
|
let { anchor, items, project, selected, lit, truncated, onSelect, onStart, canStart, children }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="rail">
|
||||||
|
<div class="head">
|
||||||
|
<StepBox
|
||||||
|
info={anchor}
|
||||||
|
{project}
|
||||||
|
selected={selected === anchor.id}
|
||||||
|
dimmed={false}
|
||||||
|
onSelect={() => onSelect(anchor.id)}
|
||||||
|
/>
|
||||||
|
{#if anchor.step.trigger}
|
||||||
|
<div class="fires"><b class="kw">FIRES FROM</b> {triggerWords(anchor.step.trigger)}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if items.length === 0}
|
||||||
|
<p class="empty">Nothing in the index happens in this symbol's body — the picture has no order to read.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="body">
|
||||||
|
<RailBlock {items} {project} {selected} {lit} {onSelect} {onStart} {canStart} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if truncated > 0}
|
||||||
|
<p class="empty">
|
||||||
|
{truncated} place{truncated === 1 ? '' : 's'} the reading stopped: code it had already read, or as deep as it goes.
|
||||||
|
Start at a step to read on from there.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.rail {
|
||||||
|
height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 20px 24px 64px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: var(--paper);
|
||||||
|
}
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 3px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
}
|
||||||
|
.body {
|
||||||
|
padding-left: 12px;
|
||||||
|
border-left: 1px solid var(--rule-soft);
|
||||||
|
}
|
||||||
|
.fires {
|
||||||
|
font: 400 11.5px var(--sans);
|
||||||
|
color: var(--ink-2);
|
||||||
|
}
|
||||||
|
.kw {
|
||||||
|
font: 600 11.5px var(--mono);
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
font: 400 12px var(--sans);
|
||||||
|
color: var(--ink-3);
|
||||||
|
max-width: 60ch;
|
||||||
|
margin: 16px 0 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -63,6 +63,12 @@ export interface StepsHrefOptions {
|
|||||||
depth?: number;
|
depth?: number;
|
||||||
/** Enter the screens the walk reaches, instead of drawing them as boundaries. */
|
/** Enter the screens the walk reaches, instead of drawing them as boundaries. */
|
||||||
through?: boolean;
|
through?: boolean;
|
||||||
|
/**
|
||||||
|
* Which reading: the code's `order` — the anchor's body as a rail — or the
|
||||||
|
* `tree` of what it sets in motion. Absent takes the answer's own default:
|
||||||
|
* the order for a handler or an endpoint, the tree for a screen.
|
||||||
|
*/
|
||||||
|
view?: 'order' | 'tree';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -155,6 +161,7 @@ export const hashNavigation: NavigationDriver = {
|
|||||||
else if (opts.symbol) params.set('symbol', opts.symbol);
|
else if (opts.symbol) params.set('symbol', opts.symbol);
|
||||||
if (opts.depth) params.set('depth', String(opts.depth));
|
if (opts.depth) params.set('depth', String(opts.depth));
|
||||||
if (opts.through) params.set('through', '1');
|
if (opts.through) params.set('through', '1');
|
||||||
|
if (opts.view) params.set('view', opts.view);
|
||||||
return `#/steps${query(params)}`;
|
return `#/steps${query(params)}`;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
/**
|
||||||
|
* The Steps view's second reading, as the rail draws it.
|
||||||
|
*
|
||||||
|
* The server answers with the anchor's body folded into blocks and forks
|
||||||
|
* (`api/program.ts`); this turns that into what a reader sees — the boxes of
|
||||||
|
* the picture in the code's order, the conditions as words, and one line for
|
||||||
|
* every place the reading has to be honest about not being plain sequence
|
||||||
|
* (work registered to run later, calls started together, a helper already read
|
||||||
|
* above). Nothing here is geometry: the rail is a column of boxes with a
|
||||||
|
* hairline down its left, and a fork is a row of columns, so the browser lays
|
||||||
|
* it out and this file only decides what each thing SAYS.
|
||||||
|
*
|
||||||
|
* The words are the ones the rest of the view uses: `steps-model.ts` for a
|
||||||
|
* box's two lines and the vocabulary a project is read in, `conditions.ts` for
|
||||||
|
* WHEN / AND / OR / NOT. A fork carries its condition once, on its head; an
|
||||||
|
* arm then says only which side it is — *when* and *when not* — except in a
|
||||||
|
* `switch`, where each arm has a condition of its own to say.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { conditionTokens, whenTokens, type WordToken } from './conditions';
|
||||||
|
import { stepLabel, stepSub, type ProjectKind, type StepNodeInfo } from './steps-model';
|
||||||
|
import type { WireArm, WireArmEnd, WireBlock, WireItem, WireNodeRef, WireStep, WireStepsPayload } from './wire';
|
||||||
|
|
||||||
|
/** The construct a fork came from. */
|
||||||
|
export type ForkForm = Extract<WireItem, { kind: 'fork' }>['form'];
|
||||||
|
|
||||||
|
/** A step of the picture, where the code writes it. */
|
||||||
|
export interface RailStep {
|
||||||
|
kind: 'step';
|
||||||
|
id: string;
|
||||||
|
/** The link it arrived on — the panel's rows for this site. */
|
||||||
|
link: string | null;
|
||||||
|
/** The box's words; null when the step is not in the picture (a cap removed it). */
|
||||||
|
info: StepNodeInfo | null;
|
||||||
|
/** The call this one is written inside the arguments of — `res.json`. */
|
||||||
|
within: string | null;
|
||||||
|
/** What it does, when the walk read on into it. */
|
||||||
|
body: RailItem[];
|
||||||
|
/** It happens here too, and was read above. */
|
||||||
|
again: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RailArm {
|
||||||
|
/** WHEN / WHEN NOT, or a case's own condition. */
|
||||||
|
words: WordToken[];
|
||||||
|
/** What the arm's last line says when it stops there: `answers`, `returns`, `throws`, `leaves`. */
|
||||||
|
ends: string | null;
|
||||||
|
body: RailItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RailFork {
|
||||||
|
kind: 'fork';
|
||||||
|
/** The decision, in the conditions vocabulary. */
|
||||||
|
words: WordToken[];
|
||||||
|
/** The word for the construct: `if`, `switch`, `try`. */
|
||||||
|
form: string;
|
||||||
|
arms: RailArm[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A run that is not plain sequence, bracketed and labelled. */
|
||||||
|
export interface RailGroup {
|
||||||
|
kind: 'group';
|
||||||
|
block: 'inline' | 'loop' | 'later' | 'together';
|
||||||
|
/** `via generateToken`, `for each item of items`, `later · then`, `together`. */
|
||||||
|
label: string;
|
||||||
|
/** The helper drawn here, for its link to the symbol view. */
|
||||||
|
via: WireNodeRef | null;
|
||||||
|
within: string | null;
|
||||||
|
again: boolean;
|
||||||
|
body: RailItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RailCut {
|
||||||
|
kind: 'cut';
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RailItem = RailStep | RailFork | RailGroup | RailCut;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rail for a payload: its anchor's body in the code's order, or an empty
|
||||||
|
* list when the server had nothing to read (a screen, an unreadable file).
|
||||||
|
*/
|
||||||
|
export function buildRailModel(payload: WireStepsPayload): RailItem[] {
|
||||||
|
if (!payload.program) return [];
|
||||||
|
const steps = new Map(payload.steps.map((s) => [s.id, s]));
|
||||||
|
return block(payload.program.root, steps, payload.project);
|
||||||
|
}
|
||||||
|
|
||||||
|
function block(items: WireBlock, steps: Map<string, WireStep>, project: ProjectKind): RailItem[] {
|
||||||
|
return items.map((item) => one(item, steps, project));
|
||||||
|
}
|
||||||
|
|
||||||
|
function one(item: WireItem, steps: Map<string, WireStep>, project: ProjectKind): RailItem {
|
||||||
|
switch (item.kind) {
|
||||||
|
case 'step': {
|
||||||
|
const step = steps.get(item.step);
|
||||||
|
return {
|
||||||
|
kind: 'step',
|
||||||
|
id: item.step,
|
||||||
|
link: item.link ?? null,
|
||||||
|
info: step ? { id: step.id, step, label: stepLabel(step), sub: stepSub(step, project) } : null,
|
||||||
|
within: item.within ?? null,
|
||||||
|
body: item.body ? block(item.body, steps, project) : [],
|
||||||
|
again: item.again === true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'fork':
|
||||||
|
return {
|
||||||
|
kind: 'fork',
|
||||||
|
// The head is the decision itself, said once; the arms say only which
|
||||||
|
// side of it they are, so the head carries no WHEN of its own.
|
||||||
|
words: whenTokens(item.on),
|
||||||
|
form: item.form === 'switch' ? 'switch' : item.form === 'try' ? 'try' : 'if',
|
||||||
|
arms: item.arms.map((arm) => ({
|
||||||
|
words: armWords(item.form, item.on, arm),
|
||||||
|
ends: arm.ends === null ? null : endWords(arm.ends),
|
||||||
|
body: block(arm.body, steps, project),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
case 'block':
|
||||||
|
return {
|
||||||
|
kind: 'group',
|
||||||
|
block: item.block,
|
||||||
|
label: groupLabel(item),
|
||||||
|
via: item.via ?? null,
|
||||||
|
within: item.within ?? null,
|
||||||
|
again: item.again === true,
|
||||||
|
body: block(item.body, steps, project),
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
kind: 'cut',
|
||||||
|
text:
|
||||||
|
item.why === 'folded'
|
||||||
|
? 'reads back into itself — the rest is the same code again'
|
||||||
|
: 'as deep as this reading goes — start at a step below to read on',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What an arm says. The fork already carries the condition, so the two sides of
|
||||||
|
* an `if` say only which side they are; a `switch` arm has a condition of its
|
||||||
|
* own, and so does an arm the reading could not match to the head.
|
||||||
|
*/
|
||||||
|
export function armWords(form: ForkForm, on: string, arm: WireArm): WordToken[] {
|
||||||
|
// A case has a condition of its own to say; the one arm of a `try` is the
|
||||||
|
// head (`on error`) and says nothing twice.
|
||||||
|
if (form === 'switch') return conditionTokens(arm.when);
|
||||||
|
if (form === 'try') return [];
|
||||||
|
if (arm.not === true) return [{ kw: true, text: 'WHEN' }, { kw: true, text: 'NOT' }];
|
||||||
|
if (arm.when === on) return [{ kw: true, text: 'WHEN' }];
|
||||||
|
return conditionTokens(arm.when);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How an arm leaves, as a reader says it. */
|
||||||
|
export function endWords(end: WireArmEnd): string {
|
||||||
|
switch (end) {
|
||||||
|
case 'reply':
|
||||||
|
return 'answers here';
|
||||||
|
case 'return':
|
||||||
|
return 'returns here';
|
||||||
|
case 'throw':
|
||||||
|
return 'throws here';
|
||||||
|
default:
|
||||||
|
return 'leaves here';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The words on a bracketed run. */
|
||||||
|
export function groupLabel(item: Extract<WireItem, { kind: 'block' }>): string {
|
||||||
|
switch (item.block) {
|
||||||
|
case 'inline':
|
||||||
|
return item.via ? `via ${item.via.name}` : 'via a helper';
|
||||||
|
case 'loop':
|
||||||
|
return item.by ? `for each ${item.by}` : 'for each';
|
||||||
|
case 'later':
|
||||||
|
return item.by ? `later · ${item.by}` : 'later';
|
||||||
|
default:
|
||||||
|
return item.by ? `together · ${item.by}` : 'together';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
* #/flow flow strip (?from=&to= | ?symbols= | ?t=<trail>)
|
* #/flow flow strip (?from=&to= | ?symbols= | ?t=<trail>)
|
||||||
* #/entry entry points (where a flow starts)
|
* #/entry entry points (where a flow starts)
|
||||||
* #/screens screens (the app's screens and transitions)
|
* #/screens screens (the app's screens and transitions)
|
||||||
* #/steps steps (?anchor=<id> | ?symbol=<name>: what happens from there)
|
* #/steps steps (?anchor=<id> | ?symbol=<name>: what happens from there; ?view=order|tree)
|
||||||
* #/dead dead code (?exported=1 widens the claim)
|
* #/dead dead code (?exported=1 widens the claim)
|
||||||
*
|
*
|
||||||
* Node ids are opaque engine strings shaped `<kind>:<hash>` or
|
* Node ids are opaque engine strings shaped `<kind>:<hash>` or
|
||||||
@@ -87,6 +87,8 @@ export type Route =
|
|||||||
symbol: string | null;
|
symbol: string | null;
|
||||||
depth: number | null;
|
depth: number | null;
|
||||||
through: boolean;
|
through: boolean;
|
||||||
|
/** Which reading the URL asked for; null takes the answer's own default. */
|
||||||
|
reading: 'order' | 'tree' | null;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
view: 'dead';
|
view: 'dead';
|
||||||
@@ -156,12 +158,14 @@ export function parseHash(hash: string): RouterLocation {
|
|||||||
// The anchor travels in the URL, so "what happens on the review screen"
|
// The anchor travels in the URL, so "what happens on the review screen"
|
||||||
// is a link that reopens as the same picture.
|
// is a link that reopens as the same picture.
|
||||||
const depth = Number.parseInt(params.get('depth') ?? '', 10);
|
const depth = Number.parseInt(params.get('depth') ?? '', 10);
|
||||||
|
const reading = params.get('view');
|
||||||
route = {
|
route = {
|
||||||
view: 'steps',
|
view: 'steps',
|
||||||
anchor: params.get('anchor'),
|
anchor: params.get('anchor'),
|
||||||
symbol: params.get('symbol'),
|
symbol: params.get('symbol'),
|
||||||
depth: Number.isFinite(depth) && depth >= 1 && depth <= 14 ? depth : null,
|
depth: Number.isFinite(depth) && depth >= 1 && depth <= 14 ? depth : null,
|
||||||
through: params.get('through') === '1',
|
through: params.get('through') === '1',
|
||||||
|
reading: reading === 'order' || reading === 'tree' ? reading : null,
|
||||||
};
|
};
|
||||||
} else if (head === 'dead' && rest.length === 0) {
|
} else if (head === 'dead' && rest.length === 0) {
|
||||||
// The widening travels in the URL like the map's shape does: a link to
|
// The widening travels in the URL like the map's shape does: a link to
|
||||||
|
|||||||
+3
-1
@@ -814,6 +814,8 @@ export type WireArmEnd = 'reply' | 'return' | 'throw' | 'exit';
|
|||||||
export interface WireArm {
|
export interface WireArm {
|
||||||
/** This arm's own condition, in the words the rest of the view uses. */
|
/** This arm's own condition, in the words the rest of the view uses. */
|
||||||
when: string;
|
when: string;
|
||||||
|
/** The arm taken when the fork's condition does NOT hold — the `else` side. */
|
||||||
|
not?: true;
|
||||||
/** How it leaves: it answers the request, returns, or throws. Null = it runs on. */
|
/** How it leaves: it answers the request, returns, or throws. Null = it runs on. */
|
||||||
ends: WireArmEnd | null;
|
ends: WireArmEnd | null;
|
||||||
body: WireBlock;
|
body: WireBlock;
|
||||||
@@ -836,7 +838,7 @@ export type WireItem =
|
|||||||
* after this function returns (`later`), or calls started together
|
* after this function returns (`later`), or calls started together
|
||||||
* (`together`).
|
* (`together`).
|
||||||
*/
|
*/
|
||||||
| { kind: 'block'; block: 'inline' | 'loop' | 'later' | 'together'; label: string; via?: WireNodeRef; within?: string; body: WireBlock; again?: true }
|
| { kind: 'block'; block: 'inline' | 'loop' | 'later' | 'together'; by?: string; via?: WireNodeRef; within?: string; body: WireBlock; again?: true }
|
||||||
/** Where the reading stopped: a helper that calls itself, or a cap the walk hit. */
|
/** Where the reading stopped: a helper that calls itself, or a cap the walk hit. */
|
||||||
| { kind: 'cut'; why: 'folded' | 'depth' };
|
| { kind: 'cut'; why: 'folded' | 'depth' };
|
||||||
|
|
||||||
|
|||||||
+124
-175
@@ -17,6 +17,8 @@
|
|||||||
import { SvelteFlow, Controls, type Node, type Edge, type Viewport } from '@xyflow/svelte';
|
import { SvelteFlow, Controls, type Node, type Edge, type Viewport } from '@xyflow/svelte';
|
||||||
import '@xyflow/svelte/dist/style.css';
|
import '@xyflow/svelte/dist/style.css';
|
||||||
import StepNode from '../components/steps/StepNode.svelte';
|
import StepNode from '../components/steps/StepNode.svelte';
|
||||||
|
import StepsRail from '../components/steps/StepsRail.svelte';
|
||||||
|
import StepsKey from '../components/steps/StepsKey.svelte';
|
||||||
import ScreenEdge from '../components/screens/ScreenEdge.svelte';
|
import ScreenEdge from '../components/screens/ScreenEdge.svelte';
|
||||||
import KindGlyph from '../components/KindGlyph.svelte';
|
import KindGlyph from '../components/KindGlyph.svelte';
|
||||||
import {
|
import {
|
||||||
@@ -44,6 +46,7 @@
|
|||||||
triggerWords,
|
triggerWords,
|
||||||
type StepsModel,
|
type StepsModel,
|
||||||
} from '../lib/steps-model';
|
} from '../lib/steps-model';
|
||||||
|
import { buildRailModel } from '../lib/program-model';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
anchor: string | null;
|
anchor: string | null;
|
||||||
@@ -51,8 +54,14 @@
|
|||||||
depth: number | null;
|
depth: number | null;
|
||||||
/** Enter the screens the walk reaches, instead of drawing them as boundaries. */
|
/** Enter the screens the walk reaches, instead of drawing them as boundaries. */
|
||||||
through: boolean;
|
through: boolean;
|
||||||
|
/**
|
||||||
|
* Which reading the URL asked for — the code's `order` or the `tree` of
|
||||||
|
* what the anchor sets in motion. Null takes the answer's own default: the
|
||||||
|
* order for a handler or an endpoint, the tree for a screen.
|
||||||
|
*/
|
||||||
|
reading: 'order' | 'tree' | null;
|
||||||
}
|
}
|
||||||
let { anchor, symbol, depth, through }: Props = $props();
|
let { anchor, symbol, depth, through, reading }: Props = $props();
|
||||||
|
|
||||||
let payload = $state<WireStepsPayload | null>(null);
|
let payload = $state<WireStepsPayload | null>(null);
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
@@ -185,6 +194,36 @@
|
|||||||
|
|
||||||
const model = $derived<StepsModel | null>(payload === null ? null : buildStepsModel(payload));
|
const model = $derived<StepsModel | null>(payload === null ? null : buildStepsModel(payload));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which reading is on screen. The URL wins; otherwise the answer's own
|
||||||
|
* default — the code's order for a handler, an endpoint or any function, the
|
||||||
|
* tree for a screen, where handlers fire on events and have nothing to order.
|
||||||
|
*/
|
||||||
|
const readAs = $derived<'order' | 'tree'>(reading ?? payload?.defaultView ?? 'tree');
|
||||||
|
const rail = $derived(payload === null || readAs !== 'order' ? [] : buildRailModel(payload));
|
||||||
|
/** The rail can be asked for and have nothing to show: say so rather than drawing an empty page. */
|
||||||
|
const railReadable = $derived(payload?.program != null);
|
||||||
|
/** The steps on the selected step's own lines — everything else on the rail is dimmed. */
|
||||||
|
const litOnRail = $derived.by(() => {
|
||||||
|
if (payload === null || selected === null) return null;
|
||||||
|
const set = new Set<string>([selected]);
|
||||||
|
for (const l of payload.links) {
|
||||||
|
if (l.from === selected) set.add(l.to);
|
||||||
|
if (l.to === selected) set.add(l.from);
|
||||||
|
}
|
||||||
|
return set;
|
||||||
|
});
|
||||||
|
/** A step with a symbol behind it can become the next anchor. */
|
||||||
|
function canStart(id: string): boolean {
|
||||||
|
const step = payload?.steps.find((s) => s.id === id);
|
||||||
|
return !!step?.node && !step.anchor;
|
||||||
|
}
|
||||||
|
function selectOnRail(id: string): void {
|
||||||
|
selected = selected === id ? null : id;
|
||||||
|
hovered = null;
|
||||||
|
panelHot = null;
|
||||||
|
}
|
||||||
|
|
||||||
const neighbours = $derived.by(() => {
|
const neighbours = $derived.by(() => {
|
||||||
if (model === null || selected === null) return null;
|
if (model === null || selected === null) return null;
|
||||||
const set = new Set<string>([selected]);
|
const set = new Set<string>([selected]);
|
||||||
@@ -284,14 +323,16 @@
|
|||||||
const visibleIds = $derived(new Set(edges.map((e) => e.id)));
|
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. */
|
/** 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 {
|
function rewrite(changes: { depth?: number; through?: boolean; view?: 'order' | 'tree' }): string {
|
||||||
const opts = {
|
return stepsHref({
|
||||||
anchor: anchor ?? undefined,
|
anchor: anchor ?? undefined,
|
||||||
symbol: anchor === null ? (symbol ?? undefined) : undefined,
|
symbol: anchor === null ? (symbol ?? undefined) : undefined,
|
||||||
depth: changes.depth ?? depth ?? undefined,
|
depth: changes.depth ?? depth ?? undefined,
|
||||||
through: changes.through ?? through,
|
through: changes.through ?? through,
|
||||||
};
|
// The reading travels in the URL once it has been chosen, so a link to
|
||||||
return stepsHref(opts);
|
// "the login endpoint in the code's order" reopens as that.
|
||||||
|
view: changes.view ?? reading ?? undefined,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function onEdgeHover(edge: MapEdgeLayout | null, event: MouseEvent | null): void {
|
function onEdgeHover(edge: MapEdgeLayout | null, event: MouseEvent | null): void {
|
||||||
@@ -308,7 +349,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onStageMove(event: MouseEvent): void {
|
function onStageMove(event: MouseEvent): void {
|
||||||
if (model === null || stage === null) return;
|
if (model === null || stage === null || readAs === 'order') return;
|
||||||
const target = event.target as Element | null;
|
const target = event.target as Element | null;
|
||||||
if (target?.closest('.spill')) return;
|
if (target?.closest('.spill')) return;
|
||||||
if (target?.closest('.snode, .legend, .tip, .svelte-flow__controls')) {
|
if (target?.closest('.snode, .legend, .tip, .svelte-flow__controls')) {
|
||||||
@@ -450,6 +491,37 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else if loading && payload === null}
|
{:else if loading && payload === null}
|
||||||
<div class="state"><p class="dim">Walking from the anchor…</p></div>
|
<div class="state"><p class="dim">Walking from the anchor…</p></div>
|
||||||
|
{:else if model !== null && payload !== null && readAs === 'order'}
|
||||||
|
{#if railReadable}
|
||||||
|
<StepsRail
|
||||||
|
anchor={model.nodes.get(payload.anchor.id) ?? [...model.nodes.values()][0]!}
|
||||||
|
items={rail}
|
||||||
|
project={payload.project}
|
||||||
|
{selected}
|
||||||
|
lit={litOnRail}
|
||||||
|
truncated={payload.program?.truncated ?? 0}
|
||||||
|
onSelect={selectOnRail}
|
||||||
|
onStart={(id) => startHere(id)}
|
||||||
|
{canStart}
|
||||||
|
>
|
||||||
|
<StepsKey
|
||||||
|
project={payload.project}
|
||||||
|
order={true}
|
||||||
|
flow={true}
|
||||||
|
open={legendOpen}
|
||||||
|
onToggle={(next) => (legendOpen = next)}
|
||||||
|
/>
|
||||||
|
</StepsRail>
|
||||||
|
{:else}
|
||||||
|
<div class="state">
|
||||||
|
<h2>This has no body to read in order</h2>
|
||||||
|
<p>
|
||||||
|
Nothing the picture holds is written inside this symbol — a screen renders handlers that fire on
|
||||||
|
events, and they have no order between them. Read it as what it sets in motion instead.
|
||||||
|
</p>
|
||||||
|
<p><a class="pick" href={rewrite({ view: 'tree' })}>What it sets in motion →</a></p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{:else if model !== null && payload !== null}
|
{:else if model !== null && payload !== null}
|
||||||
<SvelteFlow
|
<SvelteFlow
|
||||||
{nodes}
|
{nodes}
|
||||||
@@ -475,91 +547,6 @@
|
|||||||
<Controls position="bottom-right" showLock={false} />
|
<Controls position="bottom-right" showLock={false} />
|
||||||
</SvelteFlow>
|
</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>
|
|
||||||
{#if payload.project === 'api'}
|
|
||||||
<div class="lrow">
|
|
||||||
<span class="k-box mono">POST /x</span>
|
|
||||||
<span>An endpoint — its verb and path — or a handler: a function a request, a job, an event or a schedule fires; its line says which</span>
|
|
||||||
</div>
|
|
||||||
<div class="lrow">
|
|
||||||
<span class="k-box k-cross mono">⇢ fn</span>
|
|
||||||
<span>The code crosses a tier: a call into another service or a job put on a queue (⇢), or a job, an event, a message arriving (⇠)</span>
|
|
||||||
</div>
|
|
||||||
<div class="lrow">
|
|
||||||
<span class="k-box k-store mono">set</span>
|
|
||||||
<span>A data call — a function in a store or state file</span>
|
|
||||||
</div>
|
|
||||||
<div class="lrow">
|
|
||||||
<span class="k-box k-effect mono">db</span>
|
|
||||||
<span>A call that leaves the index: the database, the response, a queue, email, payments, a cache, auth, the network</span>
|
|
||||||
</div>
|
|
||||||
{:else if payload.project === 'web'}
|
|
||||||
<div class="lrow">
|
|
||||||
<span class="k-box mono">/path</span>
|
|
||||||
<span>A page, an endpoint, or a handler — a function an event, a request or a page load fires; its line says which</span>
|
|
||||||
</div>
|
|
||||||
<div class="lrow">
|
|
||||||
<span class="k-box k-cross mono">⇢ fn</span>
|
|
||||||
<span>The code crosses to the server (⇢ a request, a server action) or comes back from it (⇠ a push, a stream)</span>
|
|
||||||
</div>
|
|
||||||
<div class="lrow">
|
|
||||||
<span class="k-box k-store mono">set</span>
|
|
||||||
<span>A store action — a function in a store file</span>
|
|
||||||
</div>
|
|
||||||
<div class="lrow">
|
|
||||||
<span class="k-box k-effect mono">api</span>
|
|
||||||
<span>A call that leaves the index: the network, the database, the response, storage, a queue, email</span>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="lrow">
|
|
||||||
<span class="k-box mono">/path</span>
|
|
||||||
<span>A screen, or a handler — a function fired from a tap, an option, a listener; its line says the event</span>
|
|
||||||
</div>
|
|
||||||
<div class="lrow">
|
|
||||||
<span class="k-box k-cross mono">⇢ fn</span>
|
|
||||||
<span>The code crosses into native (⇢ a bridge call) or comes back from it (⇠ an event)</span>
|
|
||||||
</div>
|
|
||||||
<div class="lrow">
|
|
||||||
<span class="k-box k-store mono">set</span>
|
|
||||||
<span>A store action — a function in a store file</span>
|
|
||||||
</div>
|
|
||||||
<div class="lrow">
|
|
||||||
<span class="k-box k-effect mono">api</span>
|
|
||||||
<span>A call that leaves the index: the network, storage, the device, telemetry</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div class="lrow">
|
|
||||||
<svg width="44" height="12" aria-hidden="true"><path d="M2 6 H42" class="k-line" /></svg>
|
|
||||||
<span>Leads to — the plumbing between the two is folded into the line</span>
|
|
||||||
</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}
|
{#if hovered !== null && hoveredInfo !== null}
|
||||||
<div class="tip" style={`left:${hovered.x}px;top:${hovered.y}px`}>
|
<div class="tip" style={`left:${hovered.x}px;top:${hovered.y}px`}>
|
||||||
@@ -579,6 +566,10 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if payload !== null && model !== null && readAs === 'tree'}
|
||||||
|
<StepsKey project={payload.project} order={false} flow={false} open={legendOpen} onToggle={(next) => (legendOpen = next)} />
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if payload !== null && model !== null}
|
{#if payload !== null && model !== null}
|
||||||
@@ -755,6 +746,11 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
<p class="reading">
|
||||||
|
Read as:
|
||||||
|
<a class="tab" class:on={readAs === 'order'} href={rewrite({ view: 'order' })}>in order</a>
|
||||||
|
<a class="tab" class:on={readAs === 'tree'} href={rewrite({ view: 'tree' })}>what it sets in motion</a>
|
||||||
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<b>{payload.steps.length}</b> steps · <b>{payload.links.length}</b> links · depth
|
<b>{payload.steps.length}</b> steps · <b>{payload.links.length}</b> links · depth
|
||||||
<select
|
<select
|
||||||
@@ -783,12 +779,22 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</p>
|
</p>
|
||||||
<p class="dim">
|
{#if readAs === 'order'}
|
||||||
<span class="mark">●</span> The anchor is at the top; each row down is one more step away from
|
<p class="dim">
|
||||||
it. Click a step and each of its links is labelled at the far end of its line with the last
|
<span class="mark">●</span> The anchor is at the top, then its body in the code's own order: the
|
||||||
condition checked before it happens; hover the line, or its row here, for the whole chain and the
|
calls as they are written, a fork where the code forks with its arms side by side, a helper drawn
|
||||||
plumbing it travels through. A step is the next anchor, and any link opens as a Flow strip.
|
where it is called, and an arm that answers, returns or throws ending there. A call written inside
|
||||||
</p>
|
another call's arguments comes first — the token is signed before the reply that carries it. Click
|
||||||
|
a step for its sites and conditions; a step is the next anchor.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<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}
|
||||||
{#if payload.truncated.steps > 0 || payload.truncated.hubs > 0 || payload.truncated.chrome > 0}
|
{#if payload.truncated.steps > 0 || payload.truncated.hubs > 0 || payload.truncated.chrome > 0}
|
||||||
<p class="dim">
|
<p class="dim">
|
||||||
Not drawn:
|
Not drawn:
|
||||||
@@ -888,84 +894,6 @@
|
|||||||
.pick:hover {
|
.pick:hover {
|
||||||
background: var(--press);
|
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 {
|
.tip {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
@@ -1048,6 +976,27 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
accent-color: var(--accent);
|
accent-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
.reading {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--ink-3);
|
||||||
|
}
|
||||||
|
.tab {
|
||||||
|
color: var(--ink-2);
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 1px solid var(--rule-soft);
|
||||||
|
padding-bottom: 1px;
|
||||||
|
}
|
||||||
|
.tab:hover {
|
||||||
|
color: var(--ink);
|
||||||
|
border-bottom-color: var(--ink-3);
|
||||||
|
}
|
||||||
|
.tab.on {
|
||||||
|
color: var(--ink);
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
}
|
||||||
.depth {
|
.depth {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
Reference in New Issue
Block a user