@muten/shadcn 0.1.14 → 0.2.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 (182) hide show
  1. package/AGENTS.md +88 -1
  2. package/globals.css +1 -0
  3. package/kit.css +329 -124
  4. package/motion.css +3 -0
  5. package/package.json +1 -1
  6. package/registry/accordion.muten +3 -3
  7. package/registry/agenda.js +19 -16
  8. package/registry/agenda.muten +10 -10
  9. package/registry/alert-dialog.muten +2 -2
  10. package/registry/alert.muten +1 -1
  11. package/registry/app-shell.muten +1 -1
  12. package/registry/attachment.muten +1 -1
  13. package/registry/avatar.muten +4 -4
  14. package/registry/badge.muten +14 -3
  15. package/registry/band.muten +2 -2
  16. package/registry/bar-chart.muten +2 -2
  17. package/registry/bio-play.muten +2 -2
  18. package/registry/booking-play.muten +5 -5
  19. package/registry/booking.muten +2 -2
  20. package/registry/breadcrumb.muten +4 -4
  21. package/registry/button.muten +9 -7
  22. package/registry/calendar.js +15 -10
  23. package/registry/calendar.muten +1 -1
  24. package/registry/card.muten +1 -1
  25. package/registry/carousel.muten +1 -1
  26. package/registry/chat-play.muten +8 -8
  27. package/registry/chatbot.muten +5 -4
  28. package/registry/checklist.muten +1 -1
  29. package/registry/choice-list.muten +3 -3
  30. package/registry/code.js +10 -4
  31. package/registry/collapsible.muten +2 -2
  32. package/registry/color-picker.js +37 -36
  33. package/registry/color-picker.muten +1 -1
  34. package/registry/combobox.js +1 -1
  35. package/registry/combobox.muten +2 -2
  36. package/registry/command-palette.muten +2 -2
  37. package/registry/command.js +1 -1
  38. package/registry/command.muten +7 -7
  39. package/registry/context-menu.muten +3 -3
  40. package/registry/count-up.js +33 -31
  41. package/registry/count-up.muten +6 -4
  42. package/registry/date-field.js +1 -1
  43. package/registry/date-field.muten +3 -3
  44. package/registry/day-plan.muten +5 -5
  45. package/registry/device-frame.muten +3 -3
  46. package/registry/dialog.muten +1 -1
  47. package/registry/dirty-bar.js +2 -2
  48. package/registry/dirty-bar.muten +3 -3
  49. package/registry/drawer.muten +1 -1
  50. package/registry/dropdown-menu.muten +4 -4
  51. package/registry/empty.muten +1 -1
  52. package/registry/feature-grid.muten +1 -1
  53. package/registry/feature-rows.muten +2 -2
  54. package/registry/field-behavior.js +13 -3
  55. package/registry/field-behavior.muten +3 -3
  56. package/registry/field.muten +5 -5
  57. package/registry/file-input.js +8 -4
  58. package/registry/file-input.muten +3 -3
  59. package/registry/fixed-card.muten +4 -4
  60. package/registry/folder-picker.js +6 -2
  61. package/registry/folder-picker.muten +2 -2
  62. package/registry/heatmap.muten +5 -5
  63. package/registry/image-upload.js +1 -1
  64. package/registry/image-upload.muten +2 -2
  65. package/registry/inbox-play.muten +5 -5
  66. package/registry/indicator.muten +1 -1
  67. package/registry/info-table.muten +1 -1
  68. package/registry/input-group.muten +11 -11
  69. package/registry/input-otp.muten +2 -2
  70. package/registry/input.muten +1 -1
  71. package/registry/inspector.muten +2 -2
  72. package/registry/item.muten +2 -2
  73. package/registry/kit/board.js +36 -34
  74. package/registry/kit/collections.js +24 -24
  75. package/registry/kit/core.js +197 -114
  76. package/registry/kit/dates.js +29 -27
  77. package/registry/kit/i18n.js +172 -0
  78. package/registry/kit/menus.js +14 -13
  79. package/registry/kit/overlays.js +7 -2
  80. package/registry/kit/structure.js +66 -61
  81. package/registry/kit/table.js +19 -17
  82. package/registry/kit/values.js +36 -28
  83. package/registry/kit-skeleton.muten +1 -1
  84. package/registry/kit-widget.js +9 -4
  85. package/registry/lang-scope.js +8 -0
  86. package/registry/lang-scope.muten +5 -0
  87. package/registry/legal-doc.muten +5 -5
  88. package/registry/link-btn.muten +1 -1
  89. package/registry/list-motion.js +46 -0
  90. package/registry/list-motion.muten +4 -0
  91. package/registry/live-stack.muten +2 -2
  92. package/registry/logo-cloud.muten +2 -2
  93. package/registry/marketing.muten +7 -7
  94. package/registry/member-list.muten +2 -2
  95. package/registry/menubar.muten +3 -3
  96. package/registry/message-scroller.muten +3 -3
  97. package/registry/message.muten +9 -9
  98. package/registry/messaging.muten +1 -1
  99. package/registry/modal.muten +4 -4
  100. package/registry/money-field.js +1 -1
  101. package/registry/money-field.muten +2 -2
  102. package/registry/nav-button.muten +4 -4
  103. package/registry/navigation-menu.muten +4 -4
  104. package/registry/navpill.muten +6 -6
  105. package/registry/new-appointment.js +1 -1
  106. package/registry/new-appointment.muten +4 -4
  107. package/registry/number-field.js +6 -4
  108. package/registry/number-field.muten +1 -1
  109. package/registry/order-board.js +1 -1
  110. package/registry/order-board.muten +2 -2
  111. package/registry/order-play.muten +4 -4
  112. package/registry/pagination-nav.js +1 -1
  113. package/registry/pagination-nav.muten +3 -3
  114. package/registry/pagination.muten +4 -4
  115. package/registry/password-field.muten +2 -2
  116. package/registry/permission-matrix.muten +4 -4
  117. package/registry/phone-bar.muten +6 -6
  118. package/registry/phone-field.js +1 -1
  119. package/registry/phone-field.muten +1 -1
  120. package/registry/phone-shell.js +1 -1
  121. package/registry/pill.muten +2 -2
  122. package/registry/plans.muten +1 -1
  123. package/registry/popover.muten +2 -2
  124. package/registry/pricing.muten +4 -4
  125. package/registry/product-play.muten +3 -3
  126. package/registry/progress.muten +3 -3
  127. package/registry/queue.muten +1 -1
  128. package/registry/radio-group.muten +1 -1
  129. package/registry/rank-item.muten +4 -4
  130. package/registry/resizable.muten +1 -1
  131. package/registry/row-menu.js +1 -1
  132. package/registry/row-menu.muten +2 -2
  133. package/registry/save-button.muten +5 -5
  134. package/registry/scale-slider.js +1 -1
  135. package/registry/scale-slider.muten +2 -2
  136. package/registry/screen-tour.muten +1 -1
  137. package/registry/share-bar.muten +2 -2
  138. package/registry/share-button.muten +4 -4
  139. package/registry/sheet.muten +1 -1
  140. package/registry/sidebar.muten +12 -6
  141. package/registry/site-footer.muten +6 -6
  142. package/registry/sortable-behavior.js +67 -20
  143. package/registry/sortable-list.js +3 -1
  144. package/registry/sortable-list.muten +2 -2
  145. package/registry/sortable.muten +5 -4
  146. package/registry/spinner.muten +2 -2
  147. package/registry/split-button.muten +2 -2
  148. package/registry/stacked-bars.js +3 -3
  149. package/registry/stacked-bars.muten +2 -2
  150. package/registry/stamp-card.muten +2 -2
  151. package/registry/stat-band.muten +3 -3
  152. package/registry/stat.muten +3 -3
  153. package/registry/status-bar.muten +2 -2
  154. package/registry/stepper.muten +4 -4
  155. package/registry/store-qr.js +1 -1
  156. package/registry/store-qr.muten +2 -2
  157. package/registry/swatch.muten +1 -1
  158. package/registry/switch.muten +8 -7
  159. package/registry/table-view.muten +3 -3
  160. package/registry/tabs-behavior.js +9 -1
  161. package/registry/tabs.muten +2 -2
  162. package/registry/tag-input.js +1 -1
  163. package/registry/tag-input.muten +2 -2
  164. package/registry/text.muten +2 -2
  165. package/registry/time-field.js +1 -1
  166. package/registry/time-field.muten +3 -3
  167. package/registry/timeline.muten +4 -4
  168. package/registry/toaster.muten +2 -2
  169. package/registry/toggle-group.muten +1 -1
  170. package/registry/toggle.muten +3 -3
  171. package/registry/toolbar.muten +4 -4
  172. package/registry/tooltip.muten +2 -2
  173. package/registry/tree-view.js +38 -25
  174. package/registry/tree-view.muten +1 -1
  175. package/registry/veil.muten +1 -1
  176. package/registry/virtual-list.js +1 -1
  177. package/registry/virtual-list.muten +2 -2
  178. package/registry/virtual-window.js +4 -4
  179. package/registry/virtual-window.muten +2 -2
  180. package/registry/wordline.muten +1 -1
  181. package/registry/zoom-control.muten +1 -1
  182. package/registry.json +22 -2
