@sntlr/registry-shell 1.0.0

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.
Files changed (134) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +200 -0
  3. package/dist/adapter/custom.d.ts +47 -0
  4. package/dist/adapter/custom.js +53 -0
  5. package/dist/adapter/custom.js.map +1 -0
  6. package/dist/adapter/default.d.ts +40 -0
  7. package/dist/adapter/default.js +202 -0
  8. package/dist/adapter/default.js.map +1 -0
  9. package/dist/cli/build.d.ts +1 -0
  10. package/dist/cli/build.js +31 -0
  11. package/dist/cli/build.js.map +1 -0
  12. package/dist/cli/dev.d.ts +1 -0
  13. package/dist/cli/dev.js +26 -0
  14. package/dist/cli/dev.js.map +1 -0
  15. package/dist/cli/index.d.ts +12 -0
  16. package/dist/cli/index.js +49 -0
  17. package/dist/cli/index.js.map +1 -0
  18. package/dist/cli/init.d.ts +1 -0
  19. package/dist/cli/init.js +70 -0
  20. package/dist/cli/init.js.map +1 -0
  21. package/dist/cli/shared.d.ts +33 -0
  22. package/dist/cli/shared.js +278 -0
  23. package/dist/cli/shared.js.map +1 -0
  24. package/dist/cli/start.d.ts +1 -0
  25. package/dist/cli/start.js +24 -0
  26. package/dist/cli/start.js.map +1 -0
  27. package/dist/config-loader.d.ts +49 -0
  28. package/dist/config-loader.js +140 -0
  29. package/dist/define-config.d.ts +188 -0
  30. package/dist/define-config.js +21 -0
  31. package/dist/index.d.ts +11 -0
  32. package/dist/index.js +9 -0
  33. package/package.json +124 -0
  34. package/src/adapter/custom.ts +90 -0
  35. package/src/adapter/default.ts +241 -0
  36. package/src/cli/build.ts +38 -0
  37. package/src/cli/dev.ts +38 -0
  38. package/src/cli/index.ts +52 -0
  39. package/src/cli/init.ts +76 -0
  40. package/src/cli/shared.ts +306 -0
  41. package/src/cli/start.ts +28 -0
  42. package/src/config-loader.ts +190 -0
  43. package/src/define-config.ts +206 -0
  44. package/src/index.ts +17 -0
  45. package/src/next-app/app/[...asset]/route.ts +81 -0
  46. package/src/next-app/app/_user-global.css +6 -0
  47. package/src/next-app/app/_user-sources.css +9 -0
  48. package/src/next-app/app/a11y/[name]/route.ts +19 -0
  49. package/src/next-app/app/api/search-index/route.ts +19 -0
  50. package/src/next-app/app/components/[name]/page.tsx +61 -0
  51. package/src/next-app/app/components/layout.tsx +18 -0
  52. package/src/next-app/app/docs/[slug]/page.tsx +53 -0
  53. package/src/next-app/app/docs/layout.tsx +18 -0
  54. package/src/next-app/app/globals.css +329 -0
  55. package/src/next-app/app/layout.tsx +102 -0
  56. package/src/next-app/app/page.tsx +9 -0
  57. package/src/next-app/app/preview-snapshot/[name]/page.tsx +20 -0
  58. package/src/next-app/app/preview-snapshot/layout.tsx +17 -0
  59. package/src/next-app/app/props/[name]/route.ts +19 -0
  60. package/src/next-app/app/r/[name]/route.ts +14 -0
  61. package/src/next-app/app/tests/[name]/route.ts +19 -0
  62. package/src/next-app/components/a11y-info.tsx +287 -0
  63. package/src/next-app/components/a11y-provider.tsx +39 -0
  64. package/src/next-app/components/component-breadcrumb.tsx +55 -0
  65. package/src/next-app/components/component-icon.tsx +140 -0
  66. package/src/next-app/components/component-preview.tsx +13 -0
  67. package/src/next-app/components/component-tabs.tsx +209 -0
  68. package/src/next-app/components/docs-toc.tsx +86 -0
  69. package/src/next-app/components/global-mobile-sidebar.tsx +35 -0
  70. package/src/next-app/components/header.tsx +188 -0
  71. package/src/next-app/components/heading-anchor.tsx +52 -0
  72. package/src/next-app/components/homepage-demo.tsx +180 -0
  73. package/src/next-app/components/locale-toggle.tsx +35 -0
  74. package/src/next-app/components/localized-mdx-client.tsx +14 -0
  75. package/src/next-app/components/localized-mdx.tsx +27 -0
  76. package/src/next-app/components/mobile-sidebar.tsx +22 -0
  77. package/src/next-app/components/nav-data-provider.tsx +37 -0
  78. package/src/next-app/components/navigation-progress.tsx +62 -0
  79. package/src/next-app/components/preview-canvas.tsx +368 -0
  80. package/src/next-app/components/preview-controls.tsx +94 -0
  81. package/src/next-app/components/preview-layout.tsx +218 -0
  82. package/src/next-app/components/props-table.tsx +134 -0
  83. package/src/next-app/components/resizable-preview.tsx +101 -0
  84. package/src/next-app/components/search.tsx +177 -0
  85. package/src/next-app/components/settings-modal.tsx +98 -0
  86. package/src/next-app/components/shell-ui/accordion.tsx +70 -0
  87. package/src/next-app/components/shell-ui/backdrop.tsx +29 -0
  88. package/src/next-app/components/shell-ui/badge.tsx +55 -0
  89. package/src/next-app/components/shell-ui/breadcrumb.tsx +120 -0
  90. package/src/next-app/components/shell-ui/button.tsx +64 -0
  91. package/src/next-app/components/shell-ui/card.tsx +127 -0
  92. package/src/next-app/components/shell-ui/checkbox.tsx +33 -0
  93. package/src/next-app/components/shell-ui/dialog.tsx +171 -0
  94. package/src/next-app/components/shell-ui/empty-state.tsx +66 -0
  95. package/src/next-app/components/shell-ui/input.tsx +27 -0
  96. package/src/next-app/components/shell-ui/kbd.tsx +30 -0
  97. package/src/next-app/components/shell-ui/label.tsx +25 -0
  98. package/src/next-app/components/shell-ui/select.tsx +204 -0
  99. package/src/next-app/components/shell-ui/separator.tsx +32 -0
  100. package/src/next-app/components/shell-ui/skeleton.tsx +18 -0
  101. package/src/next-app/components/shell-ui/table.tsx +124 -0
  102. package/src/next-app/components/shell-ui/tabs.tsx +102 -0
  103. package/src/next-app/components/shell-ui/toggle.tsx +56 -0
  104. package/src/next-app/components/sidebar-layout.tsx +37 -0
  105. package/src/next-app/components/sidebar-provider.tsx +75 -0
  106. package/src/next-app/components/sidebar.tsx +222 -0
  107. package/src/next-app/components/snapshot-preview.tsx +28 -0
  108. package/src/next-app/components/test-info.tsx +155 -0
  109. package/src/next-app/components/theme-provider.tsx +16 -0
  110. package/src/next-app/components/theme-toggle.tsx +21 -0
  111. package/src/next-app/components/translated-text.tsx +8 -0
  112. package/src/next-app/fallback/homepage.tsx +112 -0
  113. package/src/next-app/fallback/previews.ts +17 -0
  114. package/src/next-app/hooks/use-active-section.ts +23 -0
  115. package/src/next-app/hooks/use-controls.ts +72 -0
  116. package/src/next-app/hooks/use-mobile.ts +19 -0
  117. package/src/next-app/lib/branding.ts +52 -0
  118. package/src/next-app/lib/components-nav.ts +8 -0
  119. package/src/next-app/lib/docs.ts +16 -0
  120. package/src/next-app/lib/github.ts +38 -0
  121. package/src/next-app/lib/i18n.tsx +630 -0
  122. package/src/next-app/lib/locales.ts +17 -0
  123. package/src/next-app/lib/preview-loader.ts +7 -0
  124. package/src/next-app/lib/registry-adapter.ts +199 -0
  125. package/src/next-app/lib/utils.ts +6 -0
  126. package/src/next-app/next-env.d.ts +6 -0
  127. package/src/next-app/next.config.ts +101 -0
  128. package/src/next-app/postcss.config.mjs +7 -0
  129. package/src/next-app/public/favicon.ico +0 -0
  130. package/src/next-app/public/favicon_dark.svg +3 -0
  131. package/src/next-app/public/favicon_light.svg +3 -0
  132. package/src/next-app/registry.config.ts +50 -0
  133. package/src/next-app/tsconfig.json +29 -0
  134. package/src/next-app/user-aliases.d.ts +17 -0
