@entropicwarrior/sdoc 0.2.14 → 0.2.16

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.
@@ -384,8 +384,11 @@ Content of Section B.
384
384
  \`\{!text!\}\` | Warning marker (orange)
385
385
  \`\{-text-\}\` | Negative marker (red)
386
386
  \`\{~text~\}\` | Highlight (yellow)
387
+ \`#1a73e8\` | Hex color swatch (auto-readable text)
387
388
  }
388
389
 
390
+ Hex color codes (\`#rgb\`, \`#rgba\`, \`#rrggbb\`, \`#rrggbbaa\`) render as inline swatches — a rounded box filled with the color and labelled with the code, with the text color chosen automatically (black or white) for legibility. They are detected in normal text, list items, table cells, and headings, but not inside \`\\\`inline code\\\`\` (which stays literal). A bare \`#\` at the start of a line is a heading; escape with \`\\#\` to keep a literal hash.
391
+
389
392
  Links: \`[Link text](https://example.com)\` or \`[Other doc](./other-file.sdoc)\`. Relative paths resolve from the document's directory.
390
393
 
391
394
  Images: \`![Alt text](path/to/image.png)\`
@@ -239,6 +239,84 @@
239
239
  }
240
240
  }
241
241
 
242
+ # Drilldown Slides @drilldown
243
+ {
244
+ A slide deck is normally a 1D spine: Right and Left move along it. A
245
+ spine slide can also have *vertical detail slides* — extra material
246
+ you can pull up on demand without cluttering the main flow. Mark a
247
+ child scope with the `:detail` annotation and it becomes a vertical
248
+ slide under its parent.
249
+
250
+ # Syntax @drilldown-syntax
251
+ {
252
+ ```
253
+ # ETHyR Mechanism @ethyr {
254
+ Main spine content.
255
+
256
+ # PCR primer @pcr :detail {
257
+ Detail slide one.
258
+ }
259
+
260
+ # HCR primer @hcr :detail {
261
+ Detail slide two.
262
+ }
263
+
264
+ # Notes @notes {
265
+ Speaker notes still work alongside details.
266
+ }
267
+ }
268
+ ```
269
+
270
+ A detail slide is a regular slide. It can have its own `config:
271
+ center`, two-column layout, speaker `@notes`, and any other slide
272
+ content. Drilldowns do not nest further: a `:detail` inside a
273
+ `:detail` is treated as a plain nested scope.
274
+ }
275
+
276
+ # Navigation @drilldown-navigation
277
+ {
278
+ Right — advance one step. From a spine slide, move to the next
279
+ spine. From a detail slide, advance to the next detail in the
280
+ same column; at the last detail, return to the parent spine and
281
+ advance to the next spine (reveal.js convention).
282
+
283
+ Left — mirror of Right. From a detail, previous detail or back
284
+ to parent spine; from spine, previous spine.
285
+
286
+ Down — drill into the first detail of the current spine; from
287
+ within a column, advance to the next detail.
288
+
289
+ Up — return from a detail to its parent spine. No-op on a spine.
290
+
291
+ Swipe — horizontal swipes mirror Left / Right; vertical swipes
292
+ mirror Up / Down.
293
+
294
+ URL hash — spine slides use `#N`; detail K of spine N uses
295
+ `#N.K`.
296
+ }
297
+
298
+ # Visual Affordances @drilldown-visual
299
+ {
300
+ Spine slides that have detail children get the
301
+ `slide-has-details` class — the default theme shows a small
302
+ animated chevron at the bottom of the slide. Themes can replace
303
+ this rule to fit their own visual language.
304
+
305
+ Every slide also gets a `.slide-indicator` element in the
306
+ bottom-right corner: `5 / 14` on a spine, `5.2 / 14` on detail 2
307
+ of slide 5. The denominator is always the spine count, so the
308
+ indicator stays stable as you drill in.
309
+ }
310
+
311
+ # PDF Export @drilldown-pdf
312
+ {
313
+ PDF export flattens the deck: spine 1, its details in order,
314
+ spine 2, its details in order, and so on. This is the same order
315
+ slides appear in the HTML, so PDF export needs no special case —
316
+ every slide is just one page.
317
+ }
318
+ }
319
+
242
320
  # Theme System @themes
