fvn-ui 0.1.0-alpha.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.
Files changed (44) hide show
  1. package/README.md +57 -0
  2. package/package.json +63 -0
  3. package/src/fvn-ui/LLM.md +312 -0
  4. package/src/fvn-ui/components/avatar.css +53 -0
  5. package/src/fvn-ui/components/avatar.js +69 -0
  6. package/src/fvn-ui/components/button.css +143 -0
  7. package/src/fvn-ui/components/button.js +136 -0
  8. package/src/fvn-ui/components/card.css +6 -0
  9. package/src/fvn-ui/components/card.js +63 -0
  10. package/src/fvn-ui/components/checkbox.css +5 -0
  11. package/src/fvn-ui/components/checkbox.js +82 -0
  12. package/src/fvn-ui/components/collapsible.css +22 -0
  13. package/src/fvn-ui/components/collapsible.js +72 -0
  14. package/src/fvn-ui/components/confirm.js +109 -0
  15. package/src/fvn-ui/components/dashboard.css +25 -0
  16. package/src/fvn-ui/components/dashboard.js +130 -0
  17. package/src/fvn-ui/components/dialog.css +79 -0
  18. package/src/fvn-ui/components/dialog.js +302 -0
  19. package/src/fvn-ui/components/form.css +99 -0
  20. package/src/fvn-ui/components/image.css +21 -0
  21. package/src/fvn-ui/components/image.js +70 -0
  22. package/src/fvn-ui/components/index.js +73 -0
  23. package/src/fvn-ui/components/input.css +30 -0
  24. package/src/fvn-ui/components/input.js +81 -0
  25. package/src/fvn-ui/components/radio.css +3 -0
  26. package/src/fvn-ui/components/radio.js +99 -0
  27. package/src/fvn-ui/components/select.css +160 -0
  28. package/src/fvn-ui/components/select.js +366 -0
  29. package/src/fvn-ui/components/svg.css +5 -0
  30. package/src/fvn-ui/components/svg.js +85 -0
  31. package/src/fvn-ui/components/switch.css +34 -0
  32. package/src/fvn-ui/components/switch.js +85 -0
  33. package/src/fvn-ui/components/tabs.css +168 -0
  34. package/src/fvn-ui/components/tabs.js +181 -0
  35. package/src/fvn-ui/components/text.css +62 -0
  36. package/src/fvn-ui/components/text.js +105 -0
  37. package/src/fvn-ui/components/toggle.css +46 -0
  38. package/src/fvn-ui/components/toggle.js +60 -0
  39. package/src/fvn-ui/dom.js +495 -0
  40. package/src/fvn-ui/helpers.js +29 -0
  41. package/src/fvn-ui/index.js +53 -0
  42. package/src/fvn-ui/style.css +432 -0
  43. package/src/fvn-ui/template.js +135 -0
  44. package/src/fvn-ui/template.md +26 -0
