@jogak/ui 0.1.0-alpha.0 → 0.1.0-alpha.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,20 @@ All notable changes to Jogak packages are documented here. The repository follow
5
5
 
6
6
  Version numbers apply to all packages in the workspace (synchronized release).
7
7
 
8
+ ## [0.1.0-alpha.2] — 2026-05-07
9
+
10
+ ### Fixed
11
+
12
+ - **`@jogak/ui`**: Published package was missing `src/app/main.tsx` (referenced by `index.html` as the SPA entry), causing `jogak dev` to 404 on the entry script and `jogak build` to fail with `Failed to resolve /src/app/main.tsx`. The `files` field in `package.json` only included `dist/`, but `runHost` uses the package's `index.html` as the Vite root and resolves `/src/app/main.tsx` from source. Added `src/app`, `src/components`, `src/hooks`, `src/index.ts`, `src/vite-env.d.ts` to `files`. Internal `src/host` and `src/examples` remain excluded (host is consumed via the published `dist/host` build; examples are repository-only demos).
13
+
14
+ Other packages: no source changes; version bumped to keep the workspace synchronized.
15
+
16
+ ## [0.1.0-alpha.1] — 2026-05-07
17
+
18
+ ### Changed
19
+
20
+ - **CI**: Switched npm publish authentication from `NPM_TOKEN` secret to npm Trusted Publisher (GitHub Actions OIDC). The release workflow no longer reads `NODE_AUTH_TOKEN`; identity is verified via the OIDC token issued for `id-token: write`. No functional changes to package source code.
21
+
8
22
  ## [0.1.0-alpha.0] — 2026-05-07
9
23
 
10
24
  First public preview release. API is not yet stable.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jogak/ui",
3
- "version": "0.1.0-alpha.0",
3
+ "version": "0.1.0-alpha.2",
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,11 @@
42
42
  "files": [
43
43
  "dist",
44
44
  "index.html",
45
+ "src/app",
46
+ "src/components",
47
+ "src/hooks",
48
+ "src/index.ts",
49
+ "src/vite-env.d.ts",
45
50
  "README.md",
46
51
  "LICENSE",
47
52
  "CHANGELOG.md"
@@ -55,8 +60,8 @@
55
60
  },
56
61
  "dependencies": {
57
62
  "prism-react-renderer": "^2.4.1",
58
- "@jogak/core": "0.1.0-alpha.0",
59
- "@jogak/react": "0.1.0-alpha.0"
63
+ "@jogak/core": "0.1.0-alpha.2",
64
+ "@jogak/react": "0.1.0-alpha.2"
60
65
  },
