slidemind 0.1.6 → 0.1.8

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.
@@ -0,0 +1,89 @@
1
+ // Engine-result download logic behind the `slidemind download-result` CLI
2
+ // command (bin/slidemind.mjs). Fetches a chosen image/icon search result over
3
+ // the bridge and writes it into a deck's assets/, optionally baking in a
4
+ // colour, and classifies how it recolours.
5
+ //
6
+ // It takes an already-loaded bridge `client` rather than importing it, so the
7
+ // caller controls how the (heavier) client module is loaded.
8
+
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+
12
+ import { describeRecolor } from "./recolor.mjs";
13
+
14
+ const MIME_EXT = {
15
+ "image/svg+xml": "svg",
16
+ "image/png": "png",
17
+ "image/jpeg": "jpg",
18
+ "image/jpg": "jpg",
19
+ "image/webp": "webp",
20
+ "image/gif": "gif",
21
+ };
22
+
23
+ // Derive a usable file extension from an engine result's MIME type. The brand
24
+ // engine can return any image Content-Type (image/x-icon, image/bmp,
25
+ // image/avif, …) and may carry a `;charset=` suffix, so an exact-match table
26
+ // alone would fall back to a useless `.bin` that won't render in an <img>.
27
+ // Strip params, then fall back to the MIME subtype itself for any `image/*`.
28
+ export function extFromMime(mimeType) {
29
+ const mt = String(mimeType || "")
30
+ .split(";")[0]
31
+ .trim()
32
+ .toLowerCase();
33
+ if (MIME_EXT[mt]) return MIME_EXT[mt];
34
+ const m = /^image\/(?:x-)?([a-z0-9.+-]+)$/.exec(mt);
35
+ if (m) {
36
+ const sub = m[1].replace(/\+xml$/, "");
37
+ if (sub === "svg") return "svg";
38
+ if (sub === "jpeg") return "jpg";
39
+ if (sub === "icon") return "ico";
40
+ return sub; // png, gif, webp, bmp, avif, tiff, heic, …
41
+ }
42
+ return "bin";
43
+ }
44
+
45
+ // Download one chosen engine result via the bridge and write it into assetsDir.
46
+ // Returns { selection, path, reference, mimeType, recolor }. Throws on failure
47
+ // so callers can Promise.allSettled a batch without one bad selection sinking
48
+ // the rest.
49
+ export async function downloadOneResult({
50
+ client,
51
+ presId,
52
+ backendURL = null,
53
+ searchId,
54
+ assetsDir,
55
+ selection,
56
+ iconColor,
57
+ }) {
58
+ const res = await client.engineDownload({
59
+ presId,
60
+ backendURL,
61
+ searchId,
62
+ selection,
63
+ iconColor: iconColor ?? null,
64
+ });
65
+ if (!res?.ok) {
66
+ throw new Error(res?.error ?? "download failed");
67
+ }
68
+ const ext = extFromMime(res.mimeType);
69
+ const filename = `engine-${searchId}-${selection}.${ext}`;
70
+ const target = path.join(assetsDir, filename);
71
+ if (res.svgMarkup && ext === "svg") {
72
+ // The (possibly recoloured) SVG markup is the canonical asset.
73
+ fs.writeFileSync(target, res.svgMarkup, "utf8");
74
+ } else if (typeof res.base64 === "string" && res.base64.length > 0) {
75
+ fs.writeFileSync(target, Buffer.from(res.base64, "base64"));
76
+ } else if (res.svgMarkup) {
77
+ // SVG bytes arrived only as markup (mimeType lacked an svg hint).
78
+ fs.writeFileSync(target, res.svgMarkup, "utf8");
79
+ } else {
80
+ throw new Error("engine result carried neither base64 bytes nor svgMarkup");
81
+ }
82
+ return {
83
+ selection,
84
+ path: target,
85
+ reference: `./assets/${filename}`,
86
+ mimeType: res.mimeType,
87
+ recolor: describeRecolor(res.svgMarkup ?? null, res.mimeType, iconColor ?? null),
88
+ };
89
+ }
@@ -2,22 +2,23 @@
2
2
  // open the SVG to find out (the exact step that was wasting time: "let me
