@westopp/windo 0.1.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 (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +146 -0
  3. package/dist/chunk-5RM2VYAM.js +150 -0
  4. package/dist/chunk-5RM2VYAM.js.map +1 -0
  5. package/dist/cli.cjs +303 -0
  6. package/dist/cli.cjs.map +1 -0
  7. package/dist/cli.d.cts +1 -0
  8. package/dist/cli.d.ts +1 -0
  9. package/dist/cli.js +138 -0
  10. package/dist/cli.js.map +1 -0
  11. package/dist/index.cjs +219 -0
  12. package/dist/index.cjs.map +1 -0
  13. package/dist/index.d.cts +374 -0
  14. package/dist/index.d.ts +374 -0
  15. package/dist/index.js +182 -0
  16. package/dist/index.js.map +1 -0
  17. package/dist/plugin.cjs +185 -0
  18. package/dist/plugin.cjs.map +1 -0
  19. package/dist/plugin.d.cts +11 -0
  20. package/dist/plugin.d.ts +11 -0
  21. package/dist/plugin.js +11 -0
  22. package/dist/plugin.js.map +1 -0
  23. package/package.json +95 -0
  24. package/src/cli/index.ts +160 -0
  25. package/src/client/App.tsx +310 -0
  26. package/src/client/Canvas.tsx +358 -0
  27. package/src/client/Inspector.tsx +586 -0
  28. package/src/client/Sidebar.tsx +108 -0
  29. package/src/client/bridge.ts +124 -0
  30. package/src/client/chrome.css +1966 -0
  31. package/src/client/icons.tsx +110 -0
  32. package/src/client/index.ts +15 -0
  33. package/src/client/internal-types.ts +147 -0
  34. package/src/client/persist.ts +38 -0
  35. package/src/define-config.ts +54 -0
  36. package/src/descriptor.test.ts +59 -0
  37. package/src/descriptor.ts +185 -0
  38. package/src/globals.d.ts +9 -0
  39. package/src/index.ts +54 -0
  40. package/src/plugin/index.ts +181 -0
  41. package/src/preview/ctx.ts +43 -0
  42. package/src/preview/index.ts +283 -0
  43. package/src/preview/preview.css +81 -0
  44. package/src/preview/registry.ts +159 -0
  45. package/src/preview/render.tsx +90 -0
  46. package/src/preview/virtual.d.ts +8 -0
  47. package/src/protocol.ts +59 -0
  48. package/src/types.ts +319 -0
@@ -0,0 +1,310 @@
1
+ // Workbench shell — topbar, sidebar, canvas, inspector, tweaks. Owns all global
2
+ // chrome state (persisted to localStorage), wires it to the iframe bridge, and
3
+ // composes the three panes. Ports the markup/class names from the mock app.jsx;
4
+ // the difference is that windo renders components in an IFRAME (driven through
5
+ // the bridge) instead of in-document.
6
+
7
+ import type { CSSProperties } from 'react'
8
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
9
+ import type { WindoEnvState } from '../types'
10
+ import { useWindoBridge } from './bridge'
11
+ import { Canvas } from './Canvas'
12
+ import { Inspector } from './Inspector'
13
+ import { Icons } from './icons'
14
+ import type { CanvasGridOpts, ChromeEnv, InspectorPosition, ThemeMode } from './internal-types'
15
+ import { CANVAS_GRID_DEFAULTS } from './internal-types'
16
+ import { loadJSON, loadString, saveJSON, saveString } from './persist'
17
+ import { Sidebar } from './Sidebar'
18
+
19
+ // The plugin injects this via `define` ('./__windo/iframe' in dev, where the
20
+ // middleware serves it routelessly; './__windo/iframe.html' in a build, where
21
+ // it's a real emitted document). Fall back to the dev path if it's absent.
22
+ const IFRAME_SRC = typeof __WINDO_IFRAME_SRC__ === 'string' ? __WINDO_IFRAME_SRC__ : './__windo/iframe'
23
+
24
+ const DEFAULT_ENV: ChromeEnv = {
25
+ colorScheme: 'light',
26
+ reducedMotion: false,
27
+ direction: 'ltr',
28
+ locale: 'en',
29
+ contexts: {},
30
+ }
31
+
32
+ function viewportName(width: number): 'mobile' | 'tablet' | 'desktop' {
33
+ return width < 640 ? 'mobile' : width < 1024 ? 'tablet' : 'desktop'
34
+ }
35
+
36
+ export function App() {
37
+ const iframeRef = useRef<HTMLIFrameElement | null>(null)
38
+ const bridge = useWindoBridge(iframeRef)
39
+
40
+ const [theme, setTheme] = useState<ThemeMode>(() => loadString('windo:theme', 'light') as ThemeMode)
41
+ const [selected, setSelected] = useState<string | null>(() => loadJSON<string | null>('windo:selected', null))
42
+ const [query, setQuery] = useState('')
43
+ const [navCollapsed, setNavCollapsed] = useState(() => loadJSON<boolean>('windo:nav-collapsed', false))
44
+ const [inspCollapsed, setInspCollapsed] = useState(() => loadJSON<boolean>('windo:insp-collapsed', false))
45
+ const inspectorPosition: InspectorPosition = 'right'
46
+ const accent = '#161618'
47
+ const [width, setWidth] = useState(() => loadJSON<number>('windo:width', 900))
48
+ const [height, setHeight] = useState<number | null>(() => loadJSON<number | null>('windo:height', null))
49
+ const [zoom, setZoom] = useState(() => loadJSON<number>('windo:zoom', 1))
50
+ const [grid, setGridState] = useState<CanvasGridOpts>(() => loadJSON<CanvasGridOpts>('windo:grid', CANVAS_GRID_DEFAULTS))
51
+ const [env, setEnv] = useState<ChromeEnv>(() => ({ ...loadJSON<ChromeEnv>('windo:env', DEFAULT_ENV), colorScheme: loadString('windo:theme', 'light') as ThemeMode }))
52
+
53
+ const [draftById, setDraftById] = useState<Record<string, string>>({})
54
+ // Last JSON pushed to the preview, per id. `dirty` = draft differs from this.
55
+ // Every push (seed, save, reset, variant, reload re-sync) updates it, so the
56
+ // flag tracks "the preview is stale vs the editor" rather than flipping on
57
+ // each keystroke.
58
+ const [savedById, setSavedById] = useState<Record<string, string>>({})
59
+
60
+ // Mirror the current draft in a ref so the reload re-sync can re-apply edited
61
+ // props without re-firing on every keystroke.
62
+ const draftRef = useRef(draftById)
63
+ draftRef.current = draftById
64
+
65
+ // Single push path: send props to the preview and record them as the saved
66
+ // baseline for the dirty check.
67
+ const pushProps = useCallback(
68
+ (id: string, json: string) => {
69
+ bridge.setProps(id, json)
70
+ setSavedById(prev => (prev[id] === json ? prev : { ...prev, [id]: json }))
71
+ },
72
+ [bridge.setProps]
73
+ )
74
+
75
+ // ── persistence ─────────────────────────────────────────────────────────
76
+ useEffect(() => {
77
+ document.documentElement.setAttribute('data-theme', theme)
78
+ saveString('windo:theme', theme)
79
+ }, [theme])
80
+ useEffect(() => saveJSON('windo:selected', selected), [selected])
81
+ useEffect(() => saveJSON('windo:nav-collapsed', navCollapsed), [navCollapsed])
82
+ useEffect(() => saveJSON('windo:insp-collapsed', inspCollapsed), [inspCollapsed])
83
+ useEffect(() => saveJSON('windo:width', width), [width])
84
+ useEffect(() => saveJSON('windo:height', height), [height])
85
+ useEffect(() => saveJSON('windo:zoom', zoom), [zoom])
86
+ useEffect(() => saveJSON('windo:grid', grid), [grid])
87
+ useEffect(() => saveJSON('windo:env', env), [env])
88
+
89
+ // ── selection wiring ───────────────────────────────────────────────────
90
+ useEffect(() => {
91
+ if (bridge.manifest.length && selected === null) setSelected(bridge.manifest[0].id)
92
+ }, [bridge.manifest, selected])
93
+
94
+ // Send select on selection change AND whenever the iframe (re)signals ready —
95
+ // Vite reloads the preview iframe on dep-optimize and on every HMR update, so
96
+ // we must re-push the selection (and the edited props below) each time.
97
+ useEffect(() => {
98
+ if (!selected) return
99
+ bridge.select(selected)
100
+ const json = draftRef.current[selected]
101
+ if (json !== undefined) pushProps(selected, json)
102
+ }, [selected, bridge.select, pushProps, bridge.readyNonce])
103
+
104
+ // Seed the Controls draft from the describe defaults the first time it arrives.
105
+ useEffect(() => {
106
+ const d = bridge.describe
107
+ if (!d || d.id !== selected || !selected) return
108
+ if (draftById[selected] !== undefined) return
109
+ const json = JSON.stringify(d.defaults ?? {}, null, 2)
110
+ setDraftById(prev => ({ ...prev, [selected]: json }))
111
+ pushProps(selected, json)
112
+ }, [bridge.describe, selected, draftById, pushProps])
113
+
114
+ // ── env → preview ────────────────────────────────────────────────────────
115
+ useEffect(() => {
116
+ const next: WindoEnvState = {
117
+ colorScheme: theme,
118
+ viewport: { width, height: height ?? 0, name: viewportName(width) },
119
+ reducedMotion: env.reducedMotion,
120
+ direction: env.direction,
121
+ locale: env.locale,
122
+ contexts: env.contexts,
123
+ }
124
+ bridge.setEnv(next)
125
+ }, [env, width, height, theme, bridge.setEnv, bridge.readyNonce])
126
+
127
+ // ── draft commit / variant ─────────────────────────────────────────────
128
+ function setValuesJson(json: string) {
129
+ if (!selected) return
130
+ const id = selected
131
+ setDraftById(prev => ({ ...prev, [id]: json }))
132
+ }
133
+
134
+ function onSave() {
135
+ if (!selected) return
136
+ const json = draftById[selected]
137
+ if (json !== undefined) pushProps(selected, json)
138
+ }
139
+
140
+ function onReset() {
141
+ if (!selected || !bridge.describe) return
142
+ const json = JSON.stringify(bridge.describe.defaults ?? {}, null, 2)
143
+ const id = selected
144
+ setDraftById(prev => ({ ...prev, [id]: json }))
145
+ pushProps(id, json)
146
+ }
147
+
148
+ function onApplyVariant(patch: Record<string, unknown>) {
149
+ if (!selected) return
150
+ let base: Record<string, unknown> = {}
151
+ const draft = draftById[selected]
152
+ try {
153
+ const parsed = draft === undefined ? bridge.describe?.defaults : JSON.parse(draft)
154
+ if (parsed && typeof parsed === 'object') base = parsed as Record<string, unknown>
155
+ } catch {
156
+ const fallback = bridge.describe?.defaults
157
+ if (fallback && typeof fallback === 'object') base = fallback as Record<string, unknown>
158
+ }
159
+ const json = JSON.stringify({ ...base, ...patch }, null, 2)
160
+ const id = selected
161
+ setDraftById(prev => ({ ...prev, [id]: json }))
162
+ pushProps(id, json)
163
+ }
164
+
165
+ // ── env editing ─────────────────────────────────────────────────────────
166
+ function setEnvPatch(patch: Partial<ChromeEnv>) {
167
+ setEnv(prev => ({ ...prev, ...patch }))
168
+ }
169
+
170
+ function setContextValue(name: string, key: string, value: unknown) {
171
+ setEnv(prev => ({
172
+ ...prev,
173
+ contexts: {
174
+ ...prev.contexts,
175
+ [name]: { ...(prev.contexts[name] ?? {}), [key]: value },
176
+ },
177
+ }))
178
+ }
179
+
180
+ function setGrid(patch: Partial<CanvasGridOpts> | null) {
181
+ if (patch === null) {
182
+ setGridState(CANVAS_GRID_DEFAULTS)
183
+ return
184
+ }
185
+ setGridState(prev => ({ ...prev, ...patch }))
186
+ }
187
+
188
+ function toggleNav() {
189
+ setNavCollapsed(c => !c)
190
+ }
191
+ function toggleInsp() {
192
+ setInspCollapsed(c => !c)
193
+ }
194
+
195
+ // ── derived ─────────────────────────────────────────────────────────────
196
+ const entry = useMemo(() => bridge.manifest.find(e => e.id === selected) ?? null, [bridge.manifest, selected])
197
+ const clickActions = useMemo(() => (entry?.actions ?? []).filter(a => a.on === 'click'), [entry])
198
+ const actionDisabled = selected ? (bridge.actionDisabled[selected] ?? {}) : {}
199
+ const stateForEntry = selected ? (bridge.stateValues[selected] ?? {}) : {}
200
+
201
+ function onAction(actionId: string) {
202
+ if (!selected) return
203
+ bridge.invokeAction(selected, actionId)
204
+ }
205
+
206
+ const contextsForEntry = useMemo(() => {
207
+ const uses = entry?.uses ?? []
208
+ return bridge.contexts.filter(c => c.ambient || uses.includes(c.name))
209
+ }, [bridge.contexts, entry])
210
+
211
+ const valuesJson = (selected ? draftById[selected] : undefined) ?? ''
212
+ const savedJson = (selected ? savedById[selected] : undefined) ?? ''
213
+ const dirty = valuesJson !== savedJson
214
+
215
+ // ── accent variables (mirror app.jsx) ────────────────────────────────────
216
+ const accentUi = theme === 'dark' ? `color-mix(in oklab, ${accent} 35%, white)` : accent
217
+ const accentVars = {
218
+ '--accent': accent,
219
+ '--accent-ui': accentUi,
220
+ '--accent-soft': `color-mix(in srgb, ${accentUi}${theme === 'dark' ? ' 14%' : ' 8%'}, transparent)`,
221
+ } as CSSProperties
222
+
223
+ const title = bridge.title || 'windo'
224
+ const inspectorRight = inspectorPosition === 'right'
225
+
226
+ const canvasNode = (
227
+ <Canvas
228
+ iframeRef={iframeRef}
229
+ iframeSrc={IFRAME_SRC}
230
+ theme={theme}
231
+ width={width}
232
+ setWidth={setWidth}
233
+ height={height}
234
+ setHeight={setHeight}
235
+ zoom={zoom}
236
+ setZoom={setZoom}
237
+ grid={grid}
238
+ setGrid={setGrid}
239
+ actions={clickActions}
240
+ actionDisabled={actionDisabled}
241
+ onAction={onAction}
242
+ />
243
+ )
244
+
245
+ const inspectorNode = (
246
+ <Inspector
247
+ entry={entry}
248
+ describe={bridge.describe}
249
+ iframeSrc={IFRAME_SRC}
250
+ valuesJson={valuesJson}
251
+ setValuesJson={setValuesJson}
252
+ dirty={dirty}
253
+ onSave={onSave}
254
+ onReset={onReset}
255
+ parseErrors={bridge.parseErrors}
256
+ renderError={bridge.renderError}
257
+ logs={bridge.logs}
258
+ clearLogs={bridge.clearLogs}
259
+ state={stateForEntry}
260
+ contexts={contextsForEntry}
261
+ env={env}
262
+ setEnv={setEnvPatch}
263
+ setContextValue={setContextValue}
264
+ onApplyVariant={onApplyVariant}
265
+ position={inspectorPosition}
266
+ collapsed={inspCollapsed}
267
+ onToggle={toggleInsp}
268
+ />
269
+ )
270
+
271
+ return (
272
+ <div className="wb-app" style={accentVars} data-screen-label="Workbench">
273
+ <header className="wb-topbar">
274
+ <span className="wb-logo">
275
+ <span className="wb-logo-mark">{title.charAt(0).toUpperCase()}</span>
276
+ {title}
277
+ <span className="wb-logo-sub">/ Workbench</span>
278
+ </span>
279
+ {entry && (
280
+ <span className="wb-crumb">
281
+ <span>{entry.group}</span>
282
+ <span className="sep">›</span>
283
+ <b>{entry.title}</b>
284
+ </span>
285
+ )}
286
+ <div className="wb-topbar-spacer" />
287
+ <button className="wb-iconbtn" type="button" title={theme === 'light' ? 'Switch to dark' : 'Switch to light'} onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
288
+ {theme === 'light' ? Icons.moon : Icons.sun}
289
+ </button>
290
+ </header>
291
+
292
+ <div className="wb-main">
293
+ <Sidebar groups={bridge.groups} manifest={bridge.manifest} selected={selected} onSelect={setSelected} query={query} setQuery={setQuery} collapsed={navCollapsed} onToggle={toggleNav} />
294
+ <div className="wb-center">
295
+ {inspectorRight ? (
296
+ <div className="wb-center-row">
297
+ {canvasNode}
298
+ {inspectorNode}
299
+ </div>
300
+ ) : (
301
+ <>
302
+ {canvasNode}
303
+ {inspectorNode}
304
+ </>
305
+ )}
306
+ </div>
307
+ </div>
308
+ </div>
309
+ )
310
+ }
@@ -0,0 +1,358 @@
1
+ // Canvas — resizable surface (width + height), zoom, grid/background settings,
2
+ // and the selected windo's `click` actions (out-of-band triggers for toasts,
3
+ // modals, loading states). The render surface is a transparent <iframe>; the
4
+ // cosmetic grid + background are painted on the wrapper behind it so they show
5
+ // through.
6
+
7
+ import type { CSSProperties, PointerEvent as ReactPointerEvent } from 'react'
8
+ import { useEffect, useRef, useState } from 'react'
9
+ import type { WindoActionMeta } from '../types'
10
+ import { Icons } from './icons'
11
+ import type { CanvasGridOpts, CanvasProps, ThemeMode } from './internal-types'
12
+
13
+ const PRESETS = [
14
+ { id: 'mobile', label: 'Mobile', width: 375 },
15
+ { id: 'tablet', label: 'Tablet', width: 768 },
16
+ { id: 'desktop', label: 'Desktop', width: 1160 },
17
+ ]
18
+
19
+ const MIN_WIDTH = 320
20
+ const MIN_HEIGHT = 280
21
+ const MAX_HEIGHT = 2400
22
+
23
+ function breakpointFor(w: number) {
24
+ if (w < 640) return 'mobile'
25
+ if (w < 1024) return 'tablet'
26
+ return 'desktop'
27
+ }
28
+
29
+ /* ---------- Canvas appearance settings ---------- */
30
+
31
+ function ColorField({ value, onPick, label }: { value: string; onPick: (c: string) => void; label: string }) {
32
+ return (
33
+ <label className="wb-colorinput" title="Pick a color">
34
+ <span className="well" style={{ background: value }} />
35
+ <span className="hex">{value}</span>
36
+ <input type="color" value={value} aria-label={label} onChange={e => onPick(e.target.value)} />
37
+ </label>
38
+ )
39
+ }
40
+
41
+ function CanvasSettings({ grid, setGrid, theme, onClose }: { grid: CanvasGridOpts; setGrid: CanvasProps['setGrid']; theme: ThemeMode; onClose: () => void }) {
42
+ useEffect(() => {
43
+ function onEsc(e: KeyboardEvent) {
44
+ if (e.key === 'Escape') onClose()
45
+ }
46
+ window.addEventListener('keydown', onEsc)
47
+ return () => window.removeEventListener('keydown', onEsc)
48
+ }, [onClose])
49
+
50
+ function modeRows(mode: ThemeMode) {
51
+ return (
52
+ <>
53
+ <div className="wb-pop-sect">
54
+ {mode} mode{theme === mode && <span className="live">· active</span>}
55
+ </div>
56
+ <div className="wb-pop-row">
57
+ <span className="lbl">Background</span>
58
+ <ColorField label={`${mode} background color`} value={grid[mode].bg} onPick={c => setGrid({ [mode]: { ...grid[mode], bg: c } })} />
59
+ </div>
60
+ <div className="wb-pop-row">
61
+ <span className="lbl">Grid color</span>
62
+ <ColorField label={`${mode} grid color`} value={grid[mode].dot} onPick={c => setGrid({ [mode]: { ...grid[mode], dot: c } })} />
63
+ </div>
64
+ </>
65
+ )
66
+ }
67
+
68
+ return (
69
+ <>
70
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: click-away backdrop, not a keyboard control */}
71
+ {/* biome-ignore lint/a11y/useKeyWithClickEvents: click-away backdrop, not a keyboard control */}
72
+ <div className="wb-pop-overlay" onClick={onClose} />
73
+ <div className="wb-pop" role="dialog" aria-label="Canvas settings">
74
+ <div className="wb-pop-title">Canvas settings</div>
75
+ <div className="wb-pop-row">
76
+ <span className="lbl">Grid style</span>
77
+ <div className="wb-seg" style={{ flex: '0 0 auto' }}>
78
+ {(['dots', 'lines'] as const).map(s => (
79
+ <button type="button" key={s} className={grid.style === s && grid.on ? 'on' : ''} onClick={() => setGrid({ style: s, on: true })}>
80
+ {s}
81
+ </button>
82
+ ))}
83
+ <button type="button" className={grid.on ? '' : 'on'} onClick={() => setGrid({ on: false })}>
84
+ off
85
+ </button>
86
+ </div>
87
+ </div>
88
+ <div className="wb-pop-row">
89
+ <span className="lbl">Density</span>
90
+ <input type="range" min="12" max="44" step="2" value={grid.dotSize} onChange={e => setGrid({ dotSize: parseInt(e.target.value, 10) })} />
91
+ <span className="val">{grid.dotSize}px</span>
92
+ </div>
93
+ <div className="wb-pop-row">
94
+ <span className="lbl">Opacity</span>
95
+ <input type="range" min="10" max="100" step="5" value={grid.gridOpacity} onChange={e => setGrid({ gridOpacity: parseInt(e.target.value, 10) })} />
96
+ <span className="val">{grid.gridOpacity}%</span>
97
+ </div>
98
+ {modeRows('light')}
99
+ {modeRows('dark')}
100
+ <div className="wb-pop-reset">
101
+ <button type="button" className="wb-reset" onClick={() => setGrid(null)}>
102
+ Reset canvas
103
+ </button>
104
+ </div>
105
+ </div>
106
+ </>
107
+ )
108
+ }
109
+
110
+ /* ---------- Resize handles ---------- */
111
+
112
+ function ResizeHandle({
113
+ side,
114
+ width,
115
+ setWidth,
116
+ maxWidth,
117
+ zoom,
118
+ onReset,
119
+ }: {
120
+ side: 'left' | 'right'
121
+ width: number
122
+ setWidth: (w: number) => void
123
+ maxWidth: number
124
+ zoom: number
125
+ onReset: () => void
126
+ }) {
127
+ const [dragging, setDragging] = useState(false)
128
+
129
+ function onPointerDown(e: ReactPointerEvent<HTMLDivElement>) {
130
+ e.preventDefault()
131
+ const el = e.currentTarget
132
+ const pointerId = e.pointerId
133
+ el.setPointerCapture(pointerId)
134
+ setDragging(true)
135
+ const startX = e.clientX
136
+ const startW = width
137
+
138
+ function onMove(ev: PointerEvent) {
139
+ const delta = (ev.clientX - startX) / zoom
140
+ const signed = side === 'right' ? delta : -delta
141
+ const next = Math.max(MIN_WIDTH, Math.min(maxWidth, startW + signed * 2))
142
+ setWidth(next)
143
+ }
144
+ function onUp() {
145
+ try {
146
+ el.releasePointerCapture(pointerId)
147
+ } catch {
148
+ /* already released */
149
+ }
150
+ setDragging(false)
151
+ window.removeEventListener('pointermove', onMove)
152
+ window.removeEventListener('pointerup', onUp)
153
+ }
154
+ window.addEventListener('pointermove', onMove)
155
+ window.addEventListener('pointerup', onUp)
156
+ }
157
+
158
+ // biome-ignore lint/a11y/noStaticElementInteractions: drag handle is pointer-only by design
159
+ return <div className={`wb-handle${dragging ? ' dragging' : ''}`} title="Drag to resize width — double-click to fill" onDoubleClick={onReset} onPointerDown={onPointerDown} />
160
+ }
161
+
162
+ function HeightHandle({ height, setHeight, zoom, onReset }: { height: number; setHeight: (h: number) => void; zoom: number; onReset: () => void }) {
163
+ const [dragging, setDragging] = useState(false)
164
+
165
+ function onPointerDown(e: ReactPointerEvent<HTMLDivElement>) {
166
+ e.preventDefault()
167
+ const el = e.currentTarget
168
+ const pointerId = e.pointerId
169
+ el.setPointerCapture(pointerId)
170
+ setDragging(true)
171
+ const startY = e.clientY
172
+ const startH = height
173
+
174
+ function onMove(ev: PointerEvent) {
175
+ const delta = (ev.clientY - startY) / zoom
176
+ setHeight(Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, startH + delta)))
177
+ }
178
+ function onUp() {
179
+ try {
180
+ el.releasePointerCapture(pointerId)
181
+ } catch {
182
+ /* already released */
183
+ }
184
+ setDragging(false)
185
+ window.removeEventListener('pointermove', onMove)
186
+ window.removeEventListener('pointerup', onUp)
187
+ }
188
+ window.addEventListener('pointermove', onMove)
189
+ window.addEventListener('pointerup', onUp)
190
+ }
191
+
192
+ // biome-ignore lint/a11y/noStaticElementInteractions: drag handle is pointer-only by design
193
+ return <div className={`wb-handle-h${dragging ? ' dragging' : ''}`} title="Drag to resize height — double-click to fit" onDoubleClick={onReset} onPointerDown={onPointerDown} />
194
+ }
195
+
196
+ /* ---------- Toolbar ---------- */
197
+
198
+ function CanvasToolbar({
199
+ width,
200
+ setWidth,
201
+ maxWidth,
202
+ zoom,
203
+ setZoom,
204
+ grid,
205
+ setGrid,
206
+ theme,
207
+ settingsOpen,
208
+ setSettingsOpen,
209
+ actions,
210
+ actionDisabled,
211
+ onAction,
212
+ }: {
213
+ width: number
214
+ setWidth: (w: number) => void
215
+ maxWidth: number
216
+ zoom: number
217
+ setZoom: (z: number) => void
218
+ grid: CanvasGridOpts
219
+ setGrid: CanvasProps['setGrid']
220
+ theme: ThemeMode
221
+ settingsOpen: boolean
222
+ setSettingsOpen: (open: boolean) => void
223
+ actions: WindoActionMeta[]
224
+ actionDisabled: Record<string, boolean>
225
+ onAction: (actionId: string) => void
226
+ }) {
227
+ return (
228
+ <div className="wb-toolbar">
229
+ {/* biome-ignore lint/a11y/useSemanticElements: toolbar grouping div; semantic element not warranted here */}
230
+ <div className="wb-seg" role="group" aria-label="Width presets">
231
+ {PRESETS.map(p => (
232
+ <button type="button" key={p.id} className={Math.abs(width - p.width) < 2 ? 'on' : ''} onClick={() => setWidth(Math.min(p.width, maxWidth))}>
233
+ {p.label}
234
+ </button>
235
+ ))}
236
+ <button type="button" className={width >= maxWidth - 2 ? 'on' : ''} onClick={() => setWidth(maxWidth)}>
237
+ Fill
238
+ </button>
239
+ </div>
240
+ {actions.map(a => (
241
+ <button type="button" key={a.id} className="wb-action" disabled={actionDisabled[a.id] ?? false} onClick={() => onAction(a.id)}>
242
+ {a.label}
243
+ </button>
244
+ ))}
245
+ <div className="gap" />
246
+ <button type="button" className={`wb-iconbtn${grid.on ? ' active' : ''}`} title={grid.on ? 'Hide grid' : 'Show grid'} onClick={() => setGrid({ on: !grid.on })}>
247
+ {Icons.grid}
248
+ </button>
249
+ <div className="wb-zoom">
250
+ <button type="button" className="wb-iconbtn" title="Zoom out" onClick={() => setZoom(Math.max(0.5, Math.round((zoom - 0.1) * 10) / 10))}>
251
+ {Icons.zoomOut}
252
+ </button>
253
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: pointer-only zoom-reset affordance */}
254
+ {/* biome-ignore lint/a11y/useKeyWithClickEvents: pointer-only zoom-reset affordance */}
255
+ <span className="pct" title="Reset zoom" onClick={() => setZoom(1)}>
256
+ {Math.round(zoom * 100)}%
257
+ </span>
258
+ <button type="button" className="wb-iconbtn" title="Zoom in" onClick={() => setZoom(Math.min(1.5, Math.round((zoom + 0.1) * 10) / 10))}>
259
+ {Icons.zoomIn}
260
+ </button>
261
+ </div>
262
+ <button type="button" className={`wb-iconbtn${settingsOpen ? ' active' : ''}`} title="Canvas settings" onClick={() => setSettingsOpen(!settingsOpen)}>
263
+ {Icons.settings}
264
+ </button>
265
+ {settingsOpen && <CanvasSettings grid={grid} setGrid={setGrid} theme={theme} onClose={() => setSettingsOpen(false)} />}
266
+ </div>
267
+ )
268
+ }
269
+
270
+ /* ---------- Canvas ---------- */
271
+
272
+ export function Canvas(props: CanvasProps) {
273
+ const { iframeRef, iframeSrc, theme, width, setWidth, height, setHeight, zoom, setZoom, grid, setGrid, actions, actionDisabled, onAction } = props
274
+
275
+ const stageRef = useRef<HTMLDivElement>(null)
276
+ const [maxWidth, setMaxWidth] = useState(1160)
277
+ const [availH, setAvailH] = useState(620)
278
+ const [settingsOpen, setSettingsOpen] = useState(false)
279
+
280
+ useEffect(() => {
281
+ const stage = stageRef.current
282
+ if (!stage) return
283
+ function measure() {
284
+ if (!stage) return
285
+ setMaxWidth(Math.max(360, stage.clientWidth - 92))
286
+ setAvailH(Math.max(320, stage.clientHeight - 76))
287
+ }
288
+ measure()
289
+ const ro = new ResizeObserver(measure)
290
+ ro.observe(stage)
291
+ return () => ro.disconnect()
292
+ }, [])
293
+
294
+ const clamped = Math.min(width, maxWidth)
295
+ const frameH = height ? Math.max(MIN_HEIGHT, height) : availH
296
+ const mode = theme === 'dark' ? grid.dark : grid.light
297
+ const bp = breakpointFor(clamped)
298
+
299
+ const gridClass = grid.on ? (grid.style === 'lines' ? ' grid-lines' : ' grid-dots') : ''
300
+
301
+ const frameStyle = {
302
+ width: clamped,
303
+ height: frameH,
304
+ '--cv-bg': mode.bg,
305
+ '--cv-dot': `color-mix(in srgb, ${mode.dot} ${grid.gridOpacity}%, transparent)`,
306
+ '--dot-size': `${grid.dotSize}px`,
307
+ } as CSSProperties
308
+
309
+ return (
310
+ <div className="wb-canvas-col">
311
+ <CanvasToolbar
312
+ width={clamped}
313
+ setWidth={setWidth}
314
+ maxWidth={maxWidth}
315
+ zoom={zoom}
316
+ setZoom={setZoom}
317
+ grid={grid}
318
+ setGrid={setGrid}
319
+ theme={theme}
320
+ settingsOpen={settingsOpen}
321
+ setSettingsOpen={setSettingsOpen}
322
+ actions={actions}
323
+ actionDisabled={actionDisabled}
324
+ onAction={onAction}
325
+ />
326
+ <div className="wb-stage" ref={stageRef}>
327
+ <div className="wb-zoomwrap" style={{ transform: `scale(${zoom})` }}>
328
+ <div className="wb-frame-col">
329
+ <div className="wb-frame-row">
330
+ <ResizeHandle side="left" width={clamped} setWidth={setWidth} maxWidth={maxWidth} zoom={zoom} onReset={() => setWidth(maxWidth)} />
331
+ <div className="wb-framewrap">
332
+ <span className="wb-corner tl" />
333
+ <span className="wb-corner tr" />
334
+ <span className="wb-corner bl" />
335
+ <span className="wb-corner br" />
336
+ <div className={`wb-frame${gridClass}`} style={frameStyle}>
337
+ <div className="wb-frame-inner">
338
+ <iframe
339
+ ref={iframeRef}
340
+ src={iframeSrc}
341
+ title="windo preview"
342
+ style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', background: 'transparent', border: 0, display: 'block' }}
343
+ />
344
+ </div>
345
+ </div>
346
+ </div>
347
+ <ResizeHandle side="right" width={clamped} setWidth={setWidth} maxWidth={maxWidth} zoom={zoom} onReset={() => setWidth(maxWidth)} />
348
+ </div>
349
+ <HeightHandle height={frameH} setHeight={setHeight} zoom={zoom} onReset={() => setHeight(null)} />
350
+ <div className="wb-dim-readout">
351
+ {Math.round(clamped)} × {Math.round(frameH)} px · <span className="bp">{bp}</span>
352
+ </div>
353
+ </div>
354
+ </div>
355
+ </div>
356
+ </div>
357
+ )
358
+ }