@muten/shadcn 0.1.15 → 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 +328 -123
  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
@@ -2,19 +2,23 @@
2
2
  // CodePicker (a searchable list under a trigger), MoneyField (a currency = code + locale, Intl does the rest),
3
3
  // PhoneField (a country = dial code + digits + mask) and the 6-slot verification code. Generic: data and labels in.
4
4
  export const CURRENCIES = [
5
- { id: 'COP', label: 'Peso colombiano', locale: 'es-CO', minor: 2, shown: 0 }, { id: 'USD', label: 'Dólar estadounidense', locale: 'en-US' },
6
- { id: 'MXN', label: 'Peso mexicano', locale: 'es-MX' }, { id: 'ARS', label: 'Peso argentino', locale: 'es-AR' }, { id: 'VES', label: 'Bolívar', locale: 'es-VE' },
7
- { id: 'CLP', label: 'Peso chileno', locale: 'es-CL' }, { id: 'PEN', label: 'Sol peruano', locale: 'es-PE' }, { id: 'BRL', label: 'Real brasileño', locale: 'pt-BR' },
8
- { id: 'EUR', label: 'Euro', locale: 'es-ES' }, { id: 'CAD', label: 'Dólar canadiense', locale: 'en-CA' }, { id: 'AUD', label: 'Dólar australiano', locale: 'en-AU' }, { id: 'JPY', label: 'Yen', locale: 'ja-JP' },
5
+ { id: 'COP', label: 'Colombian peso', locale: 'es-CO', minor: 2, shown: 0 }, { id: 'USD', label: 'US dollar', locale: 'en-US' },
6
+ { id: 'MXN', label: 'Mexican peso', locale: 'es-MX' }, { id: 'ARS', label: 'Argentine peso', locale: 'es-AR' }, { id: 'VES', label: 'Bolívar', locale: 'es-VE' },
7
+ { id: 'CLP', label: 'Chilean peso', locale: 'es-CL' }, { id: 'PEN', label: 'Peruvian sol', locale: 'es-PE' }, { id: 'BRL', label: 'Brazilian real', locale: 'pt-BR' },
8
+ { id: 'EUR', label: 'Euro', locale: 'es-ES' }, { id: 'CAD', label: 'Canadian dollar', locale: 'en-CA' }, { id: 'AUD', label: 'Australian dollar', locale: 'en-AU' }, { id: 'JPY', label: 'Yen', locale: 'ja-JP' },
9
9
  ];
10
10
  export const COUNTRIES = [
11
11
  { id: 'AR', label: 'Argentina', dial: '+54 9', e164: '549', len: 10, mask: '## ####-####' }, { id: 'CO', label: 'Colombia', dial: '+57', e164: '57', len: 10, mask: '### ### ####' },
12
- { id: 'MX', label: 'México', dial: '+52', e164: '52', len: 10, mask: '## #### ####' }, { id: 'US', label: 'Estados Unidos', dial: '+1', e164: '1', len: 10, mask: '(###) ###-####' },
13
- { id: 'VE', label: 'Venezuela', dial: '+58', e164: '58', len: 10, mask: '### ### ####' }, { id: 'BR', label: 'Brasil', dial: '+55', e164: '55', len: 11, mask: '(##) #####-####' },
14
- { id: 'CL', label: 'Chile', dial: '+56', e164: '56', len: 9, mask: '# #### ####' }, { id: 'PE', label: 'Perú', dial: '+51', e164: '51', len: 9, mask: '### ### ###' },
15
- { id: 'ES', label: 'España', dial: '+34', e164: '34', len: 9, mask: '### ## ## ##' }, { id: 'CA', label: 'Canadá', dial: '+1', e164: '1', len: 10, mask: '(###) ###-####' },
12
+ { id: 'MX', label: 'Mexico', dial: '+52', e164: '52', len: 10, mask: '## #### ####' }, { id: 'US', label: 'United States', dial: '+1', e164: '1', len: 10, mask: '(###) ###-####' },
13
+ { id: 'VE', label: 'Venezuela', dial: '+58', e164: '58', len: 10, mask: '### ### ####' }, { id: 'BR', label: 'Brazil', dial: '+55', e164: '55', len: 11, mask: '(##) #####-####' },
14
+ { id: 'CL', label: 'Chile', dial: '+56', e164: '56', len: 9, mask: '# #### ####' }, { id: 'PE', label: 'Peru', dial: '+51', e164: '51', len: 9, mask: '### ### ###' },
15
+ { id: 'ES', label: 'Spain', dial: '+34', e164: '34', len: 9, mask: '### ## ## ##' }, { id: 'CA', label: 'Canada', dial: '+1', e164: '1', len: 10, mask: '(###) ###-####' },
16
16
  ];
17
17
 