@@ -0,0 +1,368 @@
1
+ "use client"
2
+
3
+ import {
4
+ useCallback,
5
+ useEffect,
6
+ useRef,
7
+ useState,
8
+ type ReactNode,
9
+ } from "react"
10
+ import { Crosshair, SlidersHorizontal, Maximize, X } from "lucide-react"
11
+ import { Button } from "@shell/components/shell-ui/button"
12
+
13
+ const DOT_SPACING = 24
14
+ const DOT_RADIUS = 1
15
+ const PROXIMITY_RADIUS = 120
16
+ const MIN_ZOOM = 0.25
17
+ const MAX_ZOOM = 3
18
+ const ZOOM_STEP = 0.1
19
+
20
+ export interface CameraState {
21
+ x: number
22
+ y: number
23
+ zoom: number
24
+ }
25
+
26
+ export interface CanvasPosition {
27
+ x: number
28
+ y: number
29
+ }
30
+
31
+ export function PreviewCanvas({
32
+ children,
33
+ showControls,
34
+ onToggleControls,
35
+ fullscreen,
36
+ onToggleFullscreen,
37
+ camera: cameraProp,
38
+ onCameraChange,
39
+ position: posProp,
40
+ onPositionChange,
41
+ }: {
42
+ children: ReactNode
43
+ showControls?: boolean
44
+ onToggleControls?: () => void
45
+ fullscreen?: boolean
46
+ onToggleFullscreen?: () => void
47
+ camera?: CameraState
48
+ onCameraChange?: (camera: CameraState) => void
49
+ position?: CanvasPosition
50
+ onPositionChange?: (pos: CanvasPosition) => void
51
+ }) {
52
+ const containerRef = useRef<HTMLDivElement>(null)
53
+ const canvasRef = useRef<HTMLCanvasElement>(null)
54
+ const animRef = useRef<number>(0)
55
+
56
+ // Use controlled state if provided, otherwise internal
57
+ const [internalCamera, setInternalCamera] = useState({ x: 0, y: 0, zoom: 1 })
58
+ const [internalPos, setInternalPos] = useState({ x: 0, y: 0 })
59
+ const camera = cameraProp ?? internalCamera
60
+ const pos = posProp ?? internalPos
61
+ const cameraRef = useRef(camera)
62
+ const posRef = useRef(pos)
63
+ useEffect(() => { cameraRef.current = camera }, [camera])
64
+ useEffect(() => { posRef.current = pos }, [pos])
65
+
66
+ const setCamera: React.Dispatch<React.SetStateAction<CameraState>> = useCallback((v) => {
67
+ const next = typeof v === "function" ? v(cameraRef.current) : v
68
+ if (onCameraChange) onCameraChange(next)
69
+ else setInternalCamera(next)
70
+ }, [onCameraChange])
71
+
72
+ const setPos: React.Dispatch<React.SetStateAction<CanvasPosition>> = useCallback((v) => {
73
+ const next = typeof v === "function" ? v(posRef.current) : v
74
+ if (onPositionChange) onPositionChange(next)
75
+ else setInternalPos(next)
76
+ }, [onPositionChange])
77
+
78
+ const mouseRef = useRef({ x: -1000, y: -1000 })
79
+ const dragging = useRef<"pan" | "move" | null>(null)
80
+ const dragStart = useRef({ mx: 0, my: 0, cx: 0, cy: 0 })
81
+
82
+ // Draw dot grid — use a ref so the rAF loop always calls the latest version
83
+ const drawDotsRef = useRef<() => void>(() => {})
84
+
85
+ const drawDots = useCallback(() => {
86
+ const canvas = canvasRef.current
87
+ const container = containerRef.current
88
+ if (!canvas || !container) return
89
+
90
+ const rect = container.getBoundingClientRect()
91
+ const dpr = window.devicePixelRatio || 1
92
+ canvas.width = rect.width * dpr
93
+ canvas.height = rect.height * dpr
94
+ canvas.style.width = `${rect.width}px`
95
+ canvas.style.height = `${rect.height}px`
96
+
97
+ const ctx = canvas.getContext("2d")
98
+ if (!ctx) return
99
+ ctx.scale(dpr, dpr)
100
+ ctx.clearRect(0, 0, rect.width, rect.height)
101
+
102
+ const { x: camX, y: camY, zoom } = camera
103
+ // Keep spacing constant in screen space — never denser than base
104
+ const spacing = DOT_SPACING
105
+ const mx = mouseRef.current.x
106
+ const my = mouseRef.current.y
107
+
108
+ // Offset for panning (dots scroll with camera)
109
+ const offsetX = ((camX * zoom) % spacing + spacing) % spacing
110
+ const offsetY = ((camY * zoom) % spacing + spacing) % spacing
111
+
112
+ const isDark = document.documentElement.classList.contains("dark")
113
+ const dotColor = isDark ? "255,255,255" : "0,0,0"
114
+ const dotRadius = Math.max(DOT_RADIUS, DOT_RADIUS * zoom)
115
+
116
+ for (let x = offsetX - spacing; x < rect.width + spacing; x += spacing) {
117
+ for (
118
+ let y = offsetY - spacing;
119
+ y < rect.height + spacing;
120
+ y += spacing
121
+ ) {
122
+ const dist = Math.sqrt((x - mx) ** 2 + (y - my) ** 2)
123
+ const proximity = Math.max(0, 1 - dist / PROXIMITY_RADIUS)
124
+ const alpha = 0.08 + proximity * 0.35
125
+ ctx.fillStyle = `rgba(${dotColor}, ${alpha})`
126
+ ctx.beginPath()
127
+ ctx.arc(x, y, dotRadius, 0, Math.PI * 2)
128
+ ctx.fill()
129
+ }
130
+ }
131
+
132
+ animRef.current = requestAnimationFrame(() => drawDotsRef.current())
133
+ }, [camera])
134
+
135
+ useEffect(() => {
136
+ drawDotsRef.current = drawDots
137
+ }, [drawDots])
138
+
139
+ useEffect(() => {
140
+ animRef.current = requestAnimationFrame(() => drawDotsRef.current())
141
+ return () => cancelAnimationFrame(animRef.current)
142
+ }, [drawDots])
143
+
144
+ // Track mouse for dot proximity
145
+ const onMouseMove = useCallback(
146
+ (e: React.MouseEvent) => {
147
+ const rect = containerRef.current?.getBoundingClientRect()
148
+ if (rect) {
149
+ mouseRef.current = { x: e.clientX - rect.left, y: e.clientY - rect.top }
150
+ }
151
+
152
+ if (dragging.current === "pan") {
153
+ const dx = (e.clientX - dragStart.current.mx) / camera.zoom
154
+ const dy = (e.clientY - dragStart.current.my) / camera.zoom
155
+ setCamera((c) => ({
156
+ ...c,
157
+ x: dragStart.current.cx + dx,
158
+ y: dragStart.current.cy + dy,
159
+ }))
160
+ } else if (dragging.current === "move") {
161
+ const dx = (e.clientX - dragStart.current.mx) / camera.zoom
162
+ const dy = (e.clientY - dragStart.current.my) / camera.zoom
163
+ setPos({
164
+ x: dragStart.current.cx + dx,
165
+ y: dragStart.current.cy + dy,
166
+ })
167
+ }
168
+ },
169
+ [camera.zoom, setCamera, setPos]
170
+ )
171
+
172
+ const onMouseDown = useCallback(
173
+ (e: React.MouseEvent) => {
174
+ // Only pan on background (canvas) clicks
175
+ if (e.target === canvasRef.current) {
176
+ dragging.current = "pan"
177
+ dragStart.current = {
178
+ mx: e.clientX,
179
+ my: e.clientY,
180
+ cx: camera.x,
181
+ cy: camera.y,
182
+ }
183
+ e.preventDefault()
184
+ }
185
+ },
186
+ [camera.x, camera.y]
187
+ )
188
+
189
+ const componentWrapperRef = useRef<HTMLDivElement>(null)
190
+
191
+ const onComponentMouseDown = useCallback(
192
+ (e: React.MouseEvent) => {
193
+ // Only start move-drag when clicking the wrapper itself (the padding
194
+ // area), not on interactive children inside the component.
195
+ if (e.target !== componentWrapperRef.current) return
196
+
197
+ dragging.current = "move"
198
+ dragStart.current = {
199
+ mx: e.clientX,
200
+ my: e.clientY,
201
+ cx: pos.x,
202
+ cy: pos.y,
203
+ }
204
+ e.stopPropagation()
205
+ e.preventDefault()
206
+ },
207
+ [pos.x, pos.y]
208
+ )
209
+
210
+ const onMouseUp = useCallback(() => {
211
+ dragging.current = null
212
+ }, [])
213
+
214
+ const onMouseLeave = useCallback(() => {
215
+ mouseRef.current = { x: -1000, y: -1000 }
216
+ dragging.current = null
217
+ }, [])
218
+
219
+ // Pinch zoom on touch devices
220
+ const lastTouchDist = useRef<number | null>(null)
221
+ useEffect(() => {
222
+ const el = containerRef.current
223
+ if (!el) return
224
+ function handleTouchMove(e: TouchEvent) {
225
+ if (e.touches.length === 2) {
226
+ e.preventDefault()
227
+ const dx = e.touches[0].clientX - e.touches[1].clientX
228
+ const dy = e.touches[0].clientY - e.touches[1].clientY
229
+ const dist = Math.sqrt(dx * dx + dy * dy)
230
+ if (lastTouchDist.current !== null) {
231
+ const delta = (dist - lastTouchDist.current) * 0.005
232
+ setCamera((c) => ({
233
+ ...c,
234
+ zoom: Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, c.zoom + delta)),
235
+ }))
236
+ }
237
+ lastTouchDist.current = dist
238
+ }
239
+ }
240
+ function handleTouchEnd() { lastTouchDist.current = null }
241
+ el.addEventListener("touchmove", handleTouchMove, { passive: false })
242
+ el.addEventListener("touchend", handleTouchEnd)
243
+ return () => {
244
+ el.removeEventListener("touchmove", handleTouchMove)
245
+ el.removeEventListener("touchend", handleTouchEnd)
246
+ }
247
+ }, [setCamera])
248
+
249
+ // Native wheel listener with { passive: false } to actually prevent page scroll
250
+ useEffect(() => {
251
+ const el = containerRef.current
252
+ if (!el) return
253
+ function handleWheel(e: WheelEvent) {
254
+ e.preventDefault()
255
+ e.stopPropagation()
256
+ const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP
257
+ setCamera((c) => ({
258
+ ...c,
259
+ zoom: Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, c.zoom + delta)),
260
+ }))
261
+ }
262
+ el.addEventListener("wheel", handleWheel, { passive: false })
263
+ return () => el.removeEventListener("wheel", handleWheel)
264
+ }, [setCamera])
265
+
266
+ const recenter = useCallback(() => {
267
+ setCamera({ x: 0, y: 0, zoom: 1 })
268
+ setPos({ x: 0, y: 0 })
269
+ }, [setCamera, setPos])
270
+
271
+ const zoomIn = useCallback(() => {
272
+ setCamera((c) => ({
273
+ ...c,
274
+ zoom: Math.min(MAX_ZOOM, c.zoom + ZOOM_STEP),
275
+ }))
276
+ }, [setCamera])
277
+
278
+ const zoomOut = useCallback(() => {
279
+ setCamera((c) => ({
280
+ ...c,
281
+ zoom: Math.max(MIN_ZOOM, c.zoom - ZOOM_STEP),
282
+ }))
283
+ }, [setCamera])
284
+
285
+ return (
286
+ <div className={
287
+ fullscreen
288
+ ? "relative w-full h-full select-none"
289
+ : "relative w-full h-full overflow-hidden select-none"
290
+ }>
291
+ <div
292
+ ref={containerRef}
293
+ tabIndex={0}
294
+ className="absolute inset-0 cursor-grab active:cursor-grabbing outline-none"
295
+ onMouseMove={onMouseMove}
296
+ onMouseDown={onMouseDown}
297
+ onMouseUp={onMouseUp}
298
+ onMouseLeave={onMouseLeave}
299
+ onKeyDown={(e) => {
300
+ // Don't intercept arrows when an input/textarea/contenteditable is focused
301
+ const tag = (e.target as HTMLElement).tagName
302
+ const editable = tag === "INPUT" || tag === "TEXTAREA" || (e.target as HTMLElement).isContentEditable
303
+ if (editable) return
304
+
305
+ const PAN_STEP = 20
306
+ if (e.key === "ArrowUp") { e.preventDefault(); setCamera((c) => ({ ...c, y: c.y + PAN_STEP })) }
307
+ else if (e.key === "ArrowDown") { e.preventDefault(); setCamera((c) => ({ ...c, y: c.y - PAN_STEP })) }
308
+ else if (e.key === "ArrowLeft") { e.preventDefault(); setCamera((c) => ({ ...c, x: c.x + PAN_STEP })) }
309
+ else if (e.key === "ArrowRight") { e.preventDefault(); setCamera((c) => ({ ...c, x: c.x - PAN_STEP })) }
310
+ else if (e.key === "=" || e.key === "+") { e.preventDefault(); zoomIn() }
311
+ else if (e.key === "-") { e.preventDefault(); zoomOut() }
312
+ else if (e.key === "0") { e.preventDefault(); recenter() }
313
+ }}
314
+ >
315
+ <canvas ref={canvasRef} className="absolute inset-0" />
316
+
317
+ {/* Component layer */}
318
+ <div
319
+ className="absolute inset-0 pointer-events-none"
320
+ style={{
321
+ transform: `translate(${camera.x * camera.zoom}px, ${camera.y * camera.zoom}px) scale(${camera.zoom})`,
322
+ transformOrigin: "center",
323
+ }}
324
+ >
325
+ {/* Move handle — fills the entire transform layer, behind the component */}
326
+ <div
327
+ ref={componentWrapperRef}
328
+ className="absolute inset-0 pointer-events-auto cursor-move"
329
+ onMouseDown={onComponentMouseDown}
330
+ />
331
+ {/* Component — sits above the move handle, uses its own cursors */}
332
+ <div
333
+ className="absolute inset-0 flex items-center justify-center pointer-events-none"
334
+ >
335
+ <div
336
+ className="pointer-events-auto cursor-default"
337
+ style={{
338
+ transform: `translate(${pos.x}px, ${pos.y}px)`,
339
+ }}
340
+ >
341
+ {children}
342
+ </div>
343
+ </div>
344
+ </div>
345
+ </div>
346
+
347
+ {/* Toolbar — top-right: recenter + props toggle + fullscreen */}
348
+ <div className="absolute top-3 right-3 flex items-center gap-0.5 bg-background/80 backdrop-blur-sm border rounded-md p-0.5 pointer-events-auto z-10">
349
+ <Button variant="ghost" size="icon-xs" className="max-md:min-h-11 max-md:min-w-11" onClick={recenter} aria-label="Recenter">
350
+ <Crosshair />
351
+ </Button>
352
+ <Button
353
+ variant={showControls && onToggleControls ? "default" : "ghost"}
354
+ size="icon-xs"
355
+ className="max-md:min-h-11 max-md:min-w-11"
356
+ onClick={onToggleControls}
357
+ disabled={!onToggleControls}
358
+ aria-label="Toggle controls"
359
+ >
360
+ <SlidersHorizontal />
361
+ </Button>
362
+ <Button variant="ghost" size="icon-xs" className="max-md:min-h-11 max-md:min-w-11" onClick={onToggleFullscreen} aria-label="Toggle fullscreen">
363
+ {fullscreen ? <X /> : <Maximize />}
364
+ </Button>
365
+ </div>
366
+ </div>
367
+ )
368
+ }
@@ -0,0 +1,94 @@
1
+ "use client"
2
+
3
+ import type { ControlEntry } from "@shell/hooks/use-controls"
4
+ import { Label } from "@shell/components/shell-ui/label"
5
+ import {
6
+ Select,
7
+ SelectContent,
8
+ SelectItem,
9
+ SelectTrigger,
10
+ SelectValue,
11
+ } from "@shell/components/shell-ui/select"
12
+ import { Input } from "@shell/components/shell-ui/input"
13
+
14
+ export function PreviewControls({ entries }: { entries: ControlEntry[] }) {
15
+ if (entries.length === 0) return null
16
+
17
+ return (
18
+ <div className="space-y-3">
19
+ {entries.map((entry) => (
20
+ <div key={entry.name} className="space-y-1">
21
+ <Label className="text-xs text-muted-foreground capitalize">
22
+ {entry.name}
23
+ </Label>
24
+ <ControlInput entry={entry} />
25
+ </div>
26
+ ))}
27
+ </div>
28
+ )
29
+ }
30
+
31
+ function ControlInput({ entry }: { entry: ControlEntry }) {
32
+ const { def, value, onChange } = entry
33
+
34
+ switch (def.type) {
35
+ case "select":
36
+ return (
37
+ <Select value={value as string} onValueChange={(v) => onChange(v)}>
38
+ <SelectTrigger className="w-full h-8 text-xs">
39
+ <SelectValue />
40
+ </SelectTrigger>
41
+ <SelectContent>
42
+ {def.options.map((opt) => (
43
+ <SelectItem key={opt} value={opt} className="text-xs">
44
+ {opt}
45
+ </SelectItem>
46
+ ))}
47
+ </SelectContent>
48
+ </Select>
49
+ )
50
+
51
+ case "boolean":
52
+ return (
53
+ <button
54
+ onClick={() => onChange(!value)}
55
+ className={`flex h-8 w-full items-center justify-between rounded-md border px-2 text-xs cursor-pointer transition-colors ${
56
+ value
57
+ ? "border-primary bg-primary/5 text-foreground"
58
+ : "border-input text-muted-foreground"
59
+ }`}
60
+ >
61
+ <span>{value ? "true" : "false"}</span>
62
+ <div
63
+ className={`h-4 w-7 rounded-full transition-colors ${value ? "bg-primary" : "bg-muted"}`}
64
+ >
65
+ <div
66
+ className={`h-4 w-4 rounded-full bg-background border shadow-sm transition-transform ${value ? "translate-x-3" : "translate-x-0"}`}
67
+ />
68
+ </div>
69
+ </button>
70
+ )
71
+
72
+ case "text":
73
+ return (
74
+ <Input
75
+ type="text"
76
+ value={value as string}
77
+ onChange={(e) => onChange(e.target.value)}
78
+ className="h-8 text-xs"
79
+ />
80
+ )
81
+
82
+ case "number":
83
+ return (
84
+ <Input
85
+ type="number"
86
+ value={value as number}
87
+ min={def.min}
88
+ max={def.max}
89
+ onChange={(e) => onChange(Number(e.target.value))}
90
+ className="h-8 text-xs"
91
+ />
92
+ )
93
+ }
94
+ }
@@ -0,0 +1,218 @@
1
+ "use client"
2
+
3
+ import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react"
4
+ import type { ControlEntry } from "@shell/hooks/use-controls"
5
+ import type { CameraState, CanvasPosition } from "@shell/components/preview-canvas"
6
+ import { PreviewControls } from "@shell/components/preview-controls"
7
+ import { PreviewCanvas } from "@shell/components/preview-canvas"
8
+ import { useTranslations } from "@shell/lib/i18n"
9
+ import { useMobileSidebar } from "@shell/components/sidebar-provider"
10
+
11
+ const SnapshotModeContext = createContext(false)
12
+
13
+ /** Wrap children in this to render PreviewLayout without the canvas chrome. */
14
+ export function SnapshotModeProvider({ children }: { children: React.ReactNode }) {
15
+ return <SnapshotModeContext.Provider value={true}>{children}</SnapshotModeContext.Provider>
16
+ }
17
+
18
+ function useIsMobile() {
19
+ const [mobile, setMobile] = useState<boolean | null>(null)
20
+ useEffect(() => {
21
+ const mq = window.matchMedia("(max-width: 767px)")
22
+ const handler = (e: MediaQueryListEvent) => setMobile(e.matches)
23
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- sync with external matchMedia API
24
+ setMobile(mq.matches)
25
+ mq.addEventListener("change", handler)
26
+ return () => mq.removeEventListener("change", handler)
27
+ }, [])
28
+ return mobile
29
+ }
30
+
31
+ export function PreviewLayout({
32
+ children,
33
+ controls,
34
+ }: {
35
+ children: React.ReactNode
36
+ controls?: ControlEntry[]
37
+ }) {
38
+ const isSnapshot = useContext(SnapshotModeContext)
39
+ const hasControls = controls && controls.length > 0
40
+ const isMobile = useIsMobile()
41
+
42
+ const [showControls, setShowControls] = useState(() => {
43
+ if (typeof window === "undefined") return false
44
+ return sessionStorage.getItem("preview-controls") === "true"
45
+ })
46
+ const [restoredFullscreen] = useState(() => {
47
+ if (typeof window === "undefined") return false
48
+ return sessionStorage.getItem("preview-fullscreen") === "true"
49
+ })
50
+ const [fullscreen, setFullscreen] = useState(restoredFullscreen)
51
+ const [fsVisible, setFsVisible] = useState(restoredFullscreen)
52
+ const t = useTranslations()
53
+ const { setCollapsed } = useMobileSidebar()
54
+
55
+ // Shared camera state between inline and fullscreen canvases
56
+ const [camera, setCamera] = useState<CameraState>({ x: 0, y: 0, zoom: 1 })
57
+ const [position, setPosition] = useState<CanvasPosition>({ x: 0, y: 0 })
58
+
59
+ // Ref to the inline preview container — used to capture its position
60
+ const inlineRef = useRef<HTMLDivElement>(null)
61
+ // The captured rect of the inline preview when entering/exiting fullscreen
62
+ const [originRect, setOriginRect] = useState({ top: 0, bottom: 0, left: 0, right: 0 })
63
+
64
+ // Sync state on mount / mobile change
65
+ useEffect(() => {
66
+ if (isMobile === null) return
67
+ // Restore fullscreen sidebar collapse
68
+ if (fullscreen && !isMobile) setCollapsed(true)
69
+ }, [isMobile]) // eslint-disable-line react-hooks/exhaustive-deps -- intentionally run only on mount/mobile change
70
+
71
+ // Animate fullscreen in
72
+ useEffect(() => {
73
+ if (fullscreen) {
74
+ requestAnimationFrame(() => setFsVisible(true))
75
+ }
76
+ }, [fullscreen])
77
+
78
+ const enterFullscreen = useCallback(() => {
79
+ if (inlineRef.current) {
80
+ const rect = inlineRef.current.getBoundingClientRect()
81
+ const vw = window.innerWidth
82
+ const vh = window.innerHeight
83
+ setOriginRect({
84
+ top: rect.top,
85
+ bottom: vh - rect.bottom,
86
+ left: rect.left,
87
+ right: vw - rect.right,
88
+ })
89
+ }
90
+ if (!isMobile) setCollapsed(true)
91
+ setCamera({ x: 0, y: 0, zoom: 1 })
92
+ setPosition({ x: 0, y: 0 })
93
+ setFullscreen(true)
94
+ sessionStorage.setItem("preview-fullscreen", "true")
95
+ }, [isMobile, setCollapsed])
96
+
97
+ // In snapshot mode, render children directly — no canvas, no controls
98
+ if (isSnapshot) {
99
+ return <>{children}</>
100
+ }
101
+
102
+ function toggleControls() {
103
+ if (isMobile) {
104
+ if (fullscreen) {
105
+ setShowControls((s) => {
106
+ sessionStorage.setItem("preview-controls", String(!s))
107
+ return !s
108
+ })
109
+ } else {
110
+ setFullscreen(true)
111
+ setShowControls(true)
112
+ sessionStorage.setItem("preview-controls", "true")
113
+ }
114
+ } else {
115
+ setShowControls((s) => {
116
+ sessionStorage.setItem("preview-controls", String(!s))
117
+ return !s
118
+ })
119
+ }
120
+ }
121
+
122
+ function exitFullscreen() {
123
+ // Animate sidebar open + preview shrink simultaneously
124
+ if (!isMobile) {
125
+ setCollapsed(false)
126
+ // Set origin to where the inline preview will land (sidebar width = 16rem = 256px on md+)
127
+ setOriginRect((prev) => ({ ...prev, left: 256 }))
128
+ }
129
+ setFsVisible(false)
130
+ setCamera({ x: 0, y: 0, zoom: 1 })
131
+ setPosition({ x: 0, y: 0 })
132
+ sessionStorage.setItem("preview-fullscreen", "false")
133
+ // After animation, switch to relative
134
+ setTimeout(() => {
135
+ setFullscreen(false)
136
+ if (isMobile) {
137
+ setShowControls(false)
138
+ sessionStorage.setItem("preview-controls", "false")
139
+ }
140
+ }, 300)
141
+ }
142
+
143
+ return (
144
+ <>
145
+ {/* Inline placeholder — reserves space in the document flow when fullscreen */}
146
+ {fullscreen && (
147
+ <div ref={inlineRef} className="border-y border-border h-full" />
148
+ )}
149
+
150
+ {/* Single preview container — animates between inline and fullscreen */}
151
+ <div
152
+ ref={!fullscreen ? inlineRef : undefined}
153
+ className={`
154
+ bg-background border-y border-border overflow-hidden flex flex-col
155
+ ${fullscreen ? "fixed z-40 transition-[top,bottom,left,right] duration-300 ease-in-out motion-reduce:transition-none" : "relative h-full"}
156
+ `}
157
+ style={fullscreen ? {
158
+ top: fsVisible ? "3.5rem" : originRect.top,
159
+ bottom: fsVisible ? 0 : originRect.bottom,
160
+ left: fsVisible ? 0 : originRect.left,
161
+ right: fsVisible ? 0 : originRect.right,
162
+ } : undefined}
163
+ >
164
+ <div className="flex-1 min-h-0 relative">
165
+ <PreviewCanvas
166
+ showControls={showControls}
167
+ onToggleControls={hasControls ? toggleControls : undefined}
168
+ fullscreen={fullscreen}
169
+ onToggleFullscreen={fullscreen ? exitFullscreen : enterFullscreen}
170
+ camera={camera}
171
+ onCameraChange={setCamera}
172
+ position={position}
173
+ onPositionChange={setPosition}
174
+ >
175
+ {children}
176
+ </PreviewCanvas>
177
+
178
+ {/* Desktop props panel — floating card top-right */}
179
+ {hasControls && (
180
+ <aside
181
+ className={`
182
+ hidden md:flex flex-col absolute top-14 right-3 z-50 w-56 rounded-lg border border-border bg-background shadow-xl overflow-hidden transition-all duration-300 ease-in-out motion-reduce:transition-none
183
+ ${showControls ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-2 pointer-events-none"}
184
+ `}
185
+ style={{ maxHeight: fullscreen ? "calc(100vh - 10rem)" : "calc(100% - 4.5rem)" }}
186
+ >
187
+ <div className="shrink-0 px-4 pt-4 pb-3">
188
+ <p className="text-xs font-semibold text-foreground">
189
+ {t("controls.title")}
190
+ </p>
191
+ </div>
192
+ <div className="flex-1 min-h-0 overflow-y-auto px-4 pb-4">
193
+ <PreviewControls entries={controls} />
194
+ </div>
195
+ </aside>
196
+ )}
197
+ </div>
198
+
199
+ {/* Mobile props panel — inline, pushes preview up */}
200
+ {hasControls && (
201
+ <div
202
+ className="md:hidden shrink-0 border-t border-border bg-background transition-[max-height] duration-300 ease-in-out motion-reduce:transition-none overflow-hidden flex flex-col"
203
+ style={{ maxHeight: showControls ? "50vh" : 0 }}
204
+ >
205
+ <div className="shrink-0 px-4 pt-4 pb-3">
206
+ <p className="text-xs font-semibold text-foreground">
207
+ {t("controls.title")}
208
+ </p>
209
+ </div>
210
+ <div className="flex-1 min-h-0 overflow-y-auto px-4 pb-8">
211
+ <PreviewControls entries={controls} />
212
+ </div>
213
+ </div>
214
+ )}
215
+ </div>
216
+ </>
217
+ )
218
+ }