holygrail5 1.0.19 → 1.0.21

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 (77) hide show
  1. package/README.md +88 -18
  2. package/config.json +205 -77
  3. package/dist/assets/fonts/suisse-intl-light.woff +0 -0
  4. package/dist/assets/fonts/suisse-intl-light.woff2 +0 -0
  5. package/dist/assets/fonts/suisse-intl-medium.woff +0 -0
  6. package/dist/assets/fonts/suisse-intl-medium.woff2 +0 -0
  7. package/dist/assets/fonts/suisse-intl-regular.woff +0 -0
  8. package/dist/assets/fonts/suisse-intl-regular.woff2 +0 -0
  9. package/dist/assets/fonts/suisse-intl-semibold.woff +0 -0
  10. package/dist/assets/fonts/suisse-intl-semibold.woff2 +0 -0
  11. package/dist/assets/fonts/suisse-works-bold.woff +0 -0
  12. package/dist/assets/fonts/suisse-works-bold.woff2 +0 -0
  13. package/dist/assets/fonts/suisse-works-medium.woff +0 -0
  14. package/dist/assets/fonts/suisse-works-medium.woff2 +0 -0
  15. package/dist/assets/fonts/suisse-works-regular.woff +0 -0
  16. package/dist/assets/fonts/suisse-works-regular.woff2 +0 -0
  17. package/dist/componentes.html +429 -0
  18. package/dist/developer-guide.md +7 -7
  19. package/dist/guide-styles.css +197 -25
  20. package/dist/index.html +807 -689
  21. package/dist/output.css +217 -114
  22. package/dist/skills.html +14 -8
  23. package/dist/themes/dutti-demo.html +713 -19
  24. package/dist/themes/dutti.css +67 -16
  25. package/dist/themes/limited-demo.html +1121 -0
  26. package/dist/themes/limited.css +2493 -0
  27. package/package.json +1 -1
  28. package/src/.data/.previous-values.json +151 -84
  29. package/src/assets/fonts/suisse-intl-light.woff +0 -0
  30. package/src/assets/fonts/suisse-intl-light.woff2 +0 -0
  31. package/src/assets/fonts/suisse-intl-medium.woff +0 -0
  32. package/src/assets/fonts/suisse-intl-medium.woff2 +0 -0
  33. package/src/assets/fonts/suisse-intl-regular.woff +0 -0
  34. package/src/assets/fonts/suisse-intl-regular.woff2 +0 -0
  35. package/src/assets/fonts/suisse-intl-semibold.woff +0 -0
  36. package/src/assets/fonts/suisse-intl-semibold.woff2 +0 -0
  37. package/src/assets/fonts/suisse-works-bold.woff +0 -0
  38. package/src/assets/fonts/suisse-works-bold.woff2 +0 -0
  39. package/src/assets/fonts/suisse-works-medium.woff +0 -0
  40. package/src/assets/fonts/suisse-works-medium.woff2 +0 -0
  41. package/src/assets/fonts/suisse-works-regular.woff +0 -0
  42. package/src/assets/fonts/suisse-works-regular.woff2 +0 -0
  43. package/src/build/asset-manager.js +94 -3
  44. package/src/build/build-orchestrator.js +74 -12
  45. package/src/build/components-generator.js +584 -0
  46. package/src/build/skills-generator.js +43 -5
  47. package/src/build/theme-config-loader.js +58 -0
  48. package/src/build/theme-transformer.js +109 -16
  49. package/src/build/theme-vars-table-generator.js +349 -0
  50. package/src/build/typo-table-generator.js +154 -0
  51. package/src/docs-generator/guide-styles.css +197 -24
  52. package/src/docs-generator/html-generator.js +92 -246
  53. package/src/docs-generator/sections/colors-section.js +109 -0
  54. package/src/docs-generator/value-tracker.js +154 -0
  55. package/src/generators/typo-generator.js +2 -1
  56. package/src/generators/utils.js +90 -1
  57. package/src/skills.html +1 -0
  58. package/src/watch-config.js +99 -32
  59. package/themes/{dutti → _base}/_buttons.css +2 -2
  60. package/themes/{dutti → _base}/_checkboxes.css +1 -1
  61. package/themes/{dutti → _base}/_forms.css +1 -1
  62. package/themes/{dutti → _base}/_inputs.css +55 -10
  63. package/themes/{dutti → _base}/_labels.css +1 -1
  64. package/themes/dutti/README.md +67 -21
  65. package/themes/dutti/_variables.css +7 -1
  66. package/themes/dutti/demo.html +18 -14
  67. package/themes/dutti/theme.css +22 -44
  68. package/themes/dutti/theme.json +86 -0
  69. package/themes/limited/_variables.css +123 -0
  70. package/themes/limited/demo.html +296 -0
  71. package/themes/limited/theme.css +32 -0
  72. package/themes/limited/theme.json +105 -0
  73. /package/themes/{dutti → _base}/_containers.css +0 -0
  74. /package/themes/{dutti → _base}/_radios.css +0 -0
  75. /package/themes/{dutti → _base}/_switches.css +0 -0
  76. /package/themes/{dutti → _base}/components/_icons.css +0 -0
  77. /package/themes/{dutti → _base}/objects/_grid.css +0 -0
