@open-slide/core 0.0.6 → 0.0.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.
package/README.md ADDED
@@ -0,0 +1,84 @@
1
+ # @open-slide/core
2
+
3
+ Runtime and CLI for [open-slide](https://github.com/1weiho/open-slide) — a React-based slide framework where you write slides and the framework handles the Vite/React stack, layout, navigation, hot reload, and fullscreen play mode.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pnpm add @open-slide/core
9
+ ```
10
+
11
+ Most users get this installed automatically by running `npx @open-slide/cli init`. Use this package directly only if you're wiring up an existing workspace by hand.
12
+
13
+ ## What's inside
14
+
15
+ - **Runtime** — home page, slide viewer, thumbnail rail, keyboard navigation, and fullscreen presenter mode. Every slide renders into a fixed **1920×1080** canvas; the framework scales it.
16
+ - **Vite plugin** — discovers `slides/<id>/index.{tsx,jsx,ts,js}`, exposes them via virtual modules, and reloads when slides are added or removed.
17
+ - **CLI** — `open-slide dev | build | preview` so workspaces never need to touch Vite, React, or tsconfig directly.
18
+
19
+ ## CLI
20
+
21
+ Once installed, the `open-slide` bin is available in the workspace:
22
+
23
+ | Command | Description |
24
+ | --- | --- |
25
+ | `open-slide dev` | Start the dev server. Flags: `-p, --port <port>`, `--host [host]`, `--open`. |
26
+ | `open-slide build` | Build a static site. Flags: `--out-dir <dir>` (defaults to `dist`). |
27
+ | `open-slide preview` | Preview the production build. Flags: `-p, --port <port>`, `--host [host]`, `--open`. |
28
+
29
+ ## Config
30
+
31
+ Create `open-slide.config.ts` in the workspace root (all fields optional):
32
+
33
+ ```ts
34
+ import type { OpenSlideConfig } from '@open-slide/core';
35
+
36
+ const openSlideConfig: OpenSlideConfig = {
37
+ slidesDir: 'slides',
38
+ port: 5173,
39
+ };
40
+
41
+ export default openSlideConfig;
42
+ ```
43
+
44
+ ## Authoring slides
45
+
46
+ Slides live under `slides/<kebab-case-id>/index.tsx` and default-export an array of `Page` components:
47
+
48
+ ```tsx
49
+ import type { Page } from '@open-slide/core';
50
+
51
+ const Cover: Page = () => (
52
+ <div className="flex h-full w-full items-center justify-center">
53
+ <h1 className="text-[120px] font-bold">Hello, open-slide</h1>
54
+ </div>
55
+ );
56
+
57
+ const pages: Page[] = [Cover];
58
+ export default pages;
59
+
60
+ export const meta = { title: 'Hello' };
61
+ ```
62
+
63
+ ## Exports
64
+
65
+ ```ts
66
+ import {
67
+ CANVAS_WIDTH, // 1920
68
+ CANVAS_HEIGHT, // 1080
69
+ type Page,
70
+ type SlideMeta,
71
+ type SlideModule,
72
+ type OpenSlideConfig,
73
+ } from '@open-slide/core';
74
+ ```
75
+
76
+ The Vite plugin is exposed under a subpath for advanced setups:
77
+
78
+ ```ts
79
+ import { createViteConfig } from '@open-slide/core/vite';
80
+ ```
81
+
82
+ ## License
83
+
84
+ MIT
@@ -1,4 +1,4 @@
1
- import { createViteConfig } from "./config-DF58h0l4.js";
1
+ import { createViteConfig } from "./config-DOcMmFJ7.js";
2
2
  import path from "node:path";
3
3
  import { build as build$1, mergeConfig } from "vite";
4
4
 
package/dist/cli/bin.js CHANGED
@@ -22,15 +22,15 @@ async function run(argv) {
22
22
  const program = new Command();
23
23
  program.name("open-slide").description("Author slides — we handle the Vite/React stack.").version(version, "-v, --version", "print version").helpOption("-h, --help", "show help").showHelpAfterError(chalk.dim("(run `open-slide --help` for usage)"));
24
24
  program.command("dev").description("Start the dev server").addOption(new Option("-p, --port <port>", "port to listen on").argParser(parsePort)).addOption(new Option("--host [host]", "expose on the network (optional host)")).option("--open", "open the browser on start").action(async (flags) => {
25
- const { dev } = await import("../dev-h-rxb3xY.js");
25
+ const { dev } = await import("../dev-Brzmgu64.js");
26
26
  await dev(flags);
27
27
  });
28
28
  program.command("build").description("Build a static site").option("--out-dir <dir>", "output directory (defaults to `dist`)").action(async (flags) => {
29
- const { build } = await import("../build-BCORlVF3.js");
29
+ const { build } = await import("../build-cUKUY4bh.js");
30
30
  await build(flags);
31
31
  });
32
32
  program.command("preview").description("Preview the production build").addOption(new Option("-p, --port <port>", "port to listen on").argParser(parsePort)).addOption(new Option("--host [host]", "expose on the network (optional host)")).option("--open", "open the browser on start").action(async (flags) => {
33
- const { preview } = await import("../preview-lskE0s8A.js");
33
+ const { preview } = await import("../preview-Bf8iFXA-.js");
34
34
  await preview(flags);
35
35
  });
36
36
  await program.parseAsync(argv, { from: "user" });
@@ -1,11 +1,13 @@
1
- import fs, { readFile } from "node:fs/promises";
1
+ import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { existsSync } from "node:fs";
5
5
  import tailwindcss from "@tailwindcss/vite";
6
6
  import react from "@vitejs/plugin-react";
7
7
  import { randomUUID } from "node:crypto";
8
+ import { parse } from "@babel/parser";
8
9
  import fg from "fast-glob";
10
+ import { loadConfigFromFile } from "vite";
9
11
 
10
12
  //#region src/vite/comments-plugin.ts
11
13
  const MARKER_RE = /\{\/\*\s*@slide-comment\s+id="(c-[a-f0-9]+)"\s+ts="([^"]+)"\s+text="([A-Za-z0-9_-]+={0,2})"\s*\*\/\}/g;
@@ -70,31 +72,115 @@ function parseMarkers(source) {
70
72
  function newId() {
71
73
  return `c-${randomUUID().replace(/-/g, "").slice(0, 8)}`;
72
74
  }
73
- function isJsxOpeningLine(line) {
74
- const t = line.trimStart();
75
- if (!t.startsWith("<")) return false;
76
- if (t.startsWith("</")) return false;
77
- if (t.startsWith("<!")) return false;
78
- return true;
75
+ function lineToOffset(source, line) {
76
+ let off = 0;
77
+ for (let l = 1; l < line; l++) {
78
+ const nl = source.indexOf("\n", off);
79
+ if (nl === -1) return source.length;
80
+ off = nl + 1;
81
+ }
82
+ return off;
83
+ }
84
+ function lineIndent(source, lineNumber) {
85
+ const start = lineToOffset(source, lineNumber);
86
+ const m = source.slice(start, start + 200).match(/^[ \t]*/);
87
+ return m?.[0] ?? "";
88
+ }
89
+ /**
90
+ * Walk the AST, collect every JSXElement/JSXFragment whose location encloses
91
+ * the click point, ordered innermost-first.
92
+ *
93
+ * "Encloses" here is inclusive at the start (so a click on the opening `<`
94
+ * counts as inside) and exclusive at the end. We deliberately don't trust
95
+ * Babel's `_debugSource` line/column to be exact — HMR or upstream transforms
96
+ * can shift it slightly — so we treat the click as a probe and pick the
97
+ * tightest JSX container around it.
98
+ */
99
+ function findJsxAncestors(ast, line, column) {
100
+ const hits = [];
101
+ const walk = (node) => {
102
+ if (!node || typeof node !== "object") return;
103
+ if (Array.isArray(node)) {
104
+ for (const c of node) walk(c);
105
+ return;
106
+ }
107
+ const n = node;
108
+ if (typeof n.type !== "string") return;
109
+ if ((n.type === "JSXElement" || n.type === "JSXFragment") && n.loc) {
110
+ const s = n.loc.start;
111
+ const e = n.loc.end;
112
+ const afterStart = line > s.line || line === s.line && column >= s.column;
113
+ const beforeEnd = line < e.line || line === e.line && column < e.column;
114
+ if (afterStart && beforeEnd) hits.push({
115
+ node: n,
116
+ size: n.end - n.start
117
+ });
118
+ }
119
+ for (const key of Object.keys(n)) {
120
+ if (key === "loc" || key === "start" || key === "end" || key === "type" || key === "extra" || key === "leadingComments" || key === "trailingComments" || key === "innerComments") continue;
121
+ walk(n[key]);
122
+ }
123
+ };
124
+ walk(ast);
125
+ hits.sort((a, b) => a.size - b.size);
126
+ return hits.map((h) => h.node);
127
+ }
128
+ function planInsertion(source, target) {
129
+ if (target.type === "JSXFragment") {
130
+ const opening = target.openingFragment;
131
+ if (!opening) return null;
132
+ const startLine = target.loc?.start.line ?? 1;
133
+ return {
134
+ offset: opening.end,
135
+ indent: `${lineIndent(source, startLine)} `
136
+ };
137
+ }
138
+ if (target.type === "JSXElement") {
139
+ const opening = target.openingElement;
140
+ if (!opening || opening.selfClosing) return null;
141
+ const startLine = target.loc?.start.line ?? 1;
142
+ return {
143
+ offset: opening.end,
144
+ indent: `${lineIndent(source, startLine)} `
145
+ };
146
+ }
147
+ return null;
79
148
  }
80
149
  /**
81
- * Find the line index to insert a JSX comment above.
150
+ * Resolve a click on the slide page (line/col from React fiber's
151
+ * `_debugSource`) to an in-source offset where we can safely splice a
152
+ * `@slide-comment` marker.
82
153
  *
83
- * Babel's `_debugSource.lineNumber/columnNumber` points at the `<` of a JSX
84
- * opening tag, but the value can go stale (HMR races) or, per reports, point
85
- * at a line that's not actually a JSX boundary e.g. inside an inline style
86
- * object. Verify with the source of truth before committing.
154
+ * Strategy: parse the file, find every JSX container around the click, and
155
+ * walk innermost outermost looking for the first one we can insert *inside*
156
+ * (i.e. not self-closing). Self-closing elements like `<img/>` get hoisted to
157
+ * their nearest non-self-closing ancestor.
87
158
  */
88
- function findSafeInsertLine(lines, line, column) {
89
- const idx = line - 1;
90
- if (idx < 0 || idx >= lines.length) return null;
91
- if (column !== void 0 && lines[idx].charAt(column) === "<") return idx;
92
- if (isJsxOpeningLine(lines[idx])) return idx;
93
- const WINDOW = 30;
94
- for (let i = idx - 1; i >= Math.max(0, idx - WINDOW); i--) if (isJsxOpeningLine(lines[i])) return i;
95
- for (let i = idx + 1; i < Math.min(lines.length, idx + WINDOW); i++) if (isJsxOpeningLine(lines[i])) return i;
159
+ function findInsertion(source, line, column) {
160
+ let ast;
161
+ try {
162
+ ast = parse(source, {
163
+ sourceType: "module",
164
+ plugins: ["typescript", "jsx"],
165
+ errorRecovery: true
166
+ });
167
+ } catch {
168
+ return null;
169
+ }
170
+ const col = column ?? 0;
171
+ const ancestors = findJsxAncestors(ast, line, col);
172
+ if (ancestors.length === 0) return null;
173
+ for (const node of ancestors) {
174
+ const plan = planInsertion(source, node);
175
+ if (plan) return plan;
176
+ }
96
177
  return null;
97
178
  }
179
+ function offsetToLine(source, offset) {
180
+ let line = 1;
181
+ for (let i = 0; i < offset && i < source.length; i++) if (source[i] === "\n") line++;
182
+ return line;
183
+ }
98
184
  function commentsPlugin(opts) {
99
185
  const userCwd = opts.userCwd;
100
186
  const slidesDir = opts.slidesDir ?? "slides";
@@ -131,22 +217,21 @@ function commentsPlugin(opts) {
131
217
  } catch {
132
218
  return json$1(res, 404, { error: "slide not found" });
133
219
  }
134
- const lines = source.split("\n");
135
- const idx = findSafeInsertLine(lines, body.line, body.column);
136
- if (idx === null) return json$1(res, 422, { error: `could not find a safe JSX boundary near line ${body.line}. Try clicking a different element.` });
137
- const indent = lines[idx].match(/^\s*/)?.[0] ?? "";
220
+ const plan = findInsertion(source, body.line, body.column);
221
+ if (!plan) return json$1(res, 422, { error: `could not find a JSX container around line ${body.line}. Try clicking a different element.` });
138
222
  const id = newId();
139
223
  const ts = new Date().toISOString();
140
224
  const payload = b64urlEncode(JSON.stringify({
141
225
  note: body.text,
142
226
  hint: body.hint
143
227
  }));
144
- const marker = `${indent}{/* @slide-comment id="${id}" ts="${ts}" text="${payload}" */}`;
145
- lines.splice(idx, 0, marker);
146
- await fs.writeFile(file, lines.join("\n"), "utf8");
228
+ const marker = `\n${plan.indent}{/* @slide-comment id="${id}" ts="${ts}" text="${payload}" */}`;
229
+ const next$1 = source.slice(0, plan.offset) + marker + source.slice(plan.offset);
230
+ await fs.writeFile(file, next$1, "utf8");
231
+ const markerLine = offsetToLine(next$1, plan.offset + 1);
147
232
  return json$1(res, 200, {
148
233
  id,
149
- line: idx + 1
234
+ line: markerLine
150
235
  });
151
236
  }
152
237
  if (method === "DELETE" && url.pathname.startsWith("/")) {
@@ -481,8 +566,26 @@ function filesPlugin(opts) {
481
566
 
482
567
  //#endregion
483
568
  //#region src/vite/open-slide-plugin.ts
569
+ const CONFIG_FILE = "open-slide.config.ts";
484
570
  const SLIDES_VMOD = "virtual:open-slide/slides";
485
571
  const CONFIG_VMOD = "virtual:open-slide/config";
572
+ const FOLDERS_VMOD = "virtual:open-slide/folders";
573
+ async function readFoldersManifest(file) {
574
+ try {
575
+ const raw = await fs.readFile(file, "utf8");
576
+ const parsed = JSON.parse(raw);
577
+ return {
578
+ folders: Array.isArray(parsed.folders) ? parsed.folders : [],
579
+ assignments: parsed.assignments && typeof parsed.assignments === "object" ? parsed.assignments : {}
580
+ };
581
+ } catch (err) {
582
+ if (err.code === "ENOENT") return {
583
+ folders: [],
584
+ assignments: {}
585
+ };
586
+ throw err;
587
+ }
588
+ }
486
589
  function resolved(id) {
487
590
  return `\0${id}`;
488
591
  }
@@ -526,6 +629,7 @@ function openSlidePlugin(opts) {
526
629
  const { userCwd, config } = opts;
527
630
  const slidesDir = config.slidesDir ?? "slides";
528
631
  const slidesRoot = path.resolve(userCwd, slidesDir);
632
+ const foldersManifestPath = path.join(slidesRoot, ".folders.json");
529
633
  let isDev = false;
530
634
  return {
531
635
  name: "open-slide",
@@ -536,6 +640,7 @@ function openSlidePlugin(opts) {
536
640
  resolveId(id) {
537
641
  if (id === SLIDES_VMOD) return resolved(SLIDES_VMOD);
538
642
  if (id === CONFIG_VMOD) return resolved(CONFIG_VMOD);
643
+ if (id === FOLDERS_VMOD) return resolved(FOLDERS_VMOD);
539
644
  return null;
540
645
  },
541
646
  async load(id) {
@@ -543,7 +648,27 @@ function openSlidePlugin(opts) {
543
648
  const files = await findSlides(userCwd, slidesDir);
544
649
  return generateSlidesModule(files, slidesRoot, isDev);
545
650
  }
546
- if (id === resolved(CONFIG_VMOD)) return `export default ${JSON.stringify(config)};\n`;
651
+ if (id === resolved(CONFIG_VMOD)) {
652
+ const userBuild = config.build ?? {};
653
+ const buildResolved = isDev ? {
654
+ showSlideBrowser: true,
655
+ showSlideUi: true,
656
+ allowHtmlDownload: true
657
+ } : {
658
+ showSlideBrowser: userBuild.showSlideBrowser ?? true,
659
+ showSlideUi: userBuild.showSlideUi ?? true,
660
+ allowHtmlDownload: userBuild.allowHtmlDownload ?? true
661
+ };
662
+ const resolvedConfig = {
663
+ ...config,
664
+ build: buildResolved
665
+ };
666
+ return `export default ${JSON.stringify(resolvedConfig)};\n`;
667
+ }
668
+ if (id === resolved(FOLDERS_VMOD)) {
669
+ const manifest = await readFoldersManifest(foldersManifestPath);
670
+ return `export default ${JSON.stringify(manifest)};\n`;
671
+ }
547
672
  return null;
548
673
  },
549
674
  configureServer(server) {
@@ -559,18 +684,31 @@ function openSlidePlugin(opts) {
559
684
  server.watcher.on("unlink", (p) => {
560
685
  if (p.startsWith(slidesRoot)) reload();
561
686
  });
562
- server.middlewares.use("/__open-slide/title", (_req, res) => {
563
- res.setHeader("content-type", "application/json");
564
- res.end(JSON.stringify({ title: config.title ?? null }));
687
+ const invalidateFolders = () => {
688
+ const mod = server.moduleGraph.getModuleById(resolved(FOLDERS_VMOD));
689
+ if (mod) server.moduleGraph.invalidateModule(mod);
690
+ };
691
+ server.watcher.add(foldersManifestPath);
692
+ server.watcher.on("change", (p) => {
693
+ if (p === foldersManifestPath) invalidateFolders();
694
+ });
695
+ server.watcher.on("add", (p) => {
696
+ if (p === foldersManifestPath) invalidateFolders();
697
+ });
698
+ server.watcher.on("unlink", (p) => {
699
+ if (p === foldersManifestPath) invalidateFolders();
565
700
  });
566
701
  }
567
702
  };
568
703
  }
569
704
  async function loadUserConfig(userCwd) {
570
- const file = path.join(userCwd, "open-slide.json");
705
+ const file = path.join(userCwd, CONFIG_FILE);
571
706
  if (!existsSync(file)) return {};
572
- const raw = await readFile(file, "utf8");
573
- return JSON.parse(raw);
707
+ const loaded = await loadConfigFromFile({
708
+ command: "serve",
709
+ mode: "development"
710
+ }, file, userCwd, "silent");
711
+ return loaded?.config ?? {};
574
712
  }
575
713
 
576
714
  //#endregion
@@ -0,0 +1,12 @@
1
+ //#region src/config.d.ts
2
+ type OpenSlideBuildConfig = {
3
+ showSlideBrowser?: boolean;
4
+ showSlideUi?: boolean;
5
+ allowHtmlDownload?: boolean;
6
+ };
7
+ type OpenSlideConfig = {
8
+ slidesDir?: string;
9
+ port?: number;
10
+ build?: OpenSlideBuildConfig;
11
+ }; //#endregion
12
+ export { OpenSlideConfig };
@@ -1,4 +1,4 @@
1
- import { createViteConfig } from "./config-DF58h0l4.js";
1
+ import { createViteConfig } from "./config-DOcMmFJ7.js";
2
2
  import { createServer, mergeConfig } from "vite";
3
3
 
4
4
  //#region src/cli/dev.ts
package/dist/index.d.ts CHANGED
@@ -1,10 +1,10 @@
1
+ import { OpenSlideConfig } from "./config-SXL5qIl6.js";
1
2
  import { ComponentType } from "react";
2
3
 
3
4
  //#region src/app/lib/sdk.d.ts
4
5
  type Page = ComponentType;
5
6
  type SlideMeta = {
6
7
  title?: string;
7
- theme?: 'light' | 'dark';
8
8
  };
9
9
  type SlideModule = {
10
10
  default: Page[];
@@ -12,4 +12,4 @@ type SlideModule = {
12
12
  };
13
13
  declare const CANVAS_WIDTH = 1920;
14
14
  declare const CANVAS_HEIGHT = 1080; //#endregion
15
- export { CANVAS_HEIGHT, CANVAS_WIDTH, Page, SlideMeta, SlideModule };
15
+ export { CANVAS_HEIGHT, CANVAS_WIDTH, OpenSlideConfig, Page, SlideMeta, SlideModule };
@@ -1,4 +1,4 @@
1
- import { createViteConfig } from "./config-DF58h0l4.js";
1
+ import { createViteConfig } from "./config-DOcMmFJ7.js";
2
2
  import { mergeConfig, preview as preview$1 } from "vite";
3
3
 
4
4
  //#region src/cli/preview.ts
@@ -1,11 +1,6 @@
1
+ import { OpenSlideConfig } from "../config-SXL5qIl6.js";
1
2
  import { InlineConfig } from "vite";
2
3
 
3
- //#region src/vite/open-slide-plugin.d.ts
4
- type OpenSlideConfig = {
5
- title?: string;
6
- slidesDir?: string;
7
- port?: number;
8
- }; //#endregion
9
4
  //#region src/vite/config.d.ts
10
5
  type CreateViteConfigOptions = {
11
6
  userCwd: string;
@@ -15,4 +10,4 @@ type CreateViteConfigOptions = {
15
10
  declare function createViteConfig(opts: CreateViteConfigOptions): Promise<InlineConfig>;
16
11
 
17
12
  //#endregion
18
- export { OpenSlideConfig, createViteConfig };
13
+ export { createViteConfig };
@@ -1,3 +1,3 @@
1
- import { createViteConfig } from "../config-DF58h0l4.js";
1
+ import { createViteConfig } from "../config-DOcMmFJ7.js";
2
2
 
3
3
  export { createViteConfig };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-slide/core",
3
- "version": "0.0.6",
3
+ "version": "0.0.7",
4
4
  "description": "Runtime and CLI for open-slide — write slides in slides/, we handle the rest.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -41,6 +41,7 @@
41
41
  "access": "public"
42
42
  },
43
43
  "dependencies": {
44
+ "@babel/parser": "^7.29.2",
44
45
  "@fontsource-variable/geist": "^5.2.8",
45
46
  "@tailwindcss/vite": "^4.2.2",
46
47
  "@vitejs/plugin-react": "^4.3.3",
package/src/app/App.tsx CHANGED
@@ -1,4 +1,5 @@
1
1
  import { BrowserRouter, Route, Routes } from 'react-router-dom';
2
+ import config from 'virtual:open-slide/config';
2
3
  import { Home } from './routes/Home';
3
4
  import { Slide } from './routes/Slide';
4
5
 
@@ -6,9 +7,21 @@ export function App() {
6
7
  return (
7
8
  <BrowserRouter>
8
9
  <Routes>
9
- <Route path="/" element={<Home />} />
10
+ <Route path="/" element={config.build.showSlideBrowser ? <Home /> : <NotFound />} />
10
11
  <Route path="/s/:slideId" element={<Slide />} />
12
+ <Route path="*" element={<NotFound />} />
11
13
  </Routes>
12
14
  </BrowserRouter>
13
15
  );
14
16
  }
17
+
18
+ function NotFound() {
19
+ return (
20
+ <div className="grid h-screen place-items-center bg-background px-6 text-center">
21
+ <div>
22
+ <p className="font-mono text-sm tracking-widest text-muted-foreground uppercase">404</p>
23
+ <h1 className="mt-2 text-2xl font-semibold tracking-tight">Page not found</h1>
24
+ </div>
25
+ </div>
26
+ );
27
+ }
@@ -7,9 +7,10 @@ type Props = {
7
7
  index: number;
8
8
  onIndexChange: (index: number) => void;
9
9
  onExit: () => void;
10
+ allowExit?: boolean;
10
11
  };
11
12
 
12
- export function Player({ pages, index, onIndexChange, onExit }: Props) {
13
+ export function Player({ pages, index, onIndexChange, onExit, allowExit = true }: Props) {
13
14
  const rootRef = useRef<HTMLDivElement>(null);
14
15
 
15
16
  useEffect(() => {
@@ -24,12 +25,13 @@ export function Player({ pages, index, onIndexChange, onExit }: Props) {
24
25
  }, []);
25
26
 
26
27
  useEffect(() => {
28
+ if (!allowExit) return;
27
29
  const onFsChange = () => {
28
30
  if (!document.fullscreenElement) onExit();
29
31
  };
30
32
  document.addEventListener('fullscreenchange', onFsChange);
31
33
  return () => document.removeEventListener('fullscreenchange', onFsChange);
32
- }, [onExit]);
34
+ }, [onExit, allowExit]);
33
35
 
34
36
  useEffect(() => {
35
37
  const onKey = (e: KeyboardEvent) => {
@@ -45,7 +47,7 @@ export function Player({ pages, index, onIndexChange, onExit }: Props) {
45
47
  e.preventDefault();
46
48
  if (index > 0) onIndexChange(index - 1);
47
49
  } else if (e.key === 'Escape') {
48
- onExit();
50
+ if (allowExit) onExit();
49
51
  } else if (e.key === 'Home') {
50
52
  onIndexChange(0);
51
53
  } else if (e.key === 'End') {
@@ -54,7 +56,7 @@ export function Player({ pages, index, onIndexChange, onExit }: Props) {
54
56
  };
55
57
  window.addEventListener('keydown', onKey);
56
58
  return () => window.removeEventListener('keydown', onKey);
57
- }, [index, pages.length, onIndexChange, onExit]);
59
+ }, [index, pages.length, onIndexChange, onExit, allowExit]);
58
60
 
59
61
  const PageComp = pages[index];
60
62
 
@@ -18,6 +18,7 @@ const THUMB_HEIGHT = CANVAS_HEIGHT * THUMB_SCALE;
18
18
  export function ThumbnailRail({ pages, current, onSelect }: Props) {
19
19
  const activeRef = useRef<HTMLButtonElement | null>(null);
20
20
 
21
+ // biome-ignore lint/correctness/useExhaustiveDependencies: `current` triggers re-scroll on selection change
21
22
  useEffect(() => {
22
23
  activeRef.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
23
24
  }, [current]);
@@ -29,7 +30,9 @@ export function ThumbnailRail({ pages, current, onSelect }: Props) {
29
30
  const active = i === current;
30
31
  return (
31
32
  <button
33
+ // biome-ignore lint/suspicious/noArrayIndexKey: pages list is render-stable
32
34
  key={i}
35
+ type="button"
33
36
  ref={active ? activeRef : undefined}
34
37
  onClick={() => onSelect(i)}
35
38
  aria-label={`Go to page ${i + 1}`}
@@ -8,9 +8,9 @@ export function CommentWidget() {
8
8
  const count = comments.length;
9
9
 
10
10
  return (
11
- <div data-inspector-ui className="fixed right-4 bottom-4 z-40">
11
+ <div data-inspector-ui className="fixed right-4 bottom-4 z-40 flex flex-col items-end gap-2">
12
12
  {open && (
13
- <div className="mb-2 w-80 rounded-md border bg-card shadow-xl">
13
+ <div className="w-80 rounded-md border bg-card shadow-xl animate-in fade-in-0 slide-in-from-bottom-2 duration-200">
14
14
  <div className="flex items-center justify-between border-b px-3 py-2">
15
15
  <span className="text-xs font-semibold">
16
16
  {count} comment{count === 1 ? '' : 's'}
@@ -29,24 +29,38 @@ export function CommentWidget() {
29
29
  No comments yet. Toggle Inspect and click a slide element.
30
30
  </p>
31
31
  ) : (
32
- <ul className="max-h-72 overflow-auto">
33
- {comments.map((c) => (
34
- <li key={c.id} className="flex items-start gap-2 border-b px-3 py-2 last:border-0">
35
- <div className="min-w-0 flex-1">
36
- <div className="text-[10px] font-mono text-muted-foreground">line {c.line}</div>
37
- <div className="mt-0.5 text-xs break-words">{c.note}</div>
38
- </div>
39
- <button
40
- type="button"
41
- onClick={() => remove(c.id)}
42
- className="shrink-0 rounded p-1 text-muted-foreground hover:bg-muted hover:text-red-600"
43
- title="Delete"
32
+ <>
33
+ <ul className="max-h-72 overflow-auto">
34
+ {comments.map((c) => (
35
+ <li
36
+ key={c.id}
37
+ className="flex items-start gap-2 border-b px-3 py-2 last:border-0"
44
38
  >
45
- <Trash2 className="size-3.5" />
46
- </button>
47
- </li>
48
- ))}
49
- </ul>
39
+ <div className="min-w-0 flex-1">
40
+ <div className="text-[10px] font-mono text-muted-foreground">
41
+ line {c.line}
42
+ </div>
43
+ <div className="mt-0.5 text-xs break-words">{c.note}</div>
44
+ </div>
45
+ <button
46
+ type="button"
47
+ onClick={() => remove(c.id)}
48
+ className="shrink-0 rounded p-1 text-muted-foreground hover:bg-muted hover:text-red-600"
49
+ title="Delete"
50
+ >
51
+ <Trash2 className="size-3.5" />
52
+ </button>
53
+ </li>
54
+ ))}
55
+ </ul>
56
+ <div className="border-t px-3 py-2 text-[11px] text-muted-foreground">
57
+ Run{' '}
58
+ <code className="rounded bg-muted px-1 py-0.5 font-mono text-foreground">
59
+ /apply-comments
60
+ </code>{' '}
61
+ in your agent to apply these.
62
+ </div>
63
+ </>
50
64
  )}
51
65
  </div>
52
66
  )}
@@ -91,6 +91,7 @@ export function FolderItem({
91
91
  };
92
92
 
93
93
  return (
94
+ // biome-ignore lint/a11y/noStaticElementInteractions: drag-and-drop target wraps interactive children
94
95
  <div
95
96
  className={cn(
96
97
  'group relative flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors',
@@ -125,7 +126,6 @@ export function FolderItem({
125
126
 
126
127
  {renaming && row.kind === 'folder' ? (
127
128
  <input
128
- autoFocus
129
129
  value={draftName}
130
130
  onChange={(e) => setDraftName(e.target.value)}
131
131
  onBlur={commitRename}
@@ -22,7 +22,7 @@ export function Sidebar({
22
22
  countFor: (folderId: string | null) => number;
23
23
  selectedId: string;
24
24
  onSelect: (id: string) => void;
25
- onCreate: (name: string, icon: FolderIcon) => Promise<Folder> | void;
25
+ onCreate: (name: string, icon: FolderIcon) => Promise<Folder> | undefined;
26
26
  onRename: (id: string, name: string) => void;
27
27
  onChangeIcon: (id: string, icon: FolderIcon) => void;
28
28
  onDelete: (id: string) => void;
@@ -86,7 +86,6 @@ export function Sidebar({
86
86
  {creating ? (
87
87
  <div className="mt-1 flex items-center gap-2 rounded-md border border-dashed bg-background px-2 py-1.5">
88
88
  <input
89
- autoFocus
90
89
  value={newName}
91
90
  onChange={(e) => setNewName(e.target.value)}
92
91
  onBlur={commitCreate}
Binary file
@@ -2,6 +2,7 @@
2
2
  <html lang="en">
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
+ <link rel="icon" href="./favicon.ico" />
5
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
7
  <title>open-slide</title>
7
8
  </head>
@@ -1,15 +1,26 @@
1
1
  import { useCallback, useEffect, useState } from 'react';
2
+ import buildManifest from 'virtual:open-slide/folders';
2
3
  import type { Folder, FolderIcon, FoldersManifest } from './sdk';
3
4
 
4
5
  const EMPTY: FoldersManifest = { folders: [], assignments: {} };
5
6
 
6
7
  async function getManifest(): Promise<FoldersManifest> {
7
- const res = await fetch('/__folders');
8
- if (!res.ok) throw new Error(`GET /__folders ${res.status}`);
9
- const raw = (await res.json()) as Partial<FoldersManifest>;
8
+ // In dev the manifest is mutable: read live from the plugin endpoint so
9
+ // edits made in the sidebar reflect immediately. In a static build there
10
+ // is no server, so fall back to the bundled snapshot from the virtual
11
+ // module (populated at build time from slides/.folders.json).
12
+ if (import.meta.env.DEV) {
13
+ const res = await fetch('/__folders');
14
+ if (!res.ok) throw new Error(`GET /__folders ${res.status}`);
15
+ const raw = (await res.json()) as Partial<FoldersManifest>;
16
+ return {
17
+ folders: raw.folders ?? [],
18
+ assignments: raw.assignments ?? {},
19
+ };
20
+ }
10
21
  return {
11
- folders: raw.folders ?? [],
12
- assignments: raw.assignments ?? {},
22
+ folders: buildManifest.folders ?? [],
23
+ assignments: buildManifest.assignments ?? {},
13
24
  };
14
25
  }
15
26
 
@@ -0,0 +1,13 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { CANVAS_HEIGHT, CANVAS_WIDTH } from './sdk.ts';
3
+
4
+ describe('canvas constants', () => {
5
+ it('targets a 1920x1080 canvas', () => {
6
+ expect(CANVAS_WIDTH).toBe(1920);
7
+ expect(CANVAS_HEIGHT).toBe(1080);
8
+ });
9
+
10
+ it('preserves a 16:9 aspect ratio', () => {
11
+ expect(CANVAS_WIDTH / CANVAS_HEIGHT).toBeCloseTo(16 / 9);
12
+ });
13
+ });
@@ -4,7 +4,6 @@ export type Page = ComponentType;
4
4
 
5
5
  export type SlideMeta = {
6
6
  title?: string;
7
- theme?: 'light' | 'dark';
8
7
  };
9
8
 
10
9
  export type SlideModule = {
@@ -0,0 +1,25 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { cn } from './utils.ts';
3
+
4
+ describe('cn', () => {
5
+ it('joins multiple class names', () => {
6
+ expect(cn('a', 'b', 'c')).toBe('a b c');
7
+ });
8
+
9
+ it('drops falsy values', () => {
10
+ expect(cn('a', false, undefined, null, '', 'b')).toBe('a b');
11
+ });
12
+
13
+ it('flattens arrays and conditional objects from clsx', () => {
14
+ expect(cn(['a', 'b'], { c: true, d: false })).toBe('a b c');
15
+ });
16
+
17
+ it('lets later tailwind classes override earlier ones', () => {
18
+ expect(cn('p-2', 'p-4')).toBe('p-4');
19
+ expect(cn('text-red-500', 'text-blue-500')).toBe('text-blue-500');
20
+ });
21
+
22
+ it('preserves classes that target different properties', () => {
23
+ expect(cn('p-2', 'm-4')).toBe('p-2 m-4');
24
+ });
25
+ });
package/src/app/main.tsx CHANGED
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
3
3
  import { App } from './App';
4
4
  import './styles.css';
5
5
 
6
+ // biome-ignore lint/style/noNonNullAssertion: #root is guaranteed by index.html
6
7
  createRoot(document.getElementById('root')!).render(
7
8
  <StrictMode>
8
9
  <App />
@@ -49,7 +49,8 @@ export function Home() {
49
49
  for (const id of slideIds) {
50
50
  const folderId = manifest.assignments[id];
51
51
  if (folderId && known.has(folderId)) {
52
- (byFolder[folderId] ??= []).push(id);
52
+ byFolder[folderId] ??= [];
53
+ byFolder[folderId].push(id);
53
54
  } else {
54
55
  draft.push(id);
55
56
  }
@@ -245,6 +246,7 @@ function SlideCard({
245
246
 
246
247
  return (
247
248
  <>
249
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: drag source wraps an interactive Link */}
248
250
  <div
249
251
  draggable
250
252
  onDragStart={(e) => {
@@ -1,10 +1,17 @@
1
- import { ChevronLeft, Download, Loader2, Pencil, Play } from 'lucide-react';
1
+ import { ChevronLeft, Download, FileCode2, Loader2, Pencil, Play } from 'lucide-react';
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
3
  import { Link, useParams, useSearchParams } from 'react-router-dom';
4
+ import config from 'virtual:open-slide/config';
4
5
  import { CommentWidget } from '@/components/inspector/CommentWidget';
5
6
  import { InspectOverlay } from '@/components/inspector/InspectOverlay';
6
7
  import { InspectorProvider, InspectToggleButton } from '@/components/inspector/InspectorProvider';
7
- import { Button } from '@/components/ui/button';
8
+ import { Button, buttonVariants } from '@/components/ui/button';
9
+ import {
10
+ DropdownMenu,
11
+ DropdownMenuContent,
12
+ DropdownMenuItem,
13
+ DropdownMenuTrigger,
14
+ } from '@/components/ui/dropdown-menu';
8
15
  import { Separator } from '@/components/ui/separator';
9
16
  import { useFolders } from '@/lib/folders';
10
17
  import { cn } from '@/lib/utils';
@@ -16,6 +23,8 @@ import { exportSlideAsHtml } from '../lib/export-html';
16
23
  import type { SlideModule } from '../lib/sdk';
17
24
  import { loadSlide } from '../lib/slides';
18
25
 
26
+ const { showSlideUi, showSlideBrowser, allowHtmlDownload } = config.build;
27
+
19
28
  export function Slide() {
20
29
  const { slideId = '' } = useParams();
21
30
  const [searchParams, setSearchParams] = useSearchParams();
@@ -82,9 +91,11 @@ export function Slide() {
82
91
  if (error) {
83
92
  return (
84
93
  <div className="mx-auto max-w-3xl px-8 py-16 text-muted-foreground">
85
- <Link to="/" className="text-sm font-medium text-primary hover:underline">
86
- Home
87
- </Link>
94
+ {showSlideBrowser && (
95
+ <Link to="/" className="text-sm font-medium text-primary hover:underline">
96
+ ← Home
97
+ </Link>
98
+ )}
88
99
  <h2 className="mt-4 text-xl font-semibold text-foreground">Failed to load slide</h2>
89
100
  <pre className="mt-4 overflow-auto rounded-md border bg-card p-4 text-xs whitespace-pre-wrap shadow-sm">
90
101
  {error}
@@ -104,9 +115,11 @@ export function Slide() {
104
115
  if (pageCount === 0) {
105
116
  return (
106
117
  <div className="mx-auto max-w-3xl px-8 py-16 text-muted-foreground">
107
- <Link to="/" className="text-sm font-medium text-primary hover:underline">
108
- Home
109
- </Link>
118
+ {showSlideBrowser && (
119
+ <Link to="/" className="text-sm font-medium text-primary hover:underline">
120
+ ← Home
121
+ </Link>
122
+ )}
110
123
  <h2 className="mt-4 text-xl font-semibold text-foreground">Empty slide</h2>
111
124
  <p className="mt-2 text-sm">
112
125
  <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
@@ -120,6 +133,18 @@ export function Slide() {
120
133
  );
121
134
  }
122
135
 
136
+ if (!showSlideUi) {
137
+ return (
138
+ <Player
139
+ pages={pages}
140
+ index={index}
141
+ onIndexChange={goTo}
142
+ onExit={() => {}}
143
+ allowExit={false}
144
+ />
145
+ );
146
+ }
147
+
123
148
  if (playing) {
124
149
  return (
125
150
  <Player pages={pages} index={index} onIndexChange={goTo} onExit={() => setPlaying(false)} />
@@ -132,48 +157,73 @@ export function Slide() {
132
157
  return (
133
158
  <InspectorProvider slideId={slideId}>
134
159
  <div className="flex h-screen flex-col overflow-hidden bg-background">
135
- <header className="flex shrink-0 items-center gap-2 border-b bg-card px-3 py-2 md:gap-4 md:px-5 md:py-3">
136
- <Button asChild variant="ghost" size="sm" className="px-2 md:px-3">
137
- <Link to="/">
138
- <ChevronLeft className="size-4" />
139
- <span className="hidden md:inline">Home</span>
140
- </Link>
141
- </Button>
142
- <Separator orientation="vertical" className="hidden h-5 md:block" />
143
- <InlineTitleEditor title={title} onSubmit={(next) => renameSlide(slideId, next)} />
144
- <Button
145
- variant="ghost"
146
- size="sm"
147
- className="px-2 md:px-3"
148
- disabled={exporting}
149
- onClick={async () => {
150
- if (!slide || exporting) return;
151
- setExporting(true);
152
- try {
153
- await exportSlideAsHtml(slide, slideId);
154
- } catch (err) {
155
- console.error('[open-slide] export failed', err);
156
- } finally {
157
- setExporting(false);
158
- }
159
- }}
160
- title="Download as HTML"
161
- >
162
- {exporting ? (
163
- <Loader2 className="size-4 animate-spin" />
164
- ) : (
165
- <Download className="size-4" />
160
+ <header className="relative flex shrink-0 items-center justify-between border-b bg-card px-3 py-2 md:px-5 md:py-3">
161
+ <div className="flex items-center gap-2 md:gap-3">
162
+ {showSlideBrowser && (
163
+ <>
164
+ <Button asChild variant="ghost" size="sm" className="px-2 md:px-3">
165
+ <Link to="/">
166
+ <ChevronLeft className="size-4" />
167
+ <span className="hidden md:inline">Home</span>
168
+ </Link>
169
+ </Button>
170
+ <Separator orientation="vertical" className="hidden h-5 md:block" />
171
+ </>
166
172
  )}
167
- <span className="hidden md:inline">Download</span>
168
- </Button>
169
- <InspectToggleButton />
170
- <Button size="sm" onClick={() => setPlaying(true)} className="px-2 md:px-3">
171
- <Play className="size-4" />
172
- <span className="hidden md:inline">Play</span>
173
- <kbd className="ml-1 hidden rounded bg-primary-foreground/20 px-1 text-[10px] md:inline">
174
- F
175
- </kbd>
176
- </Button>
173
+ </div>
174
+
175
+ <div className="pointer-events-none absolute inset-x-0 top-1/2 flex -translate-y-1/2 justify-center px-2">
176
+ <div className="pointer-events-auto min-w-0 max-w-[min(32rem,calc(100vw-20rem))]">
177
+ <InlineTitleEditor title={title} onSubmit={(next) => renameSlide(slideId, next)} />
178
+ </div>
179
+ </div>
180
+
181
+ <div className="flex items-center gap-1.5">
182
+ {allowHtmlDownload && (
183
+ <DropdownMenu>
184
+ <DropdownMenuTrigger
185
+ type="button"
186
+ disabled={exporting}
187
+ aria-label="Download"
188
+ title="Download"
189
+ className={cn(buttonVariants({ variant: 'outline', size: 'icon-sm' }))}
190
+ >
191
+ {exporting ? (
192
+ <Loader2 className="size-4 animate-spin" />
193
+ ) : (
194
+ <Download className="size-4" />
195
+ )}
196
+ </DropdownMenuTrigger>
197
+ <DropdownMenuContent align="end" className="min-w-[180px]">
198
+ <DropdownMenuItem
199
+ disabled={exporting}
200
+ onSelect={async () => {
201
+ if (!slide || exporting) return;
202
+ setExporting(true);
203
+ try {
204
+ await exportSlideAsHtml(slide, slideId);
205
+ } catch (err) {
206
+ console.error('[open-slide] export failed', err);
207
+ } finally {
208
+ setExporting(false);
209
+ }
210
+ }}
211
+ >
212
+ <FileCode2 />
213
+ Download HTML
214
+ </DropdownMenuItem>
215
+ </DropdownMenuContent>
216
+ </DropdownMenu>
217
+ )}
218
+ <InspectToggleButton />
219
+ <Button size="sm" onClick={() => setPlaying(true)} className="px-2 md:px-3">
220
+ <Play className="size-4" />
221
+ <span className="hidden md:inline">Play</span>
222
+ <kbd className="ml-1 hidden rounded bg-primary-foreground/20 px-1 text-[10px] md:inline">
223
+ F
224
+ </kbd>
225
+ </Button>
226
+ </div>
177
227
  </header>
178
228
 
179
229
  <div className="flex min-h-0 flex-1">
@@ -6,9 +6,19 @@ declare module 'virtual:open-slide/slides' {
6
6
 
7
7
  declare module 'virtual:open-slide/config' {
8
8
  const config: {
9
- title?: string;
10
9
  slidesDir?: string;
11
10
  port?: number;
11
+ build: {
12
+ showSlideBrowser: boolean;
13
+ showSlideUi: boolean;
14
+ allowHtmlDownload: boolean;
15
+ };
12
16
  };
13
17
  export default config;
14
18
  }
19
+
20
+ declare module 'virtual:open-slide/folders' {
21
+ import type { FoldersManifest } from './lib/sdk';
22
+ const manifest: FoldersManifest;
23
+ export default manifest;
24
+ }