feat(expo-router): add Expo Router support for Screens and navigations and introduce Steps API

- Adds Expo Router integration with a new Screens view and a Steps API to surface screens and their transitions.
- Extends codegraph extraction/resolution to handle namespace objects, React hook bindings for handlers, and Swift RN bridge evidence; introduces per-site guard arguments and trigger metadata, enabling richer flow analysis across JS ↔ native boundaries.
- Introduces UI and data-model changes to represent conditions as words (WHEN/AND/OR/NOT), display per-site call arguments, and show what fires a site (triggers). Adds new utilities (ui/conditions.ts) and updates ScreensView and StepsView to render scenarios with multiple sites and “ways” counts.
- Implements site readers for WHEN/ARGS/TRIGGER, and wiring to expose steps via API endpoints (including /api/steps); enhances tests to cover namespace resolution, useCallback-driven handlers, and inline RN event listeners.
- Updates styling and templates to reflect the new wording, scenario rows, and per-site details, including NOT instead of leading negation strings and multi-way links.
- Documents and reflects changes in changelog and design docs to describe Expo Router integration and the Steps surface.
This commit is contained in:
Colby McHenry
2026-08-28 10:21:46 -05:00
parent 873f133c96
commit e288d7645b
16 changed files with 1028 additions and 127 deletions
+188
View File
@@ -0,0 +1,188 @@
/**
* Conditions, as a reader says them.
*
* A `when` arrives from the graph as code joined by our own operators: the
* guards along a chain joined with ` && `, a negated one wrapped as `!(…)`,
* and — on a link with several call sites — the sites' conditions joined
* with ` || `. The code inside one guard stays code (`isUploadInProgress ||
* elapsed < 5000` is what the source says); the joins are ours, and ours read
* as words — WHEN, AND, OR, NOT — set in capitals and a little bolder.
*
* A link with several sites is several scenarios, not one long condition:
* four early returns that each go home are four rows, and the clauses every
* row shares — the same first guard on all four — are said once above them.
*/
/**
* The top-level terms of `text` around `sep`, respecting brackets and
* strings. `splitTop('a && (b || c)', ' && ')` → `['a', '(b || c)']`.
*/
export function splitTop(text: string, sep: ' && ' | ' || '): string[] {
const out: string[] = [];
let depth = 0;
let quote: string | null = null;
let start = 0;
for (let i = 0; i < text.length; i++) {
const ch = text[i]!;
if (quote !== null) {
if (ch === '\\') i++;
else if (ch === quote) quote = null;
continue;
}
if (ch === "'" || ch === '"' || ch === '`') {
quote = ch;
continue;
}
if (ch === '(' || ch === '[' || ch === '{') {
depth++;
continue;
}
if (ch === ')' || ch === ']' || ch === '}') {
depth = Math.max(0, depth - 1);
continue;
}
if (depth === 0 && text.startsWith(sep, i)) {
out.push(text.slice(start, i).trim());
start = i + sep.length;
i += sep.length - 1;
}
}
out.push(text.slice(start).trim());
return out.filter((c) => c.length > 0);
}
/**
* The top-level `&&` terms of a condition, in the order they were tested —
* the outermost guard first, the one decided at the call last. A condition
* joined by a top-level `||` (several scenarios merged) has no single
* innermost term and comes back whole.
*/
export function clauses(when: string): string[] {
if (splitTop(when, ' || ').length > 1) return [when.trim()];
return splitTop(when, ' && ');
}
/**
* One word of a condition: a keyword we add (WHEN, AND, OR, NOT — set in
* capitals and a little bolder, so the joins read at a glance and the code
* between them reads as code), or a run of the code itself.
*/
export type WordToken = { kw: true; text: 'WHEN' | 'AND' | 'OR' | 'NOT' } | { kw: false; text: string };
const KW = (text: 'WHEN' | 'AND' | 'OR' | 'NOT'): WordToken => ({ kw: true, text });
const CODE = (text: string): WordToken => ({ kw: false, text });
/** `!(a || b)` → NOT `(a || b)`; `!busy` → NOT `busy`; code otherwise untouched. */
export function clauseTokens(clause: string): WordToken[] {
const text = clause.trim();
if (text.startsWith('!(') && closesAtEnd(text, 1)) return [KW('NOT'), CODE(text.slice(1))];
if (/^![A-Za-z_$][\w$.?]*$/.test(text)) return [KW('NOT'), CODE(text.slice(1))];
return [CODE(text)];
}
/** {@link clauseTokens} as one string — for a pill, which has no markup. */
export function clauseWords(clause: string): string {
return joinTokens(clauseTokens(clause));
}
/** Tokens joined by a keyword: `a AND b AND c`. */
function joinWith(groups: readonly WordToken[][], kw: 'AND' | 'OR'): WordToken[] {
const out: WordToken[] = [];
groups.forEach((g, i) => {
if (i > 0) out.push(KW(kw));
out.push(...g);
});
return out;
}
export function joinTokens(tokens: readonly WordToken[]): string {
return tokens.map((t) => t.text).join(' ');
}
/** Whether the bracket opened at `open` closes on the last character. */
function closesAtEnd(text: string, open: number): boolean {
let depth = 0;
let quote: string | null = null;
for (let i = open; i < text.length; i++) {
const ch = text[i]!;
if (quote !== null) {
if (ch === '\\') i++;
else if (ch === quote) quote = null;
continue;
}
if (ch === "'" || ch === '"' || ch === '`') quote = ch;
else if (ch === '(') depth++;
else if (ch === ')') {
depth--;
if (depth === 0) return i === text.length - 1;
}
}
return false;
}
/** The same clause tested twice along a chain (two early returns with one condition) is said once. */
function distinct(list: readonly string[]): string[] {
return list.filter((c, i) => list.indexOf(c) === i);
}
/** A whole `when` as tokens: scenarios joined by OR, each its guards joined by AND. Empty when unconditional. */
export function whenTokens(when: string): WordToken[] {
if (!when) return [];
return joinWith(
splitTop(when, ' || ').map((scenario) => joinWith(distinct(splitTop(scenario, ' && ')).map(clauseTokens), 'AND')),
'OR'
);
}
/** {@link whenTokens} led by WHEN, or `always` when there is nothing to say. */
export function conditionTokens(when: string): WordToken[] {
const tokens = whenTokens(when);
return tokens.length === 0 ? [CODE('always')] : [KW('WHEN'), ...tokens];
}
/** A whole `when` as one string — for a pill, a tooltip title, a test. */
export function whenWords(when: string): string {
return joinTokens(whenTokens(when));
}
/** The clauses every scenario shares, led by WHEN. */
export function commonTokens(common: readonly string[]): WordToken[] {
return common.length === 0 ? [] : [KW('WHEN'), ...joinWith(common.map(clauseTokens), 'AND')];
}
export interface ScenarioRows<T> {
/** The clauses every site shares, in chain order — said once. */
common: string[];
/** One row per site with what remains after the shared clauses; `rest` empty = always, given the shared ones. */
rows: Array<{ site: T; rest: string[] }>;
}
/**
* A link's sites as scenarios. One site: its whole condition is `common` and
* the one row has nothing left to say. Several: the longest common prefix of
* their clause lists is `common`, and each row keeps its own tail.
*/
export function scenarios<T extends { when: string }>(sites: readonly T[]): ScenarioRows<T> {
const lists = sites.map((site) => ({ site, all: site.when ? distinct(clauses(site.when)) : [] }));
if (lists.length === 0) return { common: [], rows: [] };
let common = lists[0]!.all.slice();
for (const { all } of lists.slice(1)) {
let i = 0;
while (i < common.length && i < all.length && common[i] === all[i]) i++;
common = common.slice(0, i);
}
return {
common,
rows: lists.map(({ site, all }) => ({ site, rest: all.slice(common.length) })),
};
}
/** The words a scenario row prints under a shared prefix: `AND x AND y` (`WHEN x` with no prefix), or `always`. */
export function restTokens(rest: readonly string[], hasCommon: boolean): WordToken[] {
if (rest.length === 0) return [CODE('always')];
return [KW(hasCommon ? 'AND' : 'WHEN'), ...joinWith(rest.map(clauseTokens), 'AND')];
}
export function restWords(rest: readonly string[], hasCommon: boolean): string {
return joinTokens(restTokens(rest, hasCommon));
}
+13 -50
View File
@@ -29,6 +29,7 @@
*/
import type { WireMapLink, WireMapModule, WireScreen, WireScreenLink, WireScreensPayload } from './wire';
import { clauseWords, clauses } from './conditions';
import {
buildMapLayout,
linkId,
@@ -72,9 +73,10 @@ const BAND_MARGIN = 2;
/**
* The longest label a pill prints before an ellipsis; the tooltip and the
* panel have the rest. Sized so the innermost clause of a typical guard
* (`guide.dontShowAgain.captureGuide`, 32 characters) fits whole.
* (`guide.dontShowAgain.captureGuide`, 32 characters) fits whole even as
* `…not guide.dontShowAgain.captureGuide` — the negation is a word now.
*/
export const EDGE_LABEL_MAX = 36;
export const EDGE_LABEL_MAX = 40;
/* ---------------------------------------------------------------- model -- */
@@ -246,48 +248,7 @@ export function entryLayering(
/* --------------------------------------------------------------- labels -- */
/**
* The top-level `&&` terms of a condition, in the order they were tested —
* the outermost guard first, the one decided at the navigation call last.
* Brackets and strings are respected; a condition joined by a top-level `||`
* (two transitions between one pair that merged) has no innermost term and
* comes back whole.
*/
export function clauses(when: string): string[] {
const out: string[] = [];
let depth = 0;
let quote: string | null = null;
let start = 0;
for (let i = 0; i < when.length; i++) {
const ch = when[i]!;
if (quote !== null) {
if (ch === '\\') i++;
else if (ch === quote) quote = null;
continue;
}
if (ch === "'" || ch === '"' || ch === '`') {
quote = ch;
continue;
}
if (ch === '(' || ch === '[' || ch === '{') {
depth++;
continue;
}
if (ch === ')' || ch === ']' || ch === '}') {
depth = Math.max(0, depth - 1);
continue;
}
if (depth !== 0) continue;
if (when.startsWith(' || ', i)) return [when.trim()];
if (when.startsWith(' && ', i)) {
out.push(when.slice(start, i).trim());
start = i + 4;
i += 3;
}
}
out.push(when.slice(start).trim());
return out.filter((c) => c.length > 0);
}
export { clauses } from './conditions';
/**
* What the connector says. Empty when unconditional and single.
@@ -299,17 +260,19 @@ export function clauses(when: string): string[] {
* …` on both arms of a fork); the last clause is the one that tells the two
* apart, and the full text is a hover away.
*/
export function edgeLabel(links: ReadonlyArray<{ when: string }>): string {
if (links.length === 1) {
const when = links[0]!.when;
export function edgeLabel(links: ReadonlyArray<{ when: string; sites?: ReadonlyArray<{ when: string }> }>): string {
// A link with several call sites is several scenarios: count them as ways.
const ways = links.flatMap((l) => (l.sites && l.sites.length > 1 ? l.sites.map((s) => s.when) : [l.when]));
if (ways.length === 1) {
const when = ways[0]!;
if (!when) return '';
const parts = clauses(when);
const last = parts[parts.length - 1] ?? when;
const last = clauseWords(parts[parts.length - 1] ?? when);
const text = parts.length > 1 ? `${last}` : last;
return text.length > EDGE_LABEL_MAX ? `${text.slice(0, EDGE_LABEL_MAX - 1)}` : text;
}
const conditional = links.filter((l) => l.when).length;
return conditional > 0 ? `${links.length} ways · ${conditional} conditional` : `${links.length} ways`;
const conditional = ways.filter((w) => w).length;
return conditional > 0 ? `${ways.length} ways · ${conditional} conditional` : `${ways.length} ways`;
}
/* ---------------------------------------------------------------- build -- */
+5
View File
@@ -669,6 +669,7 @@ export interface WireScreenSite {
line: number;
href: string;
method: string;
/** The conditions THIS site runs under (the whole chain's plus its own); '' when unconditional. */
when: string;
}
@@ -705,6 +706,10 @@ export interface WireStepSite {
line: number;
/** `push /capture`, `calls`, `client.post` — what the site does, in a word or two. */
text: string;
/** What the site passes, abbreviated (`'userEmail', values.email`); '' for none; absent when unreadable. */
args?: string;
/** The conditions THIS site runs under (the whole chain's); '' when unconditional. */
when: string;
}
export interface WireStep {
+44 -14
View File
@@ -26,6 +26,7 @@
import { live } from '../lib/live.svelte';
import { symbolHref, fileHref, stepsHref } from '../lib/navigation';
import { isEdgeVisible, type MapEdgeLayout } from '../lib/map-model';
import { commonTokens, conditionTokens, restTokens, scenarios, whenWords, type WordToken } from '../lib/conditions';
import {
buildScreensModel,
hoverPill,
@@ -249,7 +250,7 @@
/** The words a panel row puts on its line: the arrow, and the whole condition. */
function fullText(link: WireScreenLink): string {
const arriving = selected !== null && link.to === selected && link.from !== selected;
return `${arriving ? '←' : '→'} ${link.when || 'always'}`;
return `${arriving ? '←' : '→'} ${whenWords(link.when) || 'always'}`;
}
function rowHot(link: WireScreenLink): boolean {
@@ -268,6 +269,10 @@
}
</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="screens">
<div class="stage" bind:this={stage} role="presentation" onmousemove={onStageMove} onmouseleave={() => (hovered = null)}>
{#if error !== null}
@@ -365,7 +370,8 @@
<div class="mono"><b>{nameOf(hoveredInfo.from)}</b>{nameOf(hoveredInfo.to)}</div>
{#each hoveredInfo.links.slice(0, 5) as link (link.id)}
<div class="tiprow">
{#if link.when}<span class="when">when {link.when}</span>{:else}<span class="dim">always</span>{/if}
{#if link.sites.length > 1}<span class="dim">{link.sites.length} ways</span>{/if}
<span class="when">{@render words(conditionTokens(link.when))}</span>
{#if link.via.length > 0}<span class="mono dim">via {viaText(link)}</span>{/if}
</div>
{/each}
@@ -410,6 +416,7 @@
</p>
{/if}
{#each lists.opensFrom as link (link.id)}
{@const sc = scenarios(link.sites)}
<div
class="row"
class:hot={rowHot(link)}
@@ -420,12 +427,16 @@
onfocusout={() => onRowHover(null)}
>
<button class="peer mono" onclick={() => (selected = link.from)}>{sentence(link, 'from')}</button>
{#if link.when}<div class="when">when {link.when}</div>{/if}
{#if sc.common.length > 0}<div class="when">{@render words(commonTokens(sc.common))}</div>{/if}
{#if link.via.length > 0}<div class="via dim">via {viaText(link)}</div>{/if}
{#each link.sites as site (site.file + site.line)}
<a class="site dim" href={symbolHref(link.via[link.via.length - 1]?.id ?? selectedInfo.id, { line: site.line })}
>{site.method} {site.href} · {site.file.slice(site.file.lastIndexOf('/') + 1)}:{site.line}</a
>
{#if sc.rows.length > 1}<div class="ways dim">{sc.rows.length} ways</div>{/if}
{#each sc.rows as row (row.site.file + row.site.line)}
<div class="scenario" class:many={sc.rows.length > 1}>
{#if sc.rows.length > 1}<div class="when">{@render words(restTokens(row.rest, sc.common.length > 0))}</div>{/if}
<a class="site dim" href={symbolHref(link.via[link.via.length - 1]?.id ?? selectedInfo.id, { line: row.site.line })}
>{row.site.method} {row.site.href} · {row.site.file.slice(row.site.file.lastIndexOf('/') + 1)}:{row.site.line}</a
>
</div>
{/each}
</div>
{/each}
@@ -433,6 +444,7 @@
<h4>Goes to <span class="dim">{lists.goesTo.length}</span></h4>
{#if lists.goesTo.length === 0}<p class="dim">No navigation leaves this screen.</p>{/if}
{#each lists.goesTo as link (link.id)}
{@const sc = scenarios(link.sites)}
<div
class="row"
class:hot={rowHot(link)}
@@ -443,14 +455,18 @@
onfocusout={() => onRowHover(null)}
>
<button class="peer mono" onclick={() => (selected = link.to)}>{sentence(link, 'to')}</button>
{#if link.when}<div class="when">when {link.when}</div>{/if}
{#if sc.common.length > 0}<div class="when">{@render words(commonTokens(sc.common))}</div>{/if}
{#if link.via.length > 0}<div class="via dim">via {viaText(link)}</div>{/if}
{#each link.sites as site (site.file + site.line)}
<a
class="site dim"
href={symbolHref(link.via[link.via.length - 1]?.id ?? selectedInfo.screen?.component?.id ?? selectedInfo.id, { line: site.line })}
>{site.method} {site.href} · {site.file.slice(site.file.lastIndexOf('/') + 1)}:{site.line}</a
>
{#if sc.rows.length > 1}<div class="ways dim">{sc.rows.length} ways</div>{/if}
{#each sc.rows as row (row.site.file + row.site.line)}
<div class="scenario" class:many={sc.rows.length > 1}>
{#if sc.rows.length > 1}<div class="when">{@render words(restTokens(row.rest, sc.common.length > 0))}</div>{/if}
<a
class="site dim"
href={symbolHref(link.via[link.via.length - 1]?.id ?? selectedInfo.screen?.component?.id ?? selectedInfo.id, { line: row.site.line })}
>{row.site.method} {row.site.href} · {row.site.file.slice(row.site.file.lastIndexOf('/') + 1)}:{row.site.line}</a
>
</div>
{/each}
</div>
{/each}
@@ -716,10 +732,24 @@
font: 400 11.5px var(--mono);
margin-top: 2px;
}
/* The joins we add — WHEN, AND, OR, NOT — a little bolder than the code between them. */
.kw {
font-weight: 600;
}
.via {
font: 400 11px var(--mono);
margin-top: 2px;
}
.ways {
font: 500 11px var(--sans);
margin-top: 6px;
}
/* One scenario per row under a transition: its own tail of conditions, then its site. */
.scenario.many {
margin: 4px 0 0 8px;
padding-left: 8px;
border-left: 1px solid var(--rule-soft);
}
.site {
display: block;
font: 400 11px var(--mono);
+60 -18
View File
@@ -31,6 +31,7 @@
import { fileHref, flowHref, navigate, stepsHref, symbolHref } from '../lib/navigation';
import { isEdgeVisible, type MapEdgeLayout } from '../lib/map-model';
import { hoverPill, nearestEdge, placeLabels } from '../lib/screens-model';
import { commonTokens, conditionTokens, restTokens, scenarios, whenWords, type WordToken } from '../lib/conditions';
import {
buildStepsModel,
kindWord,
@@ -280,7 +281,7 @@
/** The words a panel row puts on its line: the arrow, and the whole condition. */
function fullText(link: WireStepLink): string {
const arriving = selected !== null && link.to === selected && link.from !== selected;
return `${arriving ? '←' : '→'} ${link.when || 'always'}`;
return `${arriving ? '←' : '→'} ${whenWords(link.when) || 'always'}`;
}
function rowHot(link: WireStepLink): boolean {
@@ -310,8 +311,17 @@
function basename(file: string): string {
return file.slice(file.lastIndexOf('/') + 1);
}
/** `SecureStore.setItemAsync('userEmail', values.email)` — the site, with what it passes when that could be read. */
function siteWords(site: { text: string; args?: string }): string {
return site.args === undefined ? site.text : `${site.text}(${site.args})`;
}
</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="steps">
<div class="stage" bind:this={stage} role="presentation" onmousemove={onStageMove} onmouseleave={() => (hovered = null)}>
{#if !supported}
@@ -432,9 +442,11 @@
<div class="mono"><b>{nameOf(hoveredInfo.from)}</b>{nameOf(hoveredInfo.to)}</div>
{#each hoveredInfo.links.slice(0, 5) as link (link.id)}
<div class="tiprow">
{#if link.when}<span class="when">when {link.when}</span>{:else}<span class="dim">always</span>{/if}
{#if link.sites.length > 1}<span class="dim">{link.sites.length} ways</span>{/if}
<span class="when">{@render words(conditionTokens(link.when))}</span>
{#if link.via.length > 0}<span class="mono dim">via {stepViaText(link)}</span>{/if}
{#if link.label}<span class="dim">{link.label}</span>{/if}
{#if link.sites[0]}<span class="mono">{siteWords(link.sites[0])}</span>{/if}
</div>
{/each}
{#if hoveredInfo.links.length > 5}<div class="dim">+{hoveredInfo.links.length - 5} more</div>{/if}
@@ -509,6 +521,8 @@
<p class="dim">{selectedInfo.step.anchor ? 'The anchor — the picture starts here.' : 'Nothing in the picture leads here.'}</p>
{/if}
{#each lists.arrivesFrom as link (link.id)}
{@const sc = scenarios(link.sites)}
{@const fallback = payload.steps.find((s) => s.id === link.from)?.node?.id ?? null}
<div
class="row"
class:hot={rowHot(link)}
@@ -519,16 +533,20 @@
onfocusout={() => onRowHover(null)}
>
<button class="peer mono" onclick={() => (selected = link.from)}>{nameOf(link.from)}</button>
{#if link.when}<div class="when">when {link.when}</div>{/if}
{#if sc.common.length > 0}<div class="when">{@render words(commonTokens(sc.common))}</div>{/if}
{#if link.via.length > 0}<div class="via dim">via {stepViaText(link)}</div>{/if}
{#if link.label}<div class="via dim">{link.label}</div>{/if}
{#each link.sites as site (site.file + site.line)}
{@const href = siteHref(link, site, payload.steps.find((s) => s.id === link.from)?.node?.id ?? null)}
{#if href}
<a class="site dim" {href}>{site.text} · {basename(site.file)}:{site.line}</a>
{:else}
<span class="site dim">{site.text} · {basename(site.file)}:{site.line}</span>
{/if}
{#if sc.rows.length > 1}<div class="ways dim">{sc.rows.length} ways</div>{/if}
{#each sc.rows as row (row.site.file + row.site.line)}
{@const href = siteHref(link, row.site, fallback)}
<div class="scenario" class:many={sc.rows.length > 1}>
{#if sc.rows.length > 1}<div class="when">{@render words(restTokens(row.rest, sc.common.length > 0))}</div>{/if}
{#if href}
<a class="site" {href}>{siteWords(row.site)} <span class="dim">· {basename(row.site.file)}:{row.site.line}</span></a>
{:else}
<span class="site">{siteWords(row.site)} <span class="dim">· {basename(row.site.file)}:{row.site.line}</span></span>
{/if}
</div>
{/each}
{#if stripHref(link)}<a class="site act" href={stripHref(link)}>Open as a flow →</a>{/if}
</div>
@@ -541,6 +559,8 @@
</p>
{/if}
{#each lists.leadsTo as link (link.id)}
{@const sc = scenarios(link.sites)}
{@const fallback = selectedInfo.step.screen?.component?.id ?? selectedInfo.step.node?.id ?? null}
<div
class="row"
class:hot={rowHot(link)}
@@ -551,16 +571,20 @@
onfocusout={() => onRowHover(null)}
>
<button class="peer mono" onclick={() => (selected = link.to)}>{nameOf(link.to)}</button>
{#if link.when}<div class="when">when {link.when}</div>{/if}
{#if sc.common.length > 0}<div class="when">{@render words(commonTokens(sc.common))}</div>{/if}
{#if link.via.length > 0}<div class="via dim">via {stepViaText(link)}</div>{/if}
{#if link.label}<div class="via dim">{link.label}</div>{/if}
{#each link.sites as site (site.file + site.line)}
{@const href = siteHref(link, site, selectedInfo.step.screen?.component?.id ?? selectedInfo.step.node?.id ?? null)}
{#if href}
<a class="site dim" {href}>{site.text} · {basename(site.file)}:{site.line}</a>
{:else}
<span class="site dim">{site.text} · {basename(site.file)}:{site.line}</span>
{/if}
{#if sc.rows.length > 1}<div class="ways dim">{sc.rows.length} ways</div>{/if}
{#each sc.rows as row (row.site.file + row.site.line)}
{@const href = siteHref(link, row.site, fallback)}
<div class="scenario" class:many={sc.rows.length > 1}>
{#if sc.rows.length > 1}<div class="when">{@render words(restTokens(row.rest, sc.common.length > 0))}</div>{/if}
{#if href}
<a class="site" {href}>{siteWords(row.site)} <span class="dim">· {basename(row.site.file)}:{row.site.line}</span></a>
{:else}
<span class="site">{siteWords(row.site)} <span class="dim">· {basename(row.site.file)}:{row.site.line}</span></span>
{/if}
</div>
{/each}
{#if stripHref(link)}<a class="site act" href={stripHref(link)}>Open as a flow →</a>{/if}
</div>
@@ -817,6 +841,8 @@
.big {
font-size: 15px;
font-weight: 600;
/* An effect's label is a call with its arguments — one long token. */
overflow-wrap: anywhere;
}
.sub {
display: flex;
@@ -900,15 +926,31 @@
font: 400 11.5px var(--mono);
margin-top: 2px;
}
/* The joins we add — WHEN, AND, OR, NOT — a little bolder than the code between them. */
.kw {
font-weight: 600;
}
.via {
font: 400 11px var(--mono);
margin-top: 2px;
}
.ways {
font: 500 11px var(--sans);
margin-top: 6px;
}
/* One scenario per row under a link: its own tail of conditions, then its site. */
.scenario.many {
margin: 4px 0 0 8px;
padding-left: 8px;
border-left: 1px solid var(--rule-soft);
}
.site {
display: block;
font: 400 11px var(--mono);
margin-top: 2px;
color: var(--ink-2);
text-decoration: none;
overflow-wrap: anywhere;
}
a.site:hover {
text-decoration: underline;