@entropicwarrior/sdoc 0.2.14 → 0.2.15

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.
@@ -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
 
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.15",
6
6
  "publisher": "entropicwarrior-msenfin",
7
7
  "license": "MIT",
8
8
  "repository": {
@@ -206,18 +206,45 @@ function extractNotes(children) {
206
206
  return { notes, contentNodes: rest };
207
207
  }
208
208
 
209
+ // Separates :detail child scopes (drilldown / vertical slides) from other
210
+ // children. Detail scopes are NOT removed from rendering; they are emitted as
211
+ // sibling slides positioned vertically under the spine slide.
212
+ function extractDetails(children) {
213
+ const details = [];
214
+ const rest = [];
215
+ for (const child of children) {
216
+ if (child.type === "scope" && child.scopeType === "detail") {
217
+ details.push(child);
218
+ } else {
219
+ rest.push(child);
220
+ }
221
+ }
222
+ return { details, contentNodes: rest };
223
+ }
224
+
209
225
  // ---------------------------------------------------------------------------
210
226
  // Slide rendering
211
227
  // ---------------------------------------------------------------------------
212
228
 
213
- function renderSlide(scope, slideIndex, overlayHtml) {
214
- const { config, contentNodes: afterConfig } = extractSlideConfig(scope.children);
229
+ // position: { spine: 1-based spine index, detail: 0 for spine, 1..N for details,
230
+ // totalSpines: total number of spine slides, hasDetails: bool (spine only) }
231
+ function renderSlide(scope, slideIndex, overlayHtml, position) {
232
+ // Pull :detail children out first so they don't appear inline in the spine
233
+ // slide's content; they're rendered as sibling vertical slides instead.
234
+ const { contentNodes: afterDetails } = extractDetails(scope.children);
235
+ const { config, contentNodes: afterConfig } = extractSlideConfig(afterDetails);
215
236
  const { notes, contentNodes } = extractNotes(afterConfig);
216
237
 
217
238
  const classes = ["slide"];
218
239
  if (config.layout) {
219
240
  classes.push(config.layout);
220
241
  }
242
+ if (position && position.detail === 0 && position.hasDetails) {
243
+ classes.push("slide-has-details");
244
+ }
245
+ if (position && position.detail > 0) {
246
+ classes.push("slide-detail");
247
+ }
221
248
 
222
249
  const title = scope.hasHeading !== false && scope.title
223
250
  ? `<h2>${renderInline(scope.title)}</h2>`
@@ -248,9 +275,25 @@ function renderSlide(scope, slideIndex, overlayHtml) {
248
275
  : "";
249
276
 
250
277
  const idAttr = scope.id ? ` id="${escapeAttr(scope.id)}"` : "";
251
- const overlay = overlayHtml || "";
252
278
 
253
- return `<div class="${classes.join(" ")}"${idAttr}>\n${title}\n${bodyHtml}${notesHtml}${overlay}\n</div>`;
279
+ // Drilldown metadata + slide indicator label (substituted into the footer)
280
+ let dataAttrs = "";
281
+ let indicatorLabel = "";
282
+ if (position) {
283
+ dataAttrs = ` data-spine="${position.spine}" data-detail="${position.detail}"`;
284
+ const denom = position.totalSpines;
285
+ indicatorLabel = position.detail === 0
286
+ ? `${position.spine} / ${denom}`
287
+ : `${position.spine}.${position.detail} / ${denom}`;
288
+ }
289
+ const overlay = (overlayHtml || "").replace("__SLIDE_INDICATOR__", escapeHtml(indicatorLabel));
290
+
291
+ // Wrap title + body in a scale container so PDF export can apply
292
+ // transform: scale() to fit the content onto a fixed page size. In
293
+ // screen mode the wrapper is display:contents (invisible to layout); in
294
+ // print mode it becomes a real block that the beforeprint handler can
295
+ // measure and scale.
296
+ return `<div class="${classes.join(" ")}"${idAttr}${dataAttrs}>\n<div class="slide-content-scale">\n${title}\n${bodyHtml}\n</div>${notesHtml}${overlay}\n</div>`;
254
297
  }
255
298
 
256
299
  // ---------------------------------------------------------------------------
@@ -277,7 +320,11 @@ function renderSlides(nodes, options = {}) {
277
320
  // Filter to scope nodes only (skip stray paragraphs and :comment scopes)
278
321
  const slides = slideScopes.filter((n) => n.type === "scope" && n.scopeType !== "comment");
279
322
 
280
- // Build per-slide footer: < CONFIDENTIAL ---gap--- Company >
323
+ // Build per-slide footer:
324
+ // < CONFIDENTIAL ---gap--- Company N/Total >
325
+ // The indicator slot is rendered as a literal token here and substituted
326
+ // per-slide inside renderSlide() so all the right-edge elements share one
327
+ // flexbox row (avoids the page number stacking on top of the company name).
281
328
  const footerParts = [];
282
329
  footerParts.push(`<span class="nav-prev">&lsaquo;</span>`);
283
330
  if (meta.confidential) {
@@ -292,11 +339,42 @@ function renderSlides(nodes, options = {}) {
292
339
  if (meta.company) {
293
340
  footerParts.push(`<span class="sdoc-company-footer">${escapeHtml(meta.company)}</span>`);
294
341
  }
342
+ footerParts.push(`<span class="slide-indicator">__SLIDE_INDICATOR__</span>`);
295
343
  footerParts.push(`<span class="nav-next">&rsaquo;</span>`);
296
344
  const overlayHtml = `\n<div class="slide-footer">${footerParts.join("")}</div>`;
297
345
 
298
- const slidesHtml = slides
299
- .map((scope, index) => renderSlide(scope, index, overlayHtml))
346
+ // Build a flat emission order: each spine slide, followed immediately by its
347
+ // :detail children in source order. The flat order matches what we want for
348
+ // PDF export, so PDF needs no special case.
349
+ const totalSpines = slides.length;
350
+ const emitted = [];
351
+ slides.forEach((scope, i) => {
352
+ const spineIndex = i + 1; // 1-based
353
+ const { details } = extractDetails(scope.children);
354
+ emitted.push({
355
+ scope,
356
+ position: {
357
+ spine: spineIndex,
358
+ detail: 0,
359
+ totalSpines,
360
+ hasDetails: details.length > 0
361
+ }
362
+ });
363
+ details.forEach((detail, j) => {
364
+ emitted.push({
365
+ scope: detail,
366
+ position: {
367
+ spine: spineIndex,
368
+ detail: j + 1,
369
+ totalSpines,
370
+ hasDetails: false
371
+ }
372
+ });
373
+ });
374
+ });
375
+
376
+ const slidesHtml = emitted
377
+ .map(({ scope, position }, index) => renderSlide(scope, index, overlayHtml, position))
300
378
  .join("\n\n");
301
379
 
302
380
  const title = meta.properties?.title
@@ -327,19 +405,39 @@ function renderSlides(nodes, options = {}) {
327
405
  color: rgba(160, 40, 40, 0.6);
328
406
  margin-left: 0.8em;
329
407
  }
408
+ .slide-indicator {
409
+ font-size: 0.7em; color: rgba(0,0,0,0.35);
410
+ font-variant-numeric: tabular-nums;
411
+ letter-spacing: 0.04em;
412
+ pointer-events: none;
413
+ user-select: none;
414
+ margin-right: 0.6em;
415
+ }
416
+ /* Scale wrapper: invisible to layout in screen mode so existing slide
417
+ styles (flex centering, two-column grid, etc.) work as-is. In print
418
+ mode it becomes a real block element whose transform is set by the
419
+ beforeprint handler to shrink overflowing content to fit the page. */
420
+ .slide-content-scale { display: contents; }
421
+
330
422
  @media print {
331
423
  @page { size: 13.333in 7.5in; margin: 0; }
332
424
  body { overflow: visible; height: auto; }
333
425
  .slide {
334
- display: flex !important;
426
+ display: block !important;
335
427
  position: relative !important;
336
428
  opacity: 1 !important;
337
429
  pointer-events: auto !important;
338
430
  page-break-after: always; break-after: page;
339
431
  width: 100vw; height: 100vh; max-width: none;
432
+ overflow: hidden;
340
433
  page-break-inside: avoid; break-inside: avoid;
341
434
  }
342
435
  .slide:last-child { page-break-after: auto; break-after: auto; }
436
+ .slide-content-scale {
437
+ display: block;
438
+ width: 100%;
439
+ transform-origin: top left;
440
+ }
343
441
  .nav-prev, .nav-next { display: none !important; }
344
442
  .notes { display: none; }
345
443
  }`;
@@ -359,6 +457,7 @@ blockquote p { color: #9d9d9d; }
359
457
  .nav-prev, .nav-next { color: rgba(255, 255, 255, 0.7); }
360
458
  .sdoc-company-footer { color: rgba(255, 255, 255, 0.35); }
361
459
  .sdoc-confidential-notice { color: rgba(235, 120, 120, 0.7); }
460
+ .slide-indicator { color: rgba(255, 255, 255, 0.35); }
362
461
  ` : "";
363
462
 
364
463
  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");