@ryndesign/preview 0.1.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/LICENSE +21 -0
- package/client/index.html +12 -0
- package/client/src/App.tsx +261 -0
- package/client/src/components/CodeViewer.tsx +88 -0
- package/client/src/components/ComponentBrowser.tsx +54 -0
- package/client/src/components/PreviewPanel.tsx +188 -0
- package/client/src/components/ThemeSwitcher.tsx +27 -0
- package/client/src/components/TokenEditor.tsx +113 -0
- package/client/src/hooks/useTokens.ts +14 -0
- package/client/src/hooks/useWebSocket.ts +116 -0
- package/client/src/index.tsx +6 -0
- package/dist/index.cjs +691 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +8 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +660 -0
- package/dist/index.js.map +1 -0
- package/package.json +57 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 RynDesign
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,12 @@
|
|
|
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>RynDesign Preview</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="/src/index.tsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import React, { useState, useCallback } from 'react';
|
|
2
|
+
import { useWebSocket } from './hooks/useWebSocket';
|
|
3
|
+
import { useTokens } from './hooks/useTokens';
|
|
4
|
+
import { ComponentBrowser } from './components/ComponentBrowser';
|
|
5
|
+
import { PreviewPanel } from './components/PreviewPanel';
|
|
6
|
+
import { CodeViewer } from './components/CodeViewer';
|
|
7
|
+
import { TokenEditor } from './components/TokenEditor';
|
|
8
|
+
import { ThemeSwitcher } from './components/ThemeSwitcher';
|
|
9
|
+
|
|
10
|
+
export default function App() {
|
|
11
|
+
const [currentTheme, setCurrentTheme] = useState('light');
|
|
12
|
+
const [currentPlatform, setCurrentPlatform] = useState<'react' | 'swiftui'>('react');
|
|
13
|
+
const [selectedComponent, setSelectedComponent] = useState<string | null>(null);
|
|
14
|
+
const [splitView, setSplitView] = useState(false);
|
|
15
|
+
|
|
16
|
+
const { connected, send, tokenSet, components, snippets, requestSnippets } = useWebSocket();
|
|
17
|
+
const { updateToken } = useTokens(send);
|
|
18
|
+
|
|
19
|
+
const handleThemeChange = useCallback((theme: string) => {
|
|
20
|
+
setCurrentTheme(theme);
|
|
21
|
+
document.documentElement.setAttribute('data-app-theme', theme === 'light' ? '' : theme);
|
|
22
|
+
send({ type: 'theme-change', theme });
|
|
23
|
+
}, [send]);
|
|
24
|
+
|
|
25
|
+
const handleTokenUpdate = useCallback((path: string, value: unknown) => {
|
|
26
|
+
updateToken(path, value, currentTheme === 'light' ? undefined : currentTheme);
|
|
27
|
+
}, [updateToken, currentTheme]);
|
|
28
|
+
|
|
29
|
+
const handleComponentSelect = useCallback((name: string) => {
|
|
30
|
+
setSelectedComponent(name);
|
|
31
|
+
requestSnippets(currentPlatform, name);
|
|
32
|
+
}, [currentPlatform, requestSnippets]);
|
|
33
|
+
|
|
34
|
+
const handlePlatformChange = useCallback((platform: 'react' | 'swiftui') => {
|
|
35
|
+
setCurrentPlatform(platform);
|
|
36
|
+
if (selectedComponent) {
|
|
37
|
+
requestSnippets(platform, selectedComponent);
|
|
38
|
+
}
|
|
39
|
+
}, [selectedComponent, requestSnippets]);
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div className="app">
|
|
43
|
+
<header className="header">
|
|
44
|
+
<h1>RynDesign Preview</h1>
|
|
45
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
|
46
|
+
<ThemeSwitcher
|
|
47
|
+
currentTheme={currentTheme}
|
|
48
|
+
themes={tokenSet ? ['light', ...Object.keys(tokenSet.themes?.themes || {})] : ['light']}
|
|
49
|
+
onThemeChange={handleThemeChange}
|
|
50
|
+
onSplitView={() => setSplitView(!splitView)}
|
|
51
|
+
/>
|
|
52
|
+
<div className="status">
|
|
53
|
+
<span className={`status-dot ${connected ? 'connected' : 'disconnected'}`} />
|
|
54
|
+
<span>{connected ? 'Connected' : 'Disconnected'}</span>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
</header>
|
|
58
|
+
|
|
59
|
+
<aside className="sidebar">
|
|
60
|
+
<div className="sidebar-section">
|
|
61
|
+
<h2 className="sidebar-title">Components</h2>
|
|
62
|
+
<ComponentBrowser
|
|
63
|
+
components={components}
|
|
64
|
+
selected={selectedComponent}
|
|
65
|
+
onSelect={handleComponentSelect}
|
|
66
|
+
/>
|
|
67
|
+
</div>
|
|
68
|
+
<div className="sidebar-section">
|
|
69
|
+
<h2 className="sidebar-title">Token Editor</h2>
|
|
70
|
+
<TokenEditor
|
|
71
|
+
tokens={tokenSet?.tokens ?? []}
|
|
72
|
+
onTokenUpdate={handleTokenUpdate}
|
|
73
|
+
/>
|
|
74
|
+
</div>
|
|
75
|
+
</aside>
|
|
76
|
+
|
|
77
|
+
<main className="main">
|
|
78
|
+
<div className="platform-tabs">
|
|
79
|
+
{(['react', 'swiftui'] as const).map(p => (
|
|
80
|
+
<button
|
|
81
|
+
key={p}
|
|
82
|
+
className={`platform-tab ${p === currentPlatform ? 'active' : ''}`}
|
|
83
|
+
onClick={() => handlePlatformChange(p)}
|
|
84
|
+
>
|
|
85
|
+
{p === 'react' ? 'React' : 'SwiftUI'}
|
|
86
|
+
</button>
|
|
87
|
+
))}
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<PreviewPanel
|
|
91
|
+
tokenSet={tokenSet}
|
|
92
|
+
components={components}
|
|
93
|
+
selectedComponent={selectedComponent}
|
|
94
|
+
splitView={splitView}
|
|
95
|
+
currentTheme={currentTheme}
|
|
96
|
+
/>
|
|
97
|
+
|
|
98
|
+
<div style={{ marginTop: 24 }}>
|
|
99
|
+
<h3 style={{ fontSize: 14, marginBottom: 12 }}>Generated Code</h3>
|
|
100
|
+
<CodeViewer
|
|
101
|
+
snippets={snippets}
|
|
102
|
+
platform={currentPlatform}
|
|
103
|
+
component={selectedComponent}
|
|
104
|
+
tokenSet={tokenSet}
|
|
105
|
+
/>
|
|
106
|
+
</div>
|
|
107
|
+
</main>
|
|
108
|
+
|
|
109
|
+
<footer className="footer">
|
|
110
|
+
<span>RynDesign v0.1.0</span>
|
|
111
|
+
</footer>
|
|
112
|
+
|
|
113
|
+
<style>{globalStyles}</style>
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const globalStyles = `
|
|
119
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
120
|
+
:root {
|
|
121
|
+
--bg-primary: #ffffff;
|
|
122
|
+
--bg-secondary: #f5f5f5;
|
|
123
|
+
--bg-sidebar: #fafafa;
|
|
124
|
+
--text-primary: #1a1a1a;
|
|
125
|
+
--text-secondary: #666;
|
|
126
|
+
--border-color: #e5e5e5;
|
|
127
|
+
--accent: #3B82F6;
|
|
128
|
+
}
|
|
129
|
+
[data-app-theme="dark"] {
|
|
130
|
+
--bg-primary: #1a1a2e;
|
|
131
|
+
--bg-secondary: #16213e;
|
|
132
|
+
--bg-sidebar: #0f3460;
|
|
133
|
+
--text-primary: #e5e5e5;
|
|
134
|
+
--text-secondary: #a0a0a0;
|
|
135
|
+
--border-color: #333;
|
|
136
|
+
--accent: #60a5fa;
|
|
137
|
+
}
|
|
138
|
+
body {
|
|
139
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
140
|
+
background: var(--bg-primary);
|
|
141
|
+
color: var(--text-primary);
|
|
142
|
+
}
|
|
143
|
+
.app {
|
|
144
|
+
display: grid;
|
|
145
|
+
grid-template: "header header" auto "sidebar main" 1fr "sidebar footer" auto / 300px 1fr;
|
|
146
|
+
height: 100vh;
|
|
147
|
+
}
|
|
148
|
+
.header {
|
|
149
|
+
grid-area: header; padding: 12px 20px; background: var(--bg-secondary);
|
|
150
|
+
border-bottom: 1px solid var(--border-color); display: flex;
|
|
151
|
+
align-items: center; justify-content: space-between;
|
|
152
|
+
}
|
|
153
|
+
.header h1 { font-size: 18px; font-weight: 600; }
|
|
154
|
+
.sidebar {
|
|
155
|
+
grid-area: sidebar; background: var(--bg-sidebar);
|
|
156
|
+
border-right: 1px solid var(--border-color); overflow-y: auto; padding: 16px;
|
|
157
|
+
}
|
|
158
|
+
.sidebar-section { margin-bottom: 24px; }
|
|
159
|
+
.sidebar-title { font-size: 14px; margin-bottom: 12px; font-weight: 600; }
|
|
160
|
+
.main { grid-area: main; overflow-y: auto; padding: 24px; }
|
|
161
|
+
.footer {
|
|
162
|
+
grid-area: footer; padding: 12px 20px; background: var(--bg-secondary);
|
|
163
|
+
border-top: 1px solid var(--border-color); font-size: 12px;
|
|
164
|
+
}
|
|
165
|
+
.status { display: flex; align-items: center; gap: 6px; font-size: 12px; }
|
|
166
|
+
.status-dot { width: 8px; height: 8px; border-radius: 50%; }
|
|
167
|
+
.status-dot.connected { background: #10b981; }
|
|
168
|
+
.status-dot.disconnected { background: #ef4444; }
|
|
169
|
+
.platform-tabs { display: flex; gap: 4px; margin-bottom: 20px; }
|
|
170
|
+
.platform-tab {
|
|
171
|
+
padding: 8px 16px; border: 1px solid var(--border-color); border-radius: 6px;
|
|
172
|
+
background: var(--bg-secondary); cursor: pointer; font-size: 13px;
|
|
173
|
+
color: var(--text-primary);
|
|
174
|
+
}
|
|
175
|
+
.platform-tab.active { background: var(--accent); color: white; border-color: var(--accent); }
|
|
176
|
+
.code-viewer {
|
|
177
|
+
background: #1e1e1e; color: #d4d4d4; padding: 16px; border-radius: 8px;
|
|
178
|
+
font-family: 'Fira Code', 'Cascadia Code', monospace; font-size: 13px;
|
|
179
|
+
line-height: 1.6; overflow-x: auto; white-space: pre; position: relative;
|
|
180
|
+
}
|
|
181
|
+
.code-viewer .copy-btn {
|
|
182
|
+
position: absolute; top: 8px; right: 8px; padding: 4px 10px;
|
|
183
|
+
background: #333; color: #ccc; border: 1px solid #555; border-radius: 4px;
|
|
184
|
+
cursor: pointer; font-size: 12px;
|
|
185
|
+
}
|
|
186
|
+
.code-viewer .copy-btn:hover { background: #444; }
|
|
187
|
+
.component-grid {
|
|
188
|
+
display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
|
189
|
+
gap: 16px; margin-bottom: 24px;
|
|
190
|
+
}
|
|
191
|
+
.component-card {
|
|
192
|
+
border: 1px solid var(--border-color); border-radius: 8px;
|
|
193
|
+
padding: 20px; background: var(--bg-secondary);
|
|
194
|
+
}
|
|
195
|
+
.component-card h4 { margin-bottom: 12px; font-size: 14px; }
|
|
196
|
+
.split-view { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
|
197
|
+
.split-pane {
|
|
198
|
+
border: 1px solid var(--border-color); border-radius: 8px; padding: 16px;
|
|
199
|
+
}
|
|
200
|
+
.split-pane h4 { margin-bottom: 12px; font-size: 13px; color: var(--text-secondary); }
|
|
201
|
+
.theme-switcher { display: flex; gap: 8px; }
|
|
202
|
+
.theme-btn {
|
|
203
|
+
padding: 6px 12px; border: 1px solid var(--border-color); border-radius: 6px;
|
|
204
|
+
background: var(--bg-primary); color: var(--text-primary); cursor: pointer; font-size: 13px;
|
|
205
|
+
}
|
|
206
|
+
.theme-btn.active { background: var(--accent); color: white; border-color: var(--accent); }
|
|
207
|
+
.token-group { margin-bottom: 16px; }
|
|
208
|
+
.token-group h3 {
|
|
209
|
+
font-size: 13px; font-weight: 600; margin-bottom: 8px;
|
|
210
|
+
color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px;
|
|
211
|
+
cursor: pointer; user-select: none;
|
|
212
|
+
}
|
|
213
|
+
.token-item {
|
|
214
|
+
display: flex; align-items: center; gap: 8px; padding: 6px 0;
|
|
215
|
+
border-bottom: 1px solid var(--border-color);
|
|
216
|
+
}
|
|
217
|
+
.token-name { flex: 1; font-size: 13px; font-family: monospace; }
|
|
218
|
+
.token-value { font-size: 12px; color: var(--text-secondary); }
|
|
219
|
+
.color-swatch {
|
|
220
|
+
width: 24px; height: 24px; border-radius: 4px;
|
|
221
|
+
border: 1px solid var(--border-color); cursor: pointer; position: relative;
|
|
222
|
+
overflow: hidden;
|
|
223
|
+
}
|
|
224
|
+
.color-swatch input {
|
|
225
|
+
opacity: 0; width: 24px; height: 24px; cursor: pointer;
|
|
226
|
+
position: absolute; top: 0; left: 0;
|
|
227
|
+
}
|
|
228
|
+
.token-input {
|
|
229
|
+
width: 70px; padding: 2px 6px; border: 1px solid var(--border-color);
|
|
230
|
+
border-radius: 4px; font-size: 12px; background: var(--bg-primary);
|
|
231
|
+
color: var(--text-primary);
|
|
232
|
+
}
|
|
233
|
+
.component-list { list-style: none; }
|
|
234
|
+
.component-list li {
|
|
235
|
+
padding: 8px 12px; border-radius: 6px; cursor: pointer; font-size: 13px;
|
|
236
|
+
margin-bottom: 2px;
|
|
237
|
+
}
|
|
238
|
+
.component-list li:hover { background: var(--bg-secondary); }
|
|
239
|
+
.component-list li.active { background: var(--accent); color: white; }
|
|
240
|
+
.search-input {
|
|
241
|
+
width: 100%; padding: 8px 12px; border: 1px solid var(--border-color);
|
|
242
|
+
border-radius: 6px; font-size: 13px; margin-bottom: 12px;
|
|
243
|
+
background: var(--bg-primary); color: var(--text-primary);
|
|
244
|
+
}
|
|
245
|
+
.code-tabs { display: flex; gap: 2px; margin-bottom: 0; }
|
|
246
|
+
.code-tab {
|
|
247
|
+
padding: 6px 12px; border: 1px solid var(--border-color); border-bottom: none;
|
|
248
|
+
border-radius: 6px 6px 0 0; background: var(--bg-secondary); cursor: pointer;
|
|
249
|
+
font-size: 12px; color: var(--text-primary);
|
|
250
|
+
}
|
|
251
|
+
.code-tab.active { background: #1e1e1e; color: #d4d4d4; border-color: #333; }
|
|
252
|
+
.variant-grid {
|
|
253
|
+
display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
|
254
|
+
gap: 12px; margin-bottom: 16px;
|
|
255
|
+
}
|
|
256
|
+
.variant-cell {
|
|
257
|
+
display: flex; flex-direction: column; align-items: center; gap: 8px;
|
|
258
|
+
padding: 12px; border: 1px solid var(--border-color); border-radius: 8px;
|
|
259
|
+
background: var(--bg-primary); font-size: 11px; color: var(--text-secondary);
|
|
260
|
+
}
|
|
261
|
+
`;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import React, { useState, useCallback } from 'react';
|
|
2
|
+
|
|
3
|
+
interface Props {
|
|
4
|
+
snippets: Record<string, string>;
|
|
5
|
+
platform: string;
|
|
6
|
+
component: string | null;
|
|
7
|
+
tokenSet: any;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
type CodeTab = 'tokens' | 'component';
|
|
11
|
+
|
|
12
|
+
function formatTokenValue(value: any): string {
|
|
13
|
+
if (!value) return '';
|
|
14
|
+
switch (value.type) {
|
|
15
|
+
case 'color': return value.hex;
|
|
16
|
+
case 'dimension': return `${value.value}${value.unit}`;
|
|
17
|
+
case 'fontWeight': return String(value.value);
|
|
18
|
+
case 'duration': return `${value.value}${value.unit}`;
|
|
19
|
+
case 'number': return String(value.value);
|
|
20
|
+
default: return JSON.stringify(value);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function generateTokenPreview(tokenSet: any, platform: string): string {
|
|
25
|
+
if (!tokenSet?.tokens) return '// No tokens loaded';
|
|
26
|
+
|
|
27
|
+
if (platform === 'react') {
|
|
28
|
+
let css = '/* tokens.css */\n:root {\n';
|
|
29
|
+
for (const token of tokenSet.tokens.slice(0, 20)) {
|
|
30
|
+
css += ` --${token.path.join('-')}: ${formatTokenValue(token.$value)};\n`;
|
|
31
|
+
}
|
|
32
|
+
if (tokenSet.tokens.length > 20) css += ` /* ... ${tokenSet.tokens.length - 20} more tokens */\n`;
|
|
33
|
+
css += '}';
|
|
34
|
+
return css;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (platform === 'swiftui') {
|
|
38
|
+
let swift = '// DesignTokens+Color.swift\nimport SwiftUI\n\nextension Color {\n enum DesignSystem {\n';
|
|
39
|
+
for (const token of tokenSet.tokens.filter((t: any) => t.$type === 'color').slice(0, 10)) {
|
|
40
|
+
swift += ` static let ${token.path.join('_').replace(/[.-]/g, '_')} = Color(hex: "${token.$value.hex}")\n`;
|
|
41
|
+
}
|
|
42
|
+
swift += ' }\n}';
|
|
43
|
+
return swift;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return `// ${platform} code generation`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function CodeViewer({ snippets, platform, component, tokenSet }: Props) {
|
|
50
|
+
const [activeTab, setActiveTab] = useState<CodeTab>('tokens');
|
|
51
|
+
const [copied, setCopied] = useState(false);
|
|
52
|
+
|
|
53
|
+
const code = activeTab === 'component' && component
|
|
54
|
+
? snippets[`${platform}:${component}`] || `// Select a component to view generated code`
|
|
55
|
+
: snippets[`${platform}:tokens`] || generateTokenPreview(tokenSet, platform);
|
|
56
|
+
|
|
57
|
+
const handleCopy = useCallback(() => {
|
|
58
|
+
navigator.clipboard.writeText(code).then(() => {
|
|
59
|
+
setCopied(true);
|
|
60
|
+
setTimeout(() => setCopied(false), 2000);
|
|
61
|
+
}).catch(() => {});
|
|
62
|
+
}, [code]);
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<div>
|
|
66
|
+
<div className="code-tabs">
|
|
67
|
+
<button
|
|
68
|
+
className={`code-tab ${activeTab === 'tokens' ? 'active' : ''}`}
|
|
69
|
+
onClick={() => setActiveTab('tokens')}
|
|
70
|
+
>
|
|
71
|
+
Tokens
|
|
72
|
+
</button>
|
|
73
|
+
<button
|
|
74
|
+
className={`code-tab ${activeTab === 'component' ? 'active' : ''}`}
|
|
75
|
+
onClick={() => setActiveTab('component')}
|
|
76
|
+
>
|
|
77
|
+
Component
|
|
78
|
+
</button>
|
|
79
|
+
</div>
|
|
80
|
+
<div className="code-viewer">
|
|
81
|
+
<button className="copy-btn" onClick={handleCopy}>
|
|
82
|
+
{copied ? 'Copied!' : 'Copy'}
|
|
83
|
+
</button>
|
|
84
|
+
{code}
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
|
|
3
|
+
interface Props {
|
|
4
|
+
components: any[];
|
|
5
|
+
selected: string | null;
|
|
6
|
+
onSelect: (name: string) => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function ComponentBrowser({ components, selected, onSelect }: Props) {
|
|
10
|
+
const [search, setSearch] = useState('');
|
|
11
|
+
|
|
12
|
+
const filtered = components.filter(c =>
|
|
13
|
+
c.name?.toLowerCase().includes(search.toLowerCase()) ||
|
|
14
|
+
c.definition?.name?.toLowerCase().includes(search.toLowerCase())
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
const getName = (c: any) => c.definition?.name ?? c.name ?? 'Unknown';
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<div>
|
|
21
|
+
<input
|
|
22
|
+
className="search-input"
|
|
23
|
+
type="text"
|
|
24
|
+
placeholder="Search components..."
|
|
25
|
+
value={search}
|
|
26
|
+
onChange={e => setSearch(e.target.value)}
|
|
27
|
+
/>
|
|
28
|
+
<ul className="component-list">
|
|
29
|
+
{filtered.length === 0 && (
|
|
30
|
+
<li style={{ color: 'var(--text-secondary)', fontSize: 13 }}>
|
|
31
|
+
{components.length === 0 ? 'No components loaded' : 'No matches'}
|
|
32
|
+
</li>
|
|
33
|
+
)}
|
|
34
|
+
{filtered.map(c => {
|
|
35
|
+
const name = getName(c);
|
|
36
|
+
return (
|
|
37
|
+
<li
|
|
38
|
+
key={name}
|
|
39
|
+
className={selected === name ? 'active' : ''}
|
|
40
|
+
onClick={() => onSelect(name)}
|
|
41
|
+
>
|
|
42
|
+
{name}
|
|
43
|
+
{c.definition?.category && (
|
|
44
|
+
<span style={{ fontSize: 11, opacity: 0.6, marginLeft: 8 }}>
|
|
45
|
+
{c.definition.category}
|
|
46
|
+
</span>
|
|
47
|
+
)}
|
|
48
|
+
</li>
|
|
49
|
+
);
|
|
50
|
+
})}
|
|
51
|
+
</ul>
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
interface Props {
|
|
4
|
+
tokenSet: any;
|
|
5
|
+
components: any[];
|
|
6
|
+
selectedComponent: string | null;
|
|
7
|
+
splitView: boolean;
|
|
8
|
+
currentTheme: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function formatTokenValue(value: any): string {
|
|
12
|
+
if (!value) return '';
|
|
13
|
+
switch (value.type) {
|
|
14
|
+
case 'color': return value.hex;
|
|
15
|
+
case 'dimension': return `${value.value}${value.unit}`;
|
|
16
|
+
case 'fontWeight': return String(value.value);
|
|
17
|
+
case 'duration': return `${value.value}${value.unit}`;
|
|
18
|
+
case 'number': return String(value.value);
|
|
19
|
+
default: return JSON.stringify(value);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function getToken(tokens: any[], path: string): string {
|
|
24
|
+
const t = tokens?.find((t: any) => t.path.join('.') === path);
|
|
25
|
+
return t ? formatTokenValue(t.$value) : '#ccc';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function renderComponentPreview(comp: any, tokens: any[]) {
|
|
29
|
+
const def = comp.definition ?? comp;
|
|
30
|
+
const name = def.name;
|
|
31
|
+
const variants = def.variants?.variant?.values ?? ['default'];
|
|
32
|
+
const sizes = def.variants?.size?.values ?? ['default'];
|
|
33
|
+
const variantTokens = comp.variantTokens;
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div>
|
|
37
|
+
<h3 style={{ fontSize: 16, marginBottom: 16 }}>{name}</h3>
|
|
38
|
+
<div className="variant-grid">
|
|
39
|
+
{variants.map((variant: string) =>
|
|
40
|
+
sizes.map((size: string) => {
|
|
41
|
+
// Try to get tokens from variantTokens
|
|
42
|
+
const vt = variantTokens?.[variant]?.[size];
|
|
43
|
+
const bg = vt?.background?.$value?.hex
|
|
44
|
+
?? getToken(tokens, `component.${name.toLowerCase()}.${variant}.background`);
|
|
45
|
+
const textColor = vt?.textColor?.$value?.hex
|
|
46
|
+
?? (variant === 'primary' || variant === 'secondary' ? '#fff' : bg);
|
|
47
|
+
const fontSize = vt?.fontSize?.$value
|
|
48
|
+
? `${vt.fontSize.$value.value}${vt.fontSize.$value.unit}`
|
|
49
|
+
: size === 'sm' ? '14px' : size === 'lg' ? '18px' : '16px';
|
|
50
|
+
const paddingX = vt?.paddingX?.$value
|
|
51
|
+
? `${vt.paddingX.$value.value}${vt.paddingX.$value.unit}`
|
|
52
|
+
: size === 'sm' ? '12px' : size === 'lg' ? '24px' : '16px';
|
|
53
|
+
const paddingY = vt?.paddingY?.$value
|
|
54
|
+
? `${vt.paddingY.$value.value}${vt.paddingY.$value.unit}`
|
|
55
|
+
: size === 'sm' ? '6px' : size === 'lg' ? '14px' : '10px';
|
|
56
|
+
|
|
57
|
+
const isOutline = variant === 'outline';
|
|
58
|
+
const isGhost = variant === 'ghost';
|
|
59
|
+
|
|
60
|
+
const style: React.CSSProperties = {
|
|
61
|
+
padding: `${paddingY} ${paddingX}`,
|
|
62
|
+
borderRadius: 8,
|
|
63
|
+
border: isOutline ? `1px solid ${bg}` : 'none',
|
|
64
|
+
background: (isOutline || isGhost) ? 'transparent' : bg,
|
|
65
|
+
color: (isOutline || isGhost) ? bg : textColor,
|
|
66
|
+
cursor: 'pointer',
|
|
67
|
+
fontSize,
|
|
68
|
+
display: 'inline-flex',
|
|
69
|
+
alignItems: 'center',
|
|
70
|
+
justifyContent: 'center',
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<div className="variant-cell" key={`${variant}-${size}`}>
|
|
75
|
+
<button style={style}>{name}</button>
|
|
76
|
+
<span>{variant}/{size}</span>
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
})
|
|
80
|
+
)}
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
{/* State previews */}
|
|
84
|
+
{def.states && Object.keys(def.states).length > 0 && (
|
|
85
|
+
<div style={{ marginTop: 16 }}>
|
|
86
|
+
<h4 style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 8 }}>States</h4>
|
|
87
|
+
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
|
88
|
+
{Object.keys(def.states).map((state: string) => (
|
|
89
|
+
<div key={state} style={{ textAlign: 'center' }}>
|
|
90
|
+
<button
|
|
91
|
+
style={{
|
|
92
|
+
padding: '10px 16px',
|
|
93
|
+
borderRadius: 8,
|
|
94
|
+
border: 'none',
|
|
95
|
+
background: getToken(tokens, 'color.primary'),
|
|
96
|
+
color: '#fff',
|
|
97
|
+
cursor: state === 'disabled' ? 'not-allowed' : 'pointer',
|
|
98
|
+
opacity: state === 'disabled' ? 0.6 : 1,
|
|
99
|
+
fontSize: 14,
|
|
100
|
+
}}
|
|
101
|
+
>
|
|
102
|
+
{name}
|
|
103
|
+
</button>
|
|
104
|
+
<div style={{ fontSize: 11, color: 'var(--text-secondary)', marginTop: 4 }}>{state}</div>
|
|
105
|
+
</div>
|
|
106
|
+
))}
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
)}
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function PreviewPanel({ tokenSet, components, selectedComponent, splitView, currentTheme }: Props) {
|
|
115
|
+
const tokens = tokenSet?.tokens ?? [];
|
|
116
|
+
|
|
117
|
+
if (!tokenSet) {
|
|
118
|
+
return <p>Loading...</p>;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const selectedComp = selectedComponent
|
|
122
|
+
? components.find(c => (c.definition?.name ?? c.name) === selectedComponent)
|
|
123
|
+
: null;
|
|
124
|
+
|
|
125
|
+
if (splitView && selectedComp) {
|
|
126
|
+
return (
|
|
127
|
+
<div className="split-view">
|
|
128
|
+
<div className="split-pane">
|
|
129
|
+
<h4>Light</h4>
|
|
130
|
+
{renderComponentPreview(selectedComp, tokens)}
|
|
131
|
+
</div>
|
|
132
|
+
<div className="split-pane" style={{ background: '#1a1a2e', color: '#e5e5e5' }}>
|
|
133
|
+
<h4>Dark</h4>
|
|
134
|
+
{renderComponentPreview(selectedComp, tokenSet.themes?.themes?.dark?.tokens ?? tokens)}
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (selectedComp) {
|
|
141
|
+
return renderComponentPreview(selectedComp, tokens);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Default: show all components in a grid
|
|
145
|
+
if (components.length === 0) {
|
|
146
|
+
return (
|
|
147
|
+
<div className="component-grid">
|
|
148
|
+
{renderFallbackPreview(tokens)}
|
|
149
|
+
</div>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
<div>
|
|
155
|
+
{components.map(comp => {
|
|
156
|
+
const name = comp.definition?.name ?? comp.name;
|
|
157
|
+
return (
|
|
158
|
+
<div key={name} className="component-card" style={{ marginBottom: 16 }}>
|
|
159
|
+
{renderComponentPreview(comp, tokens)}
|
|
160
|
+
</div>
|
|
161
|
+
);
|
|
162
|
+
})}
|
|
163
|
+
</div>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function renderFallbackPreview(tokens: any[]) {
|
|
168
|
+
return (
|
|
169
|
+
<div className="component-card">
|
|
170
|
+
<h4>Button</h4>
|
|
171
|
+
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
|
172
|
+
<button style={{
|
|
173
|
+
padding: '8px 16px', borderRadius: 8, border: 'none',
|
|
174
|
+
background: getToken(tokens, 'color.primary'), color: 'white', cursor: 'pointer',
|
|
175
|
+
}}>Primary</button>
|
|
176
|
+
<button style={{
|
|
177
|
+
padding: '8px 16px', borderRadius: 8,
|
|
178
|
+
border: `1px solid ${getToken(tokens, 'color.primary')}`,
|
|
179
|
+
background: 'transparent', color: getToken(tokens, 'color.primary'), cursor: 'pointer',
|
|
180
|
+
}}>Outline</button>
|
|
181
|
+
<button style={{
|
|
182
|
+
padding: '8px 16px', borderRadius: 8, border: 'none',
|
|
183
|
+
background: 'transparent', color: getToken(tokens, 'color.primary'), cursor: 'pointer',
|
|
184
|
+
}}>Ghost</button>
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
interface Props {
|
|
4
|
+
currentTheme: string;
|
|
5
|
+
themes: string[];
|
|
6
|
+
onThemeChange: (theme: string) => void;
|
|
7
|
+
onSplitView: () => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function ThemeSwitcher({ currentTheme, themes, onThemeChange, onSplitView }: Props) {
|
|
11
|
+
return (
|
|
12
|
+
<div className="theme-switcher">
|
|
13
|
+
{themes.map(t => (
|
|
14
|
+
<button
|
|
15
|
+
key={t}
|
|
16
|
+
className={`theme-btn ${t === currentTheme ? 'active' : ''}`}
|
|
17
|
+
onClick={() => onThemeChange(t)}
|
|
18
|
+
>
|
|
19
|
+
{t}
|
|
20
|
+
</button>
|
|
21
|
+
))}
|
|
22
|
+
<button className="theme-btn" onClick={onSplitView}>
|
|
23
|
+
Split
|
|
24
|
+
</button>
|
|
25
|
+
</div>
|
|
26
|
+
);
|
|
27
|
+
}
|