243
321
  {
244
322
  Themes control the visual appearance of slides. A theme is a directory
@@ -269,17 +347,23 @@
269
347
  {
270
348
  All themes include these controls by default:
271
349
 
272
- Arrow right or Space — next slide.
350
+ Arrow right or Space — next slide (or next detail).
351
+
352
+ Arrow left — previous slide (or previous detail).
353
+
354
+ Arrow down — drill into details (if any).
273
355
 
274
- Arrow left — previous slide.
356
+ Arrow up — return from details to the spine slide.
275
357
 
276
358
  Home — first slide.
277
359
 
278
- End — last slide.
360
+ End — last spine slide.
279
361
 
280
- Touch swipe left/right on mobile devices.
362
+ Touch swipe horizontal / vertical on mobile devices.
281
363
 
282
- Slide counter shown in the bottom-right corner.
364
+ Slide counter (`.slide-indicator`) shown in the bottom-right
365
+ corner — `N / TOTAL` on spine slides, `N.K / TOTAL` on detail K
366
+ of spine N. See [Drilldown Slides](#drilldown) for details.
283
367
  }
284
368
  }
285
369
 
@@ -719,12 +719,15 @@ Content of Section B.
719
719
  - Warning marker: `{!text!}` (orange highlight)
720
720
  - Negative marker: `{-text-}` (red highlight)
721
721
  - Highlight: `{~text~}` (yellow highlight)
722
+ - Hex color swatch: `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa` (e.g. `#9aa0a8`) renders as a filled inline box labelled with the code
722
723
  - Citation reference: `[@key]` (numbered superscript link to citation entry)
723
724
  - Multiple citation references: `[@key1, @key2]` (each individually linked)
724
725
  }
725
726
 
726
727
  Inline math requires non-whitespace immediately after the opening `$` and before the closing `$`. A plain `$` followed by a digit (e.g. `$100`) does not trigger math mode because there is no closing `$`.
727
728
 
729
+ Hex color swatches are detected only when the `#` is at the start of the inline text or preceded by a non-alphanumeric character, the digit run is exactly 3, 4, 6, or 8 hexadecimal digits, and it is not immediately followed by another letter, digit, or underscore. The background is the literal color and the text color (black or white) is chosen by perceptual brightness (YIQ); for `#rgba`/`#rrggbbaa` the alpha channel is ignored when choosing the text color. Hex inside `` `inline code` `` stays literal, and a `#` at the start of a line is parsed as a heading (escape with `\#`).
730
+
728
731
  Citation references (`[@key]`) are parsed before link syntax. `[@key]` is always a citation reference even if followed by `(url)`. Use `\[@key]` for a literal `[@key]` in text.
729
732
  }
730
733
 
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.14",
5
+ "version": "0.2.16",
6
6
  "publisher": "entropicwarrior-msenfin",
7
7
  "license": "MIT",
8
8
  "repository": {
package/src/sdoc.js CHANGED
@@ -1414,6 +1414,21 @@ function parseInline(text) {
1414
1414
  }
1415
1415
  }
1416
1416
 