@@ -0,0 +1,29 @@
1
+ export const merge = (target, ...sources) => {
2
+ for (const source of sources) {
3
+ for (const [key, val] of Object.entries(source)) {
4
+ const existing = target[key];
5
+
6
+ // Special handling for 'class' - always merge, never replace
7
+ if (key === 'class') {
8
+ target.class = [existing, val];
9
+ continue;
10
+ }
11
+
12
+ // Merge arrays
13
+ if (Array.isArray(existing) && Array.isArray(val)) {
14
+ existing.push(...val);
15
+ continue;
16
+ }
17
+
18
+ // Deep merge objects
19
+ if (existing && typeof existing === 'object' && !Array.isArray(existing)
20
+ && val && typeof val === 'object' && !Array.isArray(val)) {
21
+ merge(existing, val);
22
+ continue;
23
+ }
24
+
25
+ target[key] = val;
26
+ }
27
+ }
28
+ return target;
29
+ };
@@ -0,0 +1,53 @@
1
+ /**
2
+ * fvn-ui — Minimalist vanilla JS component library
3
+ * Requires a bundler that handles CSS imports (Vite, Webpack, etc.)
4
+ * @see ./LLM.md for usage reference
5
+ */
6
+ import './style.css'
7
+
8
+ export { dom, colors, el, row, col, layout } from './dom.js'
9
+ export { template, processTemplates, autoProcess } from './template.js'
10
+
11
+ export {
12
+ avatar,
13
+ button,
14
+ buttonGroup,
15
+ card,
16
+ checkbox,
17
+ collapsible,
18
+ confirm,
19
+ dashboard,
20
+ dialog,
21
+ image,
22
+ input,
23
+ modal,
24
+ radio,
25
+ selectComponent,
26
+ svg,
27
+ switchComponent,
28
+ tabs,
29
+ toggle,
30
+ tooltip,
31
+
32
+ // Text primitives
33
+ text,
34
+ title,
35
+ description,
36
+ header
37
+ } from './components/index.js'
38
+
39
+ // Namespaced export for cleaner DX: ui.button(), ui.switch(), etc.
40
+ import * as components from './components/index.js'
41
+ import { layout } from './dom.js'
42
+ export const ui = {
43
+ ...components,
44
+ select: components.selectComponent,
45
+ switch: components.switchComponent,
46
+ layout
47
+ };
48
+
49
+ document.body.classList.add('fvn-ui');
50
+
51
+ if (matchMedia('(prefers-color-scheme: dark)').matches) {
52
+ document.documentElement.classList.add('dark');
53
+ }
@@ -0,0 +1,432 @@
1
+ /* ---- Font ---- */
2
+ @import url('https://rsms.me/inter/inter.css');
3
+
4
+ /* Inter font feature settings for crispness */
5
+ :root {
6
+ font-feature-settings: 'liga' 1, 'calt' 1; /* Enable ligatures and contextual alternates */
7
+ }
8
+ @supports (font-variation-settings: normal) {
9
+ :root { font-family: 'Inter var', sans-serif; }
10
+ }
11
+
12
+ /* ---- Tokens ---- */
13
+ :root {
14
+ --core: 15px;
15
+ --icon-size-default: calc(1.2 * var(--core));
16
+ --radius: calc(var(--core) * .375);
17
+
18
+ /* Spacing scale */
19
+ --space-1: calc(var(--core) * .25);
20
+ --space-2: calc(var(--core) * .5);
21
+ --space-3: calc(var(--core) * .75);
22
+ --space-4: calc(var(--core) * 1);
23
+ --space-5: calc(var(--core) * 1.5);
24
+ --space-6: calc(var(--core) * 1.75);
25
+ --space-7: calc(var(--core) * 2);
26
+ --space-8: calc(var(--core) * 3);
27
+ --space-9: calc(var(--core) * 4);
28
+ --space-10: calc(var(--core) * 5);
29
+ --space: calc(var(--core) * .85);
30
+ --padding: var(--space-8);
31
+
32
+ /* Presets */
33
+ --input-padding: calc(var(--space) * .75) var(--space);
34
+ --shadow-sm: 0 1px 2px rgba(0,0,0,.1);
35
+ --shadow-md: rgba(100, 100, 111, 0.2) 0px 7px 29px 0px;
36
+
37
+ --core-transition: all .15s ease;
38
+
39
+ /* HSL Colors */
40
+ --hsl-white: 0 0% 100%;
41
+ --hsl-black: 256 15% 20%;
42
+ --hsl-back: 0 0% 100%;
43
+ --hsl-text: var(--hsl-black);
44
+ --hsl-muted: 256 15% 15%;
45
+ --hsl-hover: 240 3.45% 94.31%;
46
+ --hsl-border: var(--hsl-text);
47
+
48
+ /* Computed colors */
49
+ --back: hsl(var(--hsl-back));
50
+ --text: hsl(var(--hsl-text));
51
+ --muted: hsl(var(--hsl-muted) / .5);
52
+
53
+ --border-opacity: .15;
54
+ --border-opacity-sub: .5;
55
+ --border: hsl(var(--hsl-border) / var(--border-opacity, 1));
56
+ --hover: hsl(var(--hsl-hover) / var(--hover-opacity, 1));
57
+ --hover-bright: hsl(var(--hsl-text) / .02);
58
+
59
+ --hsl-shade: 240 5.88% 96.67%;
60
+ --shade: hsl(var(--hsl-shade));
61
+
62
+ /* Semantic colors */
63
+ --hsl-green: 166 100% 37%;
64
+ --green: hsl(var(--hsl-green));
65
+ --green-text: hsl(var(--hsl-white));
66
+
67
+ --hsl-blue: 208 100% 50%;
68
+ --blue: hsl(var(--hsl-blue));
69
+ --blue-text: hsl(var(--hsl-white));
70
+
71
+ --hsl-primary: 256 91% 54%;
72
+ --primary: hsl(var(--hsl-primary));
73
+ --primary-text: hsl(var(--hsl-white));
74
+
75
+ --hsl-pink: 314 100% 47%;
76
+ --pink: hsl(var(--hsl-pink));
77
+ --pink-text: hsl(var(--hsl-white));
78
+
79
+ --hsl-red: 332 100% 50%;
80
+ --red: hsl(var(--hsl-red));
81
+ --red-text: hsl(var(--hsl-white));
82
+
83
+ --hsl-orange: 40 100% 62%;
84
+ --orange: hsl(var(--hsl-orange));
85
+ --orange-text: hsl(var(--hsl-black));
86
+
87
+ --hsl-yellow: 60 92% 71%;
88
+ --yellow: hsl(var(--hsl-yellow));
89
+ --yellow-text: hsl(var(--hsl-black));
90
+
91
+ --dark-hsl-black: 222 20% 20%;
92
+ --dark-hsl-white: 0 0% 90%;
93
+ --dark-hsl-back: 222 20% 20%;
94
+ --dark-hsl-text: 0 0% 90%;
95
+ --dark-hsl-muted: 0 0% 95%;
96
+ --dark-hsl-primary: 256 91% 70%;
97
+ --dark-hsl-red: 332 100% 60%;
98
+ --dark-hsl-shade: 222 20% 24%;
99
+ --dark-hover: hsl(221.05 14.29% 26.08%);
100
+ --dark-hover-bright: hsl(var(--hsl-white) / .02);
101
+ --dark-shade: hsl(var(--dark-hsl-shade));
102
+ --dark-border-opacity: .15;
103
+ --dark-shadow-sm: 0 1px 2px rgba(0,0,0,.3);
104
+ --dark-shadow-md: 0 10px 25px rgba(0,0,0,.4);
105
+
106
+ --shade-mix: black;
107
+ }
108
+
109
+ /* ---- Dark Mode ---- */
110
+ .dark {
111
+ --hsl-black: var(--dark-hsl-black);
112
+ --hsl-white: var(--dark-hsl-white);
113
+ --hsl-back: var(--dark-hsl-back);
114
+ --hsl-text: var(--dark-hsl-text);
115
+ --hsl-muted: var(--dark-hsl-muted);
116
+ --hsl-primary: var(--dark-hsl-primary);
117
+ --hsl-red: var(--dark-hsl-red);
118
+ --hover: var(--dark-hover);
119
+ --hover-bright: var(--dark-hover-bright);
120
+ --hsl-shade: var(--dark-hsl-shade);
121
+ --shade: var(--dark-shade);
122
+ --border-opacity: var(--dark-border-opacity);
123
+ --shadow-sm: var(--dark-shadow-sm);
124
+ --shadow-md: var(--dark-shadow-md);
125
+
126
+ --shade-mix: white;
127
+ }
128
+
129
+ /* ---- Reset ---- */
130
+ * {
131
+ box-sizing: border-box;
132
+ -webkit-font-smoothing: antialiased;
133
+ -moz-osx-font-smoothing: grayscale;
134
+ }
135
+ svg {
136
+ shape-rendering: geometricPrecision;
137
+ }
138
+ button, input, select, textarea {
139
+ font: inherit;
140
+ font-weight: 400;
141
+ font-size: calc(.9 * var(--core));
142
+ }
143
+
144
+ body.fvn-ui {
145
+ margin: 0;
146
+ font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
147
+ background: var(--back);
148
+ color: var(--text);
149
+ padding: var(--padding);
150
+ text-rendering: optimizeLegibility;
151
+ font-optical-sizing: auto;
152
+ font-weight: 400;
153
+ font-size: var(--core);
154
+
155
+ &.shaded {
156
+ --shade: color-mix(in hsl, hsl(var(--hsl-shade)), var(--shade-mix) 3%);
157
+ --shade-back: hsl(var(--hsl-shade));
158
+ background: var(--shade-back);
159
+
160
+ & .ui-border:not(.ui-card *) {
161
+ border-color: transparent;
162
+ }
163
+ & .ui-card {
164
+ border-color: transparent;
165
+ box-shadow: none;
166
+
167
+ & .ui-card {
168
+ border-color: var(--border);
169
+ box-shadow: var(--shadow-sm);
170
+ }
171
+ }
172
+ }
173
+
174
+ /* ---- stray ---- */
175
+
176
+ & b {
177
+ font-weight: 500;
178
+ }
179
+
180
+ & a {
181
+ color: var(--primary);
182
+ text-decoration: underline;
183
+ }
184
+
185
+ & .ui-hidden {
186
+ display: none;
187
+ }
188
+ }
189
+
190
+ /* ---- Utilities ---- */
191
+ /* :root wrapper for specificity boost */
192
+ :root {
193
+ & .flex { display: flex; flex-wrap: wrap; }
194
+ & .nowrap { flex-wrap: nowrap; }
195
+ & .flex-1 { flex: 1; }
196
+ & .flex-0 { flex: 0; }
197
+ & .flex-col { flex-direction: column; }
198
+
199
+ & .align-start { align-items: flex-start; }
200
+ & .align-center { align-items: center; }
201
+ & .align-end { align-items: flex-end; }
202
+ & .align-stretch { align-items: stretch; }
203
+
204
+ & .justify-start { justify-content: flex-start; }
205
+ & .justify-center { justify-content: center; }
206
+ & .justify-end { justify-content: flex-end; }
207
+ & .justify-between { justify-content: space-between; }
208
+ & .justify-around { justify-content: space-around; }
209
+ & .justify-evenly { justify-content: space-evenly; }
210
+
211
+ & .w-full { width: 100%; }
212
+ & .h-full { height: 100%; }
213
+ & .min-h-screen { min-height: calc(100vh - (2 * var(--space))); }
214
+
215
+ & .round { border-radius: var(--radius); }
216
+
217
+ & .shade { background: var(--shade); padding: var(--padding); }
218
+ & .border { border: 1px solid var(--border); padding: var(--padding); }
219
+
220
+ & .border-bottom { border-bottom: 1px solid var(--border); padding-bottom: var(--space); }
221
+ & .border-top { border-top: 1px solid var(--border); padding-top: var(--space); }
222
+
223
+ /* Gap utilities */
224
+ & .gap { gap: var(--space); }
225
+ & .gap-0 { gap: 0; }
226
+ & .gap-1 { gap: var(--space-1); }
227
+ & .gap-2 { gap: var(--space-2); }
228
+ & .gap-3 { gap: var(--space-3); }
229
+ & .gap-4 { gap: var(--space-4); }
230
+ & .gap-5 { gap: var(--space-5); }
231
+ & .gap-6 { gap: var(--space-6); }
232
+ & .gap-7 { gap: var(--space-7); }
233
+ & .gap-8 { gap: var(--space-8); }
234
+ & .gap-9 { gap: var(--space-9); }
235
+ & .gap-10 { gap: var(--space-10); }
236
+
237
+ /* Padding utilities */
238
+ & .pad { padding: var(--padding); }
239
+ & .pad-1 { padding: var(--space-1); }
240
+ & .pad-2 { padding: var(--space-2); }
241
+ & .pad-3 { padding: var(--space-3); }
242
+ & .pad-4 { padding: var(--space-4); }
243
+ & .pad-5 { padding: var(--space-5); }
244
+ & .pad-6 { padding: var(--space-6); }
245
+ & .pad-7 { padding: var(--space-7); }
246
+ & .pad-8 { padding: var(--space-8); }
247
+ & .pad-9 { padding: var(--space-9); }
248
+ & .pad-10 { padding: var(--space-10); }
249
+
250
+ & .inline { padding-inline: var(--space); }
251
+ & .inline-1 { padding-inline: var(--space-1); }
252
+ & .inline-2 { padding-inline: var(--space-2); }
253
+ & .inline-3 { padding-inline: var(--space-3); }
254
+ & .inline-4 { padding-inline: var(--space-4); }
255
+ & .inline-5 { padding-inline: var(--space-5); }
256
+ & .inline-6 { padding-inline: var(--space-6); }
257
+ & .inline-7 { padding-inline: var(--space-7); }
258
+ & .inline-8 { padding-inline: var(--space-8); }
259
+ & .inline-9 { padding-inline: var(--space-9); }
260
+ & .inline-10 { padding-inline: var(--space-10); }
261
+
262
+ & .block { padding-block: var(--space); }
263
+ & .block-1 { padding-block: var(--space-1); }
264
+ & .block-2 { padding-block: var(--space-2); }
265
+ & .block-3 { padding-block: var(--space-3); }
266
+ & .block-4 { padding-block: var(--space-4); }
267
+ & .block-5 { padding-block: var(--space-5); }
268
+ & .block-6 { padding-block: var(--space-6); }
269
+ & .block-7 { padding-block: var(--space-7); }
270
+ & .block-8 { padding-block: var(--space-8); }
271
+ & .block-9 { padding-block: var(--space-9); }
272
+ & .block-10 { padding-block: var(--space-10); }
273
+
274
+ /* Text utilities */
275
+ & .small, & small { font-size: .9em; line-height: 1.25em; }
276
+ & .muted { color: var(--muted); }
277
+
278
+ /* Margin utilities */
279
+ & .ma { margin: auto; }
280
+ & .mt-2 { margin-top: var(--space-2); }
281
+ & .mt-4 { margin-top: var(--space-4); }
282
+
283
+ /* Border utilities */
284
+ & .bt { border-top: 1px solid var(--border); }
285
+ & .bb { border-bottom: 1px solid var(--border); }
286
+ & .bl { border-left: 1px solid var(--border); }
287
+ & .br { border-right: 1px solid var(--border); }
288
+ }
289
+
290
+ /* ---- Focus Ring ---- */
291
+ .ui-btn:focus-visible,
292
+ .ui-input:focus-visible,
293
+ .ui-select:focus-visible,
294
+ .ui-select__trigger:focus-visible,
295
+ .ui-select__item:focus-visible,
296
+ .ui-switch__button:focus-visible,
297
+ .ui-tabs__tab:focus-visible {
298
+ outline: none;
299
+ box-shadow: var(--shadow-sm);
300
+ }
301
+
302
+ /* :root wrapper for specificity boost over component defaults */
303
+ :root {
304
+ & .ui-inverted {
305
+ --back: hsl(var(--hsl-text));
306
+ --text: hsl(var(--hsl-back));
307
+ --muted: hsl(var(--hsl-back) / .5);
308
+ --border: hsl(var(--hsl-text) / var(--border-opacity, .2));
309
+ --hover: hsl(var(--hsl-text) / var(--hover-opacity, .08));
310
+ }
311
+
312
+ & [data-ui-col="none"] {
313
+ --bg: var(--text);
314
+ --fg: var(--back);
315
+ }
316
+ & [data-ui-col="default"] {
317
+ --bg: var(--text);
318
+ --fg: var(--back);
319
+ }
320
+ & [data-ui-col="primary"] {
321
+ --bg: var(--primary);
322
+ --fg: var(--primary-text, var(--back));
323
+ }
324
+ & [data-ui-col="secondary"] {
325
+ --bg: var(--hover);
326
+ }
327
+ & [data-ui-col="red"] {
328
+ --bg: var(--red);
329
+ --fg: var(--red-text, var(--back));
330
+ }
331
+ & [data-ui-col="green"] {
332
+ --bg: var(--green);
333
+ --fg: var(--green-text, var(--back));
334
+ }
335
+ & [data-ui-col="yellow"] {
336
+ --bg: var(--yellow);
337
+ --fg: var(--yellow-text, var(--front));
338
+ }
339
+ & [data-ui-col="orange"] {
340
+ --bg: var(--orange);
341
+ --fg: var(--orange-text, var(--front));
342
+ }
343
+ & [data-ui-col="blue"] {
344
+ --bg: var(--blue);
345
+ --fg: var(--blue-text, var(--back));
346
+ }
347
+ & [data-ui-col="pink"] {
348
+ --bg: var(--pink);
349
+ --fg: var(--pink-text, var(--back));
350
+ }
351
+
352
+ & [data-ui-col="sub-none"] {
353
+ --fg: var(--text);
354
+ }
355
+ & [data-ui-col="sub-default"] {
356
+ --hover: var(--text);
357
+ --fg: var(--text);
358
+ }
359
+ & [data-ui-col="sub-primary"] {
360
+ --hover: var(--primary);
361
+ --fg: var(--primary);
362
+ --border: hsl(var(--hsl-primary) / var(--border-opacity-sub, 1));
363
+ }
364
+ & [data-ui-col="sub-red"] {
365
+ --hover: var(--red);
366
+ --fg: var(--red);
367
+ --border: hsl(var(--hsl-red) / var(--border-opacity-sub, 1));
368
+ }
369
+ & [data-ui-col="sub-green"] {
370
+ --hover: var(--green);
371
+ --fg: var(--green);
372
+ --border: hsl(var(--hsl-green) / var(--border-opacity-sub, 1));
373
+ }
374
+ & [data-ui-col="sub-yellow"] {
375
+ --hover: var(--yellow);
376
+ --fg: var(--text);
377
+ --inverted: var(--yellow-text);
378
+ --border: hsl(var(--hsl-yellow) / var(--border-opacity-sub, 1));
379
+ }
380
+ & [data-ui-col="sub-orange"] {
381
+ --hover: var(--orange);
382
+ --fg: var(--text);
383
+ --inverted: var(--orange-text);
384
+ --border: hsl(var(--hsl-orange) / var(--border-opacity-sub, 1));
385
+ }
386
+ & [data-ui-col="sub-blue"] {
387
+ --hover: var(--blue);
388
+ --fg: var(--blue);
389
+ --border: hsl(var(--hsl-blue) / var(--border-opacity-sub, 1));
390
+ }
391
+ & [data-ui-col="sub-pink"] {
392
+ --hover: var(--pink);
393
+ --fg: var(--pink);
394
+ --border: hsl(var(--hsl-pink) / var(--border-opacity-sub, 1));
395
+ }
396
+
397
+ /* ---- Size Mods ---- */
398
+ & .ui-size--small {
399
+ --input-padding: calc(var(--space-3) * .75) var(--space-3);
400
+ }
401
+ & .ui-size--medium {
402
+ --input-padding: calc(var(--space-4) * .75) var(--space-4);
403
+ }
404
+ & .ui-size--large {
405
+ --input-padding: calc(var(--space-6) * .75) var(--space-6);
406
+ }
407
+
408
+ & .ui-ratio--square {
409
+ --input-padding: calc(var(--space) * .75);
410
+ aspect-ratio: 1;
411
+ }
412
+
413
+ & .ui-ratio--square.ui-size--medium {
414
+ --input-padding: calc(var(--space-4) * .75);
415
+ --icon-size: calc(1.25 * var(--input-padding));
416
+ --stroke-width: 1.25;
417
+ }
418
+
419
+ & .ui-ratio--square.ui-size--large {
420
+ --input-padding: calc(var(--space-6) * .75);
421
+ --icon-size: calc(1.5 * var(--input-padding));
422
+ --stroke-width: 1;
423
+ }
424
+ }
425
+
426
+ /* ---- Fade-in for template processing ---- */
427
+ .ui-fadein {
428
+ transition: opacity .2s ease;
429
+ }
430
+ .ui-pending {
431
+ opacity: 0;
432
+ }
@@ -0,0 +1,135 @@
1
+ import { layout } from './dom.js'
2
+ import {
3
+ avatar, button, card, checkbox, collapsible, confirm,
4
+ dialog, image, input, label, radio, selectComponent,
5
+ switchComponent, tabs
6
+ } from './components/index.js'
7
+
8
+ // Component registry
9
+ const COMPONENTS = {
10
+ 'ui-row': (props, children) => layout.row(children, props),
11
+ 'ui-col': (props, children) => layout.col(children, props),
12
+ 'ui-avatar': (props) => avatar(props),
13
+ 'ui-button': (props) => button(props),
14
+ 'ui-card': (props, children) => card({ ...props, content: children }),
15
+ 'ui-checkbox': (props) => checkbox(props),
16
+ 'ui-collapsible': (props, children) => collapsible({ ...props, content: children }),
17
+ 'ui-confirm': (props) => confirm(props),
18
+ 'ui-dialog': (props, children) => dialog({ ...props, content: children }),
19
+ 'ui-image': (props) => image(props),
20
+ 'ui-input': (props) => input(props),
21
+ 'ui-label': (props) => label(props),
22
+ 'ui-radio': (props) => radio(props),
23
+ 'ui-select': (props) => selectComponent(props),
24
+ 'ui-switch': (props) => switchComponent(props),
25
+ 'ui-tabs': (props) => tabs(props),
26
+ };
27
+
28
+ const TAGS = Object.keys(COMPONENTS).join(', ');
29
+
30
+ // Parse attribute value (handles booleans, numbers, JSON)
31
+ const parseValue = (val) => {
32
+ if (val === '' || val === 'true') return true;
33
+ if (val === 'false') return false;
34
+ if (!isNaN(val) && val !== '') return Number(val);
35
+ if (val.startsWith('{') || val.startsWith('[')) {
36
+ try { return JSON.parse(val); } catch { return val; }
37
+ }
38
+ return val;
39
+ };
40
+
41
+ // Extract props from element attributes
42
+ const getProps = (el) => {
43
+ const props = {};
44
+ for (const attr of el.attributes) {
45
+ const name = attr.name.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
46
+ props[name] = parseValue(attr.value);
47
+ }
48
+ return props;
49
+ };
50
+
51
+ // Process a single element recursively
52
+ const processElement = (el) => {
53
+ const tag = el.tagName.toLowerCase();
54
+ const factory = COMPONENTS[tag];
55
+
56
+ if (!factory) {
57
+ for (const child of [...el.children]) {
58
+ const result = processElement(child);
59
+ if (result && result !== child) child.replaceWith(result);
60
+ }
61
+ return el;
62
+ }
63
+
64
+ // Process children first (depth-first)
65
+ const children = [];
66
+ for (const child of [...el.childNodes]) {
67
+ if (child.nodeType === Node.ELEMENT_NODE) {
68
+ const processed = processElement(child);
69
+ if (processed) children.push(processed);
70
+ } else if (child.nodeType === Node.TEXT_NODE && child.textContent.trim()) {
71
+ children.push(document.createTextNode(child.textContent));
72
+ }
73
+ }
74
+
75
+ return factory(getProps(el), children);
76
+ };
77
+
78
+ /**
79
+ * Process all ui-* elements within a container
80
+ */
81
+ export const processTemplates = (container = document.body) => {
82
+ const topLevel = [...container.querySelectorAll(TAGS)]
83
+ .filter(el => !el.parentElement.closest(TAGS));
84
+
85
+ const processed = [];
86
+ for (const el of topLevel) {
87
+ const result = processElement(el);
88
+ if (result && result !== el) {
89
+ result.classList.add('ui-pending', 'ui-fadein');
90
+ el.replaceWith(result);
91
+ processed.push(result);
92
+ }
93
+ }
94
+
95
+ // Trigger fade-in after paint
96
+ requestAnimationFrame(() => {
97
+ requestAnimationFrame(() => {
98
+ for (const el of processed) el.classList.remove('ui-pending');
99
+ });
100
+ });
101
+ };
102
+
103
+ /**
104
+ * Tagged template literal for inline template creation
105
+ * @example
106
+ * const ui = template`<ui-row gap="2"><ui-button text="Hello"></ui-button></ui-row>`;
107
+ * document.body.appendChild(ui);
108
+ */
109
+ export const template = (strings, ...values) => {
110
+ const html = strings.reduce((acc, str, i) => acc + str + (values[i] ?? ''), '');
111
+ const temp = document.createElement('div');
112
+ temp.innerHTML = html.trim();
113
+
114
+ for (const child of [...temp.children]) {
115
+ const result = processElement(child);
116
+ if (result && result !== child) child.replaceWith(result);
117
+ }
118
+
119
+ if (temp.children.length === 1) return temp.firstElementChild;
120
+
121
+ const frag = document.createDocumentFragment();
122
+ while (temp.firstChild) frag.appendChild(temp.firstChild);
123
+ return frag;
124
+ };
125
+
126
+ /**
127
+ * Auto-process on DOMContentLoaded
128
+ */
129
+ export const autoProcess = () => {
130
+ if (document.readyState === 'loading') {
131
+ document.addEventListener('DOMContentLoaded', () => processTemplates());
132
+ } else {
133
+ processTemplates();
134
+ }
135
+ };
@@ -0,0 +1,26 @@
1
+ ```html
2
+ <body>
3
+ <ui-row gap="2" align="center">
4
+ <ui-button text="Click me" variant="primary"></ui-button>
5
+ <ui-switch label="Dark mode"></ui-switch>
6
+ </ui-row>
7
+ </body>
8
+ ```
9
+ ```js
10
+ import { autoProcess } from './fvn-ui'
11
+ autoProcess()
12
+ ```
13
+
14
+ ---
15
+
16
+ ```js
17
+ import { template } from './fvn-ui'
18
+
19
+ const ui = template`
20
+ <ui-row gap="2">
21
+ <ui-button text="Save" variant="primary"></ui-button>
22
+ <ui-button text="Cancel" variant="ghost"></ui-button>
23
+ </ui-row>
24
+ `
25
+ document.body.appendChild(ui)
26
+ ```