3
3
  // inspect two SVGs to confirm how recolouring works").
4
4
  //
5
- // The slidemind_download_result tool already has the chosen result's bytes in
6
- // hand (the bridge returns `svgMarkup` for SVG results), so we can classify it
7
- // once and report the answer instead of leaving the agent to guess.
5
+ // The download path already has the chosen result's bytes in hand (the bridge
6
+ // returns `svgMarkup` for SVG results), so we can classify it once and report
7
+ // the answer instead of leaving the agent to guess. Shared by the
8
+ // `slidemind download-result` CLI command (see src/bridge/engine-download.mjs).
8
9
  //
9
10
  // mode:
10
11
  // "currentColor" — paints with fill/stroke="currentColor". A CSS `color`
11
12
  // on the <img> tints it live in BOTH the HTML preview
12
13
  // (which inlines the SVG, see src/builders/build.mjs) and
13
14
  // the .pptx render (which substitutes currentColor). You
14
- // can also bake a fixed colour in with icon_color.
15
+ // can also bake a fixed colour in with --icon-color.
15
16
  // "mono" — a single hard-coded paint colour (or the SVG default,
16
17
  // black) and no currentColor. CSS `color` will NOT touch
17
- // it — recolour by re-downloading with icon_color, which
18
+ // it — recolour by re-downloading with --icon-color, which
18
19
  // makes the bridge rewrite the fill.
19
20
  // "multicolor" — several distinct paint colours. Leave it as-is; passing
20
- // icon_color would flatten a deliberately multi-tone glyph.
21
+ // --icon-color would flatten a deliberately multi-tone glyph.
21
22
  // "raster" — PNG/JPEG/etc. Colour is not applicable.
22
23
 
