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.js CHANGED
@@ -2,14 +2,14 @@ import { unified } from "unified";
2
2
  import remarkParse from "remark-parse";
3
3
  import remarkRehype from "remark-rehype";
4
4
  import rehypeStringify from "rehype-stringify";
5
- import remarkGfm from "remark-gfm";
6
- import remarkMath from "remark-math";
7
5
  import rehypeRaw from "rehype-raw";
8
6
  import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
9
7
  import rehypeHighlight from "rehype-highlight";
10
8
  import rehypeKatex from "rehype-katex";
11
9
  import rehypeSlug from "rehype-slug";
12
10
  import { visit } from "unist-util-visit";
11
+ import remarkGfm from "remark-gfm";
12
+ import remarkMath from "remark-math";
13
13
  import YAML from "yaml";
14
14
  import { parse } from "smol-toml";
15
15
  //#region src/rehypeVantageAnchors.ts
@@ -124,6 +124,12 @@ const VANTAGE_ALERTS = [
124
124
  "warning",
125
125
  "caution"
126
126
  ];
127
+ /**
128
+ * The class on the title element this plugin injects, which
129
+ * `styles/directives.css` styles. Exported because the sanitizer runs after
130
+ * this plugin and admits a `div`'s class only when it is this one.
131
+ */
132
+ const ALERT_TITLE_CLASS = "vantage-alert-title";
127
133
  /** The visible label per kind. Title case, as GitHub renders it. */
128
134
  const ALERT_TITLES = {
129
135
  note: "Note",
@@ -147,7 +153,7 @@ const ALERT_TITLES = {
147
153
  * anchored test on the first text node is enough, and the plugin does not have
148
154
  * to reassemble the marker across siblings.
149
155
  */
150
- const MARKER = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\][ \t]*(?:\r?\n|$)/;
156
+ const ALERT_MARKER = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\][ \t]*(?:\r?\n|$)/;
151
157
  /** The first child, if it is an element. */
152
158
  function firstElement(node) {
153
159
  const child = node.children.find((c) => c.type === "element" || c.type === "text" && c.value.trim() !== "");
@@ -174,7 +180,7 @@ function rehypeVantageAlerts() {
174
180
  if (paragraph === void 0 || paragraph.tagName !== "p") return;
175
181
  const lead = paragraph.children[0];
176
182
  if (lead === void 0 || lead.type !== "text") return;
177
- const match = MARKER.exec(lead.value);
183
+ const match = ALERT_MARKER.exec(lead.value);
178
184
  if (match === null) return;
179
185
  const kind = match[1].toLowerCase();
180
186
  lead.value = lead.value.slice(match[0].length);
@@ -186,7 +192,7 @@ function rehypeVantageAlerts() {
186
192
  node.children.unshift({
187
193
  type: "element",
188
194
  tagName: "div",
189
- properties: { className: ["vantage-alert-title"] },
195
+ properties: { className: [ALERT_TITLE_CLASS] },
190
196
  children: [{
191
197
  type: "text",
192
198
  value: ALERT_TITLES[kind]
@@ -233,16 +239,78 @@ const VANTAGE_SENTINEL = "vantage:";
233
239
  *
234
240
  * Position picks the target; the name picks the extent. `section` before a
235
241
  * heading reaches the heading's whole section, `block` reaches one block, and
236
- * `oq` marks one answerable question. The name cannot disagree with position —
237
- * it only says how far the stamp reaches — so §4.2's refusal of a `scope=` key
238
- * stands.
242
+ * `question` declares one question in any state, as `oq`, the name it
243
+ * replaces, still does (`VANTAGE_QUESTION_NAMES`). The name cannot disagree
244
+ * with position — it only says how far the stamp reaches — so §4.2's refusal
245
+ * of a `scope=` key stands.
246
+ *
247
+ * `fallback` is the one name that stamps nothing: it withholds the block after
248
+ * it, which is written for the renderers that cannot show something Vantage
249
+ * shows — an inline `<svg>`, today. Every renderer that does not know the name
250
+ * drops the comment and shows the block, a 0.7.x Vantage included, which is
251
+ * the whole mechanism: the name is new, so no older reader gives it a meaning
252
+ * (`docs/reference/inline-markup.md`, "Fallback blocks").
239
253
  */
240
254
  const DIRECTIVE_NAMES = [
241
255
  "section",
242
256
  "block",
243
- "oq"
257
+ "oq",
258
+ "question",
259
+ "fallback"
244
260
  ];
245
261
  /**
262
+ * The two names that declare a question: `question`, the one to write, and
263
+ * `oq`, which it replaces and which every Vantage keeps reading.
264
+ *
265
+ * - **`question`** declares a question in any state: open (💬, 💬 🤷 or no
266
+ * marker at all), 🔒 blocked or ✅ answered. The state comes from the marker
267
+ * and never from the name, so a question that changes state changes its
268
+ * marker and nothing else. A viewer from 0.8 on offers Take this leaning on
269
+ * an open one, and never on a 🔒 or ✅ one.
270
+ * - **`oq`** is deprecated, never removed. It keeps the meaning it shipped
271
+ * with in 0.7, an open question to answer in one click, because documents
272
+ * outlive releases and a release never gives existing notation a new
273
+ * meaning (`docs/design/checker-version-skew.md`, P0). Every viewer before
274
+ * 0.8 offers the button on every `oq` it meets, whatever its marker says,
275
+ * so `vantage-check` reports one on a 🔒 or ✅ question as an error and any
276
+ * other as a warning that names the `question` to write.
277
+ *
278
+ * The two take the same keys, `id` and `leaning`, with one grammar. A viewer
279
+ * that predates `question` drops it whole, as it drops every name it does not
280
+ * know, so on a question written with it that viewer offers no button and no
281
+ * anchor: it does less, and misreads nothing. A run holding both names is one
282
+ * question, read as such a viewer reads it: where both set a key, the `oq`'s
283
+ * value wins.
284
+ */
285
+ const VANTAGE_QUESTION_NAMES = ["oq", "question"];
286
+ /** Whether `name` declares a question, `question` or the deprecated `oq`. */
287
+ function isQuestionDirective(name) {
288
+ return name === "oq" || name === "question";
289
+ }
290
+ /**
291
+ * The attribute every question carries in the rendered page, whichever name
292
+ * declared it, with the value `"true"`. It is how a reader of this package's
293
+ * markup finds every question: `[data-vantage-question]`
294
+ * (`VANTAGE_QUESTION_SELECTOR`).
295
+ */
296
+ const VANTAGE_QUESTION_ATTRIBUTE = "data-vantage-question";
297
+ /** `[data-vantage-question]`: every question in a rendered page. */
298
+ const VANTAGE_QUESTION_SELECTOR = `[${VANTAGE_QUESTION_ATTRIBUTE}]`;
299
+ /**
300
+ * The attribute a question declared with `oq` carries besides
301
+ * `VANTAGE_QUESTION_ATTRIBUTE`, with the value `"true"`. It means what it has
302
+ * meant since 0.7, "declared with `oq`", and nothing more: whether a question
303
+ * offers Take this leaning is read from its state (`questionOffersTake`),
304
+ * never from this attribute.
305
+ */
306
+ const VANTAGE_OQ_ATTRIBUTE = "data-vantage-oq";
307
+ /**
308
+ * The attribute that carries a question's `leaning=`, normalized
309
+ * (`normalizeLeaning`), on the element `VANTAGE_QUESTION_ATTRIBUTE` is on,
310
+ * whichever name declared it. Absent when the directive states none.
311
+ */
312
+ const VANTAGE_LEANING_ATTRIBUTE = "data-vantage-leaning";
313
+ /**
246
314
  * The `tone` vocabulary: GitHub's alert words plus `muted`.
247
315
  *
248
316
  * Semantic, never chromatic (P2, Ledger OQ-3). A document says what a section
@@ -334,13 +402,32 @@ const VANTAGE_STYLE_TARGETS = [
334
402
  "div"
335
403
  ];
336
404
  /**
337
- * The tags an `oq` directive may stamp — strictly the tags the review system
338
- * can resolve an anchor on (`ANCHOR_TAGS` in the app's `MarkdownViewer`, and the
339
- * block map in `useReviewHighlights`). `ul`, `ol`, `tr`, `hr` and `div` are in
340
- * neither, so a button on one of them would build an anchor no review pass can
341
- * find — the "mis-wired button" D6 forbids.
405
+ * The tags a `fallback` directive may **withhold**: `VANTAGE_STYLE_TARGETS`
406
+ * minus the six headings, written as a subtraction so the narrowing stays
407
+ * visible.
408
+ *
409
+ * A heading is refused because it is the document's structure rather than a
410
+ * stand-in for anything: the contents column lists it, other documents link to
411
+ * its slug, and the planning index reads sections under it. A fallback heading
412
+ * would be an anchor that exists on GitHub and nowhere in Vantage. What a
413
+ * fallback holds — a sentence naming the release a drawing needs, a link to
414
+ * the drawing as a file, an ASCII sketch in a fence — is a paragraph, a list,
415
+ * a quote, a code block, a table or a `<div>`, and those are all here.
342
416
  *
343
- * The gap between this list and `VANTAGE_STYLE_TARGETS` is why an `oq`
417
+ * Three callers read it, and they must agree: `rehypeVantageDirectives`, which
418
+ * withholds the block; the planning index's scan, which must not read a
419
+ * question or a link from a block the page never shows; and the checker's
420
+ * `vantage/orphan`, which reports a fallback that withholds nothing.
421
+ */
422
+ const VANTAGE_FALLBACK_TARGETS = VANTAGE_STYLE_TARGETS.filter((tag) => !/^h[1-6]$/.test(tag));
423
+ /**
424
+ * The tags a question directive, `oq` or `question`, may stamp — strictly the
425
+ * tags the review system can resolve an anchor on (`ANCHOR_TAGS` in the app's
426
+ * `MarkdownViewer`, and the block map in `useReviewHighlights`). `ul`, `ol`,
427
+ * `tr`, `hr` and `div` are in neither, so a button on one of them would build
428
+ * an anchor no review pass can find — the "mis-wired button" D6 forbids.
429
+ *
430
+ * The gap between this list and `VANTAGE_STYLE_TARGETS` is why a question
344
431
  * directive at column 0 above a list silently does nothing: the target is the
345
432
  * `<ul>`, not the `<li>`. The checker says so.
346
433
  */
@@ -358,9 +445,11 @@ const VANTAGE_ANCHOR_TARGETS = [
358
445
  "table"
359
446
  ];
360
447
  /**
361
- * The tags a `<!-- vantage: oq … -->` directive actually yields a *button* on —
362
- * `VANTAGE_ANCHOR_TARGETS` minus `pre` and `table`, written as an explicit
363
- * subtraction so the narrowing stays visible.
448
+ * The tags a question directive declares a question on, and so the only ones
449
+ * an open question's *button* can sit on — `VANTAGE_ANCHOR_TARGETS` minus `pre`
450
+ * and `table`, written as an explicit subtraction so the narrowing stays
451
+ * visible. One list for both names and every state, so a question that changes
452
+ * state is declared in exactly the same place.
364
453
  *
365
454
  * Anchorable and button-hosting are different questions, and this is the second
366
455
  * one. A comment *can* be anchored on a `<pre>` or a `<table>` — both are in
@@ -368,17 +457,18 @@ const VANTAGE_ANCHOR_TARGETS = [
368
457
  * button renders as part of the code, and a `<button>` child of `<table>` is not
369
458
  * valid HTML at all, so the parser hoists it out.
370
459
  *
371
- * Both consumers read it from here: `OQ_HOST_TAGS` in the app's
372
- * `useOpenQuestionButtons`, and the `oq` branch of the checker's
373
- * `vantage/orphan`. They were two hand-written lists that disagreed — the
374
- * checker called an `oq` above a fence fine while the app rendered no button
375
- * and said nothing, which is the D5 break this module exists to prevent.
460
+ * Every consumer reads it from here: `OQ_HOST_TAGS` in the app's
461
+ * `useOpenQuestionButtons`, the planning index's scan, and the question branch
462
+ * of the checker's `vantage/orphan`. They were two hand-written lists that
463
+ * disagreed — the checker called an `oq` above a fence fine while the app
464
+ * rendered no button and said nothing, which is the D5 break this module
465
+ * exists to prevent.
376
466
  */
377
467
  const VANTAGE_OQ_HOST_TARGETS = VANTAGE_ANCHOR_TARGETS.filter((tag) => tag !== "pre" && tag !== "table");
378
468
  /**
379
- * The shape of an `oq` directive's `id`: `OQ-` then an optional short uppercase
380
- * prefix then digits. `OQ-9`, `OQ-TP6` and `OQ-A03` are ids; `OQ-foo`, `OQ-tp6`
381
- * and a bare `OQ6` are not.
469
+ * The shape of a question directive's `id`, on `oq` and `question` alike:
470
+ * `OQ-` then an optional short uppercase prefix then digits. `OQ-9`, `OQ-TP6`
471
+ * and `OQ-A03` are ids; `OQ-foo`, `OQ-tp6` and a bare `OQ6` are not.
382
472
  *
383
473
  * The prefix is what keeps ids distinct once one document references another's
384
474
  * questions — `trust-paths.md`'s `OQ-4` and a design sketch's `OQ-4` are
@@ -386,10 +476,15 @@ const VANTAGE_OQ_HOST_TARGETS = VANTAGE_ANCHOR_TARGETS.filter((tag) => tag !== "
386
476
  * reference means. It is optional because most documents never leave their own
387
477
  * file, and requiring it everywhere would fire on every single-doc sketch.
388
478
  *
389
- * Three consumers read it from here and none of them may re-spell it: the
390
- * plugin that stamps the anchor, the sanitizer that allowlists the value, and
391
- * the checker's `vantage/oq-id-format`. A fourth copy is how the checker starts
392
- * calling a working anchor malformed.
479
+ * Read from here, never re-spelled, by the sanitizer that allowlists the value,
480
+ * by the checker's `collectOqIds` (which feeds `vantage/oq-id-format` and the
481
+ * anchors the checker accepts) and by the planning index. The directive plugin
482
+ * that stamps the anchor deliberately does not test it: it stamps any non-empty
483
+ * id and leaves refusing a malformed one to the sanitizer. A re-spelled copy is
484
+ * how the checker starts calling a working anchor malformed — and the patterns
485
+ * that look for an id in text (`OQ_REFERENCE` and `OQ_EXACT` in the checker's
486
+ * `rules/references.ts`, `OQ_TOKEN` in `planning/scan.ts` and in
487
+ * `FrontmatterDisplay.tsx`) restate this shape, so they change with it.
393
488
  */
394
489
  const VANTAGE_OQ_ID = /^OQ-(?:[A-Z][A-Z0-9]{0,5})?[0-9]+$/;
395
490
  /**
@@ -405,11 +500,12 @@ const VANTAGE_OQ_ID = /^OQ-(?:[A-Z][A-Z0-9]{0,5})?[0-9]+$/;
405
500
  * that a screen reader can say. They were private constants in the checker until
406
501
  * the second consumer arrived.
407
502
  *
408
- * `open` is the only state that wants a one-click answer. `settled` and
409
- * `blocked` deliberately carry no directive at all — a control offering to
410
- * answer a question that is already decided, or that cannot be answered yet, is
411
- * a lie — so the checker keys on the distinction rather than on the word
412
- * "Leaning:" alone. Either non-open marker wins when both appear on one item.
503
+ * `open` is the only state that wants a one-click answer, so it is the only one
504
+ * a viewer offers Take this leaning on (`questionOffersTake`). A `settled` or
505
+ * `blocked` question carries no control — a control offering to answer a
506
+ * question that is already decided, or that cannot be answered yet, is a lie.
507
+ * The checker keys on the distinction rather than on the word "Leaning:"
508
+ * alone. Either non-open marker wins when both appear on one item.
413
509
  */
414
510
  const VANTAGE_OQ_STATUS = {
415
511
  /** 💬 — an active decision awaiting a ruling. */
@@ -444,6 +540,20 @@ function vantageOqStatus(text) {
444
540
  if (text.includes(VANTAGE_OQ_STATUS.open)) return "open";
445
541
  return null;
446
542
  }
543
+ /**
544
+ * Whether a question in this state offers Take this leaning in review mode: an
545
+ * open one, marked 💬 or 💬 🤷, or one carrying no marker, which counts as
546
+ * open. A 🔒 question cannot be answered yet and a ✅ one has been ruled, so
547
+ * neither has a leaning left to take, whatever its directive's name.
548
+ *
549
+ * One home for the rule, because every surface that answers it must agree:
550
+ * the page's button and the planning card, the counts of questions waiting on
551
+ * a human, and `vantage-check`, which reports an `oq` on a question this says
552
+ * no to, since every viewer before 0.8 offers the button there anyway.
553
+ */
554
+ function questionOffersTake(status) {
555
+ return status === null || status === "open";
556
+ }
447
557
  const STYLE_KEYS = {
448
558
  tone: VANTAGE_TONES,
449
559
  emphasis: VANTAGE_EMPHASIS,
@@ -454,10 +564,21 @@ const STYLE_KEYS = {
454
564
  * Name → key → the closed value set for that key.
455
565
  *
456
566
  * `section` and `block` share their keys: they differ in *extent*, not in what
457
- * they can say. `oq`'s two keys are the design's only values with no closed set
458
- * — `id` is a token an author chose and `leaning` is a sentence (§8.3) — so
459
- * neither can be value-allowlisted, which is recorded here as `null` rather
460
- * than left to a caller to guess.
567
+ * they can say. The two question names share theirs too, and they are the
568
+ * design's only values with no closed set — `id` is a token an author chose
569
+ * and `leaning` is a sentence (§8.3) — so neither can be value-allowlisted,
570
+ * which is recorded here as `null` rather than left to a caller to guess.
571
+ *
572
+ * `question` takes exactly `oq`'s keys and nothing more. Starting narrow is
573
+ * the forward-compatible choice: a key added later is one older viewers drop
574
+ * pair by pair, while a key accepted now could never be given a meaning (P0).
575
+ * A `leaning` on a 🔒 or ✅ question is allowed and simply not offered
576
+ * (`questionOffersTake`), so changing a question's state changes only its
577
+ * marker.
578
+ *
579
+ * `fallback` takes no keys: whether a block is withheld is the whole of what
580
+ * it says, and a key added later is dropped pair by pair by this release, as
581
+ * D2 drops any other.
461
582
  */
462
583
  const DIRECTIVE_VOCABULARY = {
463
584
  section: STYLE_KEYS,
@@ -465,9 +586,59 @@ const DIRECTIVE_VOCABULARY = {
465
586
  oq: {
466
587
  id: null,
467
588
  leaning: null
468
- }
589
+ },
590
+ question: {
591
+ id: null,
592
+ leaning: null
593
+ },
594
+ fallback: {}
469
595
  };
470
596
  /**
597
+ * Merge the question directives of one run — the comments up to the block they
598
+ * land on — into the one question they declare, or `undefined` when the run
599
+ * holds none. Directives of any other name are skipped.
600
+ *
601
+ * A run holding an `oq` is read exactly as a viewer that predates `question`
602
+ * reads it: that viewer drops every `question` and reads the `oq` alone, so
603
+ * the `oq` wins wherever it appears in the run, and only its keys apply — a
604
+ * key only a `question` in the same run sets applies nowhere, in no release.
605
+ * That is the one reading every viewer agrees on (`VANTAGE_QUESTION_NAMES`),
606
+ * so the same bytes file the same Take in 0.7 and in 0.8. Within one name, the
607
+ * last value of a key wins, as `section` and `block` resolve theirs. A pair
608
+ * its name does not accept, or a value outside a closed set, is dropped (D2).
609
+ *
610
+ * One function for every reader of a run: the plugin that stamps the question
611
+ * and the planning index that reads it, which the checker reads a question
612
+ * through, so none can disagree about which leaning or which id a run carries
613
+ * (D5).
614
+ */
615
+ function mergeQuestionRun(run) {
616
+ let name;
617
+ for (const directive of run) {
618
+ if (!isQuestionDirective(directive.name)) continue;
619
+ if (name !== "oq") name = directive.name;
620
+ }
621
+ if (name === void 0) return void 0;
622
+ const accepted = DIRECTIVE_VOCABULARY[name] ?? {};
623
+ const keys = /* @__PURE__ */ new Map();
624
+ const sources = /* @__PURE__ */ new Map();
625
+ run.forEach((directive, index) => {
626
+ if (directive.name !== name) return;
627
+ for (const pair of directive.pairs) {
628
+ const values = accepted[pair.key];
629
+ if (values === void 0) continue;
630
+ if (values !== null && !values.includes(pair.value)) continue;
631
+ keys.set(pair.key, pair.value);
632
+ sources.set(pair.key, index);
633
+ }
634
+ });
635
+ return {
636
+ name,
637
+ keys,
638
+ sources
639
+ };
640
+ }
641
+ /**
471
642
  * `ws` is `[ \t\r\n]` — the design's grammar leaves it undefined, and `\n` has
472
643
  * to be in the set because a directive may legally wrap: a multi-line comment
473
644
  * is one node whose value contains the newlines.
@@ -583,10 +754,24 @@ function parseVantageDirective(comment) {
583
754
  pairs
584
755
  };
585
756
  }
757
+ /**
758
+ * A question directive's `leaning=` value as the page carries it: whitespace
759
+ * runs collapsed to one space, trimmed, and capped at `MAX_LEANING`.
760
+ *
761
+ * A wrapped directive puts newlines and indentation into the value. Two readers
762
+ * need the result and must agree on it: `rehypeVantageDirectives`, which stamps
763
+ * it as `data-vantage-leaning` for the one-click answer, and the planning index,
764
+ * which reports the leaning a question carries. An empty result means the
765
+ * question states no leaning.
766
+ */
767
+ function normalizeLeaning(raw) {
768
+ return raw.replace(/\s+/g, " ").trim().slice(0, 500);
769
+ }
586
770
  //#endregion
587
771
  //#region src/rehypeVantageDirectives.ts
588
772
  /**
589
- * What a `section`/`block` and an `oq` directive may **target**.
773
+ * What a `section`/`block` and a question directive (`oq`, `question`) may
774
+ * **target**.
590
775
  *
591
776
  * Both lists live in `vantageDirectives.ts`, with the reasoning for each tag,
592
777
  * because the CLI checker resolves the same question over mdast and must reach
@@ -596,6 +781,8 @@ function parseVantageDirective(comment) {
596
781
  */
597
782
  const STYLE_TARGET_TAGS = new Set(VANTAGE_STYLE_TARGETS);
598
783
  const ANCHOR_TARGET_TAGS = new Set(VANTAGE_ANCHOR_TARGETS);
784
+ /** What a `fallback` may withhold: every style target but a heading. */
785
+ const FALLBACK_TARGET_TAGS = new Set(VANTAGE_FALLBACK_TARGETS);
599
786
  const HEADING_DEPTHS = /* @__PURE__ */ new Map([
600
787
  ["h1", 1],
601
788
  ["h2", 2],
@@ -636,6 +823,19 @@ const RANGE_PROPERTIES = /* @__PURE__ */ new Map([["tone", "dataVantageTone"], [
636
823
  */
637
824
  const POINT_PROPERTIES = /* @__PURE__ */ new Map([["badge", "dataVantageBadge"]]);
638
825
  const RUN_PROPERTY = "dataVantageRun";
826
+ /**
827
+ * What a question stamps, whichever name declared it: `data-vantage-question`
828
+ * on every question, and `data-vantage-oq` besides on one an `oq` declared
829
+ * (`VANTAGE_QUESTION_ATTRIBUTE`, `VANTAGE_OQ_ATTRIBUTE`).
830
+ *
831
+ * Two attributes, and neither one ever means more than it says. Every release
832
+ * since 0.7 stamps `data-vantage-oq` on an `oq` and on nothing else, so a
833
+ * reader of this package's markup that has ever read it keeps reading exactly
834
+ * what it read; `data-vantage-question` is the one to read for "a question",
835
+ * and the question's state, from its marker, is what decides whether anything
836
+ * offers to answer it (`questionOffersTake`).
837
+ */
838
+ const QUESTION_PROPERTY = "dataVantageQuestion";
639
839
  const OQ_PROPERTY = "dataVantageOq";
640
840
  const LEANING_PROPERTY = "dataVantageLeaning";
641
841
  /**
@@ -670,8 +870,6 @@ const OQ_ID_PROPERTY = "dataVantageOqId";
670
870
  const COLLAPSED_PROPERTY = "dataVantageCollapsed";
671
871
  const COLLAPSE_GROUP_PROPERTY = "dataVantageCollapseGroup";
672
872
  const COLLAPSE_TOGGLE_PROPERTY = "dataVantageCollapseToggle";
673
- /** A review-comment body, not prose. Bounds the attribute; 500 is generous. */
674
- const MAX_LEANING = 500;
675
873
  /**
676
874
  * Nodes that may sit between a directive and its target.
677
875
  *
@@ -805,13 +1003,14 @@ function stampStyle(children, targetIndex, name, pairs, state) {
805
1003
  }
806
1004
  }
807
1005
  }
808
- function stampOq(target, pairs) {
809
- setProperty(target, OQ_PROPERTY, "true");
1006
+ function stampQuestion(target, name, pairs) {
1007
+ setProperty(target, QUESTION_PROPERTY, "true");
1008
+ if (name === "oq") setProperty(target, OQ_PROPERTY, "true");
810
1009
  const id = pairs.get("id");
811
1010
  if (id !== void 0 && id !== "") setProperty(target, OQ_ID_PROPERTY, id);
812
1011
  const leaning = pairs.get("leaning");
813
1012
  if (leaning === void 0) return;
814
- const text = leaning.replace(/\s+/g, " ").trim().slice(0, MAX_LEANING);
1013
+ const text = normalizeLeaning(leaning);
815
1014
  if (text !== "") setProperty(target, LEANING_PROPERTY, text);
816
1015
  }
817
1016
  /**
@@ -826,18 +1025,14 @@ function stampOq(target, pairs) {
826
1025
  function stampRun(children, targetIndex, run, state) {
827
1026
  const target = children[targetIndex];
828
1027
  const style = /* @__PURE__ */ new Map();
829
- const oq = /* @__PURE__ */ new Map();
830
1028
  let styleName;
831
- let hasOq = false;
832
1029
  for (const directive of run) if (directive.name === "section" || directive.name === "block") {
833
1030
  styleName = directive.name;
834
1031
  for (const pair of directive.pairs) style.set(pair.key, pair.value);
835
- } else if (directive.name === "oq") {
836
- hasOq = true;
837
- for (const pair of directive.pairs) oq.set(pair.key, pair.value);
838
1032
  }
839
1033
  if (styleName !== void 0) stampStyle(children, targetIndex, styleName, style, state);
840
- if (hasOq && ANCHOR_TARGET_TAGS.has(target.tagName)) stampOq(target, oq);
1034
+ const question = mergeQuestionRun(run);
1035
+ if (question !== void 0 && ANCHOR_TARGET_TAGS.has(target.tagName)) stampQuestion(target, question.name, question.keys);
841
1036
  }
842
1037
  /** A directive comment, or `undefined` for anything else — malformed included. */
843
1038
  function directiveOf(node) {
@@ -850,7 +1045,7 @@ function directiveOf(node) {
850
1045
  *
851
1046
  * The whole tree, not just the root: `rehype-raw` leaves comment nodes inside
852
1047
  * `blockquote`, inside `li`, inside `td` and inline inside `p`, and the real
853
- * Open Questions layout puts the `oq` directive inside a list item — so a
1048
+ * Open Questions layout puts the question directive inside a list item — so a
854
1049
  * root-only walk finds none of them.
855
1050
  *
856
1051
  * Pass order is also what resolves a nested section: an inner heading's
@@ -889,8 +1084,66 @@ function processChildren(parent, state) {
889
1084
  i = j;
890
1085
  }
891
1086
  }
1087
+ /**
1088
+ * Remove every block a `fallback` run lands on, with the run itself.
1089
+ *
1090
+ * A fallback block is written for the renderers that cannot show something
1091
+ * Vantage shows — "This drawing needs Vantage 0.8 or later", beside an inline
1092
+ * `<svg>`. Every renderer that does not know the name drops the comment and
1093
+ * shows the block; this one never renders it, in any mode, in print, or
1094
+ * through any of the renderers that share this plugin (D5).
1095
+ *
1096
+ * The run is found exactly as `processChildren` finds one — the same skip
1097
+ * rules, the same first-element target, the same whole-tree walk — and it
1098
+ * withholds when any directive in it is a `fallback` and the target is on
1099
+ * `VANTAGE_FALLBACK_TARGETS`. Anything else is left for the stamping pass,
1100
+ * where a `fallback` is inert.
1101
+ *
1102
+ * A pass of its own, and first, because a `section` reaches *forward* over its
1103
+ * siblings: stamped in one pass, a toned section would count a fallback block
1104
+ * after it among its members, give it the run's last `end` marker, and leave
1105
+ * the last block anyone sees marked `middle`. Withheld first, the block was
1106
+ * never there for any later decision — the section's extent, its run markers,
1107
+ * a collapse group's members, a review anchor or an outline entry.
1108
+ */
1109
+ function withholdFallbacks(parent) {
1110
+ const children = parent.children;
1111
+ let i = 0;
1112
+ while (i < children.length) {
1113
+ const node = children[i];
1114
+ if (node.type === "element") {
1115
+ withholdFallbacks(node);
1116
+ i++;
1117
+ continue;
1118
+ }
1119
+ const first = directiveOf(node);
1120
+ if (first === void 0) {
1121
+ i++;
1122
+ continue;
1123
+ }
1124
+ let withholds = first.name === "fallback";
1125
+ let j = i + 1;
1126
+ let targetIndex = -1;
1127
+ for (; j < children.length; j++) {
1128
+ const next = children[j];
1129
+ if (next.type === "element") {
1130
+ targetIndex = j;
1131
+ break;
1132
+ }
1133
+ if (!isSkippable(next)) break;
1134
+ if (directiveOf(next)?.name === "fallback") withholds = true;
1135
+ }
1136
+ const target = targetIndex >= 0 ? children[targetIndex] : void 0;
1137
+ if (withholds && target?.type === "element" && FALLBACK_TARGET_TAGS.has(target.tagName)) {
1138
+ children.splice(i, targetIndex - i + 1);
1139
+ continue;
1140
+ }
1141
+ i = j;
1142
+ }
1143
+ }
892
1144
  const rehypeVantageDirectives = () => {
893
1145
  return (tree) => {
1146
+ withholdFallbacks(tree);
894
1147
  processChildren(tree, { nextGroup: 1 });
895
1148
  };
896
1149
  };
@@ -983,12 +1236,144 @@ const rehypeRestoreMathStamps = () => {
983
1236
  };
984
1237
  //#endregion
985
1238
  //#region src/sanitize.ts
1239
+ /** Whether an attribute definition is for `className`. */
1240
+ const isClassName = (definition) => (typeof definition === "string" ? definition : definition[0]) === "className";
986
1241
  /**
987
- * Sanitization schema for the rendering pipeline.
988
- * Allows GFM, KaTeX MathML, syntax highlighting classes, and
989
- * data-source-line attributes while blocking XSS vectors.
1242
+ * The value list the default schema gives `className` on `tag`, or an empty
1243
+ * list if it gives none.
990
1244
  */
991
- const DECLARATION = `(?:(?:${[
1245
+ function defaultClasses(tag) {
1246
+ const entry = defaultSchema.attributes?.[tag]?.find(isClassName);
1247
+ return Array.isArray(entry) ? entry.slice(1) : [];
1248
+ }
1249
+ /**
1250
+ * The only classes an element keeps: the ones the pipeline itself emits on it
1251
+ * before `rehypeSanitize` runs. Every other element keeps none.
1252
+ *
1253
+ * **A class is a style by another name.** The app ships Tailwind's utility CSS
1254
+ * for its own markup, so `<div class="fixed inset-0 z-50 bg-white">` in a
1255
+ * document laid a white sheet over the whole Vantage window, header and
1256
+ * sidebar included. That is the overlay `SAFE_STYLE` bans `position` to
1257
+ * prevent, spelled as a class instead of a declaration, and `className` used
1258
+ * to be admitted with any value on every element. A document could also
1259
+ * borrow a class the app's own code looks for: `.overflow-y-auto` is how an
1260
+ * anchor link finds the element to scroll when no `data-content-scroll` is in
1261
+ * reach, and `.heading-anchor` is what the outline strips from a heading's
1262
+ * text. The comment-body sanitizer (`frontend/src/lib/commentMarkdown.ts`)
1263
+ * already refused `class` for the same reason.
1264
+ *
1265
+ * What each entry is for, and who emits it:
1266
+ *
1267
+ * - `code`: `language-*`, from a fence's info string (`mdast-util-to-hast`).
1268
+ * `rehype-highlight` reads it, `rehype-katex` keys on `language-math`, and
1269
+ * the viewers render `language-mermaid` as a diagram.
1270
+ * - `ul`, `ol` and `li`: `contains-task-list` and `task-list-item`, from GFM
1271
+ * task lists. `styles/task-list.css` draws the checkbox from them.
1272
+ * - `section`, `h2` and `a`: GFM footnotes (`mdast-util-to-hast`), on the
1273
+ * section, its label and each back-reference.
1274
+ * - `div`: `ALERT_TITLE_CLASS`, on the title `rehypeVantageAlerts` injects.
1275
+ *
1276
+ * `remark-math` also emits `math-display` and `math-inline` on its `code`.
1277
+ * They were never kept, because the default schema's `code` entry takes only
1278
+ * `language-*`, and nothing needs them: `rehype-katex` keys on `language-math`
1279
+ * and tells display from inline by whether the `code` sits in a `pre`.
1280
+ *
1281
+ * **A class added after the sanitizer needs no entry**, and that is most of
1282
+ * the classes the page styles: `rehype-highlight`'s `hljs-*`, KaTeX's output,
1283
+ * Mermaid's diagrams, and everything the app's components and hooks add. A
1284
+ * document that writes one of those on an element keeps nothing.
1285
+ *
1286
+ * **Mermaid source is the route this list cannot close.** Mermaid renders
1287
+ * after the sanitizer and copies the names a diagram's source gives a node
1288
+ * (`:::name`, `class A name`, `classDef`) onto that node, so a diagram can
1289
+ * carry any of the app's utilities on its own nodes. The diagram bounds it:
1290
+ * `position` does nothing on an SVG group, and the diagram's `svg` clips what
1291
+ * is inside it. `frontend/e2e/mermaid.spec.ts` measures that bound. The
1292
+ * stylesheet a diagram's source could write did reach past the diagram, and
1293
+ * `MERMAID_SECURE_KEYS` in `mermaidLoader.ts` refuses the settings that write
1294
+ * it.
1295
+ *
1296
+ * A document may still write these names on these elements. By the time the
1297
+ * sanitizer runs, a hand-written `<li class="task-list-item">` and the one GFM
1298
+ * emitted are the same node, and none of the names can lay an element over
1299
+ * anything outside the document's scroll container. `styles/task-list.css`
1300
+ * floats the checkbox of a `task-list-item` instead of positioning it
1301
+ * absolutely, so it stays in the document's flow whatever the item's own
1302
+ * `style` does, and the checkbox takes no `style` itself (see
1303
+ * `UNSTYLED_TAGS`). The footnote label's class, where a stylesheet defines it,
1304
+ * clips its element to a single pixel.
1305
+ *
1306
+ * **The footnote label's class is read from the default schema, never
1307
+ * spelled here, and the tests spell it in halves.** It is a Tailwind utility,
1308
+ * and Tailwind generates a utility for every class name it finds in the files
1309
+ * it scans, comments included — this directory and all of `frontend/`. The app
1310
+ * renders the label as a visible heading only because nothing it scans spells
1311
+ * that name. Spelled whole in a test, it generated the rule, and the label
1312
+ * would have been hidden from sight as it is on GitHub: a change of rendering,
1313
+ * not a sanitizer fix.
1314
+ *
1315
+ * The list is measured, not remembered. The test "takes no class from
1316
+ * anything the pipeline emits" in `frontend/src/lib/sanitize.test.ts` records
1317
+ * every class on the tree on both sides of `rehypeSanitize`, over a document
1318
+ * that uses every feature that emits one, so a plugin that starts emitting a
1319
+ * new class fails there instead of losing it in every renderer.
1320
+ */
1321
+ const PIPELINE_CLASSES = {
1322
+ code: [/^language-./],
1323
+ ul: ["contains-task-list"],
1324
+ ol: ["contains-task-list"],
1325
+ li: ["task-list-item"],
1326
+ section: ["footnotes"],
1327
+ h2: defaultClasses("h2"),
1328
+ a: ["data-footnote-backref"],
1329
+ div: [ALERT_TITLE_CLASS]
1330
+ };
1331
+ /**
1332
+ * `tag`'s attributes: the default schema's, less any `className`, then its
1333
+ * `PIPELINE_CLASSES` entry, then `extra`.
1334
+ *
1335
+ * **Always a value list, never a bare `"className"`.** `hast-util-sanitize`
1336
+ * reads a bare name, or a tuple holding no values, as "any value", and an
1337
+ * element's own entry is consulted before `*`'s — the first match wins, and
1338
+ * `*` is not consulted when that element's own entry filters a class list down
1339
+ * to nothing. An element with no entry has none added.
1340
+ */
1341
+ function withClasses(tag, ...extra) {
1342
+ const classes = PIPELINE_CLASSES[tag] ?? [];
1343
+ const own = (defaultSchema.attributes?.[tag] ?? []).filter((definition) => !isClassName(definition));
1344
+ if (classes.length > 0) own.push(["className", ...classes]);
1345
+ return [...own, ...extra];
1346
+ }
1347
+ /**
1348
+ * CSS properties an inline `style` may set.
1349
+ *
1350
+ * **The only `style` this list ever filters is one a document wrote by hand.**
1351
+ * Nothing the pipeline generates reaches it: `rehypeKatex` and `rehypeHighlight`
1352
+ * both run *after* `rehypeSanitize` (`pipeline.ts`), so their output is trusted
1353
+ * rather than filtered, and `remark-gfm` emits table alignment as an `align`
1354
+ * attribute rather than as CSS. So this is a filter on untrusted author HTML and
1355
+ * nothing else — which is exactly the hole that made it necessary: `<div
1356
+ * style="position:fixed;inset:0">` covered the viewport and
1357
+ * `style="background:url(https://…)"` called home on render, both verbatim,
1358
+ * because `rehype-sanitize` does not parse CSS. Scripts were never the risk
1359
+ * here; layout and network were.
1360
+ *
1361
+ * The list is therefore deliberately typographic: the styling a prose document
1362
+ * has any business asking for. It is *not* sized to KaTeX, and a KaTeX release
1363
+ * that starts using a new property is a non-event here — `\pmb` already emits
1364
+ * `text-shadow`, which is not on this list and renders anyway.
1365
+ *
1366
+ * **The design doc used to argue the opposite — that `style` had to be allowed
1367
+ * and `position` enumerated because KaTeX needs them — and it was wrong.** The
1368
+ * measurement behind it was real (KaTeX does emit `position:relative` on every
1369
+ * integral) but the inference was not, because the sanitizer has finished before
1370
+ * the first KaTeX span exists. Rebuilding the shipped rehype order with a filter
1371
+ * that rejects *every* value leaves all ten of the integral's style attributes
1372
+ * untouched. The "Security" section of `docs/reference/inline-markup.md`
1373
+ * records the correction; the test that would catch a reordering is in
1374
+ * `frontend/src/lib/sanitize.test.ts`.
1375
+ */
1376
+ const SAFE_STYLE_PROPERTIES = [
992
1377
  "height",
993
1378
  "min-height",
994
1379
  "max-height",
@@ -1043,12 +1428,89 @@ const DECLARATION = `(?:(?:${[
1043
1428
  "white-space",
1044
1429
  "vertical-align",
1045
1430
  "list-style-type",
1046
- "display",
1047
1431
  "float",
1048
1432
  "clear",
1049
1433
  "opacity",
1050
1434
  "overflow"
1051
- ].join("|")})\\s*:[^;:()"'\\\\]*)`;
1435
+ ];
1436
+ /**
1437
+ * A `style` value we will keep, as a whole.
1438
+ *
1439
+ * One rule beyond the property list does the work: **no parentheses anywhere**,
1440
+ * which closes `url(…)` and `expression(…)` in one stroke — the network and
1441
+ * legacy-script vectors. Its cost is borne entirely by authors, who lose
1442
+ * `calc()`, `rgb()` and `var()` along with them; that is the trade, and it is
1443
+ * worth it for a filter this small.
1444
+ *
1445
+ * **`position` is not on the property list at all**, so every value of it is
1446
+ * refused — `static` and `relative` along with `fixed` and `sticky`. It used to
1447
+ * be enumerated, on the belief that KaTeX needed `relative`; KaTeX renders after
1448
+ * the sanitizer and never meets this regex, so the enumeration was buying
1449
+ * nothing but the residual it conceded. Banning the property closes that
1450
+ * residual, and it is bigger than "overlaps its neighbors" made it sound:
1451
+ * measured in Chrome against the viewer's real ancestor chain, an author's
1452
+ * `position:absolute;top:0;left:0;width:100%;height:100%` is sized to the whole
1453
+ * content pane (the nearest positioned ancestor is outside the scroll
1454
+ * container), is not clipped by the scroller, and survives scrolling to the end
1455
+ * of the document. It was `position:fixed` in all but the keyword.
1456
+ *
1457
+ * Matching is all-or-nothing: one unrecognized declaration drops the whole
1458
+ * attribute, and the element renders unstyled rather than partly styled. That
1459
+ * is the safe direction to fail, and it degrades to plain text rather than to a
1460
+ * broken page.
1461
+ *
1462
+ * **The grammar must stay unambiguous, and `;` is what keeps it so.** The value
1463
+ * class is "anything but the delimiters", which includes whitespace — a value
1464
+ * legitimately contains it (`margin: 0 auto`). So if whitespace could *also*
1465
+ * end a declaration, both constructs would compete for the same characters and
1466
+ * the match would fork at every declaration; on a value that ultimately fails,
1467
+ * the engine explores every fork. An earlier form of this regex separated
1468
+ * declarations with `\s*;?\s*`, and 200 document-controlled characters took the
1469
+ * renderer — and the CLI checker, and therefore CI — 94 seconds. Requiring `;`
1470
+ * pins each declaration's extent to the delimiter positions, so there is exactly
1471
+ * one way to parse any input and rejection is linear. `VALUE` absorbs the
1472
+ * padding on both sides for the same reason: a separate `\\s*` next to it would
1473
+ * put the ambiguity straight back. Pinned by the flat-time test in
1474
+ * `frontend/src/lib/sanitize.test.ts` — do not loosen the separator.
1475
+ *
1476
+ * Residual, stated plainly and now genuinely small: negative `margin` still lets
1477
+ * an element overlap its neighbors *inside the flow*. That one scrolls with the
1478
+ * content and is clipped by the scroll container, and closing it means giving up
1479
+ * margins, which prose actually uses. Containment in the stylesheet, not another
1480
+ * rule here, is what would close it. `overflow: visible` on an inline `<svg>` is
1481
+ * the same residual by another route — see `SVG_ROOT_ATTRIBUTES`.
1482
+ *
1483
+ * **The app's table of contents is inside that scroll container too**, beside
1484
+ * the document column, so a block carried over it took its clicks whenever the
1485
+ * block painted in the positioned layer: one with `opacity`, a toned block, a
1486
+ * heading, or a link to anywhere. The contents sit at `z-10` for that
1487
+ * (`frontend/src/components/TableOfContents.tsx`), and a document cannot climb
1488
+ * back over them only because `z-index` is not on the property list.
1489
+ */
1490
+ const VALUE = `[^;:()"'\\\\]*`;
1491
+ const DISPLAY_DECLARATION = `display\\s*:\\s*(?:${[
1492
+ "none",
1493
+ "block",
1494
+ "inline-block",
1495
+ "inline-flex",
1496
+ "inline-grid",
1497
+ "inline-table",
1498
+ "inline",
1499
+ "flow-root",
1500
+ "flex",
1501
+ "grid",
1502
+ "table-row-group",
1503
+ "table-row",
1504
+ "table-header-group",
1505
+ "table-footer-group",
1506
+ "table-cell",
1507
+ "table-column-group",
1508
+ "table-column",
1509
+ "table-caption",
1510
+ "table",
1511
+ "list-item"
1512
+ ].join("|")})\\s*(?:!\\s*important\\s*)?`;
1513
+ const DECLARATION = `(?:(?:${SAFE_STYLE_PROPERTIES.join("|")})\\s*:${VALUE}|${DISPLAY_DECLARATION})`;
1052
1514
  const SAFE_STYLE = new RegExp(`^\\s*(?:${DECLARATION};\\s*)*${DECLARATION}?$`, "i");
1053
1515
  /**
1054
1516
  * A collapse group id: one or more digits, anchored.
@@ -1060,6 +1522,281 @@ const SAFE_STYLE = new RegExp(`^\\s*(?:${DECLARATION};\\s*)*${DECLARATION}?$`, "
1060
1522
  */
1061
1523
  const COLLAPSE_GROUP_ID = /^[0-9]+$/;
1062
1524
  /**
1525
+ * Inline SVG, admitted as static drawing and nothing else.
1526
+ *
1527
+ * The allowlist is shapes, text and grouping. Everything in SVG that can run
1528
+ * code, fetch, or reach outside the element is refused: `script`,
1529
+ * `foreignObject`, `use` and `image` (both take an `href`), `xlink:href` on
1530
+ * `a`, the `animate`/`set` family (which can rewrite an `href` after the
1531
+ * sanitizer has looked), `style` elements, and every attribute that takes a
1532
+ * `url(…)` reference — `filter`, `mask`, `clip-path`, `marker-*`, `cursor`.
1533
+ *
1534
+ * Gradients, patterns and `<defs>` are out for a second reason: they are only
1535
+ * reachable through `url(#id)`, and the sanitizer prefixes every `id` with
1536
+ * `user-content-`, so the reference would dangle even if it were allowed.
1537
+ *
1538
+ * **Refusing an element is not the same as removing it.** `hast-util-sanitize`
1539
+ * *unwraps* an element it does not allow — drops the tag and keeps its
1540
+ * children — unless the tag is on `strip`. See `SVG_STRIPPED` for why that
1541
+ * matters here, and for the pass that removes those containers instead.
1542
+ *
1543
+ * `switch` is admitted, as a plain container, because draw.io wraps every label
1544
+ * in one: `<switch><foreignObject>…</foreignObject><text>…</text></switch>`. A
1545
+ * `switch` renders only its first child whose conditions hold, and with the
1546
+ * `foreignObject` removed and the conditional attributes (`requiredFeatures`,
1547
+ * `systemLanguage`) refused, that is the `<text>` fallback. Unwrapped instead,
1548
+ * it would render *every* branch — including the "Text is not SVG - cannot
1549
+ * display" notice draw.io appends for viewers without `foreignObject`.
1550
+ *
1551
+ * Every child requires an `svg` ancestor, so no shape or text element is
1552
+ * admitted into HTML flow on its own.
1553
+ *
1554
+ * `title` and `desc` are not children at all. They are admitted nowhere, inside
1555
+ * a drawing they are removed with their contents (see `SVG_STRIPPED`), and
1556
+ * `aria-label` on the root is the drawing's accessible name. Both are HTML
1557
+ * integration points — the parser reads what is inside them as HTML — and an
1558
+ * `svg` ancestor turned out not to be enough to keep a `<title>` in SVG
1559
+ * context. `<math><svg><title>` keeps MathML context, and React hoists any
1560
+ * `title` outside SVG context into the page's `<head>`, so the document set the
1561
+ * tab title. In `renderMarkdown`'s string output, `<svg><desc><title>`,
1562
+ * `<svg><title><title>` and a `title` inside a `foreignObject` all re-parse into
1563
+ * an HTML `title`, which is what `document.title` reads on a page that has none
1564
+ * of its own.
1565
+ */
1566
+ const SVG_CHILD_TAGS = [
1567
+ "g",
1568
+ "path",
1569
+ "rect",
1570
+ "circle",
1571
+ "ellipse",
1572
+ "line",
1573
+ "polyline",
1574
+ "polygon",
1575
+ "text",
1576
+ "tspan",
1577
+ "switch"
1578
+ ];
1579
+ /**
1580
+ * Refused SVG elements removed *with their contents* when they sit inside an
1581
+ * `<svg>`, rather than unwrapped.
1582
+ *
1583
+ * Each of these holds children that are never meant to be painted where they
1584
+ * stand: a clip region, a mask, a gradient's stops, a marker's arrowhead, a
1585
+ * symbol's body, a filter's primitives, an exporter's RDF, the text of a
1586
+ * `title` or `desc` (see `SVG_CHILD_TAGS`), or — for `foreignObject` — HTML.
1587
+ * Unwrapped, those children land in the drawing as ordinary shapes. A default
1588
+ * Figma export ends in `<defs><clipPath><rect fill="white"/>`, and unwrapping
1589
+ * it painted that white rect over the whole drawing; a `<marker>` arrowhead
1590
+ * became a stray triangle at the origin. The HTML inside a `foreignObject`
1591
+ * survived as descendants of `svg`, which React then created as invisible
1592
+ * SVG-namespace `div`s and `p`s — the anchor an Open Question button looks for
1593
+ * — and which a browser re-parsing `renderMarkdown`'s string output breaks out
1594
+ * of the `svg` at, spilling the rest of the drawing into the page.
1595
+ *
1596
+ * **Only inside an `<svg>`, which is why this is a pass and not the schema's
1597
+ * `strip`.** `strip` removes a tag wherever it stands, and most of these names
1598
+ * are lowercase in prose too. There a bare `<pattern>` on a line of its own is
1599
+ * an HTML element nothing closes, so every Markdown block after it was parsed
1600
+ * into it, and stripping it removed the rest of the document — in every
1601
+ * renderer, with `vantage-check` silent. Inside a drawing the same container is
1602
+ * bounded by the `svg`, because a Markdown paragraph or heading breaks out of
1603
+ * SVG. Outside one the sanitizer unwraps these like any tag it does not know:
1604
+ * `title` and `desc` are not admitted anywhere, so no `title` element survives
1605
+ * to be hoisted, and what they held is text or ordinary sanitized HTML.
1606
+ *
1607
+ * Names compare case-insensitively. The camel-cased ones are how the HTML
1608
+ * parser spells these elements in SVG, but under `<math><svg>` the parser stays
1609
+ * in MathML and leaves them lowercase.
1610
+ */
1611
+ const SVG_STRIPPED = [
1612
+ "foreignObject",
1613
+ "defs",
1614
+ "clipPath",
1615
+ "mask",
1616
+ "pattern",
1617
+ "marker",
1618
+ "symbol",
1619
+ "linearGradient",
1620
+ "radialGradient",
1621
+ "filter",
1622
+ "metadata",
1623
+ "title",
1624
+ "desc"
1625
+ ];
1626
+ const SVG_STRIPPED_NAMES = new Set(SVG_STRIPPED.map((name) => name.toLowerCase()));
1627
+ /**
1628
+ * The camel-cased `SVG_STRIPPED` names, which the schema strips on its own.
1629
+ *
1630
+ * Only an SVG-namespace element carries one of these spellings, so stripping
1631
+ * them everywhere costs prose nothing. It keeps the worst of the list — the
1632
+ * HTML inside a `foreignObject` — out of a drawing for a consumer who hands
1633
+ * `sanitizeSchema` to `rehype-sanitize` without the rest of `buildPipeline`.
1634
+ */
1635
+ const SVG_STRIPPED_EVERYWHERE = SVG_STRIPPED.filter((name) => name !== name.toLowerCase());
1636
+ function stripSvgContainers(parent, inSvg) {
1637
+ if (inSvg) parent.children = parent.children.filter((child) => child.type !== "element" || !SVG_STRIPPED_NAMES.has(child.tagName.toLowerCase()));
1638
+ for (const child of parent.children) if (child.type === "element") stripSvgContainers(child, inSvg || child.tagName.toLowerCase() === "svg");
1639
+ }
1640
+ /**
1641
+ * Remove every `SVG_STRIPPED` element below an `svg`, with its contents.
1642
+ *
1643
+ * Runs immediately before `rehypeSanitize` (`pipeline.ts`), and only when the
1644
+ * pipeline sanitizes: with the sanitizer off these elements are left to work
1645
+ * as SVG defines them.
1646
+ */
1647
+ function rehypeStripSvgContainers() {
1648
+ return (tree) => {
1649
+ stripSvgContainers(tree, false);
1650
+ };
1651
+ }
1652
+ /**
1653
+ * A paint value: a keyword, a named color, or a hex color. No parentheses, for
1654
+ * the same reason `SAFE_STYLE` refuses them — `fill="url(https://…)"` is a
1655
+ * render-time fetch, and `rgb()` is the price of closing it.
1656
+ */
1657
+ const SVG_PAINT = /^(?:#[0-9a-f]{3,8}|[a-z]+)$/i;
1658
+ /**
1659
+ * Attributes any SVG element may carry, drawing and typography alike.
1660
+ *
1661
+ * **Each entry is a hast property name, which is not always the camel case of
1662
+ * the attribute.** `property-information` decides the spelling, and it has
1663
+ * `strokeLineCap` for `stroke-linecap` — a naive `strokeLinecap` matches
1664
+ * nothing, and the attribute is dropped in every renderer without a word. The
1665
+ * table-driven test in `frontend/src/lib/sanitize.test.ts` renders every entry
1666
+ * in its written spelling, so a misspelled one fails there.
1667
+ *
1668
+ * `stroke-dasharray` (and with it `stroke-dashoffset`, which does nothing
1669
+ * alone) is refused, because it lets a few bytes buy an unbounded paint. The
1670
+ * number of dashes is the path's length over the dash period, both in user
1671
+ * units the document chooses. Measured in headless Chromium, fifty 90-byte
1672
+ * paths with a 0.0011 dash took 21 s to paint and a hundred took 37 s. A value
1673
+ * grammar cannot bound it: refusing sub-unit dashes is undone by scaling the
1674
+ * user units, and the same fifty paths with `stroke-dasharray="1.1"` under a
1675
+ * `viewBox` a thousand times larger still took 15 s.
1676
+ */
1677
+ const SVG_ATTRIBUTES = [
1678
+ ["fill", SVG_PAINT],
1679
+ ["stroke", SVG_PAINT],
1680
+ "fillOpacity",
1681
+ "fillRule",
1682
+ "strokeOpacity",
1683
+ "strokeWidth",
1684
+ "strokeLineCap",
1685
+ "strokeLineJoin",
1686
+ "opacity",
1687
+ "transform",
1688
+ "fontFamily",
1689
+ "fontSize",
1690
+ "fontWeight",
1691
+ "fontStyle",
1692
+ "letterSpacing",
1693
+ "textAnchor",
1694
+ "dominantBaseline",
1695
+ "x",
1696
+ "y",
1697
+ "x1",
1698
+ "y1",
1699
+ "x2",
1700
+ "y2",
1701
+ "cx",
1702
+ "cy",
1703
+ "r",
1704
+ "rx",
1705
+ "ry",
1706
+ "dx",
1707
+ "dy",
1708
+ "d",
1709
+ "points"
1710
+ ];
1711
+ /**
1712
+ * The root `<svg>`'s attributes: `SVG_ATTRIBUTES` less `transform`, plus the
1713
+ * viewport, a `role` from a closed list, and the accessible name.
1714
+ *
1715
+ * `transform` on the root is not an SVG transform at all. The root is an
1716
+ * in-flow CSS box, and the attribute becomes a CSS transform of that box, so
1717
+ * `<svg width="10" height="10" transform="translate(-300 -300) scale(80)">`
1718
+ * laid out as a 10px box and painted as an 800px one over the paragraphs
1719
+ * around it. On a child it moves shapes inside the viewport, which clips them.
1720
+ *
1721
+ * Residual: that clip is the root's `overflow`, which `SAFE_STYLE` lets a
1722
+ * document set. `style="overflow:visible"` on the root lets a child with a
1723
+ * large `transform` or large coordinates paint over its neighbors — measured in
1724
+ * Chromium, the same overlap. It is the negative-`margin` residual described at
1725
+ * `SAFE_STYLE` by another route: it stays in the flow, scrolls with the content,
1726
+ * and the scroll container clips it.
1727
+ */
1728
+ const SVG_ROOT_ATTRIBUTES = [
1729
+ ...SVG_ATTRIBUTES.filter((attribute) => attribute !== "transform"),
1730
+ "xmlns",
1731
+ "viewBox",
1732
+ "preserveAspectRatio",
1733
+ [
1734
+ "role",
1735
+ "img",
1736
+ "presentation",
1737
+ "none"
1738
+ ],
1739
+ "ariaLabel"
1740
+ ];
1741
+ /** Every element the sanitizer admits. */
1742
+ const TAG_NAMES = [
1743
+ ...defaultSchema.tagNames || [],
1744
+ "math",
1745
+ "semantics",
1746
+ "mrow",
1747
+ "mi",
1748
+ "mo",
1749
+ "mn",
1750
+ "msup",
1751
+ "msub",
1752
+ "mfrac",
1753
+ "mover",
1754
+ "munder",
1755
+ "msqrt",
1756
+ "mroot",
1757
+ "mtable",
1758
+ "mtr",
1759
+ "mtd",
1760
+ "mtext",
1761
+ "mspace",
1762
+ "annotation",
1763
+ "figure",
1764
+ "figcaption",
1765
+ "summary",
1766
+ "details",
1767
+ "svg",
1768
+ ...SVG_CHILD_TAGS
1769
+ ];
1770
+ /**
1771
+ * The admitted elements that take no `style`: only `input`, which GFM emits as
1772
+ * a task list's checkbox, and never with a style.
1773
+ *
1774
+ * `styles/task-list.css` draws that checkbox and positions it, so on it `top`
1775
+ * and `left` are not the no-ops they are on an element nothing positions, and
1776
+ * with `width`, `height` and `background-color` beside them a document laid a
1777
+ * box of its own over the page. Measured in Chromium at 1280x800 while the
1778
+ * checkbox was positioned absolutely,
1779
+ * `top:-3000px;left:-3000px;width:9000px;height:9000px;background-color:white`
1780
+ * on it covered the whole content pane. It needed no class written:
1781
+ * GFM puts `task-list-item` on the item, and a checkbox typed into the item's
1782
+ * text is a child of that item too.
1783
+ *
1784
+ * **This is why `style` is on each element's own entry, never on `*`.**
1785
+ * `hast-util-sanitize` consults `*` whenever an element's own entry yields
1786
+ * nothing, so no entry on `input` could refuse a value that `*` admits. An
1787
+ * element takes `style` only by being in `TAG_NAMES` and not in this set.
1788
+ */
1789
+ const UNSTYLED_TAGS = /* @__PURE__ */ new Set(["input"]);
1790
+ /**
1791
+ * `attributes`, with `["style", SAFE_STYLE]` added to the entry of every
1792
+ * element in `TAG_NAMES` but the `UNSTYLED_TAGS`.
1793
+ */
1794
+ function withStyle(attributes) {
1795
+ const styled = { ...attributes };
1796
+ for (const tag of new Set(TAG_NAMES)) if (!UNSTYLED_TAGS.has(tag)) styled[tag] = [...styled[tag] ?? [], ["style", SAFE_STYLE]];
1797
+ return styled;
1798
+ }
1799
+ /**
1063
1800
  * Never set `allowComments` here.
1064
1801
  *
1065
1802
  * `hast-util-sanitize` drops comment nodes because that boolean defaults to
@@ -1073,38 +1810,16 @@ const COLLAPSE_GROUP_ID = /^[0-9]+$/;
1073
1810
  */
1074
1811
  const sanitizeSchema = {
1075
1812
  ...defaultSchema,
1076
- tagNames: [
1077
- ...defaultSchema.tagNames || [],
1078
- "math",
1079
- "semantics",
1080
- "mrow",
1081
- "mi",
1082
- "mo",
1083
- "mn",
1084
- "msup",
1085
- "msub",
1086
- "mfrac",
1087
- "mover",
1088
- "munder",
1089
- "msqrt",
1090
- "mroot",
1091
- "mtable",
1092
- "mtr",
1093
- "mtd",
1094
- "mtext",
1095
- "mspace",
1096
- "annotation",
1097
- "figure",
1098
- "figcaption",
1099
- "summary",
1100
- "details"
1101
- ],
1102
- attributes: {
1813
+ strip: [...defaultSchema.strip || [], ...SVG_STRIPPED_EVERYWHERE],
1814
+ tagNames: TAG_NAMES,
1815
+ ancestors: {
1816
+ ...defaultSchema.ancestors,
1817
+ ...Object.fromEntries(SVG_CHILD_TAGS.map((tag) => [tag, ["svg"]]))
1818
+ },
1819
+ attributes: withStyle({
1103
1820
  ...defaultSchema.attributes,
1104
1821
  "*": [
1105
1822
  ...defaultSchema.attributes?.["*"] || [],
1106
- "className",
1107
- ["style", SAFE_STYLE],
1108
1823
  "dataSourceLine",
1109
1824
  ["dataVantageTone", ...VANTAGE_TONES],
1110
1825
  ["dataVantageEmphasis", ...VANTAGE_EMPHASIS],
@@ -1114,35 +1829,22 @@ const sanitizeSchema = {
1114
1829
  ["dataVantageCollapseToggle", COLLAPSE_GROUP_ID],
1115
1830
  ["dataVantageRun", ...VANTAGE_RUNS],
1116
1831
  ["dataVantageOq", "true"],
1832
+ ["dataVantageQuestion", "true"],
1117
1833
  ["dataVantageOqId", VANTAGE_OQ_ID],
1118
1834
  ["dataVantageAlert", ...VANTAGE_ALERTS],
1119
1835
  "dataVantageLeaning"
1120
1836
  ],
1121
- code: [...defaultSchema.attributes?.code || [], "className"],
1122
- span: [
1123
- ...defaultSchema.attributes?.span || [],
1124
- "className",
1125
- ["style", SAFE_STYLE]
1126
- ],
1127
- div: [
1128
- ...defaultSchema.attributes?.div || [],
1129
- "className",
1130
- ["style", SAFE_STYLE]
1131
- ],
1132
- a: [
1133
- ...defaultSchema.attributes?.a || [],
1134
- "id",
1135
- "className"
1136
- ],
1837
+ ...Object.fromEntries(Object.keys(PIPELINE_CLASSES).map((tag) => [tag, withClasses(tag)])),
1838
+ a: withClasses("a", "id"),
1137
1839
  math: ["xmlns"],
1138
1840
  annotation: ["encoding"],
1139
1841
  img: [...defaultSchema.attributes?.img || [], "loading"],
1140
- td: [...defaultSchema.attributes?.td || [], ["style", SAFE_STYLE]],
1141
- th: [...defaultSchema.attributes?.th || [], ["style", SAFE_STYLE]]
1142
- }
1842
+ svg: SVG_ROOT_ATTRIBUTES,
1843
+ ...Object.fromEntries(SVG_CHILD_TAGS.map((tag) => [tag, SVG_ATTRIBUTES]))
1844
+ })
1143
1845
  };
1144
1846
  //#endregion
1145
- //#region src/pipeline.ts
1847
+ //#region src/remarkPlugins.ts
1146
1848
  /**
1147
1849
  * The mdast half of the chain. Exported on its own because there is a real
1148
1850
  * mdast-only consumer: the CLI checker parses documents without ever running
@@ -1156,6 +1858,8 @@ function buildRemarkPlugins(options = {}) {
1156
1858
  if (math) plugins.push([remarkMath, { singleDollarTextMath: false }]);
1157
1859
  return plugins;
1158
1860
  }
1861
+ //#endregion
1862
+ //#region src/pipeline.ts
1159
1863
  /** The hast half. Deliberately not exported: see `buildPipeline`. */
1160
1864
  function buildRehypePlugins(options = {}) {
1161
1865
  const { math = true, highlight = true, sourceLines = true, sanitize = true, bodyLineOffset = 0 } = options;
@@ -1163,7 +1867,7 @@ function buildRehypePlugins(options = {}) {
1163
1867
  if (sourceLines) plugins.push([rehypeSourceLines, { offset: bodyLineOffset }]);
1164
1868
  plugins.push(rehypeVantageAlerts);
1165
1869
  plugins.push(rehypeVantageDirectives);
1166
- if (sanitize) plugins.push([rehypeSanitize, sanitizeSchema]);
1870
+ if (sanitize) plugins.push(rehypeStripSvgContainers, [rehypeSanitize, sanitizeSchema]);
1167
1871
  plugins.push(rehypeVantageAnchors);
1168
1872
  plugins.push(rehypeSlug);
1169
1873
  if (highlight) plugins.push(rehypeHighlight);
@@ -1727,6 +2431,43 @@ let mermaidLoading = null;
1727
2431
  * configured for, `null` until loaded.
1728
2432
  */
1729
2433
  let configuredTheme = null;
2434
+ /**
2435
+ * The configuration keys a diagram's own source may not set, from an `init`
2436
+ * directive or from frontmatter: Mermaid's default list, then the three
2437
+ * that write a diagram's stylesheet.
2438
+ *
2439
+ * **A diagram's stylesheet reaches past the diagram.** Mermaid puts every
2440
+ * selector of it under the diagram's id, but leaves the names of its
2441
+ * `@keyframes` global, so a diagram that defined `flash-update` redefined the
2442
+ * animation the viewer plays on a block that just changed. Given
2443
+ * `position: fixed` at the size of the window, the next flash laid that block,
2444
+ * and the link it held, over the header and the sidebar. The same stylesheet
2445
+ * fetched images from any host, which is why `SAFE_STYLE` refuses parentheses.
2446
+ *
2447
+ * - `themeCSS` is a stylesheet, and Mermaid checks only that its braces pair.
2448
+ * - `fontFamily` is copied into `themeVariables` *after* Mermaid's check on
2449
+ * theme variables has run, and lands in the stylesheet as a declaration's
2450
+ * value, where a nested block becomes a rule of its own.
2451
+ * - `altFontFamily` lands in a rule the browser parses first, which drops
2452
+ * what does not belong. It is refused anyway: nothing needs it, and that
2453
+ * parse is all that stands in the way.
2454
+ *
2455
+ * Mermaid applies the list at every depth, so `themeVariables.fontFamily` in
2456
+ * a diagram is refused too. The list is spelled whole rather than relying on
2457
+ * Mermaid merging it with its default. `frontend/e2e/mermaid.spec.ts` measures
2458
+ * all four routes; see "Mermaid" in `docs/reference/inline-markup.md`.
2459
+ */
2460
+ const MERMAID_SECURE_KEYS = [
2461
+ "secure",
2462
+ "securityLevel",
2463
+ "startOnLoad",
2464
+ "maxTextSize",
2465
+ "suppressErrorRendering",
2466
+ "maxEdges",
2467
+ "themeCSS",
2468
+ "fontFamily",
2469
+ "altFontFamily"
2470
+ ];
1730
2471
  function configure(m, palette) {
1731
2472
  const theme = mermaidThemeOf(palette);
1732
2473
  m.initialize({
@@ -1734,7 +2475,8 @@ function configure(m, palette) {
1734
2475
  theme,
1735
2476
  themeVariables: mermaidThemeVariables(theme),
1736
2477
  securityLevel: "strict",
1737
- suppressErrorRendering: true
2478
+ suppressErrorRendering: true,
2479
+ secure: MERMAID_SECURE_KEYS
1738
2480
  });
1739
2481
  configuredTheme = palette;
1740
2482
  }
@@ -1769,15 +2511,6 @@ async function getMermaid() {
1769
2511
  //#endregion
1770
2512
  //#region src/renderMermaidBlocks.ts
1771
2513
  /**
1772
- * Client-side utility to find and render mermaid code blocks in a container.
1773
- *
1774
- * After calling `renderMarkdown()`, mermaid blocks come through as
1775
- * `<pre><code class="language-mermaid">...</code></pre>`. This function
1776
- * finds those blocks and replaces them with rendered SVG diagrams.
1777
- *
1778
- * Framework-agnostic — works in any browser environment.
1779
- */
1780
- /**
1781
2514
  * Find all `<pre><code class="language-mermaid">` blocks in a container
1782
2515
  * and replace them with rendered SVG diagrams.
1783
2516
  *
@@ -1858,6 +2591,7 @@ function replaceWithSvg(preEl, svg, className) {
1858
2591
  wrapper.innerHTML = svg;
1859
2592
  preEl.replaceWith(wrapper);
1860
2593
  }
2594
+ unified().use(remarkParse).use(buildRemarkPlugins());
1861
2595
  //#endregion
1862
2596
  //#region src/resolveLinks.ts
1863
2597
  /**
@@ -1908,12 +2642,15 @@ function resolveLinks(html, options = {}) {
1908
2642
  * The canonical Vantage Markdown style guide.
1909
2643
  *
1910
2644
  * This string is the single source of truth for the conventions Vantage's
1911
- * renderer expects. Two consumers read it:
2645
+ * renderer expects. It reaches readers three ways:
1912
2646
  *
1913
2647
  * - the in-app "Style Guide for Agents" modal, which shows it with a copy
1914
- * button, and
2648
+ * button,
1915
2649
  * - the `vantage-check style-guide` command, which prints it so an agent can
1916
- * fetch it without a human in the loop.
2650
+ * fetch it without a human in the loop, and
2651
+ * - this package's own export, for anyone who renders with `vantage-md`.
2652
+ *
2653
+ * How the CLI serves it, and why there is one copy: docs/reference/agent-cli.md
1917
2654
  *
1918
2655
  * Every rule stated here should be one a checker can enforce or a renderer
1919
2656
  * actually cares about — if a line is neither, it does not belong.
@@ -1969,6 +2706,41 @@ vantage:
1969
2706
  - **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.
1970
2707
  - The chip's vocabulary is \`status\`'s, exactly: \`draft | in-review | accepted | deprecated\`, lowercase. \`Draft\` renders no chip at all, silently.
1971
2708
 
2709
+ ### Planning documents: \`stage\`, \`next\`, \`depends-on\`
2710
+ - **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.
2711
+ - 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:\`:
2712
+ \`\`\`yaml
2713
+ ---
2714
+ title: "Payload bootstrap"
2715
+ status: in-review
2716
+ stage: DESIGN
2717
+ next: "Rule OQ-B2 \u2014 the install step waits on it"
2718
+ depends-on:
2719
+ - pypi-distribution.md
2720
+ - ../plans/rollout.md#OQ-R1
2721
+ ---
2722
+ \`\`\`
2723
+ - **\`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\`.
2724
+ - **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.
2725
+ - **\`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.
2726
+ - **\`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\`).
2727
+ - **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.
2728
+ - **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.
2729
+ - **\`[planning]\` in \`.vantage.toml\`** says which files are read, which of them are roadmaps, and what each stage word means. Every key is optional:
2730
+ \`\`\`toml
2731
+ [planning]
2732
+ # roadmap = ["roadmap.md", "docs/plans/roadmap.md"] # absent: every roadmap.md
2733
+ exclude = ["docs/gallery/**"] # gitignore syntax; every .md is included by default
2734
+
2735
+ [planning.stages] # each word maps to open | ready | built | done
2736
+ DESIGN = "open"
2737
+ DECIDED = "ready"
2738
+ BUILT = "built"
2739
+ SUPERSEDED = "done"
2740
+ \`\`\`
2741
+ - **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.
2742
+ - **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.
2743
+
1972
2744
  ### Mermaid diagrams
1973
2745
  - Use \`\`\`mermaid code blocks for flowcharts, sequence diagrams, and architecture diagrams. Vantage provides interactive zoom, pan, dark/light theme adaptation, and SVG export.
1974
2746
  - **Quote labels with special characters**: Always quote node labels containing parentheses, brackets, or colons to prevent syntax errors:
@@ -1978,6 +2750,26 @@ flowchart TD
1978
2750
  srv --> git["Git CLI (git diff)"]
1979
2751
  \`\`\`
1980
2752
 
2753
+ ### Inline SVG (needs Vantage 0.8 or later)
2754
+ - **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.
2755
+ - **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.
2756
+ - **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>\`.
2757
+ - **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.
2758
+ \`\`\`markdown
2759
+ <div>
2760
+ <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">
2761
+ <rect x="1" y="1" width="40" height="38" fill="none" stroke="currentColor"/>
2762
+ <path d="M45 20 H75 M68 14 L75 20 L68 26" fill="none" stroke="currentColor"/>
2763
+ <rect x="79" y="1" width="40" height="38" fill="none" stroke="currentColor"/>
2764
+ </svg>
2765
+ </div>
2766
+
2767
+ <!-- vantage: fallback -->
2768
+
2769
+ Two boxes joined by an arrow. This drawing needs Vantage 0.8 or later.
2770
+ \`\`\`
2771
+ - **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.
2772
+
1981
2773
  ### Code blocks and diffs
1982
2774
  - Always tag fenced code blocks with language identifiers (\`ts\`, \`go\`, \`python\`, \`bash\`, \`json\`, \`yaml\`, \`diff\`, \`sql\`, etc.) for syntax highlighting.
1983
2775
  - For proposed code modifications, use \`\`\`diff blocks with \`+\` and \`-\` prefixes:
@@ -2015,25 +2807,60 @@ Vantage reads a few styling hints from ordinary HTML comments. Every other rende
2015
2807
  The steps below predate the rewrite.
2016
2808
  \`\`\`
2017
2809
 
2018
- - **Three names**: \`section\` (the heading and everything under it), \`block\` (the one block after it), \`oq\` (one answerable Open Question).
2810
+ - **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).
2019
2811
  - **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.
2020
2812
  - **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.
2021
2813
  - **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.
2022
2814
  - **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.
2023
2815
  - **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.
2024
- - **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.
2816
+ - **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.
2025
2817
  - **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.
2026
- - **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.
2027
- - **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.
2818
+
2819
+ ### Open questions
2820
+ 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.
2821
+
2822
+ - **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:
2823
+ 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?**\`.
2824
+ 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.
2825
+ 3. **The options as a list**, one item each, when there are options to choose between.
2826
+ 4. **\`_Leaning:_\` as a paragraph of its own**: which option, and why, in a sentence or two.
2827
+ 5. **\`**Answer:**\` as a paragraph of its own**, with the ruling below it once there is one.
2028
2828
 
2029
2829
  \`\`\`markdown
2030
- 1. **OQ-9: Queue position on re-entry.**
2830
+ ## Open Questions
2831
+
2832
+ 1. \u{1F4AC} **OQ-9: Where does a job go when it re-enters the queue?**
2031
2833
 
2032
- <!-- vantage: oq id=OQ-9 leaning="Back of the queue — the fix might interact with what merged while it was out." -->
2834
+ 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.
2033
2835
 
2034
- _Leaning:_ Back of the queue.
2836
+ - **A — The back of the queue.** It waits its turn again.
2837
+ - **B — Its old place.** It skips ahead of what joined since.
2838
+
2839
+ <!-- vantage: question id=OQ-9 leaning="A — the back of the queue: the fix might interact with what merged while it was out." -->
2840
+
2841
+ _Leaning:_ A. The fix might interact with what merged while it was out.
2842
+
2843
+ **Answer:**
2844
+
2845
+ > _(empty — fill in when decided)_
2846
+
2847
+ 2. \u{1F512} **OQ-10: How large is the retry budget?**
2848
+
2849
+ <!-- vantage: question id=OQ-10 -->
2850
+
2851
+ Waits on the load test, so there is no leaning to state yet.
2035
2852
  \`\`\`
2036
2853
 
2854
+ - **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.
2855
+ - **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.
2856
+ - **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.
2857
+ - **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."
2858
+ - **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.
2859
+ - **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]\`.
2860
+ - **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\`).
2861
+ - **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\`.
2862
+ - **\`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\`).
2863
+
2037
2864
  ### Tables, task lists, and math
2038
2865
  - **Tables**: Use standard markdown tables for structured comparisons and schemas.
2039
2866
  - **Task lists**: Use \`- [ ]\` and \`- [x]\` for actionable checklists and status tracking.
@@ -2041,6 +2868,6 @@ The steps below predate the rewrite.
2041
2868
  - Single dollars are **not** math delimiters: \`$HOME\` and \`$100\` stay literal, so prose and shell snippets are safe to write as-is.
2042
2869
  `;
2043
2870
  //#endregion
2044
- export { ALERT_TITLES, COLOR_THEME_ATTRIBUTE, COLOR_THEME_SOURCE_ATTRIBUTE, DIRECTIVE_NAMES, DIRECTIVE_VOCABULARY, DOC_STATUSES, DOC_STATUS_TONES, SAFE_STYLE, STYLE_GUIDE, VANTAGE_ALERTS, VANTAGE_BADGES, VANTAGE_COLLAPSED, VANTAGE_EMPHASIS, VANTAGE_FRONTMATTER_KEYS, VANTAGE_OQ_HOST_TARGETS, VANTAGE_OQ_STATUS, VANTAGE_OQ_STATUS_LABEL, VANTAGE_RUNS, VANTAGE_SENTINEL, VANTAGE_TONES, buildPipeline, buildRemarkPlugins, clearLineAnchorHighlights, currentColorTheme, hasVantageSentinel, isDocStatus, parseFrontmatter, parseLineAnchor, parseVantageDirective, readVantageFrontmatter, rehypeSourceLines, rehypeVantageAlerts, rehypeVantageAnchors, rehypeVantageDirectives, renderMarkdown, renderMermaidBlocks, resolveLinks, sanitizeSchema, scrollToLineAnchor, vantageOqStatus };
2871
+ export { ALERT_TITLES, COLOR_THEME_ATTRIBUTE, COLOR_THEME_SOURCE_ATTRIBUTE, DIRECTIVE_NAMES, DIRECTIVE_VOCABULARY, DOC_STATUSES, DOC_STATUS_TONES, SAFE_STYLE, STYLE_GUIDE, VANTAGE_ALERTS, VANTAGE_BADGES, VANTAGE_COLLAPSED, VANTAGE_EMPHASIS, VANTAGE_FRONTMATTER_KEYS, VANTAGE_LEANING_ATTRIBUTE, VANTAGE_OQ_ATTRIBUTE, VANTAGE_OQ_HOST_TARGETS, VANTAGE_OQ_STATUS, VANTAGE_OQ_STATUS_LABEL, VANTAGE_QUESTION_ATTRIBUTE, VANTAGE_QUESTION_NAMES, VANTAGE_QUESTION_SELECTOR, VANTAGE_RUNS, VANTAGE_SENTINEL, VANTAGE_TONES, buildPipeline, buildRemarkPlugins, clearLineAnchorHighlights, currentColorTheme, hasVantageSentinel, isDocStatus, isQuestionDirective, parseFrontmatter, parseLineAnchor, parseVantageDirective, questionOffersTake, readVantageFrontmatter, rehypeSourceLines, rehypeVantageAlerts, rehypeVantageAnchors, rehypeVantageDirectives, renderMarkdown, renderMermaidBlocks, resolveLinks, sanitizeSchema, scrollToLineAnchor, vantageOqStatus };
2045
2872
 
2046
2873
  //# sourceMappingURL=index.js.map