promptslide 0.3.18 → 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.
@@ -13,6 +13,7 @@ import { DEFAULT_SLIDE_TRANSITION, getSlideVariants } from "./transitions"
13
13
  // =============================================================================
14
14
 
15
15
  export interface SlideRendererProps {
16
+ playbackKey?: number
16
17
  slides: SlideConfig[]
17
18
  currentSlide: number
18
19
  animationStep: number
@@ -33,6 +34,7 @@ export interface SlideRendererProps {
33
34
  * Extracted from SlideDeck so it can be reused in SlideEmbed and other contexts.
34
35
  */
35
36
  export function SlideRenderer({
37
+ playbackKey,
36
38
  slides,
37
39
  currentSlide,
38
40
  animationStep,
@@ -77,7 +79,11 @@ export function SlideRenderer({
77
79
  showAllAnimations={showAllAnimations}
78
80
  >
79
81
  <SlideErrorBoundary slideIndex={currentSlide} slideTitle={slides[currentSlide]?.title}>
80
- <CurrentSlideComponent slideNumber={currentSlide + 1} totalSlides={slides.length} />
82
+ <CurrentSlideComponent
83
+ key={playbackKey}
84
+ slideNumber={currentSlide + 1}
85
+ totalSlides={slides.length}
86
+ />
81
87
  </SlideErrorBoundary>
82
88
  </AnimationProvider>
83
89
  </motion.div>
@@ -0,0 +1,38 @@
1
+ import type { ComponentPropsWithoutRef } from "react"
2
+
3
+ import { cn } from "./utils"
4
+
5
+ type ToolbarActionProps = Omit<
6
+ ComponentPropsWithoutRef<"button">,
7
+ "aria-label" | "aria-pressed" | "className"
8
+ > & {
9
+ label: string
10
+ selected?: boolean
11
+ }
12
+
13
+ /** Shared icon action for both the viewer toolbar and the editor's toolbar portal. */
14
+ export function ToolbarAction({
15
+ label,
16
+ selected,
17
+ title = label,
18
+ children,
19
+ ...props
20
+ }: ToolbarActionProps) {
21
+ return (
22
+ <button
23
+ {...props}
24
+ type="button"
25
+ aria-label={label}
26
+ aria-pressed={selected}
27
+ title={title}
28
+ className={cn(
29
+ "relative inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-transparent transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#FF6B35] disabled:cursor-default disabled:opacity-35 [&>svg]:h-4 [&>svg]:w-4 [&>svg]:shrink-0",
30
+ selected
31
+ ? "border-[#FF6B35]/50 bg-[#FF6B35]/15 text-[#FF6B35] enabled:hover:border-[#FF6B35] enabled:hover:bg-[#FF6B35]/25"
32
+ : "text-neutral-400 enabled:hover:bg-neutral-800 enabled:hover:text-white"
33
+ )}
34
+ >
35
+ {children}
36
+ </button>
37
+ )
38
+ }
package/src/core/types.ts CHANGED
@@ -136,3 +136,17 @@ export interface LayoutBaseProps extends SlideProps {
136
136
  hideFooter?: boolean
137
137
  className?: string
138
138
  }
139
+
140
+ /** Durations in seconds, including the initial state at dwell[0]. */
141
+ export interface LoopSlideConfig {
142
+ excluded?: boolean
143
+ /** Step count when these timings were edited; used to flag changes. */
144
+ steps?: number
145
+ dwell?: (number | null)[]
146
+ }
147
+
148
+ export interface LoopConfig {
149
+ version: 1
150
+ defaultDuration: number
151
+ slides: Record<string, LoopSlideConfig>
152
+ }
@@ -67,6 +67,8 @@ export interface UseSlideNavigationReturn {
67
67
  advance: () => void
68
68
  goBack: () => void
69
69
  goToSlide: (index: number) => void
70
+ goToStep: (step: number) => void
71
+ restartAtSlide: (index: number) => void
70
72
  onTransitionComplete: () => void
71
73
  }
72
74
 
@@ -206,6 +208,29 @@ export function useSlideNavigation({
206
208
  [currentSlide, slides.length, onSlideChange]
207
209
  )
208
210
 
211
+ const goToStep = useCallback(
212
+ (step: number) => {
213
+ if (!Number.isInteger(step)) return
214
+ setQueuedAction(null)
215
+ setAnimationStep(Math.max(0, Math.min(step, slides[currentSlide]?.steps ?? 0)))
216
+ setNavState(previous => ({ ...previous, direction: 0 }))
217
+ },
218
+ [slides, currentSlide]
219
+ )
220
+
221
+ const restartAtSlide = useCallback(
222
+ (index: number) => {
223
+ if (index < 0 || index >= slides.length) return
224
+ setQueuedAction(null)
225
+ setAnimationStep(0)
226
+ setCurrentSlide(index)
227
+ // Same-slide restarts do not have a transition to complete.
228
+ setNavState({ status: index === currentSlide ? "idle" : "transitioning", direction: 1 })
229
+ onSlideChange?.(index)
230
+ },
231
+ [slides.length, currentSlide, onSlideChange]
232
+ )
233
+
209
234
  useEffect(() => {
210
235
  if (navState.status === "idle" && queuedAction !== null) {
211
236
  setQueuedAction(null)
@@ -227,6 +252,8 @@ export function useSlideNavigation({
227
252
  advance,
228
253
  goBack,
229
254
  goToSlide,
255
+ goToStep,
256
+ restartAtSlide,
230
257
  onTransitionComplete
231
258
  }
232
259
  }
@@ -0,0 +1,130 @@
1
+ import { Check, ChevronDown, Grid3X3, List, Monitor } from "lucide-react"
2
+ import { useEffect, useId, useLayoutEffect, useRef, useState } from "react"
3
+
4
+ import { cn } from "./utils"
5
+
6
+ export type ViewMode = "slide" | "list" | "grid"
7
+
8
+ const views = [
9
+ { mode: "slide", label: "Presentation view", shortcut: "V", icon: Monitor },
10
+ { mode: "list", label: "List view", shortcut: "L", icon: List },
11
+ { mode: "grid", label: "Grid view", shortcut: "G", icon: Grid3X3 }
12
+ ] as const
13
+
14
+ export function ViewMenu({
15
+ value,
16
+ onChange,
17
+ disabled
18
+ }: {
19
+ value: ViewMode
20
+ onChange: (mode: ViewMode) => void
21
+ disabled: boolean
22
+ }) {
23
+ const [open, setOpen] = useState(false)
24
+ const id = useId()
25
+ const root = useRef<HTMLDivElement>(null)
26
+ const trigger = useRef<HTMLButtonElement>(null)
27
+ const active = views.find(view => view.mode === value)!
28
+ const Icon = active.icon
29
+
30
+ useLayoutEffect(() => {
31
+ if (open) root.current?.querySelector<HTMLElement>('[aria-checked="true"]')?.focus()
32
+ }, [open])
33
+ useEffect(() => {
34
+ if (!open) return
35
+ const close = (event: PointerEvent) => {
36
+ if (!root.current?.contains(event.target as Node)) setOpen(false)
37
+ }
38
+ document.addEventListener("pointerdown", close)
39
+ return () => document.removeEventListener("pointerdown", close)
40
+ }, [open])
41
+
42
+ return (
43
+ <div
44
+ role="group"
45
+ aria-label="View selector"
46
+ ref={root}
47
+ className="relative flex"
48
+ onBlur={event => {
49
+ const next = event.relatedTarget
50
+ if (next && !event.currentTarget.contains(next)) setOpen(false)
51
+ }}
52
+ onKeyDown={event => {
53
+ // Toolbar navigation must not trigger slide advancement or deck shortcuts.
54
+ event.stopPropagation()
55
+ if (event.key === "Escape") {
56
+ setOpen(false)
57
+ trigger.current?.focus()
58
+ } else if (open && event.key === "Tab") {
59
+ trigger.current?.focus()
60
+ setOpen(false)
61
+ } else if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
62
+ event.preventDefault()
63
+ if (!open) {
64
+ setOpen(true)
65
+ return
66
+ }
67
+ const items = Array.from(
68
+ root.current!.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]')
69
+ )
70
+ const index = items.indexOf(document.activeElement as HTMLButtonElement)
71
+ const next =
72
+ event.key === "Home"
73
+ ? 0
74
+ : event.key === "End"
75
+ ? items.length - 1
76
+ : (index + (event.key === "ArrowDown" ? 1 : -1) + items.length) % items.length
77
+ items[next]?.focus()
78
+ }
79
+ }}
80
+ >
81
+ <button
82
+ ref={trigger}
83
+ disabled={disabled}
84
+ aria-label="View options"
85
+ title={`View options · ${active.label}`}
86
+ aria-haspopup="menu"
87
+ aria-expanded={open}
88
+ aria-controls={open ? id : undefined}
89
+ className="inline-flex h-8 items-center gap-1.5 rounded-md bg-neutral-800 px-2 text-white hover:bg-neutral-700 focus-visible:outline-2 focus-visible:outline-orange-500"
90
+ onClick={() => setOpen(previous => !previous)}
91
+ >
92
+ <Icon size={16} />
93
+ <ChevronDown size={12} />
94
+ </button>
95
+ {open && (
96
+ <div
97
+ id={id}
98
+ role="menu"
99
+ aria-label="View options"
100
+ className="absolute left-0 top-10 w-56 rounded-lg border border-neutral-700 bg-neutral-950 p-1 text-sm text-white"
101
+ >
102
+ {views.map(({ mode, label, shortcut, icon: OptionIcon }) => (
103
+ <button
104
+ key={mode}
105
+ role="menuitemradio"
106
+ aria-checked={mode === value}
107
+ tabIndex={-1}
108
+ className={cn(
109
+ "flex w-full items-center gap-2 rounded px-2 py-2 text-left text-neutral-300 hover:bg-neutral-800 focus:bg-neutral-800 focus:outline-none",
110
+ mode === value && "text-white"
111
+ )}
112
+ onClick={() => {
113
+ onChange(mode)
114
+ setOpen(false)
115
+ trigger.current?.focus()
116
+ }}
117
+ >
118
+ <OptionIcon size={16} />
119
+ <span className="flex-1">{label}</span>
120
+ <span aria-hidden="true" className="text-xs text-neutral-500">
121
+ {shortcut}
122
+ </span>
123
+ <Check size={14} className={mode === value ? "text-orange-400" : "invisible"} />
124
+ </button>
125
+ ))}
126
+ </div>
127
+ )}
128
+ </div>
129
+ )
130
+ }
@@ -1,6 +1,6 @@
1
1
  # Development source editor
2
2
 
3
- Run `promptslide studio` and choose **Edit** in the existing top-right toolbar. The editor is injected by the development Vite plugin. Production builds, registry previews, embeds, and export pages do not load it. No editable-component migration is needed.
3
+ Run `promptslide studio` and choose **Edit** in the top-right toolbar, or **Elements** in the shared inspector. The core viewer owns the inspector's tabs, docking, and close control; the editor portals its tools into the Elements slot. The editor is injected by the development Vite plugin. Production builds, registry previews, embeds, and export pages do not load it. No editable-component migration is needed.
4
4
 
5
5
  ## Workflow
6
6
 
@@ -11,6 +11,7 @@ Run `promptslide studio` and choose **Edit** in the existing top-right toolbar.
11
11
  - For images, adjust the frame size, objectFit, objectPosition, and inset crop.
12
12
  - Select a range in Text and apply bold, italic, or underline. Save existing text changes first. Range formatting creates ordinary styled JSX spans, which can subsequently be selected individually.
13
13
  - Structure supports inserting text, shapes, and images into JSX containers and duplicating/deleting direct JSX children. Root components and expressions cannot be deleted. Duplicates preview immediately and support Undo and Discard; save to edit copies independently. Insertions render after Save; range formatting previews immediately.
14
+ - Switching from Elements to Slide settings or Comments uses the same Save / Discard / Keep editing guard as closing the editor. Keeping editing (or cancelling the dialog) cancels the pending panel switch. Loop timing drafts stay in the viewer across panel changes and source saves.
14
15
  - Undo/redo affects the buffered draft. Save batches edits to source; Discard restores the source rendering. Close requires resolving unsaved changes. The browser tab retains its draft across hot reloads. Export draft downloads the pending operations for recovery.
15
16
  - Editing uses the original viewer canvas, styling, and animation wrappers. Leave Edit to reveal steps normally; opening Edit preserves the currently visible content.
16
17
 
@@ -33,3 +34,12 @@ The endpoint uses a per-server random token, checks cross-site fetches, limits p
33
34
  - `tsc -p packages/cli/tsconfig.json --noEmit`: editor and engine types.
34
35
 
35
36
  Implementation uses TypeScript's JSX parser and original-range patches. No OpenSlide source code is vendored.
37
+
38
+ ## Inspector protocol
39
+
40
+ The viewer's inspector is part of the production core; the source editor remains development-only.
41
+ `EditorLauncher` discovers the existing toolbar, panel, canvas, and overlay slots, announces
42
+ `promptslide:editor-ready`, and handles `promptslide:editor-request` to open or request a guarded
43
+ close. `promptslide:editor-state` acknowledges actual open/closed state. The viewer commits a
44
+ pending tab change only after a closed acknowledgement; `promptslide:editor-close-cancelled`
45
+ clears that pending change. Source edits and their history stay in the editor's own React root.
@@ -60,7 +60,7 @@ const dispose = mountEditor(slides, theme, ${JSON.stringify(token)}, import.meta
60
60
  if (import.meta.hot) {
61
61
  import.meta.hot.accept();
62
62
  import.meta.hot.dispose((data) => {
63
- data.open = document.querySelector('.ps-edit-action[aria-pressed="true"]') !== null;
63
+ data.open = document.querySelector('[data-ps-edit-action][aria-pressed="true"]') !== null;
64
64
  dispose?.();
65
65
  window.dispatchEvent(new Event("promptslide:editor-refresh"));
66
66
  });
@@ -1,29 +1,5 @@
1
1
  /* Editor chrome lives in sibling portals. No selector reaches slide content.
2
2
  CSS updates must remain independent of the editor's buffered source draft. */
3
- .ps-edit-action {
4
- display: inline-flex;
5
- align-items: center;
6
- justify-content: center;
7
- gap: 6px;
8
- height: 32px;
9
- padding: 0 10px;
10
- border: 1px solid transparent;
11
- border-radius: 6px;
12
- font: 500 13px/1 system-ui;
13
- color: #a3a3a3;
14
- background: transparent;
15
- cursor: pointer;
16
- }
17
- .ps-edit-action:hover {
18
- color: #fafafa;
19
- background: #262626;
20
- }
21
- .ps-edit-action.active {
22
- padding: 0 8px;
23
- border-color: #ff6b3560;
24
- background: #ff6b351a;
25
- color: #ff8b60;
26
- }
27
3
  [data-ps-viewer-toolbar] button:disabled {
28
4
  opacity: 0.35;
29
5
  cursor: default;
@@ -34,7 +10,6 @@
34
10
  display: flex;
35
11
  flex-direction: column;
36
12
  background: #0a0a0a;
37
- border-left: 1px solid #262626;
38
13
  color: #e5e5e5;
39
14
  font-family: inherit;
40
15
  font-size: 12px;
@@ -92,8 +67,7 @@
92
67
  .ps-panel button:focus-visible,
93
68
  .ps-panel input:focus-visible,
94
69
  .ps-panel select:focus-visible,
95
- .ps-panel textarea:focus-visible,
96
- .ps-edit-action:focus-visible {
70
+ .ps-panel textarea:focus-visible {
97
71
  outline: 2px solid #ff6b35;
98
72
  outline-offset: 2px;
99
73
  }
@@ -31,9 +31,10 @@ import {
31
31
  MoveVertical,
32
32
  RotateCw
33
33
  } from "lucide-react"
34
- import { useEffect, useLayoutEffect, useRef, useState } from "react"
34
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"
35
35
  import { createPortal } from "react-dom"
36
36
 
37
+ import { ToolbarAction } from "../core/toolbar-action"
37
38
  import { aligned, snap, translated, type Rect } from "./geometry"
38
39
  import "./studio.css"
39
40
 
@@ -525,6 +526,8 @@ function Studio({
525
526
  useEffect(() => {
526
527
  // Prevent the presentation's global keyboard handler from advancing behind the editor.
527
528
  const handler = (event: KeyboardEvent) => {
529
+ // Inspector navigation belongs to the core viewer, including its keyboard controls.
530
+ if ((event.target as HTMLElement).closest("[data-ps-inspector-header]")) return
528
531
  event.stopImmediatePropagation()
529
532
  if (confirmClose) return
530
533
  if (busy) return
@@ -751,7 +754,8 @@ function Studio({
751
754
  surface.contains(target) ||
752
755
  overlay.contains(target) ||
753
756
  panel.contains(target) ||
754
- (target instanceof Element && target.closest("[data-ps-viewer-toolbar]"))
757
+ (target instanceof Element &&
758
+ target.closest("[data-ps-viewer-toolbar], [data-ps-inspector-header]"))
755
759
  )
756
760
  return
757
761
  if (inlineText !== null && current && inlineText !== (currentEdit?.text ?? currentText))
@@ -803,7 +807,15 @@ function Studio({
803
807
  }
804
808
  close()
805
809
  }
810
+ function cancelClosing() {
811
+ setConfirmClose(false)
812
+ window.dispatchEvent(new Event("promptslide:editor-close-cancelled"))
813
+ }
806
814
  function finishEditing() {
815
+ if (busy) {
816
+ window.dispatchEvent(new Event("promptslide:editor-close-cancelled"))
817
+ return
818
+ }
807
819
  if (dirty) {
808
820
  setCloseError("")
809
821
  setConfirmClose(true)
@@ -887,7 +899,7 @@ function Studio({
887
899
  aria-labelledby="ps-close-title"
888
900
  onCancel={event => {
889
901
  event.preventDefault()
890
- if (!busy) setConfirmClose(false)
902
+ if (!busy) cancelClosing()
891
903
  }}
892
904
  >
893
905
  <h2 id="ps-close-title">Save your changes?</h2>
@@ -901,7 +913,7 @@ function Studio({
901
913
  </p>
902
914
  )}
903
915
  <div className="ps-close-actions">
904
- <button disabled={busy} onClick={() => setConfirmClose(false)}>
916
+ <button disabled={busy} onClick={cancelClosing}>
905
917
  Keep editing
906
918
  </button>
907
919
  <button
@@ -935,9 +947,6 @@ function Studio({
935
947
  <span className="ps-overline">SLIDE EDITOR</span>
936
948
  <h2>{current ? selectedName : "Make it yours"}</h2>
937
949
  </div>
938
- <IconButton label="Done editing" disabled={busy} onClick={finishEditing}>
939
- <X size={18} />
940
- </IconButton>
941
950
  </div>
942
951
  <div className="ps-panel-tools">
943
952
  <div className="ps-tabs" role="tablist" aria-label="Editing tools">
@@ -1880,18 +1889,6 @@ export function EditorLauncher({
1880
1889
  initiallyOpen?: boolean
1881
1890
  }) {
1882
1891
  const [open, setOpen] = useState(initiallyOpen)
1883
- const [mounted, setMounted] = useState(initiallyOpen)
1884
- useEffect(() => {
1885
- if (open) {
1886
- setMounted(true)
1887
- return
1888
- }
1889
- const timeout = window.setTimeout(
1890
- () => setMounted(false),
1891
- window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : 200
1892
- )
1893
- return () => window.clearTimeout(timeout)
1894
- }, [open])
1895
1892
  const [slots, setSlots] = useState<{
1896
1893
  toolbar: HTMLElement
1897
1894
  panel: HTMLElement
@@ -1919,30 +1916,49 @@ export function EditorLauncher({
1919
1916
  observer.observe(document.getElementById("root")!, { childList: true, subtree: true })
1920
1917
  return () => observer.disconnect()
1921
1918
  }, [])
1922
- const toggle = (value: boolean) => {
1919
+ const toggle = useCallback((value: boolean) => {
1923
1920
  setOpen(value)
1924
1921
  window.dispatchEvent(new CustomEvent("promptslide:editor-state", { detail: { open: value } }))
1925
- }
1922
+ }, [])
1923
+ const openRef = useRef(open)
1924
+ openRef.current = open
1925
+ useEffect(() => {
1926
+ if (!slots) return
1927
+ window.dispatchEvent(
1928
+ new CustomEvent("promptslide:editor-ready", { detail: { available: true } })
1929
+ )
1930
+ if (openRef.current)
1931
+ window.dispatchEvent(new CustomEvent("promptslide:editor-state", { detail: { open: true } }))
1932
+ const onRequest = (event: Event) => {
1933
+ if ((event as CustomEvent).detail?.open) toggle(true)
1934
+ else window.dispatchEvent(new Event("promptslide:editor-done"))
1935
+ }
1936
+ window.addEventListener("promptslide:editor-request", onRequest)
1937
+ return () => {
1938
+ window.removeEventListener("promptslide:editor-request", onRequest)
1939
+ window.dispatchEvent(
1940
+ new CustomEvent("promptslide:editor-ready", { detail: { available: false } })
1941
+ )
1942
+ }
1943
+ }, [slots, toggle])
1926
1944
  if (!slots) return null
1927
1945
  return (
1928
1946
  <>
1929
1947
  {createPortal(
1930
- <button
1931
- className={`ps-edit-action ${open ? "active" : ""}`}
1932
- aria-label="Edit slide"
1933
- title="Edit slide"
1934
- aria-pressed={open}
1948
+ <ToolbarAction
1949
+ data-ps-edit-action
1950
+ label="Edit slide"
1951
+ selected={open}
1935
1952
  onClick={() => {
1936
1953
  if (!open) toggle(true)
1937
1954
  else window.dispatchEvent(new Event("promptslide:editor-done"))
1938
1955
  }}
1939
1956
  >
1940
1957
  <Pencil size={15} />
1941
- {!open && <span>Edit</span>}
1942
- </button>,
1958
+ </ToolbarAction>,
1943
1959
  slots.toolbar
1944
1960
  )}
1945
- {mounted && <Studio token={token} close={() => toggle(false)} {...slots} />}
1961
+ {open && <Studio token={token} close={() => toggle(false)} {...slots} />}
1946
1962
  </>
1947
1963
  )
1948
1964
  }
@@ -1,6 +1,8 @@
1
1
  import { readFileSync, writeFileSync, existsSync } from "node:fs"
2
2
  import { join } from "node:path"
3
3
 
4
+ import { DEFAULT_LOOP_CONFIG, readLoopConfig, writeLoopConfig } from "./loop-config.mjs"
5
+
4
6
  const DECK_CONFIG_PATH = "src/deck-config.ts"
5
7
 
6
8
  /**
@@ -224,6 +226,8 @@ export function replaceDeckConfig(cwd, slides, opts = {}) {
224
226
  ].join("\n")
225
227
 
226
228
  writeFileSync(configPath, content, "utf-8")
229
+ if (opts.loopConfig !== undefined) writeLoopConfig(cwd, opts.loopConfig)
230
+ else if (!existsSync(join(cwd, "loop-config.json"))) writeLoopConfig(cwd, DEFAULT_LOOP_CONFIG)
227
231
  return true
228
232
  }
229
233
 
@@ -310,6 +314,8 @@ export function parseDeckConfig(cwd) {
310
314
  if (slides.length === 0) return null
311
315
 
312
316
  const result = { slides }
317
+ const loopConfig = readLoopConfig(cwd)
318
+ if (loopConfig) result.loopConfig = loopConfig
313
319
  if (transition) result.transition = transition
314
320
  if (directionalTransition !== undefined) result.directionalTransition = directionalTransition
315
321
  return result
@@ -0,0 +1,70 @@
1
+ import { randomUUID } from "node:crypto"
2
+ import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync, lstatSync } from "node:fs"
3
+ import { join } from "node:path"
4
+
5
+ import { DEFAULT_LOOP_CONFIG, validateLoopConfig } from "../core/loop/config.mjs"
6
+
7
+ export function readLoopConfig(root) {
8
+ const path = join(root, "loop-config.json")
9
+ if (!existsSync(path)) return undefined
10
+ if (lstatSync(path).isSymbolicLink()) throw new Error("loop-config.json must be a regular file")
11
+ return validateLoopConfig(JSON.parse(readFileSync(path, "utf8")))
12
+ }
13
+
14
+ export function writeLoopConfig(root, config) {
15
+ const value = validateLoopConfig(config)
16
+ const path = join(root, "loop-config.json")
17
+ if (existsSync(path) && lstatSync(path).isSymbolicLink())
18
+ throw new Error("loop-config.json must be a regular file")
19
+ const temporary = join(root, `.loop-config-${randomUUID()}.tmp`)
20
+ try {
21
+ writeFileSync(temporary, JSON.stringify(value, null, 2) + "\n", { flag: "wx" })
22
+ renameSync(temporary, path)
23
+ } finally {
24
+ if (existsSync(temporary)) unlinkSync(temporary)
25
+ }
26
+ }
27
+
28
+ export function loopConfigMiddleware(root, onSaved = () => {}) {
29
+ return (req, res, next) => {
30
+ if (req.url !== "/__promptslide_loop") return next()
31
+ if (req.method !== "POST") {
32
+ res.statusCode = 405
33
+ return res.end()
34
+ }
35
+ const host = req.headers.host || ""
36
+ const remote = req.socket.remoteAddress
37
+ if (
38
+ !/^(localhost|127\.0\.0\.1|\[::1\])(?::\d+)?$/.test(host) ||
39
+ !["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(remote) ||
40
+ req.headers.origin !== `http://${host}` ||
41
+ !req.headers["content-type"]?.startsWith("application/json")
42
+ ) {
43
+ res.statusCode = 403
44
+ return res.end("Loop settings can only be saved from local Studio.")
45
+ }
46
+ let body = ""
47
+ req.on("data", chunk => {
48
+ body += chunk
49
+ if (Buffer.byteLength(body) > 65536) {
50
+ res.statusCode = 413
51
+ res.end()
52
+ req.destroy()
53
+ }
54
+ })
55
+ req.on("end", () => {
56
+ if (res.writableEnded) return
57
+ try {
58
+ writeLoopConfig(root, JSON.parse(body))
59
+ onSaved()
60
+ res.statusCode = 204
61
+ res.end()
62
+ } catch {
63
+ res.statusCode = 400
64
+ res.end("Invalid loop settings or project file could not be written.")
65
+ }
66
+ })
67
+ }
68
+ }
69
+
70
+ export { DEFAULT_LOOP_CONFIG }
@@ -2,6 +2,7 @@ import { readFileSync, writeFileSync, existsSync } from "node:fs"
2
2
  import { join, resolve, sep, extname } from "node:path"
3
3
 
4
4
  import { bold, dim } from "../utils/ansi.mjs"
5
+ import { readLoopConfig, DEFAULT_LOOP_CONFIG, loopConfigMiddleware } from "../utils/loop-config.mjs"
5
6
  import { projectIdentity } from "../utils/studio-discovery.mjs"
6
7
 
7
8
  /** Normalize a path to forward slashes so it can be used in ES module import strings on Windows. */
@@ -84,9 +85,11 @@ import { StrictMode, createElement } from "react"
84
85
  import { createRoot } from "react-dom/client"
85
86
  import "${r}/src/globals.css"
86
87
  import App from "${r}/src/App"
88
+ import { LoopConfigProvider } from "promptslide"
89
+ const loopConfig = ${JSON.stringify(readLoopConfig(root) ?? DEFAULT_LOOP_CONFIG)}
87
90
 
88
91
  createRoot(document.getElementById("root")).render(
89
- createElement(StrictMode, null, createElement(App))
92
+ createElement(StrictMode, null, createElement(LoopConfigProvider, { config: loopConfig }, createElement(App)))
90
93
  )
91
94
  `
92
95
  }
@@ -256,7 +259,7 @@ export function promptslidePlugin({ root: initialRoot } = {}) {
256
259
  return {
257
260
  server: {
258
261
  watch: {
259
- ignored: ["**/annotations.json"]
262
+ ignored: ["**/annotations.json", "**/.loop-config-*.tmp"]
260
263
  }
261
264
  }
262
265
  }
@@ -284,6 +287,19 @@ export function promptslidePlugin({ root: initialRoot } = {}) {
284
287
  },
285
288
 
286
289
  configureServer(server) {
290
+ const invalidateLoopConfig = () => {
291
+ const entry = server.moduleGraph.getModuleById(RESOLVED_VIRTUAL_ENTRY_ID)
292
+ if (entry) server.moduleGraph.invalidateModule(entry)
293
+ }
294
+ const loopFileChanged = path => {
295
+ if (resolve(path) === resolve(root, "loop-config.json")) invalidateLoopConfig()
296
+ }
297
+ // New page loads see the saved file without interrupting the current presentation.
298
+ server.watcher
299
+ .on("change", loopFileChanged)
300
+ .on("add", loopFileChanged)
301
+ .on("unlink", loopFileChanged)
302
+ server.middlewares.use(loopConfigMiddleware(root, invalidateLoopConfig))
287
303
  server.middlewares.use(async (req, res, next) => {
288
304
  if (req.method !== "GET" || req.url !== "/__promptslide_pdf") return next()
289
305
  try {
@@ -3,6 +3,6 @@ import { SlideTitle } from "@/slides/slide-title";
3
3
  import { SlideExample } from "@/slides/slide-example";
4
4
 
5
5
  export const slides: SlideConfig[] = [
6
- { id: "title", component: SlideTitle, steps: 0 },
7
- { id: "example", component: SlideExample, steps: 2 },
6
+ { id: "title", title: "Introduction", component: SlideTitle, steps: 0 },
7
+ { id: "example", title: "Example", component: SlideExample, steps: 2 },
8
8
  ];