create-visualbuild-app 0.1.0 → 1.0.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.
Files changed (44) hide show
  1. package/README.md +306 -123
  2. package/docs/user-guide.md +759 -0
  3. package/package.json +92 -85
  4. package/scripts/create-builder-app.mjs +513 -501
  5. package/scripts/vb-dev.mjs +41 -32
  6. package/scripts/vb-generate.mjs +332 -224
  7. package/templates/default/app/.vercelignore +6 -0
  8. package/templates/default/app/README.md +56 -21
  9. package/templates/default/app/visualbuild/components.json +3 -0
  10. package/templates/default/app/visualbuild/config.d.ts +48 -0
  11. package/templates/default/app/visualbuild.config.ts +38 -0
  12. package/templates/default/builder/README.md +37 -21
  13. package/visual-app-builder/server/parseReactPage.ts +776 -571
  14. package/visual-app-builder/shared/createReactComponentSource.d.mts +2 -0
  15. package/visual-app-builder/shared/createReactComponentSource.mjs +45 -0
  16. package/visual-app-builder/shared/generateReactSource.d.mts +57 -37
  17. package/visual-app-builder/shared/generateReactSource.mjs +608 -443
  18. package/visual-app-builder/shared/npmBuildCommand.d.mts +17 -0
  19. package/visual-app-builder/shared/npmBuildCommand.mjs +26 -0
  20. package/visual-app-builder/shared/syncCustomComponentSource.d.mts +13 -0
  21. package/visual-app-builder/shared/syncCustomComponentSource.mjs +345 -0
  22. package/visual-app-builder/shared/vercelDeployment.d.mts +31 -0
  23. package/visual-app-builder/shared/vercelDeployment.mjs +266 -0
  24. package/visual-app-builder/shared/visualbuildConfig.d.mts +144 -0
  25. package/visual-app-builder/shared/visualbuildConfig.mjs +578 -0
  26. package/visual-app-builder/src/App.tsx +1090 -874
  27. package/visual-app-builder/src/components/Canvas.tsx +1116 -1059
  28. package/visual-app-builder/src/components/CodePanel.tsx +1147 -812
  29. package/visual-app-builder/src/components/ComponentSidebar.tsx +365 -302
  30. package/visual-app-builder/src/components/DeploymentModal.tsx +295 -0
  31. package/visual-app-builder/src/components/PageSwitcher.tsx +133 -133
  32. package/visual-app-builder/src/components/ProjectExplorer.tsx +1054 -1054
  33. package/visual-app-builder/src/components/PropertiesPanel.tsx +792 -692
  34. package/visual-app-builder/src/components/Topbar.tsx +257 -128
  35. package/visual-app-builder/src/data/componentRegistry.tsx +613 -292
  36. package/visual-app-builder/src/data/tailwindClassCatalog.ts +95 -0
  37. package/visual-app-builder/src/index.css +383 -111
  38. package/visual-app-builder/src/stores/useAppStore.ts +2385 -1265
  39. package/visual-app-builder/src/theme.ts +71 -0
  40. package/visual-app-builder/src/types/index.ts +350 -261
  41. package/visual-app-builder/src/utils/codegen.ts +90 -66
  42. package/visual-app-builder/src/utils/deployment.ts +54 -0
  43. package/visual-app-builder/src/utils/projectPersistence.ts +218 -177
  44. package/visual-app-builder/vite.config.ts +1946 -1479