@@ -0,0 +1,58 @@
1
+ // Loader del JSON de configuración de tema
2
+ // Cada tema vive en themes/<nombre>/ y puede tener un theme.json con:
3
+ // - meta → nombre, descripción, versión, autor (para la demo)
4
+ // - tokenOverrides → overrides de tokens HG5 por tema (color/spacing)
5
+ // - componentVars → variables CSS de componentes (--btn-*, --input-*, etc.)
6
+ // - design → tokens de diseño del tema (border-radius, transition…)
7
+ //
8
+ // El archivo es OPCIONAL. Si no existe, devolvemos null y el resto del build
9
+ // sigue funcionando exactamente como antes — la única consecuencia es que la
10
+ // demo no mostrará la cabecera de meta ni las tablas de variables del tema.
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+
15
+ /**
16
+ * Devuelve la ruta absoluta esperada del theme.json para un tema dado.
17
+ */
18
+ function getThemeConfigPath(projectRoot, themeName) {
19
+ return path.join(projectRoot, 'themes', themeName, 'theme.json');
20
+ }
21
+
22
+ /**
23
+ * Carga themes/<themeName>/theme.json si existe. Si no existe o el JSON es
24
+ * inválido, devuelve null (con un warning si silent === false). Nunca lanza:
25
+ * el config de tema es opcional y no debe romper el build.
26
+ */
27
+ function loadThemeConfig(projectRoot, themeName, silent = false) {
28
+ if (!themeName) return null;
29
+
30
+ const themeConfigPath = getThemeConfigPath(projectRoot, themeName);
31
+ if (!fs.existsSync(themeConfigPath)) return null;
32
+
33
+ try {
34
+ const raw = fs.readFileSync(themeConfigPath, 'utf8');
35
+ const parsed = JSON.parse(raw);
36
+
37
+ // Normalizamos para que el resto del código asuma que las claves existen
38
+ parsed.meta = parsed.meta || {};
39
+ parsed.tokenOverrides = parsed.tokenOverrides || {};
40
+ parsed.componentVars = parsed.componentVars || {};
41
+ parsed.design = parsed.design || {};
42
+
43
+ // Si el JSON no trae nombre, usamos el del directorio
44
+ if (!parsed.meta.name) parsed.meta.name = themeName;
45
+
46
+ if (!silent) {
47
+ console.log(`✅ theme.json cargado: themes/${themeName}/theme.json`);
48
+ }
49
+ return parsed;
50
+ } catch (error) {
51
+ if (!silent) {
52
+ console.warn(`⚠️ No se pudo cargar themes/${themeName}/theme.json:`, error.message);
53
+ }
54
+ return null;
55
+ }
56
+ }
57
+
58
+ module.exports = { loadThemeConfig, getThemeConfigPath };
@@ -3,6 +3,9 @@
3
3
 
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
+ const { generateTypographyHTML } = require('./typo-table-generator');
7
+ const { generateThemeBlockHTML } = require('./theme-vars-table-generator');
8
+ const { applyThemeTypographyOverrides } = require('../generators/utils');
6
9
 
7
10
  // Estilos del sidebar + Lenis (solo para dutti-demo.html en dist)
8
11
  const SIDEBAR_STYLES = `
@@ -24,31 +27,84 @@ const SIDEBAR_STYLES = `
24
27
  }
