@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
@@ -3,6 +3,7 @@
3
3
  // modal (openModal/closeModal + CONTENT), the wheels, and the system kit (kit.* : calendar, agenda, messaging,
4
4
  // chatbot, stat, charts, settings, plans, team, extras, skeletons…). Edit the artifact first, then regenerate this.
5
5
  // A Custom loads it with `import("@muten/shadcn/registry/kit/core.js")` (a Custom is inlined, so no top-level import).
6
+ import { t, langOf, words, localizeTree } from './i18n.js';
6
7
 
7
8
  const $ = (s, r = document) => r.querySelector(s);
8
9
  const $$ = (s, r = document) => [...r.querySelectorAll(s)];
@@ -11,6 +12,37 @@ const root = document.documentElement;
11
12
 
12
13
  // ── the layer floating panels, the tooltip, the island and the modal live in (themed by the page's skin) ──
13
14
  const lib = document.createElement('div'); lib.className = 'cx-kit-layer'; document.body.appendChild(lib);
15
+ // THE FIRST PAINT STANDS STILL: once the page has painted, <html> says so (data-kit-live), and kit.css lets a piece
16
+ // that appears from then on make its entrance (@starting-style under [data-kit-live]); what was there at load never moves.
17
+ requestAnimationFrame(() => requestAnimationFrame(() => document.documentElement.setAttribute('data-kit-live', '')));
18
+ // The motion tokens for an animation started from script (WAAPI cannot read var()): ms('--t-move'), ease('--ease-out').
19
+ // Read live, so “reduce motion” (tokens at 1 ms) and a skin that overrides them both apply.
20
+ const motionToken = (name) => getComputedStyle(document.documentElement).getPropertyValue(name).trim();
21
+ const ms = (name, fallback = 240) => parseFloat(motionToken(name)) || fallback;
22
+ const ease = (name) => motionToken(name) || 'ease-out';
23
+ // the kit's words: tx(node, 'English', …args) in the language of that node (a widget's root), T0 for the page's
24
+ const tx = (node, k, ...a) => t(langOf(node), k, ...a);
25
+ const T0 = (k, ...a) => t(langOf(document.body), k, ...a);
26
+ // a Custom that renders once: relang(el, inputs, paint) gives it inputs.lang (as its `lang`), paints it, and paints it
27
+ // again in the new language when a lang changes and it has none of its own. Returns what the first paint returned.
28
+ const relang = (el, inputs, paint) => {
29
+ if (inputs && inputs.lang) el.setAttribute('lang', inputs.lang);
30
+ document.addEventListener('kit:lang', () => { if (el.isConnected && !(inputs && inputs.lang)) { el.innerHTML = ''; paint(); } });
31
+ return paint();
32
+ };
33
+
34
+ // ── language: what a part or a Custom marks (data-tr…, kit/i18n.js) is said in the language of its nearest `lang`, and
35
+ // said again whenever a `lang` changes (Storio switching to English); a kit:lang event tells the Customs to repaint.
36
+ localizeTree(document.body);
37
+ new MutationObserver((records) => {
38
+ let langChanged = false;
39
+ for (const r of records) {
40
+ if (r.type === 'childList') r.addedNodes.forEach(localizeTree);
41
+ else if (r.attributeName === 'lang') { langChanged = true; localizeTree(r.target === document.documentElement ? document.body : r.target); }
42
+ else localizeTree(r.target);
43
+ }
44
+ if (langChanged) document.dispatchEvent(new CustomEvent('kit:lang'));
45
+ }).observe(document.documentElement, { subtree: true, childList: true, attributes: true, attributeFilter: ['lang', 'data-tra'] });
14
46
  const sprite = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); sprite.setAttribute('aria-hidden', 'true'); sprite.style.cssText = 'position:absolute;width:0;height:0;overflow:hidden';
15
47
  sprite.innerHTML = `<defs><symbol id="b-whatsapp" viewBox="0 0 24 24"><path fill="currentColor" d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z"/></symbol><symbol id="b-instagram" viewBox="0 0 24 24"><path fill="currentColor" d="M7.0301.084c-1.2768.0602-2.1487.264-2.911.5634-.7888.3075-1.4575.72-2.1228 1.3877-.6652.6677-1.075 1.3368-1.3802 2.127-.2954.7638-.4956 1.6365-.552 2.914-.0564 1.2775-.0689 1.6882-.0626 4.947.0062 3.2586.0206 3.6671.0825 4.9473.061 1.2765.264 2.1482.5635 2.9107.308.7889.72 1.4573 1.388 2.1228.6679.6655 1.3365 1.0743 2.1285 1.38.7632.295 1.6361.4961 2.9134.552 1.2773.056 1.6884.069 4.9462.0627 3.2578-.0062 3.668-.0207 4.9478-.0814 1.28-.0607 2.147-.2652 2.9098-.5633.7889-.3086 1.4578-.72 2.1228-1.3881.665-.6682 1.0745-1.3378 1.3795-2.1284.2957-.7632.4966-1.636.552-2.9124.056-1.2809.0692-1.6898.063-4.948-.0063-3.2583-.021-3.6668-.0817-4.9465-.0607-1.2797-.264-2.1487-.5633-2.9117-.3084-.7889-.72-1.4568-1.3876-2.1228C21.2982 1.33 20.628.9208 19.8378.6165 19.074.321 18.2017.1197 16.9244.0645 15.6471.0093 15.236-.005 11.977.0014 8.718.0076 8.31.0215 7.0301.0839m.1402 21.6932c-1.17-.0509-1.8053-.2453-2.2287-.408-.5606-.216-.96-.4771-1.3819-.895-.422-.4178-.6811-.8186-.9-1.378-.1644-.4234-.3624-1.058-.4171-2.228-.0595-1.2645-.072-1.6442-.079-4.848-.007-3.2037.0053-3.583.0607-4.848.05-1.169.2456-1.805.408-2.2282.216-.5613.4762-.96.895-1.3816.4188-.4217.8184-.6814 1.3783-.9003.423-.1651 1.0575-.3614 2.227-.4171 1.2655-.06 1.6447-.072 4.848-.079 3.2033-.007 3.5835.005 4.8495.0608 1.169.0508 1.8053.2445 2.228.408.5608.216.96.4754 1.3816.895.4217.4194.6816.8176.9005 1.3787.1653.4217.3617 1.056.4169 2.2263.0602 1.2655.0739 1.645.0796 4.848.0058 3.203-.0055 3.5834-.061 4.848-.051 1.17-.245 1.8055-.408 2.2294-.216.5604-.4763.96-.8954 1.3814-.419.4215-.8181.6811-1.3783.9-.4224.1649-1.0577.3617-2.2262.4174-1.2656.0595-1.6448.072-4.8493.079-3.2045.007-3.5825-.006-4.848-.0608M16.953 5.5864A1.44 1.44 0 1 0 18.39 4.144a1.44 1.44 0 0 0-1.437 1.4424M5.8385 12.012c.0067 3.4032 2.7706 6.1557 6.173 6.1493 3.4026-.0065 6.157-2.7701 6.1506-6.1733-.0065-3.4032-2.771-6.1565-6.174-6.1498-3.403.0067-6.156 2.771-6.1496 6.1738M8 12.0077a4 4 0 1 1 4.008 3.9921A3.9996 3.9996 0 0 1 8 12.0077"/></symbol><symbol id="b-facebook" viewBox="0 0 24 24"><path fill="currentColor" d="M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z"/></symbol><symbol id="b-x" viewBox="0 0 24 24"><path fill="currentColor" d="M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z"/></symbol><symbol id="b-telegram" viewBox="0 0 24 24"><path fill="currentColor" d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/></symbol><symbol id="b-gmail" viewBox="0 0 24 24"><path fill="currentColor" d="M24 5.457v13.909c0 .904-.732 1.636-1.636 1.636h-3.819V11.73L12 16.64l-6.545-4.91v9.273H1.636A1.636 1.636 0 0 1 0 19.366V5.457c0-2.023 2.309-3.178 3.927-1.964L5.455 4.64 12 9.548l6.545-4.91 1.528-1.145C21.69 2.28 24 3.434 24 5.457z"/></symbol><symbol id="b-tiktok" viewBox="0 0 24 24"><path fill="currentColor" d="M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z"/></symbol><symbol id="x" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></symbol><symbol id="check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></symbol><symbol id="chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></symbol><symbol id="chevup" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m18 15-6-6-6 6"/></symbol><symbol id="updown" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7 15 5 5 5-5M7 9l5-5 5 5"/></symbol><symbol id="right" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></symbol><symbol id="left" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></symbol><symbol id="arrowup" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m5 12 7-7 7 7M12 19V5"/></symbol><symbol id="arrowleft" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m12 19-7-7 7-7M19 12H5"/></symbol><symbol id="more" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></symbol><symbol id="search" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></symbol><symbol id="okc" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></symbol><symbol id="info" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></symbol><symbol id="folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></symbol><symbol id="cal" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></symbol><symbol id="smile" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2M9 9h.01M15 9h.01"/></symbol><symbol id="calc" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="2" width="16" height="20" rx="2"/><path d="M8 6h8M16 14v4M8 10h.01M12 10h.01M16 10h.01M8 14h.01M12 14h.01M8 18h.01M12 18h.01"/></symbol><symbol id="user" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="5"/><path d="M20 21a8 8 0 0 0-16 0"/></symbol><symbol id="card" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="5" width="20" height="14" rx="2"/><path d="M2 10h20"/></symbol><symbol id="gear" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></symbol><symbol id="loader" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></symbol><symbol id="bold" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 12h9a4 4 0 0 1 0 8H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h7a4 4 0 0 1 0 8"/></symbol><symbol id="italic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 4h-9M14 20H5M15 4 9 20"/></symbol><symbol id="under" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 4v6a6 6 0 0 0 12 0V4M4 20h16"/></symbol><symbol id="bookmark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z"/></symbol><symbol id="ext" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 7h10v10M7 17 17 7"/></symbol><symbol id="badgecheck" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/><path d="m9 12 2 2 4-4"/></symbol><symbol id="plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></symbol><symbol id="grip" viewBox="0 0 24 24" fill="currentColor"><circle cx="9" cy="6" r="1.6"/><circle cx="15" cy="6" r="1.6"/><circle cx="9" cy="12" r="1.6"/><circle cx="15" cy="12" r="1.6"/><circle cx="9" cy="18" r="1.6"/><circle cx="15" cy="18" r="1.6"/></symbol><symbol id="scissors" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M20 4 8.12 15.88M14.47 14.48 20 20M8.12 8.12 12 12"/></symbol><symbol id="store" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9h18l-1.5-5h-15z"/><path d="M5 9v11h14V9M9 20v-6h6v6"/></symbol><symbol id="clock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></symbol><symbol id="pencil" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.85 2.85 0 0 1 4 4L7.5 20.5 2 22l1.5-5.5z"/></symbol><symbol id="trash" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6"/></symbol><symbol id="caloff" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18M9.5 14.5l5 5M14.5 14.5l-5 5"/></symbol><symbol id="copyi" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></symbol><symbol id="mappin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0z"/><circle cx="12" cy="10" r="3"/></symbol><symbol id="quote" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></symbol><symbol id="truck" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 18V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v11a1 1 0 0 0 1 1h2M15 18H9M19 18h2a1 1 0 0 0 1-1v-3.65a1 1 0 0 0-.22-.62l-3.48-4.35A1 1 0 0 0 17.52 8H14"/><circle cx="17" cy="18" r="2"/><circle cx="7" cy="18" r="2"/></symbol><symbol id="call" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.9v3a2 2 0 0 1-2.2 2 19.8 19.8 0 0 1-8.6-3.1 19.5 19.5 0 0 1-6-6A19.8 19.8 0 0 1 2.1 4.2 2 2 0 0 1 4.1 2h3a2 2 0 0 1 2 1.7c.1.9.4 1.8.7 2.7a2 2 0 0 1-.5 2.1L8 9.8a16 16 0 0 0 6 6l1.3-1.3a2 2 0 0 1 2.1-.4c.9.3 1.8.6 2.7.7a2 2 0 0 1 1.7 2z"/></symbol><symbol id="pause" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/></symbol><symbol id="play" viewBox="0 0 24 24" fill="currentColor"><path d="M7 4.5v15l13-7.5z"/></symbol><symbol id="filter" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 5h18M6 12h12M10 19h4"/></symbol><symbol id="upload" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12"/></symbol><symbol id="image" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21"/></symbol><symbol id="sort" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7 15 5 5 5-5M7 9l5-5 5 5"/></symbol><symbol id="arrowdown" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M19 12l-7 7-7-7"/></symbol><symbol id="share" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="m8.6 13.5 6.8 4M15.4 6.5l-6.8 4"/></symbol><symbol id="home" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 10.5 12 3l9 7.5V20a1 1 0 0 1-1 1h-5v-6h-6v6H4a1 1 0 0 1-1-1z"/></symbol><symbol id="bag" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4zM3 6h18M16 10a4 4 0 0 1-8 0"/></symbol><symbol id="menu-lines" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16M4 12h16M4 18h16"/></symbol><symbol id="mail" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-10 6L2 7"/></symbol><symbol id="link" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.7 1.7M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.7-1.7"/></symbol><symbol id="qrc" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><path d="M14 14h3v3h-3zM20 14v.01M14 20h.01M17 20h4v-3"/></symbol><symbol id="i-bell" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10.3 21a1.94 1.94 0 0 0 3.4 0"/></symbol><symbol id="i-clip" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.4 11.1-9.2 9.2a6 6 0 0 1-8.5-8.5l9.2-9.2a4 4 0 0 1 5.7 5.7l-9.2 9.2a2 2 0 0 1-2.8-2.8l8.5-8.5"/></symbol><symbol id="i-send" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2 11 13M22 2l-7 20-4-9-9-4z"/></symbol><symbol id="i-eye" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></symbol><symbol id="i-eyeoff" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.9 4.2A10 10 0 0 1 12 4c6.5 0 10 7 10 7a17 17 0 0 1-2.2 3.2M6.6 6.6A17 17 0 0 0 2 12s3.5 7 10 7a10 10 0 0 0 5.4-1.6M2 2l20 20M14.1 14.1a3 3 0 0 1-4.2-4.2"/></symbol><symbol id="i-gift" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="8" width="18" height="4" rx="1"/><path d="M12 8v13M19 12v7a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-7M7.5 8a2.5 2.5 0 0 1 0-5C10 3 12 8 12 8s2-5 4.5-5a2.5 2.5 0 0 1 0 5"/></symbol><symbol id="i-doc" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6M8 13h8M8 17h5"/></symbol><symbol id="i-ticks" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 7 17l-5-5M22 10l-7.5 7.5L13 16"/></symbol><symbol id="i-tick" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></symbol><symbol id="i-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></symbol><symbol id="i-lock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></symbol><symbol id="i-archive" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8M10 12h4"/></symbol><symbol id="i-bot" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8V4H8"/><rect x="4" y="8" width="16" height="12" rx="2"/><path d="M2 14h2M20 14h2M15 13v2M9 13v2"/></symbol><symbol id="i-hand" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 11V6a2 2 0 0 0-4 0v5M14 10V4a2 2 0 0 0-4 0v2M10 10.5V6a2 2 0 0 0-4 0v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/></symbol><symbol id="i-inbox" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></symbol><symbol id="i-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16M4 12h16M4 18h16"/></symbol><symbol id="i-panel" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M15 3v18"/></symbol><symbol id="i-mail" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-10 6L2 7"/></symbol></defs>`; document.body.prepend(sprite);
16
48
  // follow the page's skin: the layer sits outside the app, so it copies data-skin from the nearest skinned element
@@ -164,7 +196,18 @@ watchReveal();
164
196
  setTimeout(done, ms + 400); // safety net if an animation never reports back
165
197
  };
166
198
  // floating panel: `gap` px from the anchor, aligned to its start/center/end, flips up when it does not fit below
