# codegraph ui — design specification Authoritative visual + interaction spec for the `codegraph ui` viewer (Kommandr epics CG-39 → CG-48 → CG-56; Pro layers in docker-app DOCKERAPP-10). Companion to the design proposal ("Reading the graph") and the interactive prototype; the prototype's stylesheet is appended verbatim at the end and is the source of truth for every measurement below. Screenshots: `CodeGraph/codegraph-web-prototype/screenshots/` (also attached to the Kommandr epics). Design proposal: https://claude.ai/code/artifact/58336c87-9780-4018-8c04-37fe53236e96 Prototype: https://claude.ai/code/artifact/304bffb6-72d6-49c7-8f3a-9e4f244909f8 Prototype sources: `CodeGraph/codegraph-web-prototype/` (`proto.css`, `proto.js`, `extract.mjs`, `build.mjs`) ## 1. Principles (non-negotiable) 1. One symbol at a time — no whole-graph picture, no node-link neighborhood graph (decided). 2. Code order is the coordinate system — layouts by source line or dependency layer; deterministic; never force-directed. 3. Edges grow out of the code — every call edge is drawn from the line that makes the call (gutter port → callee row at that height). 4. Direction is spatial — callers left, callees right, flows read left→right, map dependencies point down. 5. Collapse the tails, show the counts — hubs badge (fan-in ≥ 40), tests fold, confidence < 0.6 folds ("uncertain"), outside-index counts; nothing silently dropped. 6. Honesty in the pixels — confidence = line style; heuristic (synthesized) edges dashed + wiring site; boundaries announced; drift banners; "no test within 3 hops" badge. ## 2. Visual language The engine's paper/ink editorial system (`site/src/styles/theme.css`): flat, hairline rules, **square corners everywhere** (`border-radius: 0 !important` globally), no shadows, no gradients, sentence case, **no tiny all-caps tracked labels**, one oxblood accent used only for focus/selection/edges, one amber used only for the "untested" warning. Syntax highlighting is deliberately near-monochrome so the graph's edges are the only colour in the code. ### 2.1 Color tokens | token | light | dark | used for | |---|---|---|---| | `--paper` | `#f7f6f2` | `#16150f` | page/body background (always set explicitly) | | `--paper-2` | `#f1efe8` | `#1c1a14` | trail bar, inputs, hovered code line, figure grounds | | `--press` | `#e8e6dd` | `#23211a` | hover fills, inline code background, bars | | `--press-2` | `#dedbd0` | `#2c2a22` | reserved (pressed state) | | `--ink` | `#16150f` | `#f3f1ea` | primary text, node borders, major rules | | `--ink-2` | `#56544a` | `#b8b5a8` | secondary text, strings, callers' names when uncertain | | `--ink-3` | `#87847a` | `#87847a` | tertiary text, comments, glyph borders, edge labels | | `--ink-4` | `#b4b1a5` | `#5d5b52` | line numbers, resting connectors, dimmed map nodes | | `--rule` | `#16150f` | `#f3f1ea` | top bar bottom rule, code/blast section rules | | `--rule-soft` | `#d6d3c8` | `#34322a` | rail dividers, chips, card borders | | `--rule-faint` | `#e6e3d9` | `#26241d` | row separators, map layer lines | | `--accent` | `#7a2230` | `#d48b96` | oxblood: call-site links, current trail hop, hot connectors, selected map edges | | `--accent-ink` | `#5e1a25` | `#e5a5ae` | accent text on accent-soft | | `--accent-soft` | `#f0e3e5` | `#33201f` | tinted rows ("you came from here"), hot code lines | | `--accent-line` | `#d9b3b9` | `#6b3a42` | accent borders/underlines at rest | | `--amber` | `#8a5a0b` | `#d9a94a` | "No test reaches this within 3 caller hops" badge only | | `--amber-soft` | `#f3e9d2` | `#2e2716` | that badge's fill | Theme selection: define the light set on bare `:root`; redefine under `@media (prefers-color-scheme: dark)` guarded as `:root:not([data-theme="light"])`; redefine again under `:root[data-theme="dark"]`. Never define a colour only inside a media/`[data-theme]` block. `body { background: var(--paper); color: var(--ink) }`. ### 2.2 Type - UI: **Archivo** 400/500/600/700 (fallback `-apple-system, BlinkMacSystemFont, 'Helvetica Neue', Arial, sans-serif`). - Code, symbol names, file paths, chips, trail, map labels: **IBM Plex Mono** 400/500/600 (+ italic 400) (fallback `ui-monospace, 'SF Mono', Menlo, Consolas, monospace`). - Scale: body UI `13px/1.45`; code `12.5px/20px`; symbol title `600 20px/1.2` mono, letter-spacing −0.01em; section labels (`Called by`, `Calls`, `Blast radius`) `600 13px` sans; rail rows `12.5px` mono name + `11px` sans meta; chips `11px` mono; line numbers `11px` mono in `--ink-4`; badges `11.5px`; map node label `13px` mono, count `11px`; flow card name `600 13px` mono, window `12px/19px` mono; trail `12px` mono. Headings sentence case, `text-wrap: balance`. - Code token classes: comment `--code-comment`; string `--ink-2`; keyword weight 500 (same ink); number `--ink-2`; definition name on its own line weight 600; **call-site link** = `--accent`, underline `--accent-line`, offset 3px, hover/hot fill `--accent-soft`; uncertain link = `--ink-2`, dotted underline `--ink-4`; link to a symbol outside the index = `--ink-2`, underline `--rule-soft`, not clickable. - *As built (CG-43) — comments are `--code-comment`, not `--ink-3`.* `--ink-3` measures 3.46:1 on `--paper` and 3.00:1 on the hot-line tint `--accent-soft`, both under the 4.5:1 that 12.5px body text needs. `--code-comment` is the smallest step along the same warm-grey ramp that clears 4.5:1 on every background a code line can have (`#6a675d` light — paper 5.23, paper-2 4.92, accent-soft 4.53; `#8e8b81` dark — 5.36 / 5.10 / 4.51) while staying quieter than the `--ink-2` strings and numbers use, so the recession order above is unchanged. Everything else in this list passes as specified: ink 16.9/16.2, ink-2 7.03/8.89, accent 9.25/6.91 (8.02/5.80 on `--accent-soft`). - *Line numbers remain `--ink-4` (1.99:1 light, 2.69:1 dark) — a known contrast gap, left as specified rather than changed inside a rendering task. Worth a design call before phase 2.* ### 2.3 Kind glyphs 16×16 hollow square, 1px `--ink-3` border, letter in `500 9.5px` mono: `ƒ` function · `m` method · `C` class · `I` interface · `S` struct · `T` type alias · `E` enum · `e` enum member · `k` constant · `v` variable · `p` property/field · `≡` file (dashed border) · `R` route · `⟨⟩` component · `N` namespace · `M` module · `Tr` trait · `U` union · `P` protocol. Container/type kinds get a `--press` fill. ## 3. Layout and components ### 3.1 App shell - Grid rows: **top bar 48px** / **trail bar 34px** / main. Top bar: brand (10px hollow square mark + "CodeGraph" 600 14px + "ui" in `--ink-3`), view tabs (`Map · Symbol · Flow`, 5px 10px padding, active = 2px `--ink` bottom border), search input (30px tall, `--paper-2` fill, `--rule-soft` border → `--ink` on focus, max-width 720px), project stats in `--ink-2` 12px. Bottom rule of the top bar is `--rule` (1px); the trail bar's is `--rule-soft`. - Focus ring everywhere: `outline: 2px solid var(--accent); outline-offset: 1px`. `prefers-reduced-motion` disables transitions. ### 3.2 Symbol view (`#/s/?t=&hl=`) - Grid: **left rail 300px** | stage `minmax(520px, 1fr)`; inside the stage: **center `minmax(480px, 1fr)`** | **right rail 320px**. Left rail has its own scroll; center + right rail scroll together in the stage (so callee rows stay aligned to lines). ≤ 1100px: 240px | `minmax(360px,1fr)` | 260px. - Rail headers sticky, `12px 14px 8px` padding, 600 13px, count in `--ink-3`, hint text right-aligned `11.5px` (`← step up`, `step down →`). - **Center**: padding `18px 22px 40px`. Header row: glyph, name (h1), kind word (`--ink-3` 12.5px, "· async · static · private"), location `file:start–end · N lines` (11.5px mono, file is a link). "in ClassName" breadcrumb 11.5px mono `--ink-3`. Badges row (gap 6px): `exported` · `hub · N callers` (border `--ink`) · tests badge (`Reached by tests · N files within 3 hops`, hollow 8px swatch) or amber warning (filled swatch). Signature 12px mono `--ink-2`, docstring 12.5px `--ink-2` max 70ch, relations row of chips (`extends X`, `implemented by …`, `uses types …` — chips 11.5px mono, `--rule-soft` border, 1px 6px). - **Code block**: 1px `--rule` top border + 6px; each line is a grid `44px | 1fr | 18px` (line number right-aligned, 12px right padding; text `white-space: pre`; port cell). Hover line → `--paper-2`; hot/highlighted line → `--accent-soft`. **Port**: 6×6 square, 1px `--ink-3` border, positioned right 4px / top 7px; filled `--ink-3` when the line has a resolved (≥ 0.6) edge, hollow when only uncertain; accent fill+border when hot. Gap rows ("⋯ N lines without calls"): 11px `--ink-4`, dashed `--rule-soft` top/bottom, 2px margin, indented 44px. Long bodies: head 80 lines + ±4-line windows around every call site; bodies ≤ 260 lines shown whole; containers show the outline instead of a body > 80 lines. - **Right rail rows** (`.rrow`): absolutely positioned, `left 14px right 12px`, **height 34px**, grid `16px | 1fr` gap 8px, padding `0 6px`, 1px transparent border (→ `--ink` when keyboard-selected; `--accent-line` + `--accent-soft` when hot/origin). Desired y = center of first call-site line − 17px; place in line order with `y = max(desired, prevY + 34 + 6)`; the stage's min-height grows to fit. Name 12.5px mono (`×N` in `--ink-3` when called from N lines); meta 11px `--ink-3`: file (or "same file"), edge word (`creates`, `passes as value`), tags (`hub · N`, `outside index`, `via `) as 10.5px bordered pills. Uncertain targets fold into a `
` ("+ Uncertain · N name-only matches, confidence < 0.6") placed 8px below the last row; "+N more calls into symbols outside the index" note 11.5px. - **Connectors** (SVG overlay covering the stage content): one cubic Bézier per call line → row: `M x0,ly C cx,ly cx,ry x1,ry` with `x0 = center right edge − 10`, `x1 = rail left + 14`, `cx = (x0+x1)/2`. Resting: `--ink-4` 1px; hot: `--accent` 1.5px; uncertain: dasharray `2 3`; heuristic: dasharray `6 3` in `--ink-3`; origin (the edge you arrived by): `--accent`. Left rail draws no connectors (separate scroll container); the origin caller row is tinted instead. (Real build: consider converging left connectors into the header — open question.) - **Left rail**: file groups (`.filegroup` padding `10px 14px 4px`; path 11px mono `--ink-3`, count bold `--ink-2`; the focus's own file first as "same file"); rows grid `16px | 1fr`, padding `5px 6px 5px 4px`, name 12.5px mono, meta row with edge-kind label + call-site chips (`:4657`, 11px mono, `--rule-soft` border, 0 4px; click = open caller at that line). Folds: `Tests · N calls from M files` (lists files), `Uncertain · N`. Origin row: `--accent-soft` fill + `--accent-line` border + "you came from here". Empty state note 11.5px `--ink-3`. - **Blast radius strip**: 22px above, 1px `--rule` top border, 10px padding-top; "Blast radius" 600 + stats (`N direct dependents · within 3 hops · files · test files · routes`, tabular-nums); bar 6px tall, max-width 420px, `--press` track, light fill `--ink-2` = within-3 share, dark fill `--ink` = direct share, both scaled to the widest radius in the index; legend 11.5px; `
` "What would need re-checking if this changed" listing dependents by file. - **Members outline** (classes, interfaces, structs, enums, files): rows grid `16px | minmax(160px,auto) | 1fr | auto`, padding `6px 4px`, `--rule-faint` separators, name 12.5px mono, signature 11.5px mono `--ink-3` ellipsised, counts `← in → out` 11px mono tabular; nested members indented 22px; properties/enum members dimmed. - **Keyboard**: `/` or ⌘K search · ↑/↓ (or j/k) move in the active rail · ←/→ switch rail · Enter follow · Backspace or `[` back · `m` map · `f` flow · Esc back to Symbol view. Selection = 1px `--ink` border on the row, scrolled into view. ### 3.3 Trail bar 34px, `--paper-2`, mono 12px. `Trail` label in `--ink-3` sans; hops as buttons (glyph + name, padding 4px 8px) separated by `→` (stepped into a call) or `←` (stepped up to a caller) in `--ink-3`; current hop: `--accent` text, `--accent-line` border, `--paper` fill; hover `--press`. Right side: `Read as flow`, `Clear` (sans 4px 8px, `--rule-soft` border). Empty hint in `--ink-3`. ### 3.4 File view (`#/file/`) Grid **300px | minmax(480px,1fr) | 300px**: Imported by · outline (source order, nested, counts, `line` number right) · Imports. File rows 12px mono, 5px 14px padding, `--rule-faint` separators; files outside the index in `--ink-3`, not clickable. Header: file glyph, basename as h1, `lang · KB · N symbols · generated`, full path. **As built (phase 1, CG-46).** The two rails count **dependencies**, not import statements — `getFileDependencies` / `getFileDependents`, every cross-file edge except `contains`. The prototype drew `imports` edges alone, and on this repo that understates the answer: `src/graph/traversal.ts` imports two files and depends on four (it reaches `src/resolution/lru-cache.ts` through a call no import names). The import rows are still merged in — they carry the symbol NAMES, shown as a count on the row and in full in its tooltip. Rows sort production-first then alphabetically, tests last. Imports that resolved to nothing indexed are listed under **Outside the index**, in `--ink-3` and not clickable, so a file importing `react` and `fs` does not read as having one dependency. The header's `N symbols` is the OUTLINE's total, not the file record's node count (which includes the file node and its import declarations). A file that runs code at its top level — an edge out of the file node — carries a badge ("Runs N calls at the top level — see what it calls") that focuses the file node, the only place that code can be read. Outline rows are a fixed 28px and the list is windowed above 250 rows (this repo's own fixtures hold a 1,681-symbol `.d.ts`); the two constants live together in `ui/src/lib/file-model.ts`. Keyboard: ↑/↓ within a pane, ←/→ across the three panes, Enter follows; `?hl=` selects the DEEPEST outline row whose range holds the line. **Whole-file source, as built (phase 2, CG-52).** `?src=1` on the same route. Four columns inside one scroller: sticky outline rail (240px, only at ≥ 1400px) | arcs 56px | source | callee rail 320px. The line grid, the 6x6 ports and the accent call-site links are the Symbol view's, unchanged — what differs is that **line positions are arithmetic, not measured**: every line is exactly 20px and sits at `10 + (n - 1) x 20`, so a 6 820-line file renders ~90 line elements and the arcs, ports, rail rows and connectors are all functions of a line number. `ui/src/lib/filecode-model.ts` holds the constant; `FileCodeBlock.svelte`'s CSS holds the other half of it, and they must move together. Source pages in 800 lines at a time from `/api/source`, each request reaching back 150 lines that are then discarded so a page starting inside a block comment does not render prose as code; a line whose page has not arrived still shows its number, its port and its place. Callee-rail rows are one per (CALLING symbol, called symbol) PAIR rather than one per callee — a row is anchored to a line and a helper called from two functions a thousand lines apart has no line that is both — and uncertain rows stay in place with their dotted underline rather than folding, because a fold has nowhere to sit on this screen. Arcs are half-ellipses bulging left, both ends on the arc column's right edge, depth a log function of the arc's own SPAN (so short arcs sit innermost and filtering never moves a survivor sideways); `--ink-4` 1px at rest, `--accent` 1.5px when the call line or the callee is under the pointer — never as a consequence of the crowding filter. Above 40 arcs only the focused symbol's are drawn (hovered symbol, else the symbol the scroll position is inside) and the header states the total. Clicking an arc scrolls to the callee's definition and marks it. Data: `GET /api/filecode/`. ### 3.5 Flow strip (`#/flow/`) Header: "Flow" + a `` (4–12) that rewrites the URL, per-kind counts, and the `truncated` notes. The bare tab (`#/steps`) is a chooser: the project's screens by connectivity, else its endpoints by router file, or a hint to search. A picture of at most 24 boxes is fitted to the right of the key (a per-side `fitView` padding) so its second row never sits under the legend; a larger one is fitted to the whole stage. `Picture` (`screens-model.ts`) is the structural interface the shared machinery works over; `steps-model.ts` builds one. Pure model tests: `ui-steps-model.test.ts`; the endpoint against a real RN + Expo fixture: `ui-steps-api.test.ts`. #### 3.13.1 In order — the same picture, laid out by when things happen (`&view=order`) Rows-by-distance is the right picture for a screen, where handlers fire on events and nothing orders them. It is the wrong one for a handler: on proshop's `POST /api/users/login` the tree puts `User.findOne`, `jwt.sign`, `200` and `401` side by side — each is one step from the anchor — when the code says *look the user up, then IF the password matches sign a token and answer 200, ELSE answer 401*, and the signing happens INSIDE the reply that carries it. So the same walk has a second reading, on **the same canvas, with the same boxes**: only the graph changes. ``` POST /api/users/login · authUser │ User.findOne({ email }) │ ┌ user AND (await user.matchPassword(…))? ┐ │ │ → yes → no │ │ jwt.sign({ id }, …) auth 401 response │ 200 response ``` **A line means "and then", not "leads to"** — that is the whole difference from the other reading, and the key says so. A row down is one more thing that has already happened, so the `200` sits below the `jwt.sign` it is built from and the `401` branches at the fork. The conditions are **drawn at rest** rather than only for a selected box (`placeLabels(model, selected, atRest)`) — on this picture the conditions ARE the content. An arm that answers, returns or throws simply has nothing leaving it. **A decision both of whose ways are drawn is a POINT, not two labelled lines.** Two edges that each carried the whole predicate — one of them negated, their chips truncated to near-identical strings — never said they were the same choice. (The tree answers the same problem differently, because there a fork is written INSIDE a box and its arms leave it: see **Decisions** in §3.13. Here the fork sits BETWEEN steps, so it gets a box of its own; either way `armWords` is the one place an arm is worded.) So a fork two or more of whose arms lead somewhere diverges from a small box of its own (`ForkPoint.svelte`, `fork:N` in the layout, quieter than a step: one centred line, border `--ink-2`): the box asks the condition once (`user AND (await …)?`; a switch asks its subject), and each line out answers with only the arm — `yes` / `no` for an `if` or a ternary, the case's own value with the subject stripped (`'expired'`), `else` for a default — as a pill at the arm's far end, the arm's full condition still riding the edge for the hover. The point is not a step: it takes no click, counts in no summary, and the panel lists nothing for it; selection instead reaches THROUGH it (`selectionReach`) — selecting the step before the fork lights both arms, selecting an arm lights its sibling — and the neighbour dimming follows the same closure. Client-side entirely: `orderGraph` mints the points from the fork items the wire already carries (`WireArm.not` marks the else side), so nothing changes on the server or in the tree reading. **A fork with ONE drawn arm keeps the plain line** — an early exit reads as a guard clause (`WHEN NOT product` on the line), never as a box with a single exit — and an arm reached from both sides of one decision drops the claim (`arm` is deleted on merge) rather than printing `yes` on a line that runs either way. **What it is made of.** Every hop the walk makes is recorded where the code writes it — the step it reached (or the helper it folded into), the call's position and span, the branch guards, the loops, what fires it — by the SAME pass that makes the links, so the two readings can never hold different steps (`WireStepsPayload.program`, built by `src/ui-server/api/program.ts` from the records `steps.ts` keeps; `ProgramSite` is one such record). `buildProgram` is pure over them: no graph, no source, no control-flow graph. `ui/src/lib/program-model.ts` then walks that block tree carrying a set of *tails* — the steps a next step would follow — and emits one edge per "and then"; the row of a step is the longest run of them from the anchor. A step reached twice (`session.add` before and after a check, a logout helper the code comes back to) makes the graph **cyclic**, and relaxing over a cycle never settles — it adds a row on every pass until the pass bound. So the lines that close a cycle are dropped before the rows are settled (`withoutBackEdges`, one walk from the anchor: a line back to something still open on the way here cannot be what decides its row); the cycle is still DRAWN, it just does not stretch the picture. Without this a real screen put sixteen boxes on sixty rows — a 9,400px ribbon of empty space that `fitView` opened on a gap, so the canvas came up blank. **What makes the fold possible** is that a guard names the DECISION it belongs to and not only its own words (`BranchGuard.branch` — where the branching construct starts): the `if` and the `else` of one statement carry the same branch with `negated` flipped, an early exit carries the branch of the `if` that returned, every case of a `switch` carries the branch of the switch, and two `try`/`catch` blocks in one function stay apart. Two sites are arms of ONE fork when they agree on the branch and disagree on the arm — which a joined condition string can never say. A guard also carries how the arm it is in leaves (`armExit`) and, for an early exit, how the arm that was not taken leaves (`exit`); that is `WireArm.ends`, and an arm that ends is an arm nothing leaves. **The block tree** (`WireItem`): a **step**, where the code writes it — with `inside res.json(…)` when it is written in another call's arguments, its own body under it when the walk entered it, and `again` when the same function has already been read (a function is read once per picture, however many times it is called); a **fork** — `if` / `switch` / `ternary` / `try` / an early exit — carrying its condition once, with an arm per side; a **run** that is not plain sequence, said on the line into it — a helper drawn in place (`via generateToken`), a body that repeats (`for each item of items`, read by `loopsForFile`; loops and forks nest by which construct BEGINS first), work registered to run later (`later · then`), calls started together (`together · Promise.all`); and a **cut** where the reading stopped. Source order is execution order for straight-line code and for arguments before their call; where it is not — a callback, concurrency — the line says so rather than pretending. A helper that answers on one path still returns on another, so the code after the call follows the call; what comes after is not inside it. **Honest by construction.** A fork exists only where a guard was READ: a language without rules, or a file that changed since the index sync, draws a plain sequence rather than an invented structure. Every cap is announced (`program.truncated`), and nothing floats — a step the fold could not place follows the anchor unconditionally. **Which reading opens** travels in the URL (`&view=order` / `&view=tree`) and the summary offers both; without one the answer's own `defaultView` decides — the code's order for a handler, an endpoint or any function, the tree for a screen. Tests: `ui-steps-program.test.ts` (the fold, over hand-made records), `ui-program-model.test.ts` (the graph and its rows), and one `in order` reading per framework in `ui-steps-api-servers.test.ts`. ## 4. Libraries and versions - Svelte 5 (≥ 5.25) + Vite (workspace `ui/`), Svelte Flow `@xyflow/svelte` ^1.6 for the Map and Flow canvases only (custom nodes/edges, hidden handles for port spreading, local selection state — the pattern in docker-app's `StackGraph.svelte`); `@dagrejs/dagre` only as a fallback if crossing quality demands it (never ELK). Symbol view = DOM + one SVG overlay (`ResizeObserver` re-layout). - Syntax classification comes off **the engine's own tree-sitter parse** — no highlighter dependency, no second grammar set. - *As built (CG-43, replaced in CG-57).* The first cut ran Shiki with 56 pruned TextMate grammars in `dist/textmate/`. That is gone: `@shikijs/*` is off the dependency list, `scripts/prune-grammars.mjs` and `npm run build:textmate` are deleted, and `scripts/check-ui-build.mjs` now asserts the tree-sitter grammars in `dist/extraction/wasm/` instead. A `.ts` file is read by exactly the grammar that decided what its symbols are, so the viewer and the graph can never disagree about it. - Eight token classes on the wire: `comment`, `string`, `number`, `keyword`, `type`, `def`, `ident`, `other`. Rules, not scope tables — a node whose type mentions `comment` is a comment; inside a string every leaf is string *except* below an interpolation, where code resumes (so `${user.name()}` still links); an **anonymous** leaf is a keyword when its text is a bare word and punctuation otherwise; a **named** leaf is an identifier, a type name, or — from the extractors' own definition tables — the name a definition declares. `punct` is folded into `other`: they paint identically and splitting them would roughly double the token count on a dense line. - The classification is a class NAME, never a colour, and the viewer paints it from the CSS custom properties above — so **one token stream serves light and dark** with no refetch when `prefers-color-scheme` flips, and the ramp lives only in `ui/src/lib/theme.css`. `type` is a distinct class painted at plain ink: the colouring is near-monochrome and a type name is not one of the four things it moves off plain ink. - Every code token is split into identifier runs before it goes on the wire, so the graph's call-site overlay claims a token the classifier produced rather than re-cutting a line — which is what keeps a link landing on the callee's own name whatever boundaries a grammar chose, and keeps links working in the plain-text fallback. - Single-file components (`.svelte`, `.vue`, `.astro`) have no grammar of their own; their `