@jogak/ui 0.1.0-alpha.9.1 → 1.0.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,17 @@
1
+ import { JogakAdapter } from '@jogak/core';
2
+ /** RegistryEntryMeta.framework가 받을 수 있는 모든 값 (JogakAdapter.framework와 동일). */
3
+ export type FrameworkKey = 'react' | 'next' | 'web-components' | 'vue' | 'svelte';
4
+ /**
5
+ * framework 이름으로 어댑터를 가져온다. 첫 호출은 dynamic import, 이후는 캐시 반환.
6
+ *
7
+ * 동시에 같은 framework로 호출되는 경우(예: 여러 effect가 동시에 await)에도 단 한 번만
8
+ * import가 일어나도록 inflight Promise를 공유한다.
9
+ */
10
+ export declare function adapterFor(framework: string): Promise<JogakAdapter>;
11
+ /**
12
+ * @internal test-only. 어댑터 캐시를 초기화한다.
13
+ *
14
+ * cache hit/miss 테스트, framework lookup 격리 테스트에 사용. 프로덕션 코드에서는
15
+ * 호출하지 말 것 — dynamic import의 비용이 발생한다.
16
+ */
17
+ export declare function __resetAdapterCacheForTesting(): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jogak/ui",
3
- "version": "0.1.0-alpha.9.1",
3
+ "version": "1.0.0-beta.0",
4
4
  "description": "Showcase viewer UI for Jogak — Sidebar / Preview / Controls / Actions and the JogakApp shell.",
5
5
  "keywords": [
6
6
  "jogak",
@@ -46,6 +46,7 @@
46
46
  "src/app",
47
47
  "src/components",
48
48
  "src/hooks",
49
+ "src/lib/adapter-for.ts",
49
50
  "src/styles",
50
51
  "src/index.ts",
51
52
  "src/vite-env.d.ts",
@@ -61,23 +62,24 @@
61
62
  "registry": "https://registry.npmjs.org/"
62
63
  },
63
64
  "dependencies": {
65
+ "@tailwindcss/vite": "^4.0.0",
64
66
  "clsx": "^2.1.1",
65
67
  "prism-react-renderer": "^2.4.1",
66
68
  "tailwindcss": "^4.0.0",
67
- "@tailwindcss/vite": "^4.0.0",
68
- "@jogak/core": "0.1.0-alpha.9.1",
69
- "@jogak/react": "0.1.0-alpha.9.1"
69
+ "@jogak/core": "1.0.0-beta.0"
70
70
  },
71
71
  "devDependencies": {
72
72
  "@types/node": "^20.14.0",
73
73
  "@types/react": "^19.0.0",
74
74
  "@types/react-dom": "^19.0.0",
75
75
  "@vitejs/plugin-react": "^4.3.0",
76
+ "happy-dom": "^20.9.0",
76
77
  "react": "^19.0.0",
77
78
  "react-dom": "^19.0.0",
78
79
  "typescript": "^5.5.0",
79
- "vite": "^6.0.0",
80
- "vite-plugin-dts": "^4.5.4"
80
+ "vite": "^6.4.3",
81
+ "vite-plugin-dts": "^4.5.4",
82
+ "vitest": "^2.0.0"
81
83
  },
82
84
  "peerDependencies": {
83
85
  "@vitejs/plugin-react": "^4.3.0",
@@ -97,6 +99,7 @@
97
99
  "dev": "vite",
98
100
  "build": "vite build",
99
101
  "preview": "vite preview",
100
- "typecheck": "tsc --noEmit"
102
+ "typecheck": "tsc --noEmit",
103
+ "test": "vitest run"
101
104
  }
102
105
  }
package/src/app/App.tsx CHANGED
@@ -1,7 +1,7 @@
1
1
  import { useState, useCallback, useEffect, useMemo } from 'react'
2
2
  import { ComponentRegistry, defaultRegistry } from '@jogak/core'
3
3
  import type { RegistryEntry, RegistryEntryMeta } from '@jogak/core'
