@jogak/ui 0.1.0-alpha.6 → 0.1.0-alpha.7.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jogak/ui",
3
- "version": "0.1.0-alpha.6",
3
+ "version": "0.1.0-alpha.7.1",
4
4
  "description": "Showcase viewer UI for Jogak — Sidebar / Preview / Controls / Actions and the JogakApp shell.",
5
5
  "keywords": [
6
6
  "jogak",
@@ -42,6 +42,7 @@
42
42
  "files": [
43
43
  "dist",
44
44
  "index.html",
45
+ "preview-frame.html",
45
46
  "src/app",
46
47
  "src/components",
47
48
  "src/hooks",
@@ -64,8 +65,8 @@
64
65
  "prism-react-renderer": "^2.4.1",
65
66
  "tailwindcss": "^4.0.0",
66
67
  "@tailwindcss/vite": "^4.0.0",
67
- "@jogak/core": "0.1.0-alpha.6",
68
- "@jogak/react": "0.1.0-alpha.6"
68
+ "@jogak/core": "0.1.0-alpha.7.1",
69
+ "@jogak/react": "0.1.0-alpha.7.1"
69
70
  },
70
71
  "devDependencies": {
71
72
  "@types/node": "^20.14.0",
@@ -0,0 +1,17 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>jogak preview</title>
7
+ <style>
8
+ *, *::before, *::after { box-sizing: border-box; }
9
+ html, body { margin: 0; }
10
+ body { font-family: system-ui, sans-serif; }
11
+ </style>
12
+ </head>
13
+ <body>
14
+ <div id="jogak-preview-root"></div>
15
+ <script type="module" src="/src/app/preview-frame.tsx"></script>
16
+ </body>
17
+ </html>
package/src/app/App.tsx CHANGED
@@ -20,6 +20,16 @@ export interface JogakAppProps {
20
20
  readonly entries?: readonly RegistryEntry[]
21
21
  readonly metas?: readonly RegistryEntryMeta[]
22
22
  readonly codeTheme?: string
23
+ /**
24
+ * 알파.7: Preview 영역 격리 모드. default `'none'`.
25
+ *
26
+ * - `'none'` — Preview 콘텐츠를 chrome 같은 document에 렌더 (알파.6까지의 동작).
27
+ * - `'shadow'` — ShadowRoot 안에 마운트. 사용자 globalCss/reset이 chrome 침범 차단.
28
+ * - `'iframe'` — 별도 document(iframe)에 마운트. 가장 강한 격리.
29
+ *
30
+ * 자세한 트레이드오프는 `@jogak/ui` README의 "previewIsolation 사용 가이드" 참조.
31
+ */
32
+ readonly previewIsolation?: 'none' | 'shadow' | 'iframe'
23
33
  }
24
34
 
25
35
  function readUrlParams(): { entryId: string; jogakName: string | null } | null {
@@ -42,6 +52,7 @@ export function JogakApp({
42
52
  entries,
43
53
  metas,
44
54
  codeTheme = 'vsDark',
55
+ previewIsolation = 'shadow',
45
56
  }: JogakAppProps = {}): ReactElement {
46
57
  // ── 4가지 모드 결정 (계약 §5.2) ─────────────────────────────────────
47
58
  // 1) entries가 주어지면: 새 ComponentRegistry에 register (eager, 기존 동작)
@@ -144,6 +155,7 @@ export function JogakApp({
144
155
  onReset={handleReset}
145
156
  codeTheme={codeTheme}
146
157
  onResolveJogak={handleResolveJogak}
158
+ previewIsolation={previewIsolation}
147
159
  />
148
160
  ) : (
149
161
  <div className="jogak:flex jogak:items-center jogak:justify-center jogak:h-full jogak:text-[var(--jogak-color-fg-subtle)]">
package/src/app/main.tsx CHANGED
@@ -1,21 +1,28 @@
1
1
  import { StrictMode } from 'react'
2
2
  import { createRoot } from 'react-dom/client'
3
3
  import 'virtual:jogak'
4
- import { _jogakCodeTheme } from 'virtual:jogak'
4
+ import { _jogakCodeTheme, _jogakPreviewIsolation } from 'virtual:jogak'
5
5
  import '../styles/jogak.css'
6
- // 알파.6: 사용자 globalCss opt-in.
7
- // JogakPluginOptions.globalCss=false (default) → 빈 모듈 (no-op, SPA 번들 영향 zero).
8
- // true / string / string[] → plugin이 사용자 css를 import한다.
9
- // jogak.css 뒤에 둬서 사용자가 jogak chrome 기본값을 명시적으로 override 가능 —
10
- // 단, jogak utility는 prefix=jogak로 격리되어 사용자 utility와 충돌하지 않는다.
11
- import 'virtual:jogak/global-css'
12
6
  import { JogakApp } from './App.js'
13
7
 
8
+ // 알파.7.1: 사용자 globalCss는 isolation === 'none'일 때만 outer document에 inject.
9
+ // - 'shadow'/'iframe' 모드에서는 ShadowMount/preview-frame.tsx가 자기 scope에서
10
+ // 사용자 css를 자체 import하므로 outer document inject가 불필요하고, 오히려
11
+ // chrome을 침범한다 (알파.7 결함).
12
+ // - top-level await로 가드 — Vite는 string literal specifier의 dynamic import를
13
+ // 정적 분석하여 별도 chunk + css HMR 표준 경로로 처리한다.
14
+ if (_jogakPreviewIsolation === 'none') {
15
+ await import('virtual:jogak/global-css')
16
+ }
17
+
14
18
  const rootEl = document.getElementById('root')
15
19
  if (rootEl === null) throw new Error('#root element not found')
16
20
 
17
21
  createRoot(rootEl).render(
18
22
  <StrictMode>
19
- <JogakApp codeTheme={_jogakCodeTheme} />
23
+ <JogakApp
24
+ codeTheme={_jogakCodeTheme}
25
+ previewIsolation={_jogakPreviewIsolation}
26
+ />
20
27
  </StrictMode>,
21
28
  )
@@ -0,0 +1,46 @@
1
+ /**
2
+ * 알파.7: previewIsolation='iframe' 모드 — iframe document entry.
3
+ *
4
+ * - 부모 Preview 컴포넌트의 `<IframeMount>`가 `iframe.contentWindow.__jogak_setProps__`
5
+ * 를 호출해 entry/args를 주입한다.
6
+ * - iframe과 부모는 동일 origin (Vite dev server) → contentWindow 직접 접근 가능.
7
+ * postMessage는 cross-origin/iframe sandbox 시나리오에서만 필요.
8
+ * - 사용자 globalCss(`virtual:jogak/global-css`)만 import — jogak.css는 chrome 전용
9
+ * 이라 iframe에서는 미필요. 사용자 reset이 iframe document에 free하게 적용됨.
10
+ */
11
+ import { reactAdapter } from '@jogak/react'
12
+ import type { RegistryEntry } from '@jogak/core'
13
+ import 'virtual:jogak'
14
+ import 'virtual:jogak/global-css'
15
+
16
+ interface SetPropsArgs {
17
+ readonly entry: RegistryEntry
18
+ readonly args: Readonly<Record<string, unknown>>
19
+ }
20
+
21
+ declare global {
22
+ interface Window {
23
+ __jogak_setProps__?: (args: SetPropsArgs) => void
24
+ __jogak_unmount__?: () => void
25
+ }
26
+ }
27
+
28
+ const rootEl = document.getElementById('jogak-preview-root')
29
+ if (rootEl === null) throw new Error('#jogak-preview-root not found')
30
+
31
+ let currentEl: HTMLDivElement | null = null
32
+
33
+ window.__jogak_setProps__ = ({ entry, args }) => {
34
+ if (currentEl === null) {
35
+ currentEl = document.createElement('div')
36
+ rootEl.replaceChildren(currentEl)
37
+ }
38
+ reactAdapter.render(entry, args, currentEl)
39
+ }
40
+
41
+ window.__jogak_unmount__ = () => {
42
+ if (currentEl !== null) {
43
+ reactAdapter.unmount(currentEl)
44
+ currentEl = null
45
+ }
46
+ }
@@ -0,0 +1,84 @@
1
+ import { useEffect, useRef } from 'react'
2
+ import type { ReactElement } from 'react'
3
+ import type { RegistryEntry } from '@jogak/core'
4
+
5
+ export interface IframeMountProps {
6
+ readonly entry: RegistryEntry
7
+ readonly args: Readonly<Record<string, unknown>>
8
+ readonly className?: string
9
+ readonly 'data-testid'?: string
10
+ }
11
+
12
+ interface SetPropsArgs {
13
+ readonly entry: RegistryEntry
14
+ readonly args: Readonly<Record<string, unknown>>
15
+ }
16
+
17
+ declare global {
18
+ interface Window {
19
+ __jogak_setProps__?: (args: SetPropsArgs) => void
20
+ __jogak_unmount__?: () => void
21
+ }
22
+ }
23
+
24
+ /**
25
+ * 알파.7: previewIsolation='iframe' 모드의 mount 컴포넌트.
26
+ *
27
+ * - `<iframe src="/preview-frame.html">`을 마운트.
28
+ * - iframe load 후 `iframe.contentWindow.__jogak_setProps__({ entry, args })`를
29
+ * 호출해 entry/args를 주입한다 (postMessage 미사용 — 동일 origin이므로
30
+ * contentWindow 직접 접근 가능).
31
+ * - entry/args 변경 시 setProps 재호출 (load 완료 이후).
32
+ *
33
+ * HMR:
34
+ * - iframe document 자체도 Vite dev server module을 import하므로 사용자 컴포넌트
35
+ * 파일 변경 시 fast refresh가 iframe 안에서 작동.
36
+ * - previewIsolation 모드 자체 변경은 가상 모듈 invalidate → full reload.
37
+ *
38
+ * sandbox 미설정:
39
+ * - 사용자 컴포넌트가 fetch/clipboard/storage 등 자유롭게 사용해야 하므로 sandbox X.
40
+ */
41
+ export function IframeMount({
42
+ entry,
43
+ args,
44
+ className,
45
+ 'data-testid': dataTestId,
46
+ }: IframeMountProps): ReactElement {
47
+ const iframeRef = useRef<HTMLIFrameElement | null>(null)
48
+ const readyRef = useRef(false)
49
+
50
+ // iframe load 후 첫 setProps
51
+ useEffect(() => {
52
+ const iframe = iframeRef.current
53
+ if (iframe === null) return
54
+ const handleLoad = (): void => {
55
+ readyRef.current = true
56
+ iframe.contentWindow?.__jogak_setProps__?.({ entry, args })
57
+ }
58
+ iframe.addEventListener('load', handleLoad)
59
+ return () => {
60
+ iframe.removeEventListener('load', handleLoad)
61
+ // 알파.7.1: unmount race 회피 — iframe contentWindow 정리도 microtask defer.
62
+ queueMicrotask(() => {
63
+ iframe.contentWindow?.__jogak_unmount__?.()
64
+ })
65
+ }
66
+ // eslint-disable-next-line react-hooks/exhaustive-deps
67
+ }, [])
68
+
69
+ // entry/args 변경 시 setProps 재호출 (load 후에만)
70
+ useEffect(() => {
71
+ if (!readyRef.current) return
72
+ iframeRef.current?.contentWindow?.__jogak_setProps__?.({ entry, args })
73
+ }, [entry, args])
74
+
75
+ return (
76
+ <iframe
77
+ ref={iframeRef}
78
+ src="/preview-frame.html"
79
+ title="Preview"
80
+ className={className}
81
+ data-testid={dataTestId}
82
+ />
83
+ )
84
+ }
@@ -0,0 +1,57 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import type { ReactElement, ReactNode, CSSProperties } from 'react'
4
+
5
+ export interface ShadowMountProps {
6
+ readonly children: ReactNode
7
+ readonly className?: string
8
+ readonly style?: CSSProperties
9
+ readonly 'data-testid'?: string
10
+ }
11
+
12
+ /**
13
+ * 알파.7.1: previewIsolation='shadow' 모드의 mount 컴포넌트.
14
+ *
15
+ * 책임: 양방향 격리만 제공 (Preview ↔ outer document 양방향 cascade 차단).
16
+ * - 사용자 globalCss는 main.tsx 가드로 outer document에 inject되지 않음.
17
+ * - shadow root 안에는 jogak chrome css도 사용자 css도 없음 (둘 다 외부에서 격리).
18
+ * - 사용자 컴포넌트의 utility class 컴파일은 결함 B (알파.8 사이클).
19
+ *
20
+ * 알파.7 결함 정정:
21
+ * - `syncStyleSheets`/`MutationObserver`/`adoptedStyleSheets` 흡수 로직 제거.
22
+ * 알파.7은 outer document에 사용자 css가 있는 한 chrome을 침범했고, shadow
23
+ * 안의 흡수 로직은 의미가 없었음. 알파.7.1: outer에 사용자 css 자체가 없음.
24
+ *
25
+ * Radix portal 한계 (사용자 인지 필요, README 명시):
26
+ * - default Portal target = document.body (shadow 외부). 사용자가 명시적으로
27
+ * `<Portal container={shadowRootEl}>`을 전달해야 portal 내용도 격리됨.
28
+ */
29
+ export function ShadowMount({
30
+ children,
31
+ className,
32
+ style,
33
+ 'data-testid': dataTestId,
34
+ }: ShadowMountProps): ReactElement {
35
+ const hostRef = useRef<HTMLDivElement | null>(null)
36
+ const [shadowRoot, setShadowRoot] = useState<ShadowRoot | null>(null)
37
+
38
+ useEffect(() => {
39
+ const host = hostRef.current
40
+ if (host === null) return
41
+ const sr = host.shadowRoot ?? host.attachShadow({ mode: 'open' })
42
+ setShadowRoot(sr)
43
+ // shadow root는 host element와 함께 GC — 명시 detach 불필요.
44
+ }, [])
45
+
46
+ return (
47
+ <div
48
+ ref={hostRef}
49
+ className={className}
50
+ data-testid={dataTestId}
51
+ // eslint-disable-next-line no-restricted-syntax -- jogak: ShadowMount caller-supplied style passthrough (host wrapper, content goes through ShadowRoot portal)
52
+ style={style}
53
+ >
54
+ {shadowRoot !== null ? createPortal(children, shadowRoot) : null}
55
+ </div>
56
+ )
57
+ }
@@ -8,6 +8,8 @@ import type { UseEntryState } from '@jogak/react'
8
8
  import type { RegistryEntry, RegistryEntryMeta, ArgType } from '@jogak/core'
9
9
  import { Controls } from '../Controls/index.js'
10
10
  import { Actions } from '../Actions/index.js'
11
+ import { ShadowMount } from './ShadowMount.js'
12
+ import { IframeMount } from './IframeMount.js'
11
13
 
12
14
  export interface PreviewProps {
13
15
  readonly entryId: string
@@ -21,6 +23,14 @@ export interface PreviewProps {
21
23
  * 첫 jogak로 자동 보정하기 위한 콜백. 부모가 selectedJogakName / URL을 갱신.
22
24
  */
23
25
  readonly onResolveJogak?: (entryId: string, jogakName: string) => void
26
+ /**
27
+ * 알파.7: Preview 영역 격리 모드. default `'none'`.
28
+ *
29
+ * - `'none'` — 기존 동작 (chrome과 같은 document, 알파.6 chrome 보호 rule 적용).
30
+ * - `'shadow'` — ShadowRoot에 마운트. 사용자 globalCss reset이 chrome 침범 차단.
31
+ * - `'iframe'` — `/preview-frame.html` iframe에 마운트. 강한 격리.
32
+ */
33
+ readonly previewIsolation?: 'none' | 'shadow' | 'iframe'
24
34
  }
25
35
 
26
36
  type ViewportKey = 'mobile' | 'tablet' | 'desktop'
@@ -110,6 +120,7 @@ export function Preview({
110
120
  onReset,
111
121
  codeTheme,
112
122
  onResolveJogak,
123
+ previewIsolation = 'shadow',
113
124
  }: PreviewProps): ReactElement {
114
125
  const state = useEntry(entryId)
115
126
  const [viewport, setViewport] = useState<ViewportKey>('desktop')
@@ -175,6 +186,7 @@ export function Preview({
175
186
  onBgModeChange={setBgMode}
176
187
  onBottomTabChange={setBottomTab}
177
188
  prismTheme={prismTheme}
189
+ previewIsolation={previewIsolation}
178
190
  />
179
191
  )
180
192
  }
@@ -262,6 +274,7 @@ interface ReadyFrameProps {
262
274
  readonly onBgModeChange: (bg: BgMode) => void
263
275
  readonly onBottomTabChange: (tab: 'controls' | 'actions') => void
264
276
  readonly prismTheme: PrismTheme
277
+ readonly previewIsolation: 'none' | 'shadow' | 'iframe'
265
278
  }
266
279
 
267
280
  function ReadyFrame({
@@ -278,6 +291,7 @@ function ReadyFrame({
278
291
  onBgModeChange,
279
292
  onBottomTabChange,
280
293
  prismTheme,
294
+ previewIsolation,
281
295
  }: ReadyFrameProps): ReactElement {
282
296
  // jogakName이 비어있으면 (deep link `?entry=...&jogak` 누락) 첫 jogak로 보정.
283
297
  const resolvedJogakName = jogakName ?? entry.jogaks[0]?.name ?? null
@@ -349,6 +363,7 @@ function ReadyFrame({
349
363
  args={mergedArgs}
350
364
  source={entry.source}
351
365
  theme={prismTheme}
366
+ previewIsolation={previewIsolation}
352
367
  />
353
368
  </div>
354
369
  </div>
@@ -498,17 +513,113 @@ interface JogakRendererProps {
498
513
  readonly args: Readonly<Record<string, unknown>>
499
514
  readonly source: string | undefined
500
515
  readonly theme: PrismTheme
516
+ readonly previewIsolation: 'none' | 'shadow' | 'iframe'
501
517
  }
502
518
 
503
- function JogakRenderer({ entry, args, source, theme }: JogakRendererProps): ReactElement {
504
- const containerRef = useRef<HTMLDivElement>(null)
519
+ /**
520
+ * 알파.7: previewIsolation 모드별로 사용자 콘텐츠 마운트 방식을 분기한다.
521
+ *
522
+ * - `'none'` — 같은 document에 직접 마운트 (알파.6까지의 동작 그대로).
523
+ * - `'shadow'` — `<ShadowMount>` 안에 마운트해 ShadowRoot 격리.
524
+ * - `'iframe'` — `<IframeMount>`로 별도 document에 마운트.
525
+ *
526
+ * Show source 토글, 코드 패널 등 chrome 부분은 모드 무관하게 외부에 둔다.
527
+ */
528
+ function JogakRenderer({ entry, args, source, theme, previewIsolation }: JogakRendererProps): ReactElement {
505
529
  const [showCode, setShowCode] = useState(false)
506
530
 
531
+ const previewBody = (
532
+ <div className="jogak:relative">
533
+ <PreviewMount
534
+ entry={entry}
535
+ args={args}
536
+ previewIsolation={previewIsolation}
537
+ />
538
+ <button
539
+ type="button"
540
+ onClick={() => { setShowCode((v) => !v) }}
541
+ aria-pressed={showCode}
542
+ aria-label={showCode ? 'Hide source code' : 'Show source code'}
543
+ className={clsx(
544
+ 'jogak:absolute jogak:bottom-2 jogak:right-2 jogak:px-[9px] jogak:py-1',
545
+ 'jogak:text-[11px] jogak:font-[family-name:var(--jogak-font-mono)] jogak:font-semibold jogak:tracking-[0.02em]',
546
+ 'jogak:text-[var(--jogak-color-bg)] jogak:border-none jogak:rounded-[5px] jogak:cursor-pointer',
547
+ 'jogak:shadow-[0_1px_4px_rgba(0,0,0,0.2)] jogak:transition-[background-color] jogak:duration-150 jogak:leading-none',
548
+ showCode ? 'jogak:bg-[var(--jogak-color-accent)]' : 'jogak:bg-[#1e293b]',
549
+ )}
550
+ >
551
+ {'</>'}
552
+ </button>
553
+ </div>
554
+ )
555
+
556
+ return (
557
+ <div>
558
+ {previewBody}
559
+ {/* 코드 패널 — preview-content 하단으로 펼쳐짐 */}
560
+ {showCode && (
561
+ <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)]">
562
+ <SourceViewer source={source} theme={theme} />
563
+ </div>
564
+ )}
565
+ </div>
566
+ )
567
+ }
568
+
569
+ // ── PreviewMount ──────────────────────────────────────────
570
+ //
571
+ // previewIsolation 모드별 콘텐츠 마운트. chrome 외곽 (border/radius/padding)은 모드
572
+ // 별 호스트 element에 동일하게 적용해 VR baseline 변경을 zero로 유지한다.
573
+
574
+ interface PreviewMountProps {
575
+ readonly entry: RegistryEntry
576
+ readonly args: Readonly<Record<string, unknown>>
577
+ readonly previewIsolation: 'none' | 'shadow' | 'iframe'
578
+ }
579
+
580
+ const PREVIEW_HOST_CLASS =
581
+ 'jogak:border jogak:border-dashed jogak:border-[var(--jogak-color-border)] ' +
582
+ 'jogak:rounded-[var(--jogak-radius-xl)] jogak:p-4 jogak:pb-9'
583
+
584
+ function PreviewMount({ entry, args, previewIsolation }: PreviewMountProps): ReactElement {
585
+ if (previewIsolation === 'shadow') {
586
+ return (
587
+ <ShadowMount
588
+ data-testid="preview-content"
589
+ className={PREVIEW_HOST_CLASS}
590
+ >
591
+ <ShadowAdapterContent entry={entry} args={args} />
592
+ </ShadowMount>
593
+ )
594
+ }
595
+
596
+ if (previewIsolation === 'iframe') {
597
+ return (
598
+ <IframeMount
599
+ entry={entry}
600
+ args={args}
601
+ data-testid="preview-content"
602
+ className={`${PREVIEW_HOST_CLASS} jogak:block jogak:w-full jogak:bg-transparent jogak:min-h-[256px]`}
603
+ />
604
+ )
605
+ }
606
+
607
+ // 'none' — 기존 동작 그대로
608
+ return <NoneAdapterContent entry={entry} args={args} />
609
+ }
610
+
611
+ function NoneAdapterContent({ entry, args }: { entry: RegistryEntry; args: Readonly<Record<string, unknown>> }): ReactElement {
612
+ const containerRef = useRef<HTMLDivElement>(null)
613
+
507
614
  useEffect(() => {
508
615
  const container = containerRef.current
509
616
  if (container === null) return
510
617
  reactAdapter.render(entry, args, container)
511
- return () => { reactAdapter.unmount(container) }
618
+ return () => {
619
+ // 알파.7.1: React 18 concurrent unmount race(`Attempted to synchronously unmount...`)
620
+ // 회피 — fiber commit 끝난 직후로 defer.
621
+ queueMicrotask(() => { reactAdapter.unmount(container) })
622
+ }
512
623
  // eslint-disable-next-line react-hooks/exhaustive-deps
513
624
  }, [entry])
514
625
 
@@ -519,41 +630,41 @@ function JogakRenderer({ entry, args, source, theme }: JogakRendererProps): Reac
519
630
  }, [entry, args])
520
631
 
521
632
  return (
522
- <div>
523
- {/* preview-content 영역 + 토글 버튼 */}
524
- <div className="jogak:relative">
525
- <div
526
- ref={containerRef}
527
- data-testid="preview-content"
528
- className="jogak:border jogak:border-dashed jogak:border-[var(--jogak-color-border)] jogak:rounded-[var(--jogak-radius-xl)] jogak:p-4 jogak:pb-9"
529
- />
530
- <button
531
- type="button"
532
- onClick={() => { setShowCode((v) => !v) }}
533
- aria-pressed={showCode}
534
- aria-label={showCode ? 'Hide source code' : 'Show source code'}
535
- className={clsx(
536
- 'jogak:absolute jogak:bottom-2 jogak:right-2 jogak:px-[9px] jogak:py-1',
537
- 'jogak:text-[11px] jogak:font-[family-name:var(--jogak-font-mono)] jogak:font-semibold jogak:tracking-[0.02em]',
538
- 'jogak:text-[var(--jogak-color-bg)] jogak:border-none jogak:rounded-[5px] jogak:cursor-pointer',
539
- 'jogak:shadow-[0_1px_4px_rgba(0,0,0,0.2)] jogak:transition-[background-color] jogak:duration-150 jogak:leading-none',
540
- showCode ? 'jogak:bg-[var(--jogak-color-accent)]' : 'jogak:bg-[#1e293b]',
541
- )}
542
- >
543
- {'</>'}
544
- </button>
545
- </div>
546
-
547
- {/* 코드 패널 — preview-content 하단으로 펼쳐짐 */}
548
- {showCode && (
549
- <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)]">
550
- <SourceViewer source={source} theme={theme} />
551
- </div>
552
- )}
553
- </div>
633
+ <div
634
+ ref={containerRef}
635
+ data-testid="preview-content"
636
+ className={PREVIEW_HOST_CLASS}
637
+ />
554
638
  )
555
639
  }
556
640
 
641
+ /**
642
+ * Shadow 모드 — ShadowMount의 ShadowRoot 안에서 react-adapter.render를 호출하는
643
+ * 작은 wrapper. ShadowMount 안 portal 내부에 위치하므로 useRef는 ShadowRoot scope.
644
+ */
645
+ function ShadowAdapterContent({ entry, args }: { entry: RegistryEntry; args: Readonly<Record<string, unknown>> }): ReactElement {
646
+ const ref = useRef<HTMLDivElement>(null)
647
+
648
+ useEffect(() => {
649
+ const c = ref.current
650
+ if (c === null) return
651
+ reactAdapter.render(entry, args, c)
652
+ return () => {
653
+ // 알파.7.1: unmount race 회피
654
+ queueMicrotask(() => { reactAdapter.unmount(c) })
655
+ }
656
+ // eslint-disable-next-line react-hooks/exhaustive-deps
657
+ }, [entry])
658
+
659
+ useEffect(() => {
660
+ const c = ref.current
661
+ if (c === null) return
662
+ reactAdapter.render(entry, args, c)
663
+ }, [entry, args])
664
+
665
+ return <div ref={ref} data-testid="preview-content-shadow" />
666
+ }
667
+
557
668
  // ── SourceViewer ──────────────────────────────────────────
558
669
 
559
670
  interface SourceViewerProps {
package/src/vite-env.d.ts CHANGED
@@ -3,4 +3,13 @@
3
3
  declare module 'virtual:jogak' {
4
4
  /** 플러그인 설정에서 지정한 prism-react-renderer 테마 이름 */
5
5
  export const _jogakCodeTheme: string
6
+ /**
7
+ * 알파.7: Preview 영역 격리 모드 ('none' | 'shadow' | 'iframe').
8
+ * `JogakPluginOptions.previewIsolation` (default 'none')의 literal emit.
9
+ */
10
+ export const _jogakPreviewIsolation: 'none' | 'shadow' | 'iframe'
11
+ }
12
+
13
+ declare module 'virtual:jogak/global-css' {
14
+ // empty — side-effect only
6
15
  }