61
66
  "devDependencies": {
62
67
  "@types/node": "^20.14.0",
@@ -0,0 +1,169 @@
1
+ import { useState, useCallback, useEffect, useMemo } from 'react'
2
+ import { ComponentRegistry, defaultRegistry } from '@jogak/core'
3
+ import type { RegistryEntry, RegistryEntryMeta } from '@jogak/core'
4
+ import { JogakProvider } from '@jogak/react'
5
+ import { Sidebar } from '../components/Sidebar/index.js'
6
+ import { Preview } from '../components/Preview/index.js'
7
+ import type { ReactElement } from 'react'
8
+
9
+ /**
10
+ * `JogakApp` props.
11
+ *
12
+ * - `entries`: 기존 — eager 모드. 정적 빌드 / 테스트용. 새 `ComponentRegistry`에 즉시 register.
13
+ * - `metas` : NEW — lazy 모드. `defaultRegistry`를 그대로 사용하면서 metas를 `registerMeta`로 등록.
14
+ * - 둘 다 미지정 → `defaultRegistry` 그대로 사용 (인덱스 가상모듈 `import 'virtual:jogak'`이 채웠다고 가정).
15
+ * - 둘 다 지정 → console.warn 후 `entries` 우선 (eager 우선, breaking 회피).
16
+ *
17
+ * 외부 호환을 위해 `entries`는 optional로만 변경되며 나머지 시그니처는 그대로 유지된다.
18
+ */
19
+ export interface JogakAppProps {
20
+ readonly entries?: readonly RegistryEntry[]
21
+ readonly metas?: readonly RegistryEntryMeta[]
22
+ readonly codeTheme?: string
23
+ }
24
+
25
+ function readUrlParams(): { entryId: string; jogakName: string | null } | null {
26
+ if (typeof window === 'undefined') return null
27
+ const params = new URLSearchParams(window.location.search)
28
+ const entryId = params.get('entry')
29
+ if (entryId === null) return null
30
+ const jogakName = params.get('jogak')
31
+ return { entryId, jogakName }
32
+ }
33
+
34
+ function pushUrl(entryId: string, jogakName: string): void {
35
+ const params = new URLSearchParams()
36
+ params.set('entry', entryId)
37
+ params.set('jogak', jogakName)
38
+ window.history.pushState({}, '', `?${params.toString()}`)
39
+ }
40
+
41
+ export function JogakApp({
42
+ entries,
43
+ metas,
44
+ codeTheme = 'vsDark',
45
+ }: JogakAppProps = {}): ReactElement {
46
+ // ── 4가지 모드 결정 (계약 §5.2) ─────────────────────────────────────
47
+ // 1) entries가 주어지면: 새 ComponentRegistry에 register (eager, 기존 동작)
48
+ // 2) metas만 주어지면: defaultRegistry 사용 + metas를 registerMeta로 등록
49
+ // 3) 둘 다 미지정: defaultRegistry 그대로 (인덱스 가상모듈이 채웠다고 가정)
50
+ // 4) 둘 다 지정: warn 후 entries 우선 (breaking 회피)
51
+ const registry = useMemo(() => {
52
+ if (entries !== undefined) {
53
+ if (metas !== undefined) {
54
+ // eslint-disable-next-line no-console
55
+ console.warn(
56
+ '[jogak] JogakApp received both `entries` and `metas` — `entries` (eager) takes precedence.',
57
+ )
58
+ }
59
+ const r = new ComponentRegistry()
60
+ for (const entry of entries) r.register(entry)
61
+ return r
62
+ }
63
+ if (metas !== undefined) {
64
+ for (const meta of metas) defaultRegistry.registerMeta(meta)
65
+ }
66
+ return defaultRegistry
67
+ }, [entries, metas])
68
+
69
+ // ── URL deep link 초기 상태 (계약 §5.5) ──────────────────────────────
70
+ // ?entry=<id>&jogak=<name>로 진입 시 그 entry로 마운트. jogak 미지정이면
71
+ // 사이드바가 첫 jogak을 자동 선택하지 않으므로 entry hydrate 후 보정한다.
72
+ const initial = useMemo(() => readUrlParams(), [])
73
+ const [selectedEntryId, setSelectedEntryId] = useState<string | null>(
74
+ initial?.entryId ?? null,
75
+ )
76
+ const [selectedJogakName, setSelectedJogakName] = useState<string | null>(
77
+ initial?.jogakName ?? null,
78
+ )
79
+ const [overrideArgs, setOverrideArgs] = useState<Readonly<Record<string, unknown>>>({})
80
+
81
+ useEffect(() => {
82
+ const handlePopState = (): void => {
83
+ const parsed = readUrlParams()
84
+ if (parsed !== null) {
85
+ setSelectedEntryId(parsed.entryId)
86
+ setSelectedJogakName(parsed.jogakName)
87
+ setOverrideArgs({})
88
+ } else {
89
+ setSelectedEntryId(null)
90
+ setSelectedJogakName(null)
91
+ }
92
+ }
93
+ window.addEventListener('popstate', handlePopState)
94
+ return () => { window.removeEventListener('popstate', handlePopState) }
95
+ }, [])
96
+
97
+ const handleSelect = useCallback((entryId: string, jogakName: string) => {
98
+ setSelectedEntryId(entryId)
99
+ setSelectedJogakName(jogakName)
100
+ setOverrideArgs({})
101
+ pushUrl(entryId, jogakName)
102
+ }, [])
103
+
104
+ const handleResolveJogak = useCallback((entryId: string, jogakName: string) => {
105
+ // Preview가 entry를 hydrate한 뒤 jogakName이 비어있을 때 첫 jogak로 보정.
106
+ setSelectedEntryId((prevId) => (prevId === entryId ? entryId : prevId))
107
+ setSelectedJogakName((prev) => prev ?? jogakName)
108
+ if (typeof window !== 'undefined') {
109
+ // URL에 jogak이 누락된 경우만 보정 (사용자 history는 건드리지 않음 — replaceState).
110
+ const params = new URLSearchParams(window.location.search)
111
+ if (params.get('entry') === entryId && params.get('jogak') === null) {
112
+ params.set('jogak', jogakName)
113
+ window.history.replaceState({}, '', `?${params.toString()}`)
114
+ }
115
+ }
116
+ }, [])
117
+
118
+ const handleArgChange = useCallback((key: string, value: unknown) => {
119
+ setOverrideArgs((prev) => ({ ...prev, [key]: value }))
120
+ }, [])
121
+
122
+ const handleReset = useCallback(() => {
123
+ setOverrideArgs({})
124
+ }, [])
125
+
126
+ return (
127
+ <JogakProvider registry={registry}>
128
+ <div
129
+ style={{
130
+ display: 'grid',
131
+ gridTemplateColumns: '260px 1fr',
132
+ height: '100dvh',
133
+ overflow: 'hidden',
134
+ }}
135
+ >
136
+ <Sidebar
137
+ selectedEntryId={selectedEntryId}
138
+ selectedJogakName={selectedJogakName}
139
+ onSelect={handleSelect}
140
+ />
141
+ <main style={{ overflow: 'hidden', minHeight: 0 }}>
142
+ {selectedEntryId !== null ? (
143
+ <Preview
144
+ entryId={selectedEntryId}
145
+ jogakName={selectedJogakName}
146
+ overrideArgs={overrideArgs}
147
+ onArgChange={handleArgChange}
148
+ onReset={handleReset}
149
+ codeTheme={codeTheme}
150
+ onResolveJogak={handleResolveJogak}
151
+ />
152
+ ) : (
153
+ <div
154
+ style={{
155
+ display: 'flex',
156
+ alignItems: 'center',
157
+ justifyContent: 'center',
158
+ height: '100%',
159
+ color: '#9ca3af',
160
+ }}
161
+ >
162
+ Select a component from the sidebar
163
+ </div>
164
+ )}
165
+ </main>
166
+ </div>
167
+ </JogakProvider>
168
+ )
169
+ }
@@ -0,0 +1,14 @@
1
+ import { StrictMode } from 'react'
2
+ import { createRoot } from 'react-dom/client'
3
+ import 'virtual:jogak'
4
+ import { _jogakCodeTheme } from 'virtual:jogak'
5
+ import { JogakApp } from './App.js'
6
+
7
+ const rootEl = document.getElementById('root')
8
+ if (rootEl === null) throw new Error('#root element not found')
9
+
10
+ createRoot(rootEl).render(
11
+ <StrictMode>
12
+ <JogakApp codeTheme={_jogakCodeTheme} />
13
+ </StrictMode>,
14
+ )
@@ -0,0 +1,122 @@
1
+ import { useEffect, useState } from 'react'
2
+ import type { ReactElement } from 'react'
3
+ import { defaultActionChannel } from '@jogak/core'
4
+ import type { ActionLog } from '@jogak/core'
5
+
6
+ function formatArgs(args: readonly unknown[]): string {
7
+ if (args.length === 0) return '()'
8
+ try {
9
+ return args
10
+ .map((arg) => {
11
+ if (arg === null) return 'null'
12
+ if (arg === undefined) return 'undefined'
13
+ if (typeof arg === 'function') return '[Function]'
14
+ if (typeof arg === 'object') {
15
+ const ctorName =
16
+ (arg as { constructor?: { name?: string } }).constructor?.name ?? 'Object'
17
+ if (ctorName !== 'Object' && ctorName !== 'Array') return `[${ctorName}]`
18
+ return JSON.stringify(arg)
19
+ }
20
+ return JSON.stringify(arg)
21
+ })
22
+ .join(', ')
23
+ } catch {
24
+ return '[unserializable]'
25
+ }
26
+ }
27
+
28
+ function formatTime(ts: number): string {
29
+ const d = new Date(ts)
30
+ const hh = d.getHours().toString().padStart(2, '0')
31
+ const mm = d.getMinutes().toString().padStart(2, '0')
32
+ const ss = d.getSeconds().toString().padStart(2, '0')
33
+ const ms = d.getMilliseconds().toString().padStart(3, '0')
34
+ return `${hh}:${mm}:${ss}.${ms}`
35
+ }
36
+
37
+ export function Actions(): ReactElement {
38
+ const [logs, setLogs] = useState<readonly ActionLog[]>(() => defaultActionChannel.getLogs())
39
+
40
+ useEffect(() => {
41
+ return defaultActionChannel.subscribe(setLogs)
42
+ }, [])
43
+
44
+ return (
45
+ <div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
46
+ <div
47
+ style={{
48
+ padding: '6px 20px',
49
+ fontSize: 11,
50
+ fontWeight: 700,
51
+ color: '#9ca3af',
52
+ textTransform: 'uppercase',
53
+ letterSpacing: '0.08em',
54
+ borderBottom: '1px solid #e5e7eb',
55
+ background: '#f9fafb',
56
+ display: 'flex',
57
+ alignItems: 'center',
58
+ justifyContent: 'space-between',
59
+ flexShrink: 0,
60
+ }}
61
+ >
62
+ <span>Actions {logs.length > 0 && `(${logs.length.toString()})`}</span>
63
+ <button
64
+ type="button"
65
+ onClick={() => { defaultActionChannel.clear() }}
66
+ disabled={logs.length === 0}
67
+ style={{
68
+ fontSize: 10,
69
+ fontWeight: 600,
70
+ padding: '2px 8px',
71
+ border: '1px solid #d1d5db',
72
+ borderRadius: 3,
73
+ background: '#fff',
74
+ color: logs.length === 0 ? '#9ca3af' : '#374151',
75
+ cursor: logs.length === 0 ? 'default' : 'pointer',
76
+ textTransform: 'none',
77
+ letterSpacing: 0,
78
+ }}
79
+ >
80
+ Clear
81
+ </button>
82
+ </div>
83
+
84
+ <div style={{ flex: 1, overflow: 'auto' }}>
85
+ {logs.length === 0 ? (
86
+ <div
87
+ style={{
88
+ padding: '12px 20px',
89
+ color: '#9ca3af',
90
+ fontSize: 13,
91
+ }}
92
+ >
93
+ 함수 prop이 호출되면 여기에 기록됩니다
94
+ </div>
95
+ ) : (
96
+ <ul style={{ listStyle: 'none', margin: 0, padding: 0, fontFamily: 'monospace', fontSize: 12 }}>
97
+ {logs.map((log) => (
98
+ <li
99
+ key={log.id}
100
+ style={{
101
+ display: 'flex',
102
+ alignItems: 'baseline',
103
+ gap: 10,
104
+ padding: '6px 20px',
105
+ borderBottom: '1px solid #f3f4f6',
106
+ }}
107
+ >
108
+ <span style={{ color: '#9ca3af', fontSize: 11, minWidth: 92 }}>
109
+ {formatTime(log.timestamp)}
110
+ </span>
111
+ <span style={{ color: '#7c3aed', fontWeight: 600 }}>{log.name}</span>
112
+ <span style={{ color: '#374151', wordBreak: 'break-all', flex: 1 }}>
113
+ ({formatArgs(log.args)})
114
+ </span>
115
+ </li>
116
+ ))}
117
+ </ul>
118
+ )}
119
+ </div>
120
+ </div>
121
+ )
122
+ }
@@ -0,0 +1,211 @@
1
+ import type { ReactElement, ChangeEvent, CSSProperties } from 'react'
2
+ import type { ArgType } from '@jogak/core'
3
+
4
+ export interface ControlsProps {
5
+ readonly args: Readonly<Record<string, unknown>>
6
+ readonly argTypes: Readonly<Record<string, ArgType>>
7
+ readonly onArgChange: (key: string, value: unknown) => void
8
+ }
9
+
10
+ type ControlKind = 'boolean' | 'number' | 'text' | 'select' | 'action' | 'json'
11
+
12
+ function resolveControlKind(value: unknown, argType: ArgType | undefined): ControlKind {
13
+ const ctrl = argType?.control
14
+ const isAction = argType?.action !== undefined && argType.action !== false
15
+ const isFunctionType = argType?.type === 'function' || typeof value === 'function'
16
+
17
+ if (isAction || isFunctionType) return 'action'
18
+ if (ctrl === 'boolean' || typeof value === 'boolean') return 'boolean'
19
+ if (ctrl === 'number' || ctrl === 'range' || typeof value === 'number') return 'number'
20
+ if (
21
+ ctrl === 'select' ||
22
+ ctrl === 'radio' ||
23
+ (argType?.options !== undefined && argType.options.length > 0)
24
+ )
25
+ return 'select'
26
+ if (ctrl === 'text' || ctrl === 'color' || typeof value === 'string') return 'text'
27
+ return 'json'
28
+ }
29
+
30
+ interface ControlInputProps {
31
+ readonly argKey: string
32
+ readonly value: unknown
33
+ readonly argType: ArgType | undefined
34
+ readonly onArgChange: (key: string, value: unknown) => void
35
+ }
36
+
37
+ function ControlInput({ argKey, value, argType, onArgChange }: ControlInputProps): ReactElement {
38
+ const kind = resolveControlKind(value, argType)
39
+
40
+ switch (kind) {
41
+ case 'boolean':
42
+ return (
43
+ <input
44
+ type="checkbox"
45
+ checked={value === true}
46
+ onChange={(e: ChangeEvent<HTMLInputElement>) => {
47
+ onArgChange(argKey, e.target.checked)
48
+ }}
49
+ style={{ cursor: 'pointer', width: 16, height: 16, accentColor: '#2563eb' }}
50
+ />
51
+ )
52
+ case 'number':
53
+ return (
54
+ <input
55
+ type="number"
56
+ value={typeof value === 'number' ? value : ''}
57
+ onChange={(e: ChangeEvent<HTMLInputElement>) => {
58
+ onArgChange(argKey, e.target.valueAsNumber)
59
+ }}
60
+ style={inputStyle}
61
+ />
62
+ )
63
+ case 'select': {
64
+ const options = argType?.options ?? []
65
+ return (
66
+ <select
67
+ value={String(value ?? '')}
68
+ onChange={(e: ChangeEvent<HTMLSelectElement>) => {
69
+ onArgChange(argKey, e.target.value)
70
+ }}
71
+ style={inputStyle}
72
+ >
73
+ {options.map((opt) => (
74
+ <option key={String(opt)} value={String(opt)}>
75
+ {String(opt)}
76
+ </option>
77
+ ))}
78
+ </select>
79
+ )
80
+ }
81
+ case 'text':
82
+ return (
83
+ <input
84
+ type="text"
85
+ value={typeof value === 'string' ? value : String(value ?? '')}
86
+ onChange={(e: ChangeEvent<HTMLInputElement>) => {
87
+ onArgChange(argKey, e.target.value)
88
+ }}
89
+ style={inputStyle}
90
+ />
91
+ )
92
+ case 'action':
93
+ return (
94
+ <span
95
+ style={{
96
+ display: 'inline-block',
97
+ padding: '2px 8px',
98
+ fontSize: 11,
99
+ fontWeight: 600,
100
+ color: '#7c3aed',
101
+ background: '#f5f3ff',
102
+ border: '1px solid #ddd6fe',
103
+ borderRadius: 4,
104
+ fontFamily: 'monospace',
105
+ }}
106
+ >
107
+ (action)
108
+ </span>
109
+ )
110
+ case 'json':
111
+ return (
112
+ <code style={{ fontSize: 12, color: '#6b7280', fontFamily: 'monospace' }}>
113
+ {JSON.stringify(value)}
114
+ </code>
115
+ )
116
+ }
117
+ }
118
+
119
+ const inputStyle: CSSProperties = {
120
+ padding: '4px 8px',
121
+ border: '1px solid #d1d5db',
122
+ borderRadius: 4,
123
+ fontSize: 13,
124
+ width: '100%',
125
+ maxWidth: 280,
126
+ }
127
+
128
+ const thStyle: CSSProperties = {
129
+ padding: '6px 20px',
130
+ textAlign: 'left',
131
+ color: '#6b7280',
132
+ fontWeight: 500,
133
+ fontSize: 12,
134
+ borderBottom: '1px solid #e5e7eb',
135
+ }
136
+
137
+ const tdStyle: CSSProperties = {
138
+ padding: '8px 20px',
139
+ verticalAlign: 'middle',
140
+ borderBottom: '1px solid #f3f4f6',
141
+ }
142
+
143
+ export function Controls({ args, argTypes, onArgChange }: ControlsProps): ReactElement {
144
+ const keys = Array.from(new Set([...Object.keys(args), ...Object.keys(argTypes)]))
145
+ const entries = keys.map((k) => [k, args[k]] as const)
146
+
147
+ return (
148
+ <div style={{ borderTop: '2px solid #e5e7eb' }}>
149
+ <div
150
+ style={{
151
+ padding: '6px 20px',
152
+ fontSize: 11,
153
+ fontWeight: 700,
154
+ color: '#9ca3af',
155
+ textTransform: 'uppercase',
156
+ letterSpacing: '0.08em',
157
+ borderBottom: '1px solid #e5e7eb',
158
+ background: '#f9fafb',
159
+ }}
160
+ >
161
+ Controls
162
+ </div>
163
+ {entries.length === 0 ? (
164
+ <div style={{ padding: '12px 20px', color: '#9ca3af', fontSize: 13 }}>
165
+ No args defined
166
+ </div>
167
+ ) : (
168
+ <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
169
+ <thead>
170
+ <tr>
171
+ <th style={thStyle}>Name</th>
172
+ <th style={thStyle}>Control</th>
173
+ <th style={thStyle}>Description</th>
174
+ </tr>
175
+ </thead>
176
+ <tbody>
177
+ {entries.map(([key, value]) => {
178
+ const argType = argTypes[key]
179
+ return (
180
+ <tr key={key}>
181
+ <td
182
+ style={{
183
+ ...tdStyle,
184
+ fontFamily: 'monospace',
185
+ fontSize: 12,
186
+ color: '#374151',
187
+ whiteSpace: 'nowrap',
188
+ }}
189
+ >
190
+ {key}
191
+ </td>
192
+ <td style={tdStyle}>
193
+ <ControlInput
194
+ argKey={key}
195
+ value={value}
196
+ argType={argType}
197
+ onArgChange={onArgChange}
198
+ />
199
+ </td>
200
+ <td style={{ ...tdStyle, color: '#9ca3af' }}>
201
+ {argType?.description ?? ''}
202
+ </td>
203
+ </tr>
204
+ )
205
+ })}
206
+ </tbody>
207
+ </table>
208
+ )}
209
+ </div>
210
+ )
211
+ }