199
+ // Every panel placed here rises to the browser's top layer (a manual popover, as overlays.js already does), so no
200
+ // ancestor clips or shifts it: a transform, a filter, contain or content-visibility on a card or a sheet turned its
201
+ // `position: fixed` into “fixed to that box” and cut it at the box's sides. It stays where muten put it in the DOM.
202
+ const lift = (el) => {
203
+ if (!('popover' in el) || el === tip) return;
204
+ if (!el.hasAttribute('popover')) { el.setAttribute('popover', 'manual'); el.classList.add('cx-floated'); }
205
+ // raised ONCE and left there: taking it out and back in on every opening made a menu reopened while it was still
206
+ // closing vanish and reappear for a frame (a flicker)
207
+ if (!el.matches(':popover-open')) { try { el.showPopover(); } catch { /* not connected yet */ } }
208
+ };
167
209
  const place = (el, anchor, align = 'center', gap = 4, width = null) => {
210
+ lift(el);
168
211
  const r = anchor.getBoundingClientRect();
169
212
  if (width) el.style.width = width + 'px';
170
213
  const w = el.offsetWidth, h = el.offsetHeight;
@@ -213,14 +256,21 @@ watchReveal();
213
256
  // It holds while the pointer travels inside its trigger (icon to label is not leaving, so it never blinks), it goes on
214
257
  // a press, and in a sidebar it only speaks while the rail is folded to icons: open, the label is already on screen.
215
258
  const tip = document.createElement('div'); tip.className = 'cx-tip'; tip.setAttribute('role', 'tooltip'); tip.hidden = true; lib.appendChild(tip);
216
- let tipFor = null;
217
- const tipHide = () => { tipFor = null; tip.dataset.state = 'closed'; tip.hidden = true; };
259
+ // Going from one trigger to the next (a rail of icons, a toolbar) does not blink: leaving waits 90 ms before it
260
+ // hides, so the gap between two rows is not a “leave”, and while a tooltip is up (or just went) the next one moves
261
+ // there at once, with no fade (Emil's “instant after the first”). Only the first one of a run fades in.
262
+ let tipFor = null, tipLeave = 0, tipGone = 0;
263
+ const tipHide = () => { clearTimeout(tipLeave); if (tipFor) tipGone = performance.now(); tipFor = null; tip.dataset.state = 'closed'; tip.hidden = true; tip.removeAttribute('data-instant'); };
218
264
  const tipOn = (e) => {
219
265
  const w = e.target.closest?.('[data-name]');
220
- if (!w || !w.dataset.name || w === tipFor) return; /* an empty name (no data yet) says nothing */
266
+ if (w === tipFor && w) { clearTimeout(tipLeave); return; }
267
+ if (!w || !w.dataset.name) return; /* an empty name (no data yet) says nothing */
221
268
  const rail = w.closest('[data-slot="sidebar"]');
222
269
  if (rail && rail.dataset.collapsed !== 'true' && w.dataset.slot !== 'tooltip-trigger') return;
223
- tipFor = w; tip.textContent = w.dataset.name; tip.hidden = false; tip.dataset.state = 'closed';
270
+ clearTimeout(tipLeave);
271
+ const warm = !!tipFor || performance.now() - tipGone < 400;
272
+ tipFor = w; tip.textContent = w.dataset.name; tip.hidden = false;
273
+ tip.toggleAttribute('data-instant', warm); if (!warm) tip.dataset.state = 'closed';
224
274
  const r = w.getBoundingClientRect();
225
275
  if (w.dataset.side === 'right' || rail) {
226
276
  tip.dataset.side = 'right'; tip.style.left = (r.right + 8) + 'px'; tip.style.top = (r.top + r.height / 2 - tip.offsetHeight / 2) + 'px';
@@ -231,12 +281,13 @@ watchReveal();
231
281
  tip.style.top = (above ? r.top - tip.offsetHeight - 8 : r.bottom + 8) + 'px';
232
282
  tip.style.setProperty('--ax', (r.left + r.width / 2 - parseFloat(tip.style.left)) + 'px');
233
283
  }
234
- requestAnimationFrame(() => { if (tipFor === w) tip.dataset.state = 'open'; });
284
+ if (warm) tip.dataset.state = 'open'; else requestAnimationFrame(() => { if (tipFor === w) tip.dataset.state = 'open'; });
235
285
  };
236
286
  const tipOff = (e) => {
237
287
  const w = e.target.closest?.('[data-name]');
238
288
  if (!w || w !== tipFor || (e.relatedTarget && w.contains(e.relatedTarget))) return;
239
- tipHide();
289
+ if (e.type === 'focusout') { tipHide(); return; }
290
+ clearTimeout(tipLeave); tipLeave = setTimeout(() => { if (tipFor === w) tipHide(); }, 90);
240
291
  };
241
292
  ['pointerover', 'focusin'].forEach((t) => document.addEventListener(t, tipOn)); ['pointerout', 'focusout'].forEach((t) => document.addEventListener(t, tipOff));
242
293
  document.addEventListener('pointerdown', () => { if (tipFor) tipHide(); }, true);
@@ -264,7 +315,7 @@ const islText = $('.cx-island-text', isl), islIcon = $('.cx-island-icon', isl);
264
315
  const easeHeight = (el, change) => { // FLIP on height: measure, change, measure, animate between
265
316
  const from = el.offsetHeight; change(); const to = el.offsetHeight;
266
317
  if (from === to || !from) return; // from 0 = the element was empty or not laid out yet: its first content just appears
267
- el.animate([{ height: from + 'px' }, { height: to + 'px' }], { duration: 200, easing: 'cubic-bezier(.22, 1, .36, 1)' });
318
+ el.animate([{ height: from + 'px' }, { height: to + 'px' }], { duration: 200, easing: ease('--ease-out') });
268
319
  };
269
320
 
270
321
  // ── atoms ──
@@ -296,7 +347,7 @@ const islText = $('.cx-island-text', isl), islIcon = $('.cx-island-icon', isl);
296
347
  // Text: every piece of copy is one of these variants, never an ad-hoc font size.
297
348
  ui.text = (content, variant = 'body', tag = 'span', attrs = '') => `<${tag} data-slot="text" data-variant="${variant}" class="cx-text" ${attrs}>${content}</${tag}>`;
298
349
  // Stepper: one Progress atom per step, plus the step's name as Text.
299
- ui.stepper = (steps, at) => `<div data-slot="stepper" class="cx-stepper" style="--n:${steps.length}" role="list" aria-label="Paso ${at + 1} de ${steps.length}: ${steps[at]}">${steps.map((name, i) => `<div role="listitem" class="cx-step" ${i < at ? 'data-done' : i === at ? 'data-now aria-current="step"' : ''}>${ui.progress(i <= at ? 100 : 0, name)}${ui.text(name, i === at ? 'label' : 'caption')}</div>`).join('')}</div>`;
350
+ ui.stepper = (steps, at) => `<div data-slot="stepper" class="cx-stepper" style="--n:${steps.length}" role="list" aria-label="${T0('Step {0} of {1}: {2}', at + 1, steps.length, steps[at])}">${steps.map((name, i) => `<div role="listitem" class="cx-step" ${i < at ? 'data-done' : i === at ? 'data-now aria-current="step"' : ''}>${ui.progress(i <= at ? 100 : 0, name)}${ui.text(name, i === at ? 'label' : 'caption')}</div>`).join('')}</div>`;
300
351
  ui.field = ({ id, label, control, description = '' }) => `<div data-slot="field" class="cn-field cn-field-orientation-vertical group/field"><label data-slot="label" class="cn-label" for="${id}">${label}</label>${control}${description ? `<p data-slot="field-description" class="cn-field-description">${description}</p>` : ''}</div>`;
301
352
  ui.input = ({ id, value = '', placeholder = '', type = 'text', attrs = '' }) => `<input data-slot="input" class="cn-input" id="${id}" type="${type}" value="${value}" placeholder="${placeholder}" ${attrs}>`;
302
353
  ui.choice = ({ media = '', title, description = '', aside = '', checked = false, attrs = '' }) => `<div role="radio" aria-checked="${checked}" tabindex="${checked ? 0 : -1}" data-slot="item" class="cn-item cn-item-variant-outline cn-item-size-sm group/item cx-choice" ${attrs}>${media}<div class="cn-item-content"><div class="cn-item-title">${title}</div>${description ? `<p class="cn-item-description">${description}</p>` : ''}</div>${aside ? `<div class="cn-item-actions">${aside}</div>` : ''}</div>`;
@@ -431,10 +482,10 @@ let wheelsNow = [];
431
482
  // kit.pickTime({ value, label, from, to, step }) → Promise<'HH:MM' | null>, in the adaptive modal with wheels
432
483
  let tpCb = null, tpState = null, tpBack = null;
433
484
  const tpSettle = () => requestAnimationFrame(() => requestAnimationFrame(() => { wheelsNow.forEach((w) => w.place()); $('.cx-wheel', body)?.focus({ preventScroll: true }); }));
434
- kit.pickTime = ({ value = '09:00', label = 'Hora', from = 6, to = 22, step = 15 } = {}) => new Promise((resolve) => {
485
+ kit.pickTime = ({ value = '09:00', label = T0('Time'), from = 6, to = 22, step = 15 } = {}) => new Promise((resolve) => {
435
486
  tpCb?.(null); tpState = { value, label, from, to, step }; tpCb = resolve;
436
487
  if (!open) { tpBack = null; openModal('timeWheel', 'auto'); return tpSettle(); }
437
- // a modal is already open: the wheels become a step of it, and «Listo» brings the form back untouched
488
+ // a modal is already open: the wheels become a step of it, and “Done” brings the form back untouched
438
489
  const kept = { body: [...body.childNodes], foot: [...foot.childNodes], title: $('#mdl-title').textContent, desc: $('#mdl-desc').textContent, focus: document.activeElement };
439
490
  tpBack = () => { body.replaceChildren(...kept.body); foot.replaceChildren(...kept.foot); $('#mdl-title').textContent = kept.title; $('#mdl-desc').textContent = kept.desc; kept.focus?.focus({ preventScroll: true }); };
440
491
  easeHeight(mdl, () => CONTENT.timeWheel()); tpSettle();
@@ -443,12 +494,12 @@ let wheelsNow = [];
443
494
  const { value, label, from: f, to: t, step } = tpState, [h0, m0] = value.split(':').map(Number);
444
495
  const HS = Array.from({ length: t - f + 1 }, (_, i) => f + i), MS = Array.from({ length: 60 / step }, (_, i) => i * step);
445
496
  let h = HS.includes(h0) ? h0 : HS[0], m = MS.includes(m0) ? m0 : 0;
446
- $('#mdl-title').textContent = label; $('#mdl-desc').textContent = 'Arrastra cada rueda, toca un valor o usa las flechas.';
497
+ $('#mdl-title').textContent = label; $('#mdl-desc').textContent = T0('Drag each wheel, tap a value or use the arrows.');
447
498
  body.innerHTML = '<div class="cx-wheels"><div class="cx-wheel" id="tw-h"></div><div class="cx-wheel" id="tw-m"></div></div><p class="cx-wheel-sum" id="tw-sum"></p>';
448
499
  const sum = () => { $('#tw-sum').textContent = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; };
449
- wheelsNow = [makeWheel($('#tw-h'), HS.map((x) => String(x).padStart(2, '0')), HS.indexOf(h), (i) => { h = HS[i]; sum(); }, 'Hora'), makeWheel($('#tw-m'), MS.map((x) => String(x).padStart(2, '0')), MS.indexOf(m), (i) => { m = MS[i]; sum(); }, 'Minutos')];
500
+ wheelsNow = [makeWheel($('#tw-h'), HS.map((x) => String(x).padStart(2, '0')), HS.indexOf(h), (i) => { h = HS[i]; sum(); }, T0('Hour')), makeWheel($('#tw-m'), MS.map((x) => String(x).padStart(2, '0')), MS.indexOf(m), (i) => { m = MS[i]; sum(); }, T0('Minutes'))];
450
501
  sum();
451
- foot.innerHTML = ui.button({ label: tpBack ? 'Volver' : 'Cancelar', size: 'default', attrs: 'data-tw-no', cls: 'cx-exit' }) + ui.button({ label: 'Listo', variant: 'default', size: 'default', attrs: 'data-tw-ok', cls: 'cx-go' });
502
+ foot.innerHTML = ui.button({ label: tpBack ? T0('Back') : T0('Cancel'), size: 'default', attrs: 'data-tw-no', cls: 'cx-exit' }) + ui.button({ label: T0('Done'), variant: 'default', size: 'default', attrs: 'data-tw-ok', cls: 'cx-go' });
452
503
  };
453
504
  document.addEventListener('click', (e) => {
454
505
  const ok = e.target.closest('[data-tw-ok]'), no = e.target.closest('[data-tw-no]'); if ((!ok && !no) || !tpCb) return;
@@ -478,11 +529,14 @@ let wheelsNow = [];
478
529
 
479
530
 
480
531
  // ── Calendar: mode 'single' | 'range'; months 1 | 2; onChange(value) ──
481
- kit.calendar = (root, { mode = 'single', months = 1, value = null, onChange = () => {}, min = null, mon = MON, weekdays = null, prevLabel = 'Mes anterior', nextLabel = 'Mes siguiente', prevYear = 'Año anterior', nextYear = 'Año siguiente', pickLabel = 'Elegir mes' } = {}) => {
532
+ kit.calendar = (root, opts = {}) => {
533
+ // its words default to the language of its root (the page's), English when there is none
534
+ const cw = kit.calWords(langOf(root));
535
+ let { mode = 'single', months = 1, value = null, onChange = () => {}, min = null, mon = cw.mon, weekdays = cw.weekdays, prevLabel = cw.prevLabel, nextLabel = cw.nextLabel, prevYear = cw.prevYear, nextYear = cw.nextYear, pickLabel = cw.pickLabel } = opts;
482
536
  let picking = false; // the month's title opens a year of months: far dates in two taps
483
537
  let view = new Date((mode === 'range' ? value?.[0] : value) || today); view.setDate(1);
484
538
  let sel = value, hover = null, focusDay = startOfDay((mode === 'range' ? value?.[0] : value) || today);
485
- const WD = weekdays || ['lu', 'ma', 'mi', 'ju', 'vi', 'sá', 'do'];
539
+ const WD = weekdays;
486
540
  const inRange = (d) => mode === 'range' && sel?.[0] && (sel[1] || hover) && d > startOfDay(sel[0]) && d < startOfDay(sel[1] || hover) ;
487
541
  const isEdge = (d) => mode === 'range' ? (sameDay(d, sel?.[0]) || sameDay(d, sel?.[1])) : sameDay(d, sel);
488
542
  // the arrows sit in the title's own row, so they can never land on the month's name
@@ -493,7 +547,7 @@ let wheelsNow = [];
493
547
  return `<div class="cx-cal-month" role="grid" aria-label="${mon[first.getMonth()]} ${first.getFullYear()}"><div class="cx-cal-head">${m === 0 ? navBtn(-1, 'left', prevLabel, 'nav') : '<span></span>'}<button type="button" class="cx-cal-title cx-cal-pick" data-cal-pick aria-label="${pickLabel}: ${mon[first.getMonth()]} ${first.getFullYear()}">${ui.text(`${mon[first.getMonth()][0].toUpperCase()}${mon[first.getMonth()].slice(1)} ${first.getFullYear()}`, 'heading')}${ui.icon('chev')}</button>${m === months - 1 ? navBtn(1, 'right', nextLabel, 'nav') : '<span></span>'}</div><div class="cx-cal-grid" role="row">${WD.map((w) => `<span class="cx-cal-wd" role="columnheader">${ui.text(w, 'caption')}</span>`).join('')}</div><div class="cx-cal-grid">${cells.map((d) => {
494
548
  const out = d.getMonth() !== first.getMonth(), off = min && d < startOfDay(min);
495
549
  const st = isEdge(d) ? 'edge' : inRange(d) ? 'mid' : '';
496
- return `<button class="cn-button cn-button-variant-ghost cn-button-size-icon cx-cal-day" role="gridcell" data-day="${+d}" ${out ? 'data-out' : ''} ${sameDay(d, today) ? 'data-today' : ''} ${st ? `data-range="${st}"` : ''} aria-pressed="${st === 'edge'}" aria-label="${DAYLONG[d.getDay()]} ${d.getDate()} de ${mon[d.getMonth()]}" tabindex="${sameDay(d, focusDay) && !out ? 0 : -1}" ${off ? 'disabled' : ''}>${d.getDate()}</button>`;
550
+ return `<button class="cn-button cn-button-variant-ghost cn-button-size-icon cx-cal-day" role="gridcell" data-day="${+d}" ${out ? 'data-out' : ''} ${sameDay(d, today) ? 'data-today' : ''} ${st ? `data-range="${st}"` : ''} aria-pressed="${st === 'edge'}" aria-label="${langOf(root) === 'es' ? `${kit.words('es').dayL[d.getDay()]} ${d.getDate()} de ${mon[d.getMonth()]}` : `${kit.words('en').dayL[d.getDay()]}, ${mon[d.getMonth()]} ${d.getDate()}`}" tabindex="${sameDay(d, focusDay) && !out ? 0 : -1}" ${off ? 'disabled' : ''}>${d.getDate()}</button>`;
497
551
  }).join('')}</div></div>`;
498
552
  };
499
553
  const picker = () => `<div class="cx-cal cx-cal-picker"><div class="cx-cal-head">${navBtn(-1, 'left', prevYear, 'year')}<div class="cx-cal-title">${ui.text(String(view.getFullYear()), 'heading')}</div>${navBtn(1, 'right', nextYear, 'year')}</div><div class="cx-cal-mgrid">${mon.map((m, k) => `<button type="button" class="cn-button cn-button-variant-ghost cn-button-size-sm cx-cal-m" data-cal-month="${k}" ${k === view.getMonth() ? 'aria-current="true"' : ''} ${today.getFullYear() === view.getFullYear() && today.getMonth() === k ? 'data-today' : ''}>${m[0].toUpperCase()}${m.slice(1, 3)}</button>`).join('')}</div></div>`;
@@ -509,9 +563,11 @@ let wheelsNow = [];
509
563
  focusDay = d; paint(true); onChange(sel);
510
564
  };
511
565
  root.addEventListener('click', (e) => {
512
- const n = e.target.closest('[data-cal-nav]'); if (n) { view.setMonth(view.getMonth() + +n.dataset.calNav); paint(); return; }
566
+ // a month changed by its arrow slides in from that side (kit.css “Calendar”); the keyboard's PageUp/PageDown stays instant
567
+ const slide = (dir) => { const box = root.querySelector('.cx-cal-months, .cx-cal-picker'); if (box) box.dataset.slide = dir > 0 ? 'next' : 'prev'; };
568
+ const n = e.target.closest('[data-cal-nav]'); if (n) { view.setMonth(view.getMonth() + +n.dataset.calNav); paint(); slide(+n.dataset.calNav); return; }
513
569
  if (e.target.closest('[data-cal-pick]')) { picking = true; paint(); return; }
514
- const yr = e.target.closest('[data-cal-year]'); if (yr) { view.setFullYear(view.getFullYear() + +yr.dataset.calYear); paint(); return; }
570
+ const yr = e.target.closest('[data-cal-year]'); if (yr) { view.setFullYear(view.getFullYear() + +yr.dataset.calYear); paint(); slide(+yr.dataset.calYear); return; }
515
571
  const mo = e.target.closest('[data-cal-month]'); if (mo) { view = new Date(view.getFullYear(), +mo.dataset.calMonth, 1); picking = false; paint(); return; }
516
572
  const b = e.target.closest('[data-day]'); if (b && !b.disabled) pick(new Date(+b.dataset.day));
517
573
  });
@@ -534,7 +590,7 @@ let wheelsNow = [];
534
590
  // Day columns per person, the week, or a list (the phone keeps the day grid and the list). Two ways to use it:
535
591
  // · a SHOWCASE (no app behind it): its own sheets open, book, block, move and cancel, all in memory;
536
592
  // · an APP (sheet: 'app'): the app owns the data. The agenda paints what it is given and tells the app what
537
- // happened: onOpen(event) when a cita is tapped, onCreate({resource, day, start}) on a free hour, onBlock(block)
593
+ // happened: onOpen(event) when a appointment is tapped, onCreate({resource, day, start}) on a free hour, onBlock(block)
538
594
  // when a block is asked, onMove(change) when one is dragged, onRange({from, to, view}) when the days shown
539
595
  // change (so the app loads them). The app answers with update(next): new data, same day, view and person.
540
596
  // The free hours can come from the server (free: [{resource, day, start, end}]): then they are the ONLY free hours,
@@ -589,7 +645,10 @@ let wheelsNow = [];
589
645
  mon: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'],
590
646
  },
591
647
  };
592
- // a cita's status as the agenda draws it: its tone (the colour family) and whether it still occupies its time
648
+ // the date words of a language (months, days, the calendar's buttons) for any piece that shows a date
649
+ kit.words = (lang) => AG_WORDS[lang === 'es' ? 'es' : 'en'];
650
+ kit.calWords = (lang) => { const w = kit.words(lang); return { mon: w.mon, weekdays: w.dayS.slice(1).concat(w.dayS[0]).map((x) => x.slice(0, 2).toLowerCase()), prevLabel: w.prevMonth, nextLabel: w.nextMonth, prevYear: w.prevYear, nextYear: w.nextYear, pickLabel: w.pickMonth }; };
651
+ // a appointment's status as the agenda draws it: its tone (the colour family) and whether it still occupies its time
593
652
  const AG_STATUS = { awaiting_payment: 'info', confirmed: 'ok', started: 'live', done: 'done', noshow: 'bad' };
594
653
 
595
654
  let agSeq = 0, agOwner = 0; // several agendas on one page: each its own sheets, and only the one that opened the sheet answers it
@@ -604,7 +663,7 @@ let wheelsNow = [];
604
663
  actions = null, createSheet = false, services = [], view: startView = 'day', sheet: sheetMode = 'built-in', drag: dragOn = true,
605
664
  resize: resizeMode = 'on', moveAcross = 'on',
606
665
  start: startDay = null, phone: phoneMode = '', hour12 = false, timeZone = '', phoneView = '', blockScope = 'each', phoneStrip = '' } = opts;
607
- // THE SHOP'S CLOCK: with a timeZone, «today», «now» and «the past» are the shop's, not the browser's (an owner
666
+ // THE SHOP'S CLOCK: with a timeZone, “today”, “now” and “the past” are the shop's, not the browser's (an owner
608
667
  // travelling, or a phone set to another zone, still sees the shop's day)
609
668
  const clockNow = () => { if (!timeZone) return new Date(); try { const p = Object.fromEntries(new Intl.DateTimeFormat('en-US', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).formatToParts(new Date()).map((x) => [x.type, x.value])); return new Date(+p.year, +p.month - 1, +p.day, +p.hour % 24, +p.minute); } catch { return new Date(); } };
610
669
  const today = (() => { const d = clockNow(); d.setHours(0, 0, 0, 0); return d; })();
@@ -630,13 +689,13 @@ let wheelsNow = [];
630
689
  const y = (m) => ((m - from * 60) / 60) * hourPx;
631
690
  const weekDays = () => { const mon = addDays(day, -((day.getDay() + 6) % 7)); return Array.from({ length: 7 }, (_, i) => addDays(mon, i)); };
632
691
  // SEVERAL AGENDAS: a resource is an agenda (kind "business" for the shop's own, "person" for each worker, or any other
633
- // kind: a room, a chair). The business goes first. A cita belongs to its `resource` and also takes every agenda in
634
- // `with` (a worker AND the room). `capacity` says how many citas an agenda holds at once: 1 (the default) = they never
692
+ // kind: a room, a chair). The business goes first. A appointment belongs to its `resource` and also takes every agenda in
693
+ // `with` (a worker AND the room). `capacity` says how many appointments an agenda holds at once: 1 (the default) = they never
635
694
  // overlap, 3 = a class of three, 0 = no limit. `overlap` "never" | "allow" overrides it for the whole agenda.
636
695
  const kindOf = (r) => r?.kind || 'person';
637
696
  const ordered = () => [...resources].sort((a, b) => (kindOf(a) === 'business' ? 0 : 1) - (kindOf(b) === 'business' ? 0 : 1));
638
697
  const shown = () => (who === 'all' ? ordered() : who.startsWith('kind:') ? ordered().filter((r) => kindOf(r) === who.slice(5)) : resources.filter((r) => r.id === who));
639
- // what an agenda is, under its name: its role («Barbero»), else its kind (the business, a room's group), plus how many fit
698
+ // what an agenda is, under its name: its role (“Barber”), else its kind (the business, a room's group), plus how many fit
640
699
  const roleOf = (r) => [r.role || (kindOf(r) === 'business' ? w.business : kindOf(r) !== 'person' ? r.group || '' : ''), capOf(r.id) > 1 ? w.capacityOf(capOf(r.id)) : ''].filter(Boolean).join(' · ');
641
700
  // a resource with hideEmpty (the business's own agenda, which only holds what nobody was given) shows its column
642
701
  // only on days it has something; the rest of the agendas always show
@@ -646,10 +705,10 @@ let wheelsNow = [];
646
705
  const occ = (e) => [e.resource, ...(Array.isArray(e.with) ? e.with : [])];
647
706
  const capOf = (rid) => { if (overlap === 'never') return 1; if (overlap === 'allow') return 0; const c = resources.find((r) => r.id === rid)?.capacity; return c === undefined || c === null || c === '' ? 1 : Math.max(0, Number(c) || 0); };
648
707
  const evOn = (d, rid) => { const ids = rid ? [rid] : who === 'all' ? null : shown().map((r) => r.id); return events.filter((e) => sameDay(e.day, d) && e.status !== 'cancelled' && (!ids || occ(e).some((x) => ids.includes(x)))); };
649
- // the most citas one agenda holds at once inside [st, st + dur) (except one, the cita being moved)
708
+ // the most appointments one agenda holds at once inside [st, st + dur) (except one, the appointment being moved)
650
709
  const peak = (rid, d, st, dur, except) => { const iv = events.filter((x) => x.id !== except && x.status !== 'cancelled' && sameDay(x.day, d) && occ(x).includes(rid)).map((x) => [toMin(x.start), toMin(x.start) + x.dur]).filter(([a, b]) => a < st + dur && b > st); return Math.max(0, ...[st, ...iv.map(([a]) => a).filter((a) => a > st)].map((pt) => iv.filter(([a, b]) => a <= pt && b > pt).length)); };
651
710
  const roomFor = (rid, d, st, dur, except) => { const cap = capOf(rid); return cap === 0 || peak(rid, d, st, dur, except) < cap; };
652
- // citas that break an agenda's capacity (data from the server that crossed): they say so, in words
711
+ // appointments that break an agenda's capacity (data from the server that crossed): they say so, in words
653
712
  let clash = new Set();
654
713
  const findClashes = () => { const out = new Set(); resources.forEach((r) => { const cap = capOf(r.id); if (!cap) return; events.filter((x) => x.status !== 'cancelled' && occ(x).includes(r.id)).forEach((e) => { if (peak(r.id, e.day, toMin(e.start), e.dur, null) > cap) out.add(e.id); }); }); return out; };
655
714
  const blOn = (d, rid) => blocks.filter((b) => sameDay(b.day, d) && (!rid || b.resource === rid || b.resource === 'all'));
@@ -662,13 +721,13 @@ let wheelsNow = [];
662
721
  const isClosed = (d) => closed.some((c) => sameDay(c, d)) || (!freeList && shown().every((r) => !availability(r.id, d).length));
663
722
  // overlapping events share the column: each gets a lane and the lane count of its cluster
664
723
  const lanes = (list) => { const s = [...list].sort((a, b) => toMin(a.start) - toMin(b.start)); const out = new Map(); let cluster = [], end = -1; const flush = () => { const lanesEnd = []; cluster.forEach((e) => { const st = toMin(e.start); let l = lanesEnd.findIndex((x) => x <= st); if (l < 0) { l = lanesEnd.length; lanesEnd.push(0); } lanesEnd[l] = st + e.dur; out.set(e.id, [l, 0]); }); cluster.forEach((e) => { out.get(e.id)[1] = lanesEnd.length; }); }; s.forEach((e) => { const st = toMin(e.start); if (st >= end && cluster.length) { flush(); cluster = []; } cluster.push(e); end = Math.max(end, st + e.dur); }); if (cluster.length) flush(); return out; };
665
- // an app's own words on a cita («A domicilio», «Llegó 10:02», «Confirmó»): said next to its time, never as colour
724
+ // an app's own words on an appointment (“At home”, “Arrived 10:02”, “Confirmed”): said next to its time, never as colour
666
725
  const tagsOf = (e) => (Array.isArray(e.tags) ? e.tags.filter(Boolean).join(' · ') : '');
667
726
  const SAY = ['started', 'awaiting_payment', 'noshow']; // the statuses a cita SAYS in words (no stripes, no glow)
668
727
  const evHtml = (e, ln, showWho, rid = null, gut = false) => { const [l, n] = ln.get(e.id) || [0, 1]; const short = e.dur < 45; const said = clash.has(e.id) ? w.clashOne : SAY.includes(e.status) && w.status[e.status] ? w.status[e.status] : ''; const others = rid ? occ(e).filter((x) => x !== rid).map(resName).filter(Boolean).join(', ') : ''; const tg = tagsOf(e);
669
728
  const span = gut ? '(100% - 22px)' : '100%'; /* an agenda with no limit keeps a strip on the right to book beside */ return `<button class="cx-ag-ev" data-ev="${e.id}" data-tone="${toneOf(e)}" ${e.status ? `data-status="${e.status}"` : ''}${paint1(e)}top:${y(toMin(e.start))}px;height:${(e.dur / 60) * hourPx - 3}px;left:calc(${span} * ${l / n} + 3px);width:calc(${span} / ${n} - 6px)"${clash.has(e.id) ? ' data-conflict' : ''} aria-label="${e.title}, ${clk(e.start)}, ${e.dur} ${w.minutes}${tg ? ', ' + tg : ''}${showWho ? `, ${w.with} ${resName(e.resource)}` : ''}${others ? `, ${w.with} ${others}` : ''}${e.status && w.status[e.status] ? `, ${w.status[e.status]}` : ''}${clash.has(e.id) ? `, ${w.clashOne}` : ''}">${short ? ui.text(`${e.title} · ${said || tg || clk(e.start)}`, 'label') : `${ui.text(e.title, 'label')}${ui.text(`${said ? said + ' · ' : ''}${tg ? tg + ' · ' : ''}${clk(e.start)} · ${e.subtitle || ''}${showWho ? ' · ' + resName(e.resource) : ''}${others ? ' · ' + others : ''}`, 'caption')}`}${e.price && !short ? `<span class="cx-ag-evprice">${fmtMoney(e.price)}</span>` : ''}${dragOn && resizeMode !== 'off' ? '<span class="cx-ag-grip" aria-hidden="true"></span>' : ''}</button>`; };
670
729
  // the hours a person does not work: hatched. With server free hours, everything that is neither free nor booked.
671
- // a day the business does not open: the column hatched, «Cerrado» in the middle (grid) or as the list's only line
730
+ // a day the business does not open: the column hatched, “Closed” in the middle (grid) or as the list's only line
672
731
  const shutPill = () => `<span class="cx-ag-shutpill">${ui.icon('caloff')}${ui.text(w.closed, 'label')}</span>`;
673
732
  const shutHtml = () => `<div class="cx-ag-off cx-ag-shut" style="top:0;height:100%">${shutPill()}</div>`;
674
733
  const offHtml = (d, rid) => {
@@ -678,9 +737,9 @@ let wheelsNow = [];
678
737
  const out = []; let cur = from * 60; av.forEach(([a, b]) => { if (a > cur) out.push([cur, a]); cur = Math.max(cur, b); }); if (cur < to * 60) out.push([cur, to * 60]);
679
738
  return out.map(([a, b]) => `<div class="cx-ag-off" style="top:${y(a)}px;height:${y(b) - y(a)}px"></div>`).join('');
680
739
  };
681
- // the server's free windows, drawn as dashed «free» tiles you tap to book (they replace the bare slot buttons)
740
+ // the server's free windows, drawn as dashed “free” tiles you tap to book (they replace the bare slot buttons)
682
741
  const freeHtml = (d, rid, label) => freeOf(rid, d).map(([a, b]) => `<button class="cx-ag-free" data-free="${a}" data-rid="${rid}" data-day="${+d}" style="top:${y(a)}px;height:${y(b) - y(a) - 2}px" aria-label="${w.freeFromTo(clk(toHM(a)), clk(toHM(b)))}, ${label}">${b - a >= 45 ? ui.text(`${w.free} · ${clk(toHM(a))}`, 'caption') : ''}</button>`).join('');
683
- // PLACING: while the app's «new cita» sheet is open, where the cita will land, drawn dashed (placing {resource, day,
742
+ // PLACING: while the app's “new appointment” sheet is open, where the appointment will land, drawn dashed (placing {resource, day,
684
743
  // start, dur, title, tint}); the app clears it when the sheet closes
685
744
  // what already went by: today up to now, and whole past days, under a quiet veil (nothing to book there)
686
745
  const goneHtml = (d) => { const k = new Date(d); k.setHours(0, 0, 0, 0); if (k > today) return ''; const h = k < today ? (to - from) * hourPx : Math.max(0, Math.min(y(nowMin()), (to - from) * hourPx)); return h ? `<div class="cx-ag-gone" style="height:${h}px" aria-hidden="true"></div>` : ''; };
@@ -688,12 +747,12 @@ let wheelsNow = [];
688
747
  const blockHtml = (b) => `<div class="cx-ag-block" style="top:${y(toMin(b.start))}px;height:${(b.dur / 60) * hourPx - 2}px">${ui.text(`${b.reason || w.blocked} · ${clk(b.start)}`, 'caption')}</div>`;
689
748
  // while loading, everything that would ask the server for something is shut (days, views, arrows, actions, hours)
690
749
  // the toolbar locks only on the very first load: once the agenda has shown data, a skeleton while the next week
691
- // arrives leaves the arrows, the date and the views free (tapping «next» three times in a row must work)
750
+ // arrives leaves the arrows, the date and the views free (tapping “next” three times in a row must work)
692
751
  let shownOnce = false;
693
752
  const shut = () => (loading && !shownOnce ? ' disabled aria-disabled="true"' : '');
694
753
  const title = () => (eff() === 'range' ? `${fmtD(rangeSel[0])} → ${fmtD(rangeSel[1])}` : view === 'week' && !narrow ? w.weekOf(fmtD(weekDays()[0])) : w.dayTitle(w.dayL[day.getDay()], day.getDate(), w.mon[day.getMonth()]));
695
754
  const toolbar = () => `<div class="cx-ag-bar">${ui.button({ label: w.today, size: 'sm', attrs: `data-ag="today"${shut()}` })}<div class="cx-hstack" style="gap:2px;flex-wrap:nowrap">${ui.button({ icon: 'left', variant: 'ghost', size: 'icon-sm', attrs: `data-ag="prev" data-ag-move aria-label="${view === 'week' && !narrow ? w.prevWeek : w.prevDay}"${shut()}` })}${ui.button({ icon: 'right', variant: 'ghost', size: 'icon-sm', attrs: `data-ag="next" data-ag-move aria-label="${view === 'week' && !narrow ? w.nextWeek : w.nextDay}"${shut()}` })}</div><button type="button" class="cx-ag-title" data-ag-date aria-haspopup="dialog" aria-expanded="false" aria-label="${w.goTo}: ${title()}"${shut()}>${ui.text(title(), 'heading')}${ui.icon('chev')}</button><span class="cx-grow"></span><div class="cx-tabs" role="tablist" aria-label="${w.view}">${[['day', w.vDay], ['week', w.vWeek], ['list', w.vList]].filter(([k]) => !narrow || k !== 'week').map(([k, l]) => `<button class="cn-button cn-button-variant-ghost cn-button-size-sm cx-tab" role="tab" aria-selected="${eff() === k}" data-ag-view="${k}"${shut()}>${l}</button>`).join('')}</div>${bar()}</div>`;
696
- // the toolbar's own buttons are data: [{id, label, icon, primary}]; «block» and «new» keep their behaviour, any other id goes to onAction
755
+ // the toolbar's own buttons are data: [{id, label, icon, primary}]; “block” and “new” keep their behaviour, any other id goes to onAction
697
756
  function bar() { const list = Array.isArray(actions) && actions.length ? actions : [{ id: 'block', label: w.block, icon: 'i-lock' }, { id: 'new', label: w.newItem, icon: 'plus', primary: true }]; return list.map((x) => ui.button({ label: `${x.icon ? ui.icon(x.icon) : ''}${x.label}`, variant: x.primary ? 'default' : 'outline', size: 'sm', attrs: `data-ag="${x.id}" data-ag-act${shut()}` })).join(''); }
698
757
  const personBtn = (r) => ui.button({ label: `${r.photo ? `<span class="cn-avatar cx-ag-face" style="background-image:url('${r.photo}')"></span>` : ui.avatar(r.name, { size: 'sm' })}${r.name}`, size: 'sm', variant: 'ghost', attrs: `role="radio" aria-checked="${who === r.id}" data-who="${r.id}"` });
699
758
  const groupName = (k) => (k === 'business' ? w.business : k === 'person' ? w.team : resources.find((r) => kindOf(r) === k)?.group || k);
@@ -708,9 +767,9 @@ let wheelsNow = [];
708
767
  if (kinds.length < 2) return `<div class="cx-hstack cx-ag-who" role="radiogroup" aria-label="${w.whose}"><span class="cx-ag-wholabel" aria-hidden="true">${w.whose}</span>${all}${resources.map(personBtn).join('')}</div>`;
709
768
  return `<div class="cx-hstack cx-ag-who" role="radiogroup" aria-label="${w.whose}"><span class="cx-ag-wholabel" aria-hidden="true">${w.whose}</span>${all}${kinds.map((k) => { const rs = ordered().filter((r) => kindOf(r) === k); return `<span class="cx-ag-sep" aria-hidden="true"></span>${rs.length > 1 ? ui.button({ label: groupName(k), size: 'sm', variant: 'ghost', attrs: `role="radio" aria-checked="${who === 'kind:' + k}" data-who="kind:${k}"`, cls: 'cx-ag-group' }) : ''}${rs.map(personBtn).join('')}`; }).join('')}</div>`;
710
769
  };
711
- // the week strip: each day with how many citas it has (a plain number), closed days dimmed
770
+ // the week strip: each day with how many appointments it has (a plain number), closed days dimmed
712
771
  const strip = () => `<div class="cx-ag-strip" role="group" aria-label="${w.days}">${weekDays().map((d) => { const evs = loading ? [] : evOn(d); const n = evs.length; const shut = isClosed(d); return `<button class="cn-button cn-button-variant-ghost cx-size-tile cx-ag-day" data-ag-day="${+d}" aria-pressed="${sameDay(d, day)}" ${sameDay(d, today) ? 'data-today' : ''} ${shut ? 'data-closed' : ''} aria-label="${w.dayL[d.getDay()]} ${d.getDate()}, ${shut && !n ? w.closed : w.countOf(n)}"${loading && !shownOnce ? ' disabled aria-disabled="true"' : ''}><small>${sameDay(d, today) ? w.todayShort : w.dayS[d.getDay()]}</small><b>${d.getDate()}</b>${phoneStrip === 'dots' ? `<span class="cx-ag-dots" aria-hidden="true">${loading ? '' : evs.slice(0, 4).map((e) => `<i style="background:${e.tint || 'var(--selected, var(--primary))'}"></i>`).join('')}</span>` : `<span class="cx-ag-count" aria-hidden="true">${loading ? '<s class="skel"></s>' : n || ''}</span>`}</button>`; }).join('')}</div>`;
713
- // «Ahora»: on today, the cita in progress (started, or whose time is now), first thing, one tap to open it
772
+ // “Ahora”: on today, the appointment in progress (started, or whose time is now), first thing, one tap to open it
714
773
  const nowCard = () => {
715
774
  if (!sameDay(day, today)) return '';
716
775
  const nowM = nowMin();
@@ -725,15 +784,19 @@ let wheelsNow = [];
725
784
  const skHead = (c) => { const r = resources.find((x) => x.id === c.rid); return v0() === 'week' ? `<span class="cx-ag-skhead" aria-hidden="true"><s class="skel" style="width:56px"></s></span>` : `<span class="cx-ag-skhead" aria-hidden="true"><s class="skel" style="width:24px;height:24px;border-radius:50%"></s><span class="cx-ag-colname"><b><s class="skel cx-ag-skline" style="width:64px"></s></b>${r && roleOf(r) ? `<small><s class="skel cx-ag-skline" style="width:48px"></s></small>` : ''}</span></span>`; };
726
785
  const v0 = eff;
727
786
  // the line at the current time, in today's column (closed or not: the time still passes), its dot in the first one
728
- const nowLine = (c, ci, cols) => { const nowM = nowMin(); return sameDay(c.day, today) && nowM > from * 60 && nowM < to * 60 ? `<div class="cx-ag-now" style="top:${y(nowM)}px" aria-hidden="true">${cols.findIndex((k) => sameDay(k.day, today)) === ci ? '<i></i>' : ''}</div>` : ''; };
729
- const grid = (cols) => { const hours = Array.from({ length: to - from }, (_, i) => from + i); const nowM = nowMin(); return `<div class="cx-ag-scroll cx-scroll"><div class="cx-ag" style="--cols:${cols.length};--h:${hourPx}px;grid-template-rows:auto auto"><div class="cx-ag-head"><span></span>${cols.map((c) => `<div class="cx-ag-colhead" ${c.today ? 'data-today' : ''} ${c.closed ? 'data-closed' : ''}>${loading ? skHead(c) : c.head}</div>`).join('')}</div><div class="cx-ag-body" style="height:${(to - from) * hourPx}px"><div class="cx-ag-times">${cols.some((k) => sameDay(k.day, today)) && nowM > from * 60 && nowM < to * 60 ? `<b class="cx-ag-nowtime" style="top:${y(nowM)}px">${clk(toHM(nowM))}</b>` : ''}${hours.map((h) => `<span style="top:${(h - from) * hourPx}px">${ui.text(hour12 ? `${((h + 11) % 12) + 1} ${h < 12 ? 'AM' : 'PM'}` : `${String(h).padStart(2, '0')}:00`, 'caption')}</span>`).join('')}</div>${cols.map((c, ci) => { const rid = c.rid === 'all' ? null : c.rid; const evs = evOn(c.day, rid); const cap = rid ? capOf(rid) : 1; const ln = lanes(evs); if (cap > 1) ln.forEach((v) => { v[1] = Math.max(v[1], Math.min(cap, 4)); }); const rids = rid ? [rid] : shown().map((r) => r.id); if (loading) return `<div class="cx-ag-col" data-day="${+c.day}" data-rid="${c.rid}" aria-hidden="true">${rid && !freeList ? offHtml(c.day, rid) : ''}${skelCol(ci)}</div>`;
787
+ // NOW, across the whole week when today is in it (as every calendar draws it), one unbroken line from the hour's pill:
788
+ // the days already gone show it faint, the days ahead whole; it rides over everything in the grid (appointments, blocks).
789
+ // The dot marks where today's column starts, so day and week begin the same (pill, tail, line): in day view, or
790
+ // when today is the week's first column, the pill already stands there and no dot is drawn. Nothing while loading.
791
+ const nowLine = (c, ci, cols) => { const nowM = nowMin(); const at = cols.findIndex((k) => sameDay(k.day, today)); if (at < 0 || nowM <= from * 60 || nowM >= to * 60) return ''; const mine = sameDay(c.day, today); const gone = !mine && c.day < today; return `<div class="cx-ag-now${mine ? '' : gone ? ' cx-ag-now-past' : ' cx-ag-now-ahead'}" style="top:${y(nowM)}px" aria-hidden="true">${mine && at === ci && ci > 0 ? '<i></i>' : ''}</div>`; };
792
+ const grid = (cols) => { const hours = Array.from({ length: to - from }, (_, i) => from + i); const nowM = nowMin(); return `<div class="cx-ag-scroll cx-scroll"><div class="cx-ag" style="--cols:${cols.length};--h:${hourPx}px;grid-template-rows:auto auto"><div class="cx-ag-head"><span></span>${cols.map((c) => `<div class="cx-ag-colhead" ${c.today ? 'data-today' : ''} ${c.closed ? 'data-closed' : ''}>${loading ? skHead(c) : c.head}</div>`).join('')}</div><div class="cx-ag-body" style="height:${(to - from) * hourPx}px"><div class="cx-ag-times">${!loading && cols.some((k) => sameDay(k.day, today)) && nowM > from * 60 && nowM < to * 60 ? `<b class="cx-ag-nowtime" style="top:${y(nowM)}px">${clk(toHM(nowM))}</b>` : ''}${hours.map((h) => `<span style="top:${(h - from) * hourPx}px">${ui.text(hour12 ? `${((h + 11) % 12) + 1} ${h < 12 ? 'AM' : 'PM'}` : `${String(h).padStart(2, '0')}:00`, 'caption')}</span>`).join('')}</div>${cols.map((c, ci) => { const rid = c.rid === 'all' ? null : c.rid; const evs = evOn(c.day, rid); const cap = rid ? capOf(rid) : 1; const ln = lanes(evs); if (cap > 1) ln.forEach((v) => { v[1] = Math.max(v[1], Math.min(cap, 4)); }); const rids = rid ? [rid] : shown().map((r) => r.id); if (loading) return `<div class="cx-ag-col" data-day="${+c.day}" data-rid="${c.rid}" aria-hidden="true">${rid && !freeList ? offHtml(c.day, rid) : ''}${skelCol(ci)}</div>`;
730
793
  const offRid = rid || (shown().length === 1 && !freeList ? shown()[0].id : null);
731
794
  if (c.closed && !evs.length) return `<div class="cx-ag-col" data-day="${+c.day}" data-rid="${c.rid}" data-closed role="group" aria-label="${c.label}, ${w.closed}">${shutHtml()}${nowLine(c, ci, cols)}</div>`;
732
795
  return `<div class="cx-ag-col" data-day="${+c.day}" data-rid="${c.rid}" role="group" aria-label="${c.label}">${offRid ? offHtml(c.day, offRid) : ''}${!rids.some(bookable) ? '' : freeList ? rids.filter(bookable).map((r) => freeHtml(c.day, r, c.label)).join('') : Array.from({ length: ((to - from) * 60) / slot }, (_, i) => gone(c.day, from * 60 + i * slot) ? '' : `<button class="cx-ag-slot" data-slot-time="${from * 60 + i * slot}" style="top:${y(from * 60 + i * slot)}px;height:${(slot / 60) * hourPx}px" aria-label="${w.newItem}, ${c.label}, ${toHM(from * 60 + i * slot)}" tabindex="-1"></button>`).join('')}${blOn(c.day, rid).map(blockHtml).join('')}${evs.map((e) => evHtml(e, ln, c.rid === 'all' && resources.length > 1, rid, cap === 0)).join('')}${ghostHtml(c.day, rid)}${goneHtml(c.day)}${nowLine(c, ci, cols)}</div>`; }).join('')}</div></div></div>`; };
733
796
  // list = the phone's agenda: a solid row is sold, a dashed one is still free
734
- // one cita as a row of the list (the day's list and the range's)
797
+ // one appointment as a row of the list (the day's list and the range's)
735
798
  const evRow = (e) => `<button class="cx-ag-row" data-ev="${e.id}"${clash.has(e.id) ? ' data-conflict' : ''} data-tone="${toneOf(e)}" ${e.status ? `data-status="${e.status}"` : ''}${paint1(e)}"><span class="cx-ag-row-t">${ui.text(clk(e.start), 'strong')}${ui.text(`${e.dur} min`, 'caption')}</span>${e.photo ? `<span class="cn-avatar cx-ag-face cx-ag-rowface" style="background-image:url('${e.photo}')" aria-hidden="true"></span>` : ''}<span class="cx-vstack" style="gap:0;min-width:0">${ui.text(e.title, 'heading')}${ui.text(`${e.subtitle || ''}${resources.length > 1 ? ' · ' + resName(e.resource) : ''}${tagsOf(e) ? ' · ' + tagsOf(e) : ''}`, 'muted')}</span>${e.status && w.status[e.status] ? ui.badge(w.status[e.status], { tone: { info: 'info', live: 'wait', done: 'ok', bad: 'bad', ok: 'ok' }[toneOf(e)] || 'ok' }) : e.steps ? ui.badge(e.steps[e.at], { tone: e.tone === 'wait' ? 'wait' : e.tone === 'info' ? 'info' : 'ok' }) : ''}${e.price ? ui.text(fmtMoney(e.price), 'strong') : ''}</button>`;
736
- // RANGE: the days picked in the calendar, one after another, each with its citas (a day with none says so)
799
+ // RANGE: the days picked in the calendar, one after another, each with its appointments (a day with none says so)
737
800
  const rangeList = () => { const [a, b] = rangeSel; const n = Math.min(62, Math.round((b - a) / 864e5) + 1); return `<div class="cx-ag-list cx-ag-range" role="list">${Array.from({ length: n }, (_, k) => { const d = addDays(a, k); const evs = evOn(d).sort((x, z) => toMin(x.start) - toMin(z.start)); return `<button type="button" class="cx-ag-rangeday" data-ag-day="${+d}" ${sameDay(d, today) ? 'data-today' : ''}>${ui.text(`${w.dayL[d.getDay()]} ${d.getDate()} ${w.mon[d.getMonth()]}`, 'strong')}${ui.text(isClosed(d) && !evs.length ? w.closed : evs.length ? w.countOf(evs.length) : w.noItems, 'caption')}</button>${evs.map(evRow).join('')}`; }).join('')}</div>`; };
738
801
  const list = () => {
739
802
  const rids = shown().map((r) => r.id), rows = [];
@@ -754,11 +817,11 @@ let wheelsNow = [];
754
817
  return `<div class="cx-ag-list" role="list">${html || (isClosed(day) ? `<div class="cx-ag-shutlist">${shutPill()}</div>` : '') || `<div class="cn-empty" style="padding:32px 12px"><div class="cn-empty-header"><div class="cn-empty-title">${!rids.some((rid) => availability(rid, day).length) ? w.noHours : +day < +today && !sameDay(day, today) ? w.dayOver : sameDay(day, today) ? w.todayOver : w.noHours}</div></div></div>`}</div>`;
755
818
  };
756
819
  // LOADING keeps the very layout it is about to show: the same toolbar, strip, column heads, hours and height,
757
- // with grey citas where the real ones will land. Nothing jumps when the data arrives, nothing shrinks while it loads.
820
+ // with grey appointments where the real ones will land. Nothing jumps when the data arrives, nothing shrinks while it loads.
758
821
  const SKEL = [[0.5, 1], [2, 1.5], [4.5, 1], [6, 2], [8, 1]];
759
822
  const skelCol = (ci) => SKEL.filter(([h], i) => (i + ci) % 3 !== 2 && h < to - from).map(([h, len]) => `<span class="cx-ag-skev skel" style="top:${h * hourPx + 2}px;height:${Math.min(len, to - from - h) * hourPx - 4}px"></span>`).join('');
760
823
  const skelList = () => `<div class="cx-ag-list" aria-hidden="true">${Array.from({ length: Math.max(6, Math.round((to - from) * 60 / slot / 2)) }, (_, i) => `<div class="cx-ag-row cx-ag-skrow"><span class="cx-ag-row-t"><s class="skel" style="width:38px"></s><s class="skel" style="width:28px"></s></span><span class="cx-vstack" style="gap:6px;flex:1"><s class="skel" style="width:${[46, 38, 54, 42][i % 4]}%"></s><s class="skel" style="width:${[30, 26, 34, 22][i % 4]}%"></s></span></div>`).join('')}</div>`;
761
- // the top band keeps what it had (the «Ahora» card, the queue) as grey shapes, so its height holds too
824
+ // the top band keeps what it had (the “Ahora” card, the queue) as grey shapes, so its height holds too
762
825
  let hadNow = false, hadQueue = false;
763
826
  const skelTop = () => `${hadNow && sameDay(day, today) ? '<div class="cx-ag-nowcard cx-ag-sknow" aria-hidden="true"><span class="cx-vstack" style="gap:0;flex:1">' + [[90, 'caption'], [160, 'heading'], [120, 'muted']].map(([wd, v]) => ui.text(`<s class="skel cx-ag-skline" style="width:${wd}px"></s>`, v)).join('') + '</span></div>' : ''}${hadQueue ? '<div class="cx-ag-queue" aria-hidden="true"><s class="skel" style="width:24px;height:24px;border-radius:50%"></s><s class="skel" style="width:24px;height:24px;border-radius:50%;margin-left:4px"></s><s class="skel" style="width:70px;margin-left:8px"></s></div>' : ''}`;
764
827
  // the days shown, so an app can load exactly those (it is told once per change)
@@ -777,7 +840,7 @@ let wheelsNow = [];
777
840
  else return onAction(k);
778
841
  paint();
779
842
  }
780
- // a week column says its day and, under it, how many citas it holds and who waits for a free hour (closed: «Cerrado»)
843
+ // a week column says its day and, under it, how many appointments it holds and who waits for a free hour (closed: “Closed”)
781
844
  const weekHead = (d, rid) => { const n = evOn(d, rid).length, q = queue.filter((x) => sameDay(x.day, d)).length, shut = isClosed(d); const sub = [shut && !n ? w.closed : n ? w.countOf(n) : '', q ? w.waiting(q) : ''].filter(Boolean).join(' · '); return `<span class="cx-ag-colday">${w.dayS[d.getDay()]} ${d.getDate()}</span>${sub ? `<small class="cx-ag-colsub"${q ? ' data-queue' : ''}>${sub}</small>` : ''}`; };
782
845
  const tellRange = () => { const v = eff(); const a = v === 'range' ? rangeSel[0] : v === 'week' ? weekDays()[0] : day, b = v === 'range' ? rangeSel[1] : v === 'week' ? weekDays()[6] : day; const key = `${v}|${+a}|${+b}`; if (key === lastRange) return; lastRange = key; onRange({ from: a, to: b, view: v }); };
783
846
  const paint = () => {
@@ -799,7 +862,7 @@ let wheelsNow = [];
799
862
  };
800
863
  // drag to move · drag the bottom edge to resize (15 min). A ghost shows where it lands and turns red where the
801
864
  // time is not free; the real event never moves until a valid drop. Mouse starts after 6 px, touch after a long press.
802
- // With server free hours, «free» means inside one of them or inside the cita's own time.
865
+ // With server free hours, “free” means inside one of them or inside the appointment's own time.
803
866
  // the past and the closed days are never free, whatever the hours say
804
867
  const gone = (d, st) => { const k = new Date(d); k.setHours(0, 0, 0, 0); return k < today || (sameDay(k, today) && st < nowMin()); };
805
868
  const fits = (rid, d, st, dur, except) => {
@@ -820,7 +883,7 @@ let wheelsNow = [];
820
883
  if (d.resize) { const top = d.ev.getBoundingClientRect().top; d.dur = Math.max(15, Math.min(to * 60 - toMin(d.it.start), Math.round(((cy - top) / hourPx) * 60 / 15) * 15)); d.target = { day: d.it.day, resource: d.it.resource, start: d.it.start }; }
821
884
  else {
822
885
  let col = document.elementsFromPoint(cx, cy).find((x) => x.classList?.contains('cx-ag-col')); if (!col) return;
823
- // moveAcross "off": over another person's column the cita stays in its own (only its time changes)
886
+ // moveAcross "off": over another person's column the appointment stays in its own (only its time changes)
824
887
  if (moveAcross === 'off' && col.dataset.rid !== 'all' && col.dataset.rid !== d.it.resource) col = root.querySelector(`.cx-ag-col[data-rid="${d.it.resource}"][data-day="${col.dataset.day}"]`) || col;
825
888
  const cr = col.getBoundingClientRect(), m = Math.round((((cy - d.dy - cr.top) / hourPx) * 60 + from * 60) / 15) * 15;
826
889
  d.target = { day: new Date(+col.dataset.day), resource: col.dataset.rid === 'all' || moveAcross === 'off' ? d.it.resource : col.dataset.rid, start: toHM(Math.max(from * 60, Math.min(to * 60 - d.it.dur, m))) };
@@ -849,7 +912,7 @@ let wheelsNow = [];
849
912
  if (commit && !d.ok && changed) island('error', w.notFreeLong, 2000);
850
913
  // back to where it was: the ghost flies home, then goes
851
914
  const home = d.ev.getBoundingClientRect(), g = d.ghost.getBoundingClientRect();
852
- d.ghost.animate([{ transform: 'none', opacity: 1 }, { transform: `translate(${home.left - g.left}px, ${home.top - g.top}px)`, opacity: 0.4 }], { duration: 220, easing: 'cubic-bezier(.22,1,.36,1)', fill: 'forwards' }).finished.then(() => d.ghost.remove());
915
+ d.ghost.animate([{ transform: 'none', opacity: 1 }, { transform: `translate(${home.left - g.left}px, ${home.top - g.top}px)`, opacity: 0.4 }], { duration: 220, easing: ease('--ease-out'), fill: 'forwards' }).finished.then(() => d.ghost.remove());
853
916
  };
854
917
  root.addEventListener('pointerdown', (e) => {
855
918
  if (!dragOn || loading) return;
@@ -927,7 +990,7 @@ let wheelsNow = [];
927
990
  fitKey = ''; fitSeen.clear(); paint(); })).observe(root);
928
991
 
929
992
  // ── the event sheet (showcase): detail · lifecycle · payment · move (second step) · cancel (second step).
930
- // In app mode a tapped cita goes to onOpen and the app opens its own sheet. ──
993
+ // In app mode a tapped appointment goes to onOpen and the app opens its own sheet. ──
931
994
  let open1 = null, step = 'detail', moveTo = null;
932
995
  const openEvent = (ev) => { if (!ev) return; if (appMode && onOpen) return onOpen(ev); open1 = ev; step = 'detail'; moveTo = null; sheetOf('agEvent'); };
933
996
  const sheet = () => {
@@ -1006,8 +1069,8 @@ let wheelsNow = [];
1006
1069
  // ── block time: the form is the agenda's; the block itself is the app's when it listens (onBlock), else local ──
1007
1070
  let blockOpen = false;
1008
1071
  // THE TITLE OPENS A MONTH, floating under it (on a phone, the sheet from below): one day, or a range of days
1009
- // («Varios días»: the citas of each day one after another), and the month's title opens a year of months.
1010
- // «Ver cuáles»: each press scrolls to the next crossed cita and marks it for a moment
1072
+ // (“Several days”: the appointments of each day one after another), and the month's title opens a year of months.
1073
+ // “See which”: each press scrolls to the next overlapping appointment and marks it for a moment
1011
1074
  let clashAt = 0;
1012
1075
  const seeClash = () => { const els = $$('.cx-ag-ev[data-conflict], .cx-ag-row[data-conflict]', root); if (!els.length) return; const el = els[clashAt++ % els.length]; const sc = $('.cx-ag-scroll', root);
1013
1076
  if (sc && sc.contains(el)) { const a = sc.getBoundingClientRect(), b = el.getBoundingClientRect(); sc.scrollTo({ top: sc.scrollTop + b.top - a.top - 60, left: sc.scrollLeft + b.left - a.left - 60, behavior: 'smooth' }); } else el.scrollIntoView({ block: 'center', behavior: 'smooth' });
@@ -1110,10 +1173,10 @@ let wheelsNow = [];
1110
1173
  const TICK = { sending: 'clock', sent: 'i-tick', delivered: 'i-ticks', read: 'i-ticks' };
1111
1174
  kit.bubble = (m) => {
1112
1175
  if (m.day) return `<div class="cx-chat-day" role="separator">${ui.text(m.day, 'caption')}</div>`;
1113
- const body = m.image ? `<span class="cx-chat-img" style="background:${m.image}" role="img" aria-label="Foto"></span>${m.text ? `<p>${m.text}</p>` : ''}`
1114
- : m.audio ? `<span class="cx-chat-audio">${ui.button({ icon: 'play', variant: 'ghost', size: 'icon-sm', cls: 'cx-audio-btn', attrs: `data-audio="${m.audio}" aria-label="Reproducir audio de ${m.audio} s"` })}${ui.progress(0, 'Audio')}${ui.text(`0:${String(m.audio).padStart(2, '0')}`, 'caption')}</span>`
1176
+ const body = m.image ? `<span class="cx-chat-img" style="background:${m.image}" role="img" aria-label="${T0('Photo')}"></span>${m.text ? `<p>${m.text}</p>` : ''}`
1177
+ : m.audio ? `<span class="cx-chat-audio">${ui.button({ icon: 'play', variant: 'ghost', size: 'icon-sm', cls: 'cx-audio-btn', attrs: `data-audio="${m.audio}" aria-label="${T0('Play {0} s of audio', m.audio)}"` })}${ui.progress(0, T0('Audio'))}${ui.text(`0:${String(m.audio).padStart(2, '0')}`, 'caption')}</span>`
1115
1178
  : m.card ? `<span class="cx-chat-card">${m.card}</span>` : `<p>${m.text}</p>`;
1116
- return `<div class="cx-chat-msg" data-who="${m.out ? 'out' : 'in'}" ${m.id ? `data-mid="${m.id}"` : ''}><div class="cx-chat-bubble">${body}<span class="cx-chat-meta">${m.tag ? `<span class="cx-chat-tag">${m.tagIcon ? ui.icon(m.tagIcon) : ''}${m.tag}</span>` : ''}${ui.text(m.time || '', 'caption')}${m.out && m.status ? `<svg class="cx-tick" data-status="${m.status}" aria-label="${{ sending: 'Enviando', sent: 'Enviado', delivered: 'Entregado', read: 'Leído' }[m.status]}"><use href="#${TICK[m.status]}"/></svg>` : ''}</span></div>${m.actions ? `<div class="cx-chat-actions">${m.actions}</div>` : ''}</div>`;
1179
+ return `<div class="cx-chat-msg" data-who="${m.out ? 'out' : 'in'}" ${m.id ? `data-mid="${m.id}"` : ''}><div class="cx-chat-bubble">${body}<span class="cx-chat-meta">${m.tag ? `<span class="cx-chat-tag">${m.tagIcon ? ui.icon(m.tagIcon) : ''}${m.tag}</span>` : ''}${ui.text(m.time || '', 'caption')}${m.out && m.status ? `<svg class="cx-tick" data-status="${m.status}" aria-label="${T0({ sending: 'Sending', sent: 'Sent', delivered: 'Delivered', read: 'Read' }[m.status])}"><use href="#${TICK[m.status]}"/></svg>` : ''}</span></div>${m.actions ? `<div class="cx-chat-actions">${m.actions}</div>` : ''}</div>`;
1117
1180
  };
1118
1181
  // audio bubbles play (a demo clock drives the Progress atom)
1119
1182
  document.addEventListener('click', (e) => {
@@ -1123,7 +1186,7 @@ let wheelsNow = [];
1123
1186
  let t = b._pos || 0; b.innerHTML = ui.icon('pause');
1124
1187
  b._t = setInterval(() => { t += 0.25; b._pos = t; bar.style.transform = `translateX(-${100 - (t / secs) * 100}%)`; label.textContent = `0:${String(Math.floor(t)).padStart(2, '0')}`; if (t >= secs) { clearInterval(b._t); b._t = null; b._pos = 0; b.innerHTML = ui.icon('play'); } }, 250);
1125
1188
  });
1126
- const typing = () => `<div class="cx-chat-msg" data-who="in" data-typing><div class="cx-chat-bubble cx-typing" aria-label="Escribiendo"><i></i><i></i><i></i></div></div>`;
1189
+ const typing = () => `<div class="cx-chat-msg" data-who="in" data-typing><div class="cx-chat-bubble cx-typing" aria-label="${T0('Typing')}"><i></i><i></i><i></i></div></div>`;
1127
1190
 
1128
1191
  // ── More atoms: Card, Alert and the InputGroup button, so nothing below is drawn by hand ──
1129
1192
  ui.card = ({ title = '', description = '', action = '', content = '', footer = '', size = 'sm', cls = '', attrs = '' }) =>
@@ -1134,7 +1197,7 @@ let wheelsNow = [];
1134
1197
  `<button type="${type}" data-slot="button" data-size="${size}" class="cn-button cn-button-variant-${variant} cn-input-group-button cn-input-group-button-size-${size} group/button" ${attrs}>${icon ? ui.icon(icon) : ''}${label}</button>`;
1135
1198
 
1136
1199
  // ── Action menu: the library's dropdown for verbs (archive, delete…), items [{id, label, icon, danger}] ──
1137
- kit.actionMenu = (trigger, { items, label = 'Opciones', onPick }) => {
1200
+ kit.actionMenu = (trigger, { items, label = T0('Options'), onPick }) => {
1138
1201
  const menu = document.createElement('div'); menu.className = 'cn-dropdown-menu-content pg-pop cx-selmenu'; menu.setAttribute('role', 'menu'); menu.setAttribute('aria-label', label); menu.hidden = true; lib.appendChild(menu);
1139
1202
  trigger.setAttribute('aria-haspopup', 'menu'); trigger.setAttribute('aria-expanded', 'false');
1140
1203
  const paint = () => { menu.innerHTML = (typeof items === 'function' ? items() : items).map((it) => `<div class="cn-dropdown-menu-item cx-selitem" role="menuitem" tabindex="-1" data-mi-id="${it.id}" ${it.danger ? 'data-variant="destructive"' : ''}><span class="cx-hstack" style="gap:8px">${ui.icon(it.icon)}${it.label}</span></div>`).join(''); };
@@ -1148,8 +1211,8 @@ let wheelsNow = [];
1148
1211
 
1149
1212
  // ── Composer: shadcn's InputGroup with a Textarea and a block-end addon for its buttons ──
1150
1213
  const EMOJI = ['😀', '😂', '😊', '😍', '🙏', '👍', '👌', '🙌', '🎉', '❤️', '🔥', '✨', '📅', '⏰', '📍', '💳', '✅', '👋', '🤝', '😉'];
1151
- kit.composer = ({ placeholder = 'Escribe un mensaje', attach = true, emoji = true, templates = [], hint = '' } = {}) =>
1152
- `<form class="cx-composer" data-composer><div data-slot="input-group" role="group" class="cn-input-group group/input-group cx-composer-box"><textarea data-slot="input-group-control" class="cn-textarea cn-input-group-textarea cx-composer-in" rows="1" placeholder="${placeholder}" aria-label="${placeholder}"></textarea><div data-slot="input-group-addon" data-align="block-end" role="group" class="cn-input-group-addon cn-input-group-addon-align-block-end">${emoji ? ui.groupButton({ icon: 'smile', attrs: 'data-emoji aria-haspopup="dialog" aria-label="Emoji" title="Emoji"' }) : ''}${attach ? ui.groupButton({ icon: 'i-clip', attrs: 'data-attach aria-label="Adjuntar" title="Adjuntar"' }) : ''}${templates.length ? ui.groupButton({ icon: 'i-doc', attrs: 'data-templates aria-label="Plantillas" title="Plantillas"' }) : ''}<span class="cx-grow"></span>${ui.groupButton({ icon: 'i-send', variant: 'default', type: 'submit', attrs: 'data-send aria-label="Enviar" title="Enviar" disabled' })}</div></div>${hint ? `<p class="cx-composer-hint">${ui.text(hint, 'caption')}</p>` : ''}</form>`;
1214
+ kit.composer = ({ placeholder = T0('Write a message'), attach = true, emoji = true, templates = [], hint = '' } = {}) =>
1215
+ `<form class="cx-composer" data-composer><div data-slot="input-group" role="group" class="cn-input-group group/input-group cx-composer-box"><textarea data-slot="input-group-control" class="cn-textarea cn-input-group-textarea cx-composer-in" rows="1" placeholder="${placeholder}" aria-label="${placeholder}"></textarea><div data-slot="input-group-addon" data-align="block-end" role="group" class="cn-input-group-addon cn-input-group-addon-align-block-end">${emoji ? ui.groupButton({ icon: 'smile', attrs: 'data-emoji aria-haspopup="dialog" aria-label="Emoji" title="Emoji"' }) : ''}${attach ? ui.groupButton({ icon: 'i-clip', attrs: 'data-attach aria-label="Adjuntar" title="Adjuntar"' }) : ''}${templates.length ? ui.groupButton({ icon: 'i-doc', attrs: 'data-templates aria-label="Plantillas" title="Plantillas"' }) : ''}<span class="cx-grow"></span>${ui.groupButton({ icon: 'i-send', variant: 'default', type: 'submit', attrs: `data-send aria-label="${T0('Send')}" title="${T0('Send')}" disabled` })}</div></div>${hint ? `<p class="cx-composer-hint">${ui.text(hint, 'caption')}</p>` : ''}</form>`;
1153
1216
  const autosize = (ta) => { ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, 160) + 'px'; };
1154
1217
  const wireComposer = (root, onSend, templates = []) => {
1155
1218
  const form = $('[data-composer]', root), ta = $('textarea', form), send = $('[data-send]', form);
@@ -1159,10 +1222,10 @@ let wheelsNow = [];
1159
1222
  ta.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); form.requestSubmit(); } });
1160
1223
  form.addEventListener('submit', (e) => { e.preventDefault(); if ('stop' in send.dataset) return; const t = ta.value.trim(); if (!t) return; ta.value = ''; sync(); onSend(t); });
1161
1224
  const tb = $('[data-templates]', form);
1162
- if (tb) kit.actionMenu(tb, { label: 'Plantillas', items: templates.map((t, i) => ({ id: String(i), label: t[0], icon: 'i-doc' })), onPick: (i) => { ta.value = templates[+i][1]; sync(); ta.focus(); } });
1225
+ if (tb) kit.actionMenu(tb, { label: T0('Templates'), items: templates.map((t, i) => ({ id: String(i), label: t[0], icon: 'i-doc' })), onPick: (i) => { ta.value = templates[+i][1]; sync(); ta.focus(); } });
1163
1226
  const eb = $('[data-emoji]', form);
1164
1227
  if (eb) {
1165
- const pop = document.createElement('div'); pop.className = 'cn-popover-content pg-pop cx-emoji'; pop.setAttribute('role', 'dialog'); pop.setAttribute('aria-label', 'Emoji'); pop.hidden = true; lib.appendChild(pop);
1228
+ const pop = document.createElement('div'); pop.className = 'cn-popover-content pg-pop cx-emoji'; pop.setAttribute('role', 'dialog'); pop.setAttribute('aria-label', T0('Emoji')); pop.hidden = true; lib.appendChild(pop);
1166
1229
  pop.innerHTML = EMOJI.map((x) => ui.button({ label: x, variant: 'ghost', size: 'icon', attrs: `data-emo="${x}" aria-label="${x}"` })).join('');
1167
1230
  eb.addEventListener('click', () => { if (pop.dataset.state === 'open') return hide(pop, 120); pop.hidden = false; place(pop, eb, 'start', 8); show(pop); });
1168
1231
  pop.addEventListener('click', (e) => { const b = e.target.closest('[data-emo]'); if (b) insert(b.dataset.emo); });
@@ -1176,14 +1239,15 @@ let wheelsNow = [];
1176
1239
  // and the person panel are options; a conversation is { id, name, channel, handle, state, control, unread, last,
1177
1240
  // lastBy, time, window, archived, messages }. A message can be text/image/audio/card (kit.bubble), a day, a
1178
1241
  // system line { system, icon } or an event card { event: { icon, title, subtitle, rows, total, state, actions } }.
1179
- kit.messaging = (root, { title = 'Mensajes', channels, conversations, filters, states = {}, control = {}, quick = [], profile = () => null, templates = [], reply = () => null, hint = 'Enter envía · Shift+Enter hace un salto de línea' }) => {
1242
+ kit.messaging = (root, { title = T0('Messages'), channels, conversations, filters, states = {}, control = {}, quick = [], profile = () => null, templates = [], reply = () => null, hint = T0('Enter sends · Shift+Enter adds a line break') }) => {
1243
+ const T = (k, ...a) => tx(root, k, ...a);
1180
1244
  let cur = conversations[0].id, filter = filters[0].id, q = '', archived = false, armed = null;
1181
1245
  const conv = () => conversations.find((c) => c.id === cur);
1182
1246
  const inView = (c) => !!c.archived === archived && (!q || fold(`${c.name} ${c.last}`).includes(fold(q)));
1183
1247
  const visible = () => conversations.filter((c) => inView(c) && filters.find((f) => f.id === filter).match(c));
1184
1248
  const chMark = (c) => `<span class="cx-ch" style="--brand:${channels[c.channel].color}" title="${channels[c.channel].name}">${ui.icon(channels[c.channel].icon)}</span>`;
1185
1249
  const stateBadge = (c) => (states[c.state] ? ui.badge(states[c.state].label, { tone: states[c.state].tone }) : '');
1186
- const animIn = (el) => el.animate([{ opacity: 0, transform: 'translateY(8px)' }, { opacity: 1, transform: 'none' }], { duration: 200, easing: 'cubic-bezier(.22,1,.36,1)' });
1250
+ const animIn = (el) => el.animate([{ opacity: 0, transform: 'translateY(8px)' }, { opacity: 1, transform: 'none' }], { duration: 200, easing: ease('--ease-out') });
1187
1251
  const clock = () => { const n = new Date(); return `${String(n.getHours()).padStart(2, '0')}:${String(n.getMinutes()).padStart(2, '0')}`; };
1188
1252
 
1189
1253
  // ── list ──
@@ -1191,7 +1255,7 @@ let wheelsNow = [];
1191
1255
  const paintChips = () => { $('.cx-mx-chips', root).innerHTML = filters.map((f) => ui.button({ label: `${f.label}<span class="cx-mx-n">${conversations.filter((c) => inView(c) && f.match(c)).length}</span>`, size: 'xs', attrs: `data-mx-f="${f.id}" aria-pressed="${f.id === filter}"` })).join(''); };
1192
1256
  const paintList = () => {
1193
1257
  const items = visible();
1194
- $('.cx-mx-items', root).innerHTML = items.map(row).join('') || `<div class="cn-empty cx-mx-empty"><div class="cn-empty-header"><div class="cn-empty-media cn-empty-media-icon">${ui.icon(archived ? 'i-archive' : 'i-inbox')}</div><div class="cn-empty-title">${archived ? 'Nada archivado' : 'Nada por aquí'}</div><div class="cn-empty-description">${q ? 'Prueba con otro nombre.' : archived ? 'Lo que archives queda aquí.' : 'Cuando te escriban, aparece aquí.'}</div></div></div>`;
1258
+ $('.cx-mx-items', root).innerHTML = items.map(row).join('') || `<div class="cn-empty cx-mx-empty"><div class="cn-empty-header"><div class="cn-empty-media cn-empty-media-icon">${ui.icon(archived ? 'i-archive' : 'i-inbox')}</div><div class="cn-empty-title">${archived ? T('Nothing archived') : T('Nothing here')}</div><div class="cn-empty-description">${q ? T('Try another name.') : archived ? T('What you archive stays here.') : T('When they write to you, it shows up here.')}</div></div></div>`;
1195
1259
  $('.cx-mx-count', root).textContent = `${conversations.filter((c) => !!c.archived === archived).length} ${archived ? 'archivadas' : 'conversaciones'}`;
1196
1260
  $('[data-mx-arch]', root).innerHTML = archived ? `${ui.icon('i-inbox')}Bandeja` : `${ui.icon('i-archive')}Archivadas`;
1197
1261
  paintChips();
@@ -1209,22 +1273,22 @@ let wheelsNow = [];
1209
1273
  const paintQuick = () => {
1210
1274
  const slot = $('.cx-mx-quick', root), c = conv();
1211
1275
  easeHeight(slot, () => {
1212
- slot.innerHTML = armed ? ui.alert({ icon: armed.icon, title: `Se le envía ${armed.noun} a ${c.name.split(' ')[0]} por ${channels[c.channel].name}.`, action: `<span class="cx-hstack" style="gap:6px">${ui.button({ label: 'Cancelar', variant: 'ghost', size: 'xs', attrs: 'data-mx-q-no' })}${ui.button({ label: 'Enviar', variant: 'default', size: 'xs', attrs: 'data-mx-q-yes' })}</span>`, attrs: 'data-mx-confirm' })
1213
- : `<div class="cx-mx-qrow cx-noscrollbar" role="group" aria-label="Acciones rápidas">${quick.map((x) => ui.button({ label: `${ui.icon(x.icon)}${x.label}`, size: 'xs', attrs: `data-mx-q="${x.id}"` })).join('')}</div>`;
1276
+ slot.innerHTML = armed ? ui.alert({ icon: armed.icon, title: T('{0} goes to {1} on {2}.', armed.noun, c.name.split(' ')[0], channels[c.channel].name), action: `<span class="cx-hstack" style="gap:6px">${ui.button({ label: T('Cancel'), variant: 'ghost', size: 'xs', attrs: 'data-mx-q-no' })}${ui.button({ label: T('Send'), variant: 'default', size: 'xs', attrs: 'data-mx-q-yes' })}</span>`, attrs: 'data-mx-confirm' })
1277
+ : `<div class="cx-mx-qrow cx-noscrollbar" role="group" aria-label="${T('Quick actions')}">${quick.map((x) => ui.button({ label: `${ui.icon(x.icon)}${x.label}`, size: 'xs', attrs: `data-mx-q="${x.id}"` })).join('')}</div>`;
1214
1278
  });
1215
1279
  };
1216
1280
  const paintThread = () => {
1217
1281
  const c = conv(), th = $('.cx-mx-thread', root);
1218
- if (!c) { th.innerHTML = `<div class="cn-empty cx-mx-empty" style="flex:1"><div class="cn-empty-header"><div class="cn-empty-media cn-empty-media-icon">${ui.icon('i-inbox')}</div><div class="cn-empty-title">Elige una conversación</div></div></div>`; return; }
1282
+ if (!c) { th.innerHTML = `<div class="cn-empty cx-mx-empty" style="flex:1"><div class="cn-empty-header"><div class="cn-empty-media cn-empty-media-icon">${ui.icon('i-inbox')}</div><div class="cn-empty-title">${T('Pick a conversation')}</div></div></div>`; return; }
1219
1283
  armed = null;
1220
1284
  const resolved = c.state === 'resolved';
1221
- th.innerHTML = `<header class="cx-mx-th">${ui.button({ icon: 'left', variant: 'ghost', size: 'icon-sm', cls: 'cx-mx-back', attrs: 'data-mx-back aria-label="Volver a la bandeja"' })}<span class="cx-mx-av">${ui.avatar(c.name)}${chMark(c)}</span><div class="cx-vstack cx-mx-thwho"><span class="cx-hstack" style="gap:8px;flex-wrap:nowrap;min-width:0"><span class="cx-ellipsis cx-mx-thname">${ui.text(c.name, 'heading')}</span>${stateBadge(c)}</span>${ui.text(`${channels[c.channel].name}${c.handle ? ` · ${c.handle}` : ''}`, 'caption')}</div><span class="cx-grow"></span>${ui.button({ label: `${ui.icon('check')}<span class="cx-mx-hide-sm">${resolved ? 'Resuelta' : 'Marcar resuelta'}</span>`, size: 'sm', attrs: `data-mx-resolve aria-pressed="${resolved}" aria-label="${resolved ? 'Resuelta, reabrir' : 'Marcar resuelta'}"` })}${ui.button({ icon: 'i-panel', variant: 'ghost', size: 'icon-sm', cls: 'cx-mx-ctxbtn', attrs: 'data-mx-profile aria-label="Ver cliente" title="Ver cliente"' })}${ui.button({ icon: 'more', variant: 'ghost', size: 'icon-sm', attrs: 'data-mx-more aria-label="Opciones" title="Opciones"' })}</header>`
1285
+ th.innerHTML = `<header class="cx-mx-th">${ui.button({ icon: 'left', variant: 'ghost', size: 'icon-sm', cls: 'cx-mx-back', attrs: `data-mx-back aria-label="${T('Back to the inbox')}"` })}<span class="cx-mx-av">${ui.avatar(c.name)}${chMark(c)}</span><div class="cx-vstack cx-mx-thwho"><span class="cx-hstack" style="gap:8px;flex-wrap:nowrap;min-width:0"><span class="cx-ellipsis cx-mx-thname">${ui.text(c.name, 'heading')}</span>${stateBadge(c)}</span>${ui.text(`${channels[c.channel].name}${c.handle ? ` · ${c.handle}` : ''}`, 'caption')}</div><span class="cx-grow"></span>${ui.button({ label: `${ui.icon('check')}<span class="cx-mx-hide-sm">${resolved ? 'Resuelta' : 'Marcar resuelta'}</span>`, size: 'sm', attrs: `data-mx-resolve aria-pressed="${resolved}" aria-label="${resolved ? 'Resuelta, reabrir' : 'Marcar resuelta'}"` })}${ui.button({ icon: 'i-panel', variant: 'ghost', size: 'icon-sm', cls: 'cx-mx-ctxbtn', attrs: `data-mx-profile aria-label="${T('See client')}" title="${T('See client')}"` })}${ui.button({ icon: 'more', variant: 'ghost', size: 'icon-sm', attrs: `data-mx-more aria-label="${T('Options')}" title="${T('Options')}"` })}</header>`
1222
1286
  + `<div class="cx-mx-control"></div>`
1223
1287
  + `<div class="cx-chat cx-scroll" role="log" aria-live="polite">${c.window ? `<div class="cx-chat-sys" role="note">${ui.icon('clock')}${ui.text(c.window, 'caption')}</div>` : ''}${c.messages.map(msgHtml).join('')}</div>`
1224
- + `<div class="cx-mx-compose"><div class="cx-mx-quick"></div>${kit.composer({ placeholder: 'Escribe un mensaje…', templates, hint })}</div>`;
1288
+ + `<div class="cx-mx-compose"><div class="cx-mx-quick"></div>${kit.composer({ placeholder: T('Write a message…'), templates, hint })}</div>`;
1225
1289
  paintControl(); paintQuick();
1226
1290
  wireComposer($('.cx-mx-compose', root), send, templates);
1227
- kit.actionMenu($('[data-mx-more]', root), { items: () => [{ id: 'archive', label: c.archived ? 'Sacar del archivo' : 'Archivar', icon: c.archived ? 'i-inbox' : 'i-archive' }, { id: 'delete', label: 'Eliminar', icon: 'trash', danger: true }], onPick: act });
1291
+ kit.actionMenu($('[data-mx-more]', root), { items: () => [{ id: 'archive', label: c.archived ? T('Unarchive') : T('Archive'), icon: c.archived ? 'i-inbox' : 'i-archive' }, { id: 'delete', label: T('Delete'), icon: 'trash', danger: true }], onPick: act });
1228
1292
  const ch = $('.cx-chat', root); ch.scrollTop = ch.scrollHeight;
1229
1293
  };
1230
1294
 
@@ -1233,26 +1297,26 @@ let wheelsNow = [];
1233
1297
  const p = profile(c) || {};
1234
1298
  return `<div class="cx-mx-who"><span class="cx-mx-av">${ui.avatar(c.name, { size: 'lg' })}${chMark(c)}</span>${ui.text(c.name, 'heading')}${ui.text(c.handle || channels[c.channel].name, 'caption')}</div>`
1235
1299
  + (p.stats ? `<div class="cx-mx-stats">${p.stats.map(([v, l]) => `<div>${ui.text(v, 'strong')}${ui.text(l, 'caption')}</div>`).join('')}</div>` : '')
1236
- + (p.now?.length ? `<section class="cx-mx-sec">${ui.text('Ahora', 'label')}<div class="cn-item-group">${p.now.map((n) => `<button type="button" class="cn-item cn-item-variant-outline cn-item-size-sm cx-mx-now" data-mx-go="${n.go || ''}">${ui.itemMedia(ui.icon(n.icon), 'icon')}<div class="cn-item-content"><div class="cn-item-title">${n.title}</div><p class="cn-item-description">${n.description}</p></div><div class="cn-item-actions">${ui.icon('right')}</div></button>`).join('')}</div></section>` : '')
1237
- + (p.rows?.length ? `<section class="cx-mx-sec">${ui.text('Cliente', 'label')}<dl class="cx-mx-dl">${p.rows.map(([l, v]) => `<div><dt>${ui.text(l, 'muted')}</dt><dd>${ui.text(v)}</dd></div>`).join('')}</dl></section>` : '')
1238
- + `<div class="cx-mx-acts">${ui.button({ label: `${ui.icon(c.archived ? 'i-inbox' : 'i-archive')}${c.archived ? 'Sacar del archivo' : 'Archivar'}`, variant: 'ghost', size: 'sm', attrs: 'data-mx-act="archive"' })}${ui.button({ label: `${ui.icon('trash')}Eliminar`, variant: 'ghost', size: 'sm', cls: 'cx-mx-danger', attrs: 'data-mx-act="delete"' })}</div>`;
1300
+ + (p.now?.length ? `<section class="cx-mx-sec">${ui.text(T('Now'), 'label')}<div class="cn-item-group">${p.now.map((n) => `<button type="button" class="cn-item cn-item-variant-outline cn-item-size-sm cx-mx-now" data-mx-go="${n.go || ''}">${ui.itemMedia(ui.icon(n.icon), 'icon')}<div class="cn-item-content"><div class="cn-item-title">${n.title}</div><p class="cn-item-description">${n.description}</p></div><div class="cn-item-actions">${ui.icon('right')}</div></button>`).join('')}</div></section>` : '')
1301
+ + (p.rows?.length ? `<section class="cx-mx-sec">${ui.text(T('Client'), 'label')}<dl class="cx-mx-dl">${p.rows.map(([l, v]) => `<div><dt>${ui.text(l, 'muted')}</dt><dd>${ui.text(v)}</dd></div>`).join('')}</dl></section>` : '')
1302
+ + `<div class="cx-mx-acts">${ui.button({ label: `${ui.icon(c.archived ? 'i-inbox' : 'i-archive')}${c.archived ? T('Unarchive') : T('Archive')}`, variant: 'ghost', size: 'sm', attrs: 'data-mx-act="archive"' })}${ui.button({ label: `${ui.icon('trash')}Eliminar`, variant: 'ghost', size: 'sm', cls: 'cx-mx-danger', attrs: 'data-mx-act="delete"' })}</div>`;
1239
1303
  };
1240
1304
  const paintCtx = () => { const c = conv(); $('.cx-mx-ctx', root).innerHTML = c ? profileHtml(c) : ''; };
1241
- CONTENT.mxProfile = () => { $('#mdl-title').textContent = 'Cliente'; $('#mdl-desc').textContent = conv().name; body.innerHTML = `<div class="cx-mx-ctx cx-mx-ctx-sheet">${profileHtml(conv())}</div>`; foot.innerHTML = ui.button({ label: 'Cerrar', size: 'default', attrs: 'data-dismiss', cls: 'cx-exit' }); };
1242
- CONTENT.mxDelete = () => { $('#mdl-title').textContent = '¿Eliminar la conversación?'; $('#mdl-desc').textContent = `Se borra el chat con ${conv().name} de tu bandeja. Sus citas y pedidos siguen igual.`; body.innerHTML = ''; foot.innerHTML = ui.button({ label: 'Cancelar', size: 'default', attrs: 'data-dismiss', cls: 'cx-exit' }) + ui.button({ label: 'Eliminar', variant: 'destructive', size: 'default', attrs: 'data-mx-del-go', cls: 'cx-go' }); };
1243
- CONTENT.mxConnect = () => { $('#mdl-title').textContent = 'Conectar un canal'; $('#mdl-desc').textContent = 'Los mensajes de cada canal llegan a esta misma bandeja.'; body.innerHTML = `<div class="cn-item-group">${Object.entries(channels).map(([k, ch]) => ui.item({ media: `<div class="cn-item-media"><span class="cx-ch cx-ch-lg" style="--brand:${ch.color}">${ui.icon(ch.icon)}</span></div>`, title: ch.name, description: ch.description || '', actions: ch.connected ? ui.badge('Conectado', { tone: 'ok' }) : ui.button({ label: 'Conectar', size: 'xs', attrs: `data-mx-conn="${k}"` }) })).join(ui.itemSeparator())}</div>`; foot.innerHTML = ui.button({ label: 'Listo', size: 'default', attrs: 'data-dismiss', cls: 'cx-exit' }); };
1305
+ CONTENT.mxProfile = () => { $('#mdl-title').textContent = T('Client'); $('#mdl-desc').textContent = conv().name; body.innerHTML = `<div class="cx-mx-ctx cx-mx-ctx-sheet">${profileHtml(conv())}</div>`; foot.innerHTML = ui.button({ label: T('Close'), size: 'default', attrs: 'data-dismiss', cls: 'cx-exit' }); };
1306
+ CONTENT.mxDelete = () => { $('#mdl-title').textContent = T('Delete the conversation?'); $('#mdl-desc').textContent = T('The chat with {0} is deleted from your inbox. Their appointments and orders stay the same.', conv().name); body.innerHTML = ''; foot.innerHTML = ui.button({ label: T('Cancel'), size: 'default', attrs: 'data-dismiss', cls: 'cx-exit' }) + ui.button({ label: T('Delete'), variant: 'destructive', size: 'default', attrs: 'data-mx-del-go', cls: 'cx-go' }); };
1307
+ CONTENT.mxConnect = () => { $('#mdl-title').textContent = T('Connect a channel'); $('#mdl-desc').textContent = T('The messages of every channel arrive in this same inbox.'); body.innerHTML = `<div class="cn-item-group">${Object.entries(channels).map(([k, ch]) => ui.item({ media: `<div class="cn-item-media"><span class="cx-ch cx-ch-lg" style="--brand:${ch.color}">${ui.icon(ch.icon)}</span></div>`, title: ch.name, description: ch.description || '', actions: ch.connected ? ui.badge(T('Connected'), { tone: 'ok' }) : ui.button({ label: T('Connect'), size: 'xs', attrs: `data-mx-conn="${k}"` }) })).join(ui.itemSeparator())}</div>`; foot.innerHTML = ui.button({ label: T('Done'), size: 'default', attrs: 'data-dismiss', cls: 'cx-exit' }); };
1244
1308
 
1245
1309
  // ── behaviour ──
1246
1310
  const openConv = (id) => { cur = id; const c = conv(); if (c) c.unread = 0; root.dataset.view = 'thread'; paintList(); paintThread(); paintCtx(); };
1247
1311
  const act = (what) => {
1248
1312
  const c = conv(); if (!c) return;
1249
- if (what === 'archive') { c.archived = !c.archived; island('done', c.archived ? 'Conversación archivada' : 'De vuelta en la bandeja', 2000); const next = visible()[0]; cur = next?.id; root.dataset.view = 'list'; paintList(); paintThread(); paintCtx(); }
1313
+ if (what === 'archive') { c.archived = !c.archived; island('done', c.archived ? T('Conversation archived') : T('Back in the inbox'), 2000); const next = visible()[0]; cur = next?.id; root.dataset.view = 'list'; paintList(); paintThread(); paintCtx(); }
1250
1314
  if (what === 'delete') openModal('mxDelete', 'auto');
1251
1315
  };
1252
1316
  const pushMsg = (m) => { const ch = $('.cx-chat', root); ch.insertAdjacentHTML('beforeend', msgHtml(m)); animIn(ch.lastElementChild); ch.scrollTop = ch.scrollHeight; };
1253
1317
  const send = (text, extra = {}) => {
1254
1318
  const c = conv(), id = 'm' + Date.now(), time = clock();
1255
- const m = { id, out: true, text, time, status: 'sending', tag: 'Tú', ...extra }; c.messages.push(m); c.last = text || 'Foto'; c.lastBy = 'Tú'; c.time = time;
1319
+ const m = { id, out: true, text, time, status: 'sending', tag: T('You'), ...extra }; c.messages.push(m); c.last = text || T('Photo'); c.lastBy = T('You'); c.time = time;
1256
1320
  if (c.control === 'bot' || c.control === 'need') { c.control = 'me'; if (c.state === 'attention') c.state = ''; paintControl(); syncHead(); }
1257
1321
  pushMsg(m);
1258
1322
  const st = (s) => { m.status = s; const t = $(`[data-mid="${id}"] .cx-tick`, root); if (t) { t.dataset.status = s; t.innerHTML = `<use href="#${TICK[s]}"/>`; } };
@@ -1263,7 +1327,7 @@ let wheelsNow = [];
1263
1327
  };
1264
1328
 
1265
1329
  root.classList.add('cx-mx');
1266
- root.innerHTML = `<aside class="cx-mx-list"><header class="cx-mx-lh">${ui.text(title, 'heading')}<span class="cx-grow"></span>${ui.button({ label: `${ui.icon('plus')}Conectar`, size: 'sm', attrs: 'data-mx-connect' })}</header><div class="cx-mx-tools"><div data-slot="input-group" role="group" class="cn-input-group group/input-group"><div data-slot="input-group-addon" data-align="inline-start" class="cn-input-group-addon cn-input-group-addon-align-inline-start">${ui.icon('search')}</div><input data-slot="input-group-control" class="cn-input cn-input-group-input" placeholder="Buscar conversación" aria-label="Buscar conversación" data-mx-q></div><div class="cx-mx-chips cx-noscrollbar" role="group" aria-label="Filtrar"></div></div><div class="cx-mx-items cx-scroll" role="list"></div><footer class="cx-mx-lf"><span class="cx-mx-count cx-text" data-variant="caption"></span>${ui.button({ label: '', variant: 'ghost', size: 'xs', attrs: 'data-mx-arch' })}</footer></aside><section class="cx-mx-thread" aria-label="Conversación"></section><aside class="cx-mx-ctx" aria-label="Cliente"></aside>`;
1330
+ root.innerHTML = `<aside class="cx-mx-list"><header class="cx-mx-lh">${ui.text(title, 'heading')}<span class="cx-grow"></span>${ui.button({ label: `${ui.icon('plus')}${T('Connect')}`, size: 'sm', attrs: 'data-mx-connect' })}</header><div class="cx-mx-tools"><div data-slot="input-group" role="group" class="cn-input-group group/input-group"><div data-slot="input-group-addon" data-align="inline-start" class="cn-input-group-addon cn-input-group-addon-align-inline-start">${ui.icon('search')}</div><input data-slot="input-group-control" class="cn-input cn-input-group-input" placeholder="${T('Search conversation')}" aria-label="${T('Search conversation')}" data-mx-q></div><div class="cx-mx-chips cx-noscrollbar" role="group" aria-label="${T('Filter')}"></div></div><div class="cx-mx-items cx-scroll" role="list"></div><footer class="cx-mx-lf"><span class="cx-mx-count cx-text" data-variant="caption"></span>${ui.button({ label: '', variant: 'ghost', size: 'xs', attrs: 'data-mx-arch' })}</footer></aside><section class="cx-mx-thread" aria-label="${T('Conversation')}"></section><aside class="cx-mx-ctx" aria-label="${T('Client')}"></aside>`;
1267
1331
  root.dataset.view = 'list';
1268
1332
 
1269
1333
  root.addEventListener('input', (e) => { if (e.target.matches('[data-mx-q]')) { q = e.target.value; paintList(); } });
@@ -1276,15 +1340,15 @@ let wheelsNow = [];
1276
1340
  if (t('[data-mx-connect]')) return openModal('mxConnect', 'auto');
1277
1341
  if (t('[data-mx-profile]')) return openModal('mxProfile', 'auto');
1278
1342
  if (t('[data-mx-act]')) return act(t('[data-mx-act]').dataset.mxAct);
1279
- if (t('[data-mx-resolve]')) { const c = conv(); c.state = c.state === 'resolved' ? '' : 'resolved'; if (c.state === 'resolved') island('done', 'Conversación resuelta', 1800); paintList(); const b = t('[data-mx-resolve]'), on = c.state === 'resolved'; b.setAttribute('aria-pressed', on); b.setAttribute('aria-label', on ? 'Resuelta, reabrir' : 'Marcar resuelta'); $('.cx-mx-hide-sm', b).textContent = on ? 'Resuelta' : 'Marcar resuelta'; syncHead(); return; }
1343
+ if (t('[data-mx-resolve]')) { const c = conv(); c.state = c.state === 'resolved' ? '' : 'resolved'; if (c.state === 'resolved') island('done', T('Conversation solved'), 1800); paintList(); const b = t('[data-mx-resolve]'), on = c.state === 'resolved'; b.setAttribute('aria-pressed', on); b.setAttribute('aria-label', on ? 'Resuelta, reabrir' : 'Marcar resuelta'); $('.cx-mx-hide-sm', b).textContent = on ? 'Resuelta' : 'Marcar resuelta'; syncHead(); return; }
1280
1344
  if (t('[data-mx-ctl]')) { const c = conv(); c.control = t('[data-mx-ctl]').dataset.mxCtl; if (c.state === 'attention' && c.control === 'me') c.state = ''; paintControl(); syncHead(); paintList(); return; }
1281
1345
  if (t('[data-mx-q]')) { armed = quick.find((x) => x.id === t('[data-mx-q]').dataset.mxQ); paintQuick(); $('[data-mx-q-yes]', root)?.focus(); return; }
1282
1346
  if (t('[data-mx-q-no]')) { armed = null; paintQuick(); return; }
1283
1347
  if (t('[data-mx-q-yes]')) { const x = armed; armed = null; paintQuick(); send(null, { card: x.card ? x.card(conv()) : `${ui.text(x.label, 'label')}` }); conv().last = x.label; paintList(); return; }
1284
1348
  });
1285
1349
  document.addEventListener('click', (e) => {
1286
- if (e.target.closest('[data-mx-del-go]')) { const i = conversations.findIndex((c) => c.id === cur); conversations.splice(i, 1); closeModal(); island('done', 'Conversación eliminada', 2000); cur = visible()[0]?.id; root.dataset.view = 'list'; paintList(); paintThread(); paintCtx(); }
1287
- const cn = e.target.closest('[data-mx-conn]'); if (cn) { channels[cn.dataset.mxConn].connected = true; cn.outerHTML = ui.badge('Conectado', { tone: 'ok' }); island('done', `${channels[cn.dataset.mxConn].name} conectado`, 2000); }
1350
+ if (e.target.closest('[data-mx-del-go]')) { const i = conversations.findIndex((c) => c.id === cur); conversations.splice(i, 1); closeModal(); island('done', T('Conversation deleted'), 2000); cur = visible()[0]?.id; root.dataset.view = 'list'; paintList(); paintThread(); paintCtx(); }
1351
+ const cn = e.target.closest('[data-mx-conn]'); if (cn) { channels[cn.dataset.mxConn].connected = true; cn.outerHTML = ui.badge(T('Connected'), { tone: 'ok' }); island('done', `${channels[cn.dataset.mxConn].name} conectado`, 2000); }
1288
1352
  });
1289
1353
  // layout by the component's own width, not the window's: wide = 3 panes, mid = person in a sheet, narrow = one pane
1290
1354
  kit.watchWidth(root, [[720, 'narrow'], [1060, 'mid']], 'wide');
@@ -1335,20 +1399,23 @@ let wheelsNow = [];
1335
1399
  };
1336
1400
 
1337
1401
  // ── Chatbot: { name, avatar, greeting, suggestions, respond(text) → {text, actions} } ──
1338
- kit.chatbot = (root, { name, greeting, suggestions, respond }) => {
1339
- root.innerHTML = `<header class="cx-ib-th"><span class="cx-bot-face" aria-hidden="true"><i></i><i></i></span><div class="cx-vstack" style="gap:0">${ui.text(name, 'heading')}${ui.text('Asistente · responde al instante', 'caption')}</div></header><div class="cx-chat cx-scroll" role="log" aria-live="polite"></div><div class="cx-hstack cx-bot-sugs" role="group" aria-label="Sugerencias">${suggestions.map((s) => ui.button({ label: s, size: 'sm', attrs: `data-sug="${s}"` })).join('')}</div>${kit.composer({ placeholder: `Pregúntale a ${name}`, attach: false, emoji: false })}`;
1402
+ // avatar: an image, logo or GIF url in place of the drawn face (the face stays when there is none)
1403
+ kit.chatbot = (root, { name, greeting, suggestions, respond, avatar = '' }) => {
1404
+ const T = (k, ...a) => tx(root, k, ...a);
1405
+ const face = avatar ? `<img class="cx-bot-logo" src="${String(avatar).replace(/["<>]/g, '')}" alt="" aria-hidden="true">` : '<span class="cx-bot-face" aria-hidden="true"><i></i><i></i></span>';
1406
+ root.innerHTML = `<header class="cx-ib-th">${face}<div class="cx-vstack" style="gap:0">${ui.text(name, 'heading')}${ui.text(T('Assistant · answers at once'), 'caption')}</div></header><div class="cx-chat cx-scroll" role="log" aria-live="polite"></div><div class="cx-hstack cx-bot-sugs" role="group" aria-label="${T('Suggestions')}">${suggestions.map((s) => ui.button({ label: s, size: 'sm', attrs: `data-sug="${s}"` })).join('')}</div>${kit.composer({ placeholder: `Pregúntale a ${name}`, attach: false, emoji: false })}`;
1340
1407
  const ch = $('.cx-chat', root);
1341
- const add = (html) => { ch.insertAdjacentHTML('beforeend', html); const el = ch.lastElementChild; el.animate([{ opacity: 0, transform: 'translateY(8px)' }, { opacity: 1, transform: 'none' }], { duration: 200, easing: 'cubic-bezier(.22,1,.36,1)' }); ch.scrollTop = ch.scrollHeight; return el; };
1408
+ const add = (html) => { ch.insertAdjacentHTML('beforeend', html); const el = ch.lastElementChild; el.animate([{ opacity: 0, transform: 'translateY(8px)' }, { opacity: 1, transform: 'none' }], { duration: 200, easing: ease('--ease-out') }); ch.scrollTop = ch.scrollHeight; return el; };
1342
1409
  let stop = null;
1343
1410
  const ask = (text) => {
1344
1411
  if (stop) return; add(kit.bubble({ out: true, text }));
1345
1412
  $('.cx-bot-sugs', root).hidden = true;
1346
- const thinking = add(`<div class="cx-chat-msg" data-who="in"><div class="cx-chat-bubble cx-typing" aria-label="Pensando"><i></i><i></i><i></i></div></div>`);
1347
- const send = $('[data-send]', root); send.innerHTML = ui.icon('pause'); send.disabled = false; send.setAttribute('aria-label', 'Detener'); send.dataset.stop = '';
1413
+ const thinking = add(`<div class="cx-chat-msg" data-who="in"><div class="cx-chat-bubble cx-typing" aria-label="${T('Thinking')}"><i></i><i></i><i></i></div></div>`);
1414
+ const send = $('[data-send]', root); send.innerHTML = ui.icon('pause'); send.disabled = false; send.setAttribute('aria-label', T('Stop')); send.dataset.stop = '';
1348
1415
  setTimeout(() => {
1349
1416
  const ans = respond(text); thinking.remove();
1350
1417
  const el = add(kit.bubble({ text: '' })); const p = $('p', el); let i = 0, cancelled = false;
1351
- const done = () => { stop = null; send.innerHTML = ui.icon('i-send'); send.setAttribute('aria-label', 'Enviar'); delete send.dataset.stop; send.disabled = !$('textarea', root).value.trim(); el.insertAdjacentHTML('beforeend', `<div class="cx-chat-actions">${cancelled ? ui.text('Detenido', 'caption') : ''}${!cancelled && ans.actions ? ans.actions : ''}${ui.button({ icon: 'i-copy', variant: 'ghost', size: 'icon-xs', attrs: 'data-bot-copy aria-label="Copiar respuesta" title="Copiar"' })}${ui.button({ label: '👍', variant: 'ghost', size: 'icon-xs', attrs: 'data-rate="up" aria-label="Me sirvió" aria-pressed="false"' })}${ui.button({ label: '👎', variant: 'ghost', size: 'icon-xs', attrs: 'data-rate="down" aria-label="No me sirvió" aria-pressed="false"' })}</div>`); ch.scrollTop = ch.scrollHeight; };
1418
+ const done = () => { stop = null; send.innerHTML = ui.icon('i-send'); send.setAttribute('aria-label', T('Send')); delete send.dataset.stop; send.disabled = !$('textarea', root).value.trim(); el.insertAdjacentHTML('beforeend', `<div class="cx-chat-actions">${cancelled ? ui.text(T('Stopped'), 'caption') : ''}${!cancelled && ans.actions ? ans.actions : ''}${ui.button({ icon: 'i-copy', variant: 'ghost', size: 'icon-xs', attrs: `data-bot-copy aria-label="${T('Copy answer')}" title="${T('Copy')}"` })}${ui.button({ label: '👍', variant: 'ghost', size: 'icon-xs', attrs: `data-rate="up" aria-label="${T('It helped')}" aria-pressed="false"` })}${ui.button({ label: '👎', variant: 'ghost', size: 'icon-xs', attrs: `data-rate="down" aria-label="${T('It did not help')}" aria-pressed="false"` })}</div>`); ch.scrollTop = ch.scrollHeight; };
1352
1419
  const tick = setInterval(() => { i += 2; p.textContent = ans.text.slice(0, i); ch.scrollTop = ch.scrollHeight; if (i >= ans.text.length) { clearInterval(tick); done(); } }, 18);
1353
1420
  stop = () => { clearInterval(tick); cancelled = true; done(); };
1354
1421
  }, 700);
@@ -1358,7 +1425,7 @@ let wheelsNow = [];
1358
1425
  const s = e.target.closest('[data-sug]'); if (s) return ask(s.dataset.sug);
1359
1426
  const st = e.target.closest('[data-stop]'); if (st && stop) { e.preventDefault(); stop(); return; }
1360
1427
  const r = e.target.closest('[data-rate]'); if (r) { $$('[data-rate]', r.parentElement).forEach((x) => x.setAttribute('aria-pressed', x === r)); return; }
1361
- const c = e.target.closest('[data-bot-copy]'); if (c) { try { navigator.clipboard.writeText($('p', c.closest('.cx-chat-msg')).textContent); } catch {} c.innerHTML = ui.icon('check'); }
1428
+ const c = e.target.closest('[data-bot-copy]'); if (c) { try { navigator.clipboard.writeText($('p', c.closest('.cx-chat-msg')).textContent); } catch {} c.innerHTML = `<span class="cx-swap-in">${ui.icon('check')}</span>`; }
1362
1429
  }, true);
1363
1430
  add(kit.bubble({ text: greeting }));
1364
1431
  };
@@ -1370,13 +1437,14 @@ let wheelsNow = [];
1370
1437
 
1371
1438
  // ── Bar chart { title, data [[label, value]], fmt } ──
1372
1439
  kit.barChart = (root, { title, data, fmt = (v) => v, periods }) => {
1440
+ const T = (k, ...a) => tx(root, k, ...a);
1373
1441
  const paint = (rows) => {
1374
1442
  const max = Math.max(...rows.map(([, v]) => v)) * 1.1, W = 100, H = 160, bw = W / rows.length;
1375
1443
  const ticks = [0, .5, 1].map((t) => max * t);
1376
- root.innerHTML = `<div data-slot="card" data-size="sm" class="cn-card cx-chart"><div class="cx-hstack" style="justify-content:space-between;padding:14px 14px 0">${ui.text(title, 'heading')}${periods ? `<div class="cx-tabs" data-tabs role="tablist" aria-label="Periodo">${periods.map((p, i) => `<button class="cn-button cn-button-variant-ghost cn-button-size-sm cx-tab" role="tab" ${i === 0 ? 'aria-selected="true"' : ''}>${p[0]}</button>`).join('')}</div>` : ''}</div>
1444
+ root.innerHTML = `<div data-slot="card" data-size="sm" class="cn-card cx-chart"><div class="cx-hstack" style="justify-content:space-between;padding:14px 14px 0">${ui.text(title, 'heading')}${periods ? `<div class="cx-tabs" data-tabs role="tablist" aria-label="${T('Period')}">${periods.map((p, i) => `<button class="cn-button cn-button-variant-ghost cn-button-size-sm cx-tab" role="tab" ${i === 0 ? 'aria-selected="true"' : ''}>${p[0]}</button>`).join('')}</div>` : ''}</div>
1377
1445
  <div class="cx-chart-body"><div class="cx-chart-y">${ticks.slice().reverse().map((t) => ui.text(fmt(Math.round(t)), 'caption')).join('')}</div><div class="cx-chart-plot"><div class="cx-chart-grid">${ticks.map(() => '<i></i>').join('')}</div><div class="cx-chart-bars" style="--n:${rows.length}">${rows.map(([l, v]) => `<button class="cx-chart-bar" style="--v:${(v / max) * 100}%" data-tip-v="${l}: ${fmt(v)}" aria-label="${l}: ${fmt(v)}"><i></i></button>`).join('')}</div><div class="cx-chart-x" style="--n:${rows.length}">${rows.map(([l], i) => `<span>${i % Math.ceil(rows.length / 7) === 0 ? ui.text(l, 'caption') : ''}</span>`).join('')}</div></div></div></div>`;
1378
1446
  if (periods) { const tl = $('.cx-tabs', root); initTabs(tl); tl.addEventListener('pick', (e) => { const d = periods[e.detail][1]; paint(d); const t2 = $$('.cx-tab', root); t2.forEach((x, i) => x.setAttribute('aria-selected', i === e.detail)); }); }
1379
- $$('.cx-chart-bar', root).forEach((b, i) => b.animate([{ transform: 'scaleY(0)' }, { transform: 'scaleY(1)' }], { duration: 420, delay: i * 18, easing: 'cubic-bezier(.22,1,.36,1)', fill: 'backwards' }));
1447
+ $$('.cx-chart-bar', root).forEach((b, i) => b.animate([{ transform: 'scaleY(0)' }, { transform: 'scaleY(1)' }], { duration: ms('--t-move') * 1.75, delay: ms('--t-move') > 1 ? i * 18 : 0, easing: ease('--ease-out'), fill: 'backwards' }));
1380
1448
  };
1381
1449
  root.addEventListener('pointerover', (e) => { const b = e.target.closest('.cx-chart-bar'); if (b) { tip.textContent = b.dataset.tipV; tip.hidden = false; tip.dataset.side = 'top'; tip.style.removeProperty('--ax'); const r = b.getBoundingClientRect(); tip.style.left = (r.left + r.width / 2 - tip.offsetWidth / 2) + 'px'; tip.style.top = (r.top + (1 - parseFloat(getComputedStyle(b).getPropertyValue('--v')) / 100) * r.height - tip.offsetHeight - 6) + 'px'; requestAnimationFrame(() => tip.dataset.state = 'open'); } });
1382
1450
  root.addEventListener('pointerout', (e) => { if (e.target.closest('.cx-chart-bar')) { tip.dataset.state = 'closed'; tip.hidden = true; } });
@@ -1386,23 +1454,24 @@ let wheelsNow = [];
1386
1454
 
1387
1455
  // ── Checklist { title, steps [{title, desc, done, action}] } ──
1388
1456
  kit.checklist = (root, { title, steps }) => {
1457
+ const T = (k, ...a) => tx(root, k, ...a);
1389
1458
  const paint = () => { const n = steps.filter((s) => s.done).length; root.innerHTML = `<div data-slot="card" data-size="sm" class="cn-card"><div class="cx-vstack" style="gap:8px;padding:14px 14px 4px">${ui.text(title, 'heading')}<div class="cx-hstack" style="flex-wrap:nowrap;gap:10px">${ui.progress((n / steps.length) * 100, `$%n de ${steps.length} pasos`)}${ui.text(`$%n/${steps.length}`, 'caption')}</div></div><div class="cn-card-content" style="padding:0 12px 8px"><div class="cn-item-group">${steps.map((s, i) => ui.item({ media: `<div class="cn-item-media"><span class="cx-check-dot" ${s.done ? 'data-done' : ''}>${s.done ? ui.icon('i-tick') : i + 1}</span></div>`, title: s.title, description: s.desc, actions: s.done ? '' : ui.button({ label: s.action, size: 'xs', attrs: `data-step="${i}"` }) })).join(ui.itemSeparator())}</div></div></div>`; };
1390
1459
  root.addEventListener('click', (e) => { const b = e.target.closest('[data-step]'); if (!b) return; steps[+b.dataset.step].done = true; easeHeight(root, paint); });
1391
1460
  paint();
1392
1461
  };
1393
1462
 
1394
- // ── Dirty bar: watch a form; show «unsaved changes» while it differs from its saved state ──
1463
+ // ── Dirty bar: watch a form; show “unsaved changes” while it differs from its saved state ──
1395
1464
  kit.dirtyBar = (form, { onSave }) => {
1396
1465
  const snap = () => JSON.stringify($$('input, select, textarea, [role=switch], .cx-time-btn, .cx-sel-btn', form).map((x) => x.getAttribute('role') === 'switch' ? x.getAttribute('aria-checked') : x.matches('button') ? x.textContent : x.value));
1397
1466
  let saved = snap();
1398
- const bar = document.createElement('div'); bar.className = 'cx-dirty'; bar.setAttribute('role', 'region'); bar.setAttribute('aria-label', 'Cambios sin guardar');
1399
- bar.innerHTML = `<div class="cx-dirty-in">${ui.text('Tienes cambios sin guardar', 'label')}<span class="cx-dirty-acts">${ui.button({ label: 'Descartar', variant: 'ghost', size: 'sm', attrs: 'data-discard' })}${ui.button({ label: 'Guardar', variant: 'default', size: 'sm', attrs: 'data-save-all', cls: 'cx-save-w' })}</span></div>`;
1467
+ const bar = document.createElement('div'); bar.className = 'cx-dirty'; bar.setAttribute('role', 'region'); bar.setAttribute('aria-label', tx(form, 'Unsaved changes'));
1468
+ bar.innerHTML = `<div class="cx-dirty-in">${ui.text(tx(form, 'You have unsaved changes'), 'label')}<span class="cx-dirty-acts">${ui.button({ label: tx(form, 'Discard'), variant: 'ghost', size: 'sm', attrs: 'data-discard' })}${ui.button({ label: tx(form, 'Save'), variant: 'default', size: 'sm', attrs: 'data-save-all', cls: 'cx-save-w' })}</span></div>`;
1400
1469
  form.appendChild(bar);
1401
1470
  const check = () => bar.toggleAttribute('data-open', snap() !== saved);
1402
1471
  form.addEventListener('input', check); form.addEventListener('click', () => setTimeout(check));
1403
1472
  bar.addEventListener('click', (e) => {
1404
1473
  if (e.target.closest('[data-discard]')) { form.dispatchEvent(new CustomEvent('discard', { detail: JSON.parse(saved) })); setTimeout(check, 30); }
1405
- const s = e.target.closest('[data-save-all]'); if (s) { s.innerHTML = `<svg class="animate-spin"><use href="#loader"/></svg>Guardando…`; s.disabled = true; setTimeout(() => { onSave?.(); saved = snap(); s.innerHTML = `${ui.icon('check')}Guardado`; setTimeout(() => { check(); setTimeout(() => { s.innerHTML = 'Guardar'; s.disabled = false; }, 320); }, 900); }, 800); }
1474
+ const s = e.target.closest('[data-save-all]'); if (s) { s.innerHTML = `<svg class="animate-spin"><use href="#loader"/></svg>Guardando…`; s.disabled = true; setTimeout(() => { onSave?.(); saved = snap(); s.innerHTML = `${ui.icon('check')}Guardado`; setTimeout(() => { check(); setTimeout(() => { s.innerHTML = tx(form, 'Save'); s.disabled = false; }, 320); }, 900); }, 800); }
1406
1475
  });
1407
1476
  return { reset: () => { saved = snap(); check(); } };
1408
1477
  };
@@ -1410,8 +1479,9 @@ let wheelsNow = [];
1410
1479
 
1411
1480
  // ── Weekly hours { days: [{name, open, ranges:[[from,to]]}] } ──
1412
1481
  kit.weeklyHours = (root, { days }) => {
1482
+ const T = (k, ...a) => tx(root, k, ...a);
1413
1483
  days = JSON.parse(JSON.stringify(days));
1414
- const paint = () => { root.innerHTML = days.map((d, i) => `<div class="cx-wh-day" data-wd="${i}"><div class="cx-wh-name">${sw(d.open, `${d.name} abierto`)}${ui.text(d.name, 'heading')}</div><div class="cx-wh-ranges">${d.open ? d.ranges.map((r, j) => `<div class="cx-wh-range">${kit.timeButton(r[0], `data-r="${j}" data-k="0" aria-label="${d.name}, desde ${r[0]}"`)}${ui.text('a', 'caption')}${kit.timeButton(r[1], `data-r="${j}" data-k="1" aria-label="${d.name}, hasta ${r[1]}"`)}${d.ranges.length > 1 ? ui.button({ icon: 'x', variant: 'ghost', size: 'icon-sm', attrs: `data-rm="${j}" aria-label="Quitar tramo" title="Quitar tramo"` }) : ''}</div>`).join('') + `<div class="cx-hstack">${ui.button({ label: `${ui.icon('plus')}Agregar tramo`, variant: 'ghost', size: 'xs', attrs: 'data-add' })}${i === 0 ? ui.button({ label: `${ui.icon('i-copy')}Copiar a todos`, variant: 'ghost', size: 'xs', attrs: 'data-copyall' }) : ''}</div>` : ui.text('Cerrado', 'muted')}</div></div>`).join(''); };
1484
+ const paint = () => { root.innerHTML = days.map((d, i) => `<div class="cx-wh-day" data-wd="${i}"><div class="cx-wh-name">${sw(d.open, `${d.name} abierto`)}${ui.text(d.name, 'heading')}</div><div class="cx-wh-ranges">${d.open ? d.ranges.map((r, j) => `<div class="cx-wh-range">${kit.timeButton(r[0], `data-r="${j}" data-k="0" aria-label="${d.name}, desde ${r[0]}"`)}${ui.text('a', 'caption')}${kit.timeButton(r[1], `data-r="${j}" data-k="1" aria-label="${d.name}, hasta ${r[1]}"`)}${d.ranges.length > 1 ? ui.button({ icon: 'x', variant: 'ghost', size: 'icon-sm', attrs: `data-rm="${j}" aria-label="${T('Remove range')}" title="${T('Remove range')}"` }) : ''}</div>`).join('') + `<div class="cx-hstack">${ui.button({ label: `${ui.icon('plus')}${T('Add range')}`, variant: 'ghost', size: 'xs', attrs: 'data-add' })}${i === 0 ? ui.button({ label: `${ui.icon('i-copy')}${T('Copy to all')}`, variant: 'ghost', size: 'xs', attrs: 'data-copyall' }) : ''}</div>` : ui.text('Cerrado', 'muted')}</div></div>`).join(''); };
1415
1485
  root.addEventListener('click', (e) => {
1416
1486
  const day = e.target.closest('[data-wd]'); if (!day) return; const d = days[+day.dataset.wd];
1417
1487
  if (e.target.closest('[role=switch]')) { d.open = !d.open; if (d.open && !d.ranges.length) d.ranges = [['09:00', '18:00']]; easeHeight(root, paint); }
@@ -1426,17 +1496,19 @@ let wheelsNow = [];
1426
1496
 
1427
1497
  // ── Plan cards { cycle: ['month','year'], plans [{id,name,price:{month,year},desc,features,current}] } ──
1428
1498
  kit.plans = (root, { plans, yearlyNote = '' }) => {
1499
+ const T = (k, ...a) => tx(root, k, ...a);
1429
1500
  let cycle = 'month', chosen = plans.find((p) => p.current).id;
1430
- const paint = () => { root.innerHTML = `<div class="cx-vstack" style="gap:16px;align-items:center"><div class="cx-tabs" data-tabs role="tablist" aria-label="Facturación"><button class="cn-button cn-button-variant-ghost cn-button-size-sm cx-tab" role="tab" ${cycle === 'month' ? 'aria-selected="true"' : ''} data-cycle="month">Mensual</button><button class="cn-button cn-button-variant-ghost cn-button-size-sm cx-tab" role="tab" ${cycle === 'year' ? 'aria-selected="true"' : ''} data-cycle="year">Anual ${ui.badge(yearlyNote, { variant: 'secondary' })}</button></div><div class="cx-plans" role="radiogroup" aria-label="Planes">${plans.map((p) => `<div data-slot="card" class="cn-card cx-plan" role="radio" aria-checked="${p.id === chosen}" tabindex="${p.id === chosen ? 0 : -1}" data-plan="${p.id}"><div class="cx-vstack" style="gap:6px;padding:16px 16px 0"><div class="cx-hstack" style="justify-content:space-between">${ui.text(p.name, 'heading')}${p.current ? ui.badge('Tu plan', { tone: 'ok' }) : ''}</div><div class="cx-plan-price">${ui.text(money0(p.price[cycle]), 'title', 'b')}${ui.text(cycle === 'month' ? '/ mes' : '/ año', 'muted')}</div>${ui.text(p.desc, 'muted', 'p')}</div><ul class="cx-plan-feats">${p.features.map((f) => `<li>${ui.icon('check')}${ui.text(f)}</li>`).join('')}</ul><div style="padding:0 16px 16px;margin-top:auto">${ui.button({ label: p.current ? 'Tu plan actual' : (plans.indexOf(p) > plans.findIndex((x) => x.current) ? `Pasar a ${p.name}` : `Bajar a ${p.name}`), variant: p.current ? 'outline' : 'default', size: 'default', cls: 'cx-fill', attrs: p.current ? 'disabled' : '' })}</div></div>`).join('')}</div></div>`; const tl = $('.cx-tabs', root); initTabs(tl); };
1501
+ const paint = () => { root.innerHTML = `<div class="cx-vstack" style="gap:16px;align-items:center"><div class="cx-tabs" data-tabs role="tablist" aria-label="${T('Billing')}"><button class="cn-button cn-button-variant-ghost cn-button-size-sm cx-tab" role="tab" ${cycle === 'month' ? 'aria-selected="true"' : ''} data-cycle="month">${T('Monthly')}</button><button class="cn-button cn-button-variant-ghost cn-button-size-sm cx-tab" role="tab" ${cycle === 'year' ? 'aria-selected="true"' : ''} data-cycle="year">${T('Yearly')} ${ui.badge(yearlyNote, { variant: 'secondary' })}</button></div><div class="cx-plans" role="radiogroup" aria-label="Planes">${plans.map((p) => `<div data-slot="card" class="cn-card cx-plan" role="radio" aria-checked="${p.id === chosen}" tabindex="${p.id === chosen ? 0 : -1}" data-plan="${p.id}"><div class="cx-vstack" style="gap:6px;padding:16px 16px 0"><div class="cx-hstack" style="justify-content:space-between">${ui.text(p.name, 'heading')}${p.current ? ui.badge(T('Your plan'), { tone: 'ok' }) : ''}</div><div class="cx-plan-price">${ui.text(money0(p.price[cycle]), 'title', 'b')}${ui.text(cycle === 'month' ? T('/ month') : T('/ year'), 'muted')}</div>${ui.text(p.desc, 'muted', 'p')}</div><ul class="cx-plan-feats">${p.features.map((f) => `<li>${ui.icon('check')}${ui.text(f)}</li>`).join('')}</ul><div style="padding:0 16px 16px;margin-top:auto">${ui.button({ label: p.current ? T('Your current plan') : (plans.indexOf(p) > plans.findIndex((x) => x.current) ? T('Move up to {0}', p.name) : T('Move down to {0}', p.name)), variant: p.current ? 'outline' : 'default', size: 'default', cls: 'cx-fill', attrs: p.current ? 'disabled' : '' })}</div></div>`).join('')}</div></div>`; const tl = $('.cx-tabs', root); initTabs(tl); };
1431
1502
  root.addEventListener('click', (e) => { const c = e.target.closest('[data-cycle]'); if (c && c.dataset.cycle !== cycle) { cycle = c.dataset.cycle; setTimeout(paint, 220); return; } const p = e.target.closest('[data-plan]'); if (p && !e.target.closest('button')) { chosen = p.dataset.plan; $$('[data-plan]', root).forEach((x) => { const on = x === p; x.setAttribute('aria-checked', on); x.tabIndex = on ? 0 : -1; }); } });
1432
1503
  paint();
1433
1504
  };
1434
1505
 
1435
1506
  // ── Card field (number with brand, expiry, CVC) ──
1436
1507
  kit.cardField = (root, { onSubmit }) => {
1508
+ const T = (k, ...a) => tx(root, k, ...a);
1437
1509
  const BRANDS = [[/^4/, 'Visa'], [/^(5[1-5]|2[2-7])/, 'Mastercard'], [/^3[47]/, 'Amex']];
1438
1510
  const luhn = (n) => { let s = 0; [...n].reverse().forEach((c, i) => { let d = +c; if (i % 2) { d *= 2; if (d > 9) d -= 9; } s += d; }); return n.length >= 13 && s % 10 === 0; };
1439
- root.innerHTML = `${ui.field({ id: 'cc-n', label: 'Número de la tarjeta', control: `<div data-slot="input-group" class="cn-input-group">${'<div class="cn-input-group-addon cn-input-group-addon-align-inline-start">' + ui.icon('card') + '</div>'}<input class="cn-input cn-input-group-input cx-num" id="cc-n" inputmode="numeric" autocomplete="cc-number" placeholder="1234 1234 1234 1234"><div class="cn-input-group-addon cn-input-group-addon-align-inline-end"><span class="cx-cc-brand" id="cc-b"></span></div></div>` })}<div class="cx-frange">${ui.field({ id: 'cc-e', label: 'Vence', control: ui.input({ id: 'cc-e', placeholder: 'MM/AA', attrs: 'inputmode="numeric" autocomplete="cc-exp" class="cn-input cx-num"' }) })}${ui.field({ id: 'cc-c', label: 'Código', control: ui.input({ id: 'cc-c', placeholder: '123', attrs: 'inputmode="numeric" autocomplete="cc-csc" maxlength="4"' }) })}</div><p class="cn-field-description cx-msg" id="cc-msg">${ui.icon('i-lock')} Pago seguro con Stripe. No guardamos el número.</p>${ui.button({ label: 'Guardar tarjeta', variant: 'default', size: 'default', cls: 'cx-fill', attrs: 'data-cc-save' })}`;
1511
+ root.innerHTML = `${ui.field({ id: 'cc-n', label: T('Card number'), control: `<div data-slot="input-group" class="cn-input-group">${'<div class="cn-input-group-addon cn-input-group-addon-align-inline-start">' + ui.icon('card') + '</div>'}<input class="cn-input cn-input-group-input cx-num" id="cc-n" inputmode="numeric" autocomplete="cc-number" placeholder="1234 1234 1234 1234"><div class="cn-input-group-addon cn-input-group-addon-align-inline-end"><span class="cx-cc-brand" id="cc-b"></span></div></div>` })}<div class="cx-frange">${ui.field({ id: 'cc-e', label: T('Expires'), control: ui.input({ id: 'cc-e', placeholder: 'MM/AA', attrs: 'inputmode="numeric" autocomplete="cc-exp" class="cn-input cx-num"' }) })}${ui.field({ id: 'cc-c', label: T('Code'), control: ui.input({ id: 'cc-c', placeholder: '123', attrs: 'inputmode="numeric" autocomplete="cc-csc" maxlength="4"' }) })}</div><p class="cn-field-description cx-msg" id="cc-msg">${ui.icon('i-lock')} ${T('Secure payment with Stripe. We do not keep the number.')}</p>${ui.button({ label: T('Save card'), variant: 'default', size: 'default', cls: 'cx-fill', attrs: 'data-cc-save' })}`;
1440
1512
  const n = $('#cc-n', root), ex = $('#cc-e', root), cvc = $('#cc-c', root), msg = $('#cc-msg', root), help = msg.innerHTML;
1441
1513
  const brand = () => BRANDS.find(([re]) => re.test(n.value.replace(/\D/g, '')))?.[1] || '';
1442
1514
  n.addEventListener('input', () => { reformat(n, (v) => { const d = v.replace(/\D/g, '').slice(0, brand() === 'Amex' ? 15 : 16); return brand() === 'Amex' ? d.replace(/^(\d{0,4})(\d{0,6})(\d{0,5}).*/, (_, a, b, c) => [a, b, c].filter(Boolean).join(' ')) : d.replace(/(\d{4})(?=\d)/g, '$1 '); }, /\d/); $('#cc-b', root).textContent = brand(); });
@@ -1444,22 +1516,23 @@ let wheelsNow = [];
1444
1516
  cvc.addEventListener('input', () => { cvc.value = cvc.value.replace(/\D/g, ''); });
1445
1517
  $('[data-cc-save]', root).addEventListener('click', () => {
1446
1518
  const num = n.value.replace(/\D/g, ''), [mm, yy] = ex.value.split('/').map(Number), expOk = mm >= 1 && mm <= 12 && yy >= today.getFullYear() % 100;
1447
- const err = !luhn(num) ? 'Revisa el número de la tarjeta.' : !expOk ? 'La fecha de vencimiento no es válida.' : cvc.value.length < 3 ? 'Falta el código de 3 o 4 dígitos.' : '';
1448
- msg.closest('.cx-form')?.toggleAttribute('data-invalid', !!err); msg.innerHTML = err ? `${ui.icon('info')} ${err}` : `${ui.icon('check')} Tarjeta ${brand()} terminada en ${num.slice(-4)} guardada.`; msg.dataset.tone = err ? 'bad' : 'ok';
1519
+ const err = !luhn(num) ? T('Check the card number.') : !expOk ? T('The expiry date is not valid.') : cvc.value.length < 3 ? T('The 3 or 4 digit code is missing.') : '';
1520
+ msg.closest('.cx-form')?.toggleAttribute('data-invalid', !!err); msg.innerHTML = err ? `${ui.icon('info')} ${err}` : `${ui.icon('check')} ${T('{0} card ending in {1} saved.', brand(), num.slice(-4))}`; msg.dataset.tone = err ? 'bad' : 'ok';
1449
1521
  if (!err) onSubmit?.(num.slice(-4));
1450
1522
  });
1451
1523
  };
1452
1524
 
1453
1525
  // ── Member list (any group: team, workspace, family plan…) + invite; Permission matrix (any rows × any roles) ──
1454
1526
  // memberList({ members:[{name,email,role,pending}], roles:[{name, description, icon}], ownerRole, invite:{ title, description, cta } })
1455
- kit.memberList = (root, { members, roles, ownerRole, noun = ['persona', 'personas'], invite }) => {
1456
- const paint = () => { root.innerHTML = `<div class="cx-hstack" style="justify-content:space-between">${ui.text(`${members.length} ${members.length === 1 ? noun[0] : noun[1]}`, 'heading')}${ui.button({ label: `${ui.icon('plus')}${invite.cta}`, variant: 'default', size: 'sm', attrs: 'data-invite' })}</div><div class="cn-item-group">${members.map((m, i) => ui.item({ media: ui.itemMedia(ui.avatar(m.name), 'default'), title: `${m.name}${m.pending ? ` ${ui.badge('Invitación enviada', { tone: 'wait' })}` : ''}`, description: m.email, actions: m.role === ownerRole ? ui.text(ownerRole, 'muted') : `<button type="button" class="cn-button cn-button-variant-outline cn-button-size-sm cx-sel-btn" aria-label="Rol de ${m.name}" data-mi="${i}"></button>` })).join(ui.itemSeparator())}</div>`; $$('[data-mi]', root).forEach((b) => kit.selectMenu(b, { label: `Rol de ${members[+b.dataset.mi].name}`, value: members[+b.dataset.mi].role, options: roles.filter((r) => r.name !== ownerRole).map((r) => ({ value: r.name, label: r.name, description: r.description })), onChange: (v) => { members[+b.dataset.mi].role = v; } })); };
1527
+ kit.memberList = (root, { members, roles, ownerRole, noun = ['person', 'people'], invite }) => {
1528
+ const T = (k, ...a) => tx(root, k, ...a);
1529
+ const paint = () => { root.innerHTML = `<div class="cx-hstack" style="justify-content:space-between">${ui.text(`${members.length} ${members.length === 1 ? noun[0] : noun[1]}`, 'heading')}${ui.button({ label: `${ui.icon('plus')}${invite.cta}`, variant: 'default', size: 'sm', attrs: 'data-invite' })}</div><div class="cn-item-group">${members.map((m, i) => ui.item({ media: ui.itemMedia(ui.avatar(m.name), 'default'), title: `${m.name}${m.pending ? ` ${ui.badge(T('Invitation sent'), { tone: 'wait' })}` : ''}`, description: m.email, actions: m.role === ownerRole ? ui.text(ownerRole, 'muted') : `<button type="button" class="cn-button cn-button-variant-outline cn-button-size-sm cx-sel-btn" aria-label="${T('Role of {0}', m.name)}" data-mi="${i}"></button>` })).join(ui.itemSeparator())}</div>`; $$('[data-mi]', root).forEach((b) => kit.selectMenu(b, { label: T('Role of {0}', members[+b.dataset.mi].name), value: members[+b.dataset.mi].role, options: roles.filter((r) => r.name !== ownerRole).map((r) => ({ value: r.name, label: r.name, description: r.description })), onChange: (v) => { members[+b.dataset.mi].role = v; } })); };
1457
1530
  root.addEventListener('click', (e) => { if (e.target.closest('[data-invite]')) openModal('invite', 'auto'); });
1458
1531
  const choosable = roles.filter((r) => r.name !== ownerRole);
1459
1532
  CONTENT.invite = () => {
1460
1533
  $('#mdl-title').textContent = invite.title; $('#mdl-desc').textContent = invite.description;
1461
- body.innerHTML = `${ui.field({ id: 'inv-e', label: 'Correo', control: ui.input({ id: 'inv-e', type: 'email', placeholder: 'nombre@correo.com' }) })}<div class="cx-vstack" role="radiogroup" aria-label="Rol" style="gap:8px">${ui.text('Rol', 'label')}${choosable.map((r, i) => ui.choice({ media: ui.itemMedia(ui.icon(r.icon || 'user')), title: r.name, description: r.description, checked: i === choosable.length - 1, attrs: `data-role="${r.name}"` })).join('')}</div>`;
1462
- foot.innerHTML = ui.button({ label: 'Cancelar', size: 'default', attrs: 'data-dismiss', cls: 'cx-exit' }) + ui.button({ label: `${ui.icon('i-mail')}Enviar invitación`, variant: 'default', size: 'default', attrs: 'data-inv-send', cls: 'cx-go' });
1534
+ body.innerHTML = `${ui.field({ id: 'inv-e', label: T('Email'), control: ui.input({ id: 'inv-e', type: 'email', placeholder: T('name@email.com') }) })}<div class="cx-vstack" role="radiogroup" aria-label="${T('Role')}" style="gap:8px">${ui.text(T('Role'), 'label')}${choosable.map((r, i) => ui.choice({ media: ui.itemMedia(ui.icon(r.icon || 'user')), title: r.name, description: r.description, checked: i === choosable.length - 1, attrs: `data-role="${r.name}"` })).join('')}</div>`;
1535
+ foot.innerHTML = ui.button({ label: T('Cancel'), size: 'default', attrs: 'data-dismiss', cls: 'cx-exit' }) + ui.button({ label: `${ui.icon('i-mail')}${T('Send invitation')}`, variant: 'default', size: 'default', attrs: 'data-inv-send', cls: 'cx-go' });
1463
1536
  };
1464
1537
  document.addEventListener('click', (e) => {
1465
1538
  const ch = e.target.closest('[data-role]'); if (ch && body.contains(ch)) { $$('[data-role]', body).forEach((x) => { x.setAttribute('aria-checked', x === ch); x.tabIndex = x === ch ? 0 : -1; }); }
@@ -1468,14 +1541,18 @@ let wheelsNow = [];
1468
1541
  paint();
1469
1542
  };
1470
1543
  // permissionMatrix({ roles:[name], rows:[{name, description, allow:[bool per role]}], lockedRole, onChange })
1471
- kit.permissionMatrix = (root, { roles, rows, lockedRole, rowLabel = 'Permiso', onChange = () => {} }) => {
1544
+ kit.permissionMatrix = (root, { roles, rows, lockedRole, rowLabel = T0('Permission'), onChange = () => {} }) => {
1545
+ const T = (k, ...a) => tx(root, k, ...a);
1472
1546
  const li = roles.indexOf(lockedRole);
1473
1547
  root.innerHTML = `<div class="cx-dt-scroll cx-scroll" style="max-height:none"><table class="cx-dt cx-perm"><thead><tr><th>${ui.text(rowLabel, 'label')}</th>${roles.map((r) => `<th class="cx-perm-c">${ui.text(r, 'label')}</th>`).join('')}</tr></thead><tbody>${rows.map((row, pi) => `<tr><td>${ui.text(row.name, 'heading')}${row.description ? `<br>${ui.text(row.description, 'caption')}` : ''}</td>${roles.map((r, ri) => { const on = ri === li || !!row.allow[ri]; return `<td class="cx-perm-c"><button type="button" data-slot="checkbox" role="checkbox" class="cn-checkbox" aria-checked="${on}" data-state="${on ? 'checked' : 'unchecked'}" aria-label="${r}: ${row.name}" ${ri === li ? `disabled title="${lockedRole} siempre puede"` : ''} data-p="${pi}" data-r="${ri}"><span class="cn-checkbox-indicator"><svg class="cb-mark" viewBox="0 0 24 24">${CB_CHECK}</svg></span></button></td>`; }).join('')}</tr>`).join('')}</tbody></table></div>`;
1474
1548
  root.addEventListener('click', (e) => { const c2 = e.target.closest('[data-p]'); if (!c2 || c2.disabled) return; const on = c2.getAttribute('aria-checked') !== 'true'; setCheck(c2, on); rows[+c2.dataset.p].allow[+c2.dataset.r] = on; onChange(rows); });
1475
1549
  };
1476
1550
 
1477
1551
  // ── Stamp card { owner, total, stamps, reward } ──
1478
- kit.stampCard = (root, { owner, total, stamps, reward, store, title = 'Cartilla de %owner', progress = '%n de %total · faltan %left para %reward', complete = '¡Completa! Ganó %reward.', count = '%n de %total sellos', redeem = 'Canjear premio', stamp = 'Sellar visita' }) => {
1552
+ kit.stampCard = (root, { owner, total, stamps, reward, store, title = "%owner's card", progress = '%n of %total · %left more for %reward', complete = 'Complete! Earned %reward.', count = '%n of %total stamps', redeem = 'Redeem reward', stamp = 'Stamp a visit' }) => {
1553
+ const T = (k, ...a) => tx(root, k, ...a);
1554
+ // the words are templates in English; said in the card's language before %owner… are filled
1555
+ title = T(title); progress = T(progress); complete = T(complete); count = T(count); redeem = T(redeem); stamp = T(stamp);
1479
1556
  // the words are templates, so the card speaks the page's language: %owner %n %total %left %reward
1480
1557
  const say = (tpl) => String(tpl).replace(/%(owner|total|left|reward|n)/g, (_, k) => ({ owner, n: stamps, total, left: total - stamps, reward })[k]);
1481
1558
  const paint = (popIdx = -1) => { const done = stamps >= total; root.innerHTML = `<div data-slot="card" class="cn-card cx-stamps"><div class="cx-vstack" style="gap:2px;padding:16px 16px 0">${ui.text(store, 'caption')}${ui.text(say(title), 'heading')}</div><div class="cx-stamp-grid" role="img" aria-label="${say(count)}">${Array.from({ length: total }, (_, i) => `<span class="cx-stamp" ${i < stamps ? 'data-on' : ''} ${i === total - 1 ? 'data-gift' : ''} ${i === popIdx ? 'data-pop' : ''}>${i < stamps ? ui.icon('scissors') : i === total - 1 ? ui.icon('i-gift') : ''}</span>`).join('')}</div><div class="cx-vstack" style="gap:10px;padding:0 16px 16px">${ui.text(say(done ? complete : progress), done ? 'label' : 'muted', 'p')}${done ? ui.button({ label: `${ui.icon('i-gift')}${redeem}`, variant: 'default', size: 'default', cls: 'cx-fill', attrs: 'data-redeem' }) : ui.button({ label: `${ui.icon('plus')}${stamp}`, variant: 'outline', size: 'default', cls: 'cx-fill', attrs: 'data-stamp' })}</div></div>`; };
@@ -1485,12 +1562,16 @@ let wheelsNow = [];
1485
1562
 
1486
1563
  // ── Notifications bell { items [{icon,tone,title,desc,time,unread}] } ──
1487
1564
  kit.bell = (root, { items }) => {
1488
- root.innerHTML = `<div class="cx-hstack" style="gap:12px">${ui.text('Avisos', 'heading')}<span class="cx-grow"></span><span class="cx-bell-wrap">${ui.button({ icon: 'i-bell', variant: 'outline', size: 'icon', cls: 'cx-has-indicator', attrs: 'data-bell aria-haspopup="dialog" aria-label="Avisos"' })}</span></div>${ui.text('La campana del sistema: toca para ver los avisos.', 'caption', 'p')}`;
1489
- const pop = document.createElement('div'); pop.className = 'cn-popover-content pg-pop cx-bellpop'; pop.setAttribute('role', 'dialog'); pop.setAttribute('aria-label', 'Avisos'); pop.hidden = true; lib.appendChild(pop);
1565
+ const T = (k, ...a) => tx(root, k, ...a);
1566
+ root.innerHTML = `<div class="cx-hstack" style="gap:12px">${ui.text(T('Notices'), 'heading')}<span class="cx-grow"></span><span class="cx-bell-wrap">${ui.button({ icon: 'i-bell', variant: 'outline', size: 'icon', cls: 'cx-has-indicator', attrs: 'data-bell aria-haspopup="dialog" aria-label="Avisos"' })}</span></div>${ui.text(T('The system bell: tap to see the notices.'), 'caption', 'p')}`;
1567
+ const pop = document.createElement('div'); pop.className = 'cn-popover-content pg-pop cx-bellpop'; pop.setAttribute('role', 'dialog'); pop.setAttribute('aria-label', T('Notices')); pop.hidden = true; lib.appendChild(pop);
1490
1568
  const btnEl = $('[data-bell]', root);
1491
1569
  const unread = () => items.filter((i) => i.unread).length;
1492
- const syncDot = () => { $('.cx-indicator', btnEl)?.remove(); if (unread()) btnEl.insertAdjacentHTML('beforeend', `<span class="cx-indicator cx-count-ind" data-tone="bad">${unread()}</span>`); btnEl.setAttribute('aria-label', unread() ? `Avisos, ${unread()} sin leer` : 'Avisos'); };
1493
- const paint = () => { pop.innerHTML = `<div class="cx-hstack" style="justify-content:space-between;padding:10px 12px;border-bottom:1px solid var(--border)">${ui.text('Avisos', 'heading')}${unread() ? ui.button({ label: 'Marcar todo como leído', variant: 'ghost', size: 'xs', attrs: 'data-readall' }) : ''}</div>${items.length ? `<div class="cx-bell-list cx-scroll">${items.map((it, i) => `<button class="cn-item cn-item-variant-default cn-item-size-sm cx-bell-it" data-ni="${i}" ${it.unread ? 'data-unread' : ''}>${ui.itemMedia(`<span class="cx-tonal-ic" data-tone="${it.tone}">${ui.icon(it.icon)}</span>`, 'default')}<div class="cn-item-content"><div class="cn-item-title">${it.title}</div><p class="cn-item-description">${it.desc}</p></div><div class="cn-item-actions cx-vstack" style="width:auto;align-items:flex-end;gap:6px">${ui.text(it.time, 'caption')}${it.unread ? '<span class="cx-unread-dot" aria-label="Sin leer"></span>' : ''}</div></button>`).join('')}</div>` : `<div class="cn-empty" style="padding:28px 12px"><div class="cn-empty-header"><div class="cn-empty-media cn-empty-media-icon">${ui.icon('i-bell')}</div><div class="cn-empty-title">Todo al día</div></div></div>`}`; };
1570
+ // when the unread count GOES UP (not on the first paint, not when reading), the bell gives one small nudge
1571
+ let lastUnread = null;
1572
+ const nudge = () => { const n = unread(); if (lastUnread !== null && n > lastUnread) $('svg', btnEl)?.animate([{ rotate: '0deg' }, { rotate: '14deg' }, { rotate: '-10deg' }, { rotate: '6deg' }, { rotate: '0deg' }], { duration: ms('--t-move') * 2.5, easing: ease('--ease-out') }); lastUnread = n; };
1573
+ const syncDot = () => { nudge(); $('.cx-indicator', btnEl)?.remove(); if (unread()) btnEl.insertAdjacentHTML('beforeend', `<span class="cx-indicator cx-count-ind" data-tone="bad">${unread()}</span>`); btnEl.setAttribute('aria-label', unread() ? T('Notices, {0} unread', unread()) : T('Notices')); };
1574
+ const paint = () => { pop.innerHTML = `<div class="cx-hstack" style="justify-content:space-between;padding:10px 12px;border-bottom:1px solid var(--border)">${ui.text(T('Notices'), 'heading')}${unread() ? ui.button({ label: T('Mark all as read'), variant: 'ghost', size: 'xs', attrs: 'data-readall' }) : ''}</div>${items.length ? `<div class="cx-bell-list cx-scroll">${items.map((it, i) => `<button class="cn-item cn-item-variant-default cn-item-size-sm cx-bell-it" data-ni="${i}" ${it.unread ? 'data-unread' : ''}>${ui.itemMedia(`<span class="cx-tonal-ic" data-tone="${it.tone}">${ui.icon(it.icon)}</span>`, 'default')}<div class="cn-item-content"><div class="cn-item-title">${it.title}</div><p class="cn-item-description">${it.desc}</p></div><div class="cn-item-actions cx-vstack" style="width:auto;align-items:flex-end;gap:6px">${ui.text(it.time, 'caption')}${it.unread ? `<span class="cx-unread-dot" aria-label="${T('Unread')}"></span>` : ''}</div></button>`).join('')}</div>` : `<div class="cn-empty" style="padding:28px 12px"><div class="cn-empty-header"><div class="cn-empty-media cn-empty-media-icon">${ui.icon('i-bell')}</div><div class="cn-empty-title">${T('All caught up')}</div></div></div>`}`; };
1494
1575
  btnEl.addEventListener('click', () => { if (pop.dataset.state === 'open') return hide(pop, 120); paint(); pop.hidden = false; place(pop, btnEl, 'end', 8); show(pop); });
1495
1576
  pop.addEventListener('click', (e) => { if (e.target.closest('[data-readall]')) { items.forEach((i) => i.unread = false); paint(); syncDot(); } const it = e.target.closest('[data-ni]'); if (it) { items[+it.dataset.ni].unread = false; paint(); syncDot(); } });
1496
1577
  document.addEventListener('pointerdown', (e) => { if (pop.dataset.state === 'open' && !pop.contains(e.target) && !btnEl.contains(e.target)) hide(pop, 120); });
@@ -1499,26 +1580,28 @@ let wheelsNow = [];
1499
1580
 
1500
1581
  // ── Queue (waitlist) { people [{name, service, since}] } ──
1501
1582
  kit.queue = (root, { title, people, offerMinutes = 10 }) => {
1502
- const paint = () => { root.innerHTML = `<div class="cx-hstack" style="justify-content:space-between">${ui.text(title, 'heading')}${ui.badge(`${people.length} esperando`, { variant: 'secondary' })}</div><div class="cn-item-group">${people.map((p, i) => ui.item({ media: `<div class="cn-item-media"><span class="cx-q-pos">${i + 1}</span></div>`, title: p.name, description: p.offer ? `Ofrecido ${p.offer} · ${p.service}` : `${p.service} · espera desde ${p.since}`, actions: p.offer ? `<span class="cx-q-timer" data-qi="${i}">${ui.text(`${String(Math.floor(p.left / 60)).padStart(2, '0')}:${String(p.left % 60).padStart(2, '0')}`, 'strong')}</span>` : ui.button({ label: 'Ofrecer horario', size: 'xs', attrs: `data-offer="${i}"` }) })).join(ui.itemSeparator())}</div>`; };
1503
- root.addEventListener('click', (e) => { const b = e.target.closest('[data-offer]'); if (!b) return; const p = people[+b.dataset.offer]; p.offer = 'hoy 16:30'; p.left = offerMinutes * 60; paint(); });
1583
+ const T = (k, ...a) => tx(root, k, ...a);
1584
+ const paint = () => { root.innerHTML = `<div class="cx-hstack" style="justify-content:space-between">${ui.text(title, 'heading')}${ui.badge(T('{0} waiting', people.length), { variant: 'secondary' })}</div><div class="cn-item-group">${people.map((p, i) => ui.item({ media: `<div class="cn-item-media"><span class="cx-q-pos">${i + 1}</span></div>`, title: p.name, description: p.offer ? T('Offered {0} · {1}', p.offer, p.service) : T('{0} · waiting since {1}', p.service, p.since), actions: p.offer ? `<span class="cx-q-timer" data-qi="${i}">${ui.text(`${String(Math.floor(p.left / 60)).padStart(2, '0')}:${String(p.left % 60).padStart(2, '0')}`, 'strong')}</span>` : ui.button({ label: T('Offer a time'), size: 'xs', attrs: `data-offer="${i}"` }) })).join(ui.itemSeparator())}</div>`; };
1585
+ root.addEventListener('click', (e) => { const b = e.target.closest('[data-offer]'); if (!b) return; const p = people[+b.dataset.offer]; p.offer = T('today 16:30'); p.left = offerMinutes * 60; paint(); });
1504
1586
  setInterval(() => { let changed = false; people.forEach((p, i) => { if (p.offer && p.left > 0) { p.left--; const t = $(`[data-qi="${i}"] .cx-text`, root); if (t) t.textContent = `${String(Math.floor(p.left / 60)).padStart(2, '0')}:${String(p.left % 60).padStart(2, '0')}`; } }); }, 1000);
1505
1587
  paint();
1506
1588
  };
1507
1589
 
1508
1590
  // ── Password field with strength ──
1509
- kit.password = (root, { label = 'Contraseña' } = {}) => {
1510
- const RULES = [['8 caracteres o más', (v) => v.length >= 8], ['Un número', (v) => /\d/.test(v)], ['Una mayúscula', (v) => /[A-ZÁÉÍÓÚÑ]/.test(v)], ['Un símbolo', (v) => /[^\w\s]/.test(v)]];
1511
- const NAMES_S = ['Muy débil', 'Débil', 'Regular', 'Buena', 'Fuerte'], TONES = ['bad', 'bad', 'wait', 'ok', 'ok'];
1512
- root.innerHTML = `${ui.field({ id: 'pw', label, control: `<div data-slot="input-group" class="cn-input-group"><div class="cn-input-group-addon cn-input-group-addon-align-inline-start">${ui.icon('i-lock')}</div><input class="cn-input cn-input-group-input" id="pw" type="password" autocomplete="new-password" aria-describedby="pw-s"><div class="cn-input-group-addon cn-input-group-addon-align-inline-end">${ui.button({ icon: 'i-eye', variant: 'ghost', size: 'icon-xs', attrs: 'data-pw-toggle aria-label="Mostrar contraseña" title="Mostrar"' })}</div></div>` })}<div class="cx-pw-meter" id="pw-s" aria-live="polite"><div class="cx-pw-bars">${Array.from({ length: 4 }, () => ui.progress(0, 'Fuerza')).join('')}</div>${ui.badge('Escribe una contraseña', { tone: 'muted' })}</div><ul class="cx-pw-rules">${RULES.map(([t]) => `<li>${ui.icon('i-tick')}${ui.text(t, 'muted')}</li>`).join('')}</ul>`;
1591
+ kit.password = (root, { label = 'Password' } = {}) => {
1592
+ const T = (k, ...a) => tx(root, k, ...a);
1593
+ const RULES = [[T('8 characters or more'), (v) => v.length >= 8], [T('A number'), (v) => /\d/.test(v)], [T('A capital letter'), (v) => /[A-ZÁÉÍÓÚÑ]/.test(v)], [T('A symbol'), (v) => /[^\w\s]/.test(v)]];
1594
+ const NAMES_S = [T('Very weak'), T('Weak'), T('Fair'), T('Good'), T('Strong')], TONES = ['bad', 'bad', 'wait', 'ok', 'ok'];
1595
+ root.innerHTML = `${ui.field({ id: 'pw', label, control: `<div data-slot="input-group" class="cn-input-group"><div class="cn-input-group-addon cn-input-group-addon-align-inline-start">${ui.icon('i-lock')}</div><input class="cn-input cn-input-group-input" id="pw" type="password" autocomplete="new-password" aria-describedby="pw-s"><div class="cn-input-group-addon cn-input-group-addon-align-inline-end">${ui.button({ icon: 'i-eye', variant: 'ghost', size: 'icon-xs', attrs: `data-pw-toggle aria-label="${T('Show password')}" title="${T('Show')}"` })}</div></div>` })}<div class="cx-pw-meter" id="pw-s" aria-live="polite"><div class="cx-pw-bars">${Array.from({ length: 4 }, () => ui.progress(0, T('Strength'))).join('')}</div>${ui.badge(T('Type a password'), { tone: 'muted' })}</div><ul class="cx-pw-rules">${RULES.map(([t]) => `<li>${ui.icon('i-tick')}${ui.text(t, 'muted')}</li>`).join('')}</ul>`;
1513
1596
  const inp = $('#pw', root);
1514
1597
  inp.addEventListener('input', () => {
1515
1598
  const v = inp.value, ok = RULES.map(([, f]) => f(v)), score = v ? Math.min(4, ok.filter(Boolean).length + (v.length >= 12 ? 1 : 0)) : 0;
1516
1599
  $$('.cx-pw-bars .cn-progress-indicator', root).forEach((b, i) => { b.style.transform = `translateX(-${i < score ? 0 : 100}%)`; b.parentElement.dataset.tone = TONES[score]; });
1517
- $('.cx-pw-meter .cn-badge', root).outerHTML = ui.badge(v ? NAMES_S[score] : 'Escribe una contraseña', { tone: v ? TONES[score] : 'muted' });
1600
+ $('.cx-pw-meter .cn-badge', root).outerHTML = ui.badge(v ? NAMES_S[score] : T('Type a password'), { tone: v ? TONES[score] : 'muted' });
1518
1601
  $$('.cx-pw-rules li', root).forEach((li, i) => li.toggleAttribute('data-ok', ok[i]));
1519
1602
  });
1520
- $('[data-pw-toggle]', root).addEventListener('click', (e) => { const b = e.currentTarget, showIt = inp.type === 'password'; inp.type = showIt ? 'text' : 'password'; b.innerHTML = ui.icon(showIt ? 'i-eyeoff' : 'i-eye'); b.setAttribute('aria-label', showIt ? 'Ocultar contraseña' : 'Mostrar contraseña'); });
1603
+ $('[data-pw-toggle]', root).addEventListener('click', (e) => { const b = e.currentTarget, showIt = inp.type === 'password'; inp.type = showIt ? 'text' : 'password'; b.innerHTML = ui.icon(showIt ? 'i-eyeoff' : 'i-eye'); b.setAttribute('aria-label', showIt ? T('Hide password') : T('Show password')); });
1521
1604
  };
1522
1605
 
1523
1606
 
1524
- export { CB_CHECK, kit, ui, $, $$, lib, island, openModal, closeModal, CONTENT, body, foot, mdl, easeHeight, place, show, hide, initTabs, makeWheel, today, addDays, DAY, MON, DAYLONG, sameDay, fmtDay, money0, fold, hue, ini, reformat, groupThousands, setCheck };
1607
+ export { t, langOf, words, relang, CB_CHECK, kit, ui, $, $$, lib, island, openModal, closeModal, CONTENT, body, foot, mdl, easeHeight, place, show, hide, initTabs, makeWheel, today, addDays, DAY, MON, DAYLONG, sameDay, fmtDay, money0, fold, hue, ini, reformat, groupThousands, setCheck };