1417
+ // Hex color codes (#rgb, #rgba, #rrggbb, #rrggbbaa) render as swatches.
1418
+ // Require a non-word char before the # so we don't match inside identifiers,
1419
+ // and a non-word char after the digits so partial/over-long runs are ignored.
1420
+ if (ch === "#" && (i === 0 || !/[0-9A-Za-z]/.test(text[i - 1]))) {
1421
+ const m = /^#([0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{4}|[0-9a-fA-F]{3})(?![0-9A-Za-z_])/.exec(
1422
+ text.slice(i)
1423
+ );
1424
+ if (m) {
1425
+ flush();
1426
+ nodes.push({ type: "color_swatch", value: m[0] });
1427
+ i += m[0].length;
1428
+ continue;
1429
+ }
1430
+ }
1431
+
1417
1432
  if (ch === "{") {
1418
1433
  const mc = next;
1419
1434
  let mt = null;
@@ -1728,6 +1743,46 @@ function escapeAttr(value) {
1728
1743
  return escapeHtml(value).replace(/'/g, "'");
1729
1744
  }
1730
1745
 
1746
+ // Expand a #rgb/#rgba/#rrggbb/#rrggbbaa hex string to [r, g, b] (alpha ignored).
1747
+ function hexToRgb(hex) {
1748
+ let h = hex.replace(/^#/, "");
1749
+ if (h.length === 3 || h.length === 4) {
1750
+ h = h.slice(0, 3).split("").map((c) => c + c).join("");
1751
+ } else {
1752
+ h = h.slice(0, 6);
1753
+ }
1754
+ return [
1755
+ parseInt(h.slice(0, 2), 16),
1756
+ parseInt(h.slice(2, 4), 16),
1757
+ parseInt(h.slice(4, 6), 16),
1758
+ ];
1759
+ }
1760
+
1761
+ // Pick black or white text for legibility over the given background color
1762
+ // using the perceptual YIQ brightness threshold.
1763
+ function readableTextColor(hex) {
1764
+ const [r, g, b] = hexToRgb(hex);
1765
+ const yiq = (r * 299 + g * 587 + b * 114) / 1000;
1766
+ return yiq >= 128 ? "#000000" : "#ffffff";
1767
+ }
1768
+
1769
+ // Render a hex color as a self-contained inline swatch: a rounded box filled
1770
+ // with the color, labelled with the hex code in a legible text color. Styling
1771
+ // is inline so it survives in slides, exports, and other CSS-free contexts.
1772
+ function colorSwatchHtml(value) {
1773
+ const fg = readableTextColor(value);
1774
+ // A soft neutral-grey outline on every swatch (the same for all of them) so
1775
+ // the box edge stays visible even when the fill matches the page background.
1776
+ // Semi-transparent grey reads on both light and dark backgrounds without the
1777
+ // harsh contrast of a black/white border.
1778
+ const style =
1779
+ `background-color:${value};color:${fg};` +
1780
+ "border:1px solid rgba(128,128,128,0.5);border-radius:4px;padding:0.15em 0.9em;" +
1781
+ "font-family:'JetBrains Mono','Fira Code','Source Code Pro',monospace;font-size:0.95em;" +
1782
+ "-webkit-print-color-adjust:exact;print-color-adjust:exact";
1783
+ return `<span class="sdoc-color-swatch" style="${style}">${escapeHtml(value)}</span>`;
1784
+ }
1785
+
1731
1786
  function renderInline(text) {
1732
1787
  const nodes = parseInline(text);
1733
1788
  return renderInlineNodes(nodes);
@@ -1766,6 +1821,8 @@ function renderInlineNodes(nodes) {
1766
1821
  }
1767
1822
  case "code":
1768
1823
  return `<code class="sdoc-inline-code">${escapeHtml(node.value)}</code>`;
1824
+ case "color_swatch":
1825
+ return colorSwatchHtml(node.value);
1769
1826
  case "em":
1770
1827
  return `<em>${renderInlineNodes(node.children)}</em>`;
1771
1828
  case "strong":
@@ -3773,5 +3830,7 @@ module.exports = {
3773
3830
  renderKatex,
3774
3831
  escapeHtml,
3775
3832
  escapeAttr,
3776
- sanitizeSvg
3833
+ sanitizeSvg,
3834
+ colorSwatchHtml,
3835
+ readableTextColor
3777
3836
  };
@@ -7,7 +7,7 @@
7
7
  // const { nodes, meta } = extractMeta(parsed.nodes);
8
8
  // const html = renderSlides(nodes, { meta, themeCss, themeJs });
9
9
 
10
- const { parseInline, renderKatex, escapeHtml, escapeAttr, sanitizeSvg } = require("./sdoc");
10
+ const { parseInline, renderKatex, escapeHtml, escapeAttr, sanitizeSvg, colorSwatchHtml } = require("./sdoc");
11
11
 
12
12
  // ---------------------------------------------------------------------------
13
13
  // Inline rendering — produces clean HTML without sdoc-* classes
@@ -21,6 +21,8 @@ function renderInlineNodes(nodes) {
21
21
  return escapeHtml(node.value);
22
22
  case "code":
23
23
  return `<code>${escapeHtml(node.value)}</code>`;
24
+ case "color_swatch":
25
+ return colorSwatchHtml(node.value);
24
26
  case "em":
25
27
  return `<em>${renderInlineNodes(node.children)}</em>`;
26
28
  case "strong":
@@ -206,18 +208,45 @@ function extractNotes(children) {
206
208
  return { notes, contentNodes: rest };
207
209
  }
208
210
 
211
+ // Separates :detail child scopes (drilldown / vertical slides) from other
212
+ // children. Detail scopes are NOT removed from rendering; they are emitted as
213
+ // sibling slides positioned vertically under the spine slide.
214
+ function extractDetails(children) {
215
+ const details = [];
216
+ const rest = [];
217
+ for (const child of children) {
218
+ if (child.type === "scope" && child.scopeType === "detail") {
219
+ details.push(child);
220
+ } else {
221
+ rest.push(child);
222
+ }
223
+ }
224
+ return { details, contentNodes: rest };
225
+ }
226
+
209
227
  // ---------------------------------------------------------------------------
210
228
  // Slide rendering
211
229
  // ---------------------------------------------------------------------------
212
230
 
213
- function renderSlide(scope, slideIndex, overlayHtml) {
214
- const { config, contentNodes: afterConfig } = extractSlideConfig(scope.children);
231
+ // position: { spine: 1-based spine index, detail: 0 for spine, 1..N for details,
232
+ // totalSpines: total number of spine slides, hasDetails: bool (spine only) }
233
+ function renderSlide(scope, slideIndex, overlayHtml, position) {
234
+ // Pull :detail children out first so they don't appear inline in the spine
235
+ // slide's content; they're rendered as sibling vertical slides instead.
236
+ const { contentNodes: afterDetails } = extractDetails(scope.children);
237
+ const { config, contentNodes: afterConfig } = extractSlideConfig(afterDetails);
215
238
  const { notes, contentNodes } = extractNotes(afterConfig);
216
239
 
217
240
  const classes = ["slide"];
218
241
  if (config.layout) {
219
242
  classes.push(config.layout);
220
243
  }
244
+ if (position && position.detail === 0 && position.hasDetails) {
245
+ classes.push("slide-has-details");
246
+ }
247
+ if (position && position.detail > 0) {
248
+ classes.push("slide-detail");
249
+ }
221
250
 
222
251
  const title = scope.hasHeading !== false && scope.title
223
252
  ? `<h2>${renderInline(scope.title)}</h2>`
@@ -248,9 +277,25 @@ function renderSlide(scope, slideIndex, overlayHtml) {
248
277
  : "";
249
278
 
250
279
  const idAttr = scope.id ? ` id="${escapeAttr(scope.id)}"` : "";
251
- const overlay = overlayHtml || "";
252
280
 
253
- return `<div class="${classes.join(" ")}"${idAttr}>\n${title}\n${bodyHtml}${notesHtml}${overlay}\n</div>`;
281
+ // Drilldown metadata + slide indicator label (substituted into the footer)
282
+ let dataAttrs = "";
283
+ let indicatorLabel = "";
284
+ if (position) {
285
+ dataAttrs = ` data-spine="${position.spine}" data-detail="${position.detail}"`;
286
+ const denom = position.totalSpines;
287
+ indicatorLabel = position.detail === 0
288
+ ? `${position.spine} / ${denom}`
289
+ : `${position.spine}.${position.detail} / ${denom}`;
290
+ }
291
+ const overlay = (overlayHtml || "").replace("__SLIDE_INDICATOR__", escapeHtml(indicatorLabel));
292
+
293
+ // Wrap title + body in a scale container so PDF export can apply
294
+ // transform: scale() to fit the content onto a fixed page size. In
295
+ // screen mode the wrapper is display:contents (invisible to layout); in
296
+ // print mode it becomes a real block that the beforeprint handler can
297
+ // measure and scale.
298
+ return `<div class="${classes.join(" ")}"${idAttr}${dataAttrs}>\n<div class="slide-content-scale">\n${title}\n${bodyHtml}\n</div>${notesHtml}${overlay}\n</div>`;
254
299
  }
255
300
 
256
301
  // ---------------------------------------------------------------------------
@@ -277,7 +322,11 @@ function renderSlides(nodes, options = {}) {
277
322
  // Filter to scope nodes only (skip stray paragraphs and :comment scopes)
278
323
  const slides = slideScopes.filter((n) => n.type === "scope" && n.scopeType !== "comment");
279
324
 
280
- // Build per-slide footer: < CONFIDENTIAL ---gap--- Company >
325
+ // Build per-slide footer:
326
+ // < CONFIDENTIAL ---gap--- Company N/Total >
327
+ // The indicator slot is rendered as a literal token here and substituted
328
+ // per-slide inside renderSlide() so all the right-edge elements share one
329
+ // flexbox row (avoids the page number stacking on top of the company name).
281
330
  const footerParts = [];
282
331
  footerParts.push(`<span class="nav-prev">&lsaquo;</span>`);
283
332
  if (meta.confidential) {
@@ -292,11 +341,42 @@ function renderSlides(nodes, options = {}) {
292
341
  if (meta.company) {
293
342
  footerParts.push(`<span class="sdoc-company-footer">${escapeHtml(meta.company)}</span>`);
294
343
  }
344
+ footerParts.push(`<span class="slide-indicator">__SLIDE_INDICATOR__</span>`);
295
345
  footerParts.push(`<span class="nav-next">&rsaquo;</span>`);
296
346
  const overlayHtml = `\n<div class="slide-footer">${footerParts.join("")}</div>`;
297
347
 
298
- const slidesHtml = slides
299
- .map((scope, index) => renderSlide(scope, index, overlayHtml))
348
+ // Build a flat emission order: each spine slide, followed immediately by its
349
+ // :detail children in source order. The flat order matches what we want for
350
+ // PDF export, so PDF needs no special case.
351
+ const totalSpines = slides.length;
352
+ const emitted = [];
353
+ slides.forEach((scope, i) => {
354
+ const spineIndex = i + 1; // 1-based
355
+ const { details } = extractDetails(scope.children);
356
+ emitted.push({
357
+ scope,
358
+ position: {
359
+ spine: spineIndex,
360
+ detail: 0,
361
+ totalSpines,
362
+ hasDetails: details.length > 0
363
+ }
364
+ });
365
+ details.forEach((detail, j) => {
366
+ emitted.push({
367
+ scope: detail,
368
+ position: {
369
+ spine: spineIndex,
370
+ detail: j + 1,
371
+ totalSpines,
372
+ hasDetails: false
373
+ }
374
+ });
375
+ });
376
+ });
377
+
378
+ const slidesHtml = emitted
379
+ .map(({ scope, position }, index) => renderSlide(scope, index, overlayHtml, position))
300
380
  .join("\n\n");
301
381
 
302
382
  const title = meta.properties?.title
@@ -327,19 +407,39 @@ function renderSlides(nodes, options = {}) {
327
407
  color: rgba(160, 40, 40, 0.6);
328
408
  margin-left: 0.8em;
329
409
  }
410
+ .slide-indicator {
411
+ font-size: 0.7em; color: rgba(0,0,0,0.35);
412
+ font-variant-numeric: tabular-nums;
413
+ letter-spacing: 0.04em;
414
+ pointer-events: none;
415
+ user-select: none;
416
+ margin-right: 0.6em;
417
+ }
418
+ /* Scale wrapper: invisible to layout in screen mode so existing slide
419
+ styles (flex centering, two-column grid, etc.) work as-is. In print
420
+ mode it becomes a real block element whose transform is set by the
421
+ beforeprint handler to shrink overflowing content to fit the page. */
422
+ .slide-content-scale { display: contents; }
423
+
330
424
  @media print {
331
425
  @page { size: 13.333in 7.5in; margin: 0; }
332
426
  body { overflow: visible; height: auto; }
333
427
  .slide {
334
- display: flex !important;
428
+ display: block !important;
335
429
  position: relative !important;
336
430
  opacity: 1 !important;
337
431
  pointer-events: auto !important;
338
432
  page-break-after: always; break-after: page;
339
433
  width: 100vw; height: 100vh; max-width: none;
434
+ overflow: hidden;
340
435
  page-break-inside: avoid; break-inside: avoid;
341
436
  }
342
437
  .slide:last-child { page-break-after: auto; break-after: auto; }
438
+ .slide-content-scale {
439
+ display: block;
440
+ width: 100%;
441
+ transform-origin: top left;
442
+ }
343
443
  .nav-prev, .nav-next { display: none !important; }
344
444
  .notes { display: none; }
345
445
  }`;
@@ -359,6 +459,7 @@ blockquote p { color: #9d9d9d; }
359
459
  .nav-prev, .nav-next { color: rgba(255, 255, 255, 0.7); }
360
460
  .sdoc-company-footer { color: rgba(255, 255, 255, 0.35); }
361
461
  .sdoc-confidential-notice { color: rgba(235, 120, 120, 0.7); }
462
+ .slide-indicator { color: rgba(255, 255, 255, 0.35); }
362
463
  ` : "";
363
464
 
364
465
  const cssTag = `<style>\n${structuralCss}\n${themeCss}\n${darkCss}</style>`;
@@ -135,7 +135,7 @@ async function buildHtml(filePath, options = {}) {
135
135
 
136
136
  const title = path.basename(resolvedPath, ".sdoc");
137
137
 
138
- return renderHtmlDocumentFromParsed(
138
+ const html = renderHtmlDocumentFromParsed(
139
139
  { nodes: metaResult.nodes, errors: parsed.errors },
140
140
  title,
141
141
  {
@@ -146,6 +146,41 @@ async function buildHtml(filePath, options = {}) {
146
146
  includeAbout,
147
147
  }
148
148
  );
149
+
150
+ // Inline local images as data URIs so output is self-contained. PDF export
151
+ // renders from a temp directory, where relative image paths (e.g.
152
+ // diagrams/foo.svg) no longer resolve; inlining also makes HTML portable.
153
+ return inlineLocalImages(html, docDir);
154
+ }
155
+
156
+ const IMAGE_MIME = {
157
+ ".svg": "image/svg+xml",
158
+ ".png": "image/png",
159
+ ".jpg": "image/jpeg",
160
+ ".jpeg": "image/jpeg",
161
+ ".gif": "image/gif",
162
+ ".webp": "image/webp",
163
+ };
164
+
165
+ function inlineLocalImages(html, docDir) {
166
+ return html.replace(/(<img\b[^>]*?\bsrc=")([^"]*)(")/gi, (match, pre, src, post) => {
167
+ // Leave remote URLs and already-inlined data URIs untouched.
168
+ if (/^(https?:|data:|file:)/i.test(src)) return match;
169
+ const decoded = src.replace(/&amp;/g, "&");
170
+ const ext = path.extname(decoded).toLowerCase();
171
+ const mime = IMAGE_MIME[ext];
172
+ if (!mime) return match;
173
+ const abs = path.isAbsolute(decoded) ? decoded : path.join(docDir, decoded);
174
+ let data;
175
+ try {
176
+ data = fs.readFileSync(abs);
177
+ } catch {
178
+ console.error(`Warning: could not inline image (not found): ${decoded}`);
179
+ return match;
180
+ }
181
+ const uri = `data:${mime};base64,${data.toString("base64")}`;
182
+ return `${pre}${uri}${post}`;
183
+ });
149
184
  }
150
185
 
151
186
  async function main() {
@@ -197,6 +232,7 @@ async function main() {
197
232
  outputPath = resolvedInput.replace(/\.sdoc$/i, "") + ".pdf";
198
233
  }
199
234
  const resolvedOutput = path.resolve(outputPath);
235
+ fs.mkdirSync(path.dirname(resolvedOutput), { recursive: true });
200
236
 
201
237
  // Write HTML to temp file for Chrome
202
238
  const tmpHtml = path.join(os.tmpdir(), "sdoc-doc-" + Date.now() + ".html");