@geml/geml 1.7.1 → 1.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/history.js CHANGED
@@ -38,7 +38,11 @@ export function stampUTC(d) {
38
38
  // ---------------------------------------------------------------------------
39
39
  // Top-level fenced-block locator (for the history file's own structure)
40
40
  // ---------------------------------------------------------------------------
41
- const FENCE_OPEN = /^(={3,})[ \t]+([A-Za-z][A-Za-z0-9_-]*)[ \t]*(\{.*\})?[ \t]*$/;
41
+ // Trailing whitespace nested inside the optional attrs group — the flat form
42
+ // let two runs split the same tabs every possible way (quadratic; see the note
43
+ // on geml.ts's FENCE_OPEN, which this mirrors). Same language, verified over
44
+ // 150k random strings.
45
+ const FENCE_OPEN = /^(={3,})[ \t]+([A-Za-z][A-Za-z0-9_-]*)[ \t]*(?:(\{.*\})[ \t]*)?$/;
42
46
  function locate(lines) {
43
47
  const out = [];
44
48
  let i = 0;
@@ -50,7 +54,7 @@ function locate(lines) {
50
54
  const idm = /#([A-Za-z][A-Za-z0-9_-]*)/.exec(attrLine);
51
55
  let j = i + 1;
52
56
  while (j < lines.length) {
53
- const t = lines[j].replace(/\s+$/, "");
57
+ const t = lines[j].trimEnd();
54
58
  if (/^=+$/.test(t) && t.length === fenceLen)
55
59
  break;
56
60
  j++;
@@ -108,7 +112,7 @@ function tile(lines) {
108
112
  id = /#([A-Za-z][A-Za-z0-9_-]*)/.exec(fo[3] ?? "")?.[1];
109
113
  i++;
110
114
  while (i < n) {
111
- const t = lines[i].replace(/\s+$/, "");
115
+ const t = lines[i].trimEnd();
112
116
  const close = /^=+$/.test(t) && t.length === fenceLen;
113
117
  i++;
114
118
  if (close)
package/dist/mcp.js CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  // `geml mcp` — MCP server for GEML documents and the code graph.
3
3
  //
4
- // Ten tools over a confined root directory of `.geml` documents: five read-only,
5
- // five that write, each named after the CLI verb it wraps (`geml set` ->
6
- // `geml_set`, the bare transform entry -> `geml_to`). When that root holds a code graph, the four read-only
4
+ // Eleven tools over a confined root directory of `.geml` documents: six
5
+ // read-only, five that write, each named after the CLI verb it wraps (`geml set`
6
+ // -> `geml_set`, the bare transform entry -> `geml_to`). When that root holds a code graph, the four read-only
7
7
  // code-graph tools from `codemap/mcp-server.mjs` are served from this SAME
8
8
  // process, so a client registers one server instead of two. That file stays a
9
9
  // standalone `geml codemap mcp` entry point; this one imports its tool table
@@ -273,6 +273,44 @@ export const TOOLS = [
273
273
  return run.stdout.trim();
274
274
  },
275
275
  },
276
+ {
277
+ name: "geml_find",
278
+ description: "Search block CONTENT across the served documents and get back ADDRESSES, one row of `<file>\\t<address>` per hit. This is the other half of geml_list: `list` says what a document contains, `find` says which block holds the words you are looking for — and it answers with an address that pastes straight into geml_get or geml_set, never a line number that the next edit invalidates. The address is the innermost block holding the match, and a block that matches on many lines is reported once. Substring, case-insensitive unless `case` is true. Omit `path` to search every `*.geml` under the server root, or give a file or directory to narrow it. No match is not an error: the result is empty.",
279
+ inputSchema: {
280
+ type: "object",
281
+ properties: {
282
+ pattern: { type: "string", description: "Text to look for inside block bodies" },
283
+ path: { type: "string", description: "Optional file or directory under the server root; default: the whole root" },
284
+ case: { type: "boolean", description: "Match case exactly (default: case-insensitive)" },
285
+ head: { type: "boolean", description: "Add the matching line as a third column" },
286
+ },
287
+ required: ["pattern"],
288
+ },
289
+ run: (args) => {
290
+ if (typeof args.pattern !== "string" || args.pattern === "")
291
+ throw new Error("`pattern` is required");
292
+ // `path` goes through the same confinement gate as every other file
293
+ // argument; omitting it searches the root, which is confined by being it.
294
+ const where = args.path === undefined ? OPTS.root : resolveInRoot(args.path);
295
+ const flags = [...(args.case ? ["--case"] : []), ...(args.head ? ["--head"] : [])];
296
+ const run = runCli(["find", args.pattern, where, ...flags]);
297
+ // Exit 1 means "nothing matched" — a result, not a failure. Anything on
298
+ // stderr is the real error case.
299
+ if (!run.ok && run.stderr)
300
+ throw new Error(run.stderr);
301
+ // The CLI prints the path it was given, which here is absolute. Every
302
+ // other tool in this server speaks paths relative to the root, and a
303
+ // model is meant to paste a row's file straight into geml_get — so put
304
+ // the rows in those coordinates, and keep the server's own layout out of
305
+ // the client's view while we are at it.
306
+ const base = realpathSync(OPTS.root) + sep;
307
+ return run.stdout
308
+ .split("\n")
309
+ .map((line) => (line.startsWith(base) ? line.slice(base.length).replace(/\\/g, "/") : line))
310
+ .join("\n")
311
+ .trim();
312
+ },
313
+ },
276
314
  {
277
315
  name: "geml_get",
278
316
  description: "Read ONE block from a GEML document. Use this instead of reading the whole file: it returns only that block, typically a few percent of the document. Call `geml_list` first and pass back the `address` it gives — that also reaches blocks with no `#id`, which an id alone cannot.",
@@ -282,7 +320,7 @@ export const TOOLS = [
282
320
  file: FILE_ARG,
283
321
  id: {
284
322
  type: "string",
285
- description: "What to read: a block id (with or without `#`), a `## Heading` line (its whole section), `=== type` for every block of a type, or a `@<hex>` content address for a block with no id — the forms `geml_list` prints",
323
+ description: "What to read: a block id (with or without `#`), a `## Heading` line (its whole section), `=== type` for every block of a type, a `@<hex>` content address for a block with no id, or `L27`/`L27-58` for the smallest block holding those lines — the forms `geml_list` prints, plus the line numbers an editor or a diff hunk speaks",
286
324
  },
287
325
  view: {
288
326
  type: "boolean",
@@ -290,8 +328,8 @@ export const TOOLS = [
290
328
  },
291
329
  part: {
292
330
  type: "string",
293
- enum: ["whole", "head", "body"],
294
- description: "How much of the block to return (default: whole). `body` is usually what you want together with `view`.",
331
+ enum: ["whole", "head", "intro", "body"],
332
+ description: "How much of the block to return (default: whole). For a SECTION these cut it three ways: `head` is the heading line, `intro` everything under it up to its first subheading, `body` everything under it — so `body` always contains `intro`, and equals it when the section has no subheading. Reach for `intro` to read a section's opening without pulling its subsections into the conversation; a whole `#id` on a top-level heading is often the entire document. Only a heading has an intro. `body` is usually what you want together with `view`.",
295
333
  },
296
334
  },
297
335
  required: ["file", "id"],
@@ -429,7 +467,7 @@ export const TOOLS = [
429
467
  // ----- write -----
430
468
  {
431
469
  name: "geml_set",
432
- description: "Replace ONE block, leaving every other byte of the document untouched. Prefer this over rewriting a file. The replacement is VALIDATED BEFORE it is written: if it would break the document, nothing is written and you get the diagnostics back — re-read them and fix the body rather than retrying the same content. `part` selects whole block (default), just the head/fence line, or just the body. An address matching SEVERAL blocks is refused — this writes one block, so narrow it first.",
470
+ description: "Replace ONE block, leaving every other byte of the document untouched. Prefer this over rewriting a file. The replacement is VALIDATED BEFORE it is written: if it would break the document, nothing is written and you get the diagnostics back — re-read them and fix the body rather than retrying the same content. Breaking the document is what gets refused — removing content is not: if your replacement drops blocks, the write goes through and the result names every block that went, unnamed ones included, so check that line whenever you shortened a section. `geml_revert` puts one back. The way to keep a block is to keep it in the text you send; `geml_get` on the same address just handed it to you. `part` selects whole block (default), the head/fence line, a section's `intro`, or the body. An address matching SEVERAL blocks is refused — this writes one block, so narrow it first.",
433
471
  inputSchema: {
434
472
  type: "object",
435
473
  properties: {
@@ -439,7 +477,7 @@ export const TOOLS = [
439
477
  description: "Which block to replace: an id (with or without `#`), or a `@<hex>` content address from `geml_list` for a block with no id. Must match exactly one block",
440
478
  },
441
479
  body: { type: "string", description: "The replacement text" },
442
- part: { type: "string", enum: ["whole", "head", "body"], description: "What to replace (default: whole)" },
480
+ part: { type: "string", enum: ["whole", "head", "intro", "body"], description: "What to replace (default: whole). `intro` replaces a section's opening — everything under the heading up to its first subheading — and leaves every subsection byte-identical, which is what makes a read-edit-write cycle on a long section safe. An empty intro (a subheading follows the heading immediately) is written into, so this also adds an opening where there was none." },
443
481
  },
444
482
  required: ["file", "id", "body"],
445
483
  },
@@ -2,7 +2,7 @@ import { type Document } from "./geml.js";
2
2
  import { type RenderOptions } from "./render.js";
3
3
  export declare function renderHtml(doc: Document, opts?: RenderOptions): string;
4
4
  export declare const pageAssets: {
5
- readonly css: "\n:root { --fg:#1f2328; --muted:#656d76; --bd:#d0d7de; --bg:#fff; --accent:#2563eb; --code-bg:#f6f8fa; }\n* { box-sizing: border-box; }\nbody { margin:0; color:var(--fg); background:#fafbfc; font:16px/1.6 -apple-system,BlinkMacSystemFont,\"Segoe UI\",Helvetica,Arial,\"PingFang SC\",\"Microsoft Yahei\",sans-serif; }\nmain { max-width: 860px; margin: 0 auto; padding: 48px 24px 96px; background:var(--bg); }\nh1,h2,h3,h4,h5,h6 { line-height:1.25; margin:1.6em 0 .6em; scroll-margin-top:16px; }\nh1 { font-size:2em; border-bottom:1px solid var(--bd); padding-bottom:.3em; }\nh2 { font-size:1.5em; border-bottom:1px solid var(--bd); padding-bottom:.3em; }\nh3 { font-size:1.25em; } h4 { font-size:1em; }\np { margin:.7em 0; }\na { color:var(--accent); text-decoration:none; } a:hover { text-decoration:underline; }\ncode { background:var(--code-bg); padding:.15em .35em; border-radius:6px; font:.88em ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }\npre { background:var(--code-bg); padding:14px 16px; border-radius:8px; overflow:auto; }\npre code { background:none; padding:0; font-size:.85em; }\npre.output { background:#0d1117; color:#e6edf3; }\npre.output code { color:inherit; }\nul,ol { padding-left:1.6em; } li { margin:.2em 0; }\nul.task-list { list-style:none; padding-left:.2em; }\nli.task input[type=checkbox] { appearance:none; -webkit-appearance:none; width:1.1em; height:1.1em; margin:0 .5em 0 0; vertical-align:-.2em; border:1.5px solid #c8ccd0; border-radius:4px; background:#fff; position:relative; opacity:1; cursor:default; box-sizing:border-box; }\nli.task input[type=checkbox]:checked { background-color:#1f883d; border-color:#1f883d; }\nli.task input[type=checkbox]:checked::after { content:\"✓\"; position:absolute; top:0; right:0; bottom:0; left:0; display:flex; align-items:center; justify-content:center; color:#fff; font-size:.8em; line-height:1; font-weight:700; }\naside.callout { border-left:4px solid var(--accent); background:#f0f6ff; padding:.4em 16px; border-radius:0 8px 8px 0; margin:1em 0; }\naside.aside { border-left-color:#8b949e; background:#f6f8fa; }\naside.warning { border-left-color:#d97706; background:#fff8f0; }\naside.callout > :first-child { margin-top:0; } aside.callout > :last-child { margin-bottom:0; }\nfigure { margin:1.2em 0; }\nfigcaption { color:var(--muted); font-size:.86em; text-align:center; margin-top:.5em; }\ntable.geml-table { border-collapse:collapse; width:100%; font-size:.92em; }\ntable.geml-table th, table.geml-table td { border:1px solid var(--bd); padding:6px 12px; }\ntable.geml-table thead th { background:var(--code-bg); cursor:pointer; user-select:none; white-space:nowrap; }\ntable.geml-table thead th::after { content:\" \\2195\"; color:var(--muted); font-size:.8em; }\ntable.geml-table thead th.asc::after { content:\" \\2191\"; color:var(--accent); }\ntable.geml-table thead th.desc::after { content:\" \\2193\"; color:var(--accent); }\ntable.geml-table tbody tr:nth-child(2n) { background:#fafbfc; }\ntable.geml-table td.computed { color:#0a7c52; }\ntable.geml-table tfoot td { background:var(--code-bg); font-weight:600; border-top:2px solid var(--bd); }\n.table-tools { margin-bottom:6px; } .table-filter { width:240px; max-width:100%; padding:5px 9px; border:1px solid var(--bd); border-radius:7px; font-size:.85em; }\n.table-figure details > summary { cursor:pointer; color:var(--muted); font-size:.86em; padding:4px 0; }\n.table-note { color:var(--muted); font-size:.82em; margin:6px 0 0; }\n.geml-chart { width:100%; height:auto; background:var(--bg); border:1px solid var(--bd); border-radius:8px; }\n.c-title { font-size:15px; font-weight:600; fill:var(--fg); }\n.c-grid { stroke:#eaecef; } .c-axis { stroke:#aab1b8; } .c-tick { font-size:11px; fill:var(--muted); } .c-legend { font-size:12px; fill:var(--fg); }\n.media { max-width:100%; border-radius:8px; }\n.diagram-src { color:var(--muted); } .render-error { color:#cf222e; }\n.math-block { overflow-x:auto; padding:.4em 0; }\nsup.fn a { font-size:.75em; }\n.geml-footer { max-width:860px; margin:0 auto; padding:16px 24px 40px; color:var(--muted); font-size:.82em; }\n.geml-footer code { font-size:.95em; }\n.code-graph { margin:1.4em 0; }\n.cg-mount { border:1px solid var(--bd); border-radius:8px; padding:10px 12px; background:var(--bg); }\n.cg-scroll { overflow:auto; min-height:52vh; max-height:72vh; }\n.cg-svg { display:block; }\n.cg-search-wrap { position:relative; display:inline-block; }\n.cg-search { font:12px/1.4 inherit; padding:2px 7px; border:1px solid var(--bd); border-radius:4px; background:var(--bg); color:var(--fg); min-width:13ch; }\n.cg-search-menu { position:absolute; z-index:30; top:calc(100% + 2px); left:0; min-width:24ch; max-width:52ch; max-height:52vh; overflow:auto; background:var(--bg); border:1px solid var(--bd); border-radius:6px; box-shadow:0 6px 20px rgba(0,0,0,.18); }\n.cg-search-row { display:block; width:100%; text-align:left; padding:4px 9px 4px 18px; border:0; background:none; color:var(--fg); cursor:pointer; font:12px/1.4 inherit; }\n.cg-search-row:hover { background:var(--bd); }\n.cg-search-count { position:sticky; top:0; padding:4px 9px; font-size:11px; opacity:.65; background:var(--bg); border-bottom:1px solid var(--bd); }\n.cg-search-grp { padding:6px 9px 2px; font-size:11px; font-weight:600; opacity:.7; border-top:1px solid var(--bd); }\n.cg-search-grp:first-of-type { border-top:0; }\n.cg-stage { display:flex; gap:10px; align-items:flex-start; }\n.cg-stage .cg-scroll { flex:1 1 auto; min-width:0; }\n.cg-src { flex:0 0 42%; max-width:46%; display:flex; flex-direction:column; border:1px solid var(--bd); border-radius:6px; overflow:hidden; background:var(--bg); }\n.cg-src-hd { display:flex; gap:8px; align-items:center; justify-content:space-between; padding:4px 8px; border-bottom:1px solid var(--bd); color:var(--muted); font:.76em ui-monospace,Consolas,monospace; word-break:break-all; }\n.cg-src-hd button { font:inherit; border:1px solid var(--bd); border-radius:5px; background:transparent; color:var(--muted); cursor:pointer; padding:0 6px; }\n.cg-src-body { margin:0; padding:8px 10px; overflow:auto; max-height:72vh; color:var(--fg); font:12px/1.5 ui-monospace,Consolas,monospace; white-space:pre; }\n.cg-src-note { color:var(--muted); font-style:italic; white-space:pre-wrap; }\n.cg-bar { display:flex; gap:8px; align-items:center; flex-wrap:wrap; font-size:.82em; color:var(--muted); margin-bottom:6px; }\n.cg-bar button { font:inherit; padding:1px 8px; border:1px solid var(--bd); border-radius:5px; background:transparent; cursor:pointer; }\n.cg-crumb .cg-seg { border:0; border-radius:0; padding:0; background:none; color:var(--accent); cursor:pointer; font:inherit; }\n.cg-crumb .cg-seg:hover { text-decoration:underline; }\n.cg-frame { display:block; width:100%; height:72vh; border:0; background:var(--bg); }\n.cg-flash { color:#b42318; }\n.cg-legend { display:flex; gap:14px; align-items:center; justify-content:space-between; flex-wrap:wrap; font-size:.75em; color:var(--muted); margin-top:6px; }\n.cg-upbtn { cursor:pointer; }\n.cg-upbtn circle { fill:#fff; stroke:#94a3b8; }\n.cg-upbtn text { font-size:11px; fill:#57606a; }\n.cg-upbtn:hover circle { stroke:var(--accent); stroke-width:1.6; }\n.cg-upbtn:hover text { fill:var(--accent); }\n.cg-uplink { fill:none; stroke:#94a3b8; stroke-dasharray:3 2.5; pointer-events:none; }\n.cg-groups { display:flex; flex-wrap:wrap; gap:4px 12px; margin-top:6px; font-size:.75em; color:var(--muted); }\n.cg-chip { display:inline-flex; align-items:center; gap:4px; }\n.cg-chip i { width:10px; height:10px; border-radius:2px; border:1px solid #94a3b8; display:inline-block; }\n.cg-note { font-size:.8em; color:#9a6700; }\n.cg-n rect { fill:#eef2f7; stroke:#94a3b8; }\n.cg-n text { font-size:12px; fill:var(--fg); font-family:ui-monospace,Consolas,monospace; }\n.cg-n { cursor:pointer; }\n.cg-n.root rect { fill:#dbeafe; stroke:#2563eb; stroke-width:2; }\n.cg-n.leaf { opacity:.45; }\n.cg-n.test rect { stroke-dasharray:3 2; }\n.cg-n.grp rect { stroke-width:1.8; }\n.cg-e { fill:none; stroke:#94a3b8; stroke-width:.9; }\n.cg-e.cand { stroke-dasharray:2 3; }\n.cg-e.back { stroke:#dc2626; stroke-dasharray:5 3; }\n.cg-e.http { stroke:#0891b2; stroke-width:1.5; stroke-dasharray:5 2; } /* cross-stack API link */\n.cg-e.soft { opacity:.55; }\n.cg-svg.hl .cg-n { opacity:.22; }\n.cg-svg.hl .cg-e { opacity:.1; }\n.cg-svg.hl .cg-n.hl { opacity:1; }\n.cg-svg.hl .cg-e.hl { opacity:1; stroke-width:1.6; }\n";
5
+ readonly css: "\n:root { --fg:#1f2328; --muted:#656d76; --bd:#d0d7de; --bg:#fff; --accent:#2563eb; --code-bg:#f6f8fa; }\n* { box-sizing: border-box; }\nbody { margin:0; color:var(--fg); background:#fafbfc; font:16px/1.6 -apple-system,BlinkMacSystemFont,\"Segoe UI\",Helvetica,Arial,\"PingFang SC\",\"Microsoft Yahei\",sans-serif; }\nmain { max-width: 860px; margin: 0 auto; padding: 48px 24px 96px; background:var(--bg); }\nh1,h2,h3,h4,h5,h6 { line-height:1.25; margin:1.6em 0 .6em; scroll-margin-top:16px; }\nh1 { font-size:2em; border-bottom:1px solid var(--bd); padding-bottom:.3em; }\nh2 { font-size:1.5em; border-bottom:1px solid var(--bd); padding-bottom:.3em; }\nh3 { font-size:1.25em; } h4 { font-size:1em; }\np { margin:.7em 0; }\na { color:var(--accent); text-decoration:none; } a:hover { text-decoration:underline; }\ncode { background:var(--code-bg); padding:.15em .35em; border-radius:6px; font:.88em ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }\npre { background:var(--code-bg); padding:14px 16px; border-radius:8px; overflow:auto; }\npre code { background:none; padding:0; font-size:.85em; }\npre.output { background:#0d1117; color:#e6edf3; }\npre.output code { color:inherit; }\nul,ol { padding-left:1.6em; } li { margin:.2em 0; }\nul.task-list { list-style:none; padding-left:.2em; }\nli.task input[type=checkbox] { appearance:none; -webkit-appearance:none; width:1.1em; height:1.1em; margin:0 .5em 0 0; vertical-align:-.2em; border:1.5px solid #c8ccd0; border-radius:4px; background:#fff; position:relative; opacity:1; cursor:default; box-sizing:border-box; }\nli.task input[type=checkbox]:checked { background-color:#1f883d; border-color:#1f883d; }\nli.task input[type=checkbox]:checked::after { content:\"✓\"; position:absolute; top:0; right:0; bottom:0; left:0; display:flex; align-items:center; justify-content:center; color:#fff; font-size:.8em; line-height:1; font-weight:700; }\naside.callout { border-left:4px solid var(--accent); background:#f0f6ff; padding:.4em 16px; border-radius:0 8px 8px 0; margin:1em 0; }\naside.aside { border-left-color:#8b949e; background:#f6f8fa; }\naside.warning { border-left-color:#d97706; background:#fff8f0; }\naside.callout > :first-child { margin-top:0; } aside.callout > :last-child { margin-bottom:0; }\nfigure { margin:1.2em 0; }\nfigcaption { color:var(--muted); font-size:.86em; text-align:center; margin-top:.5em; }\ntable.geml-table { border-collapse:collapse; width:100%; font-size:.92em; }\ntable.geml-table th, table.geml-table td { border:1px solid var(--bd); padding:6px 12px; }\ntable.geml-table thead th { background:var(--code-bg); cursor:pointer; user-select:none; white-space:nowrap; }\ntable.geml-table thead th::after { content:\" \\2195\"; color:var(--muted); font-size:.8em; }\ntable.geml-table thead th.asc::after { content:\" \\2191\"; color:var(--accent); }\ntable.geml-table thead th.desc::after { content:\" \\2193\"; color:var(--accent); }\ntable.geml-table tbody tr:nth-child(2n) { background:#fafbfc; }\ntable.geml-table td.computed { color:#0a7c52; }\ntable.geml-table tfoot td { background:var(--code-bg); font-weight:600; border-top:2px solid var(--bd); }\n.table-tools { margin-bottom:6px; } .table-filter { width:240px; max-width:100%; padding:5px 9px; border:1px solid var(--bd); border-radius:7px; font-size:.85em; }\n/* A table wider than the column scrolls INSIDE its own box — the page itself\n must never scroll sideways. Only the table is in here: the filter box above\n and the caption below stay put instead of sliding out of view with it. */\n.table-scroll { overflow-x:auto; max-width:100%; }\n.table-figure details > summary { cursor:pointer; color:var(--muted); font-size:.86em; padding:4px 0; }\n.table-note { color:var(--muted); font-size:.82em; margin:6px 0 0; }\n.geml-chart { width:100%; height:auto; background:var(--bg); border:1px solid var(--bd); border-radius:8px; }\n.c-title { font-size:15px; font-weight:600; fill:var(--fg); }\n.c-grid { stroke:#eaecef; } .c-axis { stroke:#aab1b8; } .c-tick { font-size:11px; fill:var(--muted); } .c-legend { font-size:12px; fill:var(--fg); }\n.media { max-width:100%; border-radius:8px; }\n.diagram-src { color:var(--muted); } .render-error { color:#cf222e; }\n.math-block { overflow-x:auto; padding:.4em 0; }\nsup.fn a { font-size:.75em; }\n.geml-footer { max-width:860px; margin:0 auto; padding:16px 24px 40px; color:var(--muted); font-size:.82em; }\n.geml-footer code { font-size:.95em; }\n.code-graph { margin:1.4em 0; }\n/* The graph is the widest artifact on the page: let it break out of main's\n 860px reading column and take the viewport, centred, leaving the prose\n around it untouched. Negative inline margins, NOT transform — a transform\n would become the containing block for the fullscreen overlay below. */\n@media (min-width:900px) { .code-graph { margin-inline: calc((100% - min(96vw, 1600px)) / 2); } }\n.cg-mount { border:1px solid var(--bd); border-radius:8px; padding:10px 12px; background:var(--bg); }\n.cg-scroll { overflow:auto; min-height:60vh; max-height:84vh; }\n.cg-svg { display:block; }\n/* Fullscreen: one class drives the layout, whether the native Fullscreen API\n took (mount is in the top layer) or we fell back to a fixed overlay. The\n :fullscreen pseudo-class is deliberately NOT in these selectors — a browser\n that doesn't know it would drop the whole rule; the runtime keeps the class\n in sync with fullscreenchange instead. Toolbar/legend keep their height,\n the stage eats the rest. */\n.cg-mount.cg-full { position:fixed; inset:0; z-index:9999; margin:0; border:0; border-radius:0; padding:10px 14px; background:var(--bg); display:flex; flex-direction:column; }\n.cg-mount.cg-full > .cg-bar, .cg-mount.cg-full > .cg-legend, .cg-mount.cg-full > .cg-groups { flex:0 0 auto; }\n.cg-mount.cg-full .cg-stage { flex:1 1 auto; min-height:0; }\n.cg-mount.cg-full .cg-scroll { min-height:0; max-height:none; height:100%; }\n.cg-mount.cg-full .cg-src-body { max-height:none; }\n.cg-mount.cg-full .cg-frame { flex:1 1 auto; height:auto; }\n.cg-search-wrap { position:relative; display:inline-block; }\n.cg-search { font:12px/1.4 inherit; padding:2px 7px; border:1px solid var(--bd); border-radius:4px; background:var(--bg); color:var(--fg); min-width:13ch; }\n.cg-search-menu { position:absolute; z-index:30; top:calc(100% + 2px); left:0; min-width:24ch; max-width:52ch; max-height:52vh; overflow:auto; background:var(--bg); border:1px solid var(--bd); border-radius:6px; box-shadow:0 6px 20px rgba(0,0,0,.18); }\n.cg-search-row { display:block; width:100%; text-align:left; padding:4px 9px 4px 18px; border:0; background:none; color:var(--fg); cursor:pointer; font:12px/1.4 inherit; }\n.cg-search-row:hover { background:var(--bd); }\n.cg-search-count { position:sticky; top:0; padding:4px 9px; font-size:11px; opacity:.65; background:var(--bg); border-bottom:1px solid var(--bd); }\n.cg-search-grp { padding:6px 9px 2px; font-size:11px; font-weight:600; opacity:.7; border-top:1px solid var(--bd); }\n.cg-search-grp:first-of-type { border-top:0; }\n.cg-stage { display:flex; gap:10px; align-items:flex-start; }\n.cg-stage .cg-scroll { flex:1 1 auto; min-width:0; }\n.cg-src { flex:0 0 42%; max-width:46%; display:flex; flex-direction:column; border:1px solid var(--bd); border-radius:6px; overflow:hidden; background:var(--bg); }\n.cg-src-hd { display:flex; gap:8px; align-items:center; justify-content:space-between; padding:4px 8px; border-bottom:1px solid var(--bd); color:var(--muted); font:.76em ui-monospace,Consolas,monospace; word-break:break-all; }\n.cg-src-hd button { font:inherit; border:1px solid var(--bd); border-radius:5px; background:transparent; color:var(--muted); cursor:pointer; padding:0 6px; }\n.cg-src-body { margin:0; padding:8px 10px; overflow:auto; max-height:84vh; color:var(--fg); font:12px/1.5 ui-monospace,Consolas,monospace; white-space:pre; }\n.cg-src-note { color:var(--muted); font-style:italic; white-space:pre-wrap; }\n.cg-bar { display:flex; gap:8px; align-items:center; flex-wrap:wrap; font-size:.82em; color:var(--muted); margin-bottom:6px; }\n.cg-bar button { font:inherit; padding:1px 8px; border:1px solid var(--bd); border-radius:5px; background:transparent; cursor:pointer; }\n.cg-crumb .cg-seg { border:0; border-radius:0; padding:0; background:none; color:var(--accent); cursor:pointer; font:inherit; }\n.cg-crumb .cg-seg:hover { text-decoration:underline; }\n.cg-frame { display:block; width:100%; height:84vh; border:0; background:var(--bg); }\n.cg-flash { color:#b42318; }\n.cg-legend { display:flex; gap:14px; align-items:center; justify-content:space-between; flex-wrap:wrap; font-size:.75em; color:var(--muted); margin-top:6px; }\n.cg-upbtn { cursor:pointer; }\n.cg-upbtn circle { fill:#fff; stroke:#94a3b8; }\n.cg-upbtn text { font-size:11px; fill:#57606a; }\n.cg-upbtn:hover circle { stroke:var(--accent); stroke-width:1.6; }\n.cg-upbtn:hover text { fill:var(--accent); }\n.cg-uplink { fill:none; stroke:#94a3b8; stroke-dasharray:3 2.5; pointer-events:none; }\n.cg-groups { display:flex; flex-wrap:wrap; gap:4px 12px; margin-top:6px; font-size:.75em; color:var(--muted); }\n.cg-chip { display:inline-flex; align-items:center; gap:4px; }\n.cg-chip i { width:10px; height:10px; border-radius:2px; border:1px solid #94a3b8; display:inline-block; }\n.cg-note { font-size:.8em; color:#9a6700; }\n.cg-n rect { fill:#eef2f7; stroke:#94a3b8; }\n.cg-n text { font-size:12px; fill:var(--fg); font-family:ui-monospace,Consolas,monospace; }\n.cg-n { cursor:pointer; }\n.cg-n.root rect { fill:#dbeafe; stroke:#2563eb; stroke-width:2; }\n.cg-n.leaf { opacity:.45; }\n.cg-n.test rect { stroke-dasharray:3 2; }\n.cg-n.grp rect { stroke-width:1.8; }\n.cg-e { fill:none; stroke:#94a3b8; stroke-width:.9; }\n.cg-e.cand { stroke-dasharray:2 3; }\n.cg-e.back { stroke:#dc2626; stroke-dasharray:5 3; }\n.cg-e.http { stroke:#0891b2; stroke-width:1.5; stroke-dasharray:5 2; } /* cross-stack API link */\n.cg-e.soft { opacity:.55; }\n.cg-svg.hl .cg-n { opacity:.22; }\n.cg-svg.hl .cg-e { opacity:.1; }\n.cg-svg.hl .cg-n.hl { opacity:1; }\n.cg-svg.hl .cg-e.hl { opacity:1; stroke-width:1.6; }\n";
6
6
  readonly js: "\n(function () {\n function cmp(a, b) {\n var na = a.dataset.sort, nb = b.dataset.sort;\n if (na !== undefined && nb !== undefined) return parseFloat(na) - parseFloat(nb);\n return (a.textContent || \"\").localeCompare(b.textContent || \"\");\n }\n document.querySelectorAll(\"table.geml-table\").forEach(function (table) {\n var tbody = table.tBodies[0];\n if (!tbody) return;\n // Sort on header click.\n var ths = table.tHead ? table.tHead.rows[0].cells : [];\n Array.prototype.forEach.call(ths, function (th, col) {\n th.addEventListener(\"click\", function () {\n var dir = th.classList.contains(\"asc\") ? \"desc\" : \"asc\";\n Array.prototype.forEach.call(ths, function (h) { h.classList.remove(\"asc\", \"desc\"); });\n th.classList.add(dir);\n var rows = Array.prototype.slice.call(tbody.rows);\n rows.sort(function (r1, r2) {\n var c = cmp(r1.cells[col], r2.cells[col]);\n return dir === \"asc\" ? c : -c;\n });\n rows.forEach(function (r) { tbody.appendChild(r); });\n });\n });\n // Filter rows.\n var fig = table.closest(\".table-figure\");\n var input = fig ? fig.querySelector(\".table-filter\") : null;\n if (input) input.addEventListener(\"input\", function () {\n var q = input.value.toLowerCase();\n Array.prototype.forEach.call(tbody.rows, function (r) {\n r.style.display = (r.textContent || \"\").toLowerCase().indexOf(q) >= 0 ? \"\" : \"none\";\n });\n });\n });\n})();\n";
7
7
  readonly codeGraphJs: string;
8
8
  };
package/dist/render.d.ts CHANGED
@@ -95,7 +95,7 @@ declare function buildCodeGraph(startRel: string, opts: RenderOptions, view?: {
95
95
  error?: string;
96
96
  truncated?: boolean;
97
97
  };
98
- export declare const CSS = "\n:root { --fg:#1f2328; --muted:#656d76; --bd:#d0d7de; --bg:#fff; --accent:#2563eb; --code-bg:#f6f8fa; }\n* { box-sizing: border-box; }\nbody { margin:0; color:var(--fg); background:#fafbfc; font:16px/1.6 -apple-system,BlinkMacSystemFont,\"Segoe UI\",Helvetica,Arial,\"PingFang SC\",\"Microsoft Yahei\",sans-serif; }\nmain { max-width: 860px; margin: 0 auto; padding: 48px 24px 96px; background:var(--bg); }\nh1,h2,h3,h4,h5,h6 { line-height:1.25; margin:1.6em 0 .6em; scroll-margin-top:16px; }\nh1 { font-size:2em; border-bottom:1px solid var(--bd); padding-bottom:.3em; }\nh2 { font-size:1.5em; border-bottom:1px solid var(--bd); padding-bottom:.3em; }\nh3 { font-size:1.25em; } h4 { font-size:1em; }\np { margin:.7em 0; }\na { color:var(--accent); text-decoration:none; } a:hover { text-decoration:underline; }\ncode { background:var(--code-bg); padding:.15em .35em; border-radius:6px; font:.88em ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }\npre { background:var(--code-bg); padding:14px 16px; border-radius:8px; overflow:auto; }\npre code { background:none; padding:0; font-size:.85em; }\npre.output { background:#0d1117; color:#e6edf3; }\npre.output code { color:inherit; }\nul,ol { padding-left:1.6em; } li { margin:.2em 0; }\nul.task-list { list-style:none; padding-left:.2em; }\nli.task input[type=checkbox] { appearance:none; -webkit-appearance:none; width:1.1em; height:1.1em; margin:0 .5em 0 0; vertical-align:-.2em; border:1.5px solid #c8ccd0; border-radius:4px; background:#fff; position:relative; opacity:1; cursor:default; box-sizing:border-box; }\nli.task input[type=checkbox]:checked { background-color:#1f883d; border-color:#1f883d; }\nli.task input[type=checkbox]:checked::after { content:\"\u2713\"; position:absolute; top:0; right:0; bottom:0; left:0; display:flex; align-items:center; justify-content:center; color:#fff; font-size:.8em; line-height:1; font-weight:700; }\naside.callout { border-left:4px solid var(--accent); background:#f0f6ff; padding:.4em 16px; border-radius:0 8px 8px 0; margin:1em 0; }\naside.aside { border-left-color:#8b949e; background:#f6f8fa; }\naside.warning { border-left-color:#d97706; background:#fff8f0; }\naside.callout > :first-child { margin-top:0; } aside.callout > :last-child { margin-bottom:0; }\nfigure { margin:1.2em 0; }\nfigcaption { color:var(--muted); font-size:.86em; text-align:center; margin-top:.5em; }\ntable.geml-table { border-collapse:collapse; width:100%; font-size:.92em; }\ntable.geml-table th, table.geml-table td { border:1px solid var(--bd); padding:6px 12px; }\ntable.geml-table thead th { background:var(--code-bg); cursor:pointer; user-select:none; white-space:nowrap; }\ntable.geml-table thead th::after { content:\" \\2195\"; color:var(--muted); font-size:.8em; }\ntable.geml-table thead th.asc::after { content:\" \\2191\"; color:var(--accent); }\ntable.geml-table thead th.desc::after { content:\" \\2193\"; color:var(--accent); }\ntable.geml-table tbody tr:nth-child(2n) { background:#fafbfc; }\ntable.geml-table td.computed { color:#0a7c52; }\ntable.geml-table tfoot td { background:var(--code-bg); font-weight:600; border-top:2px solid var(--bd); }\n.table-tools { margin-bottom:6px; } .table-filter { width:240px; max-width:100%; padding:5px 9px; border:1px solid var(--bd); border-radius:7px; font-size:.85em; }\n.table-figure details > summary { cursor:pointer; color:var(--muted); font-size:.86em; padding:4px 0; }\n.table-note { color:var(--muted); font-size:.82em; margin:6px 0 0; }\n.geml-chart { width:100%; height:auto; background:var(--bg); border:1px solid var(--bd); border-radius:8px; }\n.c-title { font-size:15px; font-weight:600; fill:var(--fg); }\n.c-grid { stroke:#eaecef; } .c-axis { stroke:#aab1b8; } .c-tick { font-size:11px; fill:var(--muted); } .c-legend { font-size:12px; fill:var(--fg); }\n.media { max-width:100%; border-radius:8px; }\n.diagram-src { color:var(--muted); } .render-error { color:#cf222e; }\n.math-block { overflow-x:auto; padding:.4em 0; }\nsup.fn a { font-size:.75em; }\n.geml-footer { max-width:860px; margin:0 auto; padding:16px 24px 40px; color:var(--muted); font-size:.82em; }\n.geml-footer code { font-size:.95em; }\n.code-graph { margin:1.4em 0; }\n.cg-mount { border:1px solid var(--bd); border-radius:8px; padding:10px 12px; background:var(--bg); }\n.cg-scroll { overflow:auto; min-height:52vh; max-height:72vh; }\n.cg-svg { display:block; }\n.cg-search-wrap { position:relative; display:inline-block; }\n.cg-search { font:12px/1.4 inherit; padding:2px 7px; border:1px solid var(--bd); border-radius:4px; background:var(--bg); color:var(--fg); min-width:13ch; }\n.cg-search-menu { position:absolute; z-index:30; top:calc(100% + 2px); left:0; min-width:24ch; max-width:52ch; max-height:52vh; overflow:auto; background:var(--bg); border:1px solid var(--bd); border-radius:6px; box-shadow:0 6px 20px rgba(0,0,0,.18); }\n.cg-search-row { display:block; width:100%; text-align:left; padding:4px 9px 4px 18px; border:0; background:none; color:var(--fg); cursor:pointer; font:12px/1.4 inherit; }\n.cg-search-row:hover { background:var(--bd); }\n.cg-search-count { position:sticky; top:0; padding:4px 9px; font-size:11px; opacity:.65; background:var(--bg); border-bottom:1px solid var(--bd); }\n.cg-search-grp { padding:6px 9px 2px; font-size:11px; font-weight:600; opacity:.7; border-top:1px solid var(--bd); }\n.cg-search-grp:first-of-type { border-top:0; }\n.cg-stage { display:flex; gap:10px; align-items:flex-start; }\n.cg-stage .cg-scroll { flex:1 1 auto; min-width:0; }\n.cg-src { flex:0 0 42%; max-width:46%; display:flex; flex-direction:column; border:1px solid var(--bd); border-radius:6px; overflow:hidden; background:var(--bg); }\n.cg-src-hd { display:flex; gap:8px; align-items:center; justify-content:space-between; padding:4px 8px; border-bottom:1px solid var(--bd); color:var(--muted); font:.76em ui-monospace,Consolas,monospace; word-break:break-all; }\n.cg-src-hd button { font:inherit; border:1px solid var(--bd); border-radius:5px; background:transparent; color:var(--muted); cursor:pointer; padding:0 6px; }\n.cg-src-body { margin:0; padding:8px 10px; overflow:auto; max-height:72vh; color:var(--fg); font:12px/1.5 ui-monospace,Consolas,monospace; white-space:pre; }\n.cg-src-note { color:var(--muted); font-style:italic; white-space:pre-wrap; }\n.cg-bar { display:flex; gap:8px; align-items:center; flex-wrap:wrap; font-size:.82em; color:var(--muted); margin-bottom:6px; }\n.cg-bar button { font:inherit; padding:1px 8px; border:1px solid var(--bd); border-radius:5px; background:transparent; cursor:pointer; }\n.cg-crumb .cg-seg { border:0; border-radius:0; padding:0; background:none; color:var(--accent); cursor:pointer; font:inherit; }\n.cg-crumb .cg-seg:hover { text-decoration:underline; }\n.cg-frame { display:block; width:100%; height:72vh; border:0; background:var(--bg); }\n.cg-flash { color:#b42318; }\n.cg-legend { display:flex; gap:14px; align-items:center; justify-content:space-between; flex-wrap:wrap; font-size:.75em; color:var(--muted); margin-top:6px; }\n.cg-upbtn { cursor:pointer; }\n.cg-upbtn circle { fill:#fff; stroke:#94a3b8; }\n.cg-upbtn text { font-size:11px; fill:#57606a; }\n.cg-upbtn:hover circle { stroke:var(--accent); stroke-width:1.6; }\n.cg-upbtn:hover text { fill:var(--accent); }\n.cg-uplink { fill:none; stroke:#94a3b8; stroke-dasharray:3 2.5; pointer-events:none; }\n.cg-groups { display:flex; flex-wrap:wrap; gap:4px 12px; margin-top:6px; font-size:.75em; color:var(--muted); }\n.cg-chip { display:inline-flex; align-items:center; gap:4px; }\n.cg-chip i { width:10px; height:10px; border-radius:2px; border:1px solid #94a3b8; display:inline-block; }\n.cg-note { font-size:.8em; color:#9a6700; }\n.cg-n rect { fill:#eef2f7; stroke:#94a3b8; }\n.cg-n text { font-size:12px; fill:var(--fg); font-family:ui-monospace,Consolas,monospace; }\n.cg-n { cursor:pointer; }\n.cg-n.root rect { fill:#dbeafe; stroke:#2563eb; stroke-width:2; }\n.cg-n.leaf { opacity:.45; }\n.cg-n.test rect { stroke-dasharray:3 2; }\n.cg-n.grp rect { stroke-width:1.8; }\n.cg-e { fill:none; stroke:#94a3b8; stroke-width:.9; }\n.cg-e.cand { stroke-dasharray:2 3; }\n.cg-e.back { stroke:#dc2626; stroke-dasharray:5 3; }\n.cg-e.http { stroke:#0891b2; stroke-width:1.5; stroke-dasharray:5 2; } /* cross-stack API link */\n.cg-e.soft { opacity:.55; }\n.cg-svg.hl .cg-n { opacity:.22; }\n.cg-svg.hl .cg-e { opacity:.1; }\n.cg-svg.hl .cg-n.hl { opacity:1; }\n.cg-svg.hl .cg-e.hl { opacity:1; stroke-width:1.6; }\n";
98
+ export declare const CSS = "\n:root { --fg:#1f2328; --muted:#656d76; --bd:#d0d7de; --bg:#fff; --accent:#2563eb; --code-bg:#f6f8fa; }\n* { box-sizing: border-box; }\nbody { margin:0; color:var(--fg); background:#fafbfc; font:16px/1.6 -apple-system,BlinkMacSystemFont,\"Segoe UI\",Helvetica,Arial,\"PingFang SC\",\"Microsoft Yahei\",sans-serif; }\nmain { max-width: 860px; margin: 0 auto; padding: 48px 24px 96px; background:var(--bg); }\nh1,h2,h3,h4,h5,h6 { line-height:1.25; margin:1.6em 0 .6em; scroll-margin-top:16px; }\nh1 { font-size:2em; border-bottom:1px solid var(--bd); padding-bottom:.3em; }\nh2 { font-size:1.5em; border-bottom:1px solid var(--bd); padding-bottom:.3em; }\nh3 { font-size:1.25em; } h4 { font-size:1em; }\np { margin:.7em 0; }\na { color:var(--accent); text-decoration:none; } a:hover { text-decoration:underline; }\ncode { background:var(--code-bg); padding:.15em .35em; border-radius:6px; font:.88em ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }\npre { background:var(--code-bg); padding:14px 16px; border-radius:8px; overflow:auto; }\npre code { background:none; padding:0; font-size:.85em; }\npre.output { background:#0d1117; color:#e6edf3; }\npre.output code { color:inherit; }\nul,ol { padding-left:1.6em; } li { margin:.2em 0; }\nul.task-list { list-style:none; padding-left:.2em; }\nli.task input[type=checkbox] { appearance:none; -webkit-appearance:none; width:1.1em; height:1.1em; margin:0 .5em 0 0; vertical-align:-.2em; border:1.5px solid #c8ccd0; border-radius:4px; background:#fff; position:relative; opacity:1; cursor:default; box-sizing:border-box; }\nli.task input[type=checkbox]:checked { background-color:#1f883d; border-color:#1f883d; }\nli.task input[type=checkbox]:checked::after { content:\"\u2713\"; position:absolute; top:0; right:0; bottom:0; left:0; display:flex; align-items:center; justify-content:center; color:#fff; font-size:.8em; line-height:1; font-weight:700; }\naside.callout { border-left:4px solid var(--accent); background:#f0f6ff; padding:.4em 16px; border-radius:0 8px 8px 0; margin:1em 0; }\naside.aside { border-left-color:#8b949e; background:#f6f8fa; }\naside.warning { border-left-color:#d97706; background:#fff8f0; }\naside.callout > :first-child { margin-top:0; } aside.callout > :last-child { margin-bottom:0; }\nfigure { margin:1.2em 0; }\nfigcaption { color:var(--muted); font-size:.86em; text-align:center; margin-top:.5em; }\ntable.geml-table { border-collapse:collapse; width:100%; font-size:.92em; }\ntable.geml-table th, table.geml-table td { border:1px solid var(--bd); padding:6px 12px; }\ntable.geml-table thead th { background:var(--code-bg); cursor:pointer; user-select:none; white-space:nowrap; }\ntable.geml-table thead th::after { content:\" \\2195\"; color:var(--muted); font-size:.8em; }\ntable.geml-table thead th.asc::after { content:\" \\2191\"; color:var(--accent); }\ntable.geml-table thead th.desc::after { content:\" \\2193\"; color:var(--accent); }\ntable.geml-table tbody tr:nth-child(2n) { background:#fafbfc; }\ntable.geml-table td.computed { color:#0a7c52; }\ntable.geml-table tfoot td { background:var(--code-bg); font-weight:600; border-top:2px solid var(--bd); }\n.table-tools { margin-bottom:6px; } .table-filter { width:240px; max-width:100%; padding:5px 9px; border:1px solid var(--bd); border-radius:7px; font-size:.85em; }\n/* A table wider than the column scrolls INSIDE its own box \u2014 the page itself\n must never scroll sideways. Only the table is in here: the filter box above\n and the caption below stay put instead of sliding out of view with it. */\n.table-scroll { overflow-x:auto; max-width:100%; }\n.table-figure details > summary { cursor:pointer; color:var(--muted); font-size:.86em; padding:4px 0; }\n.table-note { color:var(--muted); font-size:.82em; margin:6px 0 0; }\n.geml-chart { width:100%; height:auto; background:var(--bg); border:1px solid var(--bd); border-radius:8px; }\n.c-title { font-size:15px; font-weight:600; fill:var(--fg); }\n.c-grid { stroke:#eaecef; } .c-axis { stroke:#aab1b8; } .c-tick { font-size:11px; fill:var(--muted); } .c-legend { font-size:12px; fill:var(--fg); }\n.media { max-width:100%; border-radius:8px; }\n.diagram-src { color:var(--muted); } .render-error { color:#cf222e; }\n.math-block { overflow-x:auto; padding:.4em 0; }\nsup.fn a { font-size:.75em; }\n.geml-footer { max-width:860px; margin:0 auto; padding:16px 24px 40px; color:var(--muted); font-size:.82em; }\n.geml-footer code { font-size:.95em; }\n.code-graph { margin:1.4em 0; }\n/* The graph is the widest artifact on the page: let it break out of main's\n 860px reading column and take the viewport, centred, leaving the prose\n around it untouched. Negative inline margins, NOT transform \u2014 a transform\n would become the containing block for the fullscreen overlay below. */\n@media (min-width:900px) { .code-graph { margin-inline: calc((100% - min(96vw, 1600px)) / 2); } }\n.cg-mount { border:1px solid var(--bd); border-radius:8px; padding:10px 12px; background:var(--bg); }\n.cg-scroll { overflow:auto; min-height:60vh; max-height:84vh; }\n.cg-svg { display:block; }\n/* Fullscreen: one class drives the layout, whether the native Fullscreen API\n took (mount is in the top layer) or we fell back to a fixed overlay. The\n :fullscreen pseudo-class is deliberately NOT in these selectors \u2014 a browser\n that doesn't know it would drop the whole rule; the runtime keeps the class\n in sync with fullscreenchange instead. Toolbar/legend keep their height,\n the stage eats the rest. */\n.cg-mount.cg-full { position:fixed; inset:0; z-index:9999; margin:0; border:0; border-radius:0; padding:10px 14px; background:var(--bg); display:flex; flex-direction:column; }\n.cg-mount.cg-full > .cg-bar, .cg-mount.cg-full > .cg-legend, .cg-mount.cg-full > .cg-groups { flex:0 0 auto; }\n.cg-mount.cg-full .cg-stage { flex:1 1 auto; min-height:0; }\n.cg-mount.cg-full .cg-scroll { min-height:0; max-height:none; height:100%; }\n.cg-mount.cg-full .cg-src-body { max-height:none; }\n.cg-mount.cg-full .cg-frame { flex:1 1 auto; height:auto; }\n.cg-search-wrap { position:relative; display:inline-block; }\n.cg-search { font:12px/1.4 inherit; padding:2px 7px; border:1px solid var(--bd); border-radius:4px; background:var(--bg); color:var(--fg); min-width:13ch; }\n.cg-search-menu { position:absolute; z-index:30; top:calc(100% + 2px); left:0; min-width:24ch; max-width:52ch; max-height:52vh; overflow:auto; background:var(--bg); border:1px solid var(--bd); border-radius:6px; box-shadow:0 6px 20px rgba(0,0,0,.18); }\n.cg-search-row { display:block; width:100%; text-align:left; padding:4px 9px 4px 18px; border:0; background:none; color:var(--fg); cursor:pointer; font:12px/1.4 inherit; }\n.cg-search-row:hover { background:var(--bd); }\n.cg-search-count { position:sticky; top:0; padding:4px 9px; font-size:11px; opacity:.65; background:var(--bg); border-bottom:1px solid var(--bd); }\n.cg-search-grp { padding:6px 9px 2px; font-size:11px; font-weight:600; opacity:.7; border-top:1px solid var(--bd); }\n.cg-search-grp:first-of-type { border-top:0; }\n.cg-stage { display:flex; gap:10px; align-items:flex-start; }\n.cg-stage .cg-scroll { flex:1 1 auto; min-width:0; }\n.cg-src { flex:0 0 42%; max-width:46%; display:flex; flex-direction:column; border:1px solid var(--bd); border-radius:6px; overflow:hidden; background:var(--bg); }\n.cg-src-hd { display:flex; gap:8px; align-items:center; justify-content:space-between; padding:4px 8px; border-bottom:1px solid var(--bd); color:var(--muted); font:.76em ui-monospace,Consolas,monospace; word-break:break-all; }\n.cg-src-hd button { font:inherit; border:1px solid var(--bd); border-radius:5px; background:transparent; color:var(--muted); cursor:pointer; padding:0 6px; }\n.cg-src-body { margin:0; padding:8px 10px; overflow:auto; max-height:84vh; color:var(--fg); font:12px/1.5 ui-monospace,Consolas,monospace; white-space:pre; }\n.cg-src-note { color:var(--muted); font-style:italic; white-space:pre-wrap; }\n.cg-bar { display:flex; gap:8px; align-items:center; flex-wrap:wrap; font-size:.82em; color:var(--muted); margin-bottom:6px; }\n.cg-bar button { font:inherit; padding:1px 8px; border:1px solid var(--bd); border-radius:5px; background:transparent; cursor:pointer; }\n.cg-crumb .cg-seg { border:0; border-radius:0; padding:0; background:none; color:var(--accent); cursor:pointer; font:inherit; }\n.cg-crumb .cg-seg:hover { text-decoration:underline; }\n.cg-frame { display:block; width:100%; height:84vh; border:0; background:var(--bg); }\n.cg-flash { color:#b42318; }\n.cg-legend { display:flex; gap:14px; align-items:center; justify-content:space-between; flex-wrap:wrap; font-size:.75em; color:var(--muted); margin-top:6px; }\n.cg-upbtn { cursor:pointer; }\n.cg-upbtn circle { fill:#fff; stroke:#94a3b8; }\n.cg-upbtn text { font-size:11px; fill:#57606a; }\n.cg-upbtn:hover circle { stroke:var(--accent); stroke-width:1.6; }\n.cg-upbtn:hover text { fill:var(--accent); }\n.cg-uplink { fill:none; stroke:#94a3b8; stroke-dasharray:3 2.5; pointer-events:none; }\n.cg-groups { display:flex; flex-wrap:wrap; gap:4px 12px; margin-top:6px; font-size:.75em; color:var(--muted); }\n.cg-chip { display:inline-flex; align-items:center; gap:4px; }\n.cg-chip i { width:10px; height:10px; border-radius:2px; border:1px solid #94a3b8; display:inline-block; }\n.cg-note { font-size:.8em; color:#9a6700; }\n.cg-n rect { fill:#eef2f7; stroke:#94a3b8; }\n.cg-n text { font-size:12px; fill:var(--fg); font-family:ui-monospace,Consolas,monospace; }\n.cg-n { cursor:pointer; }\n.cg-n.root rect { fill:#dbeafe; stroke:#2563eb; stroke-width:2; }\n.cg-n.leaf { opacity:.45; }\n.cg-n.test rect { stroke-dasharray:3 2; }\n.cg-n.grp rect { stroke-width:1.8; }\n.cg-e { fill:none; stroke:#94a3b8; stroke-width:.9; }\n.cg-e.cand { stroke-dasharray:2 3; }\n.cg-e.back { stroke:#dc2626; stroke-dasharray:5 3; }\n.cg-e.http { stroke:#0891b2; stroke-width:1.5; stroke-dasharray:5 2; } /* cross-stack API link */\n.cg-e.soft { opacity:.55; }\n.cg-svg.hl .cg-n { opacity:.22; }\n.cg-svg.hl .cg-e { opacity:.1; }\n.cg-svg.hl .cg-n.hl { opacity:1; }\n.cg-svg.hl .cg-e.hl { opacity:1; stroke-width:1.6; }\n";
99
99
  export declare const JS = "\n(function () {\n function cmp(a, b) {\n var na = a.dataset.sort, nb = b.dataset.sort;\n if (na !== undefined && nb !== undefined) return parseFloat(na) - parseFloat(nb);\n return (a.textContent || \"\").localeCompare(b.textContent || \"\");\n }\n document.querySelectorAll(\"table.geml-table\").forEach(function (table) {\n var tbody = table.tBodies[0];\n if (!tbody) return;\n // Sort on header click.\n var ths = table.tHead ? table.tHead.rows[0].cells : [];\n Array.prototype.forEach.call(ths, function (th, col) {\n th.addEventListener(\"click\", function () {\n var dir = th.classList.contains(\"asc\") ? \"desc\" : \"asc\";\n Array.prototype.forEach.call(ths, function (h) { h.classList.remove(\"asc\", \"desc\"); });\n th.classList.add(dir);\n var rows = Array.prototype.slice.call(tbody.rows);\n rows.sort(function (r1, r2) {\n var c = cmp(r1.cells[col], r2.cells[col]);\n return dir === \"asc\" ? c : -c;\n });\n rows.forEach(function (r) { tbody.appendChild(r); });\n });\n });\n // Filter rows.\n var fig = table.closest(\".table-figure\");\n var input = fig ? fig.querySelector(\".table-filter\") : null;\n if (input) input.addEventListener(\"input\", function () {\n var q = input.value.toLowerCase();\n Array.prototype.forEach.call(tbody.rows, function (r) {\n r.style.display = (r.textContent || \"\").toLowerCase().indexOf(q) >= 0 ? \"\" : \"none\";\n });\n });\n });\n})();\n";
100
100
  export declare function codeGraphRuntime(root: {
101
101
  querySelectorAll(sel: string): ArrayLike<Element>;
package/dist/render.js CHANGED
@@ -671,13 +671,13 @@ export class RenderCtx {
671
671
  if (this.isCodemapDoc) {
672
672
  const summary = `${esc(id ? "#" + id : "table")} · ${allRows.length} rows (preview: first ${maxRows})`;
673
673
  return `<figure class="table-figure"${idAttr}><details><summary>${summary}</summary>${tools}` +
674
- `<table class="geml-table">${thead}<tbody>\n${bodyRows}\n</tbody>${tfoot}</table>${note}</details>${cap}</figure>`;
674
+ `<div class="table-scroll"><table class="geml-table">${thead}<tbody>\n${bodyRows}\n</tbody>${tfoot}</table></div>${note}</details>${cap}</figure>`;
675
675
  }
676
676
  return `<figure class="table-figure"${idAttr}>${tools}` +
677
- `<table class="geml-table">${thead}<tbody>\n${bodyRows}\n</tbody>${tfoot}</table>${note}${cap}</figure>`;
677
+ `<div class="table-scroll"><table class="geml-table">${thead}<tbody>\n${bodyRows}\n</tbody>${tfoot}</table></div>${note}${cap}</figure>`;
678
678
  }
679
679
  return `<figure class="table-figure"${idAttr}>${tools}` +
680
- `<table class="geml-table">${thead}<tbody>\n${bodyRows}\n</tbody>${tfoot}</table>${cap}</figure>`;
680
+ `<div class="table-scroll"><table class="geml-table">${thead}<tbody>\n${bodyRows}\n</tbody>${tfoot}</table></div>${cap}</figure>`;
681
681
  }
682
682
  }
683
683
  // ---------------------------------------------------------------------------
@@ -1302,6 +1302,10 @@ table.geml-table tbody tr:nth-child(2n) { background:#fafbfc; }
1302
1302
  table.geml-table td.computed { color:#0a7c52; }
1303
1303
  table.geml-table tfoot td { background:var(--code-bg); font-weight:600; border-top:2px solid var(--bd); }
1304
1304
  .table-tools { margin-bottom:6px; } .table-filter { width:240px; max-width:100%; padding:5px 9px; border:1px solid var(--bd); border-radius:7px; font-size:.85em; }
1305
+ /* A table wider than the column scrolls INSIDE its own box — the page itself
1306
+ must never scroll sideways. Only the table is in here: the filter box above
1307
+ and the caption below stay put instead of sliding out of view with it. */
1308
+ .table-scroll { overflow-x:auto; max-width:100%; }
1305
1309
  .table-figure details > summary { cursor:pointer; color:var(--muted); font-size:.86em; padding:4px 0; }
1306
1310
  .table-note { color:var(--muted); font-size:.82em; margin:6px 0 0; }
1307
1311
  .geml-chart { width:100%; height:auto; background:var(--bg); border:1px solid var(--bd); border-radius:8px; }
@@ -1314,9 +1318,26 @@ sup.fn a { font-size:.75em; }
1314
1318
  .geml-footer { max-width:860px; margin:0 auto; padding:16px 24px 40px; color:var(--muted); font-size:.82em; }
1315
1319
  .geml-footer code { font-size:.95em; }
1316
1320
  .code-graph { margin:1.4em 0; }
1321
+ /* The graph is the widest artifact on the page: let it break out of main's
1322
+ 860px reading column and take the viewport, centred, leaving the prose
1323
+ around it untouched. Negative inline margins, NOT transform — a transform
1324
+ would become the containing block for the fullscreen overlay below. */
1325
+ @media (min-width:900px) { .code-graph { margin-inline: calc((100% - min(96vw, 1600px)) / 2); } }
1317
1326
  .cg-mount { border:1px solid var(--bd); border-radius:8px; padding:10px 12px; background:var(--bg); }
1318
- .cg-scroll { overflow:auto; min-height:52vh; max-height:72vh; }
1327
+ .cg-scroll { overflow:auto; min-height:60vh; max-height:84vh; }
1319
1328
  .cg-svg { display:block; }
1329
+ /* Fullscreen: one class drives the layout, whether the native Fullscreen API
1330
+ took (mount is in the top layer) or we fell back to a fixed overlay. The
1331
+ :fullscreen pseudo-class is deliberately NOT in these selectors — a browser
1332
+ that doesn't know it would drop the whole rule; the runtime keeps the class
1333
+ in sync with fullscreenchange instead. Toolbar/legend keep their height,
1334
+ the stage eats the rest. */
1335
+ .cg-mount.cg-full { position:fixed; inset:0; z-index:9999; margin:0; border:0; border-radius:0; padding:10px 14px; background:var(--bg); display:flex; flex-direction:column; }
1336
+ .cg-mount.cg-full > .cg-bar, .cg-mount.cg-full > .cg-legend, .cg-mount.cg-full > .cg-groups { flex:0 0 auto; }
1337
+ .cg-mount.cg-full .cg-stage { flex:1 1 auto; min-height:0; }
1338
+ .cg-mount.cg-full .cg-scroll { min-height:0; max-height:none; height:100%; }
1339
+ .cg-mount.cg-full .cg-src-body { max-height:none; }
1340
+ .cg-mount.cg-full .cg-frame { flex:1 1 auto; height:auto; }
1320
1341
  .cg-search-wrap { position:relative; display:inline-block; }
1321
1342
  .cg-search { font:12px/1.4 inherit; padding:2px 7px; border:1px solid var(--bd); border-radius:4px; background:var(--bg); color:var(--fg); min-width:13ch; }
1322
1343
  .cg-search-menu { position:absolute; z-index:30; top:calc(100% + 2px); left:0; min-width:24ch; max-width:52ch; max-height:52vh; overflow:auto; background:var(--bg); border:1px solid var(--bd); border-radius:6px; box-shadow:0 6px 20px rgba(0,0,0,.18); }
@@ -1330,13 +1351,13 @@ sup.fn a { font-size:.75em; }
1330
1351
  .cg-src { flex:0 0 42%; max-width:46%; display:flex; flex-direction:column; border:1px solid var(--bd); border-radius:6px; overflow:hidden; background:var(--bg); }
1331
1352
  .cg-src-hd { display:flex; gap:8px; align-items:center; justify-content:space-between; padding:4px 8px; border-bottom:1px solid var(--bd); color:var(--muted); font:.76em ui-monospace,Consolas,monospace; word-break:break-all; }
1332
1353
  .cg-src-hd button { font:inherit; border:1px solid var(--bd); border-radius:5px; background:transparent; color:var(--muted); cursor:pointer; padding:0 6px; }
1333
- .cg-src-body { margin:0; padding:8px 10px; overflow:auto; max-height:72vh; color:var(--fg); font:12px/1.5 ui-monospace,Consolas,monospace; white-space:pre; }
1354
+ .cg-src-body { margin:0; padding:8px 10px; overflow:auto; max-height:84vh; color:var(--fg); font:12px/1.5 ui-monospace,Consolas,monospace; white-space:pre; }
1334
1355
  .cg-src-note { color:var(--muted); font-style:italic; white-space:pre-wrap; }
1335
1356
  .cg-bar { display:flex; gap:8px; align-items:center; flex-wrap:wrap; font-size:.82em; color:var(--muted); margin-bottom:6px; }
1336
1357
  .cg-bar button { font:inherit; padding:1px 8px; border:1px solid var(--bd); border-radius:5px; background:transparent; cursor:pointer; }
1337
1358
  .cg-crumb .cg-seg { border:0; border-radius:0; padding:0; background:none; color:var(--accent); cursor:pointer; font:inherit; }
1338
1359
  .cg-crumb .cg-seg:hover { text-decoration:underline; }
1339
- .cg-frame { display:block; width:100%; height:72vh; border:0; background:var(--bg); }
1360
+ .cg-frame { display:block; width:100%; height:84vh; border:0; background:var(--bg); }
1340
1361
  .cg-flash { color:#b42318; }
1341
1362
  .cg-legend { display:flex; gap:14px; align-items:center; justify-content:space-between; flex-wrap:wrap; font-size:.75em; color:var(--muted); margin-top:6px; }
1342
1363
  .cg-upbtn { cursor:pointer; }
@@ -1564,7 +1585,11 @@ export function codeGraphRuntime(root) {
1564
1585
  setData(data0.mode === "modules" && data0.mods && gpath && gpath.length ? deriveView(gpath) : homeData());
1565
1586
  // scale null = fit-to-width on first draw. Left-right is the default —
1566
1587
  // call flow reads with the text; the toggle persists per reader.
1567
- var state = { roots: data.roots.slice(), trail: [], scale: null, dir: "LR", frame: null, cap: 600, showAcc: false };
1588
+ // autoScale mirrors the last scale the VIEW picked for itself. While
1589
+ // state.scale still equals it the zoom is ours to re-derive (entering
1590
+ // fullscreen re-fits); once the user works the zoom buttons the two
1591
+ // diverge and their choice is left alone.
1592
+ var state = { roots: data.roots.slice(), trail: [], scale: null, autoScale: null, dir: "LR", frame: null, cap: 600, showAcc: false };
1568
1593
  // Direction survives module -> container navigation (each page is a fresh
1569
1594
  // document); best-effort only — file:// or the DOM stub may lack storage.
1570
1595
  try {
@@ -1573,6 +1598,103 @@ export function codeGraphRuntime(root) {
1573
1598
  state.dir = sd;
1574
1599
  }
1575
1600
  catch (e) { /* no storage */ }
1601
+ // Fullscreen — the graph takes the viewport instead of its 84vh slot in the
1602
+ // reading column. Native Fullscreen API when the browser grants it (browser
1603
+ // chrome goes away, Esc is handled for us); a fixed overlay when it does not
1604
+ // — a sandboxed iframe or a denied permission still gets the space. Both
1605
+ // paths flip the SAME .cg-full class, so the stylesheet and the pane
1606
+ // measurement never have to ask which one is in force.
1607
+ var fullBtnEl = null;
1608
+ function isFull() { return !!(mount.classList && mount.classList.contains("cg-full")); }
1609
+ // The canvas was scaled for the old pane; after the class flips it must be
1610
+ // refitted to the new one. draw() publishes the hook (drawFrame's iframe
1611
+ // needs none and clears it), and layout has to settle before the pane can
1612
+ // be measured — hence the frame delay.
1613
+ function refit() { var f = mount._cgRefit; if (typeof f === "function")
1614
+ f(); }
1615
+ function scheduleRefit() { if (typeof requestAnimationFrame === "function")
1616
+ requestAnimationFrame(refit);
1617
+ else
1618
+ refit(); }
1619
+ function syncFullBtn() {
1620
+ if (!fullBtnEl)
1621
+ return;
1622
+ fullBtnEl.textContent = isFull() ? "⛶ exit fullscreen" : "⛶ fullscreen";
1623
+ fullBtnEl.title = isFull() ? "leave fullscreen (Esc)" : "fill the viewport with the graph";
1624
+ }
1625
+ function setFull(on) {
1626
+ if (mount.classList && mount.classList.toggle)
1627
+ mount.classList.toggle("cg-full", !!on);
1628
+ syncFullBtn();
1629
+ scheduleRefit();
1630
+ }
1631
+ function nativeFullEl() {
1632
+ try {
1633
+ return document.fullscreenElement || document.webkitFullscreenElement || null;
1634
+ }
1635
+ catch (e) {
1636
+ return null;
1637
+ }
1638
+ }
1639
+ function toggleFull() {
1640
+ if (isFull()) {
1641
+ if (nativeFullEl() === mount) {
1642
+ var exit = document.exitFullscreen || document.webkitExitFullscreen;
1643
+ // The class follows in the fullscreenchange handler.
1644
+ if (exit) {
1645
+ try {
1646
+ exit.call(document);
1647
+ return;
1648
+ }
1649
+ catch (e) { /* fall through to the overlay */ }
1650
+ }
1651
+ }
1652
+ setFull(false);
1653
+ return;
1654
+ }
1655
+ var req = mount.requestFullscreen || mount.webkitRequestFullscreen;
1656
+ if (req) {
1657
+ try {
1658
+ var p = req.call(mount);
1659
+ // A rejected promise means permission denied (sandboxed frame,
1660
+ // permissions policy): take the overlay rather than nothing.
1661
+ if (p && typeof p.catch === "function")
1662
+ p.catch(function () { setFull(true); });
1663
+ setFull(true);
1664
+ return;
1665
+ }
1666
+ catch (e) { /* no native fullscreen — overlay below */ }
1667
+ }
1668
+ setFull(true);
1669
+ }
1670
+ // Esc leaves the OVERLAY fallback; native fullscreen handles its own Esc and
1671
+ // reports through fullscreenchange. The listeners are document-wide because
1672
+ // focus may sit on a node, the search box, or nothing at all.
1673
+ try {
1674
+ document.addEventListener("keydown", function (ev) { if (ev.key === "Escape" && isFull() && !nativeFullEl())
1675
+ setFull(false); });
1676
+ var syncFull = function () {
1677
+ var el = nativeFullEl();
1678
+ if (!el && isFull())
1679
+ setFull(false); // left via Esc / F11 / browser UI
1680
+ else if (el === mount && !isFull())
1681
+ setFull(true);
1682
+ else if (el === mount)
1683
+ scheduleRefit(); // class truthful, geometry still changed
1684
+ };
1685
+ document.addEventListener("fullscreenchange", syncFull);
1686
+ document.addEventListener("webkitfullscreenchange", syncFull);
1687
+ }
1688
+ catch (e) { /* no document listeners (fake-DOM runtime test) */ }
1689
+ // Built into whichever toolbar is drawing — the graph view and the nested
1690
+ // iframe view both get one.
1691
+ function fullBtn(bar) {
1692
+ var b = document.createElement("button");
1693
+ b.onclick = toggleFull;
1694
+ fullBtnEl = b;
1695
+ syncFullBtn();
1696
+ bar.appendChild(b);
1697
+ }
1576
1698
  function slice(roots) {
1577
1699
  var keep = {}, layer = {}, q = [], qi = 0, order = [];
1578
1700
  // Accessor noise (bean get/set/is leaves, .accessor) is hidden unless
@@ -1678,6 +1800,10 @@ export function codeGraphRuntime(root) {
1678
1800
  open.href = state.frame.html;
1679
1801
  open.textContent = "open standalone ↗";
1680
1802
  bar.appendChild(open);
1803
+ // The iframe is height:auto/flex in fullscreen, so it needs no refit —
1804
+ // drop the previous view's hook rather than leave a stale canvas one.
1805
+ mount._cgRefit = null;
1806
+ fullBtn(bar);
1681
1807
  mount.appendChild(bar);
1682
1808
  var fr = document.createElement("iframe");
1683
1809
  fr.className = "cg-frame";
@@ -2003,8 +2129,29 @@ export function codeGraphRuntime(root) {
2003
2129
  // every such string is filtered here first: keep it if it is a
2004
2130
  // document-RELATIVE path, drop it to "" if it carries a scheme
2005
2131
  // (`javascript:`, `data:`) or a `//host` network-path prefix.
2132
+ //
2133
+ // DECIDE ON THE STRING THE BROWSER WILL SEE, NOT THE ONE WE WERE HANDED.
2134
+ // Before navigating, a browser strips leading and trailing C0 controls and
2135
+ // spaces and ignores TAB/CR/LF anywhere inside the scheme. Testing the raw
2136
+ // bytes let the whole classic bypass family through — ` javascript:…`,
2137
+ // `\tjavascript:…`, `java<TAB>script:…`, `\x01javascript:…`,
2138
+ // ` data:text/html,…`, ` //evil.host/` — each of which fails the scheme
2139
+ // test as written and then executes once the browser normalises it.
2140
+ // Normalise first, judge second, and return the NORMALISED value: handing
2141
+ // the sink the raw string would give it bytes that were never checked.
2006
2142
  function relOnly(u) {
2007
- var s = String(u == null ? "" : u);
2143
+ var raw = String(u == null ? "" : u).replace(/[\t\n\r]/g, "");
2144
+ // Trim C0-and-space by scan, not by /^[\x00-\x20]+|[\x00-\x20]+$/ — an
2145
+ // end-anchored run is the very quadratic shape this file's fence
2146
+ // scanners were just cured of.
2147
+ var a = 0, b = raw.length;
2148
+ while (a < b && raw.charCodeAt(a) <= 0x20)
2149
+ a++;
2150
+ while (b > a && raw.charCodeAt(b - 1) <= 0x20)
2151
+ b--;
2152
+ var s = raw.slice(a, b);
2153
+ if (!s)
2154
+ return "";
2008
2155
  return /^[a-zA-Z][a-zA-Z0-9+.\-]*:/.test(s) || s.slice(0, 2) === "//" ? "" : s;
2009
2156
  }
2010
2157
  var navBase = relOnly(String(mount.getAttribute("data-src") || data.start || "").replace(/[^\/]*$/, ""));
@@ -2189,16 +2336,20 @@ export function codeGraphRuntime(root) {
2189
2336
  stage.className = "cg-stage";
2190
2337
  stage.appendChild(scroller);
2191
2338
  stage.appendChild(srcPanel);
2192
- // The scroll pane is capped at 72vh by CSS; before first layout its
2193
- // clientHeight is the unconstrained content height, so derive the cap
2194
- // from the viewport. Guards keep a collapsed pane (mid-layout measure)
2195
- // from producing a negative or zero scale invalid CSS would silently
2196
- // keep the previous size.
2339
+ // The scroll pane is capped at 84vh by CSS (keep the 0.84 here in step with
2340
+ // it); before first layout its clientHeight is the unconstrained content
2341
+ // height, so derive the cap from the viewport. In fullscreen the pane is a
2342
+ // flex child with a real measured height, so trust clientHeight there and
2343
+ // fall back to nearly the whole viewport. Guards keep a collapsed pane
2344
+ // (mid-layout measure) from producing a negative or zero scale — invalid
2345
+ // CSS would silently keep the previous size.
2197
2346
  function paneSize() {
2198
2347
  var mw = scroller.clientWidth || mount.clientWidth || 0;
2199
2348
  var mh = 0;
2200
2349
  try {
2201
- mh = Math.floor(window.innerHeight * 0.72);
2350
+ mh = isFull()
2351
+ ? Math.max(120, scroller.clientHeight || Math.floor(window.innerHeight * 0.92))
2352
+ : Math.floor(window.innerHeight * 0.84);
2202
2353
  }
2203
2354
  catch (e) { /* no window (stub) */ }
2204
2355
  return { w: mw, h: mh };
@@ -2241,6 +2392,20 @@ export function codeGraphRuntime(root) {
2241
2392
  zoomBtn("+", function () { state.scale = Math.min(4, state.scale / 0.75); });
2242
2393
  zoomBtn("fit", function () { state.scale = fitScale(); });
2243
2394
  zoomBtn("1:1", function () { state.scale = 1; });
2395
+ // Entering or leaving fullscreen resizes the pane under a canvas that was
2396
+ // scaled for the old one. Re-derive the SAME kind of scale the view opens
2397
+ // with (cross-axis fit, floored at 2/3 so text stays readable) — not
2398
+ // "fit", which would shrink a tall graph to a stamp exactly when the user
2399
+ // asked for more room. A hand-picked zoom is never overridden.
2400
+ // applyScale/initialScale are per-draw closures over THIS canvas, so
2401
+ // republish the hook on every draw.
2402
+ mount._cgRefit = function () {
2403
+ if (state.scale !== state.autoScale)
2404
+ return;
2405
+ state.autoScale = state.scale = initialScale();
2406
+ applyScale();
2407
+ };
2408
+ fullBtn(bar);
2244
2409
  var dirBtn = document.createElement("button");
2245
2410
  dirBtn.textContent = LR ? "top-down" : "left-right";
2246
2411
  dirBtn.onclick = function () {
@@ -2444,7 +2609,7 @@ export function codeGraphRuntime(root) {
2444
2609
  mount.appendChild(bar);
2445
2610
  mount.appendChild(stage);
2446
2611
  if (state.scale === null)
2447
- state.scale = initialScale();
2612
+ state.autoScale = state.scale = initialScale();
2448
2613
  applyScale();
2449
2614
  if (isUp) {
2450
2615
  // The focused method sits at the FAR end of the callers chain —
@@ -23,6 +23,10 @@ export type Selector = {
23
23
  type?: string;
24
24
  hex: string;
25
25
  nth: number;
26
+ } | {
27
+ form: "line";
28
+ from: number;
29
+ to: number;
26
30
  } | {
27
31
  form: "attr";
28
32
  type: string;
@@ -52,4 +56,7 @@ export declare function matchContent(sel: Extract<Selector, {
52
56
  form: "content";
53
57
  }>, all: Addressed[]): ContentHit;
54
58
  export declare function discoveryHint(where: string): string;
59
+ export declare function matchLine(sel: Extract<Selector, {
60
+ form: "line";
61
+ }>, all: Addressed[]): Unit | undefined;
55
62
  export declare function matchType(type: string, all: Addressed[]): Unit[];
package/dist/selector.js CHANGED
@@ -20,8 +20,15 @@ import { createHash } from "node:crypto";
20
20
  export function sha8(text) {
21
21
  return createHash("sha256").update(Buffer.from(text, "utf8")).digest("hex").slice(0, 8);
22
22
  }
23
- const FENCE_SEL = /^={3,}[ \t]*([A-Za-z][A-Za-z0-9_-]*)[ \t]*(@[0-9a-fA-F]{1,}(?:~\d+)?)?[ \t]*(\{.*\})?[ \t]*$/;
23
+ // Each optional part carries its OWN trailing whitespace. Written flat —
24
+ // `…[ \t]*(@…)?[ \t]*(\{.*\})?[ \t]*$` — three runs competed for the same tabs
25
+ // and the engine tried every way to divide them: `=== note` plus 8k tabs and
26
+ // one stray byte took 84 SECONDS. Nested, each absent part leaves exactly one
27
+ // run and the match is immediate. Same language: 150k random strings, identical
28
+ // groups. (Mirrors geml.ts's FENCE_OPEN, which had the same shape.)
29
+ const FENCE_SEL = /^={3,}[ \t]*([A-Za-z][A-Za-z0-9_-]*)[ \t]*(?:(@[0-9a-fA-F]{1,}(?:~\d+)?)[ \t]*)?(?:(\{.*\})[ \t]*)?$/;
24
30
  const BARE_AT = /^@([0-9a-fA-F]+)(?:~(\d+))?$/;
31
+ const BARE_LINE = /^[Ll](\d+)(?:-(\d+))?$/;
25
32
  // Parse selector TEXT. Never touches a document: every form is decided by
26
33
  // lexis alone, which is also what keeps the two selector namespaces on
27
34
  // `history get <file> <rev> <selector>` from overlapping (history design §10.2).
@@ -53,6 +60,19 @@ export function parseSelector(raw, attrsIdOf) {
53
60
  }
54
61
  return { form: "type", type };
55
62
  }
63
+ // Checked before the id fallthrough, so a bare `L27` is a position. An id
64
+ // that really is spelled `L27` keeps its explicit key form, `#L27` — the
65
+ // same "short form for the common case, key form always available" rule
66
+ // `@<hex>` already relies on.
67
+ const line = s.match(BARE_LINE);
68
+ if (line) {
69
+ const from = Number(line[1]);
70
+ const to = line[2] !== undefined ? Number(line[2]) : from;
71
+ // Reject rather than clamp: `L0` and `L10-5` are typos, and a selector that
72
+ // silently means something else is worse than one that refuses.
73
+ if (from >= 1 && to >= from)
74
+ return { form: "line", from, to };
75
+ }
56
76
  // Anything else is an id or a pasted heading line; the caller resolves it.
57
77
  return { form: "id", raw: s };
58
78
  }
@@ -104,6 +124,26 @@ export function matchContent(sel, all) {
104
124
  export function discoveryHint(where) {
105
125
  return ` — run \`geml get ${where}\` to list every addressable block`;
106
126
  }
127
+ // Match a position selector: the SMALLEST unit that fully contains the range.
128
+ // "Smallest" is what makes this ≤1 match instead of N. Spans nest — a heading's
129
+ // span covers its whole section, so line 30 of the comparison doc sits inside
130
+ // both `#capability-matrix` (L25-65) and `#caps` (L27-58) — and returning both
131
+ // would emit the inner block twice, once alone and once inside its section.
132
+ // The innermost is also the answer the question actually wants: a line number
133
+ // arrived from grep or a stack trace, and the caller means "the thing I have to
134
+ // edit", which is never the enclosing chapter.
135
+ export function matchLine(sel, all) {
136
+ let best;
137
+ for (const a of all) {
138
+ const start = a.unit.span.start + 1; // spans are 0-based; selectors are 1-based, as the listing prints them
139
+ const end = a.unit.span.end;
140
+ if (start > sel.from || end < sel.to)
141
+ continue; // must FULLY contain the range
142
+ if (best === undefined || end - start < best.span.end - (best.span.start + 1))
143
+ best = a.unit;
144
+ }
145
+ return best;
146
+ }
107
147
  // Match a type filter: every block of that type in document order. Blocks that
108
148
  // carry an id are INCLUDED — the selector says nothing about ids, so filtering
109
149
  // by whether one is present would be a rule nobody wrote down (§2).