18
+ // a currency's or a country's name in a language (Intl knows them all); the table's English label when it cannot
19
+ const nameIn = (lang, type, id, fallback) => { try { return new Intl.DisplayNames([lang], { type }).of(id) || fallback; } catch { return fallback; } };
20
+ const cap = (x) => x.charAt(0).toUpperCase() + x.slice(1);
21
+
18
22
  export function codePicker(core, { trigger, anchor, items, current, onPick, placeholder }) {
19
23
  const { $, $$, lib, place, show, hide, fold } = core;
20
24
  const pop = document.createElement('div');
@@ -52,13 +56,13 @@ export function codePicker(core, { trigger, anchor, items, current, onPick, plac
52
56
  // ScaleSlider: one or two thumbs, the value rides on each thumb, two bubbles that would touch merge into one.
53
57
  // i: {label, min, max, step, values "24" | "20000,80000", gap, format: hours|money|percent|"" , suffix}
54
58
  export function scaleSlider(root, i, core, on) {
55
- const { $, $$, money0 } = core;
59
+ const { $, $$, money0, t, langOf } = core, T = (k, ...a) => t(langOf(root), k, ...a);
56
60
  const min = Number(i.min) || 0, max = Number(i.max) || 100, step = Number(i.step) || 1, gap = Number(i.gap) || 0;
57
61
  const FMT = { hours: (v) => `${v} h`, money: money0, percent: (v) => `${v} %` };
58
62
  const fmt = FMT[i.format] || ((v) => `${v}${i.suffix || ''}`);
59
63
  const vals = String(i.values ?? min).split(',').map(Number), two = vals.length > 1, TW = 21;
60
64
  const id = 'sl' + Math.random().toString(36).slice(2, 7);
61
- root.innerHTML = `<div class="cx-sl-field"><div class="cx-sl-head"><span class="cn-label" id="${id}-l">${i.label || ''}</span></div><div data-slot="slider" class="cn-slider cx-slider" data-orientation="horizontal" data-horizontal><div data-slot="slider-track" class="cn-slider-track" data-orientation="horizontal"><div data-slot="slider-range" class="cn-slider-range" data-orientation="horizontal"></div></div>${vals.map((_, k) => `<span data-slot="slider-thumb" class="cn-slider-thumb" tabindex="0" role="slider" ${two ? `aria-label="${i.label || ''} ${k ? 'máximo' : 'mínimo'}"` : `aria-labelledby="${id}-l"`}></span>`).join('')}</div><div class="cx-sl-scale"><span>${fmt(min)}</span><span>${fmt(max)}</span></div></div>`;
65
+ root.innerHTML = `<div class="cx-sl-field"><div class="cx-sl-head"><span class="cn-label" id="${id}-l">${i.label || ''}</span></div><div data-slot="slider" class="cn-slider cx-slider" data-orientation="horizontal" data-horizontal><div data-slot="slider-track" class="cn-slider-track" data-orientation="horizontal"><div data-slot="slider-range" class="cn-slider-range" data-orientation="horizontal"></div></div>${vals.map((_, k) => `<span data-slot="slider-thumb" class="cn-slider-thumb" tabindex="0" role="slider" ${two ? `aria-label="${i.label || ''} ${k ? T('maximum') : T('minimum')}"` : `aria-labelledby="${id}-l"`}></span>`).join('')}</div><div class="cx-sl-scale"><span>${fmt(min)}</span><span>${fmt(max)}</span></div></div>`;
62
66
  const sl = $('.cx-slider', root), thumbs = $$('.cn-slider-thumb', sl), range = $('.cn-slider-range', sl);
63
67
  const bubbles = thumbs.map(() => { const b = document.createElement('span'); b.className = 'cx-sl-bubble'; b.setAttribute('aria-hidden', 'true'); sl.appendChild(b); return b; });
64
68
  const bounds = (k) => [two && k === 1 ? vals[0] + gap : min, two && k === 0 ? vals[1] - gap : max];
@@ -98,9 +102,9 @@ export function scaleSlider(root, i, core, on) {
98
102
 
99
103
  // MoneyField: i {label, value (a number), currency "COP", hint "on"} → on.change(json {amount, minor, currency})
100
104
  export function moneyField(root, i, core, on) {
101
- const { $, ui, reformat, groupThousands } = core;
105
+ const { $, ui, reformat, groupThousands, t, langOf } = core, L = langOf(root), T = (k, ...a) => t(L, k, ...a);
102
106
  const id = 'mf' + Math.random().toString(36).slice(2, 7);
103
- 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 || 'Precio'}</label><div data-slot="input-group" class="cn-input-group group/input-group" data-mf-group><div data-slot="input-group-addon" class="cn-input-group-addon cn-input-group-addon-align-inline-start cx-num" data-mf-sym>$</div><input data-slot="input-group-control" class="cn-input cn-input-group-input cx-num" id="${id}" inputmode="decimal" autocomplete="off"><div data-slot="input-group-addon" class="cn-input-group-addon cn-input-group-addon-align-inline-end" style="padding-right:2px"><button data-slot="button" class="cn-button cn-button-variant-ghost cn-button-size-xs group/button cx-cc" data-mf-cur aria-haspopup="dialog" aria-expanded="false" aria-label="Moneda"><span class="cx-cc-code" data-mf-code>COP</span><svg><use href="#chev"/></svg></button></div></div><p data-slot="field-description" class="cn-field-description cx-num" data-mf-out></p></div>`;
107
+ root.innerHTML = `<div data-slot="field" class="cn-field cn-field-orientation-vertical group/field"><label data-slot="label" class="cn-label" for="${id}">${T(i.label || 'Price')}</label><div data-slot="input-group" class="cn-input-group group/input-group" data-mf-group><div data-slot="input-group-addon" class="cn-input-group-addon cn-input-group-addon-align-inline-start cx-num" data-mf-sym>$</div><input data-slot="input-group-control" class="cn-input cn-input-group-input cx-num" id="${id}" inputmode="decimal" autocomplete="off"><div data-slot="input-group-addon" class="cn-input-group-addon cn-input-group-addon-align-inline-end" style="padding-right:2px"><button data-slot="button" class="cn-button cn-button-variant-ghost cn-button-size-xs group/button cx-cc" data-mf-cur aria-haspopup="dialog" aria-expanded="false" aria-label="${T('Currency')}"><span class="cx-cc-code" data-mf-code>COP</span><svg><use href="#chev"/></svg></button></div></div><p data-slot="field-description" class="cn-field-description cx-num" data-mf-out></p></div>`;
104
108
  const currencySpec = (c) => {
105
109
  const nf = new Intl.NumberFormat(c.locale, { style: 'currency', currency: c.id, currencyDisplay: 'narrowSymbol' });
106
110
  const parts = nf.formatToParts(1234567.89), part = (t, d) => parts.find((p) => p.type === t)?.value ?? d;
@@ -114,42 +118,46 @@ export function moneyField(root, i, core, on) {
114
118
  const sigFor = () => new RegExp(`[\\d${escRe(cur.dec)}]`);
115
119
  const fmtMoney = (v) => { const k = cur.places ? v.indexOf(cur.dec) : -1; const int = (k < 0 ? v : v.slice(0, k)).replace(/\D/g, '').slice(0, 12); const frac = k < 0 ? '' : v.slice(k + 1).replace(/\D/g, '').slice(0, cur.places); return (groupThousands(int, cur.group) || (k >= 0 ? '0' : '')) + (k >= 0 ? cur.dec + frac : ''); };
116
120
  const amount = () => { if (!mf.value) return 0; const [a, f = ''] = mf.value.split(cur.dec); return Number(a.split(cur.group).join('') + '.' + (f || '0')); };
117
- const unitName = () => (cur.minor === 0 ? `${curRow.id} (no tiene unidad menor)` : `centavos de ${curRow.id}`);
118
- const showMinor = () => { mfOut.textContent = mf.value ? `Se guarda: ${Math.round(amount() * 10 ** cur.minor)} ${unitName()}` : 'Escribe un precio'; };
121
+ const unitName = () => (cur.minor === 0 ? T('{0} (it has no minor unit)', curRow.id) : T('cents of {0}', curRow.id));
122
+ const showMinor = () => { mfOut.textContent = mf.value ? T('Saved as: {0} {1}', Math.round(amount() * 10 ** cur.minor), unitName()) : T('Type a price'); };
119
123
  const toText = (n) => { const [a, f] = n.toFixed(cur.places).split('.'); return groupThousands(a, cur.group) + (cur.places && /[1-9]/.test(f) ? cur.dec + f : ''); };
120
124
  const emit = () => on?.change?.(JSON.stringify({ amount: amount(), minor: Math.round(amount() * 10 ** cur.minor), currency: curRow.id }));
121
125
  mf.addEventListener('input', () => { reformat(mf, fmtMoney, sigFor()); showMinor(); emit(); });
122
- mf.addEventListener('keydown', (e) => { if (!cur.places || (e.key !== '.' && e.key !== ',') || e.key === cur.dec) return; e.preventDefault(); if (!mf.value.includes(cur.dec)) { mf.setRangeText(cur.dec, mf.selectionStart, mf.selectionEnd, 'end'); mf.dispatchEvent(new Event('input')); } });
123
- const setCurrency = (row) => { const n = amount(); curRow = row; cur = currencySpec(row); $('[data-mf-sym]', root).textContent = cur.sym; $('[data-mf-code]', root).textContent = row.id; mf.value = n ? toText(n) : ''; showMinor(); };
124
- codePicker(core, { trigger: $('[data-mf-cur]', root), anchor: $('[data-mf-group]', root), placeholder: 'Buscar moneda', current: () => curRow.id, items: () => CURRENCIES.map((c) => ({ id: c.id, label: c.label, hint: currencySpec(c).sym })), onPick: (it) => { setCurrency(CURRENCIES.find((c) => c.id === it.id)); mf.focus({ preventScroll: true }); emit(); } });
126
+ // a decimal key: in a currency with no decimals it is not written (it used to drop the comma and glue the digits after
127
+ // it to the whole part, ten times the amount); in one with decimals either key writes that currency's separator
128
+ mf.addEventListener('keydown', (e) => { if (e.key !== '.' && e.key !== ',') return; if (!cur.places) { e.preventDefault(); return; } if (e.key === cur.dec) return; e.preventDefault(); if (!mf.value.includes(cur.dec)) { mf.setRangeText(cur.dec, mf.selectionStart, mf.selectionEnd, 'end'); mf.dispatchEvent(new Event('input')); } });
129
+ // the currency's own shape as the empty field's hint: “0” or “0,00” / “0.00”
130
+ const setCurrency = (row) => { const n = amount(); curRow = row; cur = currencySpec(row); $('[data-mf-sym]', root).textContent = cur.sym; $('[data-mf-code]', root).textContent = row.id; mf.placeholder = cur.places ? `0${cur.dec}${'0'.repeat(cur.places)}` : '0'; mf.value = n ? toText(n) : ''; showMinor(); };
131
+ codePicker(core, { trigger: $('[data-mf-cur]', root), anchor: $('[data-mf-group]', root), placeholder: T('Search currency'), current: () => curRow.id, items: () => CURRENCIES.map((c) => ({ id: c.id, label: cap(nameIn(L, 'currency', c.id, c.label)), hint: currencySpec(c).sym })), onPick: (it) => { setCurrency(CURRENCIES.find((c) => c.id === it.id)); mf.focus({ preventScroll: true }); emit(); } });
125
132
  mf.value = fmtMoney(String(i.value ?? '')); setCurrency(curRow);
126
133
  void ui;
127
134
  }
128
135
 
129
136
  // PhoneField: i {label, value (digits), country "CO"} → on.change(json {e164, valid, country})
130
137
  export function phoneField(root, i, core, on) {
138
+ const L = core.langOf(root), T = (k, ...a) => core.t(L, k, ...a);
131
139
  const { $, reformat } = core;
132
140
  const id = 'ph' + Math.random().toString(36).slice(2, 7);
133
- root.innerHTML = `<div data-slot="field" class="cn-field cn-field-orientation-vertical group/field" data-ph-f><label data-slot="label" class="cn-label" for="${id}">${i.label || 'Teléfono'}</label><div data-slot="input-group" class="cn-input-group group/input-group cx-phone" data-ph-group><div data-slot="input-group-addon" class="cn-input-group-addon cn-input-group-addon-align-inline-start" style="padding-left:2px"><button data-slot="button" class="cn-button cn-button-variant-ghost cn-button-size-xs group/button cx-cc" data-ph-cc aria-haspopup="dialog" aria-expanded="false" aria-label="País"><span class="cx-cc-code" data-ph-flag></span><span class="cx-num cx-cc-dial" data-ph-dial></span><svg><use href="#chev"/></svg></button></div><input data-slot="input-group-control" class="cn-input cn-input-group-input cx-num" id="${id}" type="tel" inputmode="tel" autocomplete="tel-national" aria-describedby="${id}-msg"></div><p data-slot="field-description" class="cn-field-description cx-msg cx-num" id="${id}-msg"></p></div>`;
141
+ root.innerHTML = `<div data-slot="field" class="cn-field cn-field-orientation-vertical group/field" data-ph-f><label data-slot="label" class="cn-label" for="${id}">${T(i.label || 'Phone')}</label><div data-slot="input-group" class="cn-input-group group/input-group cx-phone" data-ph-group><div data-slot="input-group-addon" class="cn-input-group-addon cn-input-group-addon-align-inline-start" style="padding-left:2px"><button data-slot="button" class="cn-button cn-button-variant-ghost cn-button-size-xs group/button cx-cc" data-ph-cc aria-haspopup="dialog" aria-expanded="false" aria-label="${T('Country')}"><span class="cx-cc-code" data-ph-flag></span><span class="cx-num cx-cc-dial" data-ph-dial></span><svg><use href="#chev"/></svg></button></div><input data-slot="input-group-control" class="cn-input cn-input-group-input cx-num" id="${id}" type="tel" inputmode="tel" autocomplete="tel-national" aria-describedby="${id}-msg"></div><p data-slot="field-description" class="cn-field-description cx-msg cx-num" id="${id}-msg"></p></div>`;
134
142
  const ph = $('input', root), phMsg = $(`#${id}-msg`, root), phF = $('[data-ph-f]', root);
135
143
  let country = COUNTRIES.find((c) => c.id === i.country) || COUNTRIES[1];
136
144
  const phDigits = (v) => { let d = v.replace(/\D/g, ''); if (d.length > country.len && d.startsWith(country.e164)) d = d.slice(country.e164.length); return d.slice(0, country.len); };
137
145
  const fmtPhone = (v) => { const d = phDigits(v); let out = '', k = 0; for (const ch of country.mask) { if (k >= d.length) break; out += ch === '#' ? d[k++] : ch; } return out; };
138
146
  const emit = () => { const d = phDigits(ph.value); on?.change?.(JSON.stringify({ e164: `+${country.e164}${d}`, valid: d.length === country.len, country: country.id })); };
139
- const phCheck = (final) => { const d = phDigits(ph.value), ok = d.length === country.len, bad = final && d.length > 0 && !ok; phF.toggleAttribute('data-invalid', bad); ph.setAttribute('aria-invalid', bad); phMsg.textContent = ok ? `Se guarda como +${country.e164}${d}` : bad ? `Faltan ${country.len - d.length} dígitos para un número de ${country.label}` : `${country.len} dígitos, sin el ${country.dial}`; };
147
+ const phCheck = (final) => { const d = phDigits(ph.value), ok = d.length === country.len, bad = final && d.length > 0 && !ok; phF.toggleAttribute('data-invalid', bad); ph.setAttribute('aria-invalid', bad); phMsg.textContent = ok ? T('Saved as +{0}', `${country.e164}${d}`) : bad ? T('{0} digits short for a number from {1}', country.len - d.length, nameIn(L, 'region', country.id, country.label)) : T('{0} digits, without the {1}', country.len, country.dial); };
140
148
  const setCountry = (c) => { country = c; $('[data-ph-flag]', root).textContent = c.id; $('[data-ph-dial]', root).textContent = c.dial; ph.placeholder = c.mask.replace(/#/g, '0'); ph.value = fmtPhone(ph.value); phCheck(false); };
141
149
  ph.addEventListener('input', () => { reformat(ph, fmtPhone, /\d/); phCheck(phF.hasAttribute('data-invalid')); emit(); });
142
150
  ph.addEventListener('blur', () => phCheck(true));
143
- codePicker(core, { trigger: $('[data-ph-cc]', root), anchor: $('[data-ph-group]', root), placeholder: 'Buscar país', current: () => country.id, items: () => COUNTRIES.map((c) => ({ id: c.id, label: c.label, hint: c.dial })), onPick: (it) => { setCountry(COUNTRIES.find((c) => c.id === it.id)); ph.focus({ preventScroll: true }); emit(); } });
151
+ codePicker(core, { trigger: $('[data-ph-cc]', root), anchor: $('[data-ph-group]', root), placeholder: T('Search country'), current: () => country.id, items: () => COUNTRIES.map((c) => ({ id: c.id, label: nameIn(L, 'region', c.id, c.label), hint: c.dial })), onPick: (it) => { setCountry(COUNTRIES.find((c) => c.id === it.id)); ph.focus({ preventScroll: true }); emit(); } });
144
152
  setCountry(country); ph.value = fmtPhone(String(i.value ?? '')); phCheck(false);
145
153
  }
146
154
 
147
155
  // OtpField: 6 slots in two groups of 3. i {length, hint, state: ""|checking|ok|bad, message, resendIn} →
148
156
  // on.complete(code) when full, on.resend(). The page checks the code and answers with state + message.
149
157
  export function otpField(root, i, core, on) {
150
- const { $ } = core;
158
+ const { $ } = core, T = (k, ...a) => core.t(core.langOf(root), k, ...a);
151
159
  const len = Number(i.length) || 6, half = Math.ceil(len / 2);
152
- root.innerHTML = `<div class="cx-vline cx-otp-stack"><div class="cx-otp"><input class="cx-otp-input" inputmode="numeric" autocomplete="one-time-code" maxlength="${len}" aria-label="Código de ${len} dígitos"><div data-slot="input-otp" class="cn-input-otp" aria-hidden="true"><div data-slot="input-otp-group" class="cn-input-otp-group"></div><div data-slot="input-otp-separator" class="cn-input-otp-separator cx-otp-sep"><span></span></div><div data-slot="input-otp-group" class="cn-input-otp-group"></div></div></div><p class="cx-note" data-otp-msg></p><button data-slot="button" class="cn-button cn-button-variant-link cn-button-size-sm group/button" data-otp-resend disabled></button></div>`;
160
+ root.innerHTML = `<div class="cx-vline cx-otp-stack"><div class="cx-otp"><input class="cx-otp-input" inputmode="numeric" autocomplete="one-time-code" maxlength="${len}" aria-label="${T('{0}-digit code', len)}"><div data-slot="input-otp" class="cn-input-otp" aria-hidden="true"><div data-slot="input-otp-group" class="cn-input-otp-group"></div><div data-slot="input-otp-separator" class="cn-input-otp-separator cx-otp-sep"><span></span></div><div data-slot="input-otp-group" class="cn-input-otp-group"></div></div></div><p class="cx-note" data-otp-msg></p><button data-slot="button" class="cn-button cn-button-variant-link cn-button-size-sm group/button" data-otp-resend disabled></button></div>`;
153
161
  const otpIn = $('.cx-otp-input', root), otpBox = $('.cx-otp', root), otpMsg = $('[data-otp-msg]', root), resend = $('[data-otp-resend]', root);
154
162
  const slots = [];
155
163
  root.querySelectorAll('.cn-input-otp-group').forEach((g, gi) => { const n = gi ? len - half : half; for (let k = 0; k < n; k++) { const s = document.createElement('div'); s.className = 'cn-input-otp-slot'; s.dataset.slot = 'input-otp-slot'; g.appendChild(s); slots.push(s); } });
@@ -159,20 +167,20 @@ export function otpField(root, i, core, on) {
159
167
  const toEnd = () => { const n = otpIn.value.length; otpIn.setSelectionRange(n, n); };
160
168
  otpIn.addEventListener('input', () => {
161
169
  otpIn.value = otpIn.value.replace(/\D/g, '').slice(0, len); delete otpBox.dataset.state; otpMsg.removeAttribute('data-tone'); otpMsg.textContent = hint; paint();
162
- if (otpIn.value.length === len) { busy = true; otpIn.readOnly = true; otpMsg.textContent = 'Verificando…'; paint(); on?.complete?.(otpIn.value); }
170
+ if (otpIn.value.length === len) { busy = true; otpIn.readOnly = true; otpMsg.textContent = T('Checking…'); paint(); on?.complete?.(otpIn.value); }
163
171
  });
164
172
  ['focus', 'blur', 'click', 'keyup'].forEach((t) => otpIn.addEventListener(t, () => { toEnd(); paint(); }));
165
173
  otpIn.addEventListener('select', toEnd);
166
174
  let left = Number(i.resendIn ?? 30), rt = 0;
167
- const tick = () => { left--; resend.disabled = left > 0; resend.textContent = left > 0 ? `Reenviar en 0:${String(left).padStart(2, '0')}` : 'Reenviar código'; if (left > 0) rt = setTimeout(tick, 1000); };
168
- resend.textContent = `Reenviar en 0:${String(left).padStart(2, '0')}`;
169
- resend.onclick = () => { left = Number(i.resendIn ?? 30) + 1; clearTimeout(rt); tick(); otpMsg.removeAttribute('data-tone'); otpMsg.textContent = 'Te enviamos un código nuevo'; otpIn.value = ''; delete otpBox.dataset.state; otpIn.focus(); on?.resend?.(); };
175
+ const tick = () => { left--; resend.disabled = left > 0; resend.textContent = left > 0 ? T('Resend in 0:{0}', String(left).padStart(2, '0')) : T('Resend code'); if (left > 0) rt = setTimeout(tick, 1000); };
176
+ resend.textContent = T('Resend in 0:{0}', String(left).padStart(2, '0'));
177
+ resend.onclick = () => { left = Number(i.resendIn ?? 30) + 1; clearTimeout(rt); tick(); otpMsg.removeAttribute('data-tone'); otpMsg.textContent = T('We sent you a new code'); otpIn.value = ''; delete otpBox.dataset.state; otpIn.focus(); on?.resend?.(); };
170
178
  rt = setTimeout(tick, 1000); paint();
171
179
  const answer = (st, msg) => {
172
180
  if (!st || st === 'checking') return;
173
181
  busy = false; otpIn.readOnly = false;
174
- if (st === 'ok') { otpBox.dataset.state = 'ok'; otpMsg.dataset.tone = 'ok'; otpMsg.textContent = msg || 'Listo, quedó verificado'; otpIn.blur(); }
175
- if (st === 'bad') { otpBox.dataset.state = 'bad'; otpMsg.dataset.tone = 'bad'; otpMsg.textContent = msg || 'Ese código no es'; const wrong = otpIn.value; setTimeout(() => { if (otpIn.value === wrong) { otpIn.value = ''; paint(); } }, 450); }
182
+ if (st === 'ok') { otpBox.dataset.state = 'ok'; otpMsg.dataset.tone = 'ok'; otpMsg.textContent = msg || T('Done, it is verified'); otpIn.blur(); }
183
+ if (st === 'bad') { otpBox.dataset.state = 'bad'; otpMsg.dataset.tone = 'bad'; otpMsg.textContent = msg || T('That is not the code'); const wrong = otpIn.value; setTimeout(() => { if (otpIn.value === wrong) { otpIn.value = ''; paint(); } }, 450); }
176
184
  paint();
177
185
  };
178
186
  return (next) => answer(next.state, next.message);
@@ -1,6 +1,6 @@
1
1
  # Skeletons / Loading - while there is NOTHING to show, the shape of the content: built from the same atoms (Item,
2
2
  # Card, Stat, bubbles) with Skeleton in place of the text, so when the data lands only the pixels change, with a fade
3
- # and no jump in height. A refresh keeps the content: gate on «nothing yet», never on «fetching again».
3
+ # and no jump in height. A refresh keeps the content: gate on “nothing yet”, never on “fetching again”.
4
4
  # Loading(busy: orders.loading, kind: "list", count: 4) { each orders as o { … } }
5
5
  # Skeletons(kind: "messaging")
6
6
  # kind: list · item · stat · stats · card · products · bubbles · table · messaging · agenda. media: avatar · icon · none.
@@ -11,7 +11,7 @@ export function mount(el, inputs, on) {
11
11
  const conversations = list(i.conversations).map((c) => ({ ...c, messages: list(c.messages).map((m) => (m.event ? { ...m, event: { ...m.event, actions: Array.isArray(m.event.actions) ? buttons(m.event.actions) : m.event.actions || '' } } : m)) }));
12
12
  const replies = list(i.replies);
13
13
  kit.messaging(el, {
14
- title: i.title || 'Mensajes', channels: map(i.channels), conversations,
14
+ title: i.title || core0.t(core0.langOf(el), 'Messages'), channels: map(i.channels), conversations,
15
15
  filters: list(i.filters).map((f) => ({ id: f.id, label: f.label, match: f.state ? (c) => c.state === f.state : () => true })),
16
16
  states: map(i.states), control: map(i.controls),
17
17
  quick: list(i.quick).map((q) => ({ ...q, card: () => `${ui.text(q.title || q.label, 'label')}${q.subtitle ? ui.text(q.subtitle, 'caption') : ''}` })),
@@ -22,7 +22,7 @@ export function mount(el, inputs, on) {
22
22
  },
23
23
  chatbot: ({ kit, ui }, i) => {
24
24
  const answers = list(i.answers);
25
- kit.chatbot(el, { name: i.title || 'Asistente', greeting: i.greeting || '', suggestions: list(i.suggestions).map((s) => s.text ?? s),
25
+ kit.chatbot(el, { name: i.title || core0.t(core0.langOf(el), 'Assistant'), greeting: i.greeting || '', avatar: i.avatar || '', suggestions: list(i.suggestions).map((s) => s.text ?? s),
26
26
  respond: (t) => { const a = answers.find((x) => x.match && rx(x.match).test(t)) || answers.find((x) => !x.match) || { text: '' }; return { text: a.text, actions: a.action ? ui.button({ label: a.action, variant: a.primary ? 'default' : 'outline', size: 'xs', attrs: `data-kw-act="${a.action}"` }) : '' }; } });
27
27
  if (!el.__kwBound) { el.__kwBound = true; el.addEventListener('click', (e) => { const b = e.target.closest('[data-kw-act]'); if (b) on.action?.(b.dataset.kwAct); }); }
28
28
  },
@@ -39,8 +39,8 @@ export function mount(el, inputs, on) {
39
39
  stampCard: ({ kit }, i) => kit.stampCard(el, { store: i.store || '', owner: i.owner || '', total: Number(i.total) || 10, stamps: Number(i.stamps) || 0, reward: i.reward || '', ...Object.fromEntries(['title', 'progress', 'complete', 'count', 'redeem', 'stamp'].filter((k) => i[k]).map((k) => [k, i[k]])) }),
40
40
  bell: ({ kit }, i) => kit.bell(el, { items: list(i.items) }),
41
41
  queue: ({ kit }, i) => kit.queue(el, { title: i.title || '', people: list(i.people), offerMinutes: Number(i.minutes) || 10 }),
42
- password: ({ kit }, i) => kit.password(el, { label: i.label || 'Contraseña' }),
43
- // days: {name, open, ranges [["09:00","13:00"]]}. Inside a form with a DirtyBar, «Descartar» brings the hours back.
42
+ password: ({ kit, t, langOf }, i) => kit.password(el, { label: t(langOf(el), i.label || 'Password') }),
43
+ // days: {name, open, ranges [["09:00","13:00"]]}. Inside a form with a DirtyBar, “Descartar” brings the hours back.
44
44
  weeklyHours: ({ kit }, i) => {
45
45
  const wh = kit.weeklyHours(el, { days: list(i.days).map((d) => ({ name: d.name, open: !!d.open, ranges: list(d.ranges).map((r) => [...r]) })) });
46
46
  let saved = wh.value(); const form = el.closest('.cx-set-form');
@@ -51,9 +51,14 @@ export function mount(el, inputs, on) {
51
51
  RENDER.table = (core, i) => import('@muten/shadcn/registry/kit/table.js').then(({ dataTable }) => dataTable(el, { columns: i.columns, rows: i.rows, filters: i.filters, statuses: i.statuses, noun: [i.one || 'fila', i.many || 'filas'], bulk: i.bulk }, core, on));
52
52
  // until the kit arrives the host keeps its final size with a shimmer (kit.css, :empty), then the widget fades in
53
53
  el.classList.add('cx-kw-wait');
54
+ // its language: inputs.lang, else the nearest lang (the page's); a lang that changes repaints it in the new one
55
+ if (inputs.lang) el.setAttribute('lang', inputs.lang);
56
+ let core0 = null;
54
57
  import('@muten/shadcn/registry/kit/core.js').then((core) => {
58
+ core0 = core;
55
59
  const render = RENDER[inputs.name]; if (!render) return;
56
60
  render(core, inputs); el.classList.remove('cx-kw-wait');
61
+ document.addEventListener('kit:lang', () => { if (el.isConnected && !inputs.lang) { el.innerHTML = ''; render(core, inputs); } });
57
62
  el.animate([{ opacity: 0.001 }, { opacity: 1 }], { duration: 180, easing: 'ease-out' });
58
63
  play(core, render);
59
64
  });
@@ -0,0 +1,8 @@
1
+ // LangScope (muten Custom) - puts `lang` on the scope it sits first in, so every piece inside (kit/i18n.js langOf)
2
+ // speaks it; a new lang later changes it in place and the kit repaints in it (kit/core.js listens for lang changes).
3
+ export function mount(el, inputs) {
4
+ const scope = el.parentElement;
5
+ const set = (lang) => { const v = String(lang || ''); if (!scope) return; if (v) { if (scope.getAttribute('lang') !== v) scope.setAttribute('lang', v); } else scope.removeAttribute('lang'); };
6
+ set(inputs.lang);
7
+ return (next) => set(next.lang);
8
+ }
@@ -0,0 +1,5 @@
1
+ # LangScope - everything inside speaks this language (es | en …): the library's words, its dates and its Customs follow
2
+ # it. Without it they follow the page's <html lang>, English when there is none. A store in the shop's language:
3
+ # LangScope(lang: shopLang) { … the store's parts … }
4
+ # shadcn-for-muten · Custom · host JS is lang-scope.js.
5
+ part LangScope(lang: text) { Stack class("cx-lang-scope") { Custom LangScope inputs(lang: $lang) class("cx-lang-host") slot } }
@@ -1,10 +1,10 @@
1
1
  # Legal document - the pages a business must have (terms, privacy, data deletion), the way Storio's site writes them:
2
2
  # the title and its line, then two columns, the contents (they follow the reading: the section on screen is marked)
3
- # and the text at reading width, each section titled «N. Title». On a phone the contents become a row that slides.
4
- # DocHead(title: "Términos y condiciones") { Text "Última actualización: 7 de octubre de 2026" class("cx-doc-line") }
3
+ # and the text at reading width, each section titled “N. Title”. On a phone the contents become a row that slides.
4
+ # DocHead(title: "Terms and conditions") { Text "Last updated: October 7, 2026" class("cx-doc-line") }
5
5
  # DocBody {
6
- # DocToc(label: "Contenido") { DocTocLink(label: "1. Quiénes somos", to: "#s1") DocTocLink(label: "2. Tus datos", to: "#s2") }
7
- # DocArticle { DocSection(anchor: "s1", title: "1. Quiénes somos") { DocText(text: "Storio es un producto de Candhrly LLC.") } }
6
+ # DocToc(label: "Content") { DocTocLink(label: "1. Who we are", to: "#s1") DocTocLink(label: "2. Tus datos", to: "#s2") }
7
+ # DocArticle { DocSection(anchor: "s1", title: "1. Who we are") { DocText(text: "Storio is a product of Candhrly LLC.") } }
8
8
  # }
9
9
  # shadcn-for-muten · parts · no deps · kit.css (.cx-doc-*).
10
10
  part DocHead(title: text) {
@@ -14,7 +14,7 @@ part DocHead(title: text) {
14
14
  }
15
15
  }
16
16
  part DocBody() { Section class("cx-mk-col cx-doc-body") { slot } }
17
- part DocToc(label: text = "Contenido") { Stack class("cx-navbar cx-doc-toc") data(orientation: "vertical", spy: "on") aria(role: "navigation", label: $label) { slot } }
17
+ part DocToc(label: text = "Contents") { Stack class("cx-navbar cx-doc-toc") data(orientation: "vertical", spy: "on", trl: $label) aria(role: "navigation", label: $label) { slot } }
18
18
  part DocTocLink(label: text, to: text) {
19
19
  Link "{$label}" -> "{$to}" class("cn-button cn-button-variant-ghost cn-button-size-sm group/button cx-nav cx-doc-toc-item") data(slot: "button", marker: "fill")
20
20
  }
@@ -1,7 +1,7 @@
1
1
  # LinkBtn - a link to a route that looks like a Button: same variants and sizes as Btn, but it navigates (a real
2
2
  # <a>, so it opens in a new tab with a middle click and reads as a link). Label and icon go in the slot.
3
3
  # LinkBtn(to: "/signup") { Span "Crear cuenta" }
4
- # LinkBtn(to: "/docs", variant: "outline", size: "sm") { Span "Ver la guía" Icon "lucide:arrow-right" }
4
+ # LinkBtn(to: "/docs", variant: "outline", size: "sm") { Span "See the guide" Icon "lucide:arrow-right" }
5
5
  # variant: default | outline | secondary | ghost | destructive | link · size: xs | sm | default | lg | xl (44) | 2xl (52)
6
6
  # shape: "" | pill - fully rounded ends, like Btn(shape: "pill").
7
7
  # shadcn-for-muten · part · no deps · Nova classes (nova.css).
@@ -0,0 +1,46 @@
1
+ // ListMotion (muten Custom) - the rows of the list it sits in open and close their place (kit.css “List rows”).
2
+ // · a row added after the first paint opens its place and fades in (cx-row-enter)
3
+ // · a row muten removes stays a moment, closing its gap (cx-row-leave), then goes; it is already disposed, so it
4
+ // is only a picture of the row and takes no clicks
5
+ // · a row muten only MOVES (it leaves and comes back in the same update) is not an entrance
6
+ // Nothing moves on the first paint, and with reduced motion the kit's tokens make it instant.
7
+ export function mount(el) {
8
+ const box = el.parentElement;
9
+ if (!box) return;
10
+ let ready = false;
11
+ requestAnimationFrame(() => requestAnimationFrame(() => { ready = true; }));
12
+ const isRow = (n) => n.nodeType === 1 && n !== el && !n.classList.contains('cx-row-leave');
13
+ const settle = (n, cls) => {
14
+ const done = () => { clearTimeout(t); n.removeEventListener('animationend', end); if (cls === 'cx-row-leave') n.remove(); else n.classList.remove(cls); };
15
+ const end = (e) => { if (e.target === n) done(); };
16
+ n.addEventListener('animationend', end);
17
+ const t = setTimeout(done, 900); // a safety net if the animation never reports back
18
+ };
19
+ const mo = new MutationObserver((records) => {
20
+ if (!ready) return;
21
+ const removed = new Set(), added = new Set();
22
+ for (const r of records) r.removedNodes.forEach((n) => { if (isRow(n)) removed.add(n); });
23
+ for (const r of records) r.addedNodes.forEach((n) => { if (isRow(n) && !removed.has(n)) added.add(n); });
24
+ const gone = [...removed].filter((n) => n.parentNode !== box);
25
+ // A WHOLE PAGE SWAPPED (a page turned, a filter changed most rows): closing ten rows while ten open would make
26
+ // the list jump in height, so the old ones go at once and the new ones only fade in where they land
27
+ const swap = gone.length > 1 && added.size > 1;
28
+ if (swap) { added.forEach((n) => { n.classList.add('cx-row-swap'); settle(n, 'cx-row-swap'); }); return; }
29
+ for (const r of records) {
30
+ r.addedNodes.forEach((n) => {
31
+ if (!added.has(n)) return; // a move, not an entrance
32
+ n.classList.add('cx-row-enter'); settle(n, 'cx-row-enter');
33
+ });
34
+ r.removedNodes.forEach((n) => {
35
+ if (!isRow(n) || n.parentNode === box) return; // moved back in: not leaving
36
+ const ref = r.nextSibling && r.nextSibling.parentNode === box ? r.nextSibling : (r.previousSibling && r.previousSibling.parentNode === box ? r.previousSibling.nextSibling : null);
37
+ n.classList.remove('cx-row-enter'); n.classList.add('cx-row-leave'); n.inert = true;
38
+ box.insertBefore(n, ref);
39
+ n.style.setProperty('--mu-h', n.offsetHeight + 'px');
40
+ settle(n, 'cx-row-leave');
41
+ });
42
+ }
43
+ });
44
+ mo.observe(box, { childList: true });
45
+ // nothing returned: in muten a Custom's returned function is its UPDATER (called with new inputs), not a cleanup
46
+ }
@@ -0,0 +1,4 @@
1
+ # ListMotion - rows that open and close their place. Put inside a list container, after its rows; ItemGroup already
2
+ # carries it, so any `each` inside an ItemGroup moves without the page doing anything.
3
+ # shadcn-for-muten · Custom · host JS is list-motion.js.
4
+ part ListMotion() { Custom ListMotion class("cx-list-motion") }
@@ -1,5 +1,5 @@
1
- # LiveStack - news that keeps arriving, for a landing page: what the product does while nobody watches («Carla agendó
2
- # un corte», «Pedido nuevo», «Tu asistente contestó»). One at a time a card drops in at the front and the older ones
1
+ # LiveStack - news that keeps arriving, for a landing page: what the product does while nobody watches (“Carla booked
2
+ # a haircut”, “New order”, “Your assistant answered”). One at a time a card drops in at the front and the older ones
3
3
  # step back behind it, smaller, and leave after three. Pointer over it pauses; reduced motion shows the first three still.
4
4
  # LiveStack(items: @news, every: 2800)
5
5
  # items {icon (a kit sprite id), title, text, time, tone: "" | ok | wait | info}
@@ -1,6 +1,6 @@
1
- # LogoCloud - the «they use it» strip: a wrapping row of logos, muted until the pointer rests on one. A
1
+ # LogoCloud - the “they use it” strip: a wrapping row of logos, muted until the pointer rests on one. A
2
2
  # LogoCloudItem holds any logo: an Image, an Icon or a word in a Span; all take the same height.
3
- # LogoCloud(label: "Lo usan") { LogoCloudItem { Image "~/logos/roble.svg" alt("El Roble") } LogoCloudItem { Span "Pelos & Co" } }
3
+ # LogoCloud(label: "Used by") { LogoCloudItem { Image "~/logos/roble.svg" alt("El Roble") } LogoCloudItem { Span "Pelos & Co" } }
4
4
  # shadcn-for-muten · parts · no deps · kit.css (.cx-logos).
5
5
  part LogoCloud(label: text = "") { Stack class("cx-logos") aria(label: $label) { slot } }
6
6
  part LogoCloudItem() { Stack class("cx-logo") { slot } }
@@ -2,12 +2,12 @@
2
2
  # hero's pill and two-line headline, the address field with its button, a list of checks, numbered steps, cards,
3
3
  # columns of facts, a path of steps, a checked list with explanations and the closing call. Every argument is text,
4
4
  # so the words can come from a translation function: MkCheck(label: "{t(lang, "x")}").
5
- # MkSection(anchor: "how", title: "Así funciona") {
6
- # MkChecks { MkCheck(label: "Sin app para tu cliente") MkCheck(label: "Confirma solo") }
5
+ # MkSection(anchor: "how", title: "How it works") {
6
+ # MkChecks { MkCheck(label: "No app for your customer") MkCheck(label: "Confirma solo") }
7
7
  # }
8
- # MkHero { MkHeroCopy { MkPill(label: "Para músicos") { Icon "lucide:music" } MkHeadline(first: "Tu música,", second: "en un link.")
9
- # MkClaim(value: handle, to: joinUrl, cta: "Crear mi tienda") class("animate-rise") } … }
10
- # MkCta(title: "Tu negocio,", soft: "vende y agenda solo.") { MkClaim(value: handle, to: joinUrl, cta: "Crear mi tienda") }
8
+ # MkHero { MkHeroCopy { MkPill(label: "For musicians") { Icon "lucide:music" } MkHeadline(first: "Your music,", second: "in one link.")
9
+ # MkClaim(value: handle, to: joinUrl, cta: "Create my shop") class("animate-rise") } … }
10
+ # MkCta(title: "Your business,", soft: "sells and books by itself.") { MkClaim(value: handle, to: joinUrl, cta: "Create my shop") }
11
11
  # shadcn-for-muten · parts · no deps · kit.css (.cx-mk-*), uses InputGroup and LinkBtn.
12
12
 
13
13
  # MkSection - a page section at reading width with its h2. anchor is its id (a link can go to #anchor).
@@ -50,7 +50,7 @@ part MkHeadline(first: text, second: text, fit: bool = true) {
50
50
  part MkTitle(text: text, size: text = "") {
51
51
  Title "{$text}" h1 class("cx-mk-h1 words", "cx-mk-h1-fit" when $size == "fit", "cx-mk-h1-wrap" when $size == "wrap")
52
52
  }
53
- # MkClaim - the address field and its button: «storio.ink/ [your-name] [Create]». value is the page's text state.
53
+ # MkClaim - the address field and its button: “storio.ink/ [your-name] [Create]”. value is the page's text state.
54
54
  part MkClaim(value: text, to: text, cta: text, placeholder: text = "", prefix: text = "storio.ink/") {
55
55
  Stack class("cx-mk-claim") {
56
56
  InputGroup(size: "2xl", variant: "filled") {
@@ -147,7 +147,7 @@ part VsRow(what: text, us: text, them: text, usName: text, themName: text) {
147
147
  # MkRows / MkRow - zigzag rows: the words on one side and a picture on the other (MkStage); reverse puts the words on
148
148
  # the right. MkRowCopy is the words: its title (an h2, or h3 under a section that already has one), a line and, in the
149
149
  # slot, anything after it (MkChecks).
150
- # MkRows { MkRow { MkRowCopy(title: "Tu agenda", text: "Se llena sola.") { MkChecks { … } } MkStage { … } } }
150
+ # MkRows { MkRow { MkRowCopy(title: "Your schedule", text: "Se llena sola.") { MkChecks { … } } MkStage { … } } }
151
151
  part MkRows() { Stack class("cx-mk-rows") { slot } }
152
152
  part MkRow(reverse: bool = false) { Stack class("cx-mk-row", "cx-mk-rev" when $reverse) { slot } }
153
153
  part MkRowCopy(title: text, text: text, level: text = "h2") {
@@ -1,6 +1,6 @@
1
1
  # MemberList - any group (team, workspace, family plan): who is in, their role in the library's menu, invite.
2
- # MemberList(members: @members, roles: @roles, owner: "Propietario", one: "miembro", many: "miembros", cta: "Invitar", note: "Le llega un correo para unirse.")
2
+ # MemberList(members: @members, roles: @roles, owner: "Owner", one: "member", many: "members", cta: "Invite", note: "They get an email to join.")
3
3
  # shadcn-for-muten · part over KitWidget (kit core).
4
- part MemberList(members: list, roles: list, owner: text = "", one: text = "persona", many: text = "personas", cta: text = "Invitar", note: text = "") {
4
+ part MemberList(members: list, roles: list, owner: text = "", one: text = "person", many: text = "people", cta: text = "Invite", note: text = "") {
5
5
  Custom KitWidget inputs(name: "memberList", members: $members, roles: $roles, owner: $owner, one: $one, many: $many, cta: $cta, note: $note) class("cx-team")
6
6
  }
@@ -1,10 +1,10 @@
1
1
  # Menubar - a desktop app's menu bar: each word opens its menu; once one is open, moving the pointer to the next word
2
2
  # opens that one, and the left and right arrows walk the bar. Each menu holds DropdownContent and its items.
3
3
  # Menubar(label: "Editor") {
4
- # MenubarMenu { MenubarTrigger(label: "Archivo") DropdownContent { DropdownItem(label: "Nuevo", shortcut: "⌘N", onSelect: create) … } }
5
- # MenubarMenu { MenubarTrigger(label: "Editar") DropdownContent { … } }
4
+ # MenubarMenu { MenubarTrigger(label: "File") DropdownContent { DropdownItem(label: "New", shortcut: "⌘N", onSelect: create) … } }
5
+ # MenubarMenu { MenubarTrigger(label: "Edit") DropdownContent { … } }
6
6
  # }
7
7
  # shadcn-for-muten · parts + Custom (OverlayBehavior) · Nova (.cn-menubar*) + kit.css.
8
- part Menubar(label: text = "Menú") { Stack class("cn-menubar cx-menubar") data(slot: "menubar") aria(role: "menubar", label: $label) { slot } }
8
+ part Menubar(label: text = "Menu") { Stack class("cn-menubar cx-menubar") data(slot: "menubar", trl: $label) aria(role: "menubar", label: $label) { slot } }
9
9
  part MenubarMenu() { Stack class("cx-ov") data(slot: "menubar-menu") { slot OverlayBehavior(kind: "menubar") } }
10
10
  part MenubarTrigger(label: text) { Stack class("cx-ov-trigger") { Button "{$label}" class("cn-menubar-trigger") data(slot: "menubar-trigger") aria(role: "menuitem") } }
@@ -1,8 +1,8 @@
1
1
  # MessageScroller - the column of a conversation: it scrolls inside itself and opens at the latest message; a new
2
2
  # message keeps it at the bottom only if you were already there (reading back is never interrupted).
3
- # MessageScroller(label: "Chat con Valentina", height: "22rem") { Marker(label: "Hoy") Message(…) { … } }
3
+ # MessageScroller(label: "Chat with Valentina", height: "22rem") { Marker(label: "Today") Message(…) { … } }
4
4
  # shadcn-for-muten · part + Custom (stick to the bottom) · kit.css (.cx-chat).
5
- part MessageScroller(label: text = "Conversación", height: text = "22rem") {
6
- Stack class("cx-chat cx-scroll cx-msg-scroller") data(slot: "message-scroller") style(h: "{$height}") aria(role: "log", label: $label) { slot MessageScrollerBehavior() }
5
+ part MessageScroller(label: text = "Conversation", height: text = "22rem") {
6
+ Stack class("cx-chat cx-scroll cx-msg-scroller") data(slot: "message-scroller") style(h: "{$height}") data(trl: $label) aria(role: "log", label: $label) { slot MessageScrollerBehavior() }
7
7
  }
8
8
  part MessageScrollerBehavior() { Custom MessageScrollerBehavior class("cx-msg-behavior") }
@@ -1,10 +1,10 @@
1
1
  # Message - a chat bubble, the same the kit's inbox draws: theirs on the left on the surface, yours on the right in
2
2
  # the primary colour, with the time and, on yours, whether it was sent, delivered or read. Text, a photo or an
3
3
  # Attachment go inside. Marker is the day line between messages; MessageScroller holds them and scrolls.
4
- # MessageScroller(label: "Chat con Valentina") {
5
- # Marker(label: "Hoy")
6
- # Message(variant: "received", time: "10:02") { Span "Hola, ¿tienen cita hoy?" }
7
- # Message(variant: "sent", time: "10:03", status: "read") { Span "Sí: a las 15:00 con Camila." }
4
+ # MessageScroller(label: "Chat with Valentina") {
5
+ # Marker(label: "Today")
6
+ # Message(variant: "received", time: "10:02") { Span "Hi, do you have an appointment today?" }
7
+ # Message(variant: "sent", time: "10:03", status: "read") { Span "Yes: at 15:00 with Camila." }
8
8
  # }
9
9
  # variant: received | sent · status (sent only): sending | sent | delivered | read · tag: a small label (Bot).
10
10
  # shadcn-for-muten · parts · no deps · kit.css (.cx-chat-*).
@@ -15,14 +15,14 @@ part Message(variant: text = "received", time: text = "", status: text = "", tag
15
15
  Stack class("cx-chat-meta") {
16
16
  Span "{$tag}" class("cx-chat-tag")
17
17
  Span "{$time}" class("cx-text") data(variant: "caption")
18
- when $status == "sending" { Icon "lucide:clock" class("cx-tick") data(status: "sending") aria(label: "Enviando") }
19
- when $status == "sent" { Icon "lucide:check" class("cx-tick") data(status: "sent") aria(label: "Enviado") }
20
- when $status == "delivered" { Icon "lucide:check-check" class("cx-tick") data(status: "delivered") aria(label: "Entregado") }
21
- when $status == "read" { Icon "lucide:check-check" class("cx-tick") data(status: "read") aria(label: "Leído") }
18
+ when $status == "sending" { Icon "lucide:clock" class("cx-tick") data(status: "sending", trl: "Sending") aria(label: "Sending") }
19
+ when $status == "sent" { Icon "lucide:check" class("cx-tick") data(status: "sent", trl: "Sent") aria(label: "Sent") }
20
+ when $status == "delivered" { Icon "lucide:check-check" class("cx-tick") data(status: "delivered", trl: "Delivered") aria(label: "Delivered") }
21
+ when $status == "read" { Icon "lucide:check-check" class("cx-tick") data(status: "read", trl: "Read") aria(label: "Read") }
22
22
  }
23
23
  }
24
24
  }
25
25
  }
26
26
  part Bubble(variant: text = "received") { Stack class("cx-chat-bubble cx-msg-bubble") data(variant: $variant) { slot } }
27
27
  part Marker(label: text) { Stack class("cx-chat-day") aria(role: "separator") { Span "{$label}" class("cx-text") data(variant: "caption") } }
28
- part MessageImage(src: text, alt: text = "Foto") { Stack class("cx-chat-img") style(bg: "{$src}") aria(role: "img", label: $alt) }
28
+ part MessageImage(src: text, alt: text = "Photo") { Stack class("cx-chat-img") style(bg: "{$src}") data(trl: $alt) aria(role: "img", label: $alt) }
@@ -6,6 +6,6 @@
6
6
  # {id,icon,label,noun,title,subtitle}, templates {title,body}, replies {match (regex), text}.
7
7
  # Messaging(channels: @channels, conversations: @inbox, filters: @filters, states: @states, controls: @controls, quick: @quick, templates: @templates, replies: @replies, onAction: act)
8
8
  # shadcn-for-muten · part over KitWidget (kit core).
9
- part Messaging(title: text = "Mensajes", channels: list, conversations: list, filters: list, states: list, controls: list, quick: list, templates: list, replies: list, onAction: action) {
9
+ part Messaging(title: text = "Messages", channels: list, conversations: list, filters: list, states: list, controls: list, quick: list, templates: list, replies: list, onAction: action) {
10
10
  Custom KitWidget inputs(name: "messaging", title: $title, channels: $channels, conversations: $conversations, filters: $filters, states: $states, controls: $controls, quick: $quick, templates: $templates, replies: $replies) on(action: $onAction) class("cx-mx-host")
11
11
  }
@@ -3,16 +3,16 @@
3
3
  # drag down on the drawer all call onClose. Content goes in the slot; the buttons in a ModalFooter (it sticks under
4
4
  # the scrolling body): the way out (cx-exit) on the left, the action (cx-go) filling the rest.
5
5
  # state { editing = false : bool } action edit() mutates editing { editing.set(true) } action done() mutates editing { editing.set(false) }
6
- # Modal(open: editing, onClose: done, title: "Editar servicio", description: "Lo que cambies se ve en tu tienda al guardar.") {
6
+ # Modal(open: editing, onClose: done, title: "Edit service", description: "What you change shows in your shop when you save.") {
7
7
  # Field { … }
8
- # ModalFooter { Btn(variant: "outline", onClick: done) class("cx-exit") { Span "Cancelar" } Btn(onClick: save) class("cx-go") { Span "Guardar cambios" } }
8
+ # ModalFooter { Btn(variant: "outline", onClick: done) class("cx-exit") { Span "Cancel" } Btn(onClick: save) class("cx-go") { Span "Save changes" } }
9
9
  # }
10
10
  # mode: auto | dialog | drawer | sheet (a panel from a side) · side: right | left | top | bottom (for sheet)
11
11
  # role: dialog | alertdialog · dismiss: on | off (off: no ✕ and the backdrop does not close it; only its buttons do)
12
12
  # size: "" | tall (a list that pages) · shadcn-for-muten · part + Custom (ModalBehavior) · kit.css (.cx-modal).
13
13
  part Modal(open: bool, onClose: action, title: text = "", description: text = "", mode: text = "auto", size: text = "", side: text = "right", role: text = "dialog", dismiss: text = "on") {
14
14
  Stack class("cx-modal-root") data(open: $open) {
15
- when $dismiss != "off" { Button "" -> $onClose class("cx-overlay") aria(label: "Cerrar", hidden: "true") data(closes: "true") }
15
+ when $dismiss != "off" { Button "" -> $onClose class("cx-overlay") aria(label: "Close", hidden: "true") data(closes: "true", trl: "Close") }
16
16
  when $dismiss == "off" { Stack class("cx-overlay") aria(hidden: "true") }
17
17
  Stack class("cx-modal") data(mode: $mode, size: $size, side: $side) aria(role: $role, modal: "true", label: $title) {
18
18
  Stack class("cx-handle") aria(hidden: "true")
@@ -20,7 +20,7 @@ part Modal(open: bool, onClose: action, title: text = "", description: text = ""
20
20
  Span "{$title}" class("cx-modal-title")
21
21
  Span "{$description}" class("cx-modal-desc")
22
22
  }
23
- when $dismiss != "off" { Btn(variant: "ghost", size: "icon-sm", label: "Cerrar", onClick: $onClose) class("cx-modal-x") { Icon "lucide:x" } }
23
+ when $dismiss != "off" { Btn(variant: "ghost", size: "icon-sm", label: "Close", onClick: $onClose) class("cx-modal-x") { Icon "lucide:x" } }
24
24
  Stack class("cx-modal-body") { slot }
25
25
  ModalBehavior(open: $open, onClose: $onClose)
26
26
  }
@@ -1,5 +1,5 @@
1
1
  // MoneyField (muten Custom) - a price with its currency (kit/values.js moneyField).
2
2
  export function mount(el, inputs, on) {
3
- Promise.all([import('@muten/shadcn/registry/kit/core.js'), import('@muten/shadcn/registry/kit/values.js')]).then(([core, v]) => v.moneyField(el, inputs, core, on));
3
+ Promise.all([import('@muten/shadcn/registry/kit/core.js'), import('@muten/shadcn/registry/kit/values.js')]).then(([core, v]) => core.relang(el, inputs, () => v.moneyField(el, inputs, core, on)));
4
4
  return () => {};
5
5
  }
@@ -1,7 +1,7 @@
1
1
  # MoneyField - a price with its currency: the symbol, separators and decimals come from the currency (Intl); the
2
2
  # currency is picked from a searchable list; under it, what is stored (minor units). onChange gets {amount, minor, currency}.
3
- # MoneyField(label: "Precio del servicio", value: 25000, currency: "COP", onChange: setPrice)
3
+ # MoneyField(label: "Service price", value: 25000, currency: "COP", onChange: setPrice)
4
4
  # shadcn-for-muten · Custom (kit values) · host JS money-field.js.
5
- part MoneyField(label: text = "Precio", value: number = 0, currency: text = "COP", onChange: action) {
5
+ part MoneyField(label: text = "Price", value: number = 0, currency: text = "COP", onChange: action) {
6
6
  Custom MoneyField inputs(label: $label, value: $value, currency: $currency) on(change: $onChange) class("cx-mf-host")
7
7
  }
@@ -1,8 +1,8 @@
1
1
  # NavButton - a place in the app (a tab bar, a sidebar, a segmented header). Where you are is a STATE, not a
2
2
  # variant: it rides aria-pressed and is marked by `marker`; everything else is a Btn (variant, size, any content in
3
3
  # the slot: an icon, a count). Lay them in a NavBar, across or down.
4
- # NavBar(label: "Secciones") { NavButton(label: "Mi tienda", current: atShop, onClick: goShop) … }
5
- # NavBar(label: "Menú", orientation: "vertical") { NavButton(label: "Agenda", current: atAgenda, onClick: goAgenda, marker: "fill") { Icon "lucide:calendar" } }
4
+ # NavBar(label: "Sections") { NavButton(label: "My shop", current: atShop, onClick: goShop) … }
5
+ # NavBar(label: "Menu", orientation: "vertical") { NavButton(label: "Schedule", current: atAgenda, onClick: goAgenda, marker: "fill") { Icon "lucide:calendar" } }
6
6
  # marker: dot (soft fill + a dot, the default) | fill (soft fill) | line (an underline) | none (only the text firms up)
7
7
  # variant / size: as Btn. shadcn-for-muten · part · no deps · kit.css (.cx-nav, .cx-navbar).
8
8
  part NavButton(label: text = "", current: bool, onClick: action, variant: text = "ghost", size: text = "default", marker: text = "dot") {
@@ -11,11 +11,11 @@ part NavButton(label: text = "", current: bool, onClick: action, variant: text =
11
11
  Span "{$label}" class("cx-nav-label")
12
12
  }
13
13
  }
14
- part NavBar(label: text = "Secciones", orientation: text = "horizontal") { Nav class("cx-navbar") data(orientation: $orientation) aria(label: $label) { slot } }
14
+ part NavBar(label: text = "Sections", orientation: text = "horizontal") { Nav class("cx-navbar") data(orientation: $orientation, trl: $label) aria(label: $label) { slot } }
15
15
 
16
16
  # NavLink - a NavButton that goes to another page (a Link): the tabs between sibling pages (Terms · Privacy · Delete).
17
17
  # NavHere - the tab of the page you are on, which goes nowhere (aria-current="page", marked like a current NavButton).
18
- # NavBar(label: "Documentos") { NavHere(label: "Términos", marker: "fill") NavLink(label: "Privacidad", to: "/privacy", marker: "fill") }
18
+ # NavBar(label: "Documents") { NavHere(label: "Terms", marker: "fill") NavLink(label: "Privacy", to: "/privacy", marker: "fill") }
19
19
  part NavLink(label: text, to: text, variant: text = "ghost", size: text = "default", marker: text = "dot") {
20
20
  Link "" -> "{$to}" class("cn-button cn-button-variant-{$variant} cn-button-size-{$size} group/button cx-nav") data(slot: "button", marker: $marker) {
21
21
  slot