4
- import { JogakProvider } from '@jogak/react'
4
+ import { JogakProvider } from '@jogak/core/renderers/react'
5
5
  import { Sidebar } from '../components/Sidebar/index.js'
6
6
  import { Preview } from '../components/Preview/index.js'
7
7
  import type { ReactElement } from 'react'
@@ -1,5 +1,6 @@
1
1
  /**
2
2
  * 알파.9: standalone-adapter 또는 fallback 시 사용되는 same-origin iframe entry.
3
+ * 알파.14.1: entry.meta.framework 기반 adapter dispatch (react/vue/svelte/wc 지원).
3
4
  *
4
5
  * `IframeMount`는 알파.9에서 postMessage 프로토콜로 통일됐다 (cross-origin 어댑터와 동일).
5
6
  * preview-frame.tsx도 같은 프로토콜을 따라야 한다 — 부모는 `jogak:setProps` 메시지를,
@@ -8,8 +9,9 @@
8
9
  * jogak host vite scope에서 동작하므로 `virtual:jogak` (registry metas + entry loader)와
9
10
  * `virtual:jogak/global-css` (사용자 globalCss) 가상 모듈을 그대로 사용한다.
10
11
  */
11
- import { reactAdapter } from '@jogak/react'
12
+ import type { JogakAdapter } from '@jogak/core'
12
13
  import { defaultRegistry } from '@jogak/core'
14
+ import { adapterFor } from '../lib/adapter-for.js'
13
15
  import 'virtual:jogak'
14
16
  import 'virtual:jogak/global-css'
15
17
 
@@ -17,23 +19,38 @@ const rootEl = document.getElementById('jogak-preview-root')
17
19
  if (rootEl === null) throw new Error('#jogak-preview-root not found')
18
20
 
19
21
  let currentContainer: HTMLDivElement | null = null
22
+ let currentAdapter: JogakAdapter | null = null
20
23
 
21
24
  async function renderEntry(
22
25
  entryId: string,
23
26
  args: Readonly<Record<string, unknown>>,
24
27
  ): Promise<void> {
25
28
  const entry = await defaultRegistry.requestEntry(entryId)
29
+ const framework = entry.meta.framework ?? 'react'
30
+ const nextAdapter = await adapterFor(framework)
31
+
26
32
  if (currentContainer === null) {
27
33
  currentContainer = document.createElement('div')
28
34
  rootEl?.replaceChildren(currentContainer)
29
35
  }
30
- reactAdapter.render(entry, args, currentContainer)
36
+ // entry framework가 바뀐 경우 이전 어댑터로 unmount 후 새 어댑터로 mount.
37
+ if (currentAdapter !== null && currentAdapter !== nextAdapter) {
38
+ currentAdapter.unmount(currentContainer)
39
+ // container 자체는 재사용 가능하지만 이전 어댑터가 남긴 root/component 상태가
40
+ // 섞이지 않도록 새 div로 교체.
41
+ const fresh = document.createElement('div')
42
+ rootEl?.replaceChildren(fresh)
43
+ currentContainer = fresh
44
+ }
45
+ currentAdapter = nextAdapter
46
+ await nextAdapter.render(entry, args, currentContainer)
31
47
  }
32
48
 