25
28
  `;
26
29
 
27
- // HTML del header y sidebar
28
- const HEADER_AND_SIDEBAR_HTML = `
30
+ // Fallback de temas conocidos para la navegación de cada demo, usado
31
+ // cuando el llamador NO inyecta una lista dinámica (p. ej. tests o
32
+ // ejecuciones standalone del ThemeTransformer sin pasar por
33
+ // BuildOrchestrator). En modo build normal, esta lista se sustituye por
34
+ // los temas realmente activos en `config.themes[]`, de forma que
35
+ // nunca se genere un enlace `../themes/<x>-demo.html` a un fichero
36
+ // que no existe en `dist/themes/`.
37
+ // El orden aquí determina el orden en el que aparecen los enlaces.
38
+ // `name` es el slug del tema (coincide con la carpeta themes/<name>/ y
39
+ // con el fichero generado themes/<name>-demo.html). `label` es el
40
+ // texto visible en la nav principal.
41
+ const THEMES_IN_NAV = [
42
+ { name: 'dutti', label: 'Tema Dutti' },
43
+ { name: 'limited', label: 'Tema Limited' }
44
+ ];
45
+
46
+ // Capitaliza un slug para usarlo como nombre legible en el sidebar
47
+ // (p. ej. 'dutti' → 'Dutti', 'limited' → 'Limited').
48
+ function capitalize(s) {
49
+ if (!s) return '';
50
+ return s.charAt(0).toUpperCase() + s.slice(1);
51
+ }
52
+
53
+ /**
54
+ * Construye el HTML del header + sidebar para una demo concreta.
55
+ * El enlace del tema actual se marca con la clase `active` para que
56
+ * el usuario vea en qué tema está. Los enlaces a otros temas apuntan
57
+ * al fichero `../themes/<slug>-demo.html` correspondiente.
58
+ *
59
+ * @param {string} currentTheme - slug del tema activo (p. ej. 'dutti')
60
+ * @param {Array<{name:string,label:string}>} [themesForNav] - Lista de
61
+ * temas a mostrar en la nav. Si se omite o viene vacía, se usa el
62
+ * fallback `THEMES_IN_NAV`. Inyectar esta lista desde el build evita
63
+ * que la demo enlace a temas que no se han generado en `dist/themes/`.
64
+ * @returns {string} HTML listo para inyectar tras `<body>`.
65
+ */
66
+ function buildHeaderAndSidebar(currentTheme, themesForNav = null) {
67
+ const list = Array.isArray(themesForNav) && themesForNav.length > 0
68
+ ? themesForNav
69
+ : THEMES_IN_NAV;
70
+
71
+ const themeLinks = list.map(t => {
72
+ const cls = t.name === currentTheme ? ' class="active"' : '';
73
+ return ` <a href="../themes/${t.name}-demo.html"${cls}>${t.label}</a>`;
74
+ }).join('\n');
75
+
76
+ const displayName = capitalize(currentTheme);
77
+
78
+ return `
29
79
  <div class="guide-header">
30
80
  <a href="../index.html" class="guide-logo" style="text-decoration:none;">HolyGrail5</a>
31
81
  <div style="display: flex; align-items: center; gap: 1rem;">
32
82
  <nav class="guide-nav">
33
83
  <a href="../index.html">Guía</a>
34
- <a href="../themes/dutti-demo.html" class="active">Tema Dutti</a>
84
+ <a href="../componentes.html">Componentes</a>
85
+ ${themeLinks}
35
86
  <a href="../skills.html">Skills</a>
36
87
  </nav>
37
88
  <button class="guide-header-button" onclick="toggleSidebar()">☰</button>
38
89
  </div>
39
90
  </div>
40
-
91
+
41
92
  <div class="guide-sidebar-overlay" onclick="toggleSidebar()"></div>
42
-
93
+
43
94
  <aside class="guide-sidebar">
44
95
  <div class="guide-sidebar-header">
45
96
  <div>HolyGrail5</div>
46
- <p class="guide-sidebar-subtitle">Demo Tema Dutti</p>
97
+ <p class="guide-sidebar-subtitle">Demo Tema ${displayName}</p>
47
98
  </div>
48
99
 
49
100
  <nav class="guide-sidebar-nav">
101
+ <p class="guide-sidebar-title">Tema</p>
102
+
103
+ <a href="#theme-vars" class="guide-menu-item">Variables del tema</a>
104
+
50
105
  <p class="guide-sidebar-title">Componentes</p>
51
-
106
+
107
+ <a href="#typography" class="guide-menu-item">Tipografía</a>
52
108
  <a href="#buttons" class="guide-menu-item">Botones</a>
53
109
  <a href="#inputs" class="guide-menu-item">Inputs</a>
54
110
  <a href="#checkboxes" class="guide-menu-item">Checkboxes</a>
@@ -57,7 +113,7 @@ const HEADER_AND_SIDEBAR_HTML = `
57
113
  <a href="#forms" class="guide-menu-item">Formularios</a>
58
114
  </nav>
59
115
  </aside>
60
-
116
+
61
117
  <script>
62
118
  function toggleSidebar() {
63
119
  const sidebar = document.querySelector('.guide-sidebar');
@@ -65,19 +121,20 @@ const HEADER_AND_SIDEBAR_HTML = `
65
121
  sidebar.classList.toggle('open');
66
122
  overlay.classList.toggle('active');
67
123
  }
68
-
124
+
69
125
  function closeSidebar() {
70
126
  const sidebar = document.querySelector('.guide-sidebar');
71
127
  const overlay = document.querySelector('.guide-sidebar-overlay');
72
128
  sidebar.classList.remove('open');
73
129
  overlay.classList.remove('active');
74
130
  }
75
-
131
+
76
132
  // Hacer funciones globales
77
133
  window.toggleSidebar = toggleSidebar;
78
134
  window.closeSidebar = closeSidebar;
79
135
  </script>
80
136
  `;
137
+ }
81
138
 
82
139
  // Script de Lenis para el head
83
140
  const LENIS_HEAD_SCRIPT = `
@@ -153,9 +210,14 @@ class ThemeTransformer {
153
210
  * @param {string} destPath - Ruta donde guardar el HTML transformado
154
211
  * @param {string} themeName - Nombre del tema (para personalización)
155
212
  * @param {boolean} silent - Si true, no muestra mensajes
213
+ * @param {Object} [config] - Config cargado de config.json (para inyectar tablas dinámicas como tipografía)
214
+ * @param {Object} [themeData] - theme.json parseado (para inyectar meta + tablas de variables del tema)
215
+ * @param {Array<{name:string,label:string}>} [themesForNav] - Lista de
216
+ * temas activos a exponer en la nav del demo. Si se omite, se usa
217
+ * el fallback estático de theme-transformer (dutti + limited).
156
218
  * @returns {boolean} - true si se transformó exitosamente
157
219
  */
158
- transform(sourcePath, destPath, themeName = 'dutti', silent = false) {
220
+ transform(sourcePath, destPath, themeName = 'dutti', silent = false, config = null, themeData = null, themesForNav = null) {
159
221
  const fullSourcePath = path.isAbsolute(sourcePath)
160
222
  ? sourcePath
161
223
  : path.join(this.projectRoot, sourcePath);
@@ -174,7 +236,32 @@ class ThemeTransformer {
174
236
  try {
175
237
  // Leer el contenido
176
238
  let content = fs.readFileSync(fullSourcePath, 'utf8');
177
-
239
+
240
+ // Inyectar bloques dinámicos derivados del config (p. ej. tabla de tipografía).
241
+ // Si el HTML fuente no contiene el placeholder, se ignora silenciosamente.
242
+ //
243
+ // Para cada tema aplicamos los overrides de tipografía declarados
244
+ // en `theme.json → typography.fontFamilyMap`. Así la tabla
245
+ // muestra la fuente real del tema (Suisse Works en Limited, por
246
+ // ejemplo) en vez de la fuente base de config.json.
247
+ if (config) {
248
+ const typoConfig = applyThemeTypographyOverrides(config, themeData);
249
+ const typoSection = generateTypographyHTML(typoConfig);
250
+ content = content.replace(/<!--\s*HG_TYPO_TABLE\s*-->/g, typoSection);
251
+ } else {
252
+ // Sin config, eliminamos el placeholder para no mostrarlo en crudo
253
+ content = content.replace(/<!--\s*HG_TYPO_TABLE\s*-->/g, '');
254
+ }
255
+
256
+ // Inyectar bloque del tema (meta + tablas de variables) si hay theme.json.
257
+ // Si no lo hay, quitamos el placeholder para no dejar comentarios huérfanos.
258
+ if (themeData) {
259
+ const themeBlock = generateThemeBlockHTML(themeData, config);
260
+ content = content.replace(/<!--\s*HG_THEME_BLOCK\s*-->/g, themeBlock);
261
+ } else {
262
+ content = content.replace(/<!--\s*HG_THEME_BLOCK\s*-->/g, '');
263
+ }
264
+
178
265
  // Ajustar rutas CSS
179
266
  content = content.replace(/href="\.\.\/\.\.\/dist\/output\.css"/g, 'href="../output.css"');
180
267
  content = content.replace(/href="\.\.\/output\.css"/g, 'href="../output.css"');
@@ -197,11 +284,17 @@ class ThemeTransformer {
197
284
  // Añadir inicialización de Lenis antes de </body>
198
285
  content = content.replace('</body>', `${LENIS_INIT_SCRIPT}\n</body>`);
199
286
 
200
- // Añadir header y sidebar después del <body>
201
- content = content.replace(/(<body[^>]*>)/i, '$1\n' + HEADER_AND_SIDEBAR_HTML);
202
-
287
+ // Añadir header y sidebar después del <body>.
288
+ // La nav principal se construye dinámicamente a partir del tema
289
+ // activo, marcando su enlace con `active` y dejando los otros
290
+ // temas accesibles como enlaces normales. Si el build ha pasado
291
+ // la lista de temas activos, se respeta; si no, se cae al
292
+ // fallback estático THEMES_IN_NAV (compatibilidad).
293
+ const headerAndSidebarHTML = buildHeaderAndSidebar(themeName, themesForNav);
294
+ content = content.replace(/(<body[^>]*>)/i, '$1\n' + headerAndSidebarHTML);
295
+
203
296
  // Eliminar el título h1 del contenido si existe (ya está en el header)
204
- content = content.replace(/<h1 class="demo-title">Sistema de Theming Dutti<\/h1>\s*/g, '');
297
+ content = content.replace(/<h1 class="demo-title">Sistema de Theming [^<]+<\/h1>\s*/g, '');
205
298
 
206
299
  // Envolver el contenido de demo-container con guide-container
207
300
  content = content.replace(
@@ -0,0 +1,349 @@
1
+ // Generador de la cabecera de meta y de las tablas de variables del tema.
2
+ // Lee el theme.json (cargado por theme-config-loader) y produce HTML para
3
+ // inyectar en la demo del tema. Si no hay theme.json o el bloque está vacío,
4
+ // devuelve string vacío para mantener la demo limpia.
5
+
6
+ function escapeHtml(str) {
7
+ if (str === undefined || str === null) return '';
8
+ return String(str)
9
+ .replace(/&/g, '&amp;')
10
+ .replace(/</g, '&lt;')
11
+ .replace(/>/g, '&gt;')
12
+ .replace(/"/g, '&quot;');
13
+ }
14
+
15
+ /**
16
+ * Estilos comunes para las tablas del tema. Se inyectan una sola vez.
17
+ */
18
+ const SHARED_STYLES = `
19
+ <style>
20
+ .hg-theme-meta {
21
+ margin-bottom: 32px;
22
+ padding: 20px 24px;
23
+ border-left: 4px solid var(--hg-color-primary, #000);
24
+ background: var(--hg-color-bg-light, #f0f0f0);
25
+ }
26
+ .hg-theme-meta h3 { margin: 0 0 4px; font-size: 22px; font-family: arial, sans-serif; }
27
+ .hg-theme-meta .hg-theme-meta-line {
28
+ font-size: 12px; color: var(--hg-color-dark-grey, #737373);
29
+ text-transform: uppercase; letter-spacing: 0.06em;
30
+ }
31
+ .hg-theme-meta p { margin: 12px 0 0; font-size: 14px; line-height: 1.6; }
32
+ .hg-vars-group { margin: 0; }
33
+ .hg-vars-group h4 {
34
+ margin: 0 0 8px; font-family: arial, sans-serif; font-size: 14px;
35
+ text-transform: uppercase; letter-spacing: 0.06em;
36
+ color: var(--hg-color-dark-grey, #737373);
37
+ }
38
+ /* 3 columnas verticales manuales (grid), balanceadas por item count */
39
+ .hg-vars-columns {
40
+ display: grid;
41
+ grid-template-columns: repeat(3, minmax(0, 1fr));
42
+ gap: 32px;
43
+ font-family: arial, sans-serif;
44
+ font-size: 11px;
45
+ align-items: start;
46
+ }
47
+ @media (max-width: 960px) {
48
+ .hg-vars-columns { grid-template-columns: repeat(2, minmax(0, 1fr)); }
49
+ }
50
+ @media (max-width: 600px) {
51
+ .hg-vars-columns { grid-template-columns: 1fr; }
52
+ }
53
+ .hg-vars-col {
54
+ display: flex;
55
+ flex-direction: column;
56
+ gap: 24px;
57
+ min-width: 0;
58
+ }
59
+ /* Lista de variables dentro del grupo: nombre izquierda, valor derecha */
60
+ .hg-vars-list {
61
+ display: flex;
62
+ flex-direction: column;
63
+ }
64
+ .hg-vars-item {
65
+ display: flex;
66
+ align-items: center;
67
+ justify-content: space-between;
68
+ gap: 8px;
69
+ padding: 5px 0;
70
+ border-bottom: 1px solid var(--hg-color-middle-grey, #a9a9a9);
71
+ min-width: 0;
72
+ }
73
+ .hg-vars-item .hg-vars-name,
74
+ .hg-vars-item .hg-vars-value {
75
+ display: flex;
76
+ align-items: center;
77
+ min-width: 0;
78
+ }
79
+ .hg-vars-item .hg-vars-name { flex: 1 1 auto; }
80
+ .hg-vars-item .hg-vars-value { flex: 0 0 auto; justify-content: flex-end; text-align: right; }
81
+ .hg-vars-item code {
82
+ background: var(--hg-color-bg-light, #f0f0f0);
83
+ padding: 2px 5px;
84
+ border-radius: 3px;
85
+ font-size: 10.5px;
86
+ line-height: 1.35;
87
+ word-break: break-all;
88
+ white-space: normal;
89
+ }
90
+ .hg-vars-item .hg-vars-name code { color: #000; font-weight: 600; }
91
+ .hg-vars-item .hg-vars-value code { color: #333; }
92
+ .hg-vars-item.is-overridden .hg-vars-name code { color: var(--hg-color-feel-dark, #c94c07); }
93
+ .hg-vars-item .hg-vars-badge {
94
+ display: inline-block;
95
+ margin-left: 6px;
96
+ padding: 1px 5px;
97
+ font-size: 9px;
98
+ font-weight: 600;
99
+ letter-spacing: 0.04em;
100
+ text-transform: uppercase;
101
+ background: var(--hg-color-feel, #fb9962);
102
+ color: #fff;
103
+ border-radius: 2px;
104
+ }
105
+ .hg-vars-swatch {
106
+ display: inline-block; width: 12px; height: 12px;
107
+ border-radius: 3px; vertical-align: middle;
108
+ margin-left: 6px;
109
+ border: 1px solid var(--hg-color-middle-grey, #a9a9a9);
110
+ flex-shrink: 0;
111
+ order: 2;
112
+ }
113
+ /* Sección de colores semánticos: swatch al final de la fila, 18x18 */
114
+ .hg-vars-group-semantic .hg-vars-swatch {
115
+ width: 18px; height: 18px;
116
+ margin-right: 0;
117
+ margin-left: 8px;
118
+ order: 2;
119
+ }
120
+ .hg-vars-group-semantic .hg-vars-item { padding: 7px 0; }
121
+ .hg-vars-group-semantic .hg-vars-value { gap: 0; }
122
+ .hg-vars-empty { color: var(--hg-color-middle-grey, #a9a9a9); font-style: italic; padding: 12px; }
123
+ </style>`;
124
+
125
+ /**
126
+ * Cabecera con la meta del tema (nombre, descripción, versión, autor).
127
+ */
128
+ function generateThemeMetaHTML(themeData) {
129
+ if (!themeData || !themeData.meta) return '';
130
+ const m = themeData.meta;
131
+ if (!m.displayName && !m.name && !m.description) return '';
132
+
133
+ const displayName = m.displayName || m.name || 'Tema';
134
+ const lineParts = [];
135
+ if (m.name) lineParts.push(escapeHtml(m.name));
136
+ if (m.version) lineParts.push(`v${escapeHtml(m.version)}`);
137
+ if (m.author) lineParts.push(escapeHtml(m.author));
138
+ const line = lineParts.join(' · ');
139
+
140
+ return ` <div class="hg-theme-meta">
141
+ <div class="hg-theme-meta-line">${line || 'Tema'}</div>
142
+ <h3>${escapeHtml(displayName)}</h3>
143
+ ${m.description ? `<p>${escapeHtml(m.description)}</p>` : ''}
144
+ </div>`;
145
+ }
146
+
147
+ /**
148
+ * Resuelve un valor que puede ser un literal de color (#…, rgb…, hsl…)
149
+ * o una referencia a una variable CSS `var(--hg-color-xxx)`. Devuelve el
150
+ * color final aplicable o null si no se puede resolver.
151
+ */
152
+ function resolveColor(value, config, themeData, depth = 0) {
153
+ if (!value || depth > 5) return null;
154
+ if (/^(#|rgb|hsl)/i.test(value)) return value;
155
+ const m = String(value).match(/var\(\s*--hg-color-([\w-]+)\s*\)/);
156
+ if (!m) return null;
157
+ const key = m[1];
158
+ const overrides = (themeData && themeData.tokenOverrides && themeData.tokenOverrides.color) || {};
159
+ const baseColors = (config && config.colors) || {};
160
+ const resolved = Object.prototype.hasOwnProperty.call(overrides, key)
161
+ ? overrides[key]
162
+ : baseColors[key];
163
+ if (!resolved) return null;
164
+ return resolveColor(resolved, config, themeData, depth + 1);
165
+ }
166
+
167
+ function renderRow(name, value, prefix, opts = {}) {
168
+ const fullVar = prefix ? `--${prefix}-${name}` : `--${name}`;
169
+ // Swatch: si el valor es un color literal o un var(--hg-color-*) resoluble, lo mostramos
170
+ const resolvedColor = resolveColor(value, opts.config, opts.themeData);
171
+ const swatch = resolvedColor
172
+ ? `<span class="hg-vars-swatch" style="background:${escapeHtml(resolvedColor)}" title="${escapeHtml(resolvedColor)}"></span>`
173
+ : '';
174
+ const cls = opts.overridden ? ' is-overridden' : '';
175
+ const badge = opts.overridden ? '<span class="hg-vars-badge" title="Sobrescrito por el tema">tema</span>' : '';
176
+ // En la sección de colores semánticos el swatch va AL FINAL de la fila (después del valor).
177
+ // En el resto (componentes…) sigue yendo delante del valor.
178
+ const swatchAtEnd = !!opts.swatchAtEnd;
179
+ const valueHTML = swatchAtEnd
180
+ ? `<code>${escapeHtml(value)}</code>${swatch}`
181
+ : `${swatch}<code>${escapeHtml(value)}</code>`;
182
+ return ` <div class="hg-vars-item${cls}">
183
+ <div class="hg-vars-name"><code>${escapeHtml(fullVar)}</code>${badge}</div>
184
+ <div class="hg-vars-value">${valueHTML}</div>
185
+ </div>`;
186
+ }
187
+
188
+ /**
189
+ * Construye un grupo (título + lista de variables) y devuelve
190
+ * { html, count } para que la distribución en columnas pueda balancear.
191
+ */
192
+ function buildGroup(title, entries, prefix, overriddenKeys = new Set(), ctx = {}, extraClass = '', rowOpts = {}) {
193
+ if (!entries || entries.length === 0) return null;
194
+ const rows = entries.map(([name, value]) => {
195
+ return renderRow(name, value, prefix, {
196
+ overridden: overriddenKeys.has(name),
197
+ config: ctx.config,
198
+ themeData: ctx.themeData,
199
+ swatchAtEnd: !!rowOpts.swatchAtEnd
200
+ });
201
+ }).join('\n');
202
+ const cls = extraClass ? ` ${extraClass}` : '';
203
+ const html = ` <div class="hg-vars-group${cls}">
204
+ <h4>${escapeHtml(title)} <span style="color:var(--hg-color-middle-grey, #a9a9a9);font-weight:400;">(${entries.length})</span></h4>
205
+ <div class="hg-vars-list">
206
+ ${rows}
207
+ </div>
208
+ </div>`;
209
+ return { html, count: entries.length + 1 /* +1 por el header */ };
210
+ }
211
+
212
+ /**
213
+ * Distribuye los grupos en N columnas balanceando el total de items (+header).
214
+ * Algoritmo greedy: cada grupo va a la columna con menor altura acumulada.
215
+ * Preserva el orden original dentro de cada columna.
216
+ * Los grupos con `pinLast: true` se anclan al final de la última columna,
217
+ * independientemente del balance.
218
+ */
219
+ function distributeIntoColumns(groups, numCols = 3) {
220
+ const cols = Array.from({ length: numCols }, () => ({ items: [], count: 0 }));
221
+ const pinned = [];
222
+ groups.forEach(g => {
223
+ if (g.pinLast) {
224
+ pinned.push(g);
225
+ return;
226
+ }
227
+ // Encontrar columna con menor count (en caso de empate, la primera)
228
+ let target = cols[0];
229
+ for (let i = 1; i < cols.length; i++) {
230
+ if (cols[i].count < target.count) target = cols[i];
231
+ }
232
+ target.items.push(g.html);
233
+ target.count += g.count;
234
+ });
235
+ // Los grupos "pinLast" van siempre al final de la última columna
236
+ if (pinned.length) {
237
+ const last = cols[cols.length - 1];
238
+ pinned.forEach(g => {
239
+ last.items.push(g.html);
240
+ last.count += g.count;
241
+ });
242
+ }
243
+ return cols.map(c => ` <div class="hg-vars-col">\n${c.items.join('\n')}\n </div>`).join('\n');
244
+ }
245
+
246
+ /**
247
+ * Sección con todas las variables del tema, agrupadas:
248
+ * - Colores semánticos (del base, con overrides del tema si hay)
249
+ * - Spacing overrides → si hay
250
+ * - Component vars (btn, input, label…)
251
+ * - Design tokens (border-radius, transition…)
252
+ */
253
+ function generateThemeVarsHTML(themeData, config = null) {
254
+ if (!themeData) return '';
255
+
256
+ const groups = [];
257
+
258
+ const tokenOverrides = themeData.tokenOverrides || {};
259
+ const colorOverrides = tokenOverrides.color || {};
260
+ const tokenSpacing = tokenOverrides.spacing || {};
261
+
262
+ // 1) Colores semánticos — base + overrides del tema (si los hay)
263
+ // Se excluyen neutrales/utilitarios (blanco, negro, greys, backgrounds).
264
+ const NON_SEMANTIC_COLORS = new Set([
265
+ 'white', 'black',
266
+ 'dark-grey', 'middle-grey', 'light-grey', 'grey-ultra',
267
+ 'bg-light', 'bg-cream'
268
+ ]);
269
+ const baseColors = (config && config.colors) || {};
270
+ const allColorKeys = new Set([
271
+ ...Object.keys(baseColors),
272
+ ...Object.keys(colorOverrides)
273
+ ]);
274
+ // Filtrar neutrales
275
+ const semanticKeys = Array.from(allColorKeys).filter(k => !NON_SEMANTIC_COLORS.has(k));
276
+ const ctx = { config, themeData };
277
+
278
+ // 1) Colores semánticos (al principio). Swatch al final de la fila, 18x18.
279
+ if (semanticKeys.length > 0) {
280
+ const overriddenKeys = new Set(Object.keys(colorOverrides));
281
+ const entries = semanticKeys.map(name => {
282
+ const value = overriddenKeys.has(name) ? colorOverrides[name] : baseColors[name];
283
+ return [name, value];
284
+ });
285
+ const g = buildGroup('Colores semánticos', entries, 'hg-color', overriddenKeys, ctx, 'hg-vars-group-semantic', { swatchAtEnd: true });
286
+ if (g) groups.push(g);
287
+ }
288
+
289
+ // 2) Spacing overrides (si hay)
290
+ if (Object.keys(tokenSpacing).length) {
291
+ const g = buildGroup('Spacing overrides', Object.entries(tokenSpacing), 'hg-spacing', new Set(), ctx, '', { swatchAtEnd: true });
292
+ if (g) groups.push(g);
293
+ }
294
+
295
+ // 3) Component vars (btn, input, label…)
296
+ const componentVars = themeData.componentVars || {};
297
+ Object.entries(componentVars).forEach(([component, vars]) => {
298
+ const g = buildGroup(`Componente: ${component}`, Object.entries(vars), component, new Set(), ctx, '', { swatchAtEnd: true });
299
+ if (g) groups.push(g);
300
+ });
301
+
302
+ // 4) Design tokens (sin prefijo)
303
+ const design = themeData.design || {};
304
+ if (Object.keys(design).length) {
305
+ const g = buildGroup('Design tokens', Object.entries(design), '', new Set(), ctx, '', { swatchAtEnd: true });
306
+ if (g) groups.push(g);
307
+ }
308
+
309
+ if (groups.length === 0) {
310
+ return ` <section class="demo-section" id="theme-vars">
311
+ <h2 class="demo-title">Variables del tema</h2>
312
+ <p class="hg-vars-empty">El theme.json no define variables. Añade <code>componentVars</code>, <code>tokenOverrides</code> o <code>design</code> para verlos aquí.</p>
313
+ </section>`;
314
+ }
315
+
316
+ const columnsHTML = distributeIntoColumns(groups, 3);
317
+
318
+ return ` <section class="demo-section" id="theme-vars">
319
+ <h2 class="demo-title">Variables del tema</h2>
320
+ <p class="mb-24">
321
+ Definidas en <code>themes/${escapeHtml(themeData.meta?.name || 'dutti')}/theme.json</code>. La fuente del CSS sigue siendo
322
+ <code>themes/${escapeHtml(themeData.meta?.name || 'dutti')}/_variables.css</code>; este JSON sirve como manifiesto del tema y para que
323
+ la demo muestre tablas siempre coherentes con sus tokens.
324
+ </p>
325
+ <div class="hg-vars-columns">
326
+ ${columnsHTML}
327
+ </div>
328
+ </section>`;
329
+ }
330
+
331
+ /**
332
+ * Bloque combinado: meta + sección variables. Devuelve string vacío si no hay datos.
333
+ * Incluye los estilos compartidos en la primera invocación.
334
+ */
335
+ function generateThemeBlockHTML(themeData, config = null) {
336
+ if (!themeData) return '';
337
+ const meta = generateThemeMetaHTML(themeData);
338
+ const vars = generateThemeVarsHTML(themeData, config);
339
+ if (!meta && !vars) return '';
340
+ return `${SHARED_STYLES}
341
+ ${meta}
342
+ ${vars}`;
343
+ }
344
+
345
+ module.exports = {
346
+ generateThemeMetaHTML,
347
+ generateThemeVarsHTML,
348
+ generateThemeBlockHTML
349
+ };