@jogak/ui 0.1.0-alpha.1 → 0.1.0-alpha.10.3

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.
@@ -0,0 +1,767 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import type { ReactElement, CSSProperties } from 'react'
3
+ import clsx from 'clsx'
4
+ import { Highlight, themes } from 'prism-react-renderer'
5
+ import type { PrismTheme } from 'prism-react-renderer'
6
+ import { reactAdapter, useEntry } from '@jogak/core/renderers/react'
7
+ import type { UseEntryState } from '@jogak/core/renderers/react'
8
+ import type { RegistryEntry, RegistryEntryMeta, ArgType } from '@jogak/core'
9
+ import { Controls } from '../Controls/index.js'
10
+ import { Actions } from '../Actions/index.js'
11
+ import { ShadowMount } from './ShadowMount.js'
12
+ import { IframeMount } from './IframeMount.js'
13
+ import { formatUsageCode } from './format-usage.js'
14
+
15
+ export interface PreviewProps {
16
+ readonly entryId: string
17
+ readonly jogakName: string | null
18
+ readonly overrideArgs: Readonly<Record<string, unknown>>
19
+ readonly onArgChange: (key: string, value: unknown) => void
20
+ readonly onReset: () => void
21
+ readonly codeTheme: string
22
+ /**
23
+ * URL deep link `?entry=<id>` (jogak 미지정) 케이스에서 entry hydrate 후
24
+ * 첫 jogak로 자동 보정하기 위한 콜백. 부모가 selectedJogakName / URL을 갱신.
25
+ */
26
+ readonly onResolveJogak?: (entryId: string, jogakName: string) => void
27
+ /**
28
+ * 알파.8: Preview 영역 격리 모드. default `'iframe'`.
29
+ *
30
+ * - `'iframe'` (default) — 사용자 vite scope에 마운트. 사용자 utility 정상 컴파일.
31
+ * - `'shadow'` (deprecated) — ShadowRoot에 마운트. 사용자 utility 미적용.
32
+ * - `'none'` (deprecated) — chrome과 같은 document에 렌더.
33
+ */
34
+ readonly previewIsolation?: 'none' | 'shadow' | 'iframe'
35
+ /**
36
+ * 알파.9: 어댑터 dev URL. iframe `src` base.
37
+ * 빈 문자열 시 fallback (jogak SPA Vite scope의 `/preview-frame.html`).
38
+ */
39
+ readonly userPreviewUrl?: string
40
+ /**
41
+ * 알파.9: iframe entry path.
42
+ */
43
+ readonly previewEntryPath?: string
44
+ }
45
+
46
+ type ViewportKey = 'mobile' | 'tablet' | 'desktop'
47
+ type BgMode = 'white' | 'dark' | 'transparent'
48
+
49
+ /**
50
+ * dynamic style + CSS variable 주입을 위한 React `CSSProperties` 확장 타입
51
+ * (api-contracts 알파.5 PR 2 §6.1).
52
+ */
53
+ type CSSVarStyle = CSSProperties & Record<`--${string}`, string | number>
54
+
55
+ const VIEWPORT_WIDTHS: Record<ViewportKey, number | 'none'> = {
56
+ mobile: 375,
57
+ tablet: 768,
58
+ desktop: 'none',
59
+ }
60
+
61
+ const VIEWPORT_LABELS: Record<ViewportKey, string> = {
62
+ mobile: 'Mobile',
63
+ tablet: 'Tablet',
64
+ desktop: 'Desktop',
65
+ }
66
+
67
+ /**
68
+ * bgMode별 캔버스 background 표현 — 4개 longhand CSS variable로 분해.
69
+ *
70
+ * v4 background shorthand arbitrary value(`bg-[...]`)는 ambiguous 하므로
71
+ * `bg-[image:...]`, `bg-[length:...]`, `bg-[position:...]` longhand hint를 사용해야 한다.
72
+ * 따라서 `BG_STYLES` (CSSProperties spread)를 폐기하고 mode별 변수 묶음만 정의한다
73
+ * (api-contracts 알파.5 PR 2 §3.2 결정 B).
74
+ */
75
+ const BG_VARS: Record<BgMode, CSSVarStyle> = {
76
+ white: {
77
+ '--jogak-canvas-bg': '#ffffff',
78
+ '--jogak-canvas-bg-image': 'none',
79
+ '--jogak-canvas-bg-size': 'auto',
80
+ '--jogak-canvas-bg-position': '0 0',
81
+ },
82
+ dark: {
83
+ '--jogak-canvas-bg': '#1f2937',
84
+ '--jogak-canvas-bg-image': 'none',
85
+ '--jogak-canvas-bg-size': 'auto',
86
+ '--jogak-canvas-bg-position': '0 0',
87
+ },
88
+ transparent: {
89
+ '--jogak-canvas-bg': '#ffffff',
90
+ '--jogak-canvas-bg-image':
91
+ 'linear-gradient(45deg, #e2e8f0 25%, transparent 25%), ' +
92
+ 'linear-gradient(-45deg, #e2e8f0 25%, transparent 25%), ' +
93
+ 'linear-gradient(45deg, transparent 75%, #e2e8f0 75%), ' +
94
+ 'linear-gradient(-45deg, transparent 75%, #e2e8f0 75%)',
95
+ '--jogak-canvas-bg-size': '16px 16px',
96
+ '--jogak-canvas-bg-position': '0 0, 0 8px, 8px -8px, -8px 0px',
97
+ },
98
+ }
99
+
100
+ /** 캔버스/미니버튼 공통 — 모드 무관. BG_VARS 가 변수 값을 mode별로 swap. */
101
+ const CANVAS_BG_CLASS =
102
+ 'jogak:bg-[var(--jogak-canvas-bg)] ' +
103
+ 'jogak:bg-[image:var(--jogak-canvas-bg-image)] ' +
104
+ 'jogak:bg-[length:var(--jogak-canvas-bg-size)] ' +
105
+ 'jogak:bg-[position:var(--jogak-canvas-bg-position)]'
106
+
107
+ /** 캔버스 영역 minHeight — loading/ready 사이 layout shift 방지 (계약 §10). */
108
+ const CANVAS_MIN_HEIGHT = 320
109
+
110
+ function resolvePrismTheme(name: string): PrismTheme {
111
+ const map = themes as Record<string, PrismTheme | undefined>
112
+ return map[name] ?? themes.vsDark
113
+ }
114
+
115
+ /**
116
+ * Preview — `useEntry(entryId)`의 status에 따라 분기 (계약 §5.4).
117
+ *
118
+ * - `loading` → 메타로 헤더(title, jogak 이름)만 표시, 캔버스에 skeleton
119
+ * - `ready` → 현행 렌더 (entry.jogaks/component 사용)
120
+ * - `error` → 에러 패널
121
+ * - `unknown` → "Entry not found" placeholder
122
+ *
123
+ * Layout shift 방지를 위해 캔버스 영역 minHeight 유지.
124
+ */
125
+ export function Preview({
126
+ entryId,
127
+ jogakName,
128
+ overrideArgs,
129
+ onArgChange,
130
+ onReset,
131
+ codeTheme,
132
+ onResolveJogak,
133
+ previewIsolation = 'iframe',
134
+ userPreviewUrl = '',
135
+ previewEntryPath = '/__jogak_preview__/index.html',
136
+ }: PreviewProps): ReactElement {
137
+ const state = useEntry(entryId)
138
+ const [viewport, setViewport] = useState<ViewportKey>('desktop')
139
+ const [bgMode, setBgMode] = useState<BgMode>('white')
140
+ const [bottomTab, setBottomTab] = useState<'controls' | 'actions'>('controls')
141
+
142
+ const prismTheme = resolvePrismTheme(codeTheme)
143
+
144
+ // ── unknown ───────────────────────────────────────────────
145
+ if (state.status === 'unknown') {
146
+ return (
147
+ <div
148
+ data-testid="preview-not-found"
149
+ className="jogak:p-6 jogak:text-[var(--jogak-color-error)]"
150
+ >
151
+ Entry not found: {entryId}
152
+ </div>
153
+ )
154
+ }
155
+
156
+ // ── error ─────────────────────────────────────────────────
157
+ if (state.status === 'error') {
158
+ return (
159
+ <div
160
+ data-testid="preview-error"
161
+ className="jogak:p-6 jogak:text-[var(--jogak-color-error-fg)] jogak:bg-[var(--jogak-color-bg-error)] jogak:h-full jogak:flex jogak:flex-col jogak:gap-3 jogak:items-start"
162
+ >
163
+ <div className="jogak:font-semibold">Failed to load entry: {entryId}</div>
164
+ <pre className="jogak:m-0 jogak:p-3 jogak:bg-[var(--jogak-color-bg)] jogak:border jogak:border-[var(--jogak-color-error-border)] jogak:rounded-[var(--jogak-radius-lg)] jogak:text-[12px] jogak:whitespace-pre-wrap jogak:max-w-full">
165
+ {state.error.message}
166
+ </pre>
167
+ </div>
168
+ )
169
+ }
170
+
171
+ // ── loading ───────────────────────────────────────────────
172
+ if (state.status === 'loading') {
173
+ return (
174
+ <LoadingFrame
175
+ meta={state.meta}
176
+ jogakName={jogakName}
177
+ viewport={viewport}
178
+ bgMode={bgMode}
179
+ onViewportChange={setViewport}
180
+ onBgModeChange={setBgMode}
181
+ />
182
+ )
183
+ }
184
+
185
+ // ── ready ─────────────────────────────────────────────────
186
+ return (
187
+ <ReadyFrame
188
+ entry={state.entry}
189
+ jogakName={jogakName}
190
+ overrideArgs={overrideArgs}
191
+ onArgChange={onArgChange}
192
+ onReset={onReset}
193
+ onResolveJogak={onResolveJogak}
194
+ viewport={viewport}
195
+ bgMode={bgMode}
196
+ bottomTab={bottomTab}
197
+ onViewportChange={setViewport}
198
+ onBgModeChange={setBgMode}
199
+ onBottomTabChange={setBottomTab}
200
+ prismTheme={prismTheme}
201
+ previewIsolation={previewIsolation}
202
+ userPreviewUrl={userPreviewUrl}
203
+ previewEntryPath={previewEntryPath}
204
+ />
205
+ )
206
+ }
207
+
208
+ // ── LoadingFrame ──────────────────────────────────────────
209
+
210
+ interface LoadingFrameProps {
211
+ readonly meta: RegistryEntryMeta
212
+ readonly jogakName: string | null
213
+ readonly viewport: ViewportKey
214
+ readonly bgMode: BgMode
215
+ readonly onViewportChange: (vp: ViewportKey) => void
216
+ readonly onBgModeChange: (bg: BgMode) => void
217
+ }
218
+
219
+ function LoadingFrame({
220
+ meta,
221
+ jogakName,
222
+ viewport,
223
+ bgMode,
224
+ onViewportChange,
225
+ onBgModeChange,
226
+ }: LoadingFrameProps): ReactElement {
227
+ const displayJogak = jogakName ?? meta.jogakNames[0] ?? '...'
228
+ const maxWidth = VIEWPORT_WIDTHS[viewport]
229
+
230
+ return (
231
+ <div
232
+ data-testid="preview-loading"
233
+ className="jogak:flex jogak:flex-col jogak:h-full"
234
+ >
235
+ <Toolbar
236
+ title={meta.title}
237
+ jogakName={displayJogak}
238
+ viewport={viewport}
239
+ bgMode={bgMode}
240
+ onViewportChange={onViewportChange}
241
+ onBgModeChange={onBgModeChange}
242
+ showReset={false}
243
+ onReset={() => {}}
244
+ />
245
+ <div
246
+ className={`jogak:flex-1 jogak:overflow-auto jogak:min-h-[320px] ${CANVAS_BG_CLASS}`}
247
+ // eslint-disable-next-line no-restricted-syntax -- jogak: BG_VARS object inject
248
+ style={BG_VARS[bgMode]}
249
+ >
250
+ <div
251
+ className="jogak:mx-auto jogak:p-6 jogak:max-w-[var(--jogak-canvas-mw)]"
252
+ // eslint-disable-next-line no-restricted-syntax -- jogak: canvas-mw CSS var
253
+ style={
254
+ {
255
+ '--jogak-canvas-mw': maxWidth === 'none' ? '100%' : `${maxWidth}px`,
256
+ } as CSSVarStyle
257
+ }
258
+ >
259
+ {/*
260
+ * skeleton box — 알파.5 PR 4 마이그레이션: gradient + keyframe animation 을
261
+ * jogak.css `@layer components` 의 `.jogak-skeleton-shimmer` class 로 이동
262
+ * (api-contracts §6). inline `style={{...}}` 객체 + inline `<style>` 태그
263
+ * 동시 제거. 정적 부분(border / radius / padding / flex / color / fontSize /
264
+ * minHeight)은 jogak: utility 그대로 유지.
265
+ */}
266
+ <div className="jogak-skeleton-shimmer jogak:border jogak:border-dashed jogak:border-[var(--jogak-color-border)] jogak:rounded-[var(--jogak-radius-xl)] jogak:p-4 jogak:flex jogak:items-center jogak:justify-center jogak:text-[var(--jogak-color-fg-subtle)] jogak:text-[13px] jogak:min-h-[256px]">
267
+ Loading {meta.title}…
268
+ </div>
269
+ </div>
270
+ </div>
271
+ </div>
272
+ )
273
+ }
274
+
275
+ // ── ReadyFrame ────────────────────────────────────────────
276
+
277
+ interface ReadyFrameProps {
278
+ readonly entry: RegistryEntry
279
+ readonly jogakName: string | null
280
+ readonly overrideArgs: Readonly<Record<string, unknown>>
281
+ readonly onArgChange: (key: string, value: unknown) => void
282
+ readonly onReset: () => void
283
+ readonly onResolveJogak: ((entryId: string, jogakName: string) => void) | undefined
284
+ readonly viewport: ViewportKey
285
+ readonly bgMode: BgMode
286
+ readonly bottomTab: 'controls' | 'actions'
287
+ readonly onViewportChange: (vp: ViewportKey) => void
288
+ readonly onBgModeChange: (bg: BgMode) => void
289
+ readonly onBottomTabChange: (tab: 'controls' | 'actions') => void
290
+ readonly prismTheme: PrismTheme
291
+ readonly previewIsolation: 'none' | 'shadow' | 'iframe'
292
+ readonly userPreviewUrl: string
293
+ readonly previewEntryPath: string
294
+ }
295
+
296
+ function ReadyFrame({
297
+ entry,
298
+ jogakName,
299
+ overrideArgs,
300
+ onArgChange,
301
+ onReset,
302
+ onResolveJogak,
303
+ viewport,
304
+ bgMode,
305
+ bottomTab,
306
+ onViewportChange,
307
+ onBgModeChange,
308
+ onBottomTabChange,
309
+ prismTheme,
310
+ previewIsolation,
311
+ userPreviewUrl,
312
+ previewEntryPath,
313
+ }: ReadyFrameProps): ReactElement {
314
+ // jogakName이 비어있으면 (deep link `?entry=...&jogak` 누락) 첫 jogak로 보정.
315
+ const resolvedJogakName = jogakName ?? entry.jogaks[0]?.name ?? null
316
+
317
+ useEffect(() => {
318
+ if (jogakName === null && resolvedJogakName !== null && onResolveJogak !== undefined) {
319
+ onResolveJogak(entry.id, resolvedJogakName)
320
+ }
321
+ }, [jogakName, resolvedJogakName, entry.id, onResolveJogak])
322
+
323
+ if (resolvedJogakName === null) {
324
+ return (
325
+ <div className="jogak:p-6 jogak:text-[var(--jogak-color-error)]">
326
+ Entry has no jogaks: {entry.id}
327
+ </div>
328
+ )
329
+ }
330
+
331
+ const jogak = entry.jogaks.find((j) => j.name === resolvedJogakName)
332
+ if (jogak === undefined) {
333
+ return (
334
+ <div className="jogak:p-6 jogak:text-[var(--jogak-color-error)]">
335
+ Jogak not found: {resolvedJogakName}
336
+ </div>
337
+ )
338
+ }
339
+
340
+ const baseArgs = jogak.args ?? {}
341
+ const mergedArgs = { ...baseArgs, ...overrideArgs }
342
+ const mergedArgTypes: Readonly<Record<string, ArgType>> = {
343
+ ...(entry.meta.argTypes ?? {}),
344
+ ...(jogak.argTypes ?? {}),
345
+ }
346
+ const hasOverrides = Object.keys(overrideArgs).length > 0
347
+ const maxWidth = VIEWPORT_WIDTHS[viewport]
348
+
349
+ return (
350
+ <div className="jogak:flex jogak:flex-col jogak:h-full">
351
+ <Toolbar
352
+ title={entry.title}
353
+ jogakName={jogak.name}
354
+ viewport={viewport}
355
+ bgMode={bgMode}
356
+ onViewportChange={onViewportChange}
357
+ onBgModeChange={onBgModeChange}
358
+ showReset={hasOverrides}
359
+ onReset={onReset}
360
+ />
361
+
362
+ {/* ── 캔버스 ───────────────────────────────────────── */}
363
+ <div
364
+ className={`jogak:flex-1 jogak:overflow-auto jogak:min-h-[320px] ${CANVAS_BG_CLASS}`}
365
+ // eslint-disable-next-line no-restricted-syntax -- jogak: BG_VARS object inject
366
+ style={BG_VARS[bgMode]}
367
+ >
368
+ <div
369
+ data-jogak-content
370
+ className="jogak:mx-auto jogak:p-6 jogak:max-w-[var(--jogak-canvas-mw)]"
371
+ // eslint-disable-next-line no-restricted-syntax -- jogak: canvas-mw CSS var
372
+ style={
373
+ {
374
+ '--jogak-canvas-mw': maxWidth === 'none' ? '100%' : `${maxWidth}px`,
375
+ } as CSSVarStyle
376
+ }
377
+ >
378
+ <JogakRenderer
379
+ key={`${entry.id}/${jogak.name}`}
380
+ entry={entry}
381
+ args={mergedArgs}
382
+ theme={prismTheme}
383
+ previewIsolation={previewIsolation}
384
+ userPreviewUrl={userPreviewUrl}
385
+ previewEntryPath={previewEntryPath}
386
+ />
387
+ </div>
388
+ </div>
389
+
390
+ {/* ── 컨트롤/액션 패널 ──────────────────────────────── */}
391
+ <div
392
+ data-testid="bottom-panel"
393
+ className="jogak:h-[260px] jogak:shrink-0 jogak:flex jogak:flex-col jogak:border-t-2 jogak:border-[var(--jogak-color-border)]"
394
+ >
395
+ <div
396
+ role="tablist"
397
+ className="jogak:flex jogak:gap-1 jogak:pt-1 jogak:px-3 jogak:pb-0 jogak:bg-[var(--jogak-color-bg)] jogak:border-b jogak:border-[var(--jogak-color-border)] jogak:shrink-0"
398
+ >
399
+ {(['controls', 'actions'] as const).map((tab) => {
400
+ const active = bottomTab === tab
401
+ return (
402
+ <button
403
+ key={tab}
404
+ type="button"
405
+ role="tab"
406
+ aria-selected={active}
407
+ onClick={() => { onBottomTabChange(tab) }}
408
+ className={clsx(
409
+ 'jogak:px-[14px] jogak:py-[6px] jogak:text-[12px] jogak:bg-transparent jogak:border-x-0 jogak:border-t-0 jogak:border-b-2 jogak:border-solid jogak:-mb-px jogak:cursor-pointer jogak:capitalize',
410
+ active
411
+ ? 'jogak:font-semibold jogak:text-[var(--jogak-color-fg-strong)] jogak:border-[var(--jogak-color-accent)]'
412
+ : 'jogak:font-medium jogak:text-[var(--jogak-color-fg-muted)] jogak:border-transparent',
413
+ )}
414
+ >
415
+ {tab}
416
+ </button>
417
+ )
418
+ })}
419
+ </div>
420
+
421
+ <div className="jogak:flex-1 jogak:min-h-0 jogak:overflow-auto">
422
+ {bottomTab === 'controls' ? (
423
+ <Controls
424
+ args={mergedArgs}
425
+ argTypes={mergedArgTypes}
426
+ onArgChange={onArgChange}
427
+ />
428
+ ) : (
429
+ <Actions />
430
+ )}
431
+ </div>
432
+ </div>
433
+ </div>
434
+ )
435
+ }
436
+
437
+ // ── Toolbar (loading / ready 공용) ─────────────────────────
438
+
439
+ interface ToolbarProps {
440
+ readonly title: string
441
+ readonly jogakName: string
442
+ readonly viewport: ViewportKey
443
+ readonly bgMode: BgMode
444
+ readonly onViewportChange: (vp: ViewportKey) => void
445
+ readonly onBgModeChange: (bg: BgMode) => void
446
+ readonly showReset: boolean
447
+ readonly onReset: () => void
448
+ }
449
+
450
+ function Toolbar({
451
+ title,
452
+ jogakName,
453
+ viewport,
454
+ bgMode,
455
+ onViewportChange,
456
+ onBgModeChange,
457
+ showReset,
458
+ onReset,
459
+ }: ToolbarProps): ReactElement {
460
+ return (
461
+ <div className="jogak:flex jogak:items-center jogak:gap-[10px] jogak:px-[14px] jogak:py-[7px] jogak:border-b jogak:border-[var(--jogak-color-border)] jogak:bg-[var(--jogak-color-bg)] jogak:shrink-0">
462
+ <div className="jogak:flex-1 jogak:text-[13px]">
463
+ <span className="jogak:text-[var(--jogak-color-fg-subtle)]">{title}</span>
464
+ <span className="jogak:text-[var(--jogak-color-border-strong)] jogak:mx-1.5 jogak:leading-none">
465
+ /
466
+ </span>
467
+ <span className="jogak:text-[var(--jogak-color-fg-strong)] jogak:font-semibold">
468
+ {jogakName}
469
+ </span>
470
+ </div>
471
+
472
+ {/* 뷰포트 토글 */}
473
+ <div className="jogak:flex jogak:gap-0.5 jogak:bg-[var(--jogak-color-bg-subtle)] jogak:rounded-[var(--jogak-radius-lg)] jogak:p-0.5">
474
+ {(['mobile', 'tablet', 'desktop'] as const).map((vp) => (
475
+ <button
476
+ key={vp}
477
+ type="button"
478
+ onClick={() => { onViewportChange(vp) }}
479
+ aria-pressed={viewport === vp}
480
+ className={clsx(
481
+ 'jogak:px-[9px] jogak:py-[3px] jogak:text-[12px] jogak:border-none jogak:rounded-[var(--jogak-radius-md)] jogak:cursor-pointer jogak:transition-all jogak:duration-100',
482
+ viewport === vp
483
+ ? 'jogak:bg-[var(--jogak-color-bg-elevated)] jogak:text-[var(--jogak-color-fg-strong)] jogak:font-semibold jogak:shadow-[0_1px_2px_rgba(0,0,0,0.08)]'
484
+ : 'jogak:bg-transparent jogak:text-[var(--jogak-color-fg-muted)] jogak:font-normal jogak:shadow-none',
485
+ )}
486
+ >
487
+ {VIEWPORT_LABELS[vp]}
488
+ </button>
489
+ ))}
490
+ </div>
491
+
492
+ {/* 배경 토글 */}
493
+ <div className="jogak:flex jogak:gap-1 jogak:items-center">
494
+ {(['white', 'dark', 'transparent'] as const).map((bg) => (
495
+ <button
496
+ key={bg}
497
+ type="button"
498
+ onClick={() => { onBgModeChange(bg) }}
499
+ aria-pressed={bgMode === bg}
500
+ aria-label={`${bg} background`}
501
+ className={clsx(
502
+ 'jogak:w-5 jogak:h-5 jogak:rounded-[var(--jogak-radius-md)] jogak:border-2 jogak:cursor-pointer jogak:p-0 jogak:shrink-0',
503
+ CANVAS_BG_CLASS,
504
+ bgMode === bg
505
+ ? 'jogak:border-[var(--jogak-color-accent)]'
506
+ : 'jogak:border-[var(--jogak-color-border-strong)]',
507
+ )}
508
+ // eslint-disable-next-line no-restricted-syntax -- jogak: BG_VARS object inject (3 mini buttons)
509
+ style={BG_VARS[bg]}
510
+ />
511
+ ))}
512
+ </div>
513
+
514
+ {/* 리셋 */}
515
+ {showReset && (
516
+ <button
517
+ type="button"
518
+ onClick={onReset}
519
+ className="jogak:px-[10px] jogak:py-[3px] jogak:text-[12px] jogak:border jogak:border-[var(--jogak-color-border-strong)] jogak:rounded-[var(--jogak-radius-md)] jogak:bg-[var(--jogak-color-bg)] jogak:cursor-pointer jogak:text-[var(--jogak-color-fg)] jogak:leading-none"
520
+ >
521
+ Reset
522
+ </button>
523
+ )}
524
+ </div>
525
+ )
526
+ }
527
+
528
+ // ── JogakRenderer ─────────────────────────────────────────
529
+
530
+ interface JogakRendererProps {
531
+ readonly entry: RegistryEntry
532
+ readonly args: Readonly<Record<string, unknown>>
533
+ readonly theme: PrismTheme
534
+ readonly previewIsolation: 'none' | 'shadow' | 'iframe'
535
+ readonly userPreviewUrl: string
536
+ readonly previewEntryPath: string
537
+ }
538
+
539
+ /**
540
+ * 알파.9: previewIsolation 모드별로 사용자 콘텐츠 마운트 방식을 분기한다.
541
+ *
542
+ * - `'iframe'` (default) — 어댑터 dev URL의 `<IframeMount>`로 별도 document.
543
+ * - `'shadow'` (deprecated) — `<ShadowMount>` 안에 마운트.
544
+ * - `'none'` (deprecated) — 같은 document에 직접 마운트.
545
+ */
546
+ function JogakRenderer({ entry, args, theme, previewIsolation, userPreviewUrl, previewEntryPath }: JogakRendererProps): ReactElement {
547
+ const [showCode, setShowCode] = useState(false)
548
+ // 알파.10.3: 코드 패널은 jogak 메타 파일이 아니라 현재 args 기반 사용 코드를 노출.
549
+ const usageCode = formatUsageCode(entry, args)
550
+
551
+ const previewBody = (
552
+ <div className="jogak:relative">
553
+ <PreviewMount
554
+ entry={entry}
555
+ args={args}
556
+ previewIsolation={previewIsolation}
557
+ userPreviewUrl={userPreviewUrl}
558
+ previewEntryPath={previewEntryPath}
559
+ />
560
+ <button
561
+ type="button"
562
+ onClick={() => { setShowCode((v) => !v) }}
563
+ aria-pressed={showCode}
564
+ aria-label={showCode ? 'Hide source code' : 'Show source code'}
565
+ className={clsx(
566
+ 'jogak:absolute jogak:bottom-2 jogak:right-2 jogak:px-[9px] jogak:py-1',
567
+ 'jogak:text-[11px] jogak:font-[family-name:var(--jogak-font-mono)] jogak:font-semibold jogak:tracking-[0.02em]',
568
+ 'jogak:text-[var(--jogak-color-bg)] jogak:border-none jogak:rounded-[5px] jogak:cursor-pointer',
569
+ 'jogak:shadow-[0_1px_4px_rgba(0,0,0,0.2)] jogak:transition-[background-color] jogak:duration-150 jogak:leading-none',
570
+ showCode ? 'jogak:bg-[var(--jogak-color-accent)]' : 'jogak:bg-[#1e293b]',
571
+ )}
572
+ >
573
+ {'</>'}
574
+ </button>
575
+ </div>
576
+ )
577
+
578
+ return (
579
+ <div>
580
+ {previewBody}
581
+ {/* 코드 패널 — preview-content 하단으로 펼쳐짐 */}
582
+ {showCode && (
583
+ <div className="jogak:mt-2 jogak:rounded-[var(--jogak-radius-xl)] jogak:overflow-hidden jogak:h-[320px] jogak:shadow-[0_0_0_1px_rgba(0,0,0,0.08),_0_4px_16px_rgba(0,0,0,0.12)]">
584
+ <SourceViewer source={usageCode} theme={theme} />
585
+ </div>
586
+ )}
587
+ </div>
588
+ )
589
+ }
590
+
591
+ // ── PreviewMount ──────────────────────────────────────────
592
+ //
593
+ // previewIsolation 모드별 콘텐츠 마운트. chrome 외곽 (border/radius/padding)은 모드
594
+ // 별 호스트 element에 동일하게 적용해 VR baseline 변경을 zero로 유지한다.
595
+
596
+ interface PreviewMountProps {
597
+ readonly entry: RegistryEntry
598
+ readonly args: Readonly<Record<string, unknown>>
599
+ readonly previewIsolation: 'none' | 'shadow' | 'iframe'
600
+ readonly userPreviewUrl: string
601
+ readonly previewEntryPath: string
602
+ }
603
+
604
+ const PREVIEW_HOST_CLASS =
605
+ 'jogak:border jogak:border-dashed jogak:border-[var(--jogak-color-border)] ' +
606
+ 'jogak:rounded-[var(--jogak-radius-xl)] jogak:p-4 jogak:pb-9'
607
+
608
+ function PreviewMount({ entry, args, previewIsolation, userPreviewUrl, previewEntryPath }: PreviewMountProps): ReactElement {
609
+ if (previewIsolation === 'shadow') {
610
+ return (
611
+ <ShadowMount
612
+ data-testid="preview-content"
613
+ className={PREVIEW_HOST_CLASS}
614
+ >
615
+ <ShadowAdapterContent entry={entry} args={args} />
616
+ </ShadowMount>
617
+ )
618
+ }
619
+
620
+ if (previewIsolation === 'iframe') {
621
+ return (
622
+ <IframeMount
623
+ entry={entry}
624
+ args={args}
625
+ userPreviewUrl={userPreviewUrl}
626
+ previewEntryPath={previewEntryPath}
627
+ data-testid="preview-content"
628
+ className={`${PREVIEW_HOST_CLASS} jogak:block jogak:w-full jogak:bg-transparent jogak:min-h-[256px]`}
629
+ />
630
+ )
631
+ }
632
+
633
+ // 'none' — deprecated 경로 (알파.7.1 동등 동작 보존, back-compat)
634
+ return <NoneAdapterContent entry={entry} args={args} />
635
+ }
636
+
637
+ function NoneAdapterContent({ entry, args }: { entry: RegistryEntry; args: Readonly<Record<string, unknown>> }): ReactElement {
638
+ const containerRef = useRef<HTMLDivElement>(null)
639
+
640
+ useEffect(() => {
641
+ const container = containerRef.current
642
+ if (container === null) return
643
+ reactAdapter.render(entry, args, container)
644
+ return () => {
645
+ // 알파.7.1: React 18 concurrent unmount race(`Attempted to synchronously unmount...`)
646
+ // 회피 — fiber commit 끝난 직후로 defer.
647
+ queueMicrotask(() => { reactAdapter.unmount(container) })
648
+ }
649
+ // eslint-disable-next-line react-hooks/exhaustive-deps
650
+ }, [entry])
651
+
652
+ useEffect(() => {
653
+ const container = containerRef.current
654
+ if (container === null) return
655
+ reactAdapter.render(entry, args, container)
656
+ }, [entry, args])
657
+
658
+ return (
659
+ <div
660
+ ref={containerRef}
661
+ data-testid="preview-content"
662
+ className={PREVIEW_HOST_CLASS}
663
+ />
664
+ )
665
+ }
666
+
667
+ /**
668
+ * Shadow 모드 — ShadowMount의 ShadowRoot 안에서 react-adapter.render를 호출하는
669
+ * 작은 wrapper. ShadowMount 안 portal 내부에 위치하므로 useRef는 ShadowRoot scope.
670
+ */
671
+ function ShadowAdapterContent({ entry, args }: { entry: RegistryEntry; args: Readonly<Record<string, unknown>> }): ReactElement {
672
+ const ref = useRef<HTMLDivElement>(null)
673
+
674
+ useEffect(() => {
675
+ const c = ref.current
676
+ if (c === null) return
677
+ reactAdapter.render(entry, args, c)
678
+ return () => {
679
+ // 알파.7.1: unmount race 회피
680
+ queueMicrotask(() => { reactAdapter.unmount(c) })
681
+ }
682
+ // eslint-disable-next-line react-hooks/exhaustive-deps
683
+ }, [entry])
684
+
685
+ useEffect(() => {
686
+ const c = ref.current
687
+ if (c === null) return
688
+ reactAdapter.render(entry, args, c)
689
+ }, [entry, args])
690
+
691
+ return <div ref={ref} data-testid="preview-content-shadow" />
692
+ }
693
+
694
+ // ── SourceViewer ──────────────────────────────────────────
695
+
696
+ interface SourceViewerProps {
697
+ readonly source: string | undefined
698
+ readonly theme: PrismTheme
699
+ }
700
+
701
+ function SourceViewer({ source, theme }: SourceViewerProps): ReactElement {
702
+ const [copied, setCopied] = useState(false)
703
+ const bgColor = (theme.plain.backgroundColor as string | undefined) ?? '#1e293b'
704
+
705
+ if (source === undefined) {
706
+ return (
707
+ <div
708
+ className="jogak:h-full jogak:flex jogak:items-center jogak:justify-center jogak:bg-[var(--jogak-source-bg)] jogak:text-[#94a3b8] jogak:text-[13px]"
709
+ // eslint-disable-next-line no-restricted-syntax -- jogak: source-bg CSS var (prism theme)
710
+ style={{ '--jogak-source-bg': bgColor } as CSSVarStyle}
711
+ >
712
+ Source not available
713
+ </div>
714
+ )
715
+ }
716
+
717
+ const handleCopy = (): void => {
718
+ void navigator.clipboard.writeText(source).then(() => {
719
+ setCopied(true)
720
+ setTimeout(() => { setCopied(false) }, 2000)
721
+ })
722
+ }
723
+
724
+ return (
725
+ <div className="jogak:relative jogak:h-full">
726
+ <button
727
+ type="button"
728
+ onClick={handleCopy}
729
+ className="jogak:absolute jogak:top-[10px] jogak:right-3 jogak:z-[1] jogak:px-[9px] jogak:py-[3px] jogak:text-[11px] jogak:bg-[rgba(255,255,255,0.1)] jogak:text-[#e2e8f0] jogak:border jogak:border-[rgba(255,255,255,0.18)] jogak:rounded-[var(--jogak-radius-md)] jogak:cursor-pointer jogak:leading-none"
730
+ >
731
+ {copied ? '✓ Copied' : 'Copy'}
732
+ </button>
733
+
734
+ <Highlight code={source.trim()} language="tsx" theme={theme}>
735
+ {({ style, tokens, getLineProps, getTokenProps }) => (
736
+ <pre
737
+ className="jogak:m-0 jogak:py-3 jogak:px-0 jogak:text-[12.5px] jogak:leading-[1.7] jogak:font-[family-name:var(--jogak-font-mono)] jogak:h-full jogak:box-border jogak:overflow-auto"
738
+ // eslint-disable-next-line no-restricted-syntax -- jogak: prism-react-renderer external interface (pre)
739
+ style={style}
740
+ >
741
+ {tokens.map((line, i) => (
742
+ <div
743
+ key={i}
744
+ {...getLineProps({ line })}
745
+ className="jogak:flex jogak:pr-6"
746
+ // eslint-disable-next-line no-restricted-syntax -- jogak: prism-react-renderer external interface (line)
747
+ style={getLineProps({ line }).style}
748
+ >
749
+ <span className="jogak:select-none jogak:min-w-10 jogak:pl-[14px] jogak:pr-[14px] jogak:text-right jogak:text-[rgba(148,163,184,0.45)] jogak:shrink-0 jogak:leading-[1.7]">
750
+ {i + 1}
751
+ </span>
752
+ <span>
753
+ {line.map((token, key) => (
754
+ <span key={key} {...getTokenProps({ token })} />
755
+ ))}
756
+ </span>
757
+ </div>
758
+ ))}
759
+ </pre>
760
+ )}
761
+ </Highlight>
762
+ </div>
763
+ )
764
+ }
765
+
766
+ // Re-export type for ui consumers that may want to type their own wrappers.
767
+ export type { UseEntryState }