33
49
  function unmount(): void {
34
- if (currentContainer !== null) {
35
- reactAdapter.unmount(currentContainer)
50
+ if (currentContainer !== null && currentAdapter !== null) {
51
+ currentAdapter.unmount(currentContainer)
36
52
  currentContainer = null
53
+ currentAdapter = null
37
54
  }
38
55
  }
39
56
 
@@ -137,6 +137,12 @@ export function Controls({ args, argTypes, onArgChange }: ControlsProps): ReactE
137
137
  const keys = Array.from(new Set([...Object.keys(args), ...Object.keys(argTypes)]))
138
138
  const entries = keys.map((k) => [k, args[k]] as const)
139
139
 
140
+ // 알파.12: defaultValue를 가진 prop이 하나라도 있으면 Default 컬럼 노출.
141
+ // 모두 비어 있으면 컬럼 자체를 숨겨 테이블 가독성을 유지.
142
+ const hasAnyDefault = entries.some(
143
+ ([key]) => argTypes[key]?.defaultValue !== undefined,
144
+ )
145
+
140
146
  return (
141
147
  <div className="jogak:border-t-2 jogak:border-[var(--jogak-color-border)]">
142
148
  <div className="jogak:px-5 jogak:py-1.5 jogak:text-[11px] jogak:font-bold jogak:text-[var(--jogak-color-fg-subtle)] jogak:uppercase jogak:tracking-[0.08em] jogak:border-b jogak:border-[var(--jogak-color-border)] jogak:bg-[var(--jogak-color-bg-subtle)]">
@@ -152,6 +158,7 @@ export function Controls({ args, argTypes, onArgChange }: ControlsProps): ReactE
152
158
  <tr>
153
159
  <th className={thClass}>Name</th>
154
160
  <th className={thClass}>Control</th>
161
+ {hasAnyDefault && <th className={thClass}>Default</th>}
155
162
  <th className={thClass}>Description</th>
156
163
  </tr>
157
164
  </thead>
@@ -176,6 +183,18 @@ export function Controls({ args, argTypes, onArgChange }: ControlsProps): ReactE
176
183
  onArgChange={onArgChange}
177
184
  />
178
185
  </td>
186
+ {hasAnyDefault && (
187
+ <td
188
+ className={clsx(
189
+ tdClass,
190
+ 'jogak:font-[family-name:var(--jogak-font-mono)] jogak:text-[12px] jogak:text-[var(--jogak-color-fg-muted)] jogak:whitespace-nowrap',
191
+ )}
192
+ >
193
+ {argType?.defaultValue !== undefined
194
+ ? formatDefaultValue(argType.defaultValue)
195
+ : ''}
196
+ </td>
197
+ )}
179
198
  <td className={clsx(tdClass, 'jogak:text-[var(--jogak-color-fg-subtle)]')}>
180
199
  {argType?.description ?? ''}
181
200
  </td>
@@ -188,3 +207,20 @@ export function Controls({ args, argTypes, onArgChange }: ControlsProps): ReactE
188
207
  </div>
189
208
  )
190
209
  }