@@ -6,29 +6,31 @@ const pad = (n) => String(n).padStart(2, '0');
6
6
 
7
7
  // DateField: i {label, value "yyyy-mm-dd" | "today", min "today" | "", years 2} → on.change("yyyy-mm-dd")
8
8
  export function dateField(root, i, core, on) {
9
- const { $, ui, today, DAYLONG, MON, makeWheel, openModal, closeModal, CONTENT, body, foot } = core;
9
+ const { $, ui, today, makeWheel, openModal, closeModal, CONTENT, body, foot } = core;
10
+ // its language: the nearest lang (the page's), English by default; months and days from the kit's date words
11
+ const L = core.langOf(root), T = (k, ...a) => core.t(L, k, ...a), W = core.kit.words(L), DAYLONG = W.dayL, MON = W.mon;
10
12
  const MONTHS = MON.map((m) => m[0].toUpperCase() + m.slice(1));
11
13
  const parse = (s) => { if (!s || s === 'today') return new Date(today); const d = new Date(String(s) + 'T00:00:00'); return Number.isNaN(d.getTime()) ? new Date(today) : d; };
12
14
  let value = parse(i.value), draft = null, wheels = [];
13
15
  const min = i.min === 'today' ? today : i.min ? parse(i.min) : null;
14
16
  const YEARS = Array.from({ length: Number(i.years) || 2 }, (_, k) => today.getFullYear() + k);
15
- const longDate = (d) => `${DAYLONG[d.getDay()]} ${d.getDate()} de ${MON[d.getMonth()]} de ${d.getFullYear()}`;
17
+ const longDate = (d) => (L === 'es' ? `${DAYLONG[d.getDay()]} ${d.getDate()} de ${MON[d.getMonth()]} de ${d.getFullYear()}` : `${DAYLONG[d.getDay()]}, ${MON[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`);
16
18
  const id = 'dp' + Math.random().toString(36).slice(2, 7), kind = 'date' + id;
17
- root.innerHTML = `<div data-slot="field" class="cn-field cn-field-orientation-vertical group/field"><span class="cn-label" id="${id}-l">${i.label || 'Fecha'}</span><button class="cn-button cn-button-variant-outline cn-button-size-default cx-picker-field" aria-labelledby="${id}-l ${id}-v"><svg><use href="#cal"/></svg><span id="${id}-v"></span><svg class="cx-pf-chev"><use href="#chev"/></svg></button></div>`;
19
+ root.innerHTML = `<div data-slot="field" class="cn-field cn-field-orientation-vertical group/field"><span class="cn-label" id="${id}-l">${T(i.label || 'Date')}</span><button class="cn-button cn-button-variant-outline cn-button-size-default cx-picker-field" aria-labelledby="${id}-l ${id}-v"><svg><use href="#cal"/></svg><span id="${id}-v"></span><svg class="cx-pf-chev"><use href="#chev"/></svg></button></div>`;
18
20
  const out = $(`#${id}-v`, root), paint = () => { out.textContent = longDate(value); };
19
21
  const daysIn = (y, m) => new Date(y, m + 1, 0).getDate();
20
22
  CONTENT[kind] = () => {
21
- document.getElementById('mdl-title').textContent = i.title || 'Elige la fecha'; document.getElementById('mdl-desc').textContent = 'Arrastra cada rueda, toca un valor o usa las flechas.';
23
+ document.getElementById('mdl-title').textContent = i.title || T('Pick the date'); document.getElementById('mdl-desc').textContent = T('Drag each wheel, tap a value or use the arrows.');
22
24
  body.innerHTML = '<div class="cx-wheels"><div class="cx-wheel" data-w="d"></div><div class="cx-wheel" data-flex="2" data-w="m"></div><div class="cx-wheel" data-w="y"></div></div><p class="cx-wheel-sum"></p>';
23
- foot.innerHTML = ui.button({ label: 'Cancelar', size: 'default', attrs: 'data-dismiss', cls: 'cx-exit' }) + ui.button({ label: 'Listo', variant: 'default', size: 'default', attrs: `data-dp-ok="${id}"`, cls: 'cx-go' });
25
+ foot.innerHTML = ui.button({ label: T('Cancel'), size: 'default', attrs: 'data-dismiss', cls: 'cx-exit' }) + ui.button({ label: T('Done'), variant: 'default', size: 'default', attrs: `data-dp-ok="${id}"`, cls: 'cx-go' });
24
26
  draft = { y: value.getFullYear(), m: value.getMonth(), d: value.getDate() };
25
27
  const sumEl = $('.cx-wheel-sum', body);
26
- const sum = () => { const d = new Date(draft.y, draft.m, draft.d), past = min && d < min; sumEl.textContent = past ? 'Esa fecha ya pasó' : longDate(d); sumEl.style.color = past ? 'var(--destructive)' : ''; $(`[data-dp-ok]`, foot).disabled = !!past; if (min) wD.offs((k) => new Date(draft.y, draft.m, k + 1) < min); };
28
+ const sum = () => { const d = new Date(draft.y, draft.m, draft.d), past = min && d < min; sumEl.textContent = past ? T('That date has passed') : longDate(d); sumEl.style.color = past ? 'var(--destructive)' : ''; $(`[data-dp-ok]`, foot).disabled = !!past; if (min) wD.offs((k) => new Date(draft.y, draft.m, k + 1) < min); };
27
29
  const days = () => Array.from({ length: daysIn(draft.y, draft.m) }, (_, k) => k + 1);
28
- const wD = makeWheel($('[data-w=d]', body), days(), draft.d - 1, (k) => { draft.d = k + 1; sum(); }, 'Día');
30
+ const wD = makeWheel($('[data-w=d]', body), days(), draft.d - 1, (k) => { draft.d = k + 1; sum(); }, T('Day'));
29
31
  const fixDays = () => { const max = daysIn(draft.y, draft.m); if (draft.d > max) draft.d = max; wD.relabel(days()); sum(); };
30
- const wM = makeWheel($('[data-w=m]', body), MONTHS, draft.m, (k) => { draft.m = k; fixDays(); }, 'Mes');
31
- const wY = makeWheel($('[data-w=y]', body), YEARS, Math.max(0, YEARS.indexOf(draft.y)), (k) => { draft.y = YEARS[k]; fixDays(); }, 'Año');
32
+ const wM = makeWheel($('[data-w=m]', body), MONTHS, draft.m, (k) => { draft.m = k; fixDays(); }, T('Month'));
33
+ const wY = makeWheel($('[data-w=y]', body), YEARS, Math.max(0, YEARS.indexOf(draft.y)), (k) => { draft.y = YEARS[k]; fixDays(); }, T('Year'));
32
34
  wheels = [wD, wM, wY]; sum();
33
35
  };
34
36
  $('button', root).addEventListener('click', () => { openModal(kind, 'auto'); requestAnimationFrame(() => requestAnimationFrame(() => { wheels.forEach((w) => w.place()); $('.cx-wheel', body)?.focus({ preventScroll: true }); })); });
@@ -38,22 +40,22 @@ export function dateField(root, i, core, on) {
38
40
 
39
41
  // TimeField: i {label, value "10:30", from 9, to 19, step 15} → on.change("HH:MM")
40
42
  export function timeField(root, i, core, on) {
41
- const { $, ui, makeWheel, openModal, closeModal, CONTENT, body, foot } = core;
43
+ const { $, ui, makeWheel, openModal, closeModal, CONTENT, body, foot } = core, T = (k, ...a) => core.t(core.langOf(root), k, ...a);
42
44
  const from = Number(i.from ?? 9), to = Number(i.to ?? 19), step = Number(i.step) || 15;
43
45
  const HOURS = Array.from({ length: to - from + 1 }, (_, k) => k + from), MINS = Array.from({ length: Math.ceil(60 / step) }, (_, k) => k * step);
44
46
  const [h0, m0] = String(i.value || `${pad(from)}:00`).split(':').map(Number);
45
47
  let value = { h: HOURS.includes(h0) ? h0 : from, m: MINS.includes(m0) ? m0 : 0 }, draft = null, wheels = [];
46
- const hm = (t) => `${pad(t.h)}:${pad(t.m)}`, part = (t) => (t.h < 12 ? 'mañana' : 'tarde');
48
+ const hm = (t) => `${pad(t.h)}:${pad(t.m)}`, part = (t) => (t.h < 12 ? T('morning') : T('afternoon'));
47
49
  const id = 'tp' + Math.random().toString(36).slice(2, 7), kind = 'time' + id;
48
- root.innerHTML = `<div data-slot="field" class="cn-field cn-field-orientation-vertical group/field"><span class="cn-label" id="${id}-l">${i.label || 'Hora'}</span><button class="cn-button cn-button-variant-outline cn-button-size-default cx-picker-field" aria-labelledby="${id}-l ${id}-v"><svg><use href="#clock"/></svg><span id="${id}-v"></span><svg class="cx-pf-chev"><use href="#chev"/></svg></button></div>`;
50
+ root.innerHTML = `<div data-slot="field" class="cn-field cn-field-orientation-vertical group/field"><span class="cn-label" id="${id}-l">${T(i.label || 'Time')}</span><button class="cn-button cn-button-variant-outline cn-button-size-default cx-picker-field" aria-labelledby="${id}-l ${id}-v"><svg><use href="#clock"/></svg><span id="${id}-v"></span><svg class="cx-pf-chev"><use href="#chev"/></svg></button></div>`;
49
51
  const out = $(`#${id}-v`, root), paint = () => { out.textContent = `${hm(value)} · ${part(value)}`; };
50
52
  CONTENT[kind] = () => {
51
- document.getElementById('mdl-title').textContent = i.title || 'Elige la hora'; document.getElementById('mdl-desc').textContent = i.description || `Horario: ${pad(from)}:00 a ${pad(to)}:${pad(MINS.at(-1))}. Arrastra o toca.`;
53
+ document.getElementById('mdl-title').textContent = i.title || T('Pick the time'); document.getElementById('mdl-desc').textContent = i.description || T('Hours: {0} to {1}. Drag or tap.', `${pad(from)}:00`, `${pad(to)}:${pad(MINS.at(-1))}`);
52
54
  body.innerHTML = '<div class="cx-wheels"><div class="cx-wheel" data-w="h"></div><div class="cx-wheel" data-w="mi"></div></div><p class="cx-wheel-sum"></p>';
53
- foot.innerHTML = ui.button({ label: 'Cancelar', size: 'default', attrs: 'data-dismiss', cls: 'cx-exit' }) + ui.button({ label: 'Listo', variant: 'default', size: 'default', attrs: `data-tp-ok="${id}"`, cls: 'cx-go' });
55
+ foot.innerHTML = ui.button({ label: T('Cancel'), size: 'default', attrs: 'data-dismiss', cls: 'cx-exit' }) + ui.button({ label: T('Done'), variant: 'default', size: 'default', attrs: `data-tp-ok="${id}"`, cls: 'cx-go' });
54
56
  draft = { ...value };
55
57
  const sumEl = $('.cx-wheel-sum', body), sum = () => { sumEl.textContent = `${hm(draft)} · ${part(draft)}`; };
56
- wheels = [makeWheel($('[data-w=h]', body), HOURS.map(pad), HOURS.indexOf(draft.h), (k) => { draft.h = HOURS[k]; sum(); }, 'Hora'), makeWheel($('[data-w=mi]', body), MINS.map(pad), MINS.indexOf(draft.m), (k) => { draft.m = MINS[k]; sum(); }, 'Minutos')];
58
+ wheels = [makeWheel($('[data-w=h]', body), HOURS.map(pad), HOURS.indexOf(draft.h), (k) => { draft.h = HOURS[k]; sum(); }, T('Hour')), makeWheel($('[data-w=mi]', body), MINS.map(pad), MINS.indexOf(draft.m), (k) => { draft.m = MINS[k]; sum(); }, T('Minutes'))];
57
59
  sum();
58
60
  };
59
61
  $('button', root).addEventListener('click', () => { openModal(kind, 'auto'); requestAnimationFrame(() => requestAnimationFrame(() => { wheels.forEach((w) => w.place()); $('.cx-wheel', body)?.focus({ preventScroll: true }); })); });
@@ -64,28 +66,28 @@ export function timeField(root, i, core, on) {
64
66
  // ImageUpload: i {title, hint, maxMb 10, size 1200, uploadUrl, sample "on"} → on.ready(json {dataUrl | url, width, height, kb})
65
67
  // Without uploadUrl the processed image is handed back as a data URL; with it, the WebP is POSTed with real progress.
66
68
  export function imageUpload(root, i, core, on) {
67
- const { $ } = core;
68
- const MAX = (Number(i.maxMb) || 10) * 1024 * 1024, SIZE = Number(i.size) || 1200, idle = i.hint || `JPG, PNG o WebP · hasta ${Number(i.maxMb) || 10} MB`;
69
- root.innerHTML = `<div class="cx-drop" tabindex="0" role="button"><input type="file" accept="image/*" hidden><div class="cx-drop-empty"><svg><use href="#upload"/></svg><b>${i.title || 'Sube la foto'}</b><span>Toca, arrastra o pega aquí</span></div><canvas class="cx-drop-img" width="600" height="600" hidden></canvas><div class="cx-drop-bar" hidden><i></i></div></div><p class="cx-note cx-drop-msg" style="text-align:left">${idle}</p><div class="cx-line cx-line-start">${i.sample === 'on' ? '<button data-slot="button" class="cn-button cn-button-variant-outline cn-button-size-sm group/button" data-drop-sample><svg><use href="#image"/></svg>Usar una de ejemplo</button>' : ''}<button data-slot="button" class="cn-button cn-button-variant-ghost cn-button-size-sm group/button" data-drop-remove hidden><svg><use href="#trash"/></svg>Quitar</button></div>`;
69
+ const { $ } = core, L = core.langOf(root), T = (k, ...a) => core.t(L, k, ...a);
70
+ const MAX = (Number(i.maxMb) || 10) * 1024 * 1024, SIZE = Number(i.size) || 1200, idle = i.hint || T('JPG, PNG or WebP · up to {0} MB', Number(i.maxMb) || 10);
71
+ root.innerHTML = `<div class="cx-drop" tabindex="0" role="button"><input type="file" accept="image/*" hidden><div class="cx-drop-empty"><svg><use href="#upload"/></svg><b>${T(i.title || 'Upload the photo')}</b><span>${T('Tap, drag or paste here')}</span></div><canvas class="cx-drop-img" width="600" height="600" hidden></canvas><div class="cx-drop-bar" hidden><i></i></div></div><p class="cx-note cx-drop-msg" style="text-align:left">${idle}</p><div class="cx-line cx-line-start">${i.sample === 'on' ? `<button data-slot="button" class="cn-button cn-button-variant-outline cn-button-size-sm group/button" data-drop-sample><svg><use href="#image"/></svg>${T('Use a sample')}</button>` : ''}<button data-slot="button" class="cn-button cn-button-variant-ghost cn-button-size-sm group/button" data-drop-remove hidden><svg><use href="#trash"/></svg>${T('Remove')}</button></div>`;
70
72
  const drop = $('.cx-drop', root), dropFile = $('input', root), dropMsg = $('.cx-drop-msg', root), dropCanvas = $('canvas', root), dropBar = $('.cx-drop-bar', root), dropProg = $('.cx-drop-bar i', root), removeB = $('[data-drop-remove]', root);
71
73
  const say = (text, tone) => { dropMsg.textContent = text; if (tone) dropMsg.dataset.tone = tone; else dropMsg.removeAttribute('data-tone'); };
72
74
  const coverDraw = (ctx, bmp, size) => { const s = Math.min(bmp.width, bmp.height); ctx.drawImage(bmp, (bmp.width - s) / 2, (bmp.height - s) / 2, s, s, 0, 0, size, size); };
73
- const progress = (p) => { dropProg.style.width = p + '%'; say(`Subiendo… ${Math.round(p)} %`); };
75
+ const progress = (p) => { dropProg.style.width = p + '%'; say(T('Uploading… {0} %', Math.round(p))); };
74
76
  const takeFile = async (file) => {
75
77
  if (!file) return;
76
- if (!file.type.startsWith('image/')) return say(`«${file.name}» no es una imagen. Usa JPG, PNG o WebP.`, 'bad');
77
- if (file.size > MAX) return say(`Pesa ${(file.size / 1048576).toFixed(1).replace('.', ',')} MB; el tope es ${Number(i.maxMb) || 10} MB.`, 'bad');
78
- say('Preparando la imagen…');
79
- let bmp; try { bmp = await createImageBitmap(file); } catch { return say('No pudimos leer esa imagen. Prueba con otra.', 'bad'); }
78
+ if (!file.type.startsWith('image/')) return say(T('“{0}” is not an image. Use JPG, PNG or WebP.', file.name), 'bad');
79
+ if (file.size > MAX) return say(T('It weighs {0} MB; the limit is {1} MB.', (file.size / 1048576).toFixed(1).replace('.', L === 'es' ? ',' : '.'), Number(i.maxMb) || 10), 'bad');
80
+ say(T('Preparing the image…'));
81
+ let bmp; try { bmp = await createImageBitmap(file); } catch { return say(T('We could not read that image. Try another.'), 'bad'); }
80
82
  coverDraw(dropCanvas.getContext('2d'), bmp, dropCanvas.width);
81
83
  const out = document.createElement('canvas'); out.width = out.height = Math.min(SIZE, Math.min(bmp.width, bmp.height)); coverDraw(out.getContext('2d'), bmp, out.width);
82
84
  const blob = await new Promise((r) => out.toBlob(r, 'image/webp', 0.86));
83
85
  dropCanvas.hidden = false; drop.setAttribute('data-has', ''); removeB.hidden = false; dropBar.hidden = false; progress(0);
84
- const done = (extra) => { dropBar.hidden = true; say(`Lista · ${out.width} × ${out.height} px, ${Math.max(1, Math.round(blob.size / 1024))} KB`, 'ok'); on?.ready?.(JSON.stringify({ width: out.width, height: out.height, kb: Math.round(blob.size / 1024), ...extra })); };
86
+ const done = (extra) => { dropBar.hidden = true; say(T('Ready · {0} × {1} px, {2} KB', out.width, out.height, Math.max(1, Math.round(blob.size / 1024))), 'ok'); on?.ready?.(JSON.stringify({ width: out.width, height: out.height, kb: Math.round(blob.size / 1024), ...extra })); };
85
87
  if (i.uploadUrl) {
86
88
  const xhr = new XMLHttpRequest(); xhr.open('POST', i.uploadUrl); xhr.upload.onprogress = (e) => { if (e.lengthComputable) progress((e.loaded / e.total) * 100); };
87
- xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { progress(100); setTimeout(() => done({ url: xhr.responseText }), 250); } else say('No se pudo subir. Inténtalo otra vez.', 'bad'); };
88
- xhr.onerror = () => say('No se pudo subir. Revisa tu conexión.', 'bad'); xhr.send(blob);
89
+ xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { progress(100); setTimeout(() => done({ url: xhr.responseText }), 250); } else say(T('It could not be uploaded. Try again.'), 'bad'); };
90
+ xhr.onerror = () => say(T('It could not be uploaded. Check your connection.'), 'bad'); xhr.send(blob);
89
91
  } else { const r = new FileReader(); r.onload = () => { progress(100); setTimeout(() => done({ dataUrl: r.result }), 200); }; r.readAsDataURL(blob); }
90
92
  };
91
93
  drop.addEventListener('click', () => { if (!drop.hasAttribute('data-has')) dropFile.click(); });
@@ -101,6 +103,6 @@ export function imageUpload(root, i, core, on) {
101
103
  const gr = g.createLinearGradient(0, 0, 1600, 1100); gr.addColorStop(0, '#d9b48c'); gr.addColorStop(1, '#5e3f27'); g.fillStyle = gr; g.fillRect(0, 0, 1600, 1100);
102
104
  g.fillStyle = 'rgba(255,255,255,.18)'; for (let k = 0; k < 9; k++) { g.beginPath(); g.arc(300 + k * 130, 550 + Math.sin(k) * 160, 60 + (k % 3) * 30, 0, 7); g.fill(); }
103
105
  g.fillStyle = '#fff'; g.font = '600 110px sans-serif'; g.textAlign = 'center'; g.fillText('El Roble', 800, 590);
104
- c.toBlob((b) => takeFile(new File([b], 'ejemplo.png', { type: 'image/png' })), 'image/png');
106
+ c.toBlob((b) => takeFile(new File([b], 'sample.png', { type: 'image/png' })), 'image/png');
105
107
  });
106
108
  }
@@ -0,0 +1,172 @@
1
+ // kit/i18n.js - the library's words. The code is written in ENGLISH and the English text is the key; each other
2
+ // language is a dictionary from that text to its own. A piece speaks the language of the nearest `lang` (a container's
3
+ // attribute, else the page's <html lang>), English when there is none or the language has no dictionary.
4
+ // t(lang, 'Page {0} of {1}', 2, 9) → «Página 2 de 9» in Spanish
5
+ // 'One item | {0} items' → the first form when the first argument is 1
6
+ // An app with its own i18n (Storio) changes nothing: it already sets <html lang>, and a container (the public store,
7
+ // in the shop's language) can carry its own `lang`. A Custom also takes `lang` through its inputs.
8
+
9
+ export const LANGS = { es: {} };
10
+ const ES = LANGS.es;
11
+
12
+ /** Add words to a language (a part's own module can extend the dictionary the same way). */
13
+ export const words = (lang, table) => { LANGS[lang] = Object.assign(LANGS[lang] || {}, table); };
14
+
15
+ /** The language a node speaks: its nearest `lang`, else the page's; 'en' when unknown. */
16
+ export const langOf = (node) => {
17
+ const from = (node && node.closest && node.closest('[lang]')) || document.documentElement;
18
+ const code = String(from.getAttribute('lang') || 'en').toLowerCase().slice(0, 2);
19
+ return LANGS[code] ? code : 'en';
20
+ };
21
+
22
+ /** The text in that language (the English key when it has no entry), with {0}, {1}… filled and «one | many» chosen. */
23
+ export const t = (lang, key, ...args) => {
24
+ let text = (lang !== 'en' && LANGS[lang] && LANGS[lang][key]) || key;
25
+ if (text.includes(' | ')) { const [one, many] = text.split(' | '); text = Number(args[0]) === 1 ? one : many; }
26
+ return args.length ? text.replace(/\{(\d+)\}/g, (m, k) => (args[+k] ?? m)) : text;
27
+ };
28
+
29
+ /* What a part or a Custom MARKS is said in the language of its nearest `lang`. The marks are keyed by the English text:
30
+ data-tr the element's text · data-trl its aria-label · data-trn its tooltip (data-name) · data-trp its placeholder ·
31
+ data-trt its title · data-tra the values for {0}… split by «|». Only a text the piece wrote itself is replaced (it
32
+ still reads as the English key, or as what was said last): a value the app passed is left alone. kit/core.js runs it
33
+ on the whole page and again on every change; a Custom that renders on its own calls it on its root. */
34
+ const MARKS = [['tr', null], ['trl', 'aria-label'], ['trn', 'data-name'], ['trp', 'placeholder'], ['trt', 'title']];
35
+ const MARKED = '[data-tr],[data-trl],[data-trn],[data-trp],[data-trt]';
36
+ export const localize = (el) => {
37
+ const lang = langOf(el), args = el.dataset.tra ? el.dataset.tra.split('|') : [];
38
+ const said = el.__said || (el.__said = {});
39
+ for (const [key, attr] of MARKS) {
40
+ const k = el.dataset[key]; if (!k) continue;
41
+ const now = attr ? el.getAttribute(attr) : el.textContent;
42
+ if (now !== t('en', k, ...args) && now !== said[key] && now !== null && now !== '') continue; // the app's own text
43
+ const next = t(lang, k, ...args); said[key] = next;
44
+ if (now === next) continue;
45
+ if (attr) el.setAttribute(attr, next); else el.textContent = next;
46
+ }
47
+ };
48
+ export const localizeTree = (root) => { if (!root || root.nodeType !== 1) return; if (root.matches(MARKED)) localize(root); root.querySelectorAll(MARKED).forEach(localize); };
49
+
50
+ // ── Spanish ──
51
+ // Kept in one table so a translator reads it top to bottom; grouped by the piece that says it.
52
+ Object.assign(ES, {
53
+ // shared
54
+ 'Close': 'Cerrar', 'Cancel': 'Cancelar', 'Save': 'Guardar', 'Save changes': 'Guardar cambios', 'Saving…': 'Guardando…', 'Saved': 'Guardado',
55
+ 'Search': 'Buscar', 'Search…': 'Buscar…', 'Clear search': 'Limpiar búsqueda', 'Copy': 'Copiar', 'Copied': 'Copiado', 'Select it and copy': 'Selecciónalo y copia', 'Select it': 'Selecciónalo',
56
+ 'Loading': 'Cargando', 'Previous': 'Anterior', 'Next': 'Siguiente', 'More': 'Más', 'Back': 'Volver', 'Done': 'Listo', 'Send': 'Enviar', 'Stop': 'Detener',
57
+ 'Today': 'Hoy', 'Tomorrow': 'Mañana', 'Yesterday': 'Ayer', 'Menu': 'Menú', 'Show or hide the menu': 'Mostrar u ocultar el menú',
58
+ });
59
+
60
+ // parts (.muten): their default words and fixed labels
61
+ Object.assign(ES, {
62
+ 'Search or jump to…': 'Busca o salta a…', 'Nothing matches.': 'Nada coincide.', 'Breadcrumb': 'Migas', 'More pages': 'Más páginas', 'Book': 'Reservar',
63
+ 'Menu for this area': 'Menú de esta zona', 'Messages': 'Mensajes', 'Less': 'Menos', 'Contents': 'Contenido', 'Pages': 'Páginas', 'Photo': 'Foto',
64
+ 'Progress': 'Progreso', 'results': 'resultados', 'Date': 'Fecha', 'Time': 'Hora', 'person': 'persona', 'people': 'personas', 'Invite': 'Invitar',
65
+ 'Recommended': 'Recomendado', 'row': 'fila', 'rows': 'filas', 'Upload a file': 'Sube un archivo', 'Tap, drag or paste here': 'Toca, arrastra o pega aquí',
66
+ 'New appointment': 'Nueva cita', 'Files': 'Archivos', 'Order': 'Orden', 'Continue': 'Continuar', 'Password': 'Contraseña', 'Colors': 'Colores',
67
+ 'Tools': 'Herramientas', 'More options': 'Más opciones', 'Connect a folder': 'Conectar carpeta', 'Price': 'Precio', 'Upload the photo': 'Sube la foto',
68
+ 'Properties': 'Propiedades', 'Main': 'Principal', 'Permission': 'Permiso', 'Conversation': 'Conversación', 'Phone': 'Teléfono', 'Sections': 'Secciones',
69
+ 'Actions': 'Acciones', 'Share': 'Compartir', 'Footer': 'Pie de página', 'Tags': 'Etiquetas', 'List': 'Lista', 'Status': 'Estado',
70
+ 'Sending': 'Enviando', 'Sent': 'Enviado', 'Delivered': 'Entregado', 'Read': 'Leído', 'Online': 'En línea', 'Resize': 'Cambiar el tamaño',
71
+ 'Move {0}': 'Mover {0}', 'Back to 100 %': 'Volver a 100 %', 'Zoom {0} %, back to 100': 'Zoom {0} %, volver a 100',
72
+ "%owner's card": 'Cartilla de %owner', '%n of %total · %left more for %reward': '%n de %total · faltan %left para %reward', 'Complete! Earned %reward.': '¡Completa! Ganó %reward.',
73
+ '%n of %total stamps': '%n de %total sellos', 'Redeem reward': 'Canjear premio', 'Stamp a visit': 'Sellar visita',
74
+ });
75
+
76
+ // dates (calendar, date pickers)
77
+ Object.assign(ES, {
78
+ 'You picked {0}, {1}': 'Elegiste el {0} {1}', 'From {0} to {1} · {2} days': 'Del {0} al {1} · {2} días', 'From {0}: pick the last day': 'Desde el {0}: elige el último día',
79
+ 'Last 7 days': 'Últimos 7 días', 'This month': 'Este mes', 'Last month': 'Mes pasado',
80
+ });
81
+
82
+ // small Customs (code, file input, sortable, number field, kit widgets)
83
+ Object.assign(ES, {
84
+ 'Remove {0}': 'Quitar {0}', '{0} is not a type of file that can be uploaded here.': '{0} no es de un tipo que se pueda subir aquí.', '{0} is over {1} MB.': '{0} pesa más de {1} MB.',
85
+ 'Drag to reorder': 'Arrastra para ordenar', '{0}: position {1} of {2}': '{0}: posición {1} de {2}', 'Number': 'Número', 'Assistant': 'Asistente',
86
+ });
87
+
88
+ // values: money, phone, verification code, sliders
89
+ Object.assign(ES, {
90
+ 'maximum': 'máximo', 'minimum': 'mínimo', 'Currency': 'Moneda', 'Country': 'País', '{0} (it has no minor unit)': '{0} (no tiene unidad menor)', 'cents of {0}': 'centavos de {0}',
91
+ 'Saved as: {0} {1}': 'Se guarda: {0} {1}', 'Type a price': 'Escribe un precio', 'Search currency': 'Buscar moneda', 'Search country': 'Buscar país',
92
+ 'Saved as +{0}': 'Se guarda como +{0}', '{0} digits short for a number from {1}': 'Faltan {0} dígitos para un número de {1}', '{0} digits, without the {1}': '{0} dígitos, sin el {1}',
93
+ '{0}-digit code': 'Código de {0} dígitos', 'Checking…': 'Verificando…', 'Resend in 0:{0}': 'Reenviar en 0:{0}', 'Resend code': 'Reenviar código',
94
+ 'We sent you a new code': 'Te enviamos un código nuevo', 'Done, it is verified': 'Listo, quedó verificado', 'That is not the code': 'Ese código no es',
95
+ });
96
+
97
+ // menus and collections: combobox, row menu, store QR, tags, pagination, virtual list, carousel
98
+ Object.assign(ES, {
99
+ 'Use “{0}”': 'Usar «{0}»', 'Type to search': 'Escribe para buscar', 'QR code of {0}': 'Código QR de {0}', 'Copy link': 'Copiar enlace',
100
+ 'You reached the limit of {0}': 'Llegaste al tope de {0}', 'Add and press Enter': 'Agrega y presiona Enter',
101
+ 'Previous page': 'Página anterior', 'Next page': 'Página siguiente', 'Page {0}': 'Página {0}', 'Page {0} of {1}': 'Página {0} de {1}', '{0}–{1} of {2} {3}': '{0}–{1} de {2} {3}',
102
+ 'Go to {0}': 'Ir al {0}', '{0}–{1} of {2} · {3} rows in the DOM': '{0}–{1} de {2} · {3} filas en el DOM',
103
+ 'Pause': 'Pausar', 'Play': 'Reproducir', 'Slides': 'Diapositivas', 'Go to {0} of {1}': 'Ir a {0} de {1}', 'Thumbnails': 'Miniaturas', 'Photo {0}': 'Foto {0}',
104
+ });
105
+
106
+ // table
107
+ Object.assign(ES, {
108
+ 'Next 7 days': 'Próximos 7 días', 'Later': 'Más adelante', 'Clear selection': 'Quitar selección', 'Filters': 'Filtros', 'Select all': 'Seleccionar todas', 'Clear': 'Limpiar',
109
+ 'No limit': 'Sin tope', 'From': 'Desde', 'To': 'Hasta', 'Remove filter': 'Quitar filtro', 'Search in {0}': 'Buscar en {0}', 'Any date': 'Cualquier fecha', 'See {0} {1}': 'Ver {0} {1}',
110
+ 'They apply as you pick.': 'Se aplican mientras eliges.', 'Remove': 'Quitar', 'Select {0}': 'Seleccionar {0}', 'Clear filters': 'Limpiar filtros',
111
+ 'Nothing matches the filters': 'Nada coincide con los filtros', 'Remove one or clear them all.': 'Quita alguno o límpialos todos.',
112
+ });
113
+
114
+ // date and time fields, image upload
115
+ Object.assign(ES, {
116
+ 'Pick the date': 'Elige la fecha', 'Drag each wheel, tap a value or use the arrows.': 'Arrastra cada rueda, toca un valor o usa las flechas.', 'That date has passed': 'Esa fecha ya pasó',
117
+ 'Day': 'Día', 'Month': 'Mes', 'Year': 'Año', 'morning': 'mañana', 'afternoon': 'tarde', 'Pick the time': 'Elige la hora', 'Hours: {0} to {1}. Drag or tap.': 'Horario: {0} a {1}. Arrastra o toca.',
118
+ 'Hour': 'Hora', 'Minutes': 'Minutos', 'JPG, PNG or WebP · up to {0} MB': 'JPG, PNG o WebP · hasta {0} MB', 'Use a sample': 'Usar una de ejemplo', 'Uploading… {0} %': 'Subiendo… {0} %',
119
+ '“{0}” is not an image. Use JPG, PNG or WebP.': '«{0}» no es una imagen. Usa JPG, PNG o WebP.', 'It weighs {0} MB; the limit is {1} MB.': 'Pesa {0} MB; el tope es {1} MB.',
120
+ 'Preparing the image…': 'Preparando la imagen…', 'We could not read that image. Try another.': 'No pudimos leer esa imagen. Prueba con otra.', 'Ready · {0} × {1} px, {2} KB': 'Lista · {0} × {1} px, {2} KB',
121
+ 'It could not be uploaded. Try again.': 'No se pudo subir. Inténtalo otra vez.', 'It could not be uploaded. Check your connection.': 'No se pudo subir. Revisa tu conexión.',
122
+ });
123
+
124
+ // structure: phone shell, share, app shell, booking, new-appointment wizard
125
+ Object.assign(ES, {
126
+ 'Link copied. Paste it in your bio or in an Instagram story.': 'Enlace copiado. Pégalo en tu bio o en una historia de Instagram.', 'Link copied. Paste it in your TikTok bio.': 'Enlace copiado. Pégalo en tu bio de TikTok.',
127
+ '{0} at {1}': '{0} en {1}', 'Book at {0}': 'Reserva en {0}', 'Share product': 'Compartir producto', 'Share link': 'Compartir enlace', 'Select the link and copy it.': 'Selecciona el enlace y cópialo.',
128
+ 'Share on': 'Compartir en', 'QR code': 'Código QR', 'Share in another app': 'Compartir en otra app', 'Link copied.': 'Enlace copiado.',
129
+ 'Nothing here yet.': 'Todavía no hay nada aquí.', 'Detail': 'Detalle', 'History': 'Historial', 'Nothing with “{0}”': 'Nada con «{0}»', 'move': 'moverte', 'open': 'abrir', 'close': 'cerrar',
130
+ 'Previous week': 'Semana anterior', 'Next week': 'Semana siguiente', 'Pick a day and a time': 'Elige un día y una hora', 'pick a time': 'elige una hora', 'today': 'hoy', 'closed': 'cerrado',
131
+ '{0} times': '{0} horarios', 'no times': 'sin horarios', 'No times left this day.': 'No quedan horarios este día.', 'See {0} {1}': 'Ver {0} {1}', 'Morning': 'Mañana', 'Afternoon': 'Tarde',
132
+ 'Client': 'Cliente', 'Service': 'Servicio', 'Day and time': 'Día y hora', 'Confirm': 'Confirmar', 'Nobody with “{0}”': 'Nadie con «{0}»', 'Search or pick': 'Busca o elige', 'Name or phone': 'Nombre o teléfono',
133
+ 'Create appointment': 'Crear cita',
134
+ });
135
+
136
+ // order board
137
+ Object.assign(ES, {
138
+ 'Review receipt': 'Revisar comprobante', 'See Stripe receipt': 'Ver recibo de Stripe', 'See delivery': 'Ver entrega', 'See map': 'Ver mapa', 'Client note': 'Nota del cliente',
139
+ "{0}'s note: {1}": 'Nota de {0}: {1}', "{0}'s note": 'Nota de {0}', 'Pending': 'Pendientes', 'Contact {0} on {1}, as they asked': 'Contactar a {0} por {1}, como lo pidió', 'Prefers {0}': 'Prefiere {0}',
140
+ 'a call': 'una llamada', 'Drop an order here': 'Suelta aquí un pedido', 'No message from the client.': 'Sin mensaje del cliente.', 'Also to solve': 'También por resolver',
141
+ 'Copy note': 'Copiar nota', 'Payment receipt': 'Recibo de pago', '{0} · order {1}': '{0} · pedido {1}', 'Paid': 'Pagado', 'Method': 'Medio', 'Processed by': 'Procesado por',
142
+ 'Oct 8, 2026': '8 oct 2026', 'Payment ID': 'ID del pago', 'Fee': 'Comisión', 'You get': 'Recibes', 'Open in Stripe': 'Abrir en Stripe', 'Arrives in': 'Llega en', 'Open in Maps': 'Abrir en Maps',
143
+ 'Canceled': 'Cancelado', 'Refunded {0}': 'Devuelto {0}', 'Prepare': 'Preparar', 'Payment proof': 'Comprobante de pago', 'Screenshot of the transfer': 'Captura de la transferencia',
144
+ 'Transfer successful': 'Transferencia exitosa', 'Reference': 'Referencia', 'Order amount': 'Monto del pedido', 'Matches': 'Coincide', 'Reject': 'Rechazar', 'Approve payment': 'Aprobar pago',
145
+ 'Complaint': 'Queja', 'Your answer': 'Tu respuesta', 'It reaches them on WhatsApp.': 'Le llega por WhatsApp.', 'Send answer': 'Enviar respuesta', 'Rejected': 'Rechazado',
146
+ 'Complaint answered': 'Queja respondida', 'now': 'ahora', 'Refund': 'Reembolsar', 'Answer {0}': 'Responder a {0}', 'Complaint about order {0}': 'Queja del pedido {0}',
147
+ });
148
+
149
+ // kit core: stepper, messaging, chat, chart, dirty bar, plans, card, team, stamp card, bell, waitlist, password
150
+ Object.assign(ES, {
151
+ 'Step {0} of {1}: {2}': 'Paso {0} de {1}: {2}', 'Suggestions': 'Sugerencias', 'Play {0} s of audio': 'Reproducir audio de {0} s', 'Audio': 'Audio', 'Typing': 'Escribiendo', 'Options': 'Opciones',
152
+ 'Write a message': 'Escribe un mensaje', 'Write a message…': 'Escribe un mensaje…', 'Templates': 'Plantillas', 'Emoji': 'Emoji', 'Enter sends · Shift+Enter adds a line break': 'Enter envía · Shift+Enter hace un salto de línea',
153
+ 'Nothing archived': 'Nada archivado', 'Nothing here': 'Nada por aquí', 'Try another name.': 'Prueba con otro nombre.', 'What you archive stays here.': 'Lo que archives queda aquí.',
154
+ 'When they write to you, it shows up here.': 'Cuando te escriban, aparece aquí.', '{0} goes to {1} on {2}.': 'Se le envía {0} a {1} por {2}.', 'Quick actions': 'Acciones rápidas',
155
+ 'Pick a conversation': 'Elige una conversación', 'Back to the inbox': 'Volver a la bandeja', 'See client': 'Ver cliente', 'Unarchive': 'Sacar del archivo', 'Archive': 'Archivar', 'Delete': 'Eliminar',
156
+ 'Now': 'Ahora', 'Delete the conversation?': '¿Eliminar la conversación?', 'The chat with {0} is deleted from your inbox. Their appointments and orders stay the same.': 'Se borra el chat con {0} de tu bandeja. Sus citas y pedidos siguen igual.',
157
+ 'Connect a channel': 'Conectar un canal', 'Connect': 'Conectar', 'Picked: {0}': 'Elegida: {0}', 'none': 'ninguna', ' (typed by you)': ' (escrita por ti)', 'Hi {0}, I am so sorry. We are sending you a new one today at no cost.': 'Hola {0}, lo siento mucho. Te enviamos una nueva hoy sin costo.', 'The messages of every channel arrive in this same inbox.': 'Los mensajes de cada canal llegan a esta misma bandeja.', 'Conversation archived': 'Conversación archivada',
158
+ 'Back in the inbox': 'De vuelta en la bandeja', 'You': 'Tú', 'Search conversation': 'Buscar conversación', 'Filter': 'Filtrar', 'Conversation solved': 'Conversación resuelta', 'Conversation deleted': 'Conversación eliminada',
159
+ 'Connected': 'Conectado', 'Assistant · answers at once': 'Asistente · responde al instante', 'Thinking': 'Pensando', 'Stopped': 'Detenido', 'Copy answer': 'Copiar respuesta', 'It helped': 'Me sirvió', 'It did not help': 'No me sirvió',
160
+ 'Period': 'Periodo', 'Remove range': 'Quitar tramo', 'Add range': 'Agregar tramo', 'Copy to all': 'Copiar a todos', 'Unsaved changes': 'Cambios sin guardar', 'You have unsaved changes': 'Tienes cambios sin guardar', 'Discard': 'Descartar',
161
+ 'Billing': 'Facturación', 'Monthly': 'Mensual', 'Yearly': 'Anual', 'Your plan': 'Tu plan', '/ month': '/ mes', '/ year': '/ año', 'Your current plan': 'Tu plan actual', 'Move up to {0}': 'Pasar a {0}', 'Move down to {0}': 'Bajar a {0}',
162
+ 'Card number': 'Número de la tarjeta', 'Expires': 'Vence', 'Code': 'Código', 'Secure payment with Stripe. We do not keep the number.': 'Pago seguro con Stripe. No guardamos el número.', 'Save card': 'Guardar tarjeta',
163
+ 'Check the card number.': 'Revisa el número de la tarjeta.', 'The expiry date is not valid.': 'La fecha de vencimiento no es válida.', 'The 3 or 4 digit code is missing.': 'Falta el código de 3 o 4 dígitos.',
164
+ '{0} card ending in {1} saved.': 'Tarjeta {0} terminada en {1} guardada.', 'Invitation sent': 'Invitación enviada', 'Role of {0}': 'Rol de {0}', 'Email': 'Correo', 'name@email.com': 'nombre@correo.com', 'Role': 'Rol',
165
+ 'Send invitation': 'Enviar invitación', 'Notices': 'Avisos', 'The system bell: tap to see the notices.': 'La campana del sistema: toca para ver los avisos.', 'Notices, {0} unread': 'Avisos, {0} sin leer',
166
+ 'Mark all as read': 'Marcar todo como leído', 'Unread': 'Sin leer', 'All caught up': 'Todo al día', '{0} waiting': '{0} esperando', 'Offered {0} · {1}': 'Ofrecido {0} · {1}', '{0} · waiting since {1}': '{0} · espera desde {1}',
167
+ 'Offer a time': 'Ofrecer horario', 'today 16:30': 'hoy 16:30', '8 characters or more': '8 caracteres o más', 'A number': 'Un número', 'A capital letter': 'Una mayúscula', 'A symbol': 'Un símbolo',
168
+ 'Very weak': 'Muy débil', 'Weak': 'Débil', 'Fair': 'Regular', 'Good': 'Buena', 'Strong': 'Fuerte', 'Strength': 'Fuerza', 'Type a password': 'Escribe una contraseña', 'Show password': 'Mostrar contraseña', 'Hide password': 'Ocultar contraseña', 'Show': 'Mostrar',
169
+ });
170
+
171
+ // folder picker, field behavior
172
+ Object.assign(ES, { 'This browser cannot pick folders: use Chrome or Edge.': 'Este navegador no deja elegir carpetas: usa Chrome o Edge.', 'Check the email.': 'Revisa el correo.' });
@@ -1,12 +1,12 @@
1
1
  // @muten/shadcn · menus (kit): the reference artifact's Combobox (type, it filters and marks the match, arrows, Enter,
2
- // «Usar …» for something not in the list), the row menu ⋯ (arrows, Home/End, type-ahead, Esc returns focus) and the
3
- // store QR (with the mark in the middle and «Copiar enlace»). Generic: options, items and the link come in.
2
+ // “Usar …” for something not in the list), the row menu ⋯ (arrows, Home/End, type-ahead, Esc returns focus) and the
3
+ // store QR (with the mark in the middle and “Copiar enlace”). Generic: options, items and the link come in.
4
4
 
5
5
  export function combobox(root, i, core, on) {
6
- const { $, $$, lib, place, show, hide, fold } = core;
6
+ const { $, $$, lib, place, show, hide, fold } = core, T = (k, ...a) => core.t(core.langOf(root), k, ...a);
7
7
  const OPTIONS = String(i.options || '').split(',').map((x) => x.trim()).filter(Boolean);
8
8
  const allowNew = i.allowNew !== 'off', id = 'cb' + Math.random().toString(36).slice(2, 7);
9
- root.innerHTML = `<div data-slot="field" class="cn-field cn-field-orientation-vertical group/field"><label data-slot="label" class="cn-label" for="${id}">${i.label || ''}</label><div data-slot="input-group" class="cn-input-group group/input-group cx-combo"><input data-slot="input-group-control" class="cn-input cn-input-group-input" id="${id}" role="combobox" aria-expanded="false" aria-controls="${id}-list" aria-autocomplete="list" autocomplete="off" placeholder="${i.placeholder || ''}"><div data-slot="input-group-addon" class="cn-input-group-addon cn-input-group-addon-align-inline-end"><svg><use href="#updown"/></svg></div></div>${i.hint === 'off' ? '' : `<p data-slot="field-description" class="cn-field-description" data-cb-out>Elegida: ${i.value || 'ninguna'}</p>`}</div>`;
9
+ root.innerHTML = `<div data-slot="field" class="cn-field cn-field-orientation-vertical group/field"><label data-slot="label" class="cn-label" for="${id}">${i.label || ''}</label><div data-slot="input-group" class="cn-input-group group/input-group cx-combo"><input data-slot="input-group-control" class="cn-input cn-input-group-input" id="${id}" role="combobox" aria-expanded="false" aria-controls="${id}-list" aria-autocomplete="list" autocomplete="off" placeholder="${i.placeholder || ''}"><div data-slot="input-group-addon" class="cn-input-group-addon cn-input-group-addon-align-inline-end"><svg><use href="#updown"/></svg></div></div>${i.hint === 'off' ? '' : `<p data-slot="field-description" class="cn-field-description" data-cb-out>${T('Picked: {0}', i.value || T('none'))}</p>`}</div>`;
10
10
  const cIn = $('input', root), cGroup = $('.cx-combo', root), cOut = $('[data-cb-out]', root);
11
11
  const cList = document.createElement('div'); cList.className = 'cx-combo-list pg-pop'; cList.id = `${id}-list`; cList.setAttribute('role', 'listbox'); cList.hidden = true; lib.appendChild(cList);
12
12
  let cOpts = [], cHl = 0, cValue = i.value || '';
@@ -16,16 +16,17 @@ export function combobox(root, i, core, on) {
16
16
  const typed = cIn.value.trim();
17
17
  const hits = OPTIONS.filter((c) => fold(c).includes(fold(typed)));
18
18
  cOpts = hits.map((c) => ({ value: c, html: `<span>${mark(c, typed)}</span>` + (c === cValue ? '<svg class="cx-tick"><use href="#check"/></svg>' : '') }));
19
- if (allowNew && typed && !OPTIONS.some((c) => fold(c) === fold(typed))) cOpts.push({ value: typed, html: `<span>Usar «<b>${typed.replace(/</g, '&lt;')}</b>»</span>`, isNew: true });
19
+ if (allowNew && typed && !OPTIONS.some((c) => fold(c) === fold(typed))) cOpts.push({ value: typed, html: `<span>${T('Use “{0}”', `<b>${typed.replace(/</g, '&lt;')}</b>`)}</span>`, isNew: true });
20
20
  cHl = Math.min(cHl, Math.max(0, cOpts.length - 1));
21
- cList.innerHTML = cOpts.map((o, k) => `<div class="cx-opt${o.isNew ? ' cx-opt-new' : ''}" role="option" id="${id}-o${k}" data-k="${k}" aria-selected="${o.value === cValue}"${k === cHl ? ' data-hl' : ''}>${o.html}</div>`).join('') || '<div class="cx-opt" style="color:var(--muted-foreground)">Escribe para buscar</div>';
21
+ cList.innerHTML = cOpts.map((o, k) => `<div class="cx-opt${o.isNew ? ' cx-opt-new' : ''}" role="option" id="${id}-o${k}" data-k="${k}" aria-selected="${o.value === cValue}"${k === cHl ? ' data-hl' : ''}>${o.html}</div>`).join('') || `<div class="cx-opt" style="color:var(--muted-foreground)">${T('Type to search')}</div>`;
22
22
  cIn.setAttribute('aria-activedescendant', cOpts.length ? `${id}-o${cHl}` : '');
23
23
  $('[data-hl]', cList)?.scrollIntoView({ block: 'nearest' });
24
24
  };
25
25
  const cOpen = () => { if (!cList.hidden && cList.dataset.state === 'open') return; cRender(); cList.hidden = false; place(cList, cGroup, 'start', 4, cGroup.offsetWidth); show(cList); cIn.setAttribute('aria-expanded', 'true'); };
26
26
  const cClose = () => { hide(cList, 110); cIn.setAttribute('aria-expanded', 'false'); };
27
- const cPick = (k) => { const o = cOpts[k]; if (!o) return; cValue = o.value; cIn.value = o.value; if (cOut) cOut.textContent = `Elegida: ${o.value}${o.isNew ? ' (escrita por ti)' : ''}`; cClose(); on?.change?.(o.value); };
27
+ const cPick = (k) => { const o = cOpts[k]; if (!o) return; cValue = o.value; cIn.value = o.value; if (cOut) cOut.textContent = T('Picked: {0}', o.value) + (o.isNew ? T(' (typed by you)') : ''); cClose(); on?.change?.(o.value); };
28
28
  cIn.addEventListener('focus', cOpen);
29
+ cIn.addEventListener('click', cOpen); // already focused after a pick: a tap opens it again
29
30
  cIn.addEventListener('input', () => { cHl = 0; cOpen(); cRender(); });
30
31
  cIn.addEventListener('keydown', (e) => {
31
32
  if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); if (cList.hidden) return cOpen(); cHl = (cHl + (e.key === 'ArrowDown' ? 1 : -1) + cOpts.length) % Math.max(1, cOpts.length); cRender(); }
@@ -43,9 +44,9 @@ export function combobox(root, i, core, on) {
43
44
 
44
45
  // Row menu ⋯: the trigger button (in the row's actions) and its menu. i {label, items [{id, label, icon, danger, separator}]}
45
46
  export function rowMenu(root, i, core, on) {
46
- const { $, lib, place, show, hide, fold, ui } = core;
47
+ const { $, lib, place, show, hide, fold, ui } = core, T = (k, ...a) => core.t(core.langOf(root), k, ...a);
47
48
  const items = Array.isArray(i.items) ? i.items : [];
48
- root.innerHTML = ui.button({ icon: 'more', variant: 'ghost', size: 'icon-sm', attrs: `data-menu aria-haspopup="menu" aria-expanded="false" aria-label="${i.label || 'Más opciones'}" title="Más opciones"` });
49
+ root.innerHTML = ui.button({ icon: 'more', variant: 'ghost', size: 'icon-sm', attrs: `data-menu aria-haspopup="menu" aria-expanded="false" aria-label="${T(i.label || 'More options')}" title="${T(i.label || 'More options')}"` });
49
50
  const btn = $('[data-menu]', root);
50
51
  const menu = document.createElement('div'); menu.dataset.slot = 'dropdown-menu-content'; menu.className = 'cn-dropdown-menu-content pg-pop'; menu.setAttribute('role', 'menu'); menu.hidden = true; menu.style.minWidth = '184px';
51
52
  menu.innerHTML = items.map((it) => `${it.separator ? '<div data-slot="dropdown-menu-separator" class="cn-dropdown-menu-separator"></div>' : ''}<div data-slot="dropdown-menu-item" ${it.danger ? 'data-variant="destructive"' : ''} class="cn-dropdown-menu-item group/dropdown-menu-item" role="menuitem" tabindex="-1" data-act="${it.id}">${it.icon ? ui.icon(it.icon) : ''}${it.label}</div>`).join('');
@@ -69,11 +70,11 @@ export function rowMenu(root, i, core, on) {
69
70
  addEventListener('scroll', () => { if (open) place(menu, btn, 'end', 4); }, { passive: true });
70
71
  }
71
72
 
72
- // Store QR: the code with the shop's mark in the middle, the name, the link and «Copiar enlace».
73
+ // Store QR: the code with the shop's mark in the middle, the name, the link and “Copy link”.
73
74
  export function storeQr(root, i, core) {
74
- const { $ } = core;
75
+ const { $ } = core, T = (k, ...a) => core.t(core.langOf(root), k, ...a);
75
76
  const url = i.url || '', shown = url.replace(/^https?:\/\//, '');
76
- root.innerHTML = `<div class="cx-qr-card"><div class="cx-qr" role="img" aria-label="Código QR de ${shown}"></div><div class="cx-qr-side"><b>${i.name || ''}</b><span class="cx-qr-url">${shown}</span><button data-slot="button" class="cn-button cn-button-variant-outline cn-button-size-sm group/button cx-copy"><span class="cx-btn-label"><svg><use href="#copyi"/></svg>Copiar enlace</span></button></div></div>`;
77
+ root.innerHTML = `<div class="cx-qr-card"><div class="cx-qr" role="img" aria-label="${T('QR code of {0}', shown)}"></div><div class="cx-qr-side"><b>${i.name || ''}</b><span class="cx-qr-url">${shown}</span><button data-slot="button" class="cn-button cn-button-variant-outline cn-button-size-sm group/button cx-copy"><span class="cx-btn-label"><svg><use href="#copyi"/></svg>${T('Copy link')}</span></button></div></div>`;
77
78
  const qrBox = $('.cx-qr', root);
78
79
  import('@muten/shadcn/registry/kit/qrcode.js').then(({ default: qrcode }) => {
79
80
  const qr = qrcode(0, 'H'); qr.addData(url); qr.make();
@@ -83,7 +84,7 @@ export function storeQr(root, i, core) {
83
84
  });
84
85
  const copyBtn = $('.cx-copy', root), copyLabel = $('.cx-btn-label', copyBtn), copyIdle = copyLabel.innerHTML;
85
86
  copyBtn.onclick = () => {
86
- const done = (ok) => { copyBtn.toggleAttribute('data-done', ok); copyLabel.innerHTML = ok ? '<svg><use href="#check"/></svg>Copiado' : 'Selecciónalo y copia'; setTimeout(() => { copyBtn.removeAttribute('data-done'); copyLabel.innerHTML = copyIdle; }, 1800); };
87
+ const done = (ok) => { copyBtn.toggleAttribute('data-done', ok); copyLabel.innerHTML = '<span class="cx-swap-in">' + (ok ? `<svg><use href="#check"/></svg>${T('Copied')}` : T('Select it and copy')) + '</span>'; setTimeout(() => { copyBtn.removeAttribute('data-done'); copyLabel.innerHTML = '<span class="cx-swap-in">' + copyIdle + '</span>'; }, 1800); };
87
88
  const fallback = () => { getSelection().selectAllChildren($('.cx-qr-url', root)); done(false); };
88
89
  try { navigator.clipboard.writeText(url).then(() => done(true), fallback); } catch { fallback(); }
89
90
  };
@@ -39,7 +39,11 @@ export function overlay(host, kind, core) {
39
39
  const position = () => { if (open) place(panel, anchor(), at ? 'start' : align(), Number(panel.dataset.gap) || (kind === 'navmenu' ? 8 : 4)); };
40
40
  const show = (focusFirst) => {
41
41
  if (open) return; open = true;
42
- if (bar) { const other = openBars.get(bar); if (other && other !== api) other.close(false); openBars.set(bar, api); }
42
+ // moving from one menu of a bar to the next: the panel swaps in place (no close + open, which crossed two
43
+ // animations); the old one goes at once and the new one only fades its content in (kit.css [data-swap])
44
+ let swap = false;
45
+ if (bar) { const other = openBars.get(bar); if (other && other !== api) { swap = true; other.close(false, true); } openBars.set(bar, api); }
46
+ panel.toggleAttribute('data-swap', swap);
43
47
  try { panel.showPopover(); } catch { /* already in the top layer */ }
44
48
  panel.removeAttribute('data-closed'); panel.setAttribute('data-open', ''); panel.dataset.state = 'open';
45
49
  position();
@@ -48,8 +52,9 @@ export function overlay(host, kind, core) {
48
52
  if (isMenu && focusFirst) items()[0]?.focus({ preventScroll: true });
49
53
  else if (kind === 'popover') panel.querySelector('input, textarea, button, [tabindex]:not([tabindex="-1"])')?.focus({ preventScroll: true });
50
54
  };
51
- const close = (refocus) => {
55
+ const close = (refocus, swapped = false) => {
52
56
  if (!open) return; open = false; at = null;
57
+ panel.toggleAttribute('data-swap', swapped);
53
58
  if (bar && openBars.get(bar) === api) openBars.delete(bar);
54
59
  panel.removeAttribute('data-open'); panel.setAttribute('data-closed', ''); panel.dataset.state = 'closed';
55
60
  if (kind !== 'context') btn.setAttribute('aria-expanded', 'false');