slidemind 0.1.6 → 0.1.7

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();
@@ -76,8 +76,8 @@ function resolveEngine(deckDir) {
76
76
  export async function lintSlidesOrThrow({ deckDir, slideFiles, layoutSources }) {
77
77
  const { enginePath, version, primitives } = resolveEngine(deckDir);
78
78
 
79
- // `?v=` busts Node's per-process module cache so a long-lived process (the
80
- // MCP server) picks up a refreshed engine at the same path.
79
+ // `?v=` busts Node's per-process module cache so a re-import picks up a
80
+ // refreshed engine at the same path after a bundle sync.
81
81
  const engine = await import(
82
82
  `${pathToFileURL(enginePath).href}?v=${encodeURIComponent(version)}`
83
83
  );
@@ -22,6 +22,7 @@
22
22
  (function () {
23
23
  var CFG = window.__SLIDEMIND_REVIEW__;
24
24
  if (!CFG) return;
25
+ if (new URLSearchParams(location.search).get("thumb") === "1") return;
25
26
 
26
27
  var STAGE_SEL = "#stage";
27
28
  var HEADER_H = 62;
@@ -119,15 +120,15 @@
119
120
  "@keyframes sm-pin-in{from{transform:translate(-50%,-50%) scale(0)}to{transform:translate(-50%,-50%) scale(1)}}" +
120
121
  "@keyframes sm-veil-in{from{opacity:0}to{opacity:1}}" +
121
122
  "@keyframes sm-rise{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:none}}" +
122
- ":root{--sm-bg:#0f0f0f;--sm-surface:#161618;--sm-border:#2b2b30;--sm-fg:#ededed;" +
123
- "--sm-dim:#9a9aa0;--sm-accent:#a9c2ff;--sm-success:#2ecc71;--sm-danger:#f87171;" +
123
+ ":root{--sm-bg:var(--deck-chrome-bg,#111113);--sm-surface:var(--deck-chrome-bg-2,#161618);" +
124
+ "--sm-border:var(--deck-chrome-border,#2b2b30);--sm-fg:var(--deck-chrome-fg,#ededed);" +
125
+ "--sm-dim:var(--deck-chrome-dim,#9a9aa0);--sm-accent:#a9c2ff;--sm-success:#2ecc71;--sm-danger:#f87171;" +
124
126
  "--sm-font:'Poppins',ui-sans-serif,system-ui,sans-serif;--sm-word:'Urbanist',var(--sm-font);}" +
125
127
  ".sm-o,.sm-o *{box-sizing:border-box;}" +
126
128
  /* header */
127
129
  ".sm-bar{position:fixed;top:0;left:0;right:0;z-index:2147483600;display:flex;align-items:center;" +
128
- "gap:16px;height:62px;padding:0 18px;color:var(--sm-fg);font-family:var(--sm-font);font-size:13px;" +
129
- "background:linear-gradient(180deg,rgba(13,13,15,.97),rgba(13,13,15,.86));" +
130
- "-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);border-bottom:1px solid var(--sm-border);" +
130
+ "gap:16px;height:var(--deck-chrome-h,62px);padding:0 18px;color:var(--sm-fg);font-family:var(--sm-font);font-size:13px;" +
131
+ "background:var(--sm-bg);border-bottom:1px solid var(--sm-border);" +
131
132
  "user-select:none;animation:sm-bar-in .5s cubic-bezier(.2,.7,.2,1);}" +
132
133
  ".sm-brand{display:flex;align-items:center;gap:11px;}" +
133
134
  ".sm-logo{position:relative;width:48px;height:27px;border-radius:5px;overflow:hidden;flex-shrink:0;background:#0a0c11;box-shadow:0 1px 3px rgba(0,0,0,.5);}" +
@@ -142,6 +143,7 @@
142
143
  ".sm-meta{display:flex;flex-direction:column;gap:2px;}" +
143
144
  ".sm-meta .t{font-weight:600;font-size:13px;}" +
144
145
  ".sm-meta .h{font-size:11.5px;color:var(--sm-dim);}" +
146
+ ".sm-bar~.deck__rail,.deck__rail{top:var(--deck-chrome-h,62px);}" +
145
147
  ".sm-grow{flex:1;}" +
146
148
  ".sm-count{font-size:12px;color:var(--sm-dim);font-variant-numeric:tabular-nums;}" +
147
149
  /* buttons */
@@ -408,8 +410,10 @@
408
410
  function applyStageFit() {
409
411
  var st = stage();
410
412
  if (!st) return;
413
+ var rail = document.getElementById("slide-rail");
414
+ var railW = rail && getComputedStyle(rail).display !== "none" ? rail.offsetWidth : 0;
411
415
  var reserve = sidebarOpen ? SIDEBAR_W : 0;
412
- var availW = window.innerWidth - reserve;
416
+ var availW = window.innerWidth - railW - reserve;
413
417
  var availH = window.innerHeight - HEADER_H - 8;
414
418
  var s = Math.min(availW / 1280, availH / 720) * 0.94;
415
419
  var offX = -reserve / 2;
@@ -692,12 +696,23 @@
692
696
  })
693
697
  .then(function () {
694
698
  showDone(decision);
699
+ requestTabClose();
695
700
  })
696
701
  .catch(function () {
697
702
  submitted = false;
698
703
  alert("Could not reach the slidemind review server — is the terminal command still running?");
699
704
  });
700
705
  }
706
+ function requestTabClose() {
707
+ setTimeout(function () {
708
+ try {
709
+ window.open("", "_self");
710
+ window.close();
711
+ } catch {
712
+ // Some browsers only allow script-opened tabs to close themselves.
713
+ }
714
+ }, 120);
715
+ }
701
716
  function showDone(decision) {
702
717
  closePopover();
703
718
  if (pinLayer) pinLayer.style.display = "none";
@@ -713,7 +728,7 @@
713
728
  ICONS.check + "<span>" + (decision === "approve" ? "Approved" : "Comments sent") + "</span>",
714
729
  ),
715
730
  );
716
- veil.appendChild(el("div", "sub", "Returned to your terminal — you can close this tab."));
731
+ veil.appendChild(el("div", "sub", "Returned to your terminal."));
717
732
  document.body.appendChild(veil);
718
733
  }
719
734
 
@@ -291,7 +291,6 @@ export async function createReviewServer({
291
291
 
292
292
  export async function runReview({
293
293
  deckDir,
294
- open = true,
295
294
  port = 0,
296
295
  timeoutMs = DEFAULT_TIMEOUT_MS,
297
296
  } = {}) {
@@ -311,14 +310,10 @@ export async function runReview({
311
310
  port,
312
311
  });
313
312
  process.stderr.write(`Preview review ready at ${server.url}\n`);
314
- if (open) {
315
- openInBrowser(server.url);
316
- process.stderr.write(
317
- "Opened your browser — leave comments or approve, then return here.\n",
318
- );
319
- } else {
320
- process.stderr.write("Open the URL above in your browser to review.\n");
321
- }
313
+ openInBrowser(server.url);
314
+ process.stderr.write(
315
+ "Opened your browser — leave comments or approve, then return here.\n",
316
+ );
322
317
 
323
318
  let decision;
324
319
  try {
@@ -347,19 +342,17 @@ export async function runReview({
347
342
  }
348
343
 
349
344
  function parseArgs(argv) {
350
- const args = { deck: null, open: true, port: 0 };
345
+ const args = { deck: null, port: 0 };
351
346
  for (let i = 0; i < argv.length; i++) {
352
347
  const a = argv[i];
353
348
  if (a === "--deck" && i + 1 < argv.length) {
354
349
  args.deck = argv[++i];
355
- } else if (a === "--no-open") {
356
- args.open = false;
357
350
  } else if (a === "--port" && i + 1 < argv.length) {
358
351
  args.port = parseInt(argv[++i], 10) || 0;
359
352
  } else if (a === "-h" || a === "--help") {
360
353
  process.stdout.write(
361
- "Usage: slidemind review <deck> [--no-open] [--port <n>]\n" +
362
- " node src/builders/review.mjs --deck <deck> [--no-open] [--port <n>]\n",
354
+ "Usage: slidemind review <deck> [--port <n>]\n" +
355
+ " node src/builders/review.mjs --deck <deck> [--port <n>]\n",
363
356
  );
364
357
  process.exit(0);
365
358
  } else {
@@ -377,7 +370,7 @@ function parseArgs(argv) {
377
370
  async function main() {
378
371
  const args = parseArgs(process.argv.slice(2));
379
372
  const deckDir = path.resolve(args.deck);
380
- await runReview({ deckDir, open: args.open, port: args.port });
373
+ await runReview({ deckDir, port: args.port });
381
374
  }
382
375
 
383
376
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
@@ -62,27 +62,20 @@ function saveClaudeConfig(cfg) {
62
62
  fs.writeFileSync(file, JSON.stringify(cfg, null, 2) + "\n");
63
63
  }
64
64
 
65
+ // slidemind is a CLI steered by the skill — there is no MCP server. This only
66
+ // tears down any `mcpServers.slidemind` entry an older install registered, so
67
+ // re-running `init` converges to the CLI-only setup. Returns whether it removed one.
65
68
  function unregisterMcpServer() {
66
69
  const file = claudeUserConfigFile();
67
- if (!fs.existsSync(file)) return;
70
+ if (!fs.existsSync(file)) return false;
68
71
  const cfg = loadClaudeConfig();
69
72
  if (cfg.mcpServers && cfg.mcpServers[NAMESPACE]) {
70
73
  delete cfg.mcpServers[NAMESPACE];
71
74
  if (Object.keys(cfg.mcpServers).length === 0) delete cfg.mcpServers;
72
75
  saveClaudeConfig(cfg);
76
+ return true;
73
77
  }
74
- }
75
-
76
- function registerMcpServer(packageRoot) {
77
- const mcpServer = path.join(packageRoot, "src", "mcp", "server.mjs");
78
- const cfg = loadClaudeConfig();
79
- cfg.mcpServers = cfg.mcpServers || {};
80
- cfg.mcpServers[NAMESPACE] = {
81
- type: "stdio",
82
- command: "node",
83
- args: [mcpServer],
84
- };
85
- saveClaudeConfig(cfg);
78
+ return false;
86
79
  }
87
80
 
88
81
  export async function install({ packageRoot, flags }) {
@@ -111,10 +104,10 @@ export async function install({ packageRoot, flags }) {
111
104
  );
112
105
  }
113
106
 
114
- // Register the slidemind MCP server so agents can list/push into open
115
- // PowerPoint windows via slidemind_list_open_presentations / slidemind_push_deck.
116
- registerMcpServer(packageRoot);
117
- ui.step({ status: "ok", label: "mcp server", value: claudeUserConfigFile() });
107
+ // slidemind is a CLI now — remove any MCP registration an older install left.
108
+ if (unregisterMcpServer()) {
109
+ ui.step({ status: "remove", label: "mcp server", value: "removed legacy registration (slidemind is a CLI)" });
110
+ }
118
111
  }
119
112
 
120
113
  export async function uninstall() {