StepsView.svelte 45 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151
  1. <!--
  2. The Steps view (`#/steps?anchor=…`): what happens from here. One box per
  3. step — a screen, a handler, a call into native code, a native event landing
  4. back in JS, a store action, a call that leaves the index — an arrow for
  5. every way one leads to the next, and on each arrow the condition under
  6. which it happens, with the plumbing between two steps folded into the arrow
  7. and listed in the panel.
  8. Everything drawn comes from `/api/steps`: the anchor's forward walk through
  9. calls, renders, handler bindings and navigations, classified as it goes, and
  10. branch guards read from the source. The canvas is the Screens view's
  11. machinery with a different node universe (see `steps-model.ts`); the side
  12. panel is where the sentences are, and where a step becomes the next anchor
  13. or a Flow strip between two steps.
  14. -->
  15. <script lang="ts">
  16. import { SvelteFlow, Controls, type Node, type Edge, type Viewport } from '@xyflow/svelte';
  17. import '@xyflow/svelte/dist/style.css';
  18. import StepNode from '../components/steps/StepNode.svelte';
  19. import ScreenEdge from '../components/screens/ScreenEdge.svelte';
  20. import KindGlyph from '../components/KindGlyph.svelte';
  21. import {
  22. canDrawSteps,
  23. fetchRoutes,
  24. fetchScreens,
  25. fetchSteps,
  26. type WireRoute,
  27. type WireScreen,
  28. type WireStepLink,
  29. type WireStepsPayload,
  30. } from '../lib/api';
  31. import { live } from '../lib/live.svelte';
  32. import { fileHref, flowHref, navigate, stepsHref, symbolHref } from '../lib/navigation';
  33. import { isEdgeVisible, type MapEdgeLayout } from '../lib/map-model';
  34. import { hoverPill, nearestEdge, placeLabels } from '../lib/screens-model';
  35. import { commonTokens, conditionTokens, restTokens, scenarios, whenWords, type WordToken } from '../lib/conditions';
  36. import {
  37. buildStepsModel,
  38. kindWord,
  39. kindWords,
  40. stepNeighbourhood,
  41. stepPairId,
  42. stepViaText,
  43. triggerWords,
  44. type StepsModel,
  45. } from '../lib/steps-model';
  46. interface Props {
  47. anchor: string | null;
  48. symbol: string | null;
  49. depth: number | null;
  50. /** Enter the screens the walk reaches, instead of drawing them as boundaries. */
  51. through: boolean;
  52. }
  53. let { anchor, symbol, depth, through }: Props = $props();
  54. let payload = $state<WireStepsPayload | null>(null);
  55. let error = $state<string | null>(null);
  56. let loading = $state(true);
  57. let selected = $state<string | null>(null);
  58. let hovered = $state<{ edge: MapEdgeLayout; x: number; y: number } | null>(null);
  59. /** The panel row under the pointer: its edge on the canvas, and the one link it names. */
  60. let panelHot = $state<{ edge: string; link: WireStepLink } | null>(null);
  61. let stage = $state<HTMLDivElement | null>(null);
  62. let viewport = $state<Viewport | undefined>(undefined);
  63. const HOVER_REACH = 10;
  64. /** The chooser's lists, when the view opens without an anchor: the screens of an app, else the endpoints of an API. */
  65. let screens = $state<WireScreen[] | null>(null);
  66. let routes = $state<WireRoute[] | null>(null);
  67. /** What the chooser offers: null while reading. */
  68. const chooser = $derived.by<'screens' | 'routes' | 'none' | null>(() => {
  69. if (screens === null) return null;
  70. if (screens.length > 0) return 'screens';
  71. if (routes === null) return null;
  72. return routes.length > 0 ? 'routes' : 'none';
  73. });
  74. /** Endpoints by the file they are registered in — the router file is how a reader groups them — biggest first, in registration order within. */
  75. function routeGroups(list: WireRoute[]): Array<{ file: string; entries: WireRoute[] }> {
  76. const byFile = new Map<string, WireRoute[]>();
  77. for (const r of list) {
  78. const group = byFile.get(r.routeFile) ?? [];
  79. group.push(r);
  80. byFile.set(r.routeFile, group);
  81. }
  82. return [...byFile]
  83. .map(([file, entries]) => ({ file, entries: [...entries].sort((a, b) => a.routeLine - b.routeLine) }))
  84. .sort((a, b) => b.entries.length - a.entries.length || a.file.localeCompare(b.file));
  85. }
  86. const LEGEND_KEY = 'codegraph-ui:steps-legend';
  87. let legendOpen = $state(readLegendOpen());
  88. function readLegendOpen(): boolean {
  89. try {
  90. return localStorage.getItem(LEGEND_KEY) !== 'closed';
  91. } catch {
  92. return true;
  93. }
  94. }
  95. $effect(() => {
  96. try {
  97. localStorage.setItem(LEGEND_KEY, legendOpen ? 'open' : 'closed');
  98. } catch {
  99. // Storage refused (private mode): the key simply reopens next time.
  100. }
  101. });
  102. /**
  103. * The fit. A picture of a few boxes is centred — and the key, bottom left,
  104. * would sit on its second row; it is fitted to the right of the key instead.
  105. * A picture of many boxes is fitted to the whole stage, as the Screens view's.
  106. */
  107. const fitOptions = $derived(
  108. model !== null && model.layout.nodes.length <= 24 && legendOpen
  109. ? { padding: { left: '440px', top: '32px', right: '32px', bottom: '32px' }, maxZoom: 1, minZoom: 0.4 }
  110. : { padding: 0.1, maxZoom: 1, minZoom: 0.4 }
  111. );
  112. const nodeTypes = { step: StepNode };
  113. /** Two clicks on one box closer than this are a double-click. */
  114. const DOUBLE_CLICK_MS = 400;
  115. let lastClick: { id: string; at: number } | null = null;
  116. /** Start the picture at a step — the panel's *Start here →*. False for a step with no symbol, or the anchor. */
  117. function startHere(id: string): boolean {
  118. const step = model?.nodes.get(id)?.step;
  119. if (!step || !step.node || step.anchor) return false;
  120. navigate(stepsHref({ anchor: step.node.id }));
  121. return true;
  122. }
  123. const edgeTypes = { screen: ScreenEdge };
  124. const DEPTHS = [4, 6, 8, 10, 12];
  125. const asked = $derived(anchor !== null || symbol !== null);
  126. const supported = canDrawSteps();
  127. $effect(() => {
  128. void live.indexTick;
  129. const request =
  130. anchor !== null
  131. ? { anchor, depth: depth ?? undefined, through }
  132. : symbol !== null
  133. ? { symbol, depth: depth ?? undefined, through }
  134. : null;
  135. const controller = new AbortController();
  136. selected = null;
  137. hovered = null;
  138. panelHot = null;
  139. if (request === null) {
  140. payload = null;
  141. loading = false;
  142. error = null;
  143. fetchScreens(controller.signal)
  144. .then(async (next) => {
  145. screens = next.routed ? next.screens : [];
  146. // No screens: an API's endpoints are its places to start from.
  147. if (next.routed) {
  148. routes = [];
  149. return;
  150. }
  151. const found = await fetchRoutes({ limit: 300 }, controller.signal);
  152. routes = found.routed ? found.entries : [];
  153. })
  154. .catch(() => {
  155. screens = screens ?? [];
  156. routes = routes ?? [];
  157. });
  158. return () => controller.abort();
  159. }
  160. loading = true;
  161. error = null;
  162. fetchSteps(request, controller.signal)
  163. .then((next) => {
  164. payload = next;
  165. loading = false;
  166. })
  167. .catch((err: unknown) => {
  168. if (controller.signal.aborted) return;
  169. error = err instanceof Error ? err.message : String(err);
  170. loading = false;
  171. });
  172. return () => controller.abort();
  173. });
  174. const model = $derived<StepsModel | null>(payload === null ? null : buildStepsModel(payload));
  175. const neighbours = $derived.by(() => {
  176. if (model === null || selected === null) return null;
  177. const set = new Set<string>([selected]);
  178. for (const edge of model.layout.edges) {
  179. if (edge.source === selected) set.add(edge.target);
  180. if (edge.target === selected) set.add(edge.source);
  181. }
  182. return set;
  183. });
  184. const pills = $derived(model === null ? null : placeLabels(model, selected));
  185. const focusId = $derived(hovered?.edge.id ?? panelHot?.edge ?? null);
  186. const focusPill = $derived.by(() => {
  187. if (model === null || focusId === null || pills?.pills.has(focusId)) return null;
  188. const full = panelHot?.edge === focusId ? fullText(panelHot.link) : undefined;
  189. return hoverPill(model, focusId, selected, full, pills ?? undefined);
  190. });
  191. const nodes = $derived.by<Node[]>(() => {
  192. if (model === null) return [];
  193. return model.layout.nodes.map((node) => ({
  194. id: node.id,
  195. type: 'step',
  196. position: { x: node.x, y: node.y },
  197. draggable: false,
  198. selectable: false,
  199. connectable: false,
  200. data: {
  201. layout: node,
  202. info: model.nodes.get(node.id)!,
  203. project: payload?.project ?? 'app',
  204. selected: selected === node.id,
  205. dimmed: neighbours !== null && !neighbours.has(node.id),
  206. onSelect: (id: string) => {
  207. // Two clicks on the same box within a beat are a double-click:
  208. // the picture starts there. Read here rather than off the DOM's
  209. // `dblclick`, which the flow canvas does not always pass on.
  210. const now = performance.now();
  211. if (lastClick !== null && lastClick.id === id && now - lastClick.at < DOUBLE_CLICK_MS) {
  212. lastClick = null;
  213. if (startHere(id)) return;
  214. }
  215. lastClick = { id, at: now };
  216. selected = selected === id ? null : id;
  217. hovered = null;
  218. panelHot = null;
  219. },
  220. // Double-click: the picture starts here — an endpoint or another
  221. // screen drawn as a boundary opens as its own chapter. An effect has
  222. // no symbol to start from.
  223. ...(model.nodes.get(node.id)?.step.node && !model.nodes.get(node.id)?.step.anchor ? { onStart: startHere } : {}),
  224. },
  225. }));
  226. });
  227. const edges = $derived.by<Edge[]>(() => {
  228. if (model === null) return [];
  229. const focus = focusId;
  230. return model.layout.edges
  231. .filter((edge) => isEdgeVisible(edge, selected))
  232. .map((edge) => {
  233. const touches = selected !== null && (edge.source === selected || edge.target === selected);
  234. const isFocus = focus === edge.id;
  235. const hot = isFocus || touches;
  236. return {
  237. id: edge.id,
  238. source: edge.source,
  239. target: edge.target,
  240. sourceHandle: edge.sourceHandle,
  241. targetHandle: edge.targetHandle,
  242. type: 'screen',
  243. selectable: false,
  244. deletable: false,
  245. zIndex: isFocus ? 3 : hot ? 2 : 1,
  246. data: {
  247. edge,
  248. info: model.edges.get(edge.id)!,
  249. curve: model.curves.get(edge.id)!,
  250. hot,
  251. soft: hot && focus !== null && !isFocus,
  252. focus: isFocus,
  253. dimmed: selected !== null && !touches,
  254. pill: pills?.pills.get(edge.id) ?? (isFocus ? focusPill : null),
  255. full: panelHot?.edge === edge.id ? fullText(panelHot.link) : null,
  256. onHover: onEdgeHover,
  257. },
  258. };
  259. });
  260. });
  261. const selectedInfo = $derived(selected === null || model === null ? null : (model.nodes.get(selected) ?? null));
  262. const lists = $derived(selected === null || payload === null ? null : stepNeighbourhood(payload, selected));
  263. const hoveredInfo = $derived(hovered === null || model === null ? null : (model.edges.get(hovered.edge.id) ?? null));
  264. const edgeById = $derived(
  265. model === null ? new Map<string, MapEdgeLayout>() : new Map(model.layout.edges.map((e) => [e.id, e]))
  266. );
  267. const visibleIds = $derived(new Set(edges.map((e) => e.id)));
  268. /** The same picture with one setting changed: the anchor as the URL asked for it, the rest kept. */
  269. function rewrite(changes: { depth?: number; through?: boolean }): string {
  270. const opts = {
  271. anchor: anchor ?? undefined,
  272. symbol: anchor === null ? (symbol ?? undefined) : undefined,
  273. depth: changes.depth ?? depth ?? undefined,
  274. through: changes.through ?? through,
  275. };
  276. return stepsHref(opts);
  277. }
  278. function onEdgeHover(edge: MapEdgeLayout | null, event: MouseEvent | null): void {
  279. if (edge === null || event === null || stage === null) {
  280. hovered = null;
  281. return;
  282. }
  283. const box = stage.getBoundingClientRect();
  284. hovered = {
  285. edge,
  286. x: Math.min(event.clientX - box.left + 14, box.width - 420),
  287. y: event.clientY - box.top + 14,
  288. };
  289. }
  290. function onStageMove(event: MouseEvent): void {
  291. if (model === null || stage === null) return;
  292. const target = event.target as Element | null;
  293. if (target?.closest('.spill')) return;
  294. if (target?.closest('.snode, .legend, .tip, .svelte-flow__controls')) {
  295. hovered = null;
  296. return;
  297. }
  298. const view = viewport ?? readViewport();
  299. if (!view) return;
  300. const box = stage.getBoundingClientRect();
  301. const point = {
  302. x: (event.clientX - box.left - view.x) / view.zoom,
  303. y: (event.clientY - box.top - view.y) / view.zoom,
  304. };
  305. const hit = nearestEdge(model, point, visibleIds, HOVER_REACH / view.zoom);
  306. const edge = hit === null ? undefined : edgeById.get(hit.id);
  307. if (!edge) {
  308. hovered = null;
  309. return;
  310. }
  311. hovered = {
  312. edge,
  313. x: Math.min(event.clientX - box.left + 14, box.width - 420),
  314. y: event.clientY - box.top + 14,
  315. };
  316. }
  317. function readViewport(): Viewport | null {
  318. const el = stage?.querySelector<HTMLElement>('.svelte-flow__viewport');
  319. const m = el?.style.transform.match(/translate\(([-\d.]+)px,\s*([-\d.]+)px\)\s*scale\(([-\d.]+)\)/);
  320. return m ? { x: Number(m[1]), y: Number(m[2]), zoom: Number(m[3]) } : null;
  321. }
  322. function onRowHover(link: WireStepLink | null): void {
  323. const edge = link === null ? null : stepPairId(link);
  324. panelHot = link === null || edge === null ? null : { edge, link };
  325. }
  326. /** The words a panel row puts on its line: the arrow, and the whole condition. */
  327. function fullText(link: WireStepLink): string {
  328. const arriving = selected !== null && link.to === selected && link.from !== selected;
  329. return `${arriving ? '←' : '→'} ${whenWords(link.when) || 'always'}`;
  330. }
  331. function rowHot(link: WireStepLink): boolean {
  332. if (panelHot !== null) return panelHot.link.id === link.id;
  333. return hovered !== null && stepPairId(link) === hovered.edge.id;
  334. }
  335. function nameOf(id: string): string {
  336. return model?.nodes.get(id)?.label ?? id;
  337. }
  338. /** A Flow strip between the two symbols of a link, when both are symbols. */
  339. function stripHref(link: WireStepLink): string | null {
  340. const from = payload?.steps.find((s) => s.id === link.from)?.node;
  341. const to = payload?.steps.find((s) => s.id === link.to)?.node;
  342. if (!from || !to) return null;
  343. return flowHref({ from: from.name, to: to.name });
  344. }
  345. /** The symbol a site's line belongs to: the last folded symbol, else the step's own. */
  346. function siteHref(link: WireStepLink, site: { file: string; line: number }, fallback: string | null): string | null {
  347. const last = link.via[link.via.length - 1];
  348. const id = last?.id ?? fallback;
  349. return id === null ? null : symbolHref(id, { line: site.line });
  350. }
  351. function basename(file: string): string {
  352. return file.slice(file.lastIndexOf('/') + 1);
  353. }
  354. /** `SecureStore.setItemAsync('userEmail', values.email)` — the site, with what it passes when that could be read. */
  355. function siteWords(site: { text: string; args?: string }): string {
  356. return site.args === undefined ? site.text : `${site.text}(${site.args})`;
  357. }
  358. </script>
  359. {#snippet words(tokens: WordToken[])}
  360. {#each tokens as t, i (i)}{#if i > 0}{' '}{/if}{#if t.kw}<b class="kw">{t.text}</b>{:else}{t.text}{/if}{/each}
  361. {/snippet}
  362. <div class="steps">
  363. <div class="stage" bind:this={stage} role="presentation" onmousemove={onStageMove} onmouseleave={() => (hovered = null)}>
  364. {#if !supported}
  365. <div class="state">
  366. <h2>This viewer cannot draw steps</h2>
  367. <p>The host it runs in has not wired the steps question. The Screens and Flow views still work.</p>
  368. </div>
  369. {:else if !asked}
  370. <div class="state chooser">
  371. <h2>What happens from where?</h2>
  372. {#if chooser === 'routes'}
  373. <p>
  374. Pick an endpoint and this view draws everything it sets in motion — its handler and what runs
  375. before it, the calls into the database, a queue, another service, and every response it can
  376. send — one box per step, an arrow for every way one leads to the next, and on each arrow the
  377. condition under which it happens. Or search a symbol and choose <i>What happens from here</i>.
  378. </p>
  379. {:else}
  380. <p>
  381. Pick a screen and this view draws everything it sets in motion — its handlers, the calls that
  382. cross into native code, the events that come back, the state it writes, the requests that leave
  383. the app — one box per step, an arrow for every way one leads to the next, and on each arrow the
  384. condition under which it happens. Or search a symbol and choose <i>What happens from here</i>.
  385. </p>
  386. {/if}
  387. {#if chooser === null}
  388. <p class="dim">Reading {screens === null ? 'screens' : 'endpoints'}…</p>
  389. {:else if chooser === 'none'}
  390. <p class="dim">
  391. No screens or endpoints in this graph. Open a symbol from the search box and follow <i>What happens from here</i>,
  392. or link here directly with <span class="mono">#/steps?symbol=&lt;name&gt;</span>.
  393. </p>
  394. {:else if chooser === 'screens' && screens !== null}
  395. <div class="chooser-list">
  396. {#each [...screens].sort((a, b) => b.outgoing + b.incoming - (a.outgoing + a.incoming) || a.path.localeCompare(b.path)) as screen (screen.id)}
  397. <a class="pick mono" href={stepsHref({ anchor: screen.id })}
  398. >{screen.path} <span class="dim sans">{screen.component?.name ?? basename(screen.file)}</span></a
  399. >
  400. {/each}
  401. </div>
  402. {:else if routes !== null}
  403. {#each routeGroups(routes) as group (group.file)}
  404. <div class="group-h"><span class="mono">{group.file}</span><span class="dim">{group.entries.length}</span></div>
  405. <div class="chooser-list">
  406. {#each group.entries as route (route.routeId)}
  407. <a class="pick mono" href={stepsHref({ anchor: route.routeId })}
  408. >{route.url} <span class="dim sans">{route.handler}</span></a
  409. >
  410. {/each}
  411. </div>
  412. {/each}
  413. {/if}
  414. </div>
  415. {:else if error !== null}
  416. <div class="state">
  417. <h2>The steps could not be read</h2>
  418. <p>{error}</p>
  419. </div>
  420. {:else if loading && payload === null}
  421. <div class="state"><p class="dim">Walking from the anchor…</p></div>
  422. {:else if model !== null && payload !== null}
  423. <SvelteFlow
  424. {nodes}
  425. {edges}
  426. {nodeTypes}
  427. {edgeTypes}
  428. fitView
  429. fitViewOptions={fitOptions}
  430. bind:viewport
  431. minZoom={0.2}
  432. maxZoom={3}
  433. nodesDraggable={false}
  434. nodesConnectable={false}
  435. elementsSelectable={false}
  436. panOnDrag
  437. proOptions={{ hideAttribution: true }}
  438. onpaneclick={() => {
  439. selected = null;
  440. hovered = null;
  441. panelHot = null;
  442. }}
  443. >
  444. <Controls position="bottom-right" showLock={false} />
  445. </SvelteFlow>
  446. <div class="legend" class:open={legendOpen}>
  447. <button class="legend-h" onclick={() => (legendOpen = !legendOpen)} aria-expanded={legendOpen}>
  448. Key <span class="dim">{legendOpen ? '▾' : '▸'}</span>
  449. </button>
  450. {#if legendOpen}
  451. <div class="legend-body">
  452. <div class="lrow">
  453. <span class="k-box k-anchor mono"><span class="mark">●</span>start</span>
  454. <span>Where the picture starts; each row down is one more step away</span>
  455. </div>
  456. {#if payload.project === 'api'}
  457. <div class="lrow">
  458. <span class="k-box mono">POST /x</span>
  459. <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>
  460. </div>
  461. <div class="lrow">
  462. <span class="k-box k-cross mono">⇢ fn</span>
  463. <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>
  464. </div>
  465. <div class="lrow">
  466. <span class="k-box k-store mono">set</span>
  467. <span>A data call — a function in a store or state file</span>
  468. </div>
  469. <div class="lrow">
  470. <span class="k-box k-effect mono">db</span>
  471. <span>A call that leaves the index: the database, the response, a queue, email, payments, a cache, auth, the network</span>
  472. </div>
  473. {:else if payload.project === 'web'}
  474. <div class="lrow">
  475. <span class="k-box mono">/path</span>
  476. <span>A page, an endpoint, or a handler — a function an event, a request or a page load fires; its line says which</span>
  477. </div>
  478. <div class="lrow">
  479. <span class="k-box k-cross mono">⇢ fn</span>
  480. <span>The code crosses to the server (⇢ a request, a server action) or comes back from it (⇠ a push, a stream)</span>
  481. </div>
  482. <div class="lrow">
  483. <span class="k-box k-store mono">set</span>
  484. <span>A store action — a function in a store file</span>
  485. </div>
  486. <div class="lrow">
  487. <span class="k-box k-effect mono">api</span>
  488. <span>A call that leaves the index: the network, the database, the response, storage, a queue, email</span>
  489. </div>
  490. {:else}
  491. <div class="lrow">
  492. <span class="k-box mono">/path</span>
  493. <span>A screen, or a handler — a function fired from a tap, an option, a listener; its line says the event</span>
  494. </div>
  495. <div class="lrow">
  496. <span class="k-box k-cross mono">⇢ fn</span>
  497. <span>The code crosses into native (⇢ a bridge call) or comes back from it (⇠ an event)</span>
  498. </div>
  499. <div class="lrow">
  500. <span class="k-box k-store mono">set</span>
  501. <span>A store action — a function in a store file</span>
  502. </div>
  503. <div class="lrow">
  504. <span class="k-box k-effect mono">api</span>
  505. <span>A call that leaves the index: the network, storage, the device, telemetry</span>
  506. </div>
  507. {/if}
  508. <div class="lrow">
  509. <svg width="44" height="12" aria-hidden="true"><path d="M2 6 H42" class="k-line" /></svg>
  510. <span>Leads to — the plumbing between the two is folded into the line</span>
  511. </div>
  512. <div class="lrow">
  513. <svg width="44" height="12" aria-hidden="true"><path d="M2 6 H42" class="k-line k-synth" /></svg>
  514. <span>Established by a synthesized hop (an event channel, a callback, a helper's return value)</span>
  515. </div>
  516. <div class="lrow">
  517. <svg width="44" height="12" aria-hidden="true"><path d="M2 6 H42" class="k-line k-back" /></svg>
  518. <span>Goes back up the picture — leaves the top of its box, arrives at the bottom of the other</span>
  519. </div>
  520. <div class="lrow">
  521. <span class="k-label mono">→ …x</span>
  522. <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>
  523. </div>
  524. <div class="lrow">
  525. <span class="k-label mono">name …</span>
  526. <span>Not entered: another screen (a chapter of its own), or a cap the walk hit — start there to see on</span>
  527. </div>
  528. </div>
  529. {/if}
  530. </div>
  531. {#if hovered !== null && hoveredInfo !== null}
  532. <div class="tip" style={`left:${hovered.x}px;top:${hovered.y}px`}>
  533. <div class="mono"><b>{nameOf(hoveredInfo.from)}</b> → {nameOf(hoveredInfo.to)}</div>
  534. {#each hoveredInfo.links.slice(0, 5) as link (link.id)}
  535. <div class="tiprow">
  536. {#if link.trigger}<span class="fires"><b class="kw">FIRES FROM</b> {triggerWords(link.trigger)} <span class="dim">in {link.trigger.in}</span></span>{/if}
  537. {#if link.via.length > 0}<span class="via">via {stepViaText(link)}</span>{/if}
  538. {#if link.sites.length > 1}<span class="dim">{link.sites.length} ways</span>{/if}
  539. <span class="when">{@render words(conditionTokens(link.when))}</span>
  540. {#if link.label}<span class="dim">{link.label}</span>{/if}
  541. {#if link.sites[0]}<span class="mono">{#if link.sites[0].status}<b class="status">{link.sites[0].status}</b> · {/if}{siteWords(link.sites[0])}</span>{/if}
  542. </div>
  543. {/each}
  544. {#if hoveredInfo.links.length > 5}<div class="dim">+{hoveredInfo.links.length - 5} more</div>{/if}
  545. </div>
  546. {/if}
  547. {/if}
  548. </div>
  549. {#if payload !== null && model !== null}
  550. <aside class="side">
  551. {#if selectedInfo !== null && lists !== null}
  552. <div class="head">
  553. <div>
  554. <div class="mono big">{selectedInfo.label}</div>
  555. <div class="sub dim">{kindWord(selectedInfo.step.kind, payload.project, selectedInfo.step)}{#if selectedInfo.step.anchor} · where the picture starts{/if}</div>
  556. {#if selectedInfo.step.trigger}
  557. <div class="fires"><b class="kw">FIRES FROM</b> {triggerWords(selectedInfo.step.trigger)} <span class="dim">in {selectedInfo.step.trigger.in}</span></div>
  558. {/if}
  559. {#if selectedInfo.step.screen?.component}
  560. <a class="sub" href={symbolHref(selectedInfo.step.screen.component.id)}>
  561. <KindGlyph kind={selectedInfo.step.screen.component.kind} />
  562. {selectedInfo.step.screen.component.name}
  563. </a>
  564. {:else if selectedInfo.step.node && selectedInfo.step.kind !== 'screen'}
  565. <a class="sub" href={symbolHref(selectedInfo.step.node.id)}>
  566. <KindGlyph kind={selectedInfo.step.node.kind} />
  567. {selectedInfo.step.node.name}
  568. </a>
  569. {/if}
  570. {#if selectedInfo.step.effect}
  571. <a class="sub" href={symbolHref(selectedInfo.step.effect.by.id, { line: selectedInfo.step.effect.line })}>
  572. <KindGlyph kind={selectedInfo.step.effect.by.kind} />
  573. {selectedInfo.step.effect.by.name} · line {selectedInfo.step.effect.line}
  574. </a>
  575. {/if}
  576. {#if selectedInfo.step.node}
  577. <a class="sub dim" href={fileHref(selectedInfo.step.node.file)}>{selectedInfo.step.node.file}</a>
  578. {/if}
  579. {#if selectedInfo.step.node && !selectedInfo.step.anchor}
  580. <a class="sub act" href={stepsHref({ anchor: selectedInfo.step.node.id })}>Start here →</a>
  581. {/if}
  582. </div>
  583. <button class="clear" onclick={() => (selected = null)}>clear</button>
  584. </div>
  585. {#if selectedInfo.step.cut === 'screen'}
  586. <p class="dim note">Another {kindWord('screen', payload.project, selectedInfo.step)} — a chapter of its own. Start here (or double-click its box) to see what happens on it, or continue through {kindWords('screen', payload.project)[1]} from the summary.</p>
  587. {:else if selectedInfo.step.cut === 'component'}
  588. <p class="dim note">The event lands in a component of another screen — a picture of its own. Start here (or double-click its box) to see it, or continue through screens from the summary.</p>
  589. {:else if selectedInfo.step.cut !== null}
  590. <p class="dim note">
  591. The walk was cut at this step ({selectedInfo.step.cut === 'depth'
  592. ? 'the picture’s depth'
  593. : selectedInfo.step.cut === 'fan-out'
  594. ? 'more calls than the walk follows from one node'
  595. : selectedInfo.step.cut === 'folded'
  596. ? 'as much plumbing as it folds from one step'
  597. : 'the picture’s size'}). Start here to see on.
  598. </p>
  599. {/if}
  600. {#if selectedInfo.step.effect && selectedInfo.step.effect.apis.length > 1}
  601. <p class="dim note mono">{selectedInfo.step.effect.apis.join(' · ')}</p>
  602. {/if}
  603. {#if selectedInfo.step.effect?.category === 'response'}
  604. <p class="dim note">The endpoint’s contract as the code has it: each row below is one way it answers, with the condition it answers under.</p>
  605. {/if}
  606. {#if selectedInfo.step.events && selectedInfo.step.events.length > 1}
  607. <p class="dim note mono">⇠ {selectedInfo.step.events.join(' · ')}</p>
  608. {/if}
  609. {#if pills !== null && pills.hidden > 0}
  610. <p class="dim note">
  611. {pills.hidden} condition{pills.hidden === 1 ? '' : 's'} not drawn on the picture for want of
  612. room — hover a row below to see {pills.hidden === 1 ? 'it' : 'each'} on its line.
  613. </p>
  614. {/if}
  615. <h4>Arrives from <span class="dim">{lists.arrivesFrom.length}</span></h4>
  616. {#if lists.arrivesFrom.length === 0}
  617. <p class="dim">{selectedInfo.step.anchor ? 'The anchor — the picture starts here.' : 'Nothing in the picture leads here.'}</p>
  618. {/if}
  619. {#each lists.arrivesFrom as link (link.id)}
  620. {@const sc = scenarios(link.sites)}
  621. {@const fallback = payload.steps.find((s) => s.id === link.from)?.node?.id ?? null}
  622. <div
  623. class="row"
  624. class:hot={rowHot(link)}
  625. role="presentation"
  626. onmouseenter={() => onRowHover(link)}
  627. onmouseleave={() => onRowHover(null)}
  628. onfocusin={() => onRowHover(link)}
  629. onfocusout={() => onRowHover(null)}
  630. >
  631. <button class="peer mono" onclick={() => (selected = link.from)}>{nameOf(link.from)}</button>
  632. {#if link.trigger}<div class="fires"><b class="kw">FIRES FROM</b> {triggerWords(link.trigger)} <span class="dim">in {link.trigger.in}</span></div>{/if}
  633. {#if link.via.length > 0}<div class="via">via {stepViaText(link)}</div>{/if}
  634. {#if sc.common.length > 0}<div class="when">{@render words(commonTokens(sc.common))}</div>{/if}
  635. {#if link.label}<div class="via dim">{link.label}</div>{/if}
  636. {#if sc.rows.length > 1}<div class="ways dim">{sc.rows.length} ways</div>{/if}
  637. {#each sc.rows as row (row.site.file + row.site.line)}
  638. {@const href = siteHref(link, row.site, fallback)}
  639. <div class="scenario" class:many={sc.rows.length > 1}>
  640. {#if row.site.trigger && triggerWords(row.site.trigger) !== (link.trigger ? triggerWords(link.trigger) : '')}
  641. <div class="fires"><b class="kw">FIRES FROM</b> {triggerWords(row.site.trigger)}</div>
  642. {/if}
  643. {#if sc.rows.length > 1}<div class="when">{@render words(restTokens(row.rest, sc.common.length > 0))}</div>{/if}
  644. {#if href}
  645. <a class="site" {href}>{#if row.site.status}<b class="status">{row.site.status}</b> · {/if}{siteWords(row.site)} <span class="dim">· {basename(row.site.file)}:{row.site.line}</span></a>
  646. {:else}
  647. <span class="site">{#if row.site.status}<b class="status">{row.site.status}</b> · {/if}{siteWords(row.site)} <span class="dim">· {basename(row.site.file)}:{row.site.line}</span></span>
  648. {/if}
  649. </div>
  650. {/each}
  651. {#if stripHref(link)}<a class="site act" href={stripHref(link)}>Open as a flow →</a>{/if}
  652. </div>
  653. {/each}
  654. <h4>Leads to <span class="dim">{lists.leadsTo.length}</span></h4>
  655. {#if lists.leadsTo.length === 0}
  656. <p class="dim">
  657. {selectedInfo.step.kind === 'effect'
  658. ? 'Outside the index: the graph cannot follow it further.'
  659. : selectedInfo.step.cut === 'screen' || selectedInfo.step.cut === 'component'
  660. ? 'Not entered — a boundary. Start here for its own picture, or continue through from the summary.'
  661. : 'Nothing the walk follows leaves this step.'}
  662. </p>
  663. {/if}
  664. {#each lists.leadsTo as link (link.id)}
  665. {@const sc = scenarios(link.sites)}
  666. {@const fallback = selectedInfo.step.screen?.component?.id ?? selectedInfo.step.node?.id ?? null}
  667. <div
  668. class="row"
  669. class:hot={rowHot(link)}
  670. role="presentation"
  671. onmouseenter={() => onRowHover(link)}
  672. onmouseleave={() => onRowHover(null)}
  673. onfocusin={() => onRowHover(link)}
  674. onfocusout={() => onRowHover(null)}
  675. >
  676. <button class="peer mono" onclick={() => (selected = link.to)}>{nameOf(link.to)}</button>
  677. {#if link.trigger}<div class="fires"><b class="kw">FIRES FROM</b> {triggerWords(link.trigger)} <span class="dim">in {link.trigger.in}</span></div>{/if}
  678. {#if link.via.length > 0}<div class="via">via {stepViaText(link)}</div>{/if}
  679. {#if sc.common.length > 0}<div class="when">{@render words(commonTokens(sc.common))}</div>{/if}
  680. {#if link.label}<div class="via dim">{link.label}</div>{/if}
  681. {#if sc.rows.length > 1}<div class="ways dim">{sc.rows.length} ways</div>{/if}
  682. {#each sc.rows as row (row.site.file + row.site.line)}
  683. {@const href = siteHref(link, row.site, fallback)}
  684. <div class="scenario" class:many={sc.rows.length > 1}>
  685. {#if row.site.trigger && triggerWords(row.site.trigger) !== (link.trigger ? triggerWords(link.trigger) : '')}
  686. <div class="fires"><b class="kw">FIRES FROM</b> {triggerWords(row.site.trigger)}</div>
  687. {/if}
  688. {#if sc.rows.length > 1}<div class="when">{@render words(restTokens(row.rest, sc.common.length > 0))}</div>{/if}
  689. {#if href}
  690. <a class="site" {href}>{#if row.site.status}<b class="status">{row.site.status}</b> · {/if}{siteWords(row.site)} <span class="dim">· {basename(row.site.file)}:{row.site.line}</span></a>
  691. {:else}
  692. <span class="site">{#if row.site.status}<b class="status">{row.site.status}</b> · {/if}{siteWords(row.site)} <span class="dim">· {basename(row.site.file)}:{row.site.line}</span></span>
  693. {/if}
  694. </div>
  695. {/each}
  696. {#if stripHref(link)}<a class="site act" href={stripHref(link)}>Open as a flow →</a>{/if}
  697. </div>
  698. {/each}
  699. {:else}
  700. <div class="head">
  701. <div>
  702. <div class="big">What happens from <span class="mono">{payload.anchor.name}</span></div>
  703. <a class="sub" href={symbolHref(payload.anchor.id)}>
  704. <KindGlyph kind={payload.anchor.kind} />
  705. {payload.anchor.qualifiedName}
  706. </a>
  707. <a class="sub dim" href={fileHref(payload.anchor.file)}>{payload.anchor.file}</a>
  708. </div>
  709. </div>
  710. {#if payload.ambiguous.length > 0}
  711. <p class="dim note">
  712. {payload.ambiguous.length} other symbol{payload.ambiguous.length === 1 ? '' : 's'} share this name:
  713. {#each payload.ambiguous as other, i (other.id)}
  714. {#if i > 0},{/if}
  715. <a href={stepsHref({ anchor: other.id })}>{other.kind} in {basename(other.file)}</a>
  716. {/each}
  717. </p>
  718. {/if}
  719. <p>
  720. <b>{payload.steps.length}</b> steps · <b>{payload.links.length}</b> links · depth
  721. <select
  722. class="depth"
  723. value={String(payload.depth)}
  724. onchange={(e) => navigate(rewrite({ depth: Number((e.currentTarget as HTMLSelectElement).value) }))}
  725. >
  726. {#each DEPTHS as d (d)}
  727. <option value={String(d)}>{d}</option>
  728. {/each}
  729. {#if !DEPTHS.includes(payload.depth)}<option value={String(payload.depth)}>{payload.depth}</option>{/if}
  730. </select>
  731. </p>
  732. <p>
  733. <label class="opt">
  734. <input type="checkbox" checked={payload.through} onchange={(e) => navigate(rewrite({ through: (e.currentTarget as HTMLInputElement).checked }))} />
  735. Continue through {kindWords('screen', payload.project)[1]}
  736. </label>
  737. <span class="dim">— otherwise another {kindWord('screen', payload.project)} is drawn as a boundary, and is a click from being the next anchor.</span>
  738. </p>
  739. <p class="counts">
  740. {#each ['screen', 'trigger', 'bridge', 'event', 'store', 'effect'] as const as kind (kind)}
  741. {#if model.counts[kind] > 0}
  742. {@const words = kindWords(kind, payload.project)}
  743. <span><b>{model.counts[kind]}</b> {model.counts[kind] === 1 ? words[0] : words[1]}</span>
  744. {/if}
  745. {/each}
  746. </p>
  747. <p class="dim">
  748. <span class="mark">●</span> The anchor is at the top; each row down is one more step away from
  749. it. Click a step and each of its links is labelled at the far end of its line with the last
  750. condition checked before it happens; hover the line, or its row here, for the whole chain and the
  751. plumbing it travels through. A step is the next anchor, and any link opens as a Flow strip.
  752. </p>
  753. {#if payload.truncated.steps > 0 || payload.truncated.hubs > 0 || payload.truncated.chrome > 0}
  754. <p class="dim">
  755. Not drawn:
  756. {#if payload.truncated.steps > 0}<b>{payload.truncated.steps}</b> step{payload.truncated.steps === 1 ? '' : 's'} past the picture’s size limit;{/if}
  757. {#if payload.truncated.hubs > 0}<b>{payload.truncated.hubs}</b> walk{payload.truncated.hubs === 1 ? '' : 's'} that reached a hub;{/if}
  758. {#if payload.truncated.chrome > 0}<b>{payload.truncated.chrome}</b> into shared chrome.{/if}
  759. </p>
  760. {/if}
  761. <h4>Most connected</h4>
  762. {#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)}
  763. <button class="peer mono" onclick={() => (selected = step.id)}>{model.nodes.get(step.id)?.label ?? step.label} <span class="dim sans">{kindWord(step.kind, payload.project, step)}</span></button>
  764. {/each}
  765. {/if}
  766. </aside>
  767. {/if}
  768. </div>
  769. <style>
  770. .steps {
  771. display: grid;
  772. grid-template-columns: minmax(600px, 1fr) 340px;
  773. height: 100%;
  774. min-height: 0;
  775. }
  776. .stage {
  777. position: relative;
  778. overflow: hidden;
  779. background: var(--paper);
  780. }
  781. .stage :global(.svelte-flow) {
  782. background: var(--paper);
  783. }
  784. .stage :global(.svelte-flow__handle) {
  785. opacity: 0;
  786. width: 1px;
  787. height: 1px;
  788. min-width: 0;
  789. min-height: 0;
  790. border: 0;
  791. pointer-events: none;
  792. }
  793. .stage :global(.svelte-flow__edge-labels) {
  794. pointer-events: none;
  795. }
  796. .stage :global(.svelte-flow__controls-button) {
  797. background: var(--paper);
  798. border: 0;
  799. border-bottom: 1px solid var(--rule-soft);
  800. border-radius: 0;
  801. color: var(--ink-2);
  802. }
  803. .stage :global(.svelte-flow__controls-button svg) {
  804. fill: var(--ink-2);
  805. }
  806. .state {
  807. padding: 48px 40px;
  808. max-width: 560px;
  809. }
  810. .state h2 {
  811. font: 600 20px var(--sans);
  812. margin: 0 0 8px;
  813. }
  814. .chooser {
  815. max-width: 720px;
  816. overflow: auto;
  817. height: 100%;
  818. box-sizing: border-box;
  819. }
  820. .chooser-list {
  821. display: grid;
  822. grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
  823. gap: 0;
  824. margin-top: 12px;
  825. border-top: 1px solid var(--rule-soft);
  826. }
  827. /* A router file heading over its endpoints; the list under it keeps its own top rule. */
  828. .group-h {
  829. display: flex;
  830. justify-content: space-between;
  831. align-items: baseline;
  832. gap: 12px;
  833. margin-top: 18px;
  834. font-size: 11.5px;
  835. color: var(--ink-2);
  836. }
  837. .group-h + .chooser-list {
  838. margin-top: 6px;
  839. }
  840. .pick {
  841. display: block;
  842. padding: 7px 8px;
  843. border-bottom: 1px solid var(--rule-soft);
  844. color: var(--ink);
  845. text-decoration: none;
  846. font-size: 12.5px;
  847. }
  848. .pick:hover {
  849. background: var(--press);
  850. }
  851. .legend {
  852. position: absolute;
  853. left: 12px;
  854. bottom: 12px;
  855. z-index: 4;
  856. max-width: 400px;
  857. border: 1px solid var(--rule);
  858. background: var(--paper);
  859. font-size: 11.5px;
  860. color: var(--ink-2);
  861. }
  862. .legend-h {
  863. display: block;
  864. width: 100%;
  865. border: 0;
  866. background: transparent;
  867. padding: 5px 10px;
  868. text-align: left;
  869. color: var(--ink);
  870. font: 600 12px var(--sans);
  871. cursor: pointer;
  872. }
  873. .legend-body {
  874. padding: 2px 10px 8px;
  875. border-top: 1px solid var(--rule-soft);
  876. }
  877. .lrow {
  878. display: flex;
  879. align-items: center;
  880. gap: 10px;
  881. padding: 3px 0;
  882. }
  883. .lrow > :first-child {
  884. flex: 0 0 44px;
  885. display: inline-flex;
  886. justify-content: center;
  887. }
  888. .k-line {
  889. stroke: var(--ink);
  890. stroke-opacity: 0.6;
  891. stroke-width: 1.5;
  892. fill: none;
  893. }
  894. .k-line.k-synth {
  895. stroke-dasharray: 5 3;
  896. }
  897. .k-line.k-back {
  898. stroke: var(--accent);
  899. stroke-opacity: 0.8;
  900. stroke-dasharray: 4 3;
  901. }
  902. .k-label {
  903. font-size: 10.5px;
  904. color: var(--ink-3);
  905. }
  906. .k-box {
  907. box-sizing: border-box;
  908. padding: 1px 5px;
  909. border: 1px solid var(--ink);
  910. font-size: 10.5px;
  911. color: var(--ink);
  912. line-height: 14px;
  913. }
  914. .k-box.k-cross {
  915. border-left: 3px solid var(--accent);
  916. }
  917. .k-box.k-store {
  918. background: var(--paper-2);
  919. }
  920. .k-box.k-effect {
  921. border-style: dashed;
  922. border-color: var(--ink-3);
  923. }
  924. .k-anchor .mark {
  925. font-size: 8px;
  926. margin-right: 3px;
  927. vertical-align: 1px;
  928. }
  929. .tip {
  930. position: absolute;
  931. z-index: 5;
  932. width: 400px;
  933. padding: 8px 10px;
  934. border: 1px solid var(--ink);
  935. background: var(--paper);
  936. box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
  937. font-size: 12px;
  938. pointer-events: none;
  939. /* A call with its arguments is one long token: it wraps inside the box. */
  940. overflow-wrap: anywhere;
  941. }
  942. .tip .mono,
  943. .tip .when,
  944. .tip .via,
  945. .tip .fires {
  946. overflow-wrap: anywhere;
  947. }
  948. .tiprow {
  949. display: flex;
  950. flex-direction: column;
  951. gap: 1px;
  952. margin-top: 6px;
  953. padding-top: 6px;
  954. border-top: 1px solid var(--rule-soft);
  955. }
  956. .side {
  957. border-left: 1px solid var(--rule);
  958. padding: 14px 16px;
  959. overflow: auto;
  960. font-size: 12.5px;
  961. }
  962. .head {
  963. display: flex;
  964. justify-content: space-between;
  965. align-items: flex-start;
  966. gap: 8px;
  967. margin-bottom: 10px;
  968. }
  969. .big {
  970. font-size: 15px;
  971. font-weight: 600;
  972. /* An effect's label is a call with its arguments — one long token. */
  973. overflow-wrap: anywhere;
  974. }
  975. .sub {
  976. display: flex;
  977. align-items: center;
  978. gap: 5px;
  979. margin-top: 3px;
  980. color: var(--ink-2);
  981. text-decoration: none;
  982. }
  983. a.sub:hover {
  984. text-decoration: underline;
  985. }
  986. .act {
  987. color: var(--accent);
  988. }
  989. .clear {
  990. border: 1px solid var(--rule);
  991. background: transparent;
  992. color: var(--ink-2);
  993. font: inherit;
  994. font-size: 11.5px;
  995. padding: 1px 7px;
  996. cursor: pointer;
  997. }
  998. .note {
  999. margin: 0 0 6px;
  1000. }
  1001. .opt {
  1002. display: inline-flex;
  1003. align-items: center;
  1004. gap: 5px;
  1005. cursor: pointer;
  1006. }
  1007. .opt input {
  1008. margin: 0;
  1009. accent-color: var(--accent);
  1010. }
  1011. .depth {
  1012. font: inherit;
  1013. font-size: 12px;
  1014. border: 1px solid var(--rule-soft);
  1015. background: var(--paper-2);
  1016. color: var(--ink);
  1017. padding: 0 4px;
  1018. }
  1019. .counts {
  1020. display: flex;
  1021. flex-wrap: wrap;
  1022. gap: 4px 12px;
  1023. color: var(--ink-2);
  1024. }
  1025. h4 {
  1026. margin: 16px 0 6px;
  1027. font: 600 12.5px var(--sans);
  1028. }
  1029. .row {
  1030. padding: 7px 8px;
  1031. margin: 0 -8px;
  1032. border-top: 1px solid var(--rule-soft);
  1033. transition: background 90ms linear;
  1034. }
  1035. .row.hot {
  1036. background: var(--press);
  1037. }
  1038. .peer {
  1039. display: block;
  1040. width: 100%;
  1041. border: 0;
  1042. background: transparent;
  1043. padding: 2px 0;
  1044. text-align: left;
  1045. color: var(--ink);
  1046. font: 500 12.5px var(--mono);
  1047. cursor: pointer;
  1048. }
  1049. .peer:hover {
  1050. text-decoration: underline;
  1051. }
  1052. .when {
  1053. color: var(--ink);
  1054. font: 400 11.5px var(--mono);
  1055. margin-top: 2px;
  1056. }
  1057. /* The joins we add — WHEN, AND, OR, NOT — a little bolder than the code between them. */
  1058. .kw {
  1059. font-weight: 600;
  1060. }
  1061. /* The chain a hop travels through — the answer to "where on the screen": read, not dim. */
  1062. .via {
  1063. color: var(--ink-2);
  1064. font: 400 11.5px var(--mono);
  1065. margin-top: 2px;
  1066. }
  1067. .via.dim {
  1068. color: var(--ink-3);
  1069. font-size: 11px;
  1070. }
  1071. .fires {
  1072. color: var(--ink);
  1073. font: 400 11.5px var(--mono);
  1074. margin-top: 2px;
  1075. }
  1076. .ways {
  1077. font: 500 11px var(--sans);
  1078. margin-top: 6px;
  1079. }
  1080. /* One scenario per row under a link: its own tail of conditions, then its site. */
  1081. .scenario.many {
  1082. margin: 4px 0 0 8px;
  1083. padding-left: 8px;
  1084. border-left: 1px solid var(--rule-soft);
  1085. }
  1086. .site {
  1087. display: block;
  1088. font: 400 11px var(--mono);
  1089. margin-top: 2px;
  1090. color: var(--ink-2);
  1091. text-decoration: none;
  1092. overflow-wrap: anywhere;
  1093. }
  1094. /* A response's status code leads its row: the number is the fact. */
  1095. .status {
  1096. color: var(--ink);
  1097. font-weight: 600;
  1098. }
  1099. a.site:hover {
  1100. text-decoration: underline;
  1101. }
  1102. .mono {
  1103. font-family: var(--mono);
  1104. }
  1105. .sans {
  1106. font-family: var(--sans);
  1107. }
  1108. .dim {
  1109. color: var(--ink-3);
  1110. }
  1111. .mark {
  1112. color: var(--accent);
  1113. }
  1114. </style>