23
24
  function isSvg(mimeType, svgMarkup) {
@@ -92,13 +93,13 @@ export function describeRecolor(svgMarkup, mimeType, appliedColor = null) {
92
93
  } else if (mode === "currentColor") {
93
94
  note =
94
95
  "Tintable: set CSS `color` on the <img> (honoured in both the HTML " +
95
- "preview and the .pptx render), or pass icon_color to bake a fixed colour in.";
96
+ "preview and the .pptx render), or pass --icon-color to bake a fixed colour in.";
96
97
  } else if (mode === "mono") {
97
98
  note =
98
99
  "Single-colour glyph: CSS `color` won't tint it — re-download with " +
99
- "icon_color (a hex resolved from a corporate-design.css token) to recolour.";
100
+ "--icon-color (a hex resolved from a corporate-design.css token) to recolour.";
100
101
  } else {
101
- note = "Multi-colour glyph: leave as-is; icon_color would flatten it.";
102
+ note = "Multi-colour glyph: leave as-is; --icon-color would flatten it.";
102
103
  }
103
104
 
104
105
  return {
@@ -206,6 +206,27 @@ const HTML_TEMPLATE = `<!doctype html>
206
206
  <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Arimo:ital,wght@0,400..700;1,400..700&display=swap" />
207
207
  <link rel="stylesheet" href="./corporate-design.css" />
208
208
  <link rel="stylesheet" href="./layouts.css" />
209
+ <style>
210
+ /* Compatibility aliases for decks/slides authored against the older
211
+ statworx token names. New materialized bundles expose semantic tokens
212
+ (--fg-*, --bg-*, --border-*); existing slide JSX may still refer
213
+ to --sw-*. Keep those declarations valid in the HTML preview. */
214
+ :root {
215
+ --sw-black: var(--fg-1, #000000);
216
+ --sw-white: var(--bg-1, #ffffff);
217
+ --sw-ink: var(--fg-2, #283440);
218
+ --sw-slate: var(--fg-3, #6C7D8C);
219
+ --sw-gray: var(--fg-4, #B6BDCC);
220
+ --sw-fog: var(--bg-2, #EBF0F2);
221
+ --sw-ice-blue: var(--bg-3, #E6E6FF);
222
+ --sw-tech-blue: var(--fg-accent, #0000FF);
223
+ --sw-tech-blue-10: #E6E6FF;
224
+ --sw-tech-blue-20: #CCCCFF;
225
+ --sw-tech-blue-40: #9999FF;
226
+ --sw-tech-blue-80: #3333FF;
227
+ --sw-magenta: #E6007E;
228
+ }
229
+ </style>
209
230
  <style>
210
231
  /* Substitute Arial with Arimo (a metric clone) so wrap behaviour
211
232
  matches PowerPoint's rendering. Local Arial wins only if Arimo
@@ -237,9 +258,18 @@ const HTML_TEMPLATE = `<!doctype html>
237
258
  </style>
238
259
  <style>
239
260
  html, body { margin: 0; padding: 0; height: 100%; background: #0a0a0a; overflow: hidden; }
261
+ :root {
262
+ --deck-rail-w: 220px;
263
+ --deck-chrome-h: 62px;
264
+ --deck-chrome-bg: #111113;
265
+ --deck-chrome-bg-2: #161618;
266
+ --deck-chrome-border: #2b2b30;
267
+ --deck-chrome-fg: #ededed;
268
+ --deck-chrome-dim: #9a9aa0;
269
+ }
240
270
  .deck {
241
271
  position: fixed;
242
- inset: 0;
272
+ inset: 0 0 0 var(--deck-rail-w);
243
273
  display: flex;
244
274
  align-items: center;
245
275
  justify-content: center;
@@ -265,6 +295,89 @@ const HTML_TEMPLATE = `<!doctype html>
265
295
  letter-spacing: 0.02em;
266
296
  user-select: none;
267
297
  }
298
+ .deck__rail {
299
+ position: fixed;
300
+ top: 0;
301
+ left: 0;
302
+ bottom: 0;
303
+ width: var(--deck-rail-w);
304
+ z-index: 2147483590;
305
+ display: flex;
306
+ flex-direction: column;
307
+ background: var(--deck-chrome-bg);
308
+ border-right: 1px solid var(--deck-chrome-border);
309
+ color: var(--deck-chrome-fg);
310
+ font-family: var(--font-sans, sans-serif);
311
+ }
312
+ .deck__thumbs {
313
+ flex: 1 1 auto;
314
+ overflow-y: auto;
315
+ padding: 12px 14px 18px;
316
+ scrollbar-width: none;
317
+ -ms-overflow-style: none;
318
+ }
319
+ .deck__thumbs::-webkit-scrollbar {
320
+ width: 0;
321
+ height: 0;
322
+ display: none;
323
+ }
324
+ .deck__thumb {
325
+ width: 100%;
326
+ margin: 0 0 12px;
327
+ padding: 8px;
328
+ border: 1px solid var(--deck-chrome-border);
329
+ border-radius: 8px;
330
+ background: rgba(255,255,255,0.025);
331
+ color: inherit;
332
+ cursor: pointer;
333
+ text-align: left;
334
+ transition: border-color 120ms ease, background 120ms ease, box-shadow 120ms ease;
335
+ }
336
+ .deck__thumb:hover {
337
+ background: rgba(255,255,255,0.06);
338
+ border-color: #3a3a40;
339
+ }
340
+ .deck__thumb.is-active {
341
+ border-color: rgba(237,237,237,0.7);
342
+ background: rgba(255,255,255,0.085);
343
+ box-shadow: 0 0 0 2px rgba(237,237,237,0.08);
344
+ }
345
+ .deck__thumb-preview {
346
+ width: 176px;
347
+ height: 99px;
348
+ overflow: hidden;
349
+ background: #fff;
350
+ box-shadow: 0 4px 14px rgba(0,0,0,0.28);
351
+ pointer-events: none;
352
+ }
353
+ .deck__thumb-frame {
354
+ display: block;
355
+ width: 176px;
356
+ height: 99px;
357
+ border: 0;
358
+ background: #fff;
359
+ pointer-events: none;
360
+ }
361
+ .deck__thumb-meta {
362
+ display: flex;
363
+ align-items: baseline;
364
+ gap: 7px;
365
+ margin-top: 7px;
366
+ min-width: 0;
367
+ font-size: 11px;
368
+ line-height: 1.25;
369
+ }
370
+ .deck__thumb-number {
371
+ flex: 0 0 auto;
372
+ color: var(--deck-chrome-dim);
373
+ font-variant-numeric: tabular-nums;
374
+ }
375
+ .deck__thumb-name {
376
+ min-width: 0;
377
+ overflow: hidden;
378
+ text-overflow: ellipsis;
379
+ white-space: nowrap;
380
+ }
268
381
  .deck__error {
269
382
  padding: 32px;
270
383
  color: #ff6b6b;
@@ -283,7 +396,16 @@ const HTML_TEMPLATE = `<!doctype html>
283
396
  margin: 0;
284
397
  position: static;
285
398
  }
286
- body.deck--export .deck__nav { display: none !important; }
399
+ body.deck--export .deck__nav,
400
+ body.deck--export .deck__rail { display: none !important; }
401
+ /* Thumbnail mode runs inside the left rail's isolated iframes. It must
402
+ render exactly one slide and no chrome, so per-slide <style> tags do not
403
+ leak into the main review document. */
404
+ body.deck--thumb { background: transparent !important; overflow: hidden; }
405
+ body.deck--thumb .deck { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; }
406
+ body.deck--thumb .deck__stage { box-shadow: none !important; }
407
+ body.deck--thumb .deck__nav,
408
+ body.deck--thumb .deck__rail { display: none !important; }
287
409
  /* Print mode (?print=1) — stacks every slide as its own page so the
288
410
  browser's Print / Cmd-P renders one slide per page (proofing). */
289
411
  body.deck--print {
@@ -292,7 +414,8 @@ const HTML_TEMPLATE = `<!doctype html>
292
414
  height: auto !important;
293
415
  }
294
416
  body.deck--print .deck,
295
- body.deck--print .deck__nav { display: none !important; }
417
+ body.deck--print .deck__nav,
418
+ body.deck--print .deck__rail { display: none !important; }
296
419
  .print-deck { background: #ffffff; }
297
420
  .print-page {
298
421
  width: 1280px;
@@ -325,6 +448,9 @@ const HTML_TEMPLATE = `<!doctype html>
325
448
  </style>
326
449
  </head>
327
450
  <body>
451
+ <aside class="deck__rail" id="slide-rail" aria-label="Slide thumbnails">
452
+ <div class="deck__thumbs" id="slide-thumbs"></div>
453
+ </aside>
328
454
  <div class="deck">
329
455
  <div class="deck__stage" id="stage"></div>
330
456
  </div>
@@ -540,10 +666,15 @@ __RUNTIME_APPLIER__
540
666
 
541
667
  var stage = document.getElementById("stage");
542
668
  var nav = document.getElementById("nav");
669
+ var rail = document.getElementById("slide-rail");
670
+ var thumbs = document.getElementById("slide-thumbs");
671
+ var thumbButtons = [];
543
672
  var params = new URLSearchParams(location.search);
544
673
  var exportMode = params.get("export") === "1";
545
674
  var printMode = params.get("print") === "1";
675
+ var thumbMode = params.get("thumb") === "1";
546
676
  if (exportMode) document.body.classList.add("deck--export");
677
+ if (thumbMode) document.body.classList.add("deck--thumb");
547
678
 
548
679
  smInlineSvgAssets();
549
680
 
@@ -576,6 +707,66 @@ __RUNTIME_APPLIER__
576
707
  var current = startIndex;
577
708
  var root = ReactDOM.createRoot(stage);
578
709
 
710
+ function buildThumbs() {
711
+ if (!thumbs || printMode || exportMode || thumbMode) return;
712
+ thumbs.innerHTML = "";
713
+ thumbButtons = [];
714
+ slides.forEach(function (slide, idx) {
715
+ var btn = document.createElement("button");
716
+ btn.type = "button";
717
+ btn.className = "deck__thumb";
718
+ btn.setAttribute("aria-label", "Go to slide " + (idx + 1) + ": " + slide.name);
719
+
720
+ var preview = document.createElement("div");
721
+ preview.className = "deck__thumb-preview";
722
+ var frame = document.createElement("iframe");
723
+ frame.className = "deck__thumb-frame";
724
+ frame.loading = "lazy";
725
+ frame.setAttribute("aria-hidden", "true");
726
+ frame.tabIndex = -1;
727
+ frame.src = location.pathname + "?slide=" + idx + "&thumb=1";
728
+ preview.appendChild(frame);
729
+
730
+ var meta = document.createElement("div");
731
+ meta.className = "deck__thumb-meta";
732
+ var num = document.createElement("span");
733
+ num.className = "deck__thumb-number";
734
+ num.textContent = String(idx + 1).padStart(2, "0");
735
+ var name = document.createElement("span");
736
+ name.className = "deck__thumb-name";
737
+ name.textContent = slide.name;
738
+ meta.appendChild(num);
739
+ meta.appendChild(name);
740
+
741
+ btn.appendChild(preview);
742
+ btn.appendChild(meta);
743
+ btn.addEventListener("click", function () {
744
+ setCurrent(idx);
745
+ stage.focus && stage.focus();
746
+ });
747
+ thumbs.appendChild(btn);
748
+ thumbButtons[idx] = btn;
749
+ });
750
+ }
751
+
752
+ function updateThumbs() {
753
+ thumbButtons.forEach(function (btn, idx) {
754
+ if (!btn) return;
755
+ var active = idx === current;
756
+ btn.classList.toggle("is-active", active);
757
+ btn.setAttribute("aria-current", active ? "true" : "false");
758
+ if (active && thumbs) {
759
+ var top = btn.offsetTop;
760
+ var bottom = top + btn.offsetHeight;
761
+ if (top < thumbs.scrollTop) {
762
+ thumbs.scrollTop = Math.max(0, top - 12);
763
+ } else if (bottom > thumbs.scrollTop + thumbs.clientHeight) {
764
+ thumbs.scrollTop = bottom - thumbs.clientHeight + 12;
765
+ }
766
+ }
767
+ });
768
+ }
769
+
579
770
  function render() {
580
771
  var slide = slides[current];
581
772
  if (!slide) return;
@@ -589,11 +780,18 @@ __RUNTIME_APPLIER__
589
780
  slide.name + ".jsx failed:\\n" + slide.error.message
590
781
  )
591
782
  );
783
+ updateThumbs();
592
784
  return;
593
785
  }
594
786
  root.render(
595
787
  React.createElement(slide.Component, { assetsPath: "./assets" })
596
788
  );
789
+ updateThumbs();
790
+ }
791
+
792
+ function setCurrent(index) {
793
+ current = Math.max(0, Math.min(index, slides.length - 1));
794
+ render();
597
795
  }
598
796
 
599
797
  function fit() {
@@ -601,32 +799,30 @@ __RUNTIME_APPLIER__
601
799
  stage.style.transform = "none";
602
800
  return;
603
801
  }
604
- var sx = window.innerWidth / 1280;
802
+ var railW = rail && getComputedStyle(rail).display !== "none" ? rail.offsetWidth : 0;
803
+ var sx = Math.max(1, window.innerWidth - railW) / 1280;
605
804
  var sy = window.innerHeight / 720;
606
- var s = Math.min(sx, sy) * 0.95;
805
+ var s = Math.min(sx, sy) * (thumbMode ? 1 : 0.95);
607
806
  stage.style.transform = "scale(" + s + ")";
608
807
  }