210
+
211
+ /**
212
+ * 알파.12: defaultValue를 Controls 패널에 표시할 때 사용. JSON-safe 값은 작은
213
+ * 인용부호로 string, 그 외 literal은 그대로 직렬화. 의도: 사용자가 코드에 쓸 수
214
+ * 있는 형태로 보여주기.
215
+ */
216
+ function formatDefaultValue(v: unknown): string {
217
+ if (typeof v === 'string') return `'${v}'`
218
+ if (typeof v === 'number' || typeof v === 'boolean' || v === null) {
219
+ return String(v)
220
+ }
221
+ try {
222
+ return JSON.stringify(v)
223
+ } catch {
224
+ return String(v)
225
+ }
226
+ }
@@ -0,0 +1,115 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { RegistryEntry } from '@jogak/core'
3
+ import { formatUsageCode } from './format-usage.js'
4
+
5
+ function makeEntry(componentName: string, title = 'UI/Demo'): RegistryEntry {
6
+ function Demo(): null {
7
+ return null
8
+ }
9
+ Object.defineProperty(Demo, 'name', { value: componentName })
10
+ return {
11
+ id: title,
12
+ title,
13
+ jogaks: [],
14
+ meta: { title, component: Demo, argTypes: {} },
15
+ source: '',
16
+ filePath: '',
17
+ }
18
+ }
19
+
20
+ describe('formatUsageCode', () => {
21
+ it('children 문자열 → 단일 라인', () => {
22
+ const entry = makeEntry('Badge')
23
+ expect(formatUsageCode(entry, { children: 'New', variant: 'default' })).toBe(
24
+ '<Badge variant="default">New</Badge>',
25
+ )
26
+ })
27
+
28
+ it('children 없음 → self-closing', () => {
29
+ const entry = makeEntry('Card')
30
+ expect(formatUsageCode(entry, { title: 'Hello' })).toBe(
31
+ '<Card title="Hello" />',
32
+ )
33
+ })
34
+
35
+ it('boolean true → key only, false → key={false}', () => {
36
+ const entry = makeEntry('Toggle')
37
+ expect(formatUsageCode(entry, { disabled: true, checked: false })).toBe(
38
+ '<Toggle disabled checked={false} />',
39
+ )
40
+ })
41
+
42
+ it('number → 중괄호 표현', () => {
43
+ const entry = makeEntry('Counter')
44
+ expect(formatUsageCode(entry, { count: 42 })).toBe('<Counter count={42} />')
45
+ })
46
+
47
+ it('function → key={fn}', () => {
48
+ const entry = makeEntry('Button')
49
+ expect(
50
+ formatUsageCode(entry, { onClick: () => undefined, label: 'Go' }),
51
+ ).toBe('<Button onClick={fn} label="Go" />')
52
+ })
53
+
54
+ it('객체/배열 → JSON 표현', () => {
55
+ const entry = makeEntry('List')
56
+ expect(formatUsageCode(entry, { items: [1, 2, 3] })).toBe(
57
+ '<List items={[1,2,3]} />',
58
+ )
59
+ })
60
+
61
+ it('children에 number → JSX 표현', () => {
62
+ const entry = makeEntry('Counter')
63
+ expect(formatUsageCode(entry, { children: 7 })).toBe(
64
+ '<Counter>{7}</Counter>',
65
+ )
66
+ })
67
+
68
+ it('component name 없으면 title의 마지막 segment 사용', () => {
69
+ const entry: RegistryEntry = {
70
+ id: 'UI/Anonymous',
71
+ title: 'UI/Anonymous',
72
+ jogaks: [],
73
+ meta: {
74
+ title: 'UI/Anonymous',
75
+ component: undefined,
76
+ argTypes: {},
77
+ },
78
+ source: '',
79
+ filePath: '',
80
+ }
81
+ expect(formatUsageCode(entry, { children: 'X' })).toBe(
82
+ '<Anonymous>X</Anonymous>',
83
+ )
84
+ })
85
+
86
+ it('많은 props → multi-line 포맷', () => {
87
+ const entry = makeEntry('Form')
88
+ const out = formatUsageCode(entry, {
89
+ title: 'Long descriptive title',
90
+ description: 'Another lengthy text value',
91
+ disabled: false,
92
+ autoFocus: true,
93
+ })
94
+ expect(out).toContain('\n')
95
+ expect(out.startsWith('<Form\n ')).toBe(true)
96
+ expect(out.endsWith('/>')).toBe(true)
97
+ })
98
+
99
+ it('따옴표 escape', () => {
100
+ const entry = makeEntry('Label')
101
+ expect(formatUsageCode(entry, { text: 'He said "hi"' })).toBe(
102
+ '<Label text="He said &quot;hi&quot;" />',
103
+ )
104
+ })
105
+
106
+ it('children + props 모두 multi-line으로 ', () => {
107
+ const entry = makeEntry('Card')
108
+ const out = formatUsageCode(entry, {
109
+ title: 'A long title',
110
+ description: 'A long description',
111
+ children: 'Body content',
112
+ })
113
+ expect(out).toMatch(/^<Card\n {2}title="A long title"\n {2}description="A long description"\n>\n {2}Body content\n<\/Card>$/u)
114
+ })
115
+ })
@@ -0,0 +1,132 @@
1
+ /**
2
+ * 알파.10.3: 컴포넌트 사용 코드 포매터.
3
+ *
4
+ * 코드 패널에는 `.jogak.tsx` 파일 전체가 아니라, 현재 args 기반의 사용 스니펫을 노출한다.
5
+ * 사용자가 Controls 패널에서 args를 바꾸면 즉시 갱신된다.
6
+ *
7
+ * 출력 예:
8
+ * <Badge variant="default">New</Badge>
9
+ *
10
+ * <Card
11
+ * title="Hello"
12
+ * disabled
13
+ * onClick={fn}
14
+ * />
15
+ */
16
+
17
+ import type { RegistryEntry } from '@jogak/core'
18
+
19
+ const SINGLE_LINE_THRESHOLD = 60
20
+
21
+ /**
22
+ * `entry` + 현재 `args`로부터 JSX 사용 코드를 생성한다.
23
+ * children은 태그 본문에, 나머지 props는 attribute로.
24
+ */
25
+ export function formatUsageCode(
26
+ entry: RegistryEntry,
27
+ args: Readonly<Record<string, unknown>>,
28
+ ): string {
29
+ const componentName = resolveComponentName(entry)
30
+ const { children, restProps } = splitChildren(args)
31
+
32
+ const propTokens = Object.entries(restProps)
33
+ .filter(([, v]) => v !== undefined)
34
+ .map(([k, v]) => formatProp(k, v))
35
+
36
+ const childrenStr = formatChildren(children)
37
+ const hasChildren = childrenStr !== null
38
+
39
+ // single-line 시도
40
+ const inlineProps = propTokens.length === 0 ? '' : ' ' + propTokens.join(' ')
41
+ const singleLine = hasChildren
42
+ ? `<${componentName}${inlineProps}>${childrenStr ?? ''}</${componentName}>`
43
+ : `<${componentName}${inlineProps} />`
44
+
45
+ if (singleLine.length <= SINGLE_LINE_THRESHOLD && !singleLine.includes('\n')) {
46
+ return singleLine
47
+ }
48
+
49
+ // multi-line — 각 prop을 별도 줄에
50
+ const indentedProps =
51
+ propTokens.length === 0 ? '' : '\n ' + propTokens.join('\n ') + '\n'
52
+ if (hasChildren) {
53
+ const indentedChildren = (childrenStr ?? '')
54
+ .split('\n')
55
+ .map((line) => ` ${line}`)
56
+ .join('\n')
57
+ return `<${componentName}${indentedProps}>\n${indentedChildren}\n</${componentName}>`
58
+ }
59
+ return `<${componentName}${indentedProps}/>`
60
+ }
61
+
62
+ function resolveComponentName(entry: RegistryEntry): string {
63
+ // 알파.14.1: iframe isolation 모드는 chrome scope에 component를 import하지 않으므로
64
+ // entry.meta.component가 `null`. fallback (title 마지막 segment)으로 직행한다.
65
+ const component = entry.meta.component as
66
+ | { displayName?: unknown; name?: unknown }
67
+ | null
68
+ | undefined
69
+ if (component !== null && component !== undefined) {
70
+ if (typeof component.displayName === 'string' && component.displayName.length > 0) {
71
+ return component.displayName
72
+ }
73
+ if (typeof component.name === 'string' && component.name.length > 0) {
74
+ return component.name
75
+ }
76
+ }
77
+ // fallback: title의 마지막 segment ("UI/Badge" → "Badge")
78
+ const lastSeg = entry.title.split('/').pop()
79
+ return lastSeg !== undefined && lastSeg.length > 0 ? lastSeg : 'Component'
80
+ }
81
+
82
+ interface SplitChildrenResult {
83
+ readonly children: unknown
84
+ readonly restProps: Readonly<Record<string, unknown>>
85
+ }
86
+
87
+ function splitChildren(args: Readonly<Record<string, unknown>>): SplitChildrenResult {
88
+ const { children, ...rest } = args as { children?: unknown } & Record<string, unknown>
89
+ return { children, restProps: rest }
90
+ }
91
+
92
+ function formatChildren(children: unknown): string | null {
93
+ if (children === undefined || children === null) return null
94
+ if (typeof children === 'string') {
95
+ if (children.length === 0) return null
96
+ return children
97
+ }
98
+ if (typeof children === 'number' || typeof children === 'bigint') {
99
+ return `{${children.toString()}}`
100
+ }
101
+ if (typeof children === 'boolean') {
102
+ return null
103
+ }
104
+ // 복합 타입(object/array/function): JSON 표현
105
+ return `{${stringifyValue(children)}}`
106
+ }
107
+
108
+ function formatProp(key: string, value: unknown): string {
109
+ if (value === true) return key
110
+ if (value === false) return `${key}={false}`
111
+ if (value === null) return `${key}={null}`
112
+ if (typeof value === 'string') {
113
+ // 따옴표 escape
114
+ const escaped = value.replace(/"/gu, '&quot;')
115
+ return `${key}="${escaped}"`
116
+ }
117
+ if (typeof value === 'number' || typeof value === 'bigint') {
118
+ return `${key}={${value.toString()}}`
119
+ }
120
+ if (typeof value === 'function') {
121
+ return `${key}={fn}`
122
+ }
123
+ return `${key}={${stringifyValue(value)}}`
124
+ }
125
+
126
+ function stringifyValue(value: unknown): string {
127
+ try {
128
+ return JSON.stringify(value)
129
+ } catch {
130
+ return String(value)
131
+ }
132
+ }
@@ -3,13 +3,15 @@ import type { ReactElement, CSSProperties } from 'react'
3
3
  import clsx from 'clsx'
4
4
  import { Highlight, themes } from 'prism-react-renderer'
5
5
  import type { PrismTheme } from 'prism-react-renderer'
6
- import { reactAdapter, useEntry } from '@jogak/react'
7
- import type { UseEntryState } from '@jogak/react'
8
- import type { RegistryEntry, RegistryEntryMeta, ArgType } from '@jogak/core'
6
+ import { useEntry } from '@jogak/core/renderers/react'
7
+ import type { UseEntryState } from '@jogak/core/renderers/react'
8
+ import type { JogakAdapter, RegistryEntry, RegistryEntryMeta, ArgType } from '@jogak/core'
9
+ import { adapterFor } from '../../lib/adapter-for.js'
9
10
  import { Controls } from '../Controls/index.js'
10
11
  import { Actions } from '../Actions/index.js'
11
12
  import { ShadowMount } from './ShadowMount.js'
12
13
  import { IframeMount } from './IframeMount.js'
14
+ import { formatUsageCode } from './format-usage.js'
13
15
 
14
16
  export interface PreviewProps {
15
17
  readonly entryId: string
@@ -133,7 +135,11 @@ export function Preview({
133
135
  userPreviewUrl = '',
134
136
  previewEntryPath = '/__jogak_preview__/index.html',
135
137
  }: PreviewProps): ReactElement {
136
- const state = useEntry(entryId)
138
+ // 알파.14.1: iframe isolation 모드에서는 chrome 측에 component 모듈을 import하지 않는다
139
+ // (chrome vite scope에 .vue/.svelte가 들어오면 plugin-vue/svelte 부재로 transform 실패).
140
+ // skipHydrate=true → useEntry가 synthetic entry(component=null)로 ready를 노출하고,
141
+ // 실제 마운트는 IframeMount가 사용자 vite scope의 iframe entry에 위임한다.
142
+ const state = useEntry(entryId, { skipHydrate: previewIsolation === 'iframe' })
137
143
  const [viewport, setViewport] = useState<ViewportKey>('desktop')
138
144
  const [bgMode, setBgMode] = useState<BgMode>('white')
139
145
  const [bottomTab, setBottomTab] = useState<'controls' | 'actions'>('controls')
@@ -378,7 +384,6 @@ function ReadyFrame({
378
384
  key={`${entry.id}/${jogak.name}`}
379
385
  entry={entry}
380
386
  args={mergedArgs}
381
- source={entry.source}
382
387
  theme={prismTheme}
383
388
  previewIsolation={previewIsolation}
384
389
  userPreviewUrl={userPreviewUrl}
@@ -530,7 +535,6 @@ function Toolbar({
530
535
  interface JogakRendererProps {
531
536
  readonly entry: RegistryEntry
532
537
  readonly args: Readonly<Record<string, unknown>>
533
- readonly source: string | undefined
534
538
  readonly theme: PrismTheme
535
539
  readonly previewIsolation: 'none' | 'shadow' | 'iframe'
536
540
  readonly userPreviewUrl: string
@@ -544,8 +548,10 @@ interface JogakRendererProps {
544
548
  * - `'shadow'` (deprecated) — `<ShadowMount>` 안에 마운트.
545
549
  * - `'none'` (deprecated) — 같은 document에 직접 마운트.
546
550
  */
547
- function JogakRenderer({ entry, args, source, theme, previewIsolation, userPreviewUrl, previewEntryPath }: JogakRendererProps): ReactElement {
551
+ function JogakRenderer({ entry, args, theme, previewIsolation, userPreviewUrl, previewEntryPath }: JogakRendererProps): ReactElement {
548
552
  const [showCode, setShowCode] = useState(false)
553
+ // 알파.10.3: 코드 패널은 jogak 메타 파일이 아니라 현재 args 기반 사용 코드를 노출.
554
+ const usageCode = formatUsageCode(entry, args)
549
555
 
550
556
  const previewBody = (
551
557
  <div className="jogak:relative">
@@ -580,7 +586,7 @@ function JogakRenderer({ entry, args, source, theme, previewIsolation, userPrevi
580
586
  {/* 코드 패널 — preview-content 하단으로 펼쳐짐 */}
581
587
  {showCode && (
582
588
  <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} />
589
+ <SourceViewer source={usageCode} theme={theme} />
584
590
  </div>
585
591
  )}
586
592
  </div>
@@ -635,23 +641,50 @@ function PreviewMount({ entry, args, previewIsolation, userPreviewUrl, previewEn
635
641
 
636
642
  function NoneAdapterContent({ entry, args }: { entry: RegistryEntry; args: Readonly<Record<string, unknown>> }): ReactElement {
637
643
  const containerRef = useRef<HTMLDivElement>(null)
644
+ const adapterRef = useRef<JogakAdapter | null>(null)
638
645
 
646
+ // 알파.14.1: entry.meta.framework로 dispatch. async 적응을 위해 effect 내에서
647
+ // await adapterFor → 캡처된 adapter로 render. unmount는 같은 adapter ref 사용.
639
648
  useEffect(() => {
640
649
  const container = containerRef.current
641
650
  if (container === null) return
642
- reactAdapter.render(entry, args, container)
651
+ let cancelled = false
652
+
653
+ const framework = entry.meta.framework ?? 'react'
654
+ void adapterFor(framework).then((adapter) => {
655
+ if (cancelled) return
656
+ adapterRef.current = adapter
657
+ void adapter.render(entry, args, container)
658
+ })
659
+
643
660
  return () => {
661
+ cancelled = true
662
+ const adapter = adapterRef.current
663
+ if (adapter === null) return
644
664
  // 알파.7.1: React 18 concurrent unmount race(`Attempted to synchronously unmount...`)
645
665
  // 회피 — fiber commit 끝난 직후로 defer.
646
- queueMicrotask(() => { reactAdapter.unmount(container) })
666
+ queueMicrotask(() => { adapter.unmount(container) })
647
667
  }
648
668
  // eslint-disable-next-line react-hooks/exhaustive-deps
649
669
  }, [entry])
650
670
 
671
+ // args 갱신용 effect — adapter가 이미 캐시돼 있으면 동기 분기를 탄다.
651
672
  useEffect(() => {
652
673
  const container = containerRef.current
653
674
  if (container === null) return
654
- reactAdapter.render(entry, args, container)
675
+ let cancelled = false
676
+ const adapter = adapterRef.current
677
+ if (adapter !== null) {
678
+ void adapter.render(entry, args, container)
679
+ return
680
+ }
681
+ const framework = entry.meta.framework ?? 'react'
682
+ void adapterFor(framework).then((resolved) => {
683
+ if (cancelled) return
684
+ adapterRef.current = resolved
685
+ void resolved.render(entry, args, container)
686
+ })
687
+ return () => { cancelled = true }
655
688
  }, [entry, args])
656
689
 
657
690
  return (
@@ -664,19 +697,31 @@ function NoneAdapterContent({ entry, args }: { entry: RegistryEntry; args: Reado
664
697
  }
665
698
 
666
699
  /**
667
- * Shadow 모드 — ShadowMount의 ShadowRoot 안에서 react-adapter.render를 호출하는
700
+ * Shadow 모드 — ShadowMount의 ShadowRoot 안에서 adapter.render를 호출하는
668
701
  * 작은 wrapper. ShadowMount 안 portal 내부에 위치하므로 useRef는 ShadowRoot scope.
669
702
  */
670
703
  function ShadowAdapterContent({ entry, args }: { entry: RegistryEntry; args: Readonly<Record<string, unknown>> }): ReactElement {
671
704
  const ref = useRef<HTMLDivElement>(null)
705
+ const adapterRef = useRef<JogakAdapter | null>(null)
672
706
 
673
707
  useEffect(() => {
674
708
  const c = ref.current
675
709
  if (c === null) return
676
- reactAdapter.render(entry, args, c)
710
+ let cancelled = false
711
+
712
+ const framework = entry.meta.framework ?? 'react'
713
+ void adapterFor(framework).then((adapter) => {
714
+ if (cancelled) return
715
+ adapterRef.current = adapter
716
+ void adapter.render(entry, args, c)
717
+ })
718
+
677
719
  return () => {
720
+ cancelled = true
721
+ const adapter = adapterRef.current
722
+ if (adapter === null) return
678
723
  // 알파.7.1: unmount race 회피
679
- queueMicrotask(() => { reactAdapter.unmount(c) })
724
+ queueMicrotask(() => { adapter.unmount(c) })
680
725
  }
681
726
  // eslint-disable-next-line react-hooks/exhaustive-deps
682
727
  }, [entry])
@@ -684,7 +729,19 @@ function ShadowAdapterContent({ entry, args }: { entry: RegistryEntry; args: Rea
684
729
  useEffect(() => {
685
730
  const c = ref.current
686
731
  if (c === null) return
687
- reactAdapter.render(entry, args, c)
732
+ let cancelled = false
733
+ const adapter = adapterRef.current
734
+ if (adapter !== null) {
735
+ void adapter.render(entry, args, c)
736
+ return
737
+ }
738
+ const framework = entry.meta.framework ?? 'react'
739
+ void adapterFor(framework).then((resolved) => {
740
+ if (cancelled) return
741
+ adapterRef.current = resolved
742
+ void resolved.render(entry, args, c)
743
+ })
744
+ return () => { cancelled = true }
688
745
  }, [entry, args])
689
746
 
690
747
  return <div ref={ref} data-testid="preview-content-shadow" />
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'
2
2
  import type { CSSProperties, ReactElement } from 'react'
3
3
  import clsx from 'clsx'
4
4
  import type { CategoryMetaTree, RegistryEntryMeta } from '@jogak/core'
5
- import { useRegistryMeta } from '@jogak/react'
5
+ import { useRegistryMeta } from '@jogak/core/renderers/react'
6
6
 
7
7
  // CSS custom property를 React style prop에 주입하기 위한 헬퍼 타입.
8
8
  // React 18+는 string-keyed `--` prefix를 인식하나 TS는 명시적 cast 필요.
@@ -1,4 +1,4 @@
1
- import { useRegistry as useRegistryFromAdapter } from '@jogak/react'
1
+ import { useRegistry as useRegistryFromAdapter } from '@jogak/core/renderers/react'
2
2
  import { useMemo } from 'react'
3
3
  import type { CategoryTree, RegistryEntry } from '@jogak/core'
4
4