@jogak/ui 0.1.0-alpha.0 → 0.1.0-alpha.10.2

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