609
808
 
610
809
  window.addEventListener("resize", fit);
810
+ buildThumbs();
611
811
  fit();
612
812
  render();
613
813
 
614
814
  document.addEventListener("keydown", function (e) {
615
815
  var k = e.key;
616
816
  if (k === "ArrowRight" || k === "PageDown" || k === " ") {
617
- current = Math.min(current + 1, slides.length - 1);
618
- render();
817
+ setCurrent(current + 1);
619
818
  e.preventDefault();
620
819
  } else if (k === "ArrowLeft" || k === "PageUp") {
621
- current = Math.max(current - 1, 0);
622
- render();
820
+ setCurrent(current - 1);
623
821
  e.preventDefault();
624
822
  } else if (k === "Home") {
625
- current = 0;
626
- render();
823
+ setCurrent(0);
627
824
  } else if (k === "End") {
628
- current = slides.length - 1;
629
- render();
825
+ setCurrent(slides.length - 1);
630
826
  } else if (k === "f" || k === "F") {
631
827
  if (document.fullscreenElement) document.exitFullscreen();
632
828
  else document.documentElement.requestFullscreen();
@@ -721,17 +917,59 @@ export async function buildDeck(deckDir, opts = {}) {
721
917
  return { out, slideCount: slideFiles.length };
722
918
  }
723
919
 
724
- // Open a local file path OR an http URL in the user's default browser. The
725
- // platform openers (open / start / xdg-open) accept both, so review.mjs reuses
726
- // this to launch the local review server URL.
920
+ // Detect whether this Linux box is really WSL, so we can hand the target to the
921
+ // Windows-side opener instead of a (usually absent) Linux one. Any read/parse
922
+ // failure just means "not WSL".
923
+ function isWsl() {
924
+ if (process.platform !== "linux") return false;
925
+ try {
926
+ return /microsoft/i.test(fs.readFileSync("/proc/version", "utf8"));
927
+ } catch {
928
+ return false;
929
+ }
930
+ }
931
+
932
+ // Ordered list of opener candidates to try for the current platform. The
933
+ // platform openers (open / start / xdg-open / wslview) accept both a local file
934
+ // path AND an http URL, so review.mjs reuses this to launch the local review
935
+ // server URL and view-draft passes png paths.
936
+ function openerChain(target) {
937
+ if (process.platform === "darwin") return [{ cmd: "open", args: [target] }];
938
+ if (process.platform === "win32")
939
+ return [{ cmd: "cmd", args: ["/c", "start", "", target] }];
940
+ if (isWsl())
941
+ return [
942
+ { cmd: "wslview", args: [target] },
943
+ { cmd: "powershell.exe", args: ["-NoProfile", "-Command", "start", target] },
944
+ ];
945
+ return [{ cmd: "xdg-open", args: [target] }];
946
+ }
947
+
948
+ // Open a local file path OR an http URL in the user's default browser, trying
949
+ // each platform opener in turn. This is fire-and-forget and must NEVER crash:
950
+ // if the opener binary is missing (e.g. a headless Linux box with no xdg-open)
951
+ // the spawned child would otherwise emit an unhandled 'error' and take down the
952
+ // whole CLI. We degrade gracefully — on exhausting the chain we just print the
953
+ // target for the user to open manually.
727
954
  export function openInBrowser(target) {
728
- const opener =
729
- process.platform === "darwin"
730
- ? { cmd: "open", args: [target] }
731
- : process.platform === "win32"
732
- ? { cmd: "cmd", args: ["/c", "start", "", target] }
733
- : { cmd: "xdg-open", args: [target] };
734
- spawn(opener.cmd, opener.args, { stdio: "ignore", detached: true }).unref();
955
+ const chain = openerChain(target);
956
+ const tryAt = (i) => {
957
+ if (i >= chain.length) {
958
+ process.stderr.write(`couldn't open a browser — open ${target} manually\n`);
959
+ return;
960
+ }
961
+ const { cmd, args } = chain[i];
962
+ let child;
963
+ try {
964
+ child = spawn(cmd, args, { stdio: "ignore", detached: true });
965
+ } catch {
966
+ tryAt(i + 1);
967
+ return;
968
+ }
969
+ child.on("error", () => tryAt(i + 1));
970
+ child.unref();
971
+ };
972
+ tryAt(0);
735
973
  }
736
974
 
737
975
  function parseArgs(argv) {