@@ -1,692 +1,792 @@
1
- import { useMemo, useRef, useState } from 'react'
2
- import { primitiveRegistry } from '../data/primitiveRegistry'
3
- import { useAppStore } from '../stores/useAppStore'
4
- import type { PrimitiveType, PropSchema, VisualNode } from '../types'
5
-
6
- export default function PropertiesPanel({
7
- embedded = false,
8
- }: {
9
- embedded?: boolean
10
- }) {
11
- const {
12
- pages,
13
- appShell,
14
- activePageId,
15
- selectedComponentId,
16
- updateNodeProps,
17
- updatePageRoot,
18
- } = useAppStore()
19
-
20
- const activePage = pages.find((page) => page.id === activePageId)
21
- const isPageRootSelected =
22
- activePage?.root.id !== undefined && activePage.root.id === selectedComponentId
23
- const selectedNode = isPageRootSelected
24
- ? activePage.root
25
- : findNode(
26
- [
27
- ...(appShell.header ? [appShell.header] : []),
28
- ...(activePage?.components ?? []),
29
- ...(appShell.footer ? [appShell.footer] : []),
30
- ],
31
- selectedComponentId
32
- )
33
-
34
- if (!selectedNode) {
35
- return (
36
- <aside
37
- className={`${embedded ? 'h-full w-full' : 'w-60 border-l'} flex shrink-0 flex-col border-white/[0.06] bg-panel`}
38
- >
39
- <div className="px-3 py-2.5 border-b border-white/[0.06]">
40
- <p className="text-[10px] font-semibold uppercase tracking-widest text-gray-500">
41
- Properties
42
- </p>
43
- </div>
44
- <div className="flex-1 flex flex-col items-center justify-center text-center px-4">
45
- <p className="text-xs text-gray-600">
46
- Select any element on the canvas to edit text, attributes, and Tailwind classes
47
- </p>
48
- </div>
49
- </aside>
50
- )
51
- }
52
-
53
- const definition = primitiveRegistry[selectedNode.type]
54
-
55
- const handleChange = (key: string, value: unknown) => {
56
- if (isPageRootSelected && activePage) {
57
- updatePageRoot(activePage.id, { props: { [key]: value } })
58
- return
59
- }
60
-
61
- updateNodeProps(selectedNode.id, { [key]: value })
62
- }
63
-
64
- const handleRootTagChange = (type: PrimitiveType) => {
65
- if (!activePage) {
66
- return
67
- }
68
-
69
- updatePageRoot(activePage.id, { type })
70
- }
71
-
72
- return (
73
- <aside
74
- className={`${embedded ? 'h-full w-full' : 'w-60 border-l'} flex shrink-0 flex-col overflow-hidden border-white/[0.06] bg-panel`}
75
- >
76
- <div className="px-3 py-2.5 border-b border-white/[0.06] flex items-center justify-between">
77
- <p className="text-[10px] font-semibold uppercase tracking-widest text-gray-500">
78
- Properties
79
- </p>
80
- <span className="text-[10px] font-medium px-1.5 py-0.5 bg-accent/20 text-accent rounded">
81
- {isPageRootSelected ? 'Page root' : selectedNode.label ?? definition.label}
82
- </span>
83
- </div>
84
-
85
- <div className="flex-1 overflow-y-auto p-3 space-y-3">
86
- {isPageRootSelected ? (
87
- <RootTagField value={selectedNode.type} onChange={handleRootTagChange} />
88
- ) : (
89
- <ReadOnlyField label="Tag" value={selectedNode.type} />
90
- )}
91
- {Object.entries(definition.propSchema).map(([key, schema]) => (
92
- <PropField
93
- key={`${selectedNode.id}:${key}`}
94
- schema={schema}
95
- value={selectedNode.props[key] ?? schema.default}
96
- onChange={(value) => handleChange(key, value)}
97
- />
98
- ))}
99
- </div>
100
- </aside>
101
- )
102
- }
103
-
104
- function RootTagField({
105
- value,
106
- onChange,
107
- }: {
108
- value: PrimitiveType
109
- onChange: (type: PrimitiveType) => void
110
- }) {
111
- return (
112
- <FieldShell label="Page root tag">
113
- <select
114
- value={value}
115
- onChange={(event) => onChange(event.target.value as PrimitiveType)}
116
- className="w-full px-2.5 py-1.5 text-xs bg-white/[0.05] border border-white/[0.08] rounded text-gray-200 focus:outline-none focus:border-accent/50 transition-colors"
117
- >
118
- {pageRootTags.map((tag) => (
119
- <option key={tag} value={tag}>
120
- {tag}
121
- </option>
122
- ))}
123
- </select>
124
- </FieldShell>
125
- )
126
- }
127
-
128
- const pageRootTags: PrimitiveType[] = [
129
- 'div',
130
- 'main',
131
- 'section',
132
- 'article',
133
- 'header',
134
- 'footer',
135
- 'nav',
136
- 'aside',
137
- 'form',
138
- ]
139
-
140
- function findNode(nodes: VisualNode[], nodeId: string | null): VisualNode | null {
141
- if (!nodeId) {
142
- return null
143
- }
144
-
145
- for (const node of nodes) {
146
- if (node.id === nodeId) {
147
- return node
148
- }
149
-
150
- const child = findNode(node.children, nodeId)
151
-
152
- if (child) {
153
- return child
154
- }
155
- }
156
-
157
- return null
158
- }
159
-
160
- function ReadOnlyField({ label, value }: { label: string; value: string }) {
161
- return (
162
- <div className="space-y-1">
163
- <label className="text-[10px] font-medium text-gray-500 uppercase tracking-wide">
164
- {label}
165
- </label>
166
- <div className="w-full px-2.5 py-1.5 text-xs bg-white/[0.03] border border-white/[0.06] rounded text-gray-500">
167
- {value}
168
- </div>
169
- </div>
170
- )
171
- }
172
-
173
- function PropField({
174
- schema,
175
- value,
176
- onChange,
177
- }: {
178
- schema: PropSchema
179
- value: unknown
180
- onChange: (value: unknown) => void
181
- }) {
182
- if (schema.type === 'boolean') {
183
- const checked = Boolean(value)
184
-
185
- return (
186
- <div className="flex items-center justify-between">
187
- <label className="text-xs text-gray-400">{schema.label}</label>
188
- <button
189
- type="button"
190
- onClick={() => onChange(!checked)}
191
- className={`w-8 h-4 rounded-full transition-colors ${
192
- checked ? 'bg-accent' : 'bg-white/10'
193
- }`}
194
- >
195
- <span
196
- className={`block w-3 h-3 rounded-full bg-white shadow transition-transform mx-0.5 ${
197
- checked ? 'translate-x-4' : 'translate-x-0'
198
- }`}
199
- />
200
- </button>
201
- </div>
202
- )
203
- }
204
-
205
- if (schema.type === 'array') {
206
- return <ArrayField schema={schema} value={value} onChange={onChange} />
207
- }
208
-
209
- if (schema.type === 'select') {
210
- return (
211
- <FieldShell label={schema.label}>
212
- <select
213
- value={String(value ?? schema.default)}
214
- onChange={(event) => onChange(event.target.value)}
215
- className="w-full px-2.5 py-1.5 text-xs bg-white/[0.05] border border-white/[0.08] rounded text-gray-200 focus:outline-none focus:border-accent/50 transition-colors"
216
- >
217
- {(schema.options ?? []).map((option) => (
218
- <option key={option} value={option}>
219
- {option}
220
- </option>
221
- ))}
222
- </select>
223
- </FieldShell>
224
- )
225
- }
226
-
227
- if (schema.label === 'Tailwind classes') {
228
- return (
229
- <TailwindClassField
230
- value={String(value ?? schema.default)}
231
- onChange={onChange}
232
- />
233
- )
234
- }
235
-
236
- if (schema.type === 'text') {
237
- return (
238
- <FieldShell label={schema.label}>
239
- <textarea
240
- value={String(value ?? schema.default)}
241
- onChange={(event) => onChange(event.target.value)}
242
- rows={3}
243
- className="w-full px-2.5 py-1.5 text-xs bg-white/[0.05] border border-white/[0.08] rounded text-gray-200 focus:outline-none focus:border-accent/50 transition-colors resize-none"
244
- />
245
- </FieldShell>
246
- )
247
- }
248
-
249
- return (
250
- <FieldShell label={schema.label}>
251
- <input
252
- type={schema.type === 'number' ? 'number' : 'text'}
253
- value={String(value ?? schema.default)}
254
- onChange={(event) =>
255
- onChange(
256
- schema.type === 'number'
257
- ? Number(event.target.value)
258
- : event.target.value
259
- )
260
- }
261
- className="w-full px-2.5 py-1.5 text-xs bg-white/[0.05] border border-white/[0.08] rounded text-gray-200 placeholder-gray-600 focus:outline-none focus:border-accent/50 transition-colors"
262
- />
263
- </FieldShell>
264
- )
265
- }
266
-
267
- function TailwindClassField({
268
- value,
269
- onChange,
270
- }: {
271
- value: string
272
- onChange: (value: unknown) => void
273
- }) {
274
- const textareaRef = useRef<HTMLTextAreaElement | null>(null)
275
- const [caretIndex, setCaretIndex] = useState(value.length)
276
- const [isFocused, setIsFocused] = useState(false)
277
- const [activeIndex, setActiveIndex] = useState(0)
278
- const token = getCurrentClassToken(value, caretIndex)
279
- const suggestions = useMemo(() => {
280
- if (!isFocused || token.value.length === 0) {
281
- return []
282
- }
283
-
284
- return tailwindClassSuggestions
285
- .filter((className) => className.startsWith(token.value))
286
- .slice(0, 8)
287
- }, [isFocused, token.value])
288
-
289
- const updateCaret = () => {
290
- setCaretIndex(textareaRef.current?.selectionStart ?? value.length)
291
- }
292
-
293
- const applySuggestion = (className: string) => {
294
- const before = value.slice(0, token.start)
295
- const after = value.slice(token.end)
296
- const needsLeadingSpace = before.length > 0 && !/\s$/.test(before)
297
- const needsTrailingSpace = after.length === 0 || !/^\s/.test(after)
298
- const nextValue = `${before}${needsLeadingSpace ? ' ' : ''}${className}${
299
- needsTrailingSpace ? ' ' : ''
300
- }${after}`
301
- const nextCaret =
302
- before.length + (needsLeadingSpace ? 1 : 0) + className.length + 1
303
-
304
- onChange(nextValue)
305
- setCaretIndex(nextCaret)
306
- setActiveIndex(0)
307
- window.setTimeout(() => {
308
- textareaRef.current?.focus()
309
- textareaRef.current?.setSelectionRange(nextCaret, nextCaret)
310
- }, 0)
311
- }
312
-
313
- return (
314
- <FieldShell label="Tailwind classes">
315
- <div className="relative">
316
- <textarea
317
- ref={textareaRef}
318
- value={value}
319
- onFocus={() => setIsFocused(true)}
320
- onBlur={() => {
321
- window.setTimeout(() => setIsFocused(false), 120)
322
- }}
323
- onChange={(event) => {
324
- onChange(event.target.value)
325
- setCaretIndex(event.target.selectionStart)
326
- setActiveIndex(0)
327
- }}
328
- onSelect={updateCaret}
329
- onKeyUp={updateCaret}
330
- onKeyDown={(event) => {
331
- if (suggestions.length === 0) {
332
- return
333
- }
334
-
335
- if (event.key === 'ArrowDown') {
336
- event.preventDefault()
337
- setActiveIndex((index) => (index + 1) % suggestions.length)
338
- return
339
- }
340
-
341
- if (event.key === 'ArrowUp') {
342
- event.preventDefault()
343
- setActiveIndex(
344
- (index) => (index - 1 + suggestions.length) % suggestions.length
345
- )
346
- return
347
- }
348
-
349
- if (event.key === 'Enter' || event.key === 'Tab') {
350
- event.preventDefault()
351
- applySuggestion(suggestions[activeIndex])
352
- return
353
- }
354
-
355
- if (event.key === 'Escape') {
356
- setIsFocused(false)
357
- }
358
- }}
359
- rows={4}
360
- className="w-full px-2.5 py-1.5 text-xs bg-white/[0.05] border border-white/[0.08] rounded text-gray-200 focus:outline-none focus:border-accent/50 transition-colors resize-none"
361
- />
362
- {suggestions.length > 0 && (
363
- <div className="absolute left-0 right-0 top-full z-30 mt-1 max-h-44 overflow-y-auto rounded border border-white/[0.12] bg-[#11151c] py-1 shadow-xl">
364
- {suggestions.map((className, index) => (
365
- <button
366
- key={className}
367
- type="button"
368
- onMouseDown={(event) => {
369
- event.preventDefault()
370
- applySuggestion(className)
371
- }}
372
- className={`block w-full cursor-pointer px-2.5 py-1.5 text-left font-mono text-[11px] transition-colors ${
373
- index === activeIndex
374
- ? 'bg-accent text-white'
375
- : 'text-gray-300 hover:bg-white/[0.08] hover:text-white'
376
- }`}
377
- >
378
- {className}
379
- </button>
380
- ))}
381
- </div>
382
- )}
383
- </div>
384
- <p className="text-[10px] text-gray-600">
385
- Type a class prefix, then press Tab or Enter to accept.
386
- </p>
387
- </FieldShell>
388
- )
389
- }
390
-
391
- function getCurrentClassToken(value: string, caretIndex: number) {
392
- const safeCaret = Math.max(0, Math.min(caretIndex, value.length))
393
- const before = value.slice(0, safeCaret)
394
- const tokenStart = Math.max(
395
- before.lastIndexOf(' '),
396
- before.lastIndexOf('\n'),
397
- before.lastIndexOf('\t')
398
- ) + 1
399
- const after = value.slice(safeCaret)
400
- const nextBreak = after.search(/\s/)
401
- const tokenEnd = nextBreak === -1 ? value.length : safeCaret + nextBreak
402
-
403
- return {
404
- start: tokenStart,
405
- end: tokenEnd,
406
- value: value.slice(tokenStart, tokenEnd),
407
- }
408
- }
409
-
410
- function ArrayField({
411
- schema,
412
- value,
413
- onChange,
414
- }: {
415
- schema: PropSchema
416
- value: unknown
417
- onChange: (value: unknown) => void
418
- }) {
419
- const [draft, setDraft] = useState(formatArrayValue(value))
420
-
421
- const handleChange = (rawValue: string) => {
422
- setDraft(rawValue)
423
- onChange(parseArrayValue(rawValue))
424
- }
425
-
426
- return (
427
- <FieldShell label={schema.label}>
428
- <textarea
429
- value={draft}
430
- onChange={(event) => handleChange(event.target.value)}
431
- rows={2}
432
- className="w-full px-2.5 py-1.5 text-xs bg-white/[0.05] border border-white/[0.08] rounded text-gray-200 focus:outline-none focus:border-accent/50 transition-colors resize-none"
433
- />
434
- <p className="text-[10px] text-gray-600">Comma or line separated</p>
435
- </FieldShell>
436
- )
437
- }
438
-
439
- function formatArrayValue(value: unknown) {
440
- return Array.isArray(value) ? value.join(', ') : ''
441
- }
442
-
443
- function parseArrayValue(value: string) {
444
- return value
445
- .split(/[,\n]/)
446
- .map((item) => item.trim())
447
- .filter(Boolean)
448
- }
449
-
450
- function FieldShell({
451
- label,
452
- children,
453
- }: {
454
- label: string
455
- children: React.ReactNode
456
- }) {
457
- return (
458
- <div className="space-y-1">
459
- <label className="text-[10px] font-medium text-gray-500 uppercase tracking-wide">
460
- {label}
461
- </label>
462
- {children}
463
- </div>
464
- )
465
- }
466
-
467
- const tailwindClassSuggestions = [
468
- 'block',
469
- 'inline',
470
- 'inline-block',
471
- 'flex',
472
- 'inline-flex',
473
- 'grid',
474
- 'hidden',
475
- 'contents',
476
- 'flex-row',
477
- 'flex-col',
478
- 'flex-wrap',
479
- 'items-start',
480
- 'items-center',
481
- 'items-end',
482
- 'items-stretch',
483
- 'justify-start',
484
- 'justify-center',
485
- 'justify-end',
486
- 'justify-between',
487
- 'justify-around',
488
- 'content-center',
489
- 'self-start',
490
- 'self-center',
491
- 'self-end',
492
- 'gap-1',
493
- 'gap-2',
494
- 'gap-3',
495
- 'gap-4',
496
- 'gap-6',
497
- 'gap-8',
498
- 'grid-cols-1',
499
- 'grid-cols-2',
500
- 'grid-cols-3',
501
- 'grid-cols-4',
502
- 'grid-cols-6',
503
- 'col-span-1',
504
- 'col-span-2',
505
- 'col-span-full',
506
- 'w-full',
507
- 'w-screen',
508
- 'w-auto',
509
- 'w-fit',
510
- 'w-1/2',
511
- 'w-1/3',
512
- 'w-2/3',
513
- 'w-1/4',
514
- 'w-3/4',
515
- 'min-w-0',
516
- 'max-w-sm',
517
- 'max-w-md',
518
- 'max-w-lg',
519
- 'max-w-xl',
520
- 'max-w-2xl',
521
- 'max-w-4xl',
522
- 'max-w-6xl',
523
- 'max-w-full',
524
- 'h-full',
525
- 'h-screen',
526
- 'h-auto',
527
- 'min-h-12',
528
- 'min-h-24',
529
- 'min-h-48',
530
- 'min-h-screen',
531
- 'p-0',
532
- 'p-1',
533
- 'p-2',
534
- 'p-3',
535
- 'p-4',
536
- 'p-6',
537
- 'p-8',
538
- 'px-2',
539
- 'px-3',
540
- 'px-4',
541
- 'px-6',
542
- 'px-8',
543
- 'py-1',
544
- 'py-2',
545
- 'py-3',
546
- 'py-4',
547
- 'py-6',
548
- 'py-8',
549
- 'm-0',
550
- 'mx-auto',
551
- 'mt-1',
552
- 'mt-2',
553
- 'mt-4',
554
- 'mb-1',
555
- 'mb-2',
556
- 'mb-4',
557
- 'space-y-1',
558
- 'space-y-2',
559
- 'space-y-3',
560
- 'space-y-4',
561
- 'text-left',
562
- 'text-center',
563
- 'text-right',
564
- 'text-xs',
565
- 'text-sm',
566
- 'text-base',
567
- 'text-lg',
568
- 'text-xl',
569
- 'text-2xl',
570
- 'text-3xl',
571
- 'text-4xl',
572
- 'text-5xl',
573
- 'font-thin',
574
- 'font-light',
575
- 'font-normal',
576
- 'font-medium',
577
- 'font-semibold',
578
- 'font-bold',
579
- 'font-extrabold',
580
- 'italic',
581
- 'not-italic',
582
- 'underline',
583
- 'line-through',
584
- 'leading-none',
585
- 'leading-tight',
586
- 'leading-normal',
587
- 'leading-relaxed',
588
- 'tracking-tight',
589
- 'tracking-normal',
590
- 'tracking-wide',
591
- 'text-white',
592
- 'text-black',
593
- 'text-gray-50',
594
- 'text-gray-100',
595
- 'text-gray-200',
596
- 'text-gray-300',
597
- 'text-gray-400',
598
- 'text-gray-500',
599
- 'text-gray-600',
600
- 'text-gray-700',
601
- 'text-gray-800',
602
- 'text-gray-900',
603
- 'text-red-500',
604
- 'text-orange-500',
605
- 'text-amber-500',
606
- 'text-yellow-500',
607
- 'text-green-500',
608
- 'text-emerald-500',
609
- 'text-teal-500',
610
- 'text-cyan-500',
611
- 'text-sky-500',
612
- 'text-blue-500',
613
- 'text-blue-600',
614
- 'text-indigo-500',
615
- 'text-violet-500',
616
- 'text-purple-500',
617
- 'text-pink-500',
618
- 'bg-white',
619
- 'bg-black',
620
- 'bg-transparent',
621
- 'bg-gray-50',
622
- 'bg-gray-100',
623
- 'bg-gray-200',
624
- 'bg-gray-300',
625
- 'bg-gray-800',
626
- 'bg-gray-900',
627
- 'bg-red-500',
628
- 'bg-orange-500',
629
- 'bg-yellow-500',
630
- 'bg-green-500',
631
- 'bg-blue-500',
632
- 'bg-blue-600',
633
- 'bg-indigo-600',
634
- 'bg-purple-600',
635
- 'border',
636
- 'border-0',
637
- 'border-2',
638
- 'border-t',
639
- 'border-b',
640
- 'border-l',
641
- 'border-r',
642
- 'border-transparent',
643
- 'border-white',
644
- 'border-black',
645
- 'border-gray-200',
646
- 'border-gray-300',
647
- 'border-gray-700',
648
- 'border-blue-500',
649
- 'rounded',
650
- 'rounded-sm',
651
- 'rounded-md',
652
- 'rounded-lg',
653
- 'rounded-xl',
654
- 'rounded-2xl',
655
- 'rounded-full',
656
- 'shadow',
657
- 'shadow-sm',
658
- 'shadow-md',
659
- 'shadow-lg',
660
- 'shadow-xl',
661
- 'overflow-hidden',
662
- 'overflow-auto',
663
- 'overflow-x-auto',
664
- 'relative',
665
- 'absolute',
666
- 'fixed',
667
- 'sticky',
668
- 'top-0',
669
- 'right-0',
670
- 'bottom-0',
671
- 'left-0',
672
- 'z-10',
673
- 'z-20',
674
- 'z-50',
675
- 'opacity-0',
676
- 'opacity-50',
677
- 'opacity-75',
678
- 'opacity-100',
679
- 'cursor-pointer',
680
- 'select-none',
681
- 'transition',
682
- 'transition-colors',
683
- 'duration-150',
684
- 'duration-200',
685
- 'hover:bg-gray-100',
686
- 'hover:bg-gray-900',
687
- 'hover:text-white',
688
- 'hover:text-black',
689
- 'focus:outline-none',
690
- 'focus:ring-2',
691
- 'focus:ring-blue-500',
692
- ]
1
+ import { useMemo, useRef, useState } from 'react'
2
+ import { primitiveRegistry } from '../data/primitiveRegistry'
3
+ import { getTailwindColorClassSuggestions } from '../data/tailwindClassCatalog'
4
+ import { useAppStore } from '../stores/useAppStore'
5
+ import type { PrimitiveType, PropSchema, VisualNode } from '../types'
6
+
7
+ export default function PropertiesPanel({
8
+ embedded = false,
9
+ }: {
10
+ embedded?: boolean
11
+ }) {
12
+ const {
13
+ pages,
14
+ appShell,
15
+ activePageId,
16
+ selectedComponentId,
17
+ updateNodeProps,
18
+ updatePageRoot,
19
+ customComponents,
20
+ } = useAppStore()
21
+
22
+ const activePage = pages.find((page) => page.id === activePageId)
23
+ const isPageRootSelected =
24
+ activePage?.root.id !== undefined && activePage.root.id === selectedComponentId
25
+ const selectedNode = isPageRootSelected
26
+ ? activePage.root
27
+ : findNode(
28
+ [
29
+ ...(appShell.header ? [appShell.header] : []),
30
+ ...(activePage?.components ?? []),
31
+ ...(appShell.footer ? [appShell.footer] : []),
32
+ ],
33
+ selectedComponentId
34
+ )
35
+
36
+ if (!selectedNode) {
37
+ return (
38
+ <aside
39
+ className={`${embedded ? 'h-full w-full' : 'w-60 border-l'} flex shrink-0 flex-col border-white/[0.06] bg-panel`}
40
+ >
41
+ <div className="px-3 py-2.5 border-b border-white/[0.06]">
42
+ <p className="text-[10px] font-semibold uppercase tracking-widest text-gray-500">
43
+ Properties
44
+ </p>
45
+ </div>
46
+ <div className="flex-1 flex flex-col items-center justify-center text-center px-4">
47
+ <p className="text-xs text-gray-600">
48
+ Select any element on the canvas to edit text, attributes, and Tailwind classes
49
+ </p>
50
+ </div>
51
+ </aside>
52
+ )
53
+ }
54
+
55
+ const definition =
56
+ primitiveRegistry[selectedNode.type as PrimitiveType] ??
57
+ customComponents.find((component) => component.name === selectedNode.type)
58
+
59
+ if (!definition) {
60
+ return (
61
+ <aside className={`${embedded ? 'h-full w-full' : 'w-60 border-l'} flex shrink-0 flex-col border-white/[0.06] bg-panel p-4`}>
62
+ <p className="text-xs text-amber-400">
63
+ {selectedNode.type} is no longer registered in visualbuild.config.ts.
64
+ </p>
65
+ </aside>
66
+ )
67
+ }
68
+
69
+ const handleChange = (key: string, value: unknown) => {
70
+ if (isPageRootSelected && activePage) {
71
+ updatePageRoot(activePage.id, { props: { [key]: value } })
72
+ return
73
+ }
74
+
75
+ updateNodeProps(selectedNode.id, { [key]: value })
76
+ }
77
+
78
+ const handleRootTagChange = (type: PrimitiveType) => {
79
+ if (!activePage) {
80
+ return
81
+ }
82
+
83
+ updatePageRoot(activePage.id, { type })
84
+ }
85
+
86
+ return (
87
+ <aside
88
+ className={`${embedded ? 'h-full w-full' : 'w-60 border-l'} flex shrink-0 flex-col overflow-hidden border-white/[0.06] bg-panel`}
89
+ >
90
+ <div className="px-3 py-2.5 border-b border-white/[0.06] flex items-center justify-between">
91
+ <p className="text-[10px] font-semibold uppercase tracking-widest text-gray-500">
92
+ Properties
93
+ </p>
94
+ <span className="text-[10px] font-medium px-1.5 py-0.5 bg-accent/20 text-accent rounded">
95
+ {isPageRootSelected ? 'Page root' : selectedNode.label ?? definition.label}
96
+ </span>
97
+ </div>
98
+
99
+ <div className="flex-1 overflow-y-auto p-3 space-y-3">
100
+ {isPageRootSelected ? (
101
+ <RootTagField
102
+ value={selectedNode.type as PrimitiveType}
103
+ onChange={handleRootTagChange}
104
+ />
105
+ ) : (
106
+ <ReadOnlyField label="Tag" value={selectedNode.type} />
107
+ )}
108
+ {Object.entries(definition.propSchema).map(([key, schema]) => (
109
+ <PropField
110
+ key={`${selectedNode.id}:${key}`}
111
+ schema={schema}
112
+ value={selectedNode.props[key] ?? schema.default}
113
+ onChange={(value) => handleChange(key, value)}
114
+ />
115
+ ))}
116
+ </div>
117
+ </aside>
118
+ )
119
+ }
120
+
121
+ function RootTagField({
122
+ value,
123
+ onChange,
124
+ }: {
125
+ value: PrimitiveType
126
+ onChange: (type: PrimitiveType) => void
127
+ }) {
128
+ return (
129
+ <FieldShell label="Page root tag">
130
+ <select
131
+ value={value}
132
+ onChange={(event) => onChange(event.target.value as PrimitiveType)}
133
+ className="w-full px-2.5 py-1.5 text-xs bg-white/[0.05] border border-white/[0.08] rounded text-gray-200 focus:outline-none focus:border-accent/50 transition-colors"
134
+ >
135
+ {pageRootTags.map((tag) => (
136
+ <option key={tag} value={tag}>
137
+ {tag}
138
+ </option>
139
+ ))}
140
+ </select>
141
+ </FieldShell>
142
+ )
143
+ }
144
+
145
+ const pageRootTags: PrimitiveType[] = [
146
+ 'div',
147
+ 'main',
148
+ 'section',
149
+ 'article',
150
+ 'header',
151
+ 'footer',
152
+ 'nav',
153
+ 'aside',
154
+ 'form',
155
+ ]
156
+
157
+ function findNode(nodes: VisualNode[], nodeId: string | null): VisualNode | null {
158
+ if (!nodeId) {
159
+ return null
160
+ }
161
+
162
+ for (const node of nodes) {
163
+ if (node.id === nodeId) {
164
+ return node
165
+ }
166
+
167
+ const child = findNode(node.children, nodeId)
168
+
169
+ if (child) {
170
+ return child
171
+ }
172
+
173
+ const componentTree = node.props.componentTree
174
+ if (
175
+ typeof componentTree === 'object' &&
176
+ componentTree !== null &&
177
+ Array.isArray((componentTree as { children?: unknown }).children)
178
+ ) {
179
+ const definitionChild = findNode(
180
+ (componentTree as { children: VisualNode[] }).children,
181
+ nodeId
182
+ )
183
+ if (definitionChild) return definitionChild
184
+ }
185
+ }
186
+
187
+ return null
188
+ }
189
+
190
+ function ReadOnlyField({ label, value }: { label: string; value: string }) {
191
+ return (
192
+ <div className="space-y-1">
193
+ <label className="text-[10px] font-medium text-gray-500 uppercase tracking-wide">
194
+ {label}
195
+ </label>
196
+ <div className="w-full px-2.5 py-1.5 text-xs bg-white/[0.03] border border-white/[0.06] rounded text-gray-500">
197
+ {value}
198
+ </div>
199
+ </div>
200
+ )
201
+ }
202
+
203
+ function PropField({
204
+ schema,
205
+ value,
206
+ onChange,
207
+ }: {
208
+ schema: PropSchema
209
+ value: unknown
210
+ onChange: (value: unknown) => void
211
+ }) {
212
+ if (schema.type === 'boolean') {
213
+ const checked = Boolean(value)
214
+
215
+ return (
216
+ <div className="flex items-center justify-between">
217
+ <label className="text-xs text-gray-400">{schema.label}</label>
218
+ <button
219
+ type="button"
220
+ onClick={() => onChange(!checked)}
221
+ className={`w-8 h-4 rounded-full transition-colors ${
222
+ checked ? 'bg-accent' : 'bg-white/10'
223
+ }`}
224
+ >
225
+ <span
226
+ className={`block w-3 h-3 rounded-full bg-white shadow transition-transform mx-0.5 ${
227
+ checked ? 'translate-x-4' : 'translate-x-0'
228
+ }`}
229
+ />
230
+ </button>
231
+ </div>
232
+ )
233
+ }
234
+
235
+ if (schema.type === 'array') {
236
+ return <ArrayField schema={schema} value={value} onChange={onChange} />
237
+ }
238
+
239
+ if (schema.type === 'attributes') {
240
+ return <AttributesField schema={schema} value={value} onChange={onChange} />
241
+ }
242
+
243
+ if (schema.type === 'select') {
244
+ return (
245
+ <FieldShell label={schema.label}>
246
+ <select
247
+ value={String(value ?? schema.default)}
248
+ onChange={(event) => onChange(event.target.value)}
249
+ className="w-full px-2.5 py-1.5 text-xs bg-white/[0.05] border border-white/[0.08] rounded text-gray-200 focus:outline-none focus:border-accent/50 transition-colors"
250
+ >
251
+ {(schema.options ?? []).map((option) => (
252
+ <option key={option} value={option}>
253
+ {option}
254
+ </option>
255
+ ))}
256
+ </select>
257
+ </FieldShell>
258
+ )
259
+ }
260
+
261
+ if (schema.label === 'Tailwind classes') {
262
+ return (
263
+ <TailwindClassField
264
+ value={String(value ?? schema.default)}
265
+ onChange={onChange}
266
+ />
267
+ )
268
+ }
269
+
270
+ if (schema.type === 'text') {
271
+ return (
272
+ <FieldShell label={schema.label}>
273
+ <textarea
274
+ value={String(value ?? schema.default)}
275
+ onChange={(event) => onChange(event.target.value)}
276
+ rows={3}
277
+ className="w-full px-2.5 py-1.5 text-xs bg-white/[0.05] border border-white/[0.08] rounded text-gray-200 focus:outline-none focus:border-accent/50 transition-colors resize-none"
278
+ />
279
+ </FieldShell>
280
+ )
281
+ }
282
+
283
+ return (
284
+ <FieldShell label={schema.label}>
285
+ <input
286
+ type={schema.type === 'number' ? 'number' : 'text'}
287
+ value={String(value ?? schema.default)}
288
+ onChange={(event) =>
289
+ onChange(
290
+ schema.type === 'number'
291
+ ? Number(event.target.value)
292
+ : event.target.value
293
+ )
294
+ }
295
+ className="w-full px-2.5 py-1.5 text-xs bg-white/[0.05] border border-white/[0.08] rounded text-gray-200 placeholder-gray-600 focus:outline-none focus:border-accent/50 transition-colors"
296
+ />
297
+ </FieldShell>
298
+ )
299
+ }
300
+
301
+ function TailwindClassField({
302
+ value,
303
+ onChange,
304
+ }: {
305
+ value: string
306
+ onChange: (value: unknown) => void
307
+ }) {
308
+ const textareaRef = useRef<HTMLTextAreaElement | null>(null)
309
+ const [caretIndex, setCaretIndex] = useState(value.length)
310
+ const [isFocused, setIsFocused] = useState(false)
311
+ const [activeIndex, setActiveIndex] = useState(0)
312
+ const token = getCurrentClassToken(value, caretIndex)
313
+ const suggestions = useMemo(() => {
314
+ if (!isFocused || token.value.length === 0) {
315
+ return []
316
+ }
317
+
318
+ const variantSeparator = token.value.lastIndexOf(':')
319
+ const variantPrefix =
320
+ variantSeparator >= 0 ? token.value.slice(0, variantSeparator + 1) : ''
321
+ const utilityToken =
322
+ variantSeparator >= 0
323
+ ? token.value.slice(variantSeparator + 1)
324
+ : token.value
325
+ const generalSuggestions = tailwindClassSuggestions
326
+ .filter((className) => className.startsWith(utilityToken))
327
+ .map((className) => `${variantPrefix}${className}`)
328
+
329
+ return Array.from(
330
+ new Set([
331
+ ...getTailwindColorClassSuggestions(token.value),
332
+ ...generalSuggestions,
333
+ ]),
334
+ ).slice(0, 64)
335
+ }, [isFocused, token.value])
336
+
337
+ const updateCaret = () => {
338
+ setCaretIndex(textareaRef.current?.selectionStart ?? value.length)
339
+ }
340
+
341
+ const applySuggestion = (className: string) => {
342
+ const before = value.slice(0, token.start)
343
+ const after = value.slice(token.end)
344
+ const needsLeadingSpace = before.length > 0 && !/\s$/.test(before)
345
+ const needsTrailingSpace = after.length === 0 || !/^\s/.test(after)
346
+ const nextValue = `${before}${needsLeadingSpace ? ' ' : ''}${className}${
347
+ needsTrailingSpace ? ' ' : ''
348
+ }${after}`
349
+ const nextCaret =
350
+ before.length + (needsLeadingSpace ? 1 : 0) + className.length + 1
351
+
352
+ onChange(nextValue)
353
+ setCaretIndex(nextCaret)
354
+ setActiveIndex(0)
355
+ window.setTimeout(() => {
356
+ textareaRef.current?.focus()
357
+ textareaRef.current?.setSelectionRange(nextCaret, nextCaret)
358
+ }, 0)
359
+ }
360
+
361
+ return (
362
+ <FieldShell label="Tailwind classes">
363
+ <div className="relative">
364
+ <textarea
365
+ ref={textareaRef}
366
+ value={value}
367
+ onFocus={() => setIsFocused(true)}
368
+ onBlur={() => {
369
+ window.setTimeout(() => setIsFocused(false), 120)
370
+ }}
371
+ onChange={(event) => {
372
+ onChange(event.target.value)
373
+ setCaretIndex(event.target.selectionStart)
374
+ setActiveIndex(0)
375
+ }}
376
+ onSelect={updateCaret}
377
+ onKeyUp={updateCaret}
378
+ onKeyDown={(event) => {
379
+ if (suggestions.length === 0) {
380
+ return
381
+ }
382
+
383
+ if (event.key === 'ArrowDown') {
384
+ event.preventDefault()
385
+ setActiveIndex((index) => (index + 1) % suggestions.length)
386
+ return
387
+ }
388
+
389
+ if (event.key === 'ArrowUp') {
390
+ event.preventDefault()
391
+ setActiveIndex(
392
+ (index) => (index - 1 + suggestions.length) % suggestions.length
393
+ )
394
+ return
395
+ }
396
+
397
+ if (event.key === 'Enter' || event.key === 'Tab') {
398
+ event.preventDefault()
399
+ applySuggestion(suggestions[activeIndex])
400
+ return
401
+ }
402
+
403
+ if (event.key === 'Escape') {
404
+ setIsFocused(false)
405
+ }
406
+ }}
407
+ rows={4}
408
+ className="w-full px-2.5 py-1.5 text-xs bg-white/[0.05] border border-white/[0.08] rounded text-gray-200 focus:outline-none focus:border-accent/50 transition-colors resize-none"
409
+ />
410
+ {suggestions.length > 0 && (
411
+ <div className="absolute left-0 right-0 top-full z-30 mt-1 max-h-44 overflow-y-auto rounded border border-white/[0.12] bg-[#11151c] py-1 shadow-xl">
412
+ {suggestions.map((className, index) => (
413
+ <button
414
+ key={className}
415
+ type="button"
416
+ onMouseDown={(event) => {
417
+ event.preventDefault()
418
+ applySuggestion(className)
419
+ }}
420
+ className={`block w-full cursor-pointer px-2.5 py-1.5 text-left font-mono text-[11px] transition-colors ${
421
+ index === activeIndex
422
+ ? 'bg-accent text-white'
423
+ : 'text-gray-300 hover:bg-white/[0.08] hover:text-white'
424
+ }`}
425
+ >
426
+ {className}
427
+ </button>
428
+ ))}
429
+ </div>
430
+ )}
431
+ </div>
432
+ <p className="text-[10px] text-gray-600">
433
+ Type a class prefix, then press Tab or Enter to accept.
434
+ </p>
435
+ </FieldShell>
436
+ )
437
+ }
438
+
439
+ function getCurrentClassToken(value: string, caretIndex: number) {
440
+ const safeCaret = Math.max(0, Math.min(caretIndex, value.length))
441
+ const before = value.slice(0, safeCaret)
442
+ const tokenStart = Math.max(
443
+ before.lastIndexOf(' '),
444
+ before.lastIndexOf('\n'),
445
+ before.lastIndexOf('\t')
446
+ ) + 1
447
+ const after = value.slice(safeCaret)
448
+ const nextBreak = after.search(/\s/)
449
+ const tokenEnd = nextBreak === -1 ? value.length : safeCaret + nextBreak
450
+
451
+ return {
452
+ start: tokenStart,
453
+ end: tokenEnd,
454
+ value: value.slice(tokenStart, tokenEnd),
455
+ }
456
+ }
457
+
458
+ function ArrayField({
459
+ schema,
460
+ value,
461
+ onChange,
462
+ }: {
463
+ schema: PropSchema
464
+ value: unknown
465
+ onChange: (value: unknown) => void
466
+ }) {
467
+ const [draft, setDraft] = useState(formatArrayValue(value))
468
+
469
+ const handleChange = (rawValue: string) => {
470
+ setDraft(rawValue)
471
+ onChange(parseArrayValue(rawValue))
472
+ }
473
+
474
+ return (
475
+ <FieldShell label={schema.label}>
476
+ <textarea
477
+ value={draft}
478
+ onChange={(event) => handleChange(event.target.value)}
479
+ rows={2}
480
+ className="w-full px-2.5 py-1.5 text-xs bg-white/[0.05] border border-white/[0.08] rounded text-gray-200 focus:outline-none focus:border-accent/50 transition-colors resize-none"
481
+ />
482
+ <p className="text-[10px] text-gray-600">Comma or line separated</p>
483
+ </FieldShell>
484
+ )
485
+ }
486
+
487
+ function AttributesField({
488
+ schema,
489
+ value,
490
+ onChange,
491
+ }: {
492
+ schema: PropSchema
493
+ value: unknown
494
+ onChange: (value: unknown) => void
495
+ }) {
496
+ const [draft, setDraft] = useState(() =>
497
+ JSON.stringify(
498
+ value && typeof value === 'object' && !Array.isArray(value) ? value : {},
499
+ null,
500
+ 2
501
+ )
502
+ )
503
+ const [error, setError] = useState<string | null>(null)
504
+
505
+ return (
506
+ <FieldShell label={schema.label}>
507
+ <textarea
508
+ value={draft}
509
+ onChange={(event) => {
510
+ const nextDraft = event.target.value
511
+ setDraft(nextDraft)
512
+
513
+ try {
514
+ const parsed = JSON.parse(nextDraft) as unknown
515
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
516
+ throw new Error('Use a JSON object')
517
+ }
518
+ setError(null)
519
+ onChange(parsed)
520
+ } catch (parseError) {
521
+ setError(
522
+ parseError instanceof Error
523
+ ? parseError.message
524
+ : 'Enter valid JSON'
525
+ )
526
+ }
527
+ }}
528
+ rows={5}
529
+ spellCheck={false}
530
+ className="w-full resize-none rounded border border-white/[0.08] bg-white/[0.05] px-2.5 py-1.5 font-mono text-xs text-gray-200 outline-none transition-colors focus:border-accent/50"
531
+ />
532
+ <p className={`text-[10px] ${error ? 'text-red-300' : 'text-gray-600'}`}>
533
+ {error ?? 'JSON attributes, for example {"aria-label": "Card"}'}
534
+ </p>
535
+ </FieldShell>
536
+ )
537
+ }
538
+
539
+ function formatArrayValue(value: unknown) {
540
+ return Array.isArray(value) ? value.join(', ') : ''
541
+ }
542
+
543
+ function parseArrayValue(value: string) {
544
+ return value
545
+ .split(/[,\n]/)
546
+ .map((item) => item.trim())
547
+ .filter(Boolean)
548
+ }
549
+
550
+ function FieldShell({
551
+ label,
552
+ children,
553
+ }: {
554
+ label: string
555
+ children: React.ReactNode
556
+ }) {
557
+ return (
558
+ <div className="space-y-1">
559
+ <label className="text-[10px] font-medium text-gray-500 uppercase tracking-wide">
560
+ {label}
561
+ </label>
562
+ {children}
563
+ </div>
564
+ )
565
+ }
566
+
567
+ const tailwindClassSuggestions = [
568
+ 'block',
569
+ 'inline',
570
+ 'inline-block',
571
+ 'flex',
572
+ 'inline-flex',
573
+ 'grid',
574
+ 'hidden',
575
+ 'contents',
576
+ 'flex-row',
577
+ 'flex-col',
578
+ 'flex-wrap',
579
+ 'items-start',
580
+ 'items-center',
581
+ 'items-end',
582
+ 'items-stretch',
583
+ 'justify-start',
584
+ 'justify-center',
585
+ 'justify-end',
586
+ 'justify-between',
587
+ 'justify-around',
588
+ 'content-center',
589
+ 'self-start',
590
+ 'self-center',
591
+ 'self-end',
592
+ 'gap-1',
593
+ 'gap-2',
594
+ 'gap-3',
595
+ 'gap-4',
596
+ 'gap-6',
597
+ 'gap-8',
598
+ 'grid-cols-1',
599
+ 'grid-cols-2',
600
+ 'grid-cols-3',
601
+ 'grid-cols-4',
602
+ 'grid-cols-6',
603
+ 'col-span-1',
604
+ 'col-span-2',
605
+ 'col-span-full',
606
+ 'w-full',
607
+ 'w-screen',
608
+ 'w-auto',
609
+ 'w-fit',
610
+ 'w-1/2',
611
+ 'w-1/3',
612
+ 'w-2/3',
613
+ 'w-1/4',
614
+ 'w-3/4',
615
+ 'min-w-0',
616
+ 'max-w-sm',
617
+ 'max-w-md',
618
+ 'max-w-lg',
619
+ 'max-w-xl',
620
+ 'max-w-2xl',
621
+ 'max-w-4xl',
622
+ 'max-w-6xl',
623
+ 'max-w-full',
624
+ 'h-full',
625
+ 'h-screen',
626
+ 'h-auto',
627
+ 'min-h-12',
628
+ 'min-h-24',
629
+ 'min-h-48',
630
+ 'min-h-screen',
631
+ 'p-0',
632
+ 'p-1',
633
+ 'p-2',
634
+ 'p-3',
635
+ 'p-4',
636
+ 'p-6',
637
+ 'p-8',
638
+ 'px-2',
639
+ 'px-3',
640
+ 'px-4',
641
+ 'px-6',
642
+ 'px-8',
643
+ 'py-1',
644
+ 'py-2',
645
+ 'py-3',
646
+ 'py-4',
647
+ 'py-6',
648
+ 'py-8',
649
+ 'm-0',
650
+ 'mx-auto',
651
+ 'mt-1',
652
+ 'mt-2',
653
+ 'mt-4',
654
+ 'mb-1',
655
+ 'mb-2',
656
+ 'mb-4',
657
+ 'space-y-1',
658
+ 'space-y-2',
659
+ 'space-y-3',
660
+ 'space-y-4',
661
+ 'text-left',
662
+ 'text-center',
663
+ 'text-right',
664
+ 'text-xs',
665
+ 'text-sm',
666
+ 'text-base',
667
+ 'text-lg',
668
+ 'text-xl',
669
+ 'text-2xl',
670
+ 'text-3xl',
671
+ 'text-4xl',
672
+ 'text-5xl',
673
+ 'font-thin',
674
+ 'font-light',
675
+ 'font-normal',
676
+ 'font-medium',
677
+ 'font-semibold',
678
+ 'font-bold',
679
+ 'font-extrabold',
680
+ 'italic',
681
+ 'not-italic',
682
+ 'underline',
683
+ 'line-through',
684
+ 'leading-none',
685
+ 'leading-tight',
686
+ 'leading-normal',
687
+ 'leading-relaxed',
688
+ 'tracking-tight',
689
+ 'tracking-normal',
690
+ 'tracking-wide',
691
+ 'text-white',
692
+ 'text-black',
693
+ 'text-gray-50',
694
+ 'text-gray-100',
695
+ 'text-gray-200',
696
+ 'text-gray-300',
697
+ 'text-gray-400',
698
+ 'text-gray-500',
699
+ 'text-gray-600',
700
+ 'text-gray-700',
701
+ 'text-gray-800',
702
+ 'text-gray-900',
703
+ 'text-red-500',
704
+ 'text-orange-500',
705
+ 'text-amber-500',
706
+ 'text-yellow-500',
707
+ 'text-green-500',
708
+ 'text-emerald-500',
709
+ 'text-teal-500',
710
+ 'text-cyan-500',
711
+ 'text-sky-500',
712
+ 'text-blue-500',
713
+ 'text-blue-600',
714
+ 'text-indigo-500',
715
+ 'text-violet-500',
716
+ 'text-purple-500',
717
+ 'text-pink-500',
718
+ 'bg-white',
719
+ 'bg-black',
720
+ 'bg-transparent',
721
+ 'bg-gray-50',
722
+ 'bg-gray-100',
723
+ 'bg-gray-200',
724
+ 'bg-gray-300',
725
+ 'bg-gray-800',
726
+ 'bg-gray-900',
727
+ 'bg-red-500',
728
+ 'bg-orange-500',
729
+ 'bg-yellow-500',
730
+ 'bg-green-500',
731
+ 'bg-blue-500',
732
+ 'bg-blue-600',
733
+ 'bg-indigo-600',
734
+ 'bg-purple-600',
735
+ 'border',
736
+ 'border-0',
737
+ 'border-2',
738
+ 'border-t',
739
+ 'border-b',
740
+ 'border-l',
741
+ 'border-r',
742
+ 'border-transparent',
743
+ 'border-white',
744
+ 'border-black',
745
+ 'border-gray-200',
746
+ 'border-gray-300',
747
+ 'border-gray-700',
748
+ 'border-blue-500',
749
+ 'rounded',
750
+ 'rounded-sm',
751
+ 'rounded-md',
752
+ 'rounded-lg',
753
+ 'rounded-xl',
754
+ 'rounded-2xl',
755
+ 'rounded-full',
756
+ 'shadow',
757
+ 'shadow-sm',
758
+ 'shadow-md',
759
+ 'shadow-lg',
760
+ 'shadow-xl',
761
+ 'overflow-hidden',
762
+ 'overflow-auto',
763
+ 'overflow-x-auto',
764
+ 'relative',
765
+ 'absolute',
766
+ 'fixed',
767
+ 'sticky',
768
+ 'top-0',
769
+ 'right-0',
770
+ 'bottom-0',
771
+ 'left-0',
772
+ 'z-10',
773
+ 'z-20',
774
+ 'z-50',
775
+ 'opacity-0',
776
+ 'opacity-50',
777
+ 'opacity-75',
778
+ 'opacity-100',
779
+ 'cursor-pointer',
780
+ 'select-none',
781
+ 'transition',
782
+ 'transition-colors',
783
+ 'duration-150',
784
+ 'duration-200',
785
+ 'hover:bg-gray-100',
786
+ 'hover:bg-gray-900',
787
+ 'hover:text-white',
788
+ 'hover:text-black',
789
+ 'focus:outline-none',
790
+ 'focus:ring-2',
791
+ 'focus:ring-blue-500',
792
+ ]