@jogak/ui 0.1.0-alpha.5 → 0.1.0-alpha.7

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.5",
3
+ "version": "0.1.0-alpha.7",
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.5",
68
- "@jogak/react": "0.1.0-alpha.5"
68
+ "@jogak/core": "0.1.0-alpha.7",
69
+ "@jogak/react": "0.1.0-alpha.7"
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 = 'none',
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,8 +1,14 @@
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'
6
12
  import { JogakApp } from './App.js'
7
13
 
8
14
  const rootEl = document.getElementById('root')
@@ -10,6 +16,9 @@ if (rootEl === null) throw new Error('#root element not found')
10
16
 
11
17
  createRoot(rootEl).render(
12
18
  <StrictMode>
13
- <JogakApp codeTheme={_jogakCodeTheme} />
19
+ <JogakApp
20
+ codeTheme={_jogakCodeTheme}
21
+ previewIsolation={_jogakPreviewIsolation}
22
+ />
14
23
  </StrictMode>,
15
24
  )
@@ -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,82 @@
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
+ // unmount 시 iframe 안 react root도 정리 (best-effort)
62
+ iframe.contentWindow?.__jogak_unmount__?.()
63
+ }
64
+ // eslint-disable-next-line react-hooks/exhaustive-deps
65
+ }, [])
66
+
67
+ // entry/args 변경 시 setProps 재호출 (load 후에만)
68
+ useEffect(() => {
69
+ if (!readyRef.current) return
70
+ iframeRef.current?.contentWindow?.__jogak_setProps__?.({ entry, args })
71
+ }, [entry, args])
72
+
73
+ return (
74
+ <iframe
75
+ ref={iframeRef}
76
+ src="/preview-frame.html"
77
+ title="Preview"
78
+ className={className}
79
+ data-testid={dataTestId}
80
+ />
81
+ )
82
+ }
@@ -0,0 +1,101 @@
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
+ /** 외부 테스트 hook (호스트 div에 부여). */
10
+ readonly 'data-testid'?: string
11
+ }
12
+
13
+ /**
14
+ * 알파.7: previewIsolation='shadow' 모드의 mount 컴포넌트.
15
+ *
16
+ * 역할:
17
+ * - 호스트 `<div>`에 `attachShadow({ mode: 'open' })`로 ShadowRoot를 부착.
18
+ * - `createPortal`로 children을 ShadowRoot에 렌더 (React tree 유지).
19
+ * - 외부 document의 모든 `<style>` / cross-origin 가능 stylesheet를
20
+ * `adoptedStyleSheets`로 ShadowRoot에 share (jogak.css + virtual:jogak/global-css
21
+ * 둘 다 자동 포함).
22
+ * - Vite dev에서 `<style>` HMR 시 MutationObserver로 ShadowRoot도 갱신.
23
+ *
24
+ * Radix portal 한계 (사용자 인지 필요):
25
+ * - 사용자 컴포넌트가 `Dialog.Portal` / `Popover.Portal` 등을 default로 쓰면
26
+ * portal target은 `document.body` — ShadowRoot 외부. utility class는 외부
27
+ * document에 정의되어 적용됨, 단 z-index/focus/event boundary가 분리될 수 있음.
28
+ * - 회피: 사용자가 명시적으로 `<Portal container={shadowRootEl}>`을 전달.
29
+ */
30
+ export function ShadowMount({
31
+ children,
32
+ className,
33
+ style,
34
+ 'data-testid': dataTestId,
35
+ }: ShadowMountProps): ReactElement {
36
+ const hostRef = useRef<HTMLDivElement | null>(null)
37
+ const [shadowRoot, setShadowRoot] = useState<ShadowRoot | null>(null)
38
+
39
+ useEffect(() => {
40
+ const host = hostRef.current
41
+ if (host === null) return
42
+ let sr: ShadowRoot
43
+ if (host.shadowRoot !== null) {
44
+ sr = host.shadowRoot
45
+ } else {
46
+ sr = host.attachShadow({ mode: 'open' })
47
+ }
48
+ setShadowRoot(sr)
49
+ syncStyleSheets(sr)
50
+ const observer = observeDocumentStyles(sr)
51
+ return () => { observer.disconnect() }
52
+ }, [])
53
+
54
+ return (
55
+ <div
56
+ ref={hostRef}
57
+ className={className}
58
+ data-testid={dataTestId}
59
+ // eslint-disable-next-line no-restricted-syntax -- jogak: ShadowMount caller-supplied style passthrough (host wrapper, content goes through ShadowRoot portal)
60
+ style={style}
61
+ >
62
+ {shadowRoot !== null ? createPortal(children, shadowRoot) : null}
63
+ </div>
64
+ )
65
+ }
66
+
67
+ /**
68
+ * 외부 document의 모든 stylesheet를 ShadowRoot에 share한다.
69
+ *
70
+ * - `adoptedStyleSheets` API를 사용 (Chromium/Safari/Firefox 모던 브라우저 지원).
71
+ * - cross-origin sheet는 `cssRules` 접근 시 SecurityError → catch 후 skip.
72
+ * - Vite dev의 `<style>` HMR이 자주 발생할 수 있어 `replaceSync`를 통한 새
73
+ * `CSSStyleSheet` 인스턴스 생성으로 처리.
74
+ */
75
+ function syncStyleSheets(shadowRoot: ShadowRoot): void {
76
+ const sheets: CSSStyleSheet[] = []
77
+ for (const sheet of Array.from(document.styleSheets)) {
78
+ try {
79
+ const rules = sheet.cssRules
80
+ const cs = new CSSStyleSheet()
81
+ const cssText = Array.from(rules).map((r) => r.cssText).join('\n')
82
+ cs.replaceSync(cssText)
83
+ sheets.push(cs)
84
+ } catch {
85
+ // cross-origin sheet — skip
86
+ }
87
+ }
88
+ shadowRoot.adoptedStyleSheets = sheets
89
+ }
90
+
91
+ /**
92
+ * document.head의 변화(`<style>` HMR add/remove)를 관찰해 ShadowRoot의
93
+ * adoptedStyleSheets를 다시 sync.
94
+ */
95
+ function observeDocumentStyles(shadowRoot: ShadowRoot): MutationObserver {
96
+ const observer = new MutationObserver(() => {
97
+ syncStyleSheets(shadowRoot)
98
+ })
99
+ observer.observe(document.head, { childList: true, subtree: true })
100
+ return observer
101
+ }
@@ -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 = 'none',
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,12 +513,104 @@ 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
@@ -519,41 +626,38 @@ function JogakRenderer({ entry, args, source, theme }: JogakRendererProps): Reac
519
626
  }, [entry, args])
520
627
 
521
628
  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>
629
+ <div
630
+ ref={containerRef}
631
+ data-testid="preview-content"
632
+ className={PREVIEW_HOST_CLASS}
633
+ />
554
634
  )
555
635
  }
556
636
 
637
+ /**
638
+ * Shadow 모드 — ShadowMount의 ShadowRoot 안에서 react-adapter.render를 호출하는
639
+ * 작은 wrapper. ShadowMount 안 portal 내부에 위치하므로 useRef는 ShadowRoot scope.
640
+ */
641
+ function ShadowAdapterContent({ entry, args }: { entry: RegistryEntry; args: Readonly<Record<string, unknown>> }): ReactElement {
642
+ const ref = useRef<HTMLDivElement>(null)
643
+
644
+ useEffect(() => {
645
+ const c = ref.current
646
+ if (c === null) return
647
+ reactAdapter.render(entry, args, c)
648
+ return () => { reactAdapter.unmount(c) }
649
+ // eslint-disable-next-line react-hooks/exhaustive-deps
650
+ }, [entry])
651
+
652
+ useEffect(() => {
653
+ const c = ref.current
654
+ if (c === null) return
655
+ reactAdapter.render(entry, args, c)
656
+ }, [entry, args])
657
+
658
+ return <div ref={ref} data-testid="preview-content-shadow" />
659
+ }
660
+
557
661
  // ── SourceViewer ──────────────────────────────────────────
558
662
 
559
663
  interface SourceViewerProps {
@@ -82,6 +82,26 @@
82
82
  * 회귀 자체 차단 + 알파.6 사용자 globalCss 충돌 가능성 zero.
83
83
  */
84
84
  }
85
+
86
+ /*
87
+ * 알파.6 wrapper 보호 rule (api-contracts §8 옵션 C).
88
+ *
89
+ * 사용자 globalCss(예: shadcn `@layer base { * { @apply border-border ... } }`)가
90
+ * jogak chrome의 form element를 침범하는 것을 차단한다. `:where(...)` specificity 0
91
+ * 으로 사용자가 명시적으로 `[data-jogak-shell] button { ... }`를 작성하면 자연스럽게
92
+ * 이긴다.
93
+ *
94
+ * 적용 범위: chrome의 모든 form element. preview(`[data-jogak-content]`) 안의
95
+ * 사용자 컴포넌트는 영향 받지 않음 — `:not([data-jogak-content] *)`로 제외.
96
+ *
97
+ * 알파.5 baseline 영향: zero. chrome 컴포넌트는 본 rule이 차단하는 사용자 reset이
98
+ * 없을 때 동일 시각 (revert-layer는 해당 layer가 없으면 no-op).
99
+ */
100
+ [data-jogak-shell] :where(button, input, select, textarea):not([data-jogak-content] *) {
101
+ border-color: revert-layer;
102
+ background-color: revert-layer;
103
+ color: revert-layer;
104
+ }
85
105
  }
86
106
 
87
107
  @layer components {
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
  }