@entropicwarrior/sdoc 0.2.20 → 0.2.22

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.
@@ -107,6 +107,18 @@
107
107
  reserved. \`--pptx\` runs the same check for free, because it has
108
108
  already measured the deck.
109
109
 
110
+ To override what a format does with the optional slides:
111
+
112
+ ```
113
+ node tools/build-slides.js deck.sdoc --pdf --with-optional
114
+ node tools/build-slides.js deck.sdoc --no-optional
115
+ ```
116
+
117
+ A slide marked \`optional: true\` is kept in the HTML build and
118
+ left out of \`--pdf\` and \`--pptx\`. Either default can be
119
+ overridden in any format: \`--with-optional\` keeps them,
120
+ \`--no-optional\` drops them. See [Optional Slides](#optional).
121
+
110
122
  To use a custom theme:
111
123
 
112
124
  ```
@@ -138,6 +150,7 @@
138
150
  \`footnote:\` | Small print pinned under the body: provenance, caveats, sources.
139
151
  \`status:\` | A second line under the kicker on a title slide, for a distribution notice.
140
152
  \`accent:\` | \`primary\`, \`secondary\`, \`tertiary\` or \`neutral\`. An accent names a role; the theme chooses the colour.
153
+ \`optional:\` | \`true\` holds the slide back from the exports, and from any build passed \`--no-optional\`. See [Optional Slides](#optional).
141
154
  \`numbered:\` | \`columns\` and \`rows\`. \`true\` numbers the children 01, 02, 03.
142
155
  \`variant:\` | \`columns\` and \`rows\`. A named treatment, such as \`panel\` or \`mono\`.
143
156
  \`weights:\` | \`split\` only. The ratio between panes, as in \`48 52\`.
@@ -150,11 +163,11 @@
150
163
  }
151
164
 
152
165
  A key is only configuration where it means something. \`config:\`,
153
- \`kicker:\`, \`lede:\`, \`footnote:\`, \`accent:\` and \`status:\` are
154
- understood by every scope; the rest are understood only by the
155
- layout named against them above. Anywhere else they are ordinary
156
- text — a slide opening "Value: the customer keeps their data."
157
- renders that sentence, it does not silently swallow it. A
166
+ \`kicker:\`, \`lede:\`, \`footnote:\`, \`accent:\`, \`status:\` and
167
+ \`optional:\` are understood by every scope; the rest are understood
168
+ only by the layout named against them above. Anywhere else they are
169
+ ordinary text — a slide opening "Value: the customer keeps their
170
+ data." renders that sentence, it does not silently swallow it. A
158
171
  paragraph spanning more than one source line is never
159
172
  configuration.
160
173
 
@@ -445,9 +458,13 @@
445
458
  \`variant: card\`, an image at the top of each column with a heading
446
459
  and a line of body beneath gives the standard figure row.
447
460
 
448
- Paths are resolved relative to the \`.sdoc\` file. They are embedded
449
- in the PPTX export; a remote URL is left out of it, and the build
450
- says which.
461
+ Paths are resolved relative to the \`.sdoc\` file, in every format.
462
+ A local image is embedded in the built deck itself — HTML, PDF and
463
+ PPTX alike — so the file you send someone carries its own pictures
464
+ and can be moved anywhere. A remote URL is left as a link, and the
465
+ PPTX export says which ones it could not embed. An image the build
466
+ cannot read is left as a plain reference and named in a warning,
467
+ rather than becoming a broken picture nobody was told about.
451
468
  }
452
469
  }
453
470
 
@@ -526,6 +543,114 @@
526
543
  spine 2, its details in order, and so on. This is the same order
527
544
  slides appear in the HTML, so PDF export needs no special case —
528
545
  every slide is just one page.
546
+
547
+ Every detail is a page, then, including the ones the audience
548
+ never saw. Mark a detail `optional: true` to keep it out of the
549
+ exported file — see [Optional Slides](#optional).
550
+ }
551
+ }
552
+
553
+ # Optional Slides @optional
554
+ {
555
+ Some slides exist for the room and not for the file that gets sent
556
+ on: the answer to a question that may not be asked, the figure
557
+ behind a figure, the rebuttal you hope to leave unused. Set
558
+ \`optional: true\` on the slide and it stays in the HTML build, which
559
+ is what you present from, and leaves the PDF and the PPTX, which are
560
+ what you send.
561
+
562
+ # Syntax @optional-syntax
563
+ {
564
+ ```
565
+ # Unit economics @unit-economics {
566
+ Headline numbers.
567
+
568
+ # Gross margin by cohort @margin :detail {
569
+ optional: true
570
+
571
+ The cohort table, if anyone asks for it.
572
+ }
573
+ }
574
+
575
+ # Pricing objections @objections {
576
+ optional: true
577
+
578
+ Held back unless pricing comes up.
579
+ }
580
+ ```
581
+
582
+ \`optional:\` is a slide property, not a scope annotation, and that
583
+ is deliberate: a heading carries one \`:type\`, and a drilldown has
584
+ already spent it on \`:detail\`. Reading optional-ness off the
585
+ configuration run keeps the two independent, so all four
586
+ combinations are available — a required spine, an optional spine,
587
+ a required detail, an optional detail.
588
+
589
+ A slide is the unit. Setting it on a cell inside a structured
590
+ layout — a column, a pipeline row, a pane of a \`split\` — has no
591
+ effect: the whole slide travels or none of it does. Mark the slide
592
+ that holds the cell.
593
+
594
+ It is set the way the other boolean properties are: \`true\`,
595
+ \`yes\`, \`on\` or \`1\` mark the slide; \`false\`, \`no\`, \`off\` or \`0\`
596
+ leave it alone. Like every slide property it must come before any
597
+ content.
598
+
599
+ A value that is none of those words is not configuration at all.
600
+ A slide whose first line is "Optional: a second seat costs
601
+ nothing" renders that sentence — a boolean key discards what it
602
+ cannot read, so it declines the line rather than swallowing it.
603
+ }
604
+
605
+ # What each format does @optional-formats
606
+ {
607
+ {[table]
608
+ Format | Default | Why
609
+ HTML | Included | It is the format you present from, and an optional slide is one you may want in the room.
610
+ PDF | Excluded | It is a format you send on.
611
+ PPTX | Excluded | The same.
612
+ }
613
+
614
+ The default is a guess about intent, not a property of the format,
615
+ so either can be overridden anywhere. An HTML deck is also
616
+ something you send someone:
617
+
618
+ ```
619
+ node tools/build-slides.js deck.sdoc --pdf --with-optional
620
+ node tools/build-slides.js deck.sdoc --no-optional
621
+ ```
622
+
623
+ When both are given the last one wins, as with \`--fit\`.
624
+
625
+ An optional spine takes its details with it. A detail belongs to
626
+ its spine, and there is nowhere for it to stand once the spine is
627
+ gone, so it goes whether or not it is marked itself.
628
+ }
629
+
630
+ # Numbering @optional-numbering
631
+ {
632
+ Optional slides are dropped before the deck is numbered, not
633
+ hidden afterwards, so the slide indicator counts what the reader
634
+ actually holds. A deck of four spines with one of them optional
635
+ reads \`3 / 3\` on its last page in the exported file and \`4 / 4\`
636
+ in the HTML build. The pages are consecutive either way; there is
637
+ no gap where a slide was removed.
638
+
639
+ A spine whose only details are optional loses its
640
+ \`slide-has-details\` class in the export too, so no theme draws a
641
+ drilldown chevron pointing at nothing.
642
+ }
643
+
644
+ # Themes @optional-theme
645
+ {
646
+ An optional slide carries the \`slide-optional\` class wherever it
647
+ is kept. A theme can use it to mark the slide on screen — a corner
648
+ tab, a tinted rule — so the presenter can see which slides are off
649
+ the main path. The built-in theme does not style it, so an optional
650
+ slide looks like any other until a theme says otherwise.
651
+
652
+ Nothing marked optional reaches a build it was excluded from, so
653
+ the class cannot appear in one.
529
654
  }
530
655
  }
531
656
 
@@ -768,6 +893,10 @@
768
893
  rather than an outlined box, so a hairline under a heading is a
769
894
  hairline rather than a rectangle with three invisible edges.
770
895
 
896
+ What is not in the file at all: slides marked \`optional: true\`,
897
+ unless \`--with-optional\` was passed. See
898
+ [Optional Slides](#optional).
899
+
771
900
  What does not carry: web fonts are named, not embedded, so the
772
901
  fonts a theme uses must be available to the viewer. Choosing faces
773
902
  that are on Google Fonts is the reliable route, because Slides
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@entropicwarrior/sdoc",
3
3
  "displayName": "SDOC - Docs for Human/Agent Teams",
4
4
  "description": "A plain-text documentation format with explicit brace scoping — deterministic parsing, AI-agent efficiency, and 10-50x token savings vs Markdown.",
5
- "version": "0.2.20",
5
+ "version": "0.2.22",
6
6
  "publisher": "entropicwarrior-msenfin",
7
7
  "license": "MIT",
8
8
  "repository": {
@@ -143,7 +143,12 @@ const MEASURE_SCRIPT = `
143
143
  // The nav chevrons are a screen affordance, not deck content: nothing
144
144
  // clicks them in a PDF or a .pptx. (The theme runtime also hides one of
145
145
  // the pair at load time, so measuring them exported a lone arrow.)
146
- if (el.classList && (el.classList.contains("nav-prev") || el.classList.contains("nav-next"))) return;
146
+ // .nav-vert is the vertical drilldown pair and its container; it sits in
147
+ // the bottom margin, so counting it would report an overflow on any
148
+ // slide whose content reaches near the bottom of the design box.
149
+ if (el.classList && (el.classList.contains("nav-prev") ||
150
+ el.classList.contains("nav-next") ||
151
+ el.classList.contains("nav-vert"))) return;
147
152
 
148
153
  var cs = getComputedStyle(el);
149
154
  if (cs.display === "none" || cs.visibility === "hidden" || parseFloat(cs.opacity) === 0) return;
@@ -32,6 +32,7 @@ const COMMON_KEYS = new Set([
32
32
  "footnote",
33
33
  "accent",
34
34
  "status",
35
+ "optional",
35
36
  ]);
36
37
 
37
38
  const LAYOUT_KEYS = {
@@ -53,6 +54,17 @@ const CELL_KEYS = {
53
54
  bars: ["value", "fill"],
54
55
  };
55
56
 
57
+ // Keys whose value is a boolean. Every other key renders the text it is given,
58
+ // so consuming the line is visible in the output; a boolean key discards
59
+ // anything it does not recognise, which would make an opening sentence
60
+ // disappear. These therefore only take the line when the value is a word they
61
+ // actually understand — "Optional: a second seat costs nothing" is prose.
62
+ const BOOLEAN_KEYS = new Set(["optional", "numbered", "rule"]);
63
+ const BOOLEAN_WORDS = new Set([
64
+ "true", "yes", "on", "1",
65
+ "false", "no", "off", "0",
66
+ ]);
67
+
56
68
  // Every key any scope might understand. A leading paragraph opening with one
57
69
  // of these is a configuration *candidate*; whether it is kept depends on the
58
70
  // context resolved below.
@@ -136,6 +148,13 @@ function extractConfig(children, parentLayout) {
136
148
  rejected.push(candidate.node);
137
149
  continue;
138
150
  }
151
+ if (
152
+ BOOLEAN_KEYS.has(candidate.key) &&
153
+ !BOOLEAN_WORDS.has(candidate.value.trim().toLowerCase())
154
+ ) {
155
+ rejected.push(candidate.node);
156
+ continue;
157
+ }
139
158
  if (candidate.key === "config" || candidate.key === "layout") {
140
159
  config.layout = candidate.value.toLowerCase();
141
160
  } else {
@@ -505,10 +524,12 @@ function buildBody(layout, cell, ctx) {
505
524
  }
506
525
 
507
526
  module.exports = {
527
+ BOOLEAN_KEYS,
508
528
  CONFIG_KEYS,
509
529
  STRUCTURED_LAYOUTS,
510
530
  extractConfig,
511
531
  buildBody,
512
532
  accentClass,
513
533
  slug,
534
+ truthy,
514
535
  };
@@ -7,8 +7,101 @@
7
7
  // const { nodes, meta } = extractMeta(parsed.nodes);
8
8
  // const html = renderSlides(nodes, { meta, themeCss, themeJs });
9
9
 
10
+ const fs = require("fs");
11
+ const path = require("path");
10
12
  const { parseInline, renderKatex, escapeHtml, escapeAttr, sanitizeSvg, colorSwatchHtml } = require("./sdoc");
11
- const { extractConfig, buildBody, accentClass, slug } = require("./slide-layouts");
13
+ const { extractConfig, buildBody, accentClass, slug, truthy } = require("./slide-layouts");
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Image inlining
17
+ //
18
+ // renderSlides emits `<img src>` exactly as the document wrote it, and the
19
+ // documentation is explicit that those paths are relative to the .sdoc file.
20
+ // A built deck, though, is a single file that gets written wherever -o says
21
+ // and then moved, mailed and opened from somewhere else entirely, at which
22
+ // point a relative path no longer names anything. The PDF and PPTX exporters
23
+ // dodged this by reading the deck from a temp copy beside the input; the HTML
24
+ // build had no such trick and silently shipped broken images whenever the
25
+ // output went to another directory.
26
+ //
27
+ // Inlining resolves the paths once, against the .sdoc, and makes the question
28
+ // of where the file ends up irrelevant for every format. It is the same thing
29
+ // loadTheme already does for a theme's fonts and backgrounds, for the same
30
+ // reason, and readImage in slide-pptx.js already decodes data: URIs, so the
31
+ // PPTX export embeds them exactly as it did loose files.
32
+ //
33
+ // This reads from disk, so it is not part of renderSlides: the renderer stays
34
+ // a pure AST-to-HTML function and the builder calls this afterwards.
35
+ // ---------------------------------------------------------------------------
36
+
37
+ const INLINE_IMAGE_TYPES = {
38
+ ".png": "image/png",
39
+ ".jpg": "image/jpeg",
40
+ ".jpeg": "image/jpeg",
41
+ ".gif": "image/gif",
42
+ ".svg": "image/svg+xml",
43
+ ".webp": "image/webp",
44
+ ".avif": "image/avif",
45
+ ".bmp": "image/bmp",
46
+ ".ico": "image/x-icon",
47
+ };
48
+
49
+ // Returns { html, inlined, missing }. `missing` describes every local image
50
+ // that could not be embedded — the silent failure this exists to stop — as
51
+ // { src, resolved, reason }, so the caller can say where it looked and not
52
+ // just what it wanted. Naming the resolved path is what makes the rule
53
+ // visible at the moment it bites: a deck written against a different
54
+ // convention otherwise sees only that a file it can see plainly is "missing".
55
+ // A remote or data: URI is neither inlined nor missing: nothing to resolve.
56
+ function inlineDeckImages(html, baseDir) {
57
+ const inlined = [];
58
+ const missing = [];
59
+
60
+ const out = html.replace(/(<img\b[^>]*?\bsrc=")([^"]*)(")/gi, (match, pre, src, post) => {
61
+ const raw = src.trim();
62
+ if (!raw) return match;
63
+ // Already embedded, or somewhere this build cannot reach.
64
+ if (/^data:/i.test(raw)) return match;
65
+ if (/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(raw)) return match;
66
+
67
+ // The src sits in an HTML attribute, so entities are decoded before it is
68
+ // read as a path, and any ?query or #fragment dropped.
69
+ const decoded = raw
70
+ .replace(/&amp;/g, "&")
71
+ .replace(/&#39;/g, "'")
72
+ .replace(/&quot;/g, '"')
73
+ .replace(/[?#].*$/, "");
74
+
75
+ let filePath;
76
+ try {
77
+ filePath = path.isAbsolute(decoded)
78
+ ? decoded
79
+ : path.resolve(baseDir, decodeURIComponent(decoded));
80
+ } catch {
81
+ filePath = path.isAbsolute(decoded) ? decoded : path.resolve(baseDir, decoded);
82
+ }
83
+
84
+ const ext = path.extname(filePath).toLowerCase();
85
+ const mime = INLINE_IMAGE_TYPES[ext];
86
+ if (!mime) {
87
+ missing.push({ src: decoded, resolved: filePath, reason: "unsupported-type" });
88
+ return match;
89
+ }
90
+
91
+ let data;
92
+ try {
93
+ data = fs.readFileSync(filePath);
94
+ } catch {
95
+ missing.push({ src: decoded, resolved: filePath, reason: "not-found" });
96
+ return match;
97
+ }
98
+
99
+ inlined.push(decoded);
100
+ return `${pre}data:${mime};base64,${data.toString("base64")}${post}`;
101
+ });
102
+
103
+ return { html: out, inlined, missing };
104
+ }
12
105
 
13
106
  // ---------------------------------------------------------------------------
14
107
  // Inline rendering — produces clean HTML without sdoc-* classes
@@ -211,6 +304,23 @@ function extractDetails(children) {
211
304
  return { details, contentNodes: rest };
212
305
  }
213
306
 
307
+ // Whether a slide scope carries `optional: true`.
308
+ //
309
+ // Optional-ness is a slide *property*, not a scope type: a heading carries one
310
+ // `:type` annotation and a drilldown has already spent it on `:detail`, so a
311
+ // detail slide could not also be annotated optional. Reading it off the
312
+ // configuration run keeps the two orthogonal — every combination of
313
+ // spine/detail and required/optional is expressible.
314
+ //
315
+ // The details are pulled out first and the config read without a parent
316
+ // layout, exactly as renderSlide() does it, so this answer and the one the
317
+ // slide renders under can never disagree.
318
+ function isOptionalSlide(scope) {
319
+ const { contentNodes } = extractDetails(scope.children || []);
320
+ const { config } = extractConfig(contentNodes);
321
+ return truthy(config.optional);
322
+ }
323
+
214
324
  // ---------------------------------------------------------------------------
215
325
  // Slide rendering
216
326
  // ---------------------------------------------------------------------------
@@ -246,6 +356,11 @@ function renderSlide(scope, slideIndex, overlayHtml, position) {
246
356
  if (position && position.detail > 0) {
247
357
  classes.push("slide-detail");
248
358
  }
359
+ // Present in the HTML build, which is the presenting format; dropped before
360
+ // this point when the deck is rendered for PDF or PPTX export.
361
+ if (truthy(config.optional)) {
362
+ classes.push("slide-optional");
363
+ }
249
364
 
250
365
  // On a title slide the kicker sits beneath the statement rather than above
251
366
  // it, so the eye lands on the name first.
@@ -312,7 +427,8 @@ function renderSlides(nodes, options = {}) {
312
427
  themeJs = "",
313
428
  darkMode = false,
314
429
  themeConfig = {},
315
- fit = null
430
+ fit = null,
431
+ includeOptional = true
316
432
  } = options;
317
433
 
318
434
  // The design box and print page come from the theme (themes/<name>/theme.json).
@@ -371,16 +487,53 @@ function renderSlides(nodes, options = {}) {
371
487
  }
372
488
  footerParts.push(`<span class="slide-indicator">__SLIDE_INDICATOR__</span>`);
373
489
  footerParts.push(`<span class="nav-next">&rsaquo;</span>`);
374
- const overlayHtml = `\n<div class="slide-footer">${footerParts.join("")}</div>`;
490
+
491
+ // The vertical pair, stacked up-over-down at bottom centre. Both are emitted
492
+ // on every slide and start hidden; the theme runtime turns each on only when
493
+ // that move exists from the slide you are actually on. That is the same
494
+ // contract as .nav-prev / .nav-next, and it is why these cannot be a CSS
495
+ // pseudo-element on a build-time class: whether you can go up or down is a
496
+ // property of the current position, not of the slide.
497
+ // One chevron path, drawn twice, mirrored for the up arrow. Text arrowheads
498
+ // cannot do this: U+2303 and U+2304 are not designed as a pair and measure
499
+ // ~26% apart in ink width, and because few fonts carry either codepoint the
500
+ // metrics come from whatever the OS falls back to — so the mismatch is not
501
+ // even consistent between platforms. A path is identical by construction
502
+ // everywhere. The viewBox is symmetric about its own centre (y spans
503
+ // 1.25..5.75 of 7), so the mirrored copy occupies the same box.
504
+ const chevronSvg =
505
+ `<svg viewBox="0 0 12 7" aria-hidden="true" focusable="false">` +
506
+ `<path d="M1 1.25 L6 5.75 L11 1.25" fill="none" stroke="currentColor" ` +
507
+ `stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
508
+ const vertNavHtml =
509
+ `\n<div class="nav-vert">` +
510
+ `<span class="nav-up">${chevronSvg}</span>` +
511
+ `<span class="nav-down">${chevronSvg}</span>` +
512
+ `</div>`;
513
+ const overlayHtml = `\n<div class="slide-footer">${footerParts.join("")}</div>${vertNavHtml}`;
514
+
515
+ // Optional slides are for the room, not the file that gets sent on. They are
516
+ // kept in the HTML build (includeOptional defaults to true, so every existing
517
+ // caller sees the deck it saw before) and dropped here when a deck is
518
+ // rendered for export. Dropping them before numbering — rather than hiding
519
+ // them later in print CSS — is what keeps the indicator honest: the
520
+ // denominator counts the spine slides the reader actually has.
521
+ //
522
+ // An optional spine takes its details with it. A detail belongs to its spine;
523
+ // there is nowhere for it to go once the spine is gone.
524
+ const kept = includeOptional ? slides : slides.filter((s) => !isOptionalSlide(s));
375
525
 
376
526
  // Build a flat emission order: each spine slide, followed immediately by its
377
527
  // :detail children in source order. The flat order matches what we want for
378
528
  // PDF export, so PDF needs no special case.
379
- const totalSpines = slides.length;
529
+ const totalSpines = kept.length;
380
530
  const emitted = [];
381
- slides.forEach((scope, i) => {
531
+ kept.forEach((scope, i) => {
382
532
  const spineIndex = i + 1; // 1-based
383
- const { details } = extractDetails(scope.children);
533
+ const { details: allDetails } = extractDetails(scope.children);
534
+ const details = includeOptional
535
+ ? allDetails
536
+ : allDetails.filter((d) => !isOptionalSlide(d));
384
537
  emitted.push({
385
538
  scope,
386
539
  position: {
@@ -465,6 +618,43 @@ function renderSlides(nodes, options = {}) {
465
618
  cursor: pointer; pointer-events: auto;
466
619
  user-select: none;
467
620
  }
621
+ /* Vertical drilldown pair. The container is anchored by its bottom edge and
622
+ grows upward, so .nav-down keeps the exact position the old pseudo-element
623
+ chevron had and .nav-up stacks above it. */
624
+ .nav-vert {
625
+ position: absolute;
626
+ /* 16px, not 18px: the old chevron was a text glyph whose ink ran to the
627
+ bottom of its line box, so an 18px box offset put the visible mark 18px
628
+ up. The SVG carries a little padding below the stroke, so the box sits
629
+ 2px lower to land the mark in the same place. Measured, not guessed. */
630
+ bottom: 16px; left: 50%;
631
+ transform: translateX(-50%);
632
+ display: flex; flex-direction: column; align-items: center;
633
+ /* The two arrows are sized by their own boxes now, not by a line box with
634
+ leading around a small glyph, which is where the old slack came from. */
635
+ gap: 0.42em;
636
+ line-height: 0;
637
+ pointer-events: none;
638
+ }
639
+ .nav-up, .nav-down {
640
+ /* Hidden until a runtime turns them on. A custom theme.js written before
641
+ these elements existed does not know to hide them, and a dead arrowhead
642
+ on every slide is worse than no arrowhead at all, so the safe state is
643
+ the default and the runtime opts in. */
644
+ visibility: hidden;
645
+ display: block;
646
+ font-size: 1.2em; color: #ccc;
647
+ cursor: pointer; pointer-events: auto;
648
+ user-select: none;
649
+ }
650
+ .nav-up svg, .nav-down svg {
651
+ display: block;
652
+ width: 0.62em; height: auto;
653
+ stroke: currentColor;
654
+ }
655
+ /* The mirror. Same path, flipped about its own centre, so the pair matches to
656
+ the pixel whatever font or platform the deck is presented on. */
657
+ .nav-up svg { transform: scaleY(-1); }
468
658
  .sdoc-company-footer {
469
659
  font-size: 0.7em; color: rgba(0,0,0,0.35);
470
660
  letter-spacing: 0.04em;
@@ -524,6 +714,7 @@ function renderSlides(nodes, options = {}) {
524
714
  transform-origin: top left;
525
715
  }
526
716
  .nav-prev, .nav-next { display: none !important; }
717
+ .nav-vert { display: none !important; }
527
718
  .notes { display: none; }
528
719
  }`;
529
720
 
@@ -540,6 +731,7 @@ p code, li code { background: rgba(255, 255, 255, 0.08); }
540
731
  blockquote { border-left-color: #5b9bd5; color: #9d9d9d; }
541
732
  blockquote p { color: #9d9d9d; }
542
733
  .nav-prev, .nav-next { color: rgba(255, 255, 255, 0.7); }
734
+ .nav-up, .nav-down { color: rgba(255, 255, 255, 0.5); }
543
735
  .sdoc-company-footer { color: rgba(255, 255, 255, 0.35); }
544
736
  .sdoc-confidential-notice { color: rgba(235, 120, 120, 0.7); }
545
737
  .slide-indicator { color: rgba(255, 255, 255, 0.35); }
@@ -573,4 +765,4 @@ ${jsTag}${mermaidTag}
573
765
  </html>`;
574
766
  }
575
767
 
576
- module.exports = { renderSlides, renderSlide, renderNode, renderInline };
768
+ module.exports = { renderSlides, renderSlide, renderNode, renderInline, isOptionalSlide, inlineDeckImages };
package/src/theme.js CHANGED
@@ -100,6 +100,44 @@ function readThemeConfig(themeDir) {
100
100
  }
101
101
  }
102
102
 
103
+ // The drilldown chevron used to be a pseudo-element on .slide-has-details.
104
+ // It is now two real elements, .nav-up and .nav-down, which the runtime shows
105
+ // and hides per position. A theme derived from the default before that change
106
+ // still carries the old rule, and the old rule still renders: `content` on
107
+ // .slide-has-details::after paints a chevron of its own whatever the renderer
108
+ // emits. The result is two chevrons a few pixels apart on every spine slide
109
+ // that has details, and nothing in the build otherwise notices, because the
110
+ // duplicate is in the theme and the original is in the renderer.
111
+ //
112
+ // Only a rule that actually paints something is worth warning about, so a
113
+ // `content` of none/normal/empty is left alone.
114
+ const NO_CONTENT = new Set(["none", "normal", '""', "''"]);
115
+
116
+ function staleChevronWarnings(css) {
117
+ // Comments first: a theme that merely *documents* the old rule is fine.
118
+ const stripped = css.replace(/\/\*[\s\S]*?\*\//g, "");
119
+ const warnings = [];
120
+ // Innermost declaration blocks: the selector cannot contain braces, so this
121
+ // also reaches rules nested inside @media without tripping on the wrapper.
122
+ const rule = /([^{}]*)\{([^{}]*)\}/g;
123
+ let match;
124
+ while ((match = rule.exec(stripped)) !== null) {
125
+ const selector = match[1];
126
+ if (!/\.slide-has-details\s*::?after\b/.test(selector)) continue;
127
+ const content = /(?:^|[;{\s])content\s*:\s*([^;]+)/i.exec(match[2]);
128
+ if (!content) continue;
129
+ if (NO_CONTENT.has(content[1].trim().toLowerCase())) continue;
130
+ warnings.push(
131
+ "theme still draws the old drilldown chevron with " +
132
+ "`.slide-has-details::after { content: ... }`. That is now a duplicate of " +
133
+ ".nav-down, which the renderer emits and the runtime drives, so spine " +
134
+ "slides with details show two chevrons. Remove the rule from the theme."
135
+ );
136
+ break; // one rule is enough to say it; listing every copy adds no information
137
+ }
138
+ return warnings;
139
+ }
140
+
103
141
  // Reads a theme directory. `fallbackDir` supplies theme.js when the theme
104
142
  // ships none, which is how a theme opts into the default runtime (keyboard
105
143
  // navigation, touch, fit-to-window scaling) without copying it.
@@ -122,6 +160,7 @@ function loadTheme(themeDir, fallbackDir) {
122
160
  for (const ref of missing) {
123
161
  warnings.push(`theme asset not found, left as a plain reference: ${ref}`);
124
162
  }
163
+ warnings.push(...staleChevronWarnings(raw));
125
164
  } else {
126
165
  warnings.push(`theme.css not found at ${cssPath}`);
127
166
  }
@@ -138,4 +177,4 @@ function loadTheme(themeDir, fallbackDir) {
138
177
  return { themeCss, themeJs, themeConfig, warnings, dir: resolved };
139
178
  }
140
179
 
141
- module.exports = { loadTheme, inlineCssAssets, readThemeConfig, DEFAULT_THEME_CONFIG };
180
+ module.exports = { loadTheme, inlineCssAssets, readThemeConfig, staleChevronWarnings, DEFAULT_THEME_CONFIG };