promptslide 0.3.17 → 0.3.19

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/package.json CHANGED
@@ -1,16 +1,20 @@
1
1
  {
2
2
  "name": "promptslide",
3
- "version": "0.3.17",
3
+ "version": "0.3.19",
4
4
  "description": "CLI and slide engine for PromptSlide presentations",
5
- "type": "module",
6
- "main": "./dist/index.js",
7
- "types": "./dist/index.d.ts",
8
- "exports": {
9
- ".": {
10
- "types": "./dist/index.d.ts",
11
- "import": "./dist/index.js"
12
- },
13
- "./src/commands/*": "./src/commands/*"
5
+ "keywords": [
6
+ "cli",
7
+ "presentations",
8
+ "promptslide",
9
+ "react",
10
+ "slides",
11
+ "vite"
12
+ ],
13
+ "license": "MIT",
14
+ "author": "prompticeu",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/prompticeu/promptslide.git"
14
18
  },
15
19
  "bin": {
16
20
  "promptslide": "src/index.mjs"
@@ -22,47 +26,43 @@
22
26
  "CHANGELOG.md",
23
27
  "README.md"
24
28
  ],
29
+ "type": "module",
30
+ "main": "./dist/index.js",
31
+ "types": "./dist/index.d.ts",
32
+ "exports": {
33
+ ".": {
34
+ "types": "./dist/index.d.ts",
35
+ "import": "./dist/index.js"
36
+ },
37
+ "./src/commands/*": "./src/commands/*"
38
+ },
25
39
  "scripts": {
26
40
  "build": "tsup",
27
41
  "test": "node --test test/*.test.mjs",
28
42
  "prepublishOnly": "npm run build"
29
43
  },
30
- "license": "MIT",
31
- "repository": {
32
- "type": "git",
33
- "url": "git+https://github.com/prompticeu/promptslide.git"
34
- },
35
- "author": "prompticeu",
36
- "keywords": [
37
- "slides",
38
- "presentations",
39
- "cli",
40
- "react",
41
- "promptslide",
42
- "vite"
43
- ],
44
44
  "dependencies": {
45
45
  "@tailwindcss/postcss": "^4.1.13",
46
46
  "@vitejs/plugin-react": "^4.5.2",
47
47
  "clsx": "^2.1.1",
48
48
  "lucide-react": "^0.552.0",
49
+ "playwright": "^1.50.0",
49
50
  "postcss": "^8.5.6",
50
51
  "tailwind-merge": "^3.3.1",
51
52
  "tailwindcss": "^4.1.13",
52
- "playwright": "^1.50.0",
53
+ "typescript": "^5.9.3",
53
54
  "vite": "^6.3.5"
54
55
  },
56
+ "devDependencies": {
57
+ "@types/react": "^19.2.14",
58
+ "@types/react-dom": "^19.2.3",
59
+ "tsup": "^8.5.1"
60
+ },
55
61
  "peerDependencies": {
56
62
  "framer-motion": "^12.0.0",
57
63
  "react": "^19.0.0",
58
64
  "react-dom": "^19.0.0"
59
65
  },
60
- "devDependencies": {
61
- "@types/react": "^19.2.14",
62
- "@types/react-dom": "^19.2.3",
63
- "tsup": "^8.5.1",
64
- "typescript": "^5.9.3"
65
- },
66
66
  "engines": {
67
67
  "node": ">=18.0.0"
68
68
  }
@@ -4,6 +4,7 @@ import { resolve, join } from "node:path"
4
4
  import { build as viteBuild } from "vite"
5
5
 
6
6
  import { bold, green, dim } from "../utils/ansi.mjs"
7
+ import { readLoopConfig, DEFAULT_LOOP_CONFIG } from "../utils/loop-config.mjs"
7
8
  import { ensureTsConfig } from "../utils/tsconfig.mjs"
8
9
  import { createViteConfig } from "../vite/config.mjs"
9
10
 
@@ -36,9 +37,11 @@ import { StrictMode, createElement } from "react"
36
37
  import { createRoot } from "react-dom/client"
37
38
  import "${globals}"
38
39
  import App from "${app}"
40
+ import { LoopConfigProvider } from "promptslide"
41
+ const loopConfig = ${JSON.stringify(readLoopConfig(cwd) ?? DEFAULT_LOOP_CONFIG)}
39
42
 
40
43
  createRoot(document.getElementById("root")).render(
41
- createElement(StrictMode, null, createElement(App))
44
+ createElement(StrictMode, null, createElement(LoopConfigProvider, { config: loopConfig }, createElement(App)))
42
45
  )
43
46
  `
44
47
  }
@@ -179,7 +179,9 @@ export async function clone(args) {
179
179
  const registrySlideSlugs = collectRegistrySlideSlugs(resolved.items)
180
180
  const { available, missing } = partitionDeckSlides(item.meta.slides, registrySlideSlugs)
181
181
  if (missing.length > 0) {
182
- console.log(` ${yellow("!")} Skipped ${missing.length} slide(s) listed in the deck but not included in this version:`)
182
+ console.log(
183
+ ` ${yellow("!")} Skipped ${missing.length} slide(s) listed in the deck but not included in this version:`
184
+ )
183
185
  console.log(` ${dim(missing.map(s => s.slug).join(", "))}`)
184
186
  }
185
187
  const slides = available.map(s => ({
@@ -191,7 +193,8 @@ export async function clone(args) {
191
193
  }))
192
194
  replaceDeckConfig(targetDir, slides, {
193
195
  transition: item.meta.transition,
194
- directionalTransition: item.meta.directionalTransition
196
+ directionalTransition: item.meta.directionalTransition,
197
+ loopConfig: item.meta.loopConfig
195
198
  })
196
199
  console.log(
197
200
  ` ${green("✓")} Generated ${cyan("deck-config.ts")} ${dim(`(${slides.length} slides)`)}`
@@ -6,10 +6,14 @@ import { fileURLToPath } from "node:url"
6
6
  import { bold, green, cyan, red, dim, yellow } from "../utils/ansi.mjs"
7
7
  import { requireAuth } from "../utils/auth.mjs"
8
8
  import { hexToOklch, isValidHex } from "../utils/colors.mjs"
9
- import { prompt, confirm, closePrompts } from "../utils/prompts.mjs"
10
- import { fetchRegistryItem, resolveRegistryDependencies, writeLockfile } from "../utils/registry.mjs"
11
9
  import { toPascalCase, replaceDeckConfig } from "../utils/deck-config.mjs"
12
10
  import { collectRegistrySlideSlugs, partitionDeckSlides } from "../utils/deck-sync.mjs"
11
+ import { prompt, confirm, closePrompts } from "../utils/prompts.mjs"
12
+ import {
13
+ fetchRegistryItem,
14
+ resolveRegistryDependencies,
15
+ writeLockfile
16
+ } from "../utils/registry.mjs"
13
17
  import { ensureTsConfig } from "../utils/tsconfig.mjs"
14
18
 
15
19
  const __filename = fileURLToPath(import.meta.url)
@@ -43,13 +47,17 @@ export async function create(args) {
43
47
  const useDefaults = args.includes("--yes") || args.includes("-y")
44
48
  const fromIdx = args.findIndex(a => a === "--from")
45
49
  const fromSlug = fromIdx >= 0 ? args[fromIdx + 1] : null
46
- const filteredArgs = args.filter((a, i) => a !== "--yes" && a !== "-y" && a !== "--from" && !(fromIdx >= 0 && i === fromIdx + 1))
50
+ const filteredArgs = args.filter(
51
+ (a, i) => a !== "--yes" && a !== "-y" && a !== "--from" && !(fromIdx >= 0 && i === fromIdx + 1)
52
+ )
47
53
 
48
54
  // 1. Parse directory name from args or prompt
49
55
  let dirName = filteredArgs[0]
50
56
 
51
57
  if (dirName === "--help" || dirName === "-h") {
52
- console.log(` ${bold("Usage:")} promptslide create ${dim("[project-directory]")} ${dim("[options]")}`)
58
+ console.log(
59
+ ` ${bold("Usage:")} promptslide create ${dim("[project-directory]")} ${dim("[options]")}`
60
+ )
53
61
  console.log()
54
62
  console.log(` Scaffolds a new PromptSlide slide deck project.`)
55
63
  console.log()
@@ -80,7 +88,9 @@ export async function create(args) {
80
88
  }
81
89
 
82
90
  if (fromItem.type !== "deck") {
83
- console.error(` ${red("Error:")} "${fromSlug}" is a ${fromItem.type}, not a deck. Use --from with a published deck.`)
91
+ console.error(
92
+ ` ${red("Error:")} "${fromSlug}" is a ${fromItem.type}, not a deck. Use --from with a published deck.`
93
+ )
84
94
  closePrompts()
85
95
  process.exit(1)
86
96
  }
@@ -93,8 +103,12 @@ export async function create(args) {
93
103
  const localParts = CLI_VERSION.match(/^(\d+)\.(\d+)/)
94
104
  if (pubParts && localParts && pubParts[2] !== localParts[2]) {
95
105
  console.log()
96
- console.log(` ${yellow("⚠")} This deck was published with promptslide ${bold(`v${fromItem.promptslideVersion}`)}`)
97
- console.log(` You have ${bold(`v${CLI_VERSION}`)} installed — some slides may need updating.`)
106
+ console.log(
107
+ ` ${yellow("⚠")} This deck was published with promptslide ${bold(`v${fromItem.promptslideVersion}`)}`
108
+ )
109
+ console.log(
110
+ ` You have ${bold(`v${CLI_VERSION}`)} installed — some slides may need updating.`
111
+ )
98
112
  }
99
113
  }
100
114
 
@@ -158,7 +172,11 @@ export async function create(args) {
158
172
  const replacements = [
159
173
  {
160
174
  path: join(targetDir, "package.json"),
161
- values: { "{{PROJECT_SLUG}}": dirName, "{{PROJECT_NAME}}": projectName, "{{PROMPTSLIDE_VERSION}}": `^${CLI_VERSION}` }
175
+ values: {
176
+ "{{PROJECT_SLUG}}": dirName,
177
+ "{{PROJECT_NAME}}": projectName,
178
+ "{{PROMPTSLIDE_VERSION}}": `^${CLI_VERSION}`
179
+ }
162
180
  },
163
181
  {
164
182
  path: join(targetDir, "src", "theme.ts"),
@@ -212,7 +230,10 @@ export async function create(args) {
212
230
 
213
231
  const dataUriPrefix = file.content.match(/^data:[^;]+;base64,/)
214
232
  if (dataUriPrefix) {
215
- writeFileSync(targetPath, Buffer.from(file.content.slice(dataUriPrefix[0].length), "base64"))
233
+ writeFileSync(
234
+ targetPath,
235
+ Buffer.from(file.content.slice(dataUriPrefix[0].length), "base64")
236
+ )
216
237
  } else {
217
238
  writeFileSync(targetPath, file.content, "utf-8")
218
239
  }
@@ -225,20 +246,26 @@ export async function create(args) {
225
246
  const registrySlideSlugs = collectRegistrySlideSlugs(resolved.items)
226
247
  const { available, missing } = partitionDeckSlides(fromItem.meta.slides, registrySlideSlugs)
227
248
  if (missing.length > 0) {
228
- console.log(` ${yellow("⚠")} Skipped ${missing.length} slide(s) listed in the deck but not included in this version:`)
249
+ console.log(
250
+ ` ${yellow("⚠")} Skipped ${missing.length} slide(s) listed in the deck but not included in this version:`
251
+ )
229
252
  console.log(` ${dim(missing.map(s => s.slug).join(", "))}`)
230
253
  }
231
254
  const slides = available.map(s => ({
232
255
  componentName: s.componentName || toPascalCase(s.slug),
233
256
  importPath: `@/slides/${s.slug}`,
257
+ id: s.id,
234
258
  steps: s.steps,
235
259
  section: s.section
236
260
  }))
237
261
  replaceDeckConfig(targetDir, slides, {
238
262
  transition: fromItem.meta.transition,
239
- directionalTransition: fromItem.meta.directionalTransition
263
+ directionalTransition: fromItem.meta.directionalTransition,
264
+ loopConfig: fromItem.meta.loopConfig
240
265
  })
241
- console.log(` ${green("✓")} Generated ${cyan("deck-config.ts")} ${dim(`(${slides.length} slides)`)}`)
266
+ console.log(
267
+ ` ${green("✓")} Generated ${cyan("deck-config.ts")} ${dim(`(${slides.length} slides)`)}`
268
+ )
242
269
  }
243
270
 
244
271
  // Add npm dependencies from the deck to package.json
@@ -255,7 +282,9 @@ export async function create(args) {
255
282
  }
256
283
 
257
284
  // New deck identity — deckSlug stays as dirName (set in initial lockfile scaffold)
258
- console.log(` ${dim("Deck slug set to")} ${bold(dirName)} ${dim("(publish will create a new deck)")}`)
285
+ console.log(
286
+ ` ${dim("Deck slug set to")} ${bold(dirName)} ${dim("(publish will create a new deck)")}`
287
+ )
259
288
 
260
289
  console.log()
261
290
  }
@@ -265,7 +294,9 @@ export async function create(args) {
265
294
 
266
295
  // 9. Install PromptSlide agent skill (defaults to yes; skipped with --yes since skills CLI is interactive)
267
296
  if (useDefaults) {
268
- console.log(` ${dim("Tip: Run")} npx skills add prompticeu/promptslide ${dim("to install the agent skill")}`)
297
+ console.log(
298
+ ` ${dim("Tip: Run")} npx skills add prompticeu/promptslide ${dim("to install the agent skill")}`
299
+ )
269
300
  } else {
270
301
  const installSkill = await confirm("Install PromptSlide agent skill?")
271
302
 
@@ -5,6 +5,7 @@ import { join, dirname, resolve, sep } from "node:path"
5
5
  import { bold, green, cyan, red, yellow, dim } from "../utils/ansi.mjs"
6
6
  import { requireAuth } from "../utils/auth.mjs"
7
7
  import { toPascalCase, addSlideToDeckConfig, replaceDeckConfig } from "../utils/deck-config.mjs"
8
+ import { collectRegistrySlideSlugs, partitionDeckSlides } from "../utils/deck-sync.mjs"
8
9
  import { confirm, closePrompts } from "../utils/prompts.mjs"
9
10
  import {
10
11
  fetchRegistryItem,
@@ -17,7 +18,6 @@ import {
17
18
  hashFile,
18
19
  isFileDirty
19
20
  } from "../utils/registry.mjs"
20
- import { collectRegistrySlideSlugs, partitionDeckSlides } from "../utils/deck-sync.mjs"
21
21
 
22
22
  export async function pull(args) {
23
23
  const cwd = process.cwd()
@@ -184,7 +184,9 @@ export async function pull(args) {
184
184
  const registrySlideSlugs = collectRegistrySlideSlugs(resolved.items)
185
185
  const { available, missing } = partitionDeckSlides(deckItem.meta.slides, registrySlideSlugs)
186
186
  if (missing.length > 0) {
187
- console.log(` ${yellow("!")} Skipped ${missing.length} slide(s) listed in the deck but not included in this version:`)
187
+ console.log(
188
+ ` ${yellow("!")} Skipped ${missing.length} slide(s) listed in the deck but not included in this version:`
189
+ )
188
190
  console.log(` ${dim(missing.map(s => s.slug).join(", "))}`)
189
191
  }
190
192
  const shouldReplace = await confirm(" Replace deck-config.ts with pulled deck config?", true)
@@ -198,7 +200,8 @@ export async function pull(args) {
198
200
  }))
199
201
  replaceDeckConfig(cwd, slides, {
200
202
  transition: deckItem.meta.transition,
201
- directionalTransition: deckItem.meta.directionalTransition
203
+ directionalTransition: deckItem.meta.directionalTransition,
204
+ loopConfig: deckItem.meta.loopConfig
202
205
  })
203
206
  console.log(
204
207
  ` ${green("+")} Replaced ${cyan("deck-config.ts")} ${dim(`(${slides.length} slides)`)}`
@@ -11,7 +11,7 @@ export async function studio(args) {
11
11
  const port = portArg ? parseInt(portArg.split("=")[1], 10) : 5173
12
12
  const hasHost = args.includes("--host") || args.some(a => a.startsWith("--host="))
13
13
  const hostArg = args.find(a => a.startsWith("--host="))
14
- const host = hasHost ? (hostArg ? hostArg.split("=")[1] || "0.0.0.0" : "0.0.0.0") : undefined
14
+ const host = hasHost ? (hostArg ? hostArg.split("=")[1] || "0.0.0.0" : "0.0.0.0") : "127.0.0.1"
15
15
 
16
16
  ensureTsConfig(cwd)
17
17
 
@@ -19,13 +19,14 @@ export async function studio(args) {
19
19
  console.log(` ${bold("promptslide")} ${dim("studio")}`)
20
20
  console.log()
21
21
 
22
- const config = createViteConfig({ cwd, mode: "development" })
22
+ const config = createViteConfig({ cwd, mode: "development", editor: !hasHost })
23
23
  const server = await createServer({
24
24
  ...config,
25
25
  server: {
26
26
  port,
27
+ host,
27
28
  strictPort: false,
28
- ...(host && { host, allowedHosts: true })
29
+ ...(hasHost && { allowedHosts: true })
29
30
  }
30
31
  })
31
32
 
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react"
4
4
  import type { Annotation } from "./types"
5
5
 
6
6
  interface AnnotationPanelProps {
7
+ embedded?: boolean
7
8
  annotations: Annotation[]
8
9
  unlinkedAnnotations?: Annotation[]
9
10
  selectedId: string | null
@@ -15,6 +16,7 @@ interface AnnotationPanelProps {
15
16
  }
16
17
 
17
18
  export function AnnotationPanel({
19
+ embedded = false,
18
20
  annotations,
19
21
  unlinkedAnnotations = [],
20
22
  selectedId,
@@ -31,18 +33,22 @@ export function AnnotationPanel({
31
33
  annotations.findIndex(a => a.id === annotation.id) + 1
32
34
 
33
35
  return (
34
- <div className="flex h-full w-80 max-w-[90vw] flex-shrink-0 flex-col border-l border-neutral-800 bg-neutral-950 text-white">
35
- <div className="flex items-center justify-between border-b border-neutral-800 px-3 py-2">
36
- <button
37
- onClick={onClose}
38
- className="rounded p-2 text-neutral-400 hover:bg-neutral-800 hover:text-white"
39
- aria-label="Hide comments"
40
- title="Hide comments (C)"
41
- >
42
- <PanelRightClose className="h-4 w-4" />
43
- </button>
44
- <span className="text-right text-sm font-semibold">Comments ({total})</span>
45
- </div>
36
+ <div
37
+ className={`flex h-full max-w-[90vw] flex-shrink-0 flex-col bg-neutral-950 text-white ${embedded ? "w-full" : "w-80 border-l border-neutral-800"}`}
38
+ >
39
+ {!embedded && (
40
+ <div className="flex items-center justify-between border-b border-neutral-800 px-3 py-2">
41
+ <button
42
+ onClick={onClose}
43
+ className="rounded p-2 text-neutral-400 hover:bg-neutral-800 hover:text-white"
44
+ aria-label="Hide comments"
45
+ title="Hide comments (C)"
46
+ >
47
+ <PanelRightClose className="h-4 w-4" />
48
+ </button>
49
+ <span className="text-right text-sm font-semibold">Comments ({total})</span>
50
+ </div>
51
+ )}
46
52
 
47
53
  <div className="min-h-0 flex-1 overflow-y-auto p-3">
48
54
  {annotations.length === 0 && unlinkedAnnotations.length === 0 && (
package/src/core/index.ts CHANGED
@@ -3,6 +3,8 @@ export type {
3
3
  SlideProps,
4
4
  SlideComponent,
5
5
  SlideConfig,
6
+ LoopConfig,
7
+ LoopSlideConfig,
6
8
  NavigationDirection
7
9
  } from "./types"
8
10
 
@@ -51,10 +53,7 @@ export { Morph, MorphGroup, MorphItem, MorphText } from "./morph"
51
53
 
52
54
  // Navigation Hook
53
55
  export { useSlideNavigation } from "./use-slide-navigation"
54
- export type {
55
- UseSlideNavigationOptions,
56
- UseSlideNavigationReturn
57
- } from "./use-slide-navigation"
56
+ export type { UseSlideNavigationOptions, UseSlideNavigationReturn } from "./use-slide-navigation"
58
57
 
59
58
  // Theme
60
59
  export { SlideThemeProvider, useTheme } from "./theme-context"
@@ -75,7 +74,14 @@ export { SlideErrorBoundary } from "./slide-error-boundary"
75
74
 
76
75
  // Annotations
77
76
  export { AnnotationOverlay, useAnnotations, createHttpAdapter } from "./annotations"
78
- export type { Annotation, AnnotationsFile, AnnotationStorageAdapter, AnnotationTarget } from "./annotations"
77
+ export type {
78
+ Annotation,
79
+ AnnotationsFile,
80
+ AnnotationStorageAdapter,
81
+ AnnotationTarget
82
+ } from "./annotations"
79
83
 
80
84
  // Utils
81
85
  export { cn } from "./utils"
86
+
87
+ export { LoopConfigProvider } from "./loop/context"
@@ -0,0 +1,155 @@
1
+ import type { ReactNode } from "react"
2
+
3
+ import { X } from "lucide-react"
4
+ import { useId } from "react"
5
+
6
+ import { cn } from "./utils"
7
+
8
+ export type InspectorMode = "slide" | "editor" | "comments"
9
+
10
+ /** Core-owned docking and navigation; development tools portal into the editor slot. */
11
+ export function Inspector({
12
+ mode,
13
+ visible,
14
+ editorAvailable,
15
+ width,
16
+ overlay,
17
+ onSelect,
18
+ onClose,
19
+ slideSettings,
20
+ comments
21
+ }: {
22
+ mode: InspectorMode | null
23
+ visible: boolean
24
+ editorAvailable: boolean
25
+ width: number
26
+ overlay: boolean
27
+ onSelect: (mode: InspectorMode) => void
28
+ onClose: () => void
29
+ slideSettings: ReactNode
30
+ comments: ReactNode
31
+ }) {
32
+ const prefix = useId()
33
+ const tabs: { id: InspectorMode; label: string }[] = [
34
+ { id: "slide", label: "Slide" },
35
+ ...(editorAvailable ? [{ id: "editor" as const, label: "Elements" }] : []),
36
+ { id: "comments", label: "Comments" }
37
+ ]
38
+ const closeLabel =
39
+ mode === "editor"
40
+ ? "Done editing"
41
+ : mode === "comments"
42
+ ? "Hide comments"
43
+ : "Close slide settings"
44
+ return (
45
+ <aside
46
+ data-ps-inspector
47
+ aria-label="Inspector"
48
+ aria-hidden={!visible}
49
+ inert={!visible}
50
+ className={cn(
51
+ "h-full shrink-0 overflow-hidden bg-neutral-950 text-white transition-[width,transform] duration-200 ease-out motion-reduce:transition-none print:hidden",
52
+ overlay && "fixed inset-y-0 right-0 z-[60]",
53
+ overlay && !visible && "translate-x-full"
54
+ )}
55
+ style={{ width: visible ? width : 0, maxWidth: overlay ? "90vw" : undefined }}
56
+ onKeyDown={event => {
57
+ // Native button activation must not also advance the presentation.
58
+ if (
59
+ (event.key === " " || event.key === "Enter") &&
60
+ (event.target as HTMLElement).closest("button")
61
+ )
62
+ event.stopPropagation()
63
+ }}
64
+ >
65
+ <div className="flex h-full min-h-0 w-full flex-col border-l border-neutral-800">
66
+ <div
67
+ data-ps-inspector-header
68
+ className="flex shrink-0 items-center gap-1 border-b border-neutral-800 px-2 py-2"
69
+ >
70
+ <div
71
+ role="tablist"
72
+ aria-label="Inspector sections"
73
+ className="flex min-w-0 flex-1 gap-1"
74
+ onKeyDown={event => {
75
+ if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return
76
+ event.preventDefault()
77
+ event.stopPropagation()
78
+ const buttons = Array.from(
79
+ event.currentTarget.querySelectorAll<HTMLButtonElement>("button")
80
+ )
81
+ const index = buttons.indexOf(event.target as HTMLButtonElement)
82
+ const next =
83
+ event.key === "Home"
84
+ ? 0
85
+ : event.key === "End"
86
+ ? buttons.length - 1
87
+ : (index + (event.key === "ArrowRight" ? 1 : -1) + buttons.length) %
88
+ buttons.length
89
+ buttons[next]?.focus()
90
+ }}
91
+ >
92
+ {tabs.map(tab => (
93
+ <button
94
+ key={tab.id}
95
+ id={`${prefix}-tab-${tab.id}`}
96
+ role="tab"
97
+ aria-selected={mode === tab.id}
98
+ aria-controls={`${prefix}-panel-${tab.id}`}
99
+ tabIndex={mode === tab.id ? 0 : -1}
100
+ className={cn(
101
+ "rounded-md px-2 py-1.5 text-xs font-medium hover:bg-neutral-800 focus-visible:outline-2 focus-visible:outline-orange-500",
102
+ mode === tab.id ? "bg-neutral-800 text-white" : "text-neutral-400"
103
+ )}
104
+ onClick={() => onSelect(tab.id)}
105
+ >
106
+ {tab.label}
107
+ </button>
108
+ ))}
109
+ </div>
110
+ <button
111
+ onClick={onClose}
112
+ aria-label={closeLabel}
113
+ title={closeLabel}
114
+ className="shrink-0 rounded-md p-1.5 text-neutral-400 hover:bg-neutral-800 hover:text-white focus-visible:outline-2 focus-visible:outline-orange-500"
115
+ >
116
+ <X size={16} />
117
+ </button>
118
+ </div>
119
+ <div
120
+ id={`${prefix}-panel-slide`}
121
+ role="tabpanel"
122
+ aria-labelledby={`${prefix}-tab-slide`}
123
+ hidden={mode !== "slide"}
124
+ className="min-h-0 flex-1"
125
+ >
126
+ {slideSettings}
127
+ </div>
128
+ <div
129
+ id={`${prefix}-panel-editor`}
130
+ role="tabpanel"
131
+ aria-labelledby={`${prefix}-tab-editor`}
132
+ hidden={mode !== "editor"}
133
+ className="min-h-0 flex-1"
134
+ >
135
+ <div
136
+ data-ps-editor-panel
137
+ data-open={mode === "editor" && visible}
138
+ aria-hidden={mode !== "editor" || !visible}
139
+ inert={mode !== "editor" || !visible}
140
+ className="h-full w-full"
141
+ />
142
+ </div>
143
+ <div
144
+ id={`${prefix}-panel-comments`}
145
+ role="tabpanel"
146
+ aria-labelledby={`${prefix}-tab-comments`}
147
+ hidden={mode !== "comments"}
148
+ className="min-h-0 flex-1"
149
+ >
150
+ {comments}
151
+ </div>
152
+ </div>
153
+ </aside>
154
+ )
155
+ }
@@ -0,0 +1,13 @@
1
+ import type { LoopConfig, SlideConfig } from "../types"
2
+ export const DEFAULT_LOOP_CONFIG: LoopConfig
3
+ export function validateLoopConfig(value: unknown): LoopConfig
4
+ export function validDuration(value: unknown): boolean
5
+ export function loopSlideKey(slide: Pick<SlideConfig, "id">, index: number): string
6
+ export function stateDuration(
7
+ config: LoopConfig,
8
+ slide: Pick<SlideConfig, "id">,
9
+ index: number,
10
+ step: number
11
+ ): number
12
+ export function includedSlides(config: LoopConfig, slides: SlideConfig[]): number[]
13
+ export function loopDuration(config: LoopConfig, slides: SlideConfig[]): number
@@ -0,0 +1,65 @@
1
+ export const DEFAULT_LOOP_CONFIG = { version: 1, defaultDuration: 5, slides: {} }
2
+
3
+ export function validateLoopConfig(value) {
4
+ if (
5
+ !value ||
6
+ value.version !== 1 ||
7
+ !validDuration(value.defaultDuration) ||
8
+ !value.slides ||
9
+ typeof value.slides !== "object" ||
10
+ Array.isArray(value.slides)
11
+ ) {
12
+ throw new Error("Invalid loop settings")
13
+ }
14
+ const slides = Object.create(null)
15
+ if (Object.keys(value.slides).length > 1000) throw new Error("Too many slides")
16
+ for (const [id, item] of Object.entries(value.slides)) {
17
+ if (
18
+ !id ||
19
+ id.length > 256 ||
20
+ !item ||
21
+ typeof item !== "object" ||
22
+ (item.excluded !== undefined && typeof item.excluded !== "boolean") ||
23
+ (item.steps !== undefined &&
24
+ (!Number.isInteger(item.steps) || item.steps < 0 || item.steps > 1000)) ||
25
+ (item.dwell !== undefined &&
26
+ (!Array.isArray(item.dwell) ||
27
+ item.dwell.length > 1001 ||
28
+ !item.dwell.every(duration => duration === null || validDuration(duration))))
29
+ )
30
+ throw new Error(`Invalid timing for ${id}`)
31
+ slides[id] = {
32
+ ...(item.excluded !== undefined ? { excluded: item.excluded } : {}),
33
+ ...(item.steps !== undefined ? { steps: item.steps } : {}),
34
+ ...(item.dwell !== undefined ? { dwell: [...item.dwell] } : {})
35
+ }
36
+ }
37
+ return { version: 1, defaultDuration: value.defaultDuration, slides }
38
+ }
39
+
40
+ export function validDuration(value) {
41
+ return typeof value === "number" && Number.isFinite(value) && value >= 0.1 && value <= 3600
42
+ }
43
+
44
+ export function loopSlideKey(slide, index) {
45
+ return slide.id || String(index + 1)
46
+ }
47
+
48
+ export function stateDuration(config, slide, index, step) {
49
+ return config.slides[loopSlideKey(slide, index)]?.dwell?.[step] ?? config.defaultDuration
50
+ }
51
+
52
+ export function includedSlides(config, slides) {
53
+ return slides.flatMap((slide, index) =>
54
+ config.slides[loopSlideKey(slide, index)]?.excluded ? [] : [index]
55
+ )
56
+ }
57
+
58
+ export function loopDuration(config, slides) {
59
+ return includedSlides(config, slides).reduce((total, index) => {
60
+ const slide = slides[index]
61
+ for (let step = 0; step <= slide.steps; step++)
62
+ total += stateDuration(config, slide, index, step)
63
+ return total
64
+ }, 0)
65
+ }