vantage-md 0.7.1 → 0.8.1

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/index.cjs CHANGED
@@ -28,10 +28,6 @@ let remark_rehype = require("remark-rehype");
28
28
  remark_rehype = __toESM(remark_rehype, 1);
29
29
  let rehype_stringify = require("rehype-stringify");
30
30
  rehype_stringify = __toESM(rehype_stringify, 1);
31
- let remark_gfm = require("remark-gfm");
32
- remark_gfm = __toESM(remark_gfm, 1);
33
- let remark_math = require("remark-math");
34
- remark_math = __toESM(remark_math, 1);
35
31
  let rehype_raw = require("rehype-raw");
36
32
  rehype_raw = __toESM(rehype_raw, 1);
37
33
  let rehype_sanitize = require("rehype-sanitize");
@@ -43,6 +39,10 @@ rehype_katex = __toESM(rehype_katex, 1);
43
39
  let rehype_slug = require("rehype-slug");
44
40
  rehype_slug = __toESM(rehype_slug, 1);
45
41
  let unist_util_visit = require("unist-util-visit");
42
+ let remark_gfm = require("remark-gfm");
43
+ remark_gfm = __toESM(remark_gfm, 1);
44
+ let remark_math = require("remark-math");
45
+ remark_math = __toESM(remark_math, 1);
46
46
  let yaml = require("yaml");
47
47
  yaml = __toESM(yaml, 1);
48
48
  let smol_toml = require("smol-toml");
@@ -91,21 +91,21 @@ const BLOCK_TAGS = /* @__PURE__ */ new Set([
91
91
  "hr",
92
92
  "div"
93
93
  ]);
94
- function visit$1(node, offset) {
94
+ function visit$2(node, offset) {
95
95
  if ("children" in node) {
96
96
  for (const child of node.children) if (child.type === "element") {
97
97
  if (BLOCK_TAGS.has(child.tagName) && child.position?.start?.line) {
98
98
  child.properties = child.properties || {};
99
99
  child.properties["dataSourceLine"] = child.position.start.line + offset;
100
100
  }
101
- visit$1(child, offset);
101
+ visit$2(child, offset);
102
102
  }
103
103
  }
104
104
  }
105
105
  const rehypeSourceLines = (options) => {
106
106
  const offset = options?.offset ?? 0;
107
107
  return (tree) => {
108
- visit$1(tree, offset);
108
+ visit$2(tree, offset);
109
109
  };
110
110
  };
111
111
  //#endregion
@@ -158,6 +158,12 @@ const VANTAGE_ALERTS = [
158
158
  "warning",
159
159
  "caution"
160
160
  ];
161
+ /**
162
+ * The class on the title element this plugin injects, which
163
+ * `styles/directives.css` styles. Exported because the sanitizer runs after
164
+ * this plugin and admits a `div`'s class only when it is this one.
165
+ */
166
+ const ALERT_TITLE_CLASS = "vantage-alert-title";
161
167
  /** The visible label per kind. Title case, as GitHub renders it. */
162
168
  const ALERT_TITLES = {
163
169
  note: "Note",
@@ -181,7 +187,7 @@ const ALERT_TITLES = {
181
187
  * anchored test on the first text node is enough, and the plugin does not have
182
188
  * to reassemble the marker across siblings.
183
189
  */
184
- const MARKER = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\][ \t]*(?:\r?\n|$)/;
190
+ const ALERT_MARKER = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\][ \t]*(?:\r?\n|$)/;
185
191
  /** The first child, if it is an element. */
186
192
  function firstElement(node) {
187
193
  const child = node.children.find((c) => c.type === "element" || c.type === "text" && c.value.trim() !== "");
@@ -208,7 +214,7 @@ function rehypeVantageAlerts() {
208
214
  if (paragraph === void 0 || paragraph.tagName !== "p") return;
209
215
  const lead = paragraph.children[0];
210
216
  if (lead === void 0 || lead.type !== "text") return;
211
- const match = MARKER.exec(lead.value);
217
+ const match = ALERT_MARKER.exec(lead.value);
212
218
  if (match === null) return;
213
219
  const kind = match[1].toLowerCase();
214
220
  lead.value = lead.value.slice(match[0].length);
@@ -220,7 +226,7 @@ function rehypeVantageAlerts() {
220
226
  node.children.unshift({
221
227
  type: "element",
222
228
  tagName: "div",
223
- properties: { className: ["vantage-alert-title"] },
229
+ properties: { className: [ALERT_TITLE_CLASS] },
224
230
  children: [{
225
231
  type: "text",
226
232
  value: ALERT_TITLES[kind]
@@ -267,16 +273,78 @@ const VANTAGE_SENTINEL = "vantage:";
267
273
  *
268
274
  * Position picks the target; the name picks the extent. `section` before a
269
275
  * heading reaches the heading's whole section, `block` reaches one block, and
270
- * `oq` marks one answerable question. The name cannot disagree with position —
271
- * it only says how far the stamp reaches — so §4.2's refusal of a `scope=` key
272
- * stands.
276
+ * `question` declares one question in any state, as `oq`, the name it
277
+ * replaces, still does (`VANTAGE_QUESTION_NAMES`). The name cannot disagree
278
+ * with position — it only says how far the stamp reaches — so §4.2's refusal
279
+ * of a `scope=` key stands.
280
+ *
281
+ * `fallback` is the one name that stamps nothing: it withholds the block after
282
+ * it, which is written for the renderers that cannot show something Vantage
283
+ * shows — an inline `<svg>`, today. Every renderer that does not know the name
284
+ * drops the comment and shows the block, a 0.7.x Vantage included, which is
285
+ * the whole mechanism: the name is new, so no older reader gives it a meaning
286
+ * (`docs/reference/inline-markup.md`, "Fallback blocks").
273
287
  */
274
288
  const DIRECTIVE_NAMES = [
275
289
  "section",
276
290
  "block",
277
- "oq"
291
+ "oq",
292
+ "question",
293
+ "fallback"
278
294
  ];
279
295
  /**
296
+ * The two names that declare a question: `question`, the one to write, and
297
+ * `oq`, which it replaces and which every Vantage keeps reading.
298
+ *
299
+ * - **`question`** declares a question in any state: open (💬, 💬 🤷 or no
300
+ * marker at all), 🔒 blocked or ✅ answered. The state comes from the marker
301
+ * and never from the name, so a question that changes state changes its
302
+ * marker and nothing else. A viewer from 0.8 on offers Take this leaning on
303
+ * an open one, and never on a 🔒 or ✅ one.
304
+ * - **`oq`** is deprecated, never removed. It keeps the meaning it shipped
305
+ * with in 0.7, an open question to answer in one click, because documents
306
+ * outlive releases and a release never gives existing notation a new
307
+ * meaning (`docs/design/checker-version-skew.md`, P0). Every viewer before
308
+ * 0.8 offers the button on every `oq` it meets, whatever its marker says,
309
+ * so `vantage-check` reports one on a 🔒 or ✅ question as an error and any
310
+ * other as a warning that names the `question` to write.
311
+ *
312
+ * The two take the same keys, `id` and `leaning`, with one grammar. A viewer
313
+ * that predates `question` drops it whole, as it drops every name it does not
314
+ * know, so on a question written with it that viewer offers no button and no
315
+ * anchor: it does less, and misreads nothing. A run holding both names is one
316
+ * question, read as such a viewer reads it: where both set a key, the `oq`'s
317
+ * value wins.
318
+ */
319
+ const VANTAGE_QUESTION_NAMES = ["oq", "question"];
320
+ /** Whether `name` declares a question, `question` or the deprecated `oq`. */
321
+ function isQuestionDirective(name) {
322
+ return name === "oq" || name === "question";
323
+ }
324
+ /**
325
+ * The attribute every question carries in the rendered page, whichever name
326
+ * declared it, with the value `"true"`. It is how a reader of this package's
327
+ * markup finds every question: `[data-vantage-question]`
328
+ * (`VANTAGE_QUESTION_SELECTOR`).
329
+ */
330
+ const VANTAGE_QUESTION_ATTRIBUTE = "data-vantage-question";
331
+ /** `[data-vantage-question]`: every question in a rendered page. */
332
+ const VANTAGE_QUESTION_SELECTOR = `[${VANTAGE_QUESTION_ATTRIBUTE}]`;
333
+ /**
334
+ * The attribute a question declared with `oq` carries besides
335
+ * `VANTAGE_QUESTION_ATTRIBUTE`, with the value `"true"`. It means what it has
336
+ * meant since 0.7, "declared with `oq`", and nothing more: whether a question
337
+ * offers Take this leaning is read from its state (`questionOffersTake`),
338
+ * never from this attribute.
339
+ */
340
+ const VANTAGE_OQ_ATTRIBUTE = "data-vantage-oq";
341
+ /**
342
+ * The attribute that carries a question's `leaning=`, normalized
343
+ * (`normalizeLeaning`), on the element `VANTAGE_QUESTION_ATTRIBUTE` is on,
344
+ * whichever name declared it. Absent when the directive states none.
345
+ */
346
+ const VANTAGE_LEANING_ATTRIBUTE = "data-vantage-leaning";
347
+ /**
280
348
  * The `tone` vocabulary: GitHub's alert words plus `muted`.
281
349
  *
282
350
  * Semantic, never chromatic (P2, Ledger OQ-3). A document says what a section
@@ -368,13 +436,32 @@ const VANTAGE_STYLE_TARGETS = [
368
436
  "div"
369
437
  ];
370
438
  /**
371
- * The tags an `oq` directive may stamp — strictly the tags the review system
372
- * can resolve an anchor on (`ANCHOR_TAGS` in the app's `MarkdownViewer`, and the
373
- * block map in `useReviewHighlights`). `ul`, `ol`, `tr`, `hr` and `div` are in
374
- * neither, so a button on one of them would build an anchor no review pass can
375
- * find — the "mis-wired button" D6 forbids.
439
+ * The tags a `fallback` directive may **withhold**: `VANTAGE_STYLE_TARGETS`
440
+ * minus the six headings, written as a subtraction so the narrowing stays
441
+ * visible.
376
442
  *
377
- * The gap between this list and `VANTAGE_STYLE_TARGETS` is why an `oq`
443
+ * A heading is refused because it is the document's structure rather than a
444
+ * stand-in for anything: the contents column lists it, other documents link to
445
+ * its slug, and the planning index reads sections under it. A fallback heading
446
+ * would be an anchor that exists on GitHub and nowhere in Vantage. What a
447
+ * fallback holds — a sentence naming the release a drawing needs, a link to
448
+ * the drawing as a file, an ASCII sketch in a fence — is a paragraph, a list,
449
+ * a quote, a code block, a table or a `<div>`, and those are all here.
450
+ *
451
+ * Three callers read it, and they must agree: `rehypeVantageDirectives`, which
452
+ * withholds the block; the planning index's scan, which must not read a
453
+ * question or a link from a block the page never shows; and the checker's
454
+ * `vantage/orphan`, which reports a fallback that withholds nothing.
455
+ */
456
+ const VANTAGE_FALLBACK_TARGETS = VANTAGE_STYLE_TARGETS.filter((tag) => !/^h[1-6]$/.test(tag));
457
+ /**
458
+ * The tags a question directive, `oq` or `question`, may stamp — strictly the
459
+ * tags the review system can resolve an anchor on (`ANCHOR_TAGS` in the app's
460
+ * `MarkdownViewer`, and the block map in `useReviewHighlights`). `ul`, `ol`,
461
+ * `tr`, `hr` and `div` are in neither, so a button on one of them would build
462
+ * an anchor no review pass can find — the "mis-wired button" D6 forbids.
463
+ *
464
+ * The gap between this list and `VANTAGE_STYLE_TARGETS` is why a question
378
465
  * directive at column 0 above a list silently does nothing: the target is the
379
466
  * `<ul>`, not the `<li>`. The checker says so.
380
467
  */
@@ -392,9 +479,11 @@ const VANTAGE_ANCHOR_TARGETS = [
392
479
  "table"
393
480
  ];
394
481
  /**
395
- * The tags a `<!-- vantage: oq … -->` directive actually yields a *button* on —
396
- * `VANTAGE_ANCHOR_TARGETS` minus `pre` and `table`, written as an explicit
397
- * subtraction so the narrowing stays visible.
482
+ * The tags a question directive declares a question on, and so the only ones
483
+ * an open question's *button* can sit on — `VANTAGE_ANCHOR_TARGETS` minus `pre`
484
+ * and `table`, written as an explicit subtraction so the narrowing stays
485
+ * visible. One list for both names and every state, so a question that changes
486
+ * state is declared in exactly the same place.
398
487
  *
399
488
  * Anchorable and button-hosting are different questions, and this is the second
400
489
  * one. A comment *can* be anchored on a `<pre>` or a `<table>` — both are in
@@ -402,17 +491,18 @@ const VANTAGE_ANCHOR_TARGETS = [
402
491
  * button renders as part of the code, and a `<button>` child of `<table>` is not
403
492
  * valid HTML at all, so the parser hoists it out.
404
493
  *
405
- * Both consumers read it from here: `OQ_HOST_TAGS` in the app's
406
- * `useOpenQuestionButtons`, and the `oq` branch of the checker's
407
- * `vantage/orphan`. They were two hand-written lists that disagreed — the
408
- * checker called an `oq` above a fence fine while the app rendered no button
409
- * and said nothing, which is the D5 break this module exists to prevent.
494
+ * Every consumer reads it from here: `OQ_HOST_TAGS` in the app's
495
+ * `useOpenQuestionButtons`, the planning index's scan, and the question branch
496
+ * of the checker's `vantage/orphan`. They were two hand-written lists that
497
+ * disagreed — the checker called an `oq` above a fence fine while the app
498
+ * rendered no button and said nothing, which is the D5 break this module
499
+ * exists to prevent.
410
500
  */
411
501
  const VANTAGE_OQ_HOST_TARGETS = VANTAGE_ANCHOR_TARGETS.filter((tag) => tag !== "pre" && tag !== "table");
412
502
  /**
413
- * The shape of an `oq` directive's `id`: `OQ-` then an optional short uppercase
414
- * prefix then digits. `OQ-9`, `OQ-TP6` and `OQ-A03` are ids; `OQ-foo`, `OQ-tp6`
415
- * and a bare `OQ6` are not.
503
+ * The shape of a question directive's `id`, on `oq` and `question` alike:
504
+ * `OQ-` then an optional short uppercase prefix then digits. `OQ-9`, `OQ-TP6`
505
+ * and `OQ-A03` are ids; `OQ-foo`, `OQ-tp6` and a bare `OQ6` are not.
416
506
  *
417
507
  * The prefix is what keeps ids distinct once one document references another's
418
508
  * questions — `trust-paths.md`'s `OQ-4` and a design sketch's `OQ-4` are
@@ -420,10 +510,15 @@ const VANTAGE_OQ_HOST_TARGETS = VANTAGE_ANCHOR_TARGETS.filter((tag) => tag !== "
420
510
  * reference means. It is optional because most documents never leave their own
421
511
  * file, and requiring it everywhere would fire on every single-doc sketch.
422
512
  *
423
- * Three consumers read it from here and none of them may re-spell it: the
424
- * plugin that stamps the anchor, the sanitizer that allowlists the value, and
425
- * the checker's `vantage/oq-id-format`. A fourth copy is how the checker starts
426
- * calling a working anchor malformed.
513
+ * Read from here, never re-spelled, by the sanitizer that allowlists the value,
514
+ * by the checker's `collectOqIds` (which feeds `vantage/oq-id-format` and the
515
+ * anchors the checker accepts) and by the planning index. The directive plugin
516
+ * that stamps the anchor deliberately does not test it: it stamps any non-empty
517
+ * id and leaves refusing a malformed one to the sanitizer. A re-spelled copy is
518
+ * how the checker starts calling a working anchor malformed — and the patterns
519
+ * that look for an id in text (`OQ_REFERENCE` and `OQ_EXACT` in the checker's
520
+ * `rules/references.ts`, `OQ_TOKEN` in `planning/scan.ts` and in
521
+ * `FrontmatterDisplay.tsx`) restate this shape, so they change with it.
427
522
  */
428
523
  const VANTAGE_OQ_ID = /^OQ-(?:[A-Z][A-Z0-9]{0,5})?[0-9]+$/;
429
524
  /**
@@ -439,11 +534,12 @@ const VANTAGE_OQ_ID = /^OQ-(?:[A-Z][A-Z0-9]{0,5})?[0-9]+$/;
439
534
  * that a screen reader can say. They were private constants in the checker until
440
535
  * the second consumer arrived.
441
536
  *
442
- * `open` is the only state that wants a one-click answer. `settled` and
443
- * `blocked` deliberately carry no directive at all — a control offering to
444
- * answer a question that is already decided, or that cannot be answered yet, is
445
- * a lie — so the checker keys on the distinction rather than on the word
446
- * "Leaning:" alone. Either non-open marker wins when both appear on one item.
537
+ * `open` is the only state that wants a one-click answer, so it is the only one
538
+ * a viewer offers Take this leaning on (`questionOffersTake`). A `settled` or
539
+ * `blocked` question carries no control — a control offering to answer a
540
+ * question that is already decided, or that cannot be answered yet, is a lie.
541
+ * The checker keys on the distinction rather than on the word "Leaning:"
542
+ * alone. Either non-open marker wins when both appear on one item.
447
543
  */
448
544
  const VANTAGE_OQ_STATUS = {
449
545
  /** 💬 — an active decision awaiting a ruling. */
@@ -478,6 +574,20 @@ function vantageOqStatus(text) {
478
574
  if (text.includes(VANTAGE_OQ_STATUS.open)) return "open";
479
575
  return null;
480
576
  }
577
+ /**
578
+ * Whether a question in this state offers Take this leaning in review mode: an
579
+ * open one, marked 💬 or 💬 🤷, or one carrying no marker, which counts as
580
+ * open. A 🔒 question cannot be answered yet and a ✅ one has been ruled, so
581
+ * neither has a leaning left to take, whatever its directive's name.
582
+ *
583
+ * One home for the rule, because every surface that answers it must agree:
584
+ * the page's button and the planning card, the counts of questions waiting on
585
+ * a human, and `vantage-check`, which reports an `oq` on a question this says
586
+ * no to, since every viewer before 0.8 offers the button there anyway.
587
+ */
588
+ function questionOffersTake(status) {
589
+ return status === null || status === "open";
590
+ }
481
591
  const STYLE_KEYS = {
482
592
  tone: VANTAGE_TONES,
483
593
  emphasis: VANTAGE_EMPHASIS,
@@ -488,10 +598,21 @@ const STYLE_KEYS = {
488
598
  * Name → key → the closed value set for that key.
489
599
  *
490
600
  * `section` and `block` share their keys: they differ in *extent*, not in what
491
- * they can say. `oq`'s two keys are the design's only values with no closed set
492
- * — `id` is a token an author chose and `leaning` is a sentence (§8.3) — so
493
- * neither can be value-allowlisted, which is recorded here as `null` rather
494
- * than left to a caller to guess.
601
+ * they can say. The two question names share theirs too, and they are the
602
+ * design's only values with no closed set — `id` is a token an author chose
603
+ * and `leaning` is a sentence (§8.3) — so neither can be value-allowlisted,
604
+ * which is recorded here as `null` rather than left to a caller to guess.
605
+ *
606
+ * `question` takes exactly `oq`'s keys and nothing more. Starting narrow is
607
+ * the forward-compatible choice: a key added later is one older viewers drop
608
+ * pair by pair, while a key accepted now could never be given a meaning (P0).
609
+ * A `leaning` on a 🔒 or ✅ question is allowed and simply not offered
610
+ * (`questionOffersTake`), so changing a question's state changes only its
611
+ * marker.
612
+ *
613
+ * `fallback` takes no keys: whether a block is withheld is the whole of what
614
+ * it says, and a key added later is dropped pair by pair by this release, as
615
+ * D2 drops any other.
495
616
  */
496
617
  const DIRECTIVE_VOCABULARY = {
497
618
  section: STYLE_KEYS,
@@ -499,9 +620,59 @@ const DIRECTIVE_VOCABULARY = {
499
620
  oq: {
500
621
  id: null,
501
622
  leaning: null
502
- }
623
+ },
624
+ question: {
625
+ id: null,
626
+ leaning: null
627
+ },
628
+ fallback: {}
503
629
  };
504
630
  /**
631
+ * Merge the question directives of one run — the comments up to the block they
632
+ * land on — into the one question they declare, or `undefined` when the run
633
+ * holds none. Directives of any other name are skipped.
634
+ *
635
+ * A run holding an `oq` is read exactly as a viewer that predates `question`
636
+ * reads it: that viewer drops every `question` and reads the `oq` alone, so
637
+ * the `oq` wins wherever it appears in the run, and only its keys apply — a
638
+ * key only a `question` in the same run sets applies nowhere, in no release.
639
+ * That is the one reading every viewer agrees on (`VANTAGE_QUESTION_NAMES`),
640
+ * so the same bytes file the same Take in 0.7 and in 0.8. Within one name, the
641
+ * last value of a key wins, as `section` and `block` resolve theirs. A pair
642
+ * its name does not accept, or a value outside a closed set, is dropped (D2).
643
+ *
644
+ * One function for every reader of a run: the plugin that stamps the question
645
+ * and the planning index that reads it, which the checker reads a question
646
+ * through, so none can disagree about which leaning or which id a run carries
647
+ * (D5).
648
+ */
649
+ function mergeQuestionRun(run) {
650
+ let name;
651
+ for (const directive of run) {
652
+ if (!isQuestionDirective(directive.name)) continue;
653
+ if (name !== "oq") name = directive.name;
654
+ }
655
+ if (name === void 0) return void 0;
656
+ const accepted = DIRECTIVE_VOCABULARY[name] ?? {};
657
+ const keys = /* @__PURE__ */ new Map();
658
+ const sources = /* @__PURE__ */ new Map();
659
+ run.forEach((directive, index) => {
660
+ if (directive.name !== name) return;
661
+ for (const pair of directive.pairs) {
662
+ const values = accepted[pair.key];
663
+ if (values === void 0) continue;
664
+ if (values !== null && !values.includes(pair.value)) continue;
665
+ keys.set(pair.key, pair.value);
666
+ sources.set(pair.key, index);
667
+ }
668
+ });
669
+ return {
670
+ name,
671
+ keys,
672
+ sources
673
+ };
674
+ }
675
+ /**
505
676
  * `ws` is `[ \t\r\n]` — the design's grammar leaves it undefined, and `\n` has
506
677
  * to be in the set because a directive may legally wrap: a multi-line comment
507
678
  * is one node whose value contains the newlines.
@@ -617,10 +788,24 @@ function parseVantageDirective(comment) {
617
788
  pairs
618
789
  };
619
790
  }
791
+ /**
792
+ * A question directive's `leaning=` value as the page carries it: whitespace
793
+ * runs collapsed to one space, trimmed, and capped at `MAX_LEANING`.
794
+ *
795
+ * A wrapped directive puts newlines and indentation into the value. Two readers
796
+ * need the result and must agree on it: `rehypeVantageDirectives`, which stamps
797
+ * it as `data-vantage-leaning` for the one-click answer, and the planning index,
798
+ * which reports the leaning a question carries. An empty result means the
799
+ * question states no leaning.
800
+ */
801
+ function normalizeLeaning(raw) {
802
+ return raw.replace(/\s+/g, " ").trim().slice(0, 500);
803
+ }
620
804
  //#endregion
621
805
  //#region src/rehypeVantageDirectives.ts
622
806
  /**
623
- * What a `section`/`block` and an `oq` directive may **target**.
807
+ * What a `section`/`block` and a question directive (`oq`, `question`) may
808
+ * **target**.
624
809
  *
625
810
  * Both lists live in `vantageDirectives.ts`, with the reasoning for each tag,
626
811
  * because the CLI checker resolves the same question over mdast and must reach
@@ -630,6 +815,8 @@ function parseVantageDirective(comment) {
630
815
  */
631
816
  const STYLE_TARGET_TAGS = new Set(VANTAGE_STYLE_TARGETS);
632
817
  const ANCHOR_TARGET_TAGS = new Set(VANTAGE_ANCHOR_TARGETS);
818
+ /** What a `fallback` may withhold: every style target but a heading. */
819
+ const FALLBACK_TARGET_TAGS = new Set(VANTAGE_FALLBACK_TARGETS);
633
820
  const HEADING_DEPTHS = /* @__PURE__ */ new Map([
634
821
  ["h1", 1],
635
822
  ["h2", 2],
@@ -670,6 +857,19 @@ const RANGE_PROPERTIES = /* @__PURE__ */ new Map([["tone", "dataVantageTone"], [
670
857
  */
671
858
  const POINT_PROPERTIES = /* @__PURE__ */ new Map([["badge", "dataVantageBadge"]]);
672
859
  const RUN_PROPERTY = "dataVantageRun";
860
+ /**
861
+ * What a question stamps, whichever name declared it: `data-vantage-question`
862
+ * on every question, and `data-vantage-oq` besides on one an `oq` declared
863
+ * (`VANTAGE_QUESTION_ATTRIBUTE`, `VANTAGE_OQ_ATTRIBUTE`).
864
+ *
865
+ * Two attributes, and neither one ever means more than it says. Every release
866
+ * since 0.7 stamps `data-vantage-oq` on an `oq` and on nothing else, so a
867
+ * reader of this package's markup that has ever read it keeps reading exactly
868
+ * what it read; `data-vantage-question` is the one to read for "a question",
869
+ * and the question's state, from its marker, is what decides whether anything
870
+ * offers to answer it (`questionOffersTake`).
871
+ */
872
+ const QUESTION_PROPERTY = "dataVantageQuestion";
673
873
  const OQ_PROPERTY = "dataVantageOq";
674
874
  const LEANING_PROPERTY = "dataVantageLeaning";
675
875
  /**
@@ -704,8 +904,6 @@ const OQ_ID_PROPERTY = "dataVantageOqId";
704
904
  const COLLAPSED_PROPERTY = "dataVantageCollapsed";
705
905
  const COLLAPSE_GROUP_PROPERTY = "dataVantageCollapseGroup";
706
906
  const COLLAPSE_TOGGLE_PROPERTY = "dataVantageCollapseToggle";
707
- /** A review-comment body, not prose. Bounds the attribute; 500 is generous. */
708
- const MAX_LEANING = 500;
709
907
  /**
710
908
  * Nodes that may sit between a directive and its target.
711
909
  *
@@ -839,13 +1037,14 @@ function stampStyle(children, targetIndex, name, pairs, state) {
839
1037
  }
840
1038
  }
841
1039
  }
842
- function stampOq(target, pairs) {
843
- setProperty(target, OQ_PROPERTY, "true");
1040
+ function stampQuestion(target, name, pairs) {
1041
+ setProperty(target, QUESTION_PROPERTY, "true");
1042
+ if (name === "oq") setProperty(target, OQ_PROPERTY, "true");
844
1043
  const id = pairs.get("id");
845
1044
  if (id !== void 0 && id !== "") setProperty(target, OQ_ID_PROPERTY, id);
846
1045
  const leaning = pairs.get("leaning");
847
1046
  if (leaning === void 0) return;
848
- const text = leaning.replace(/\s+/g, " ").trim().slice(0, MAX_LEANING);
1047
+ const text = normalizeLeaning(leaning);
849
1048
  if (text !== "") setProperty(target, LEANING_PROPERTY, text);
850
1049
  }
851
1050
  /**
@@ -860,18 +1059,14 @@ function stampOq(target, pairs) {
860
1059
  function stampRun(children, targetIndex, run, state) {
861
1060
  const target = children[targetIndex];
862
1061
  const style = /* @__PURE__ */ new Map();
863
- const oq = /* @__PURE__ */ new Map();
864
1062
  let styleName;
865
- let hasOq = false;
866
1063
  for (const directive of run) if (directive.name === "section" || directive.name === "block") {
867
1064
  styleName = directive.name;
868
1065
  for (const pair of directive.pairs) style.set(pair.key, pair.value);
869
- } else if (directive.name === "oq") {
870
- hasOq = true;
871
- for (const pair of directive.pairs) oq.set(pair.key, pair.value);
872
1066
  }
873
1067
  if (styleName !== void 0) stampStyle(children, targetIndex, styleName, style, state);
874
- if (hasOq && ANCHOR_TARGET_TAGS.has(target.tagName)) stampOq(target, oq);
1068
+ const question = mergeQuestionRun(run);
1069
+ if (question !== void 0 && ANCHOR_TARGET_TAGS.has(target.tagName)) stampQuestion(target, question.name, question.keys);
875
1070
  }
876
1071
  /** A directive comment, or `undefined` for anything else — malformed included. */
877
1072
  function directiveOf(node) {
@@ -884,7 +1079,7 @@ function directiveOf(node) {
884
1079
  *
885
1080
  * The whole tree, not just the root: `rehype-raw` leaves comment nodes inside
886
1081
  * `blockquote`, inside `li`, inside `td` and inline inside `p`, and the real
887
- * Open Questions layout puts the `oq` directive inside a list item — so a
1082
+ * Open Questions layout puts the question directive inside a list item — so a
888
1083
  * root-only walk finds none of them.
889
1084
  *
890
1085
  * Pass order is also what resolves a nested section: an inner heading's
@@ -923,8 +1118,66 @@ function processChildren(parent, state) {
923
1118
  i = j;
924
1119
  }
925
1120
  }
1121
+ /**
1122
+ * Remove every block a `fallback` run lands on, with the run itself.
1123
+ *
1124
+ * A fallback block is written for the renderers that cannot show something
1125
+ * Vantage shows — "This drawing needs Vantage 0.8 or later", beside an inline
1126
+ * `<svg>`. Every renderer that does not know the name drops the comment and
1127
+ * shows the block; this one never renders it, in any mode, in print, or
1128
+ * through any of the renderers that share this plugin (D5).
1129
+ *
1130
+ * The run is found exactly as `processChildren` finds one — the same skip
1131
+ * rules, the same first-element target, the same whole-tree walk — and it
1132
+ * withholds when any directive in it is a `fallback` and the target is on
1133
+ * `VANTAGE_FALLBACK_TARGETS`. Anything else is left for the stamping pass,
1134
+ * where a `fallback` is inert.
1135
+ *
1136
+ * A pass of its own, and first, because a `section` reaches *forward* over its
1137
+ * siblings: stamped in one pass, a toned section would count a fallback block
1138
+ * after it among its members, give it the run's last `end` marker, and leave
1139
+ * the last block anyone sees marked `middle`. Withheld first, the block was
1140
+ * never there for any later decision — the section's extent, its run markers,
1141
+ * a collapse group's members, a review anchor or an outline entry.
1142
+ */
1143
+ function withholdFallbacks(parent) {
1144
+ const children = parent.children;
1145
+ let i = 0;
1146
+ while (i < children.length) {
1147
+ const node = children[i];
1148
+ if (node.type === "element") {
1149
+ withholdFallbacks(node);
1150
+ i++;
1151
+ continue;
1152
+ }
1153
+ const first = directiveOf(node);
1154
+ if (first === void 0) {
1155
+ i++;
1156
+ continue;
1157
+ }
1158
+ let withholds = first.name === "fallback";
1159
+ let j = i + 1;
1160
+ let targetIndex = -1;
1161
+ for (; j < children.length; j++) {
1162
+ const next = children[j];
1163
+ if (next.type === "element") {
1164
+ targetIndex = j;
1165
+ break;
1166
+ }
1167
+ if (!isSkippable(next)) break;
1168
+ if (directiveOf(next)?.name === "fallback") withholds = true;
1169
+ }
1170
+ const target = targetIndex >= 0 ? children[targetIndex] : void 0;
1171
+ if (withholds && target?.type === "element" && FALLBACK_TARGET_TAGS.has(target.tagName)) {
1172
+ children.splice(i, targetIndex - i + 1);
1173
+ continue;
1174
+ }
1175
+ i = j;
1176
+ }
1177
+ }
926
1178
  const rehypeVantageDirectives = () => {
927
1179
  return (tree) => {
1180
+ withholdFallbacks(tree);
928
1181
  processChildren(tree, { nextGroup: 1 });
929
1182
  };
930
1183
  };
@@ -1017,12 +1270,144 @@ const rehypeRestoreMathStamps = () => {
1017
1270
  };
1018
1271
  //#endregion
1019
1272
  //#region src/sanitize.ts
1273
+ /** Whether an attribute definition is for `className`. */
1274
+ const isClassName = (definition) => (typeof definition === "string" ? definition : definition[0]) === "className";
1275
+ /**
1276
+ * The value list the default schema gives `className` on `tag`, or an empty
1277
+ * list if it gives none.
1278
+ */
1279
+ function defaultClasses(tag) {
1280
+ const entry = rehype_sanitize.defaultSchema.attributes?.[tag]?.find(isClassName);
1281
+ return Array.isArray(entry) ? entry.slice(1) : [];
1282
+ }
1283
+ /**
1284
+ * The only classes an element keeps: the ones the pipeline itself emits on it
1285
+ * before `rehypeSanitize` runs. Every other element keeps none.
1286
+ *
1287
+ * **A class is a style by another name.** The app ships Tailwind's utility CSS
1288
+ * for its own markup, so `<div class="fixed inset-0 z-50 bg-white">` in a
1289
+ * document laid a white sheet over the whole Vantage window, header and
1290
+ * sidebar included. That is the overlay `SAFE_STYLE` bans `position` to
1291
+ * prevent, spelled as a class instead of a declaration, and `className` used
1292
+ * to be admitted with any value on every element. A document could also
1293
+ * borrow a class the app's own code looks for: `.overflow-y-auto` is how an
1294
+ * anchor link finds the element to scroll when no `data-content-scroll` is in
1295
+ * reach, and `.heading-anchor` is what the outline strips from a heading's
1296
+ * text. The comment-body sanitizer (`frontend/src/lib/commentMarkdown.ts`)
1297
+ * already refused `class` for the same reason.
1298
+ *
1299
+ * What each entry is for, and who emits it:
1300
+ *
1301
+ * - `code`: `language-*`, from a fence's info string (`mdast-util-to-hast`).
1302
+ * `rehype-highlight` reads it, `rehype-katex` keys on `language-math`, and
1303
+ * the viewers render `language-mermaid` as a diagram.
1304
+ * - `ul`, `ol` and `li`: `contains-task-list` and `task-list-item`, from GFM
1305
+ * task lists. `styles/task-list.css` draws the checkbox from them.
1306
+ * - `section`, `h2` and `a`: GFM footnotes (`mdast-util-to-hast`), on the
1307
+ * section, its label and each back-reference.
1308
+ * - `div`: `ALERT_TITLE_CLASS`, on the title `rehypeVantageAlerts` injects.
1309
+ *
1310
+ * `remark-math` also emits `math-display` and `math-inline` on its `code`.
1311
+ * They were never kept, because the default schema's `code` entry takes only
1312
+ * `language-*`, and nothing needs them: `rehype-katex` keys on `language-math`
1313
+ * and tells display from inline by whether the `code` sits in a `pre`.
1314
+ *
1315
+ * **A class added after the sanitizer needs no entry**, and that is most of
1316
+ * the classes the page styles: `rehype-highlight`'s `hljs-*`, KaTeX's output,
1317
+ * Mermaid's diagrams, and everything the app's components and hooks add. A
1318
+ * document that writes one of those on an element keeps nothing.
1319
+ *
1320
+ * **Mermaid source is the route this list cannot close.** Mermaid renders
1321
+ * after the sanitizer and copies the names a diagram's source gives a node
1322
+ * (`:::name`, `class A name`, `classDef`) onto that node, so a diagram can
1323
+ * carry any of the app's utilities on its own nodes. The diagram bounds it:
1324
+ * `position` does nothing on an SVG group, and the diagram's `svg` clips what
1325
+ * is inside it. `frontend/e2e/mermaid.spec.ts` measures that bound. The
1326
+ * stylesheet a diagram's source could write did reach past the diagram, and
1327
+ * `MERMAID_SECURE_KEYS` in `mermaidLoader.ts` refuses the settings that write
1328
+ * it.
1329
+ *
1330
+ * A document may still write these names on these elements. By the time the
1331
+ * sanitizer runs, a hand-written `<li class="task-list-item">` and the one GFM
1332
+ * emitted are the same node, and none of the names can lay an element over
1333
+ * anything outside the document's scroll container. `styles/task-list.css`
1334
+ * floats the checkbox of a `task-list-item` instead of positioning it
1335
+ * absolutely, so it stays in the document's flow whatever the item's own
1336
+ * `style` does, and the checkbox takes no `style` itself (see
1337
+ * `UNSTYLED_TAGS`). The footnote label's class, where a stylesheet defines it,
1338
+ * clips its element to a single pixel.
1339
+ *
1340
+ * **The footnote label's class is read from the default schema, never
1341
+ * spelled here, and the tests spell it in halves.** It is a Tailwind utility,
1342
+ * and Tailwind generates a utility for every class name it finds in the files
1343
+ * it scans, comments included — this directory and all of `frontend/`. The app
1344
+ * renders the label as a visible heading only because nothing it scans spells
1345
+ * that name. Spelled whole in a test, it generated the rule, and the label
1346
+ * would have been hidden from sight as it is on GitHub: a change of rendering,
1347
+ * not a sanitizer fix.
1348
+ *
1349
+ * The list is measured, not remembered. The test "takes no class from
1350
+ * anything the pipeline emits" in `frontend/src/lib/sanitize.test.ts` records
1351
+ * every class on the tree on both sides of `rehypeSanitize`, over a document
1352
+ * that uses every feature that emits one, so a plugin that starts emitting a
1353
+ * new class fails there instead of losing it in every renderer.
1354
+ */
1355
+ const PIPELINE_CLASSES = {
1356
+ code: [/^language-./],
1357
+ ul: ["contains-task-list"],
1358
+ ol: ["contains-task-list"],
1359
+ li: ["task-list-item"],
1360
+ section: ["footnotes"],
1361
+ h2: defaultClasses("h2"),
1362
+ a: ["data-footnote-backref"],
1363
+ div: [ALERT_TITLE_CLASS]
1364
+ };
1020
1365
  /**
1021
- * Sanitization schema for the rendering pipeline.
1022
- * Allows GFM, KaTeX MathML, syntax highlighting classes, and
1023
- * data-source-line attributes while blocking XSS vectors.
1366
+ * `tag`'s attributes: the default schema's, less any `className`, then its
1367
+ * `PIPELINE_CLASSES` entry, then `extra`.
1368
+ *
1369
+ * **Always a value list, never a bare `"className"`.** `hast-util-sanitize`
1370
+ * reads a bare name, or a tuple holding no values, as "any value", and an
1371
+ * element's own entry is consulted before `*`'s — the first match wins, and
1372
+ * `*` is not consulted when that element's own entry filters a class list down
1373
+ * to nothing. An element with no entry has none added.
1024
1374
  */
1025
- const DECLARATION = `(?:(?:${[
1375
+ function withClasses(tag, ...extra) {
1376
+ const classes = PIPELINE_CLASSES[tag] ?? [];
1377
+ const own = (rehype_sanitize.defaultSchema.attributes?.[tag] ?? []).filter((definition) => !isClassName(definition));
1378
+ if (classes.length > 0) own.push(["className", ...classes]);
1379
+ return [...own, ...extra];
1380
+ }
1381
+ /**
1382
+ * CSS properties an inline `style` may set.
1383
+ *
1384
+ * **The only `style` this list ever filters is one a document wrote by hand.**
1385
+ * Nothing the pipeline generates reaches it: `rehypeKatex` and `rehypeHighlight`
1386
+ * both run *after* `rehypeSanitize` (`pipeline.ts`), so their output is trusted
1387
+ * rather than filtered, and `remark-gfm` emits table alignment as an `align`
1388
+ * attribute rather than as CSS. So this is a filter on untrusted author HTML and
1389
+ * nothing else — which is exactly the hole that made it necessary: `<div
1390
+ * style="position:fixed;inset:0">` covered the viewport and
1391
+ * `style="background:url(https://…)"` called home on render, both verbatim,
1392
+ * because `rehype-sanitize` does not parse CSS. Scripts were never the risk
1393
+ * here; layout and network were.
1394
+ *
1395
+ * The list is therefore deliberately typographic: the styling a prose document
1396
+ * has any business asking for. It is *not* sized to KaTeX, and a KaTeX release
1397
+ * that starts using a new property is a non-event here — `\pmb` already emits
1398
+ * `text-shadow`, which is not on this list and renders anyway.
1399
+ *
1400
+ * **The design doc used to argue the opposite — that `style` had to be allowed
1401
+ * and `position` enumerated because KaTeX needs them — and it was wrong.** The
1402
+ * measurement behind it was real (KaTeX does emit `position:relative` on every
1403
+ * integral) but the inference was not, because the sanitizer has finished before
1404
+ * the first KaTeX span exists. Rebuilding the shipped rehype order with a filter
1405
+ * that rejects *every* value leaves all ten of the integral's style attributes
1406
+ * untouched. The "Security" section of `docs/reference/inline-markup.md`
1407
+ * records the correction; the test that would catch a reordering is in
1408
+ * `frontend/src/lib/sanitize.test.ts`.
1409
+ */
1410
+ const SAFE_STYLE_PROPERTIES = [
1026
1411
  "height",
1027
1412
  "min-height",
1028
1413
  "max-height",
@@ -1077,12 +1462,89 @@ const DECLARATION = `(?:(?:${[
1077
1462
  "white-space",
1078
1463
  "vertical-align",
1079
1464
  "list-style-type",
1080
- "display",
1081
1465
  "float",
1082
1466
  "clear",
1083
1467
  "opacity",
1084
1468
  "overflow"
1085
- ].join("|")})\\s*:[^;:()"'\\\\]*)`;
1469
+ ];
1470
+ /**
1471
+ * A `style` value we will keep, as a whole.
1472
+ *
1473
+ * One rule beyond the property list does the work: **no parentheses anywhere**,
1474
+ * which closes `url(…)` and `expression(…)` in one stroke — the network and
1475
+ * legacy-script vectors. Its cost is borne entirely by authors, who lose
1476
+ * `calc()`, `rgb()` and `var()` along with them; that is the trade, and it is
1477
+ * worth it for a filter this small.
1478
+ *
1479
+ * **`position` is not on the property list at all**, so every value of it is
1480
+ * refused — `static` and `relative` along with `fixed` and `sticky`. It used to
1481
+ * be enumerated, on the belief that KaTeX needed `relative`; KaTeX renders after
1482
+ * the sanitizer and never meets this regex, so the enumeration was buying
1483
+ * nothing but the residual it conceded. Banning the property closes that
1484
+ * residual, and it is bigger than "overlaps its neighbors" made it sound:
1485
+ * measured in Chrome against the viewer's real ancestor chain, an author's
1486
+ * `position:absolute;top:0;left:0;width:100%;height:100%` is sized to the whole
1487
+ * content pane (the nearest positioned ancestor is outside the scroll
1488
+ * container), is not clipped by the scroller, and survives scrolling to the end
1489
+ * of the document. It was `position:fixed` in all but the keyword.
1490
+ *
1491
+ * Matching is all-or-nothing: one unrecognized declaration drops the whole
1492
+ * attribute, and the element renders unstyled rather than partly styled. That
1493
+ * is the safe direction to fail, and it degrades to plain text rather than to a
1494
+ * broken page.
1495
+ *
1496
+ * **The grammar must stay unambiguous, and `;` is what keeps it so.** The value
1497
+ * class is "anything but the delimiters", which includes whitespace — a value
1498
+ * legitimately contains it (`margin: 0 auto`). So if whitespace could *also*
1499
+ * end a declaration, both constructs would compete for the same characters and
1500
+ * the match would fork at every declaration; on a value that ultimately fails,
1501
+ * the engine explores every fork. An earlier form of this regex separated
1502
+ * declarations with `\s*;?\s*`, and 200 document-controlled characters took the
1503
+ * renderer — and the CLI checker, and therefore CI — 94 seconds. Requiring `;`
1504
+ * pins each declaration's extent to the delimiter positions, so there is exactly
1505
+ * one way to parse any input and rejection is linear. `VALUE` absorbs the
1506
+ * padding on both sides for the same reason: a separate `\\s*` next to it would
1507
+ * put the ambiguity straight back. Pinned by the flat-time test in
1508
+ * `frontend/src/lib/sanitize.test.ts` — do not loosen the separator.
1509
+ *
1510
+ * Residual, stated plainly and now genuinely small: negative `margin` still lets
1511
+ * an element overlap its neighbors *inside the flow*. That one scrolls with the
1512
+ * content and is clipped by the scroll container, and closing it means giving up
1513
+ * margins, which prose actually uses. Containment in the stylesheet, not another
1514
+ * rule here, is what would close it. `overflow: visible` on an inline `<svg>` is
1515
+ * the same residual by another route — see `SVG_ROOT_ATTRIBUTES`.
1516
+ *
1517
+ * **The app's table of contents is inside that scroll container too**, beside
1518
+ * the document column, so a block carried over it took its clicks whenever the
1519
+ * block painted in the positioned layer: one with `opacity`, a toned block, a
1520
+ * heading, or a link to anywhere. The contents sit at `z-10` for that
1521
+ * (`frontend/src/components/TableOfContents.tsx`), and a document cannot climb
1522
+ * back over them only because `z-index` is not on the property list.
1523
+ */
1524
+ const VALUE = `[^;:()"'\\\\]*`;
1525
+ const DISPLAY_DECLARATION = `display\\s*:\\s*(?:${[
1526
+ "none",
1527
+ "block",
1528
+ "inline-block",
1529
+ "inline-flex",
1530
+ "inline-grid",
1531
+ "inline-table",
1532
+ "inline",
1533
+ "flow-root",
1534
+ "flex",
1535
+ "grid",
1536
+ "table-row-group",
1537
+ "table-row",
1538
+ "table-header-group",
1539
+ "table-footer-group",
1540
+ "table-cell",
1541
+ "table-column-group",
1542
+ "table-column",
1543
+ "table-caption",
1544
+ "table",
1545
+ "list-item"
1546
+ ].join("|")})\\s*(?:!\\s*important\\s*)?`;
1547
+ const DECLARATION = `(?:(?:${SAFE_STYLE_PROPERTIES.join("|")})\\s*:${VALUE}|${DISPLAY_DECLARATION})`;
1086
1548
  const SAFE_STYLE = new RegExp(`^\\s*(?:${DECLARATION};\\s*)*${DECLARATION}?$`, "i");
1087
1549
  /**
1088
1550
  * A collapse group id: one or more digits, anchored.
@@ -1094,6 +1556,281 @@ const SAFE_STYLE = new RegExp(`^\\s*(?:${DECLARATION};\\s*)*${DECLARATION}?$`, "
1094
1556
  */
1095
1557
  const COLLAPSE_GROUP_ID = /^[0-9]+$/;
1096
1558
  /**
1559
+ * Inline SVG, admitted as static drawing and nothing else.
1560
+ *
1561
+ * The allowlist is shapes, text and grouping. Everything in SVG that can run
1562
+ * code, fetch, or reach outside the element is refused: `script`,
1563
+ * `foreignObject`, `use` and `image` (both take an `href`), `xlink:href` on
1564
+ * `a`, the `animate`/`set` family (which can rewrite an `href` after the
1565
+ * sanitizer has looked), `style` elements, and every attribute that takes a
1566
+ * `url(…)` reference — `filter`, `mask`, `clip-path`, `marker-*`, `cursor`.
1567
+ *
1568
+ * Gradients, patterns and `<defs>` are out for a second reason: they are only
1569
+ * reachable through `url(#id)`, and the sanitizer prefixes every `id` with
1570
+ * `user-content-`, so the reference would dangle even if it were allowed.
1571
+ *
1572
+ * **Refusing an element is not the same as removing it.** `hast-util-sanitize`
1573
+ * *unwraps* an element it does not allow — drops the tag and keeps its
1574
+ * children — unless the tag is on `strip`. See `SVG_STRIPPED` for why that
1575
+ * matters here, and for the pass that removes those containers instead.
1576
+ *
1577
+ * `switch` is admitted, as a plain container, because draw.io wraps every label
1578
+ * in one: `<switch><foreignObject>…</foreignObject><text>…</text></switch>`. A
1579
+ * `switch` renders only its first child whose conditions hold, and with the
1580
+ * `foreignObject` removed and the conditional attributes (`requiredFeatures`,
1581
+ * `systemLanguage`) refused, that is the `<text>` fallback. Unwrapped instead,
1582
+ * it would render *every* branch — including the "Text is not SVG - cannot
1583
+ * display" notice draw.io appends for viewers without `foreignObject`.
1584
+ *
1585
+ * Every child requires an `svg` ancestor, so no shape or text element is
1586
+ * admitted into HTML flow on its own.
1587
+ *
1588
+ * `title` and `desc` are not children at all. They are admitted nowhere, inside
1589
+ * a drawing they are removed with their contents (see `SVG_STRIPPED`), and
1590
+ * `aria-label` on the root is the drawing's accessible name. Both are HTML
1591
+ * integration points — the parser reads what is inside them as HTML — and an
1592
+ * `svg` ancestor turned out not to be enough to keep a `<title>` in SVG
1593
+ * context. `<math><svg><title>` keeps MathML context, and React hoists any
1594
+ * `title` outside SVG context into the page's `<head>`, so the document set the
1595
+ * tab title. In `renderMarkdown`'s string output, `<svg><desc><title>`,
1596
+ * `<svg><title><title>` and a `title` inside a `foreignObject` all re-parse into
1597
+ * an HTML `title`, which is what `document.title` reads on a page that has none
1598
+ * of its own.
1599
+ */
1600
+ const SVG_CHILD_TAGS = [
1601
+ "g",
1602
+ "path",
1603
+ "rect",
1604
+ "circle",
1605
+ "ellipse",
1606
+ "line",
1607
+ "polyline",
1608
+ "polygon",
1609
+ "text",
1610
+ "tspan",
1611
+ "switch"
1612
+ ];
1613
+ /**
1614
+ * Refused SVG elements removed *with their contents* when they sit inside an
1615
+ * `<svg>`, rather than unwrapped.
1616
+ *
1617
+ * Each of these holds children that are never meant to be painted where they
1618
+ * stand: a clip region, a mask, a gradient's stops, a marker's arrowhead, a
1619
+ * symbol's body, a filter's primitives, an exporter's RDF, the text of a
1620
+ * `title` or `desc` (see `SVG_CHILD_TAGS`), or — for `foreignObject` — HTML.
1621
+ * Unwrapped, those children land in the drawing as ordinary shapes. A default
1622
+ * Figma export ends in `<defs><clipPath><rect fill="white"/>`, and unwrapping
1623
+ * it painted that white rect over the whole drawing; a `<marker>` arrowhead
1624
+ * became a stray triangle at the origin. The HTML inside a `foreignObject`
1625
+ * survived as descendants of `svg`, which React then created as invisible
1626
+ * SVG-namespace `div`s and `p`s — the anchor an Open Question button looks for
1627
+ * — and which a browser re-parsing `renderMarkdown`'s string output breaks out
1628
+ * of the `svg` at, spilling the rest of the drawing into the page.
1629
+ *
1630
+ * **Only inside an `<svg>`, which is why this is a pass and not the schema's
1631
+ * `strip`.** `strip` removes a tag wherever it stands, and most of these names
1632
+ * are lowercase in prose too. There a bare `<pattern>` on a line of its own is
1633
+ * an HTML element nothing closes, so every Markdown block after it was parsed
1634
+ * into it, and stripping it removed the rest of the document — in every
1635
+ * renderer, with `vantage-check` silent. Inside a drawing the same container is
1636
+ * bounded by the `svg`, because a Markdown paragraph or heading breaks out of
1637
+ * SVG. Outside one the sanitizer unwraps these like any tag it does not know:
1638
+ * `title` and `desc` are not admitted anywhere, so no `title` element survives
1639
+ * to be hoisted, and what they held is text or ordinary sanitized HTML.
1640
+ *
1641
+ * Names compare case-insensitively. The camel-cased ones are how the HTML
1642
+ * parser spells these elements in SVG, but under `<math><svg>` the parser stays
1643
+ * in MathML and leaves them lowercase.
1644
+ */
1645
+ const SVG_STRIPPED = [
1646
+ "foreignObject",
1647
+ "defs",
1648
+ "clipPath",
1649
+ "mask",
1650
+ "pattern",
1651
+ "marker",
1652
+ "symbol",
1653
+ "linearGradient",
1654
+ "radialGradient",
1655
+ "filter",
1656
+ "metadata",
1657
+ "title",
1658
+ "desc"
1659
+ ];
1660
+ const SVG_STRIPPED_NAMES = new Set(SVG_STRIPPED.map((name) => name.toLowerCase()));
1661
+ /**
1662
+ * The camel-cased `SVG_STRIPPED` names, which the schema strips on its own.
1663
+ *
1664
+ * Only an SVG-namespace element carries one of these spellings, so stripping
1665
+ * them everywhere costs prose nothing. It keeps the worst of the list — the
1666
+ * HTML inside a `foreignObject` — out of a drawing for a consumer who hands
1667
+ * `sanitizeSchema` to `rehype-sanitize` without the rest of `buildPipeline`.
1668
+ */
1669
+ const SVG_STRIPPED_EVERYWHERE = SVG_STRIPPED.filter((name) => name !== name.toLowerCase());
1670
+ function stripSvgContainers(parent, inSvg) {
1671
+ if (inSvg) parent.children = parent.children.filter((child) => child.type !== "element" || !SVG_STRIPPED_NAMES.has(child.tagName.toLowerCase()));
1672
+ for (const child of parent.children) if (child.type === "element") stripSvgContainers(child, inSvg || child.tagName.toLowerCase() === "svg");
1673
+ }
1674
+ /**
1675
+ * Remove every `SVG_STRIPPED` element below an `svg`, with its contents.
1676
+ *
1677
+ * Runs immediately before `rehypeSanitize` (`pipeline.ts`), and only when the
1678
+ * pipeline sanitizes: with the sanitizer off these elements are left to work
1679
+ * as SVG defines them.
1680
+ */
1681
+ function rehypeStripSvgContainers() {
1682
+ return (tree) => {
1683
+ stripSvgContainers(tree, false);
1684
+ };
1685
+ }
1686
+ /**
1687
+ * A paint value: a keyword, a named color, or a hex color. No parentheses, for
1688
+ * the same reason `SAFE_STYLE` refuses them — `fill="url(https://…)"` is a
1689
+ * render-time fetch, and `rgb()` is the price of closing it.
1690
+ */
1691
+ const SVG_PAINT = /^(?:#[0-9a-f]{3,8}|[a-z]+)$/i;
1692
+ /**
1693
+ * Attributes any SVG element may carry, drawing and typography alike.
1694
+ *
1695
+ * **Each entry is a hast property name, which is not always the camel case of
1696
+ * the attribute.** `property-information` decides the spelling, and it has
1697
+ * `strokeLineCap` for `stroke-linecap` — a naive `strokeLinecap` matches
1698
+ * nothing, and the attribute is dropped in every renderer without a word. The
1699
+ * table-driven test in `frontend/src/lib/sanitize.test.ts` renders every entry
1700
+ * in its written spelling, so a misspelled one fails there.
1701
+ *
1702
+ * `stroke-dasharray` (and with it `stroke-dashoffset`, which does nothing
1703
+ * alone) is refused, because it lets a few bytes buy an unbounded paint. The
1704
+ * number of dashes is the path's length over the dash period, both in user
1705
+ * units the document chooses. Measured in headless Chromium, fifty 90-byte
1706
+ * paths with a 0.0011 dash took 21 s to paint and a hundred took 37 s. A value
1707
+ * grammar cannot bound it: refusing sub-unit dashes is undone by scaling the
1708
+ * user units, and the same fifty paths with `stroke-dasharray="1.1"` under a
1709
+ * `viewBox` a thousand times larger still took 15 s.
1710
+ */
1711
+ const SVG_ATTRIBUTES = [
1712
+ ["fill", SVG_PAINT],
1713
+ ["stroke", SVG_PAINT],
1714
+ "fillOpacity",
1715
+ "fillRule",
1716
+ "strokeOpacity",
1717
+ "strokeWidth",
1718
+ "strokeLineCap",
1719
+ "strokeLineJoin",
1720
+ "opacity",
1721
+ "transform",
1722
+ "fontFamily",
1723
+ "fontSize",
1724
+ "fontWeight",
1725
+ "fontStyle",
1726
+ "letterSpacing",
1727
+ "textAnchor",
1728
+ "dominantBaseline",
1729
+ "x",
1730
+ "y",
1731
+ "x1",
1732
+ "y1",
1733
+ "x2",
1734
+ "y2",
1735
+ "cx",
1736
+ "cy",
1737
+ "r",
1738
+ "rx",
1739
+ "ry",
1740
+ "dx",
1741
+ "dy",
1742
+ "d",
1743
+ "points"
1744
+ ];
1745
+ /**
1746
+ * The root `<svg>`'s attributes: `SVG_ATTRIBUTES` less `transform`, plus the
1747
+ * viewport, a `role` from a closed list, and the accessible name.
1748
+ *
1749
+ * `transform` on the root is not an SVG transform at all. The root is an
1750
+ * in-flow CSS box, and the attribute becomes a CSS transform of that box, so
1751
+ * `<svg width="10" height="10" transform="translate(-300 -300) scale(80)">`
1752
+ * laid out as a 10px box and painted as an 800px one over the paragraphs
1753
+ * around it. On a child it moves shapes inside the viewport, which clips them.
1754
+ *
1755
+ * Residual: that clip is the root's `overflow`, which `SAFE_STYLE` lets a
1756
+ * document set. `style="overflow:visible"` on the root lets a child with a
1757
+ * large `transform` or large coordinates paint over its neighbors — measured in
1758
+ * Chromium, the same overlap. It is the negative-`margin` residual described at
1759
+ * `SAFE_STYLE` by another route: it stays in the flow, scrolls with the content,
1760
+ * and the scroll container clips it.
1761
+ */
1762
+ const SVG_ROOT_ATTRIBUTES = [
1763
+ ...SVG_ATTRIBUTES.filter((attribute) => attribute !== "transform"),
1764
+ "xmlns",
1765
+ "viewBox",
1766
+ "preserveAspectRatio",
1767
+ [
1768
+ "role",
1769
+ "img",
1770
+ "presentation",
1771
+ "none"
1772
+ ],
1773
+ "ariaLabel"
1774
+ ];
1775
+ /** Every element the sanitizer admits. */
1776
+ const TAG_NAMES = [
1777
+ ...rehype_sanitize.defaultSchema.tagNames || [],
1778
+ "math",
1779
+ "semantics",
1780
+ "mrow",
1781
+ "mi",
1782
+ "mo",
1783
+ "mn",
1784
+ "msup",
1785
+ "msub",
1786
+ "mfrac",
1787
+ "mover",
1788
+ "munder",
1789
+ "msqrt",
1790
+ "mroot",
1791
+ "mtable",
1792
+ "mtr",
1793
+ "mtd",
1794
+ "mtext",
1795
+ "mspace",
1796
+ "annotation",
1797
+ "figure",
1798
+ "figcaption",
1799
+ "summary",
1800
+ "details",
1801
+ "svg",
1802
+ ...SVG_CHILD_TAGS
1803
+ ];
1804
+ /**
1805
+ * The admitted elements that take no `style`: only `input`, which GFM emits as
1806
+ * a task list's checkbox, and never with a style.
1807
+ *
1808
+ * `styles/task-list.css` draws that checkbox and positions it, so on it `top`
1809
+ * and `left` are not the no-ops they are on an element nothing positions, and
1810
+ * with `width`, `height` and `background-color` beside them a document laid a
1811
+ * box of its own over the page. Measured in Chromium at 1280x800 while the
1812
+ * checkbox was positioned absolutely,
1813
+ * `top:-3000px;left:-3000px;width:9000px;height:9000px;background-color:white`
1814
+ * on it covered the whole content pane. It needed no class written:
1815
+ * GFM puts `task-list-item` on the item, and a checkbox typed into the item's
1816
+ * text is a child of that item too.
1817
+ *
1818
+ * **This is why `style` is on each element's own entry, never on `*`.**
1819
+ * `hast-util-sanitize` consults `*` whenever an element's own entry yields
1820
+ * nothing, so no entry on `input` could refuse a value that `*` admits. An
1821
+ * element takes `style` only by being in `TAG_NAMES` and not in this set.
1822
+ */
1823
+ const UNSTYLED_TAGS = /* @__PURE__ */ new Set(["input"]);
1824
+ /**
1825
+ * `attributes`, with `["style", SAFE_STYLE]` added to the entry of every
1826
+ * element in `TAG_NAMES` but the `UNSTYLED_TAGS`.
1827
+ */
1828
+ function withStyle(attributes) {
1829
+ const styled = { ...attributes };
1830
+ for (const tag of new Set(TAG_NAMES)) if (!UNSTYLED_TAGS.has(tag)) styled[tag] = [...styled[tag] ?? [], ["style", SAFE_STYLE]];
1831
+ return styled;
1832
+ }
1833
+ /**
1097
1834
  * Never set `allowComments` here.
1098
1835
  *
1099
1836
  * `hast-util-sanitize` drops comment nodes because that boolean defaults to
@@ -1107,38 +1844,16 @@ const COLLAPSE_GROUP_ID = /^[0-9]+$/;
1107
1844
  */
1108
1845
  const sanitizeSchema = {
1109
1846
  ...rehype_sanitize.defaultSchema,
1110
- tagNames: [
1111
- ...rehype_sanitize.defaultSchema.tagNames || [],
1112
- "math",
1113
- "semantics",
1114
- "mrow",
1115
- "mi",
1116
- "mo",
1117
- "mn",
1118
- "msup",
1119
- "msub",
1120
- "mfrac",
1121
- "mover",
1122
- "munder",
1123
- "msqrt",
1124
- "mroot",
1125
- "mtable",
1126
- "mtr",
1127
- "mtd",
1128
- "mtext",
1129
- "mspace",
1130
- "annotation",
1131
- "figure",
1132
- "figcaption",
1133
- "summary",
1134
- "details"
1135
- ],
1136
- attributes: {
1847
+ strip: [...rehype_sanitize.defaultSchema.strip || [], ...SVG_STRIPPED_EVERYWHERE],
1848
+ tagNames: TAG_NAMES,
1849
+ ancestors: {
1850
+ ...rehype_sanitize.defaultSchema.ancestors,
1851
+ ...Object.fromEntries(SVG_CHILD_TAGS.map((tag) => [tag, ["svg"]]))
1852
+ },
1853
+ attributes: withStyle({
1137
1854
  ...rehype_sanitize.defaultSchema.attributes,
1138
1855
  "*": [
1139
1856
  ...rehype_sanitize.defaultSchema.attributes?.["*"] || [],
1140
- "className",
1141
- ["style", SAFE_STYLE],
1142
1857
  "dataSourceLine",
1143
1858
  ["dataVantageTone", ...VANTAGE_TONES],
1144
1859
  ["dataVantageEmphasis", ...VANTAGE_EMPHASIS],
@@ -1148,35 +1863,22 @@ const sanitizeSchema = {
1148
1863
  ["dataVantageCollapseToggle", COLLAPSE_GROUP_ID],
1149
1864
  ["dataVantageRun", ...VANTAGE_RUNS],
1150
1865
  ["dataVantageOq", "true"],
1866
+ ["dataVantageQuestion", "true"],
1151
1867
  ["dataVantageOqId", VANTAGE_OQ_ID],
1152
1868
  ["dataVantageAlert", ...VANTAGE_ALERTS],
1153
1869
  "dataVantageLeaning"
1154
1870
  ],
1155
- code: [...rehype_sanitize.defaultSchema.attributes?.code || [], "className"],
1156
- span: [
1157
- ...rehype_sanitize.defaultSchema.attributes?.span || [],
1158
- "className",
1159
- ["style", SAFE_STYLE]
1160
- ],
1161
- div: [
1162
- ...rehype_sanitize.defaultSchema.attributes?.div || [],
1163
- "className",
1164
- ["style", SAFE_STYLE]
1165
- ],
1166
- a: [
1167
- ...rehype_sanitize.defaultSchema.attributes?.a || [],
1168
- "id",
1169
- "className"
1170
- ],
1871
+ ...Object.fromEntries(Object.keys(PIPELINE_CLASSES).map((tag) => [tag, withClasses(tag)])),
1872
+ a: withClasses("a", "id"),
1171
1873
  math: ["xmlns"],
1172
1874
  annotation: ["encoding"],
1173
1875
  img: [...rehype_sanitize.defaultSchema.attributes?.img || [], "loading"],
1174
- td: [...rehype_sanitize.defaultSchema.attributes?.td || [], ["style", SAFE_STYLE]],
1175
- th: [...rehype_sanitize.defaultSchema.attributes?.th || [], ["style", SAFE_STYLE]]
1176
- }
1876
+ svg: SVG_ROOT_ATTRIBUTES,
1877
+ ...Object.fromEntries(SVG_CHILD_TAGS.map((tag) => [tag, SVG_ATTRIBUTES]))
1878
+ })
1177
1879
  };
1178
1880
  //#endregion
1179
- //#region src/pipeline.ts
1881
+ //#region src/remarkPlugins.ts
1180
1882
  /**
1181
1883
  * The mdast half of the chain. Exported on its own because there is a real
1182
1884
  * mdast-only consumer: the CLI checker parses documents without ever running
@@ -1190,6 +1892,8 @@ function buildRemarkPlugins(options = {}) {
1190
1892
  if (math) plugins.push([remark_math.default, { singleDollarTextMath: false }]);
1191
1893
  return plugins;
1192
1894
  }
1895
+ //#endregion
1896
+ //#region src/pipeline.ts
1193
1897
  /** The hast half. Deliberately not exported: see `buildPipeline`. */
1194
1898
  function buildRehypePlugins(options = {}) {
1195
1899
  const { math = true, highlight = true, sourceLines = true, sanitize = true, bodyLineOffset = 0 } = options;
@@ -1197,7 +1901,7 @@ function buildRehypePlugins(options = {}) {
1197
1901
  if (sourceLines) plugins.push([rehypeSourceLines, { offset: bodyLineOffset }]);
1198
1902
  plugins.push(rehypeVantageAlerts);
1199
1903
  plugins.push(rehypeVantageDirectives);
1200
- if (sanitize) plugins.push([rehype_sanitize.default, sanitizeSchema]);
1904
+ if (sanitize) plugins.push(rehypeStripSvgContainers, [rehype_sanitize.default, sanitizeSchema]);
1201
1905
  plugins.push(rehypeVantageAnchors);
1202
1906
  plugins.push(rehype_slug.default);
1203
1907
  if (highlight) plugins.push(rehype_highlight.default);
@@ -1761,6 +2465,43 @@ let mermaidLoading = null;
1761
2465
  * configured for, `null` until loaded.
1762
2466
  */
1763
2467
  let configuredTheme = null;
2468
+ /**
2469
+ * The configuration keys a diagram's own source may not set, from an `init`
2470
+ * directive or from frontmatter: Mermaid's default list, then the three
2471
+ * that write a diagram's stylesheet.
2472
+ *
2473
+ * **A diagram's stylesheet reaches past the diagram.** Mermaid puts every
2474
+ * selector of it under the diagram's id, but leaves the names of its
2475
+ * `@keyframes` global, so a diagram that defined `flash-update` redefined the
2476
+ * animation the viewer plays on a block that just changed. Given
2477
+ * `position: fixed` at the size of the window, the next flash laid that block,
2478
+ * and the link it held, over the header and the sidebar. The same stylesheet
2479
+ * fetched images from any host, which is why `SAFE_STYLE` refuses parentheses.
2480
+ *
2481
+ * - `themeCSS` is a stylesheet, and Mermaid checks only that its braces pair.
2482
+ * - `fontFamily` is copied into `themeVariables` *after* Mermaid's check on
2483
+ * theme variables has run, and lands in the stylesheet as a declaration's
2484
+ * value, where a nested block becomes a rule of its own.
2485
+ * - `altFontFamily` lands in a rule the browser parses first, which drops
2486
+ * what does not belong. It is refused anyway: nothing needs it, and that
2487
+ * parse is all that stands in the way.
2488
+ *
2489
+ * Mermaid applies the list at every depth, so `themeVariables.fontFamily` in
2490
+ * a diagram is refused too. The list is spelled whole rather than relying on
2491
+ * Mermaid merging it with its default. `frontend/e2e/mermaid.spec.ts` measures
2492
+ * all four routes; see "Mermaid" in `docs/reference/inline-markup.md`.
2493
+ */
2494
+ const MERMAID_SECURE_KEYS = [
2495
+ "secure",
2496
+ "securityLevel",
2497
+ "startOnLoad",
2498
+ "maxTextSize",
2499
+ "suppressErrorRendering",
2500
+ "maxEdges",
2501
+ "themeCSS",
2502
+ "fontFamily",
2503
+ "altFontFamily"
2504
+ ];
1764
2505
  function configure(m, palette) {
1765
2506
  const theme = mermaidThemeOf(palette);
1766
2507
  m.initialize({
@@ -1768,7 +2509,8 @@ function configure(m, palette) {
1768
2509
  theme,
1769
2510
  themeVariables: mermaidThemeVariables(theme),
1770
2511
  securityLevel: "strict",
1771
- suppressErrorRendering: true
2512
+ suppressErrorRendering: true,
2513
+ secure: MERMAID_SECURE_KEYS
1772
2514
  });
1773
2515
  configuredTheme = palette;
1774
2516
  }
@@ -1803,15 +2545,6 @@ async function getMermaid() {
1803
2545
  //#endregion
1804
2546
  //#region src/renderMermaidBlocks.ts
1805
2547
  /**
1806
- * Client-side utility to find and render mermaid code blocks in a container.
1807
- *
1808
- * After calling `renderMarkdown()`, mermaid blocks come through as
1809
- * `<pre><code class="language-mermaid">...</code></pre>`. This function
1810
- * finds those blocks and replaces them with rendered SVG diagrams.
1811
- *
1812
- * Framework-agnostic — works in any browser environment.
1813
- */
1814
- /**
1815
2548
  * Find all `<pre><code class="language-mermaid">` blocks in a container
1816
2549
  * and replace them with rendered SVG diagrams.
1817
2550
  *
@@ -1892,6 +2625,7 @@ function replaceWithSvg(preEl, svg, className) {
1892
2625
  wrapper.innerHTML = svg;
1893
2626
  preEl.replaceWith(wrapper);
1894
2627
  }
2628
+ (0, unified.unified)().use(remark_parse.default).use(buildRemarkPlugins());
1895
2629
  //#endregion
1896
2630
  //#region src/resolveLinks.ts
1897
2631
  /**
@@ -1942,12 +2676,15 @@ function resolveLinks(html, options = {}) {
1942
2676
  * The canonical Vantage Markdown style guide.
1943
2677
  *
1944
2678
  * This string is the single source of truth for the conventions Vantage's
1945
- * renderer expects. Two consumers read it:
2679
+ * renderer expects. It reaches readers three ways:
1946
2680
  *
1947
2681
  * - the in-app "Style Guide for Agents" modal, which shows it with a copy
1948
- * button, and
2682
+ * button,
1949
2683
  * - the `vantage-check style-guide` command, which prints it so an agent can
1950
- * fetch it without a human in the loop.
2684
+ * fetch it without a human in the loop, and
2685
+ * - this package's own export, for anyone who renders with `vantage-md`.
2686
+ *
2687
+ * How the CLI serves it, and why there is one copy: docs/reference/agent-cli.md
1951
2688
  *
1952
2689
  * Every rule stated here should be one a checker can enforce or a renderer
1953
2690
  * actually cares about — if a line is neither, it does not belong.
@@ -2003,6 +2740,41 @@ vantage:
2003
2740
  - **Prefer \`status-chip: true\`**, which shows the document's own \`status:\` and therefore cannot disagree with it. A literal \`status-chip: accepted\` is accepted too, but it is a second value that goes stale on its own — \`vantage-check\` reports the disagreement.
2004
2741
  - The chip's vocabulary is \`status\`'s, exactly: \`draft | in-review | accepted | deprecated\`, lowercase. \`Draft\` renders no chip at all, silently.
2005
2742
 
2743
+ ### Planning documents: \`stage\`, \`next\`, \`depends-on\`
2744
+ - **Vantage reads a repository's plans as a set.** A document whose frontmatter has \`status\` or \`stage\`, or that carries a \`question\` directive (or an \`oq\`, the name it replaces), is a *planning document*, and Vantage shows its state (status, stage, open questions) in a badge beside every link to it. So write each fact once, in the document it belongs to, and link to it everywhere else: a copied status or count is the one that goes stale.
2745
+ - Three top-level keys beside \`status\` hold the rest of a document's planning state. They are facts about the file, so they sit at the top level, never under \`vantage:\`:
2746
+ \`\`\`yaml
2747
+ ---
2748
+ title: "Payload bootstrap"
2749
+ status: in-review
2750
+ stage: DESIGN
2751
+ next: "Rule OQ-B2 \u2014 the install step waits on it"
2752
+ depends-on:
2753
+ - pypi-distribution.md
2754
+ - ../plans/rollout.md#OQ-R1
2755
+ ---
2756
+ \`\`\`
2757
+ - **\`stage\` is one word from the repository's own vocabulary**, the words \`[planning.stages]\` declares (below), spelled exactly as declared: matching is case-sensitive, so \`Decided\` is not \`DECIDED\`. \`vantage-check\` reports a word outside them as \`planning/stage-vocabulary\`.
2758
+ - **Frontmatter is the stage's one home.** Do not repeat the word in a prose \`**Status:**\` line: that is a second copy, and no tool can read or check it. Keep the prose line, where a document has one, for the date and the why.
2759
+ - **\`next\` is the next step, on one line of plain text.** A bare \`OQ-\` id in it links to this document's question of that id, so name the question rather than paraphrasing it.
2760
+ - **\`depends-on\` lists what the document waits on**: relative paths, resolved like links, each optionally ending in \`#OQ-\u2026\` to name one question. A single path may stand on its own. A target that does not exist, lies outside the repository, or does not contain the id is an error (\`planning/depends-on-missing\`).
2761
+ - **Never a \`priority\` key.** A priority only means something relative to the others, so it lives in an ordered list, a roadmap, and not in each document.
2762
+ - **A roadmap is ordered links.** Every \`roadmap.md\` Vantage reads is a roadmap, in any directory, unless \`[planning]\` lists the roadmaps instead; one in a hidden directory, matched by \`.vantageignore\`, or ruled out by \`include\` or \`exclude\` is not read, and so is not a roadmap. Each entry is a link to a document or a question, then a one-clause reason for its place; the badge beside the link carries the rest, so never copy a status, a stage or a count into it. Prose beneath an entry holds only what has no other home, such as what would unblock it. A bare link to a document *routes* every question in it into the roadmap's order, and a link to one question's \`#OQ-\u2026\` anchor routes that question; a link to any other heading routes nothing. A question is routed when any roadmap routes it, and with \`planning/unrouted\` turned on, \`vantage-check\` reports an open question no roadmap routes.
2763
+ - **\`[planning]\` in \`.vantage.toml\`** says which files are read, which of them are roadmaps, and what each stage word means. Every key is optional:
2764
+ \`\`\`toml
2765
+ [planning]
2766
+ # roadmap = ["roadmap.md", "docs/plans/roadmap.md"] # absent: every roadmap.md
2767
+ exclude = ["docs/gallery/**"] # gitignore syntax; every .md is included by default
2768
+
2769
+ [planning.stages] # each word maps to open | ready | built | done
2770
+ DESIGN = "open"
2771
+ DECIDED = "ready"
2772
+ BUILT = "built"
2773
+ SUPERSEDED = "done"
2774
+ \`\`\`
2775
+ - **Each stage word maps to one of four roles**, which is what the word means to Vantage: \`open\` is still being decided, \`ready\` is decided and not built, \`built\` is built, and \`done\` is no longer a live proposal, so its questions leave every list of what needs a ruling. \`vantage-check index\` prints those lists.
2776
+ - **Never change \`target\`.** A \`target = "X.Y"\` line at the top of \`.vantage.toml\` names the oldest Vantage release the repository's readers use, and only a human edits it. A \`vantage-check\` older than the target refuses to run and names the release it needs: run that release, and leave the target as it is.
2777
+
2006
2778
  ### Mermaid diagrams
2007
2779
  - Use \`\`\`mermaid code blocks for flowcharts, sequence diagrams, and architecture diagrams. Vantage provides interactive zoom, pan, dark/light theme adaptation, and SVG export.
2008
2780
  - **Quote labels with special characters**: Always quote node labels containing parentheses, brackets, or colons to prevent syntax errors:
@@ -2012,6 +2784,26 @@ flowchart TD
2012
2784
  srv --> git["Git CLI (git diff)"]
2013
2785
  \`\`\`
2014
2786
 
2787
+ ### Inline SVG (needs Vantage 0.8 or later)
2788
+ - **Inline \`<svg>\` is a capability, not notation: it needs Vantage 0.8 or later.** GitHub, every other renderer and Vantage before 0.8 drop the drawing and leave the words of its \`<text>\` elements behind as loose text, so keep \`<text>\` to short labels a reader can bear to see as loose words.
2789
+ - **Name the drawing with \`aria-label\` on the \`<svg>\`, never with \`<title>\` or \`<desc>\`.** Vantage 0.8 removes both, so they name nothing there, and every other reader prints them: Vantage before 0.8 as loose words, GitHub with the tags.
2790
+ - **Pair every inline drawing with a fallback block**: a \`<!-- vantage: fallback -->\` directive above the block that stands in for the drawing. Vantage 0.8 and later never show that block; every other reader drops the comment and shows it. Say what the drawing shows and that it needs Vantage 0.8 or later, and link a copy of it committed as a file where there is one (\`[the drawing](diagram.svg)\`). The block is a paragraph, a list, a quote, a code block, a table or a \`<div>\`; Vantage shows a raw \`<img>\`, \`<figure>\` or \`<details>\` there, so wrap one in a \`<div>\`.
2791
+ - **Wrap the drawing in a \`<div>\` on a line of its own, with no blank line anywhere inside it**, and give it a \`width\`, a \`height\` and a \`viewBox\`. Paint with \`fill\`/\`stroke\` set to \`currentColor\` or a hex color, so it reads in light and dark mode.
2792
+ \`\`\`markdown
2793
+ <div>
2794
+ <svg xmlns="http://www.w3.org/2000/svg" width="120" height="40" viewBox="0 0 120 40" role="img" aria-label="Two boxes joined by an arrow">
2795
+ <rect x="1" y="1" width="40" height="38" fill="none" stroke="currentColor"/>
2796
+ <path d="M45 20 H75 M68 14 L75 20 L68 26" fill="none" stroke="currentColor"/>
2797
+ <rect x="79" y="1" width="40" height="38" fill="none" stroke="currentColor"/>
2798
+ </svg>
2799
+ </div>
2800
+
2801
+ <!-- vantage: fallback -->
2802
+
2803
+ Two boxes joined by an arrow. This drawing needs Vantage 0.8 or later.
2804
+ \`\`\`
2805
+ - **For a document read mostly on GitHub**, commit the drawing as a file and embed it with \`![alt](diagram.svg)\` instead, which every renderer shows.
2806
+
2015
2807
  ### Code blocks and diffs
2016
2808
  - Always tag fenced code blocks with language identifiers (\`ts\`, \`go\`, \`python\`, \`bash\`, \`json\`, \`yaml\`, \`diff\`, \`sql\`, etc.) for syntax highlighting.
2017
2809
  - For proposed code modifications, use \`\`\`diff blocks with \`+\` and \`-\` prefixes:
@@ -2049,25 +2841,60 @@ Vantage reads a few styling hints from ordinary HTML comments. Every other rende
2049
2841
  The steps below predate the rewrite.
2050
2842
  \`\`\`
2051
2843
 
2052
- - **Three names**: \`section\` (the heading and everything under it), \`block\` (the one block after it), \`oq\` (one answerable Open Question).
2844
+ - **Four names to write**: \`section\` (the heading and everything under it), \`block\` (the one block after it), \`question\` (one Open Question, in any state; see Open questions below), \`fallback\` (the one block after it, which Vantage 0.8 and later never show; see Inline SVG above). \`fallback\` takes no keys, and withholds only a paragraph, a list, a quote, a code block, a table, a rule or a \`<div>\`, never a heading. A fifth, \`oq\`, is the name \`question\` replaces (below).
2053
2845
  - **The keys and values are a closed set**: \`tone\` = \`note | tip | important | warning | caution | muted\`; \`emphasis\` = \`strong | normal | quiet\`; \`badge\` = \`draft | stale | blocked | done | wip\`; \`collapsed\` = \`true | false\`. Name a *tone*, never a color — the theme decides what a warning looks like, in light mode, in dark mode, and in print.
2054
2846
  - **Use them sparingly.** One or two per document, on the sections that genuinely differ. A document where everything is toned says nothing, and a rainbow one is harder to read than a plain one.
2055
2847
  - **Anything outside those sets is silently ignored** — nothing breaks, and nothing styles either. Run \`vantage-check\` on the document: the \`vantage/*\` rules are the only thing that will ever tell you a directive did nothing.
2056
2848
  - **Always close the comment with \`-->\`.** Never \`--!>\`, and never leave it open: Markdown reads every line below an unclosed \`<!--\` as part of the comment, and the whole rest of the document vanishes from the page. For the same reason \`-->\` cannot appear *inside* a value — it ends the comment early and spills the remainder into the page as literal text.
2057
2849
  - **In a list, indent the directive inside the item**, with blank lines around it (below). At the start of a line between two items it ends the list and starts a second one, which changes the numbering and the spacing in every renderer — the one thing a directive must never do.
2058
- - **An open question's id is \`OQ-\` then an optional short uppercase prefix then digits** — \`OQ-9\`, \`OQ-TP6\`, \`OQ-A03\`. The prefix is what keeps ids distinct once one document references another's questions, so use one in both whenever they cross-reference. \`vantage-check\` reports anything outside that shape as \`vantage/oq-id-format\`, and the same id twice in one document as \`vantage/oq-id-duplicate\` — both are silent otherwise, because the id becomes the block's anchor and a refused or duplicated one simply goes nowhere.
2850
+ - **A question's id is \`OQ-\` then an optional short uppercase prefix then digits** — \`OQ-9\`, \`OQ-TP6\`, \`OQ-A03\`. The prefix is what keeps ids distinct once one document references another's questions, so use one in both whenever they cross-reference. \`vantage-check\` reports anything outside that shape as \`vantage/oq-id-format\`, and the same id twice in one document as \`vantage/oq-id-duplicate\` — both are silent otherwise, because the id becomes the block's anchor and a refused or duplicated one simply goes nowhere.
2059
2851
  - **A reference is a link, or it is a lie.** An \`OQ-\` id, a \`\u00a7N\` section number and a filename all read like pointers, and written as bare prose none of them can be followed or checked — which is exactly why a stale one is never caught. Link the question to its anchor (\`[OQ-4](#OQ-4)\`, or the Decision Ledger once it is compacted), the section to its heading, the filename to the file. \`vantage-check\` reports all three (\`ref/*\`) as errors, and checks that the link points at the thing the reference names rather than merely at something. Writing a specimen rather than a reference? Put it in a fenced block, which the rules never read.
2060
- - **Every open question (\u{1F4AC}) with a stated leaning gets an \`oq\` directive.** The convention's prose — the emoji, the \`OQ-N\` id, the \`_Leaning:_\` line, the fill-in \`**Answer:**\` — produces no button on its own. Writing the convention and stopping there is the most common way this feature goes missing: the questions look complete, review mode is on, and there is nothing to click. **\`vantage-check\` reports it as an error** (\`vantage/oq-missing\`), because a question awaiting a ruling that the reviewer cannot file is not a style preference. Mark it \u{1F512} if it is blocked on something upstream and cannot be answered yet, or \u2705 once it is decided; either state needs no directive.
2061
- - **A \`leaning\` restates the leaning; it is never "yes".** The one-click button in review mode files that text as a review comment, and the comment is all the agent reading it has — nobody remembers which button was clicked. \`leaning="Yes"\` beside a two-branch question is a support ticket.
2852
+
2853
+ ### Open questions
2854
+ A question a document leaves for a human to rule on is an *Open Question*, written in a numbered list under a heading of its own.
2855
+
2856
+ - **Write each question in its parts, never as one run-together paragraph.** Vantage lays a question out by these parts: the contents column and the planning page's card show its title as the question, and the card shows the leaning as the leaning and the Answer as the answer, so a part run into another one is read as neither. Each part is a block of its own, with a blank line before it:
2857
+ 1. **The title line**: the status marker, the id and the question itself in bold, saying what is being decided: \`\u{1F4AC} **OQ-9: Where does a job go when it re-enters the queue?**\`.
2858
+ 2. **The context**, in short paragraphs: only what a ruling needs. Background, history and cross-references belong in the document's own sections, linked from the question.
2859
+ 3. **The options as a list**, one item each, when there are options to choose between.
2860
+ 4. **\`_Leaning:_\` as a paragraph of its own**: which option, and why, in a sentence or two.
2861
+ 5. **\`**Answer:**\` as a paragraph of its own**, with the ruling below it once there is one.
2062
2862
 
2063
2863
  \`\`\`markdown
2064
- 1. **OQ-9: Queue position on re-entry.**
2864
+ ## Open Questions
2865
+
2866
+ 1. \u{1F4AC} **OQ-9: Where does a job go when it re-enters the queue?**
2065
2867
 
2066
- <!-- vantage: oq id=OQ-9 leaning="Back of the queue — the fix might interact with what merged while it was out." -->
2868
+ A job that fails its checks leaves the queue and comes back once it is fixed. What merged while it was out may change what it does.
2067
2869
 
2068
- _Leaning:_ Back of the queue.
2870
+ - **A — The back of the queue.** It waits its turn again.
2871
+ - **B — Its old place.** It skips ahead of what joined since.
2872
+
2873
+ <!-- vantage: question id=OQ-9 leaning="A — the back of the queue: the fix might interact with what merged while it was out." -->
2874
+
2875
+ _Leaning:_ A. The fix might interact with what merged while it was out.
2876
+
2877
+ **Answer:**
2878
+
2879
+ > _(empty — fill in when decided)_
2880
+
2881
+ 2. \u{1F512} **OQ-10: How large is the retry budget?**
2882
+
2883
+ <!-- vantage: question id=OQ-10 -->
2884
+
2885
+ Waits on the load test, so there is no leaning to state yet.
2069
2886
  \`\`\`
2070
2887
 
2888
+ - **The marker is the question's state**: \u{1F4AC} open (\u{1F4AC} \u{1F937} when it is a matter of preference), \u{1F512} blocked on something upstream that has to happen first, \u2705 answered. Review mode offers a one-click **Take this leaning** on an open question, and on nothing else.
2889
+ - **Every question gets a \`question\` directive**, \`<!-- vantage: question id=OQ-9 leaning="…" -->\`, in every state, indented into its list item on a line of its own directly above the \`_Leaning:_\` paragraph, or above another of its paragraphs when it states no leaning. The directive is what makes it a question to Vantage: it gives the question its \`#OQ-9\` anchor, and Vantage's planning index (its model of a repository's plans) reads a question from nothing else, so without one nothing counts it, badges it or lists it as waiting. It goes when the question is compacted into the Decision Ledger, and not before.
2890
+ - **Changing a question's state changes its marker and nothing else.** Mark it \u{1F512} when it cannot be answered yet, and \u2705 once it is decided; the directive stays as it is, its \`leaning\` included, which nothing offers to take once the question is not open.
2891
+ - **Every open question with a stated leaning restates it as the directive's \`leaning\`.** The convention's prose — the emoji, the \`OQ-N\` id, the \`_Leaning:_\` line, the fill-in \`**Answer:**\` — produces no button on its own, and writing it and stopping there is the most common way the button goes missing: the questions look complete, review mode is on, and there is nothing to click. \`vantage-check\` reports an open question with a \`_Leaning:_\` and no directive as an error (\`vantage/oq-missing\`). An open question with no leaning yet takes the directive without \`leaning\`, so the planning index still counts it; until it states one, its button files the literal text "Take the stated leaning."
2892
+ - **A \`leaning\` restates the leaning; it is never "yes".** The one-click button in review mode files that text as a review comment, and the comment is all the agent reading it has — nobody remembers which button was clicked. \`leaning="Yes"\` beside a two-branch question is a support ticket.
2893
+ - **Keep the text below the title short.** The planning page shows each question as a card that leads with the title and shows only the first few lines of the rest, so a question buried in a paragraph of background is one the reviewer has to dig for. \`vantage-check\` warns when a question's text, not counting its \`_Leaning:_\` paragraph and its \`**Answer:**\`, runs past 120 words (\`planning/question-length\`), and when a leaning shares a paragraph with other text (\`vantage/question-layout\`); a repository can raise the limit with \`max-words\`, or turn either rule off, under \`[check.rules]\`.
2894
+ - **Enumerate with a list, never inline.** A question's options, or any three steps or parts, go in a Markdown list, one item per line, rather than run together in one sentence as \`(a) \u2026 (b) \u2026 (c)\`, where a reader has to find where each one starts. \`vantage-check\` warns on a paragraph that runs three or more together (\`prose/inline-list\`).
2895
+ - **Write a question as a list item.** Outside a list, a question runs from the block its directive lands on through the blocks after it, up to the next heading (for a question written as a heading, the next one of its level or higher), the next rule or the next question, so whatever prose follows it before then is read as part of it. There the directive goes above the question's title, since every viewer reads the question's marker from the block it lands on: a directive below the bold title lands on the block after it, which carries no marker, so every viewer reads the question as open. \`vantage-check\` reports that as \`vantage/question-name\`.
2896
+ - **\`oq\` is the name \`question\` replaces.** Vantage keeps reading \`<!-- vantage: oq id=OQ-9 leaning="…" -->\` as it always has, and \`vantage-check\` warns on it (\`vantage/oq-deprecated\`) with the \`question\` to write instead, keys unchanged. Vantage before 0.8 drops \`question\` whole, so a reader still on 0.7 gets no one-click answer and no anchor on a question written with it, and misreads nothing. So keep an \`oq\` on an open question unless you know every reader of the repository is on 0.8 or later. A repository whose readers are still on 0.7 says so with a \`target\` before 0.8, such as \`target = "0.7"\`: there an open question takes \`oq\`, new ones included, and \`vantage-check\` asks for \`oq\` rather than \`question\` on one. **Never put \`oq\` on a \u{1F512} or \u2705 question**: every Vantage before 0.8 offers the one-click button on every \`oq\`, whatever its marker says, and on a question with no leaning it files the literal text "Take the stated leaning." \`vantage-check\` reports that as an error (\`vantage/question-name\`).
2897
+
2071
2898
  ### Tables, task lists, and math
2072
2899
  - **Tables**: Use standard markdown tables for structured comparisons and schemas.
2073
2900
  - **Task lists**: Use \`- [ ]\` and \`- [x]\` for actionable checklists and status tracking.
@@ -2089,9 +2916,14 @@ exports.VANTAGE_BADGES = VANTAGE_BADGES;
2089
2916
  exports.VANTAGE_COLLAPSED = VANTAGE_COLLAPSED;
2090
2917
  exports.VANTAGE_EMPHASIS = VANTAGE_EMPHASIS;
2091
2918
  exports.VANTAGE_FRONTMATTER_KEYS = VANTAGE_FRONTMATTER_KEYS;
2919
+ exports.VANTAGE_LEANING_ATTRIBUTE = VANTAGE_LEANING_ATTRIBUTE;
2920
+ exports.VANTAGE_OQ_ATTRIBUTE = VANTAGE_OQ_ATTRIBUTE;
2092
2921
  exports.VANTAGE_OQ_HOST_TARGETS = VANTAGE_OQ_HOST_TARGETS;
2093
2922
  exports.VANTAGE_OQ_STATUS = VANTAGE_OQ_STATUS;
2094
2923
  exports.VANTAGE_OQ_STATUS_LABEL = VANTAGE_OQ_STATUS_LABEL;
2924
+ exports.VANTAGE_QUESTION_ATTRIBUTE = VANTAGE_QUESTION_ATTRIBUTE;
2925
+ exports.VANTAGE_QUESTION_NAMES = VANTAGE_QUESTION_NAMES;
2926
+ exports.VANTAGE_QUESTION_SELECTOR = VANTAGE_QUESTION_SELECTOR;
2095
2927
  exports.VANTAGE_RUNS = VANTAGE_RUNS;
2096
2928
  exports.VANTAGE_SENTINEL = VANTAGE_SENTINEL;
2097
2929
  exports.VANTAGE_TONES = VANTAGE_TONES;
@@ -2101,9 +2933,11 @@ exports.clearLineAnchorHighlights = clearLineAnchorHighlights;
2101
2933
  exports.currentColorTheme = currentColorTheme;
2102
2934
  exports.hasVantageSentinel = hasVantageSentinel;
2103
2935
  exports.isDocStatus = isDocStatus;
2936
+ exports.isQuestionDirective = isQuestionDirective;
2104
2937
  exports.parseFrontmatter = parseFrontmatter;
2105
2938
  exports.parseLineAnchor = parseLineAnchor;
2106
2939
  exports.parseVantageDirective = parseVantageDirective;
2940
+ exports.questionOffersTake = questionOffersTake;
2107
2941
  exports.readVantageFrontmatter = readVantageFrontmatter;
2108
2942
  exports.rehypeSourceLines = rehypeSourceLines;
2109
2943
  exports.rehypeVantageAlerts = rehypeVantageAlerts;