@muten/shadcn 0.1.1 → 0.1.3

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 (48) hide show
  1. package/AGENTS.md +69 -1
  2. package/README.md +7 -0
  3. package/kit.css +2406 -24
  4. package/motion.css +15 -0
  5. package/package.json +3 -2
  6. package/registry/accordion.muten +10 -0
  7. package/registry/agenda.js +72 -24
  8. package/registry/agenda.muten +30 -2
  9. package/registry/band.muten +1 -1
  10. package/registry/bio-play.muten +16 -0
  11. package/registry/booking-play.muten +43 -0
  12. package/registry/button.muten +3 -2
  13. package/registry/chat-play.muten +77 -0
  14. package/registry/day-plan.muten +40 -0
  15. package/registry/device-frame.muten +25 -7
  16. package/registry/gradient-blinds.js +88 -89
  17. package/registry/gradient-blinds.muten +11 -4
  18. package/registry/heatmap.muten +30 -0
  19. package/registry/inbox-play.muten +56 -0
  20. package/registry/input-group.muten +3 -1
  21. package/registry/kit/collections.js +10 -2
  22. package/registry/kit/core.js +519 -99
  23. package/registry/kit/structure.js +3 -3
  24. package/registry/kit-widget.js +1 -1
  25. package/registry/legal-doc.muten +27 -0
  26. package/registry/modal-behavior.js +11 -0
  27. package/registry/modal.muten +1 -1
  28. package/registry/order-play.muten +33 -0
  29. package/registry/permission-matrix.muten +31 -5
  30. package/registry/phone-bar.muten +18 -0
  31. package/registry/phone-shell.muten +1 -1
  32. package/registry/product-play.muten +20 -0
  33. package/registry/progress.muten +5 -2
  34. package/registry/rank-item.muten +32 -0
  35. package/registry/screen-tour.js +59 -0
  36. package/registry/screen-tour.muten +10 -0
  37. package/registry/share-bar.muten +22 -0
  38. package/registry/sidebar.muten +9 -0
  39. package/registry/site-footer.muten +37 -0
  40. package/registry/stacked-bars.js +75 -0
  41. package/registry/stacked-bars.muten +13 -0
  42. package/registry/stamp-card.muten +5 -2
  43. package/registry/stat-band.muten +5 -1
  44. package/registry/stat.muten +19 -7
  45. package/registry/veil.muten +18 -0
  46. package/registry/zoom-control.muten +3 -3
  47. package/registry.json +116 -0
  48. package/scripts/reuse-audit.mjs +37 -0
@@ -13,7 +13,7 @@ const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<
13
13
  export function phoneShell(root, i, core, on) {
14
14
  const { $, $$ } = core;
15
15
  const tabs = list(i.tabs), rows = list(i.rows);
16
- root.innerHTML = `<div class="cx-phone-frame"><div class="cx-ph-screen">
16
+ root.innerHTML = `<div class="cx-device cx-ph-device" data-kind="phone"><div class="cx-device-screen"><div class="cx-ph-screen">
17
17
  <div class="cx-ph-status"><span>9:41</span><span class="cx-ph-island"></span><span>100 %</span></div>
18
18
  <header class="cx-ph-head"><div class="cx-ph-bar">
19
19
  <button data-slot="button" class="cn-button cn-button-variant-ghost cn-button-size-icon group/button" aria-label="Atrás" title="Atrás"><svg><use href="#left"/></svg></button>
@@ -22,7 +22,7 @@ export function phoneShell(root, i, core, on) {
22
22
  </div></header>
23
23
  <div class="cx-ph-body cx-scroll"></div>
24
24
  <nav class="cx-ph-nav" aria-label="Principal">${tabs.map((t, k) => `<button class="cn-button cn-button-variant-ghost cx-size-tile cx-ph-tab" data-tab="${k}" ${k === 0 ? 'aria-current="page"' : ''}>${t.badge ? `<span class="cx-ph-ico"><svg><use href="#${t.icon}"/></svg><i class="cx-ph-badge">${esc(t.badge)}</i></span>` : `<svg><use href="#${t.icon}"/></svg>`}<span>${esc(t.label)}</span></button>`).join('')}</nav>
25
- </div></div>`;
25
+ </div></div></div>`;
26
26
  const head = $('.cx-ph-head', root), phBody = $('.cx-ph-body', root);
27
27
  const screen = (label) => {
28
28
  const mine = rows.filter((r) => r.tab === label);
@@ -299,7 +299,7 @@ export function booking(root, i, core, on) {
299
299
  b.setAttribute('aria-pressed', !!picked.day && key(d) === key(picked.day));
300
300
  // the name starts with what the tile shows («vie 9»), so a voice user can say it; the long day and the state follow
301
301
  b.setAttribute('aria-label', `${key(d) === key(today) ? 'hoy' : DAY[d.getDay()]} ${d.getDate()}, ${DAYLONG[d.getDay()]}${s === null ? ', cerrado' : s.length ? `, ${s.length} horarios` : ', sin horarios'}`);
302
- b.innerHTML = `<small>${key(d) === key(today) ? 'hoy' : DAY[d.getDay()]}</small><b>${d.getDate()}</b><i></i>`;
302
+ b.innerHTML = `<small>${key(d) === key(today) ? 'hoy' : DAY[d.getDay()]}</small> <b>${d.getDate()}</b><i></i>`;
303
303
  b.onclick = () => pickDay(d);
304
304
  weekEl.appendChild(b);
305
305
  }
@@ -36,7 +36,7 @@ export function mount(el, inputs, on) {
36
36
  cardField: ({ kit }) => kit.cardField(el, { onSubmit: (x) => on.action?.(JSON.stringify(x || {})) }),
37
37
  memberList: ({ kit }, i) => kit.memberList(el, { members: list(i.members), roles: list(i.roles), ownerRole: i.owner || '', noun: [i.one || 'persona', i.many || 'personas'], invite: { cta: i.cta || 'Invitar', title: i.cta || 'Invitar', description: i.note || '' } }),
38
38
  permissionMatrix: ({ kit }, i) => kit.permissionMatrix(el, { roles: list(i.roles).map((r) => r.name ?? r), rows: list(i.rows), lockedRole: i.locked || '', onChange: (rows) => on.action?.(JSON.stringify(rows)) }),
39
- stampCard: ({ kit }, i) => kit.stampCard(el, { store: i.store || '', owner: i.owner || '', total: Number(i.total) || 10, stamps: Number(i.stamps) || 0, reward: i.reward || '' }),
39
+ stampCard: ({ kit }, i) => kit.stampCard(el, { store: i.store || '', owner: i.owner || '', total: Number(i.total) || 10, stamps: Number(i.stamps) || 0, reward: i.reward || '', ...Object.fromEntries(['title', 'progress', 'complete', 'count', 'redeem', 'stamp'].filter((k) => i[k]).map((k) => [k, i[k]])) }),
40
40
  bell: ({ kit }, i) => kit.bell(el, { items: list(i.items) }),
41
41
  queue: ({ kit }, i) => kit.queue(el, { title: i.title || '', people: list(i.people), offerMinutes: Number(i.minutes) || 10 }),
42
42
  password: ({ kit }, i) => kit.password(el, { label: i.label || 'Contraseña' }),
@@ -0,0 +1,27 @@
1
+ # Legal document - the pages a business must have (terms, privacy, data deletion), the way Storio's site writes them:
2
+ # the title and its line, then two columns, the contents (they follow the reading: the section on screen is marked)
3
+ # and the text at reading width, each section titled «N. Title». On a phone the contents become a row that slides.
4
+ # DocHead(title: "Términos y condiciones") { Text "Última actualización: 7 de octubre de 2026" class("cx-doc-line") }
5
+ # DocBody {
6
+ # DocToc(label: "Contenido") { DocTocLink(label: "1. Quiénes somos", to: "#s1") DocTocLink(label: "2. Tus datos", to: "#s2") }
7
+ # DocArticle { DocSection(anchor: "s1", title: "1. Quiénes somos") { Text "Storio es un producto de Candhrly LLC." class("cx-doc-p") } }
8
+ # }
9
+ # shadcn-for-muten · parts · no deps · kit.css (.cx-doc-*).
10
+ part DocHead(title: text) {
11
+ Section class("cx-mk-col cx-doc-head") {
12
+ Title "{$title}" h1 class("cx-mk-h1 cx-mk-h1-wrap cx-doc-h1")
13
+ slot
14
+ }
15
+ }
16
+ part DocBody() { Section class("cx-mk-col cx-doc-body") { slot } }
17
+ part DocToc(label: text = "Contenido") { Stack class("cx-navbar cx-doc-toc") data(orientation: "vertical", spy: "on") aria(role: "navigation", label: $label) { slot } }
18
+ part DocTocLink(label: text, to: text) {
19
+ Link "{$label}" -> "{$to}" class("cn-button cn-button-variant-ghost cn-button-size-sm group/button cx-nav cx-doc-toc-item") data(slot: "button", marker: "fill")
20
+ }
21
+ part DocArticle() { Stack class("cx-doc-article") { slot } }
22
+ part DocSection(anchor: text, title: text) {
23
+ Stack class("cx-doc-sec") id("{$anchor}") {
24
+ Title "{$title}" h2 class("cx-doc-h2")
25
+ slot
26
+ }
27
+ }
@@ -33,6 +33,17 @@ export function mount(el, inputs, on) {
33
33
  if (pushed) { pushed = false; history.back(); }
34
34
  };
35
35
  mdl.tabIndex = -1;
36
+ // A link inside (a menu in a drawer): close first, then go. Closing undoes the history entry the modal pushed, and
37
+ // doing that after the link's own navigation would take the person straight back to where they were.
38
+ mdl.addEventListener('click', (e) => {
39
+ const a = e.target.closest('a[href]');
40
+ if (!a || !isOpen || !pushed || a.target || e.defaultPrevented || e.button > 0 || e.metaKey || e.ctrlKey || e.shiftKey) return;
41
+ const url = new URL(a.getAttribute('href'), location.href); if (url.origin !== location.origin) return;
42
+ e.preventDefault(); e.stopPropagation();
43
+ const go = () => { removeEventListener('popstate', go); history.pushState({ muDepth: (history.state?.muDepth ?? 0) + 1 }, '', url.pathname + url.search + url.hash); dispatchEvent(new PopStateEvent('popstate')); };
44
+ pushed = false; addEventListener('popstate', go);
45
+ close(); history.back();
46
+ }, true);
36
47
  addEventListener('popstate', () => { if (isOpen) { pushed = false; close(); } });
37
48
  document.addEventListener('keydown', (e) => {
38
49
  if (!isOpen) return;
@@ -20,7 +20,7 @@ part Modal(open: bool, onClose: action, title: text = "", description: text = ""
20
20
  Span "{$title}" class("cx-modal-title")
21
21
  Span "{$description}" class("cx-modal-desc")
22
22
  }
23
- when $dismiss != "off" { Button -> $onClose class("cn-button cn-button-variant-ghost cn-button-size-icon-sm group/button cx-modal-x") aria(label: "Cerrar") { Icon "lucide:x" } }
23
+ when $dismiss != "off" { Btn(variant: "ghost", size: "icon-sm", label: "Cerrar", onClick: $onClose) class("cx-modal-x") { Icon "lucide:x" } }
24
24
  Stack class("cx-modal-body") { slot }
25
25
  ModalBehavior(open: $open, onClose: $onClose)
26
26
  }
@@ -0,0 +1,33 @@
1
+ # OrderPlay - an order shown, not used: who ordered, the products with their photos, how it is paid and delivered,
2
+ # and the one next step. In a loop the step is pressed and the order moves on (its badge and the button change).
3
+ # BookingPlay's twin, for a landing's bento: the same sheet, the same rhythm. The real board is OrderBoard.
4
+ # OrderPlay(who: "Diego Paredes", sub: "#1046 · hace 2 min", status: "Nuevo", statusNext: "Preparando",
5
+ # pay: "Efectivo al retirar", total: "USD 32", delivery: "Retira hoy · 6:00 PM", cta: "Preparar",
6
+ # ctaNext: "Avisar que está listo", label: "Un pedido como te llega") {
7
+ # OrderPlayItem(name: "Pomada mate", detail: "1 × USD 14", price: "USD 14", photo: "/img/pomada.webp")
8
+ # }
9
+ # photo: the client's face (empty = their initial).
10
+ # Reduced motion: the order as it arrives. shadcn-for-muten · part · no deps · kit.css (.cx-odp).
11
+ part OrderPlay(who: text, photo: text = "", sub: text = "", status: text = "", statusNext: text = "", pay: text = "", total: text = "", delivery: text = "", cta: text = "", ctaNext: text = "", label: text = "") {
12
+ Stack class("cx-odp cx-play") aria(role: "img", label: $label) {
13
+ Stack class("cx-odp-who") aria(hidden: true) {
14
+ when $photo != "" { Image "{$photo}" alt("") class("cx-odp-av cx-play-face") }
15
+ when $photo == "" { Span "{initial($who)}" class("cx-odp-av") }
16
+ Stack class("cx-odp-name") { Span "{$who}" class("cx-odp-t") Span "{$sub}" class("cx-odp-s") }
17
+ when $status != "" { Stack class("cx-odp-badge") data(next: $statusNext != "") { Span "{$status}" class("cx-odp-b1") Span "{$statusNext}" class("cx-odp-b2") } }
18
+ }
19
+ Stack class("cx-odp-items") aria(hidden: true) { slot }
20
+ Stack class("cx-odp-rows") aria(hidden: true) {
21
+ Stack class("cx-odp-row") { Icon "lucide:wallet" Span "{$pay}" class("cx-odp-rt") Span "{$total}" class("cx-odp-total") }
22
+ Stack class("cx-odp-row") { Icon "lucide:store" Span "{$delivery}" class("cx-odp-rt") }
23
+ }
24
+ Stack class("cx-odp-go") data(next: $ctaNext != "") aria(hidden: true) { Span "{$cta}" class("cx-odp-g1") Span "{$ctaNext}" class("cx-odp-g2") }
25
+ }
26
+ }
27
+ part OrderPlayItem(name: text, detail: text = "", price: text = "", photo: text = "") {
28
+ Stack class("cx-odp-item") {
29
+ when $photo != "" { Image "{$photo}" alt("") class("cx-odp-ph") }
30
+ Stack class("cx-odp-name") { Span "{$name}" class("cx-odp-t") Span "{$detail}" class("cx-odp-s") }
31
+ Span "{$price}" class("cx-odp-price")
32
+ }
33
+ }
@@ -1,6 +1,32 @@
1
- # PermissionMatrix - any rows × any roles; one role can be locked (always allowed).
2
- # PermissionMatrix(roles: @roleNames, rows: @permissions, locked: "Propietario", onChange: savePerms) rows: {name, description, allow [1,0,…]}
3
- # shadcn-for-muten · part over KitWidget (kit core).
4
- part PermissionMatrix(roles: list, rows: list, locked: text = "", onChange: action) {
5
- Custom KitWidget inputs(name: "permissionMatrix", roles: $roles, rows: $rows, locked: $locked) on(action: $onChange) class("cx-perms")
1
+ # PermissionMatrix - what each role may do: any permissions (rows) × any roles (columns), a checkbox where they meet, one
2
+ # role locked (always allowed). A real table (muten's Table / Row / Cell): the header names line up over their column and
3
+ # every checkbox sits in the middle of its cell. On a phone the columns narrow; the table scrolls sideways if it must.
4
+ # The grants are a list of "permission:role" keys, so the page flips one with `toggle`:
5
+ # state { granted = ["Ver:Editor", "Ver:Lector", "Editar:Editor"] : list<text> }
6
+ # action flip(key: text) mutates granted { granted.toggle(key) }
7
+ # PermissionMatrix(roles: @roles, rows: @permissions, granted: granted, locked: "Propietario", onToggle: flip)
8
+ # roles {name} · rows {name, description}. label names the first column.
9
+ # shadcn-for-muten · part · no deps · kit.css (.cx-perm).
10
+ part PermissionMatrix(roles: list, rows: list, granted: list, locked: text = "", label: text = "Permiso", onToggle: action) {
11
+ Stack class("cx-dt-scroll cx-scroll cx-perms") {
12
+ Table class("cx-dt cx-perm") {
13
+ Row head {
14
+ Cell "{$label}"
15
+ each $roles as role { Cell "{role.name}" class("cx-perm-c") }
16
+ }
17
+ each $rows as perm {
18
+ Row {
19
+ Cell {
20
+ Span "{perm.name}" class("cx-perm-name")
21
+ Span "{perm.description}" class("cx-perm-desc")
22
+ }
23
+ each $roles as role {
24
+ Cell class("cx-perm-c") {
25
+ Checkbox checked(role.name == $locked or $granted contains perm.name + ":" + role.name) -> $onToggle(perm.name + ":" + role.name) disabled when role.name == $locked aria(label: role.name + ": " + perm.name)
26
+ }
27
+ }
28
+ }
29
+ }
30
+ }
31
+ }
6
32
  }
@@ -0,0 +1,18 @@
1
+ # PhoneBar - the app's bar at the bottom of a phone, the shape Storio's app uses: three fixed cells. The left one holds
2
+ # the menu (or a back), the middle one the screen's tools as grey icons, the right one the screen's «create» (black),
3
+ # or nothing. A screen whose buttons live in its own toolbar on a desktop hands them here on a phone (AgendaLive with
4
+ # phone: "nav" does), so the page stays clean and the hand finds them in the same place on every screen.
5
+ # PhoneBar(label: "Agenda") {
6
+ # PhoneBarCell(side: "left") { PhoneBarButton(label: "Menú", onClick: openMenu) { Icon "lucide:menu" } }
7
+ # PhoneBarCell() { PhoneBarButton(label: "Día anterior", onClick: prev) { Icon "lucide:chevron-left" } … }
8
+ # PhoneBarCell(side: "right") { PhoneBarButton(label: "Nueva cita", look: "primary", onClick: add) { Icon "lucide:plus" } }
9
+ # }
10
+ # place: "" (fixed to the bottom of the screen, only below 64rem: a desktop keeps its toolbars) · "inline" (where it
11
+ # sits, always) · "frame" (the bottom of its positioned box: a phone mockup). The page's scroll keeps room for it with
12
+ # padding-bottom: var(--cx-pbar-space). Every button needs its label: icon-only on screen, named for a screen reader.
13
+ # shadcn-for-muten · part · no deps · kit.css (.cx-pbar).
14
+ part PhoneBar(label: text = "Acciones", place: text = "") { Nav class("cx-pbar") data(place: $place) aria(label: $label) { slot } }
15
+ part PhoneBarCell(side: text = "center") { Stack class("cx-pbar-cell") data(side: $side) { slot } }
16
+ part PhoneBarButton(label: text = "", onClick: action, look: text = "", current: bool = false) {
17
+ Button -> $onClick class("cx-pbar-btn") data(look: $look) aria(label: $label, pressed: $current) { slot }
18
+ }
@@ -3,7 +3,7 @@
3
3
  # on --selected with its dot; a count never changes the width). Respects the phone's safe area.
4
4
  # PhoneShell(tabs: @tabs, rows: @rows, onSelect: pickTab)
5
5
  # tabs {label, icon, badge} · rows {tab, section, title, subtitle, value}: each tab's screen is its rows, grouped by section.
6
- # shadcn-for-muten · Custom (kit/structure.js) · kit.css (.cx-phone-frame, .cx-ph-*).
6
+ # Drawn inside the library phone (DeviceFrame's rule). shadcn-for-muten · Custom (kit/structure.js) · kit.css (.cx-ph-*).
7
7
  part PhoneShell(tabs: list, rows: list, onSelect: action) {
8
8
  Custom PhoneShell inputs(tabs: $tabs, rows: $rows) on(select: $onSelect) class("cx-ph-host")
9
9
  }
@@ -0,0 +1,20 @@
1
+ # ProductPlay - a product sheet shown, not used: the photo, the shop, the name with its price, a line about it, its
2
+ # options (one picked) and the quantity beside «Agregar». For a landing; a picture (aria-hidden), `label` says what.
3
+ # ProductPlay(photo: "/pomade.webp", shop: "Bruma Barbería", name: "Pomada mate", price: "USD 14",
4
+ # text: "Fijación media.", cta: "Agregar · USD 14", label: "…") {
5
+ # ProductPlayOption(label: "50 g") ProductPlayOption(label: "100 g", on: "yes")
6
+ # }
7
+ # shadcn-for-muten · part · no deps · kit.css (.cx-pdp).
8
+ part ProductPlay(photo: text, shop: text, name: text, price: text, text: text = "", cta: text, label: text = "") {
9
+ Stack class("cx-pdp cx-play") aria(role: "img", label: $label) {
10
+ Image "{$photo}" alt("") class("cx-pdp-img")
11
+ Stack class("cx-pdp-in") aria(hidden: true) {
12
+ Span "{$shop}" class("cx-pdp-k")
13
+ Stack class("cx-pdp-t") { Span "{$name}" Span "{$price}" }
14
+ Span "{$text}" class("cx-pdp-d")
15
+ Stack class("cx-pdp-opts") { slot }
16
+ Stack class("cx-pdp-go") { Stack class("cx-pdp-q") { Icon "lucide:minus" Span "1" Icon "lucide:plus" } Span "{$cta}" class("cx-pdp-add") }
17
+ }
18
+ }
19
+ }
20
+ part ProductPlayOption(label: text, on: text = "") { Span "{$label}" class("cx-pdp-opt") data(on: $on) }
@@ -1,7 +1,10 @@
1
1
  # Progress - a determinate bar, value 0-100; it eases between values. Progress(value: done, label: "Pasos hechos")
2
+ # tone: the bar's colour from the series palette: brand (the skin's accent) | coral | teal | amber | blue | pink. Empty: the
3
+ # primary colour. A row of channels or a ranking is a Progress per row, each in its tone.
4
+ # Progress(value: 46, tone: "coral", label: "Instagram")
2
5
  # shadcn-for-muten · part · no deps · Nova (.cn-progress) + kit.css (the value rides --w).
3
- part Progress(value: number, label: text = "Progreso") {
4
- Stack class("cn-progress") data(slot: "progress") aria(role: "progressbar", label: $label, valuemin: 0, valuemax: 100, valuenow: $value) {
6
+ part Progress(value: number, label: text = "Progreso", tone: text = "") {
7
+ Stack class("cn-progress") data(slot: "progress", tone: $tone) aria(role: "progressbar", label: $label, valuemin: 0, valuemax: 100, valuenow: $value) {
5
8
  Stack class("cn-progress-indicator") data(slot: "progress-indicator") style(w: "{$value}%")
6
9
  }
7
10
  }
@@ -0,0 +1,32 @@
1
+ # RankItem - one row of a ranking or a short list of things to act on: a lead (the rank «1», or a count «13»), the title
2
+ # with an optional tag, a line under it, an optional bar for its share, and the value on the right. Whatever goes in
3
+ # the slot sits at the end of the row (an action: «Agregar», «Responder»).
4
+ # RankItem(lead: "1", title: "Corte clásico", tag: "Servicio", sub: "38 veces", value: "USD 760", share: 100)
5
+ # RankItem(lead: "13", title: "«tinte»", sub: "No está entre tus servicios") { Btn(variant: "outline", size: "sm", onClick: addService) { Span "Agregar" } }
6
+ # share: 0-100 draws a Progress under the title (leave it at -1 for none). tone: the bar's colour (series palette).
7
+ # busy: true keeps the row's shape with a skeleton.
8
+ # shadcn-for-muten · part · no deps · kit.css (.cx-rank) + Item, Badge, Progress.
9
+ part RankItem(lead: text, title: text = "", tag: text = "", sub: text = "", value: text = "", share: number = -1, tone: text = "brand", busy: bool = false) {
10
+ Stack class("cn-item cn-item-variant-default cn-item-size-sm cx-rank") data(slot: "item", size: "sm", busy: $busy) {
11
+ Stack class("cn-item-media cn-item-media-variant-icon cx-rank-lead") data(slot: "item-media", variant: "icon") { Span "{$lead}" }
12
+ Stack class("cn-item-content cx-rank-mid") data(slot: "item-content") {
13
+ when $busy {
14
+ Skeleton() class("cx-rank-sk-t")
15
+ Skeleton() class("cx-rank-sk-s")
16
+ }
17
+ when not $busy {
18
+ Stack class("cx-rank-t") {
19
+ Span "{$title}" class("cn-item-title") data(slot: "item-title")
20
+ when $tag != "" { Span "{$tag}" class("cn-badge cn-badge-variant-secondary group/badge") data(slot: "badge") }
21
+ }
22
+ Text "{$sub}" class("cn-item-description cx-rank-sub") data(slot: "item-description")
23
+ when $share >= 0 { Progress(value: $share, label: $title, tone: $tone) }
24
+ }
25
+ }
26
+ Stack class("cn-item-actions cx-rank-end") data(slot: "item-actions") {
27
+ when $busy { Skeleton() class("cx-rank-sk-v") }
28
+ when not $busy { Span "{$value}" class("cx-rank-v") }
29
+ slot
30
+ }
31
+ }
32
+ }
@@ -0,0 +1,59 @@
1
+ // ScreenTour (muten Custom) - steps beside a phone that plays them on real captures (see screen-tour.muten).
2
+ const SCROLL_MS = 1800;
3
+ export function mount(el, inputs) {
4
+ if (typeof window === 'undefined') return undefined;
5
+ const still = matchMedia('(prefers-reduced-motion: reduce)').matches;
6
+ const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
7
+ let tracks = [], track = 0, index = 0, timer = 0, manual = false, live = false, label = '', width = '300px';
8
+ const paint = () => {
9
+ const t = tracks[track] || { steps: [] };
10
+ const shots = [...new Set(t.steps.map((s) => s.shot).filter(Boolean))];
11
+ const d = t.done || null;
12
+ el.style.setProperty('--tour-w', width);
13
+ el.innerHTML = `${tracks.length > 1 ? `<div class="cx-tabs cx-tour-tabs" data-fill="phone" role="tablist" aria-label="${esc(label)}">${tracks.map((x, i) => `<button type="button" role="tab" class="cn-button cn-button-variant-ghost cn-button-size-sm cx-tab" aria-selected="${i === track}" data-track="${i}">${esc(x.label)}</button>`).join('')}</div>` : ''}
14
+ <div class="cx-tour-body">
15
+ <ol class="cx-stepper cx-stp cx-tour-steps" data-orientation="vertical" data-look="list" style="--n:${t.steps.length}">${t.steps.map((s, i) => `<li class="cx-step cx-stp-step" data-step="${i}"><button type="button" class="cx-tour-hit" aria-label="${i + 1}. ${esc(s.title)}"></button><div class="cn-progress cx-stp-bar"><div class="cn-progress-indicator"></div></div><div class="cx-stp-dot"><span class="cx-stp-num">${i + 1}</span><svg class="cx-stp-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg></div><div class="cx-stp-text"><span class="cx-text cx-stp-label">${esc(s.title)}</span><div class="cx-tour-desc"><p class="cx-stp-desc">${esc(s.text)}</p></div></div></li>`).join('')}</ol>
16
+ <div class="cx-device cx-tour-phone" data-kind="phone" data-sized="true" style="--w:${esc(width)}"><div class="cx-device-screen cx-tour-screen">${shots.map((src, i) => `<img src="${esc(src)}" alt="" data-shot="${esc(src)}" ${i === 0 ? 'data-long' : ''} loading="lazy" decoding="async">`).join('')}<span class="cx-tour-tap" aria-hidden="true"></span>${d ? `<div class="cx-tour-done"><div class="cx-tour-sheet"><span class="cx-tour-grab"></span><span class="cx-tour-ok"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg></span><b class="cx-tour-dt">${esc(d.title)}</b><div class="cx-tour-rows">${(d.rows || []).map(([k, v]) => `<div><span>${esc(k)}</span><b>${esc(v)}</b></div>`).join('')}</div>${d.code ? `<div class="cx-tour-pass"><span>${esc(d.codeLabel || '')}</span><b>${esc(d.code)}</b></div>` : ''}${d.note ? `<p class="cx-tour-note">${esc(d.note)}</p>` : ''}</div></div>` : ''}</div></div>
17
+ </div>`;
18
+ el.setAttribute('aria-label', label);
19
+ };
20
+ const $ = (s) => el.querySelector(s);
21
+ const show = (next) => {
22
+ const t = tracks[track]; if (!t || !t.steps.length) return;
23
+ index = next; const step = t.steps[index];
24
+ el.querySelectorAll('.cx-tour-steps > li').forEach((li, i) => {
25
+ li.dataset.state = i === index ? 'now' : i < index ? 'done' : '';
26
+ li.style.setProperty('--dur', manual || still ? '0s' : (step.ms || 3200) / 1000 + 's');
27
+ const hit = li.querySelector('.cx-tour-hit'); if (hit) { if (i === index) hit.setAttribute('aria-current', 'step'); else hit.removeAttribute('aria-current'); }
28
+ });
29
+ el.querySelectorAll('.cx-tour-screen > img').forEach((img) => { img.dataset.on = img.dataset.shot === step.shot ? '1' : ''; });
30
+ const long = $('.cx-tour-screen > img[data-long]');
31
+ if (long) {
32
+ // the long first page scrolls down on its own; its fade stays the stylesheet's, so it never snaps
33
+ if (step.scroll && !still) {
34
+ long.style.transition = 'opacity 480ms cubic-bezier(.23,1,.32,1)'; long.style.transform = 'translateY(0)'; void long.offsetWidth;
35
+ long.style.transition = `opacity 480ms cubic-bezier(.23,1,.32,1), transform ${SCROLL_MS}ms cubic-bezier(.65,0,.35,1) 500ms`;
36
+ long.style.transform = `translateY(-${step.scroll}%)`;
37
+ } else if (index === 0) { long.style.transition = ''; long.style.transform = 'translateY(0)'; }
38
+ else long.style.transition = 'opacity 480ms cubic-bezier(.23,1,.32,1)';
39
+ }
40
+ const done = $('.cx-tour-done'); if (done) done.dataset.on = step.done ? '1' : '';
41
+ const tap = $('.cx-tour-tap');
42
+ if (tap) { tap.removeAttribute('data-go'); if (step.tap && !still && !manual) { tap.style.left = step.tap[0] + '%'; tap.style.top = step.tap[1] + '%'; tap.style.animationDelay = step.scroll ? SCROLL_MS + 700 + 'ms' : ''; void tap.offsetWidth; tap.setAttribute('data-go', ''); } }
43
+ clearTimeout(timer);
44
+ if (!manual && !still && live) timer = setTimeout(() => show((index + 1) % t.steps.length), step.ms || 3200);
45
+ };
46
+ const pick = (i) => { track = i; manual = false; paint(); show(0); };
47
+ el.addEventListener('click', (e) => {
48
+ const tab = e.target.closest('[data-track]'); if (tab) { pick(+tab.dataset.track); return; }
49
+ const li = e.target.closest('.cx-tour-steps > li'); if (li) { manual = true; show(+li.dataset.step); }
50
+ });
51
+ const io = 'IntersectionObserver' in window ? new IntersectionObserver((entries) => {
52
+ const on = entries[0].isIntersecting;
53
+ if (on && !live) { live = true; if (!manual) show(index); } else if (!on && live) { live = false; clearTimeout(timer); }
54
+ }, { threshold: 0.45 }) : null;
55
+ const set = (next) => { tracks = Array.isArray(next.tracks) ? next.tracks : []; label = next.label || ''; width = next.width || '300px'; if (track >= tracks.length) track = 0; paint(); show(index < ((tracks[track] || {}).steps || []).length ? index : 0); };
56
+ set(inputs || {});
57
+ if (io) io.observe(el); else { live = true; show(0); }
58
+ return (next) => set(next || {});
59
+ }
@@ -0,0 +1,10 @@
1
+ # ScreenTour - how a product is used, played on its real screens: numbered steps on one side, a phone on the other
2
+ # that shows each step's capture, a tap where the finger would land, and at the end the sheet that confirms it. It
3
+ # moves on by itself while it is on screen (each step's bar fills for its time); touching a step goes to it and stays.
4
+ # Several tracks (book · order) get the library's tabs on top. Reduced motion: the first step, still; steps still work.
5
+ # ScreenTour(tracks: tour, label: "Así te reservan") tour = a getter or a state list
6
+ # tracks [{id, label, steps: [{title, text, shot, tap: [x%, y%], ms, scroll (% of the capture's height), done}],
7
+ # done: {title, rows: [[label, value]], code, codeLabel, note}}]
8
+ # shot: an image url (a long capture can scroll: give the first step `scroll`). A step with done: true shows the sheet.
9
+ # shadcn-for-muten · Custom · kit.css (.cx-tour) · host JS screen-tour.js.
10
+ part ScreenTour(tracks: list, label: text = "", width: text = "300px") { Custom ScreenTour inputs(tracks: $tracks, label: $label, width: $width) class("cx-tour") }
@@ -0,0 +1,22 @@
1
+ # ShareBar - how one whole splits: a bar at 100 % cut into its parts, each in its tone, and under it each part's value
2
+ # and name. Citas against pedidos, new clients against returning ones. All parts at 0 leave the empty track. busy: true
3
+ # shows the bar and the values as a skeleton while they load.
4
+ # ShareBar(parts: split, label: "Ganado por tipo")
5
+ # parts {label, value (a number: the part's weight), text (what is written: "USD 3.240", "28"), tone}
6
+ # tone: brand | coral | teal | amber | blue | pink (the series palette; Chart and StackedBars use the same colours).
7
+ # shadcn-for-muten · part · no deps · kit.css (.cx-split).
8
+ part ShareBar(parts: list, label: text = "", busy: bool = false) {
9
+ Stack class("cx-split") data(busy: $busy) aria(busy: $busy) {
10
+ Stack class("cx-split-bar") aria(role: "img", label: $label) {
11
+ each $parts as part { Span "" class("cx-split-seg") data(tone: part.tone) style(f: "{part.value}") }
12
+ }
13
+ Stack class("cx-split-keys") {
14
+ each $parts as part {
15
+ Stack class("cx-split-key") {
16
+ Span "{part.text}" class("cx-split-v")
17
+ Span "{part.label}" class("cx-split-l") data(tone: part.tone)
18
+ }
19
+ }
20
+ }
21
+ }
22
+ }
@@ -6,6 +6,8 @@
6
6
  # SidebarGroup(label: "Principal") { SidebarItem(label: "Agenda", value: "agenda", current: place, count: "14", onSelect: go) { Icon "lucide:calendar" } }
7
7
  # SidebarFooter { … }
8
8
  # }
9
+ # SidebarLink is the same item as a link to a route: the app marks where you are by itself (no `current` to keep).
10
+ # SidebarLink(label: "Citas", to: "/schedule", count: "6") { Icon "lucide:calendar-days" }
9
11
  # The name AppSidebar avoids muten's own Sidebar primitive. shadcn-for-muten · parts · no deps · Nova (.cn-sidebar-*) + kit.css.
10
12
  part AppSidebar(collapsed: bool, label: text = "Menú") {
11
13
  Nav class("cx-sidebar") data(slot: "sidebar", collapsed: $collapsed) aria(label: $label) { Stack class("cn-sidebar-inner cx-sb-inner") data(slot: "sidebar-inner") { slot } }
@@ -25,3 +27,10 @@ part SidebarItem(label: text, value: text, current: text, onSelect: action, coun
25
27
  Span "{$count}" class("cn-sidebar-menu-badge cx-sb-count") data(slot: "sidebar-menu-badge")
26
28
  }
27
29
  }
30
+ part SidebarLink(label: text, to: text, count: text = "") {
31
+ Link -> "{$to}" class("cn-sidebar-menu-button cn-sidebar-menu-button-variant-default cn-sidebar-menu-button-size-default cx-sb-item") data(slot: "sidebar-menu-button", name: $label) {
32
+ slot
33
+ Span "{$label}" class("cx-sb-label")
34
+ Span "{$count}" class("cn-sidebar-menu-badge cx-sb-count") data(slot: "sidebar-menu-badge")
35
+ }
36
+ }
@@ -0,0 +1,37 @@
1
+ # SiteFooter - a site's footer, the one the Storio landing wears without its painting: the brand (logo and a line) with
2
+ # the two ways in on one row over a hairline, then every group of links on one row, then a bar with the company and the
3
+ # ways to reach it. On a phone the brand stacks, the groups go two by two and the bar stacks. Colours come from the
4
+ # theme, so it sits on any skin, light or dark.
5
+ # SiteFooter {
6
+ # FooterHead {
7
+ # FooterBrand(tag: "Vende y agenda por ti.") { Link "" -> "/" aria(label: "Inicio") { Image "/logo.svg" alt("Storio") } }
8
+ # FooterActions { LinkBtn(to: "/login", variant: "outline") { Span "Entrar" } LinkBtn(to: "/signup") { Span "Crear mi tienda" } }
9
+ # }
10
+ # FooterLinks {
11
+ # FooterCol(title: "Producto") { Link "Qué es" -> "/product" Link "Precios" -> "/pricing" }
12
+ # FooterCol(title: "Legal") { Link "Términos" -> "/terms" Link "Privacidad" -> "/privacy" }
13
+ # }
14
+ # FooterBar { Span "© 2026 Candhrly LLC" Link "hello@storio.ink" -> "mailto:hello@storio.ink" }
15
+ # }
16
+ # FooterCol(wide: "on") takes two columns of links on a phone and one long column on a desk (a long list, like trades).
17
+ # The bar puts its first child on the left and the rest on the right.
18
+ # shadcn-for-muten · part · no deps · kit.css (.cx-foot).
19
+ part SiteFooter(label: text = "Pie de página") {
20
+ Footer class("cx-foot") aria(label: $label) { Stack class("cx-foot-in") { slot } }
21
+ }
22
+ part FooterHead() { Stack class("cx-foot-head") { slot } }
23
+ part FooterBrand(tag: text = "") {
24
+ Stack class("cx-foot-brand") {
25
+ Stack class("cx-foot-logo") { slot }
26
+ Text "{$tag}" class("cx-foot-tag")
27
+ }
28
+ }
29
+ part FooterActions() { Stack class("cx-foot-doors") { slot } }
30
+ part FooterLinks() { Stack class("cx-foot-grid") { slot } }
31
+ part FooterCol(title: text, wide: text = "") {
32
+ Nav class("cx-foot-col") data(wide: $wide) aria(label: $title) {
33
+ Span "{$title}" class("cx-foot-h")
34
+ Stack class("cx-foot-links") { slot }
35
+ }
36
+ }
37
+ part FooterBar() { Stack class("cx-foot-bar") { slot } }
@@ -0,0 +1,75 @@
1
+ // StackedBars (muten Custom) - bars over time with one or more series stacked in each (citas + pedidos by month), an
2
+ // axis in the page's unit (USD 8K), a bar you can pick (it says so with aria-pressed and sends its id), a note over the
3
+ // plot while there is nothing to show, and a skeleton while it loads. The data changes in place: the bars slide to their
4
+ // new height, nothing blinks. inputs: rows [{id, label, <key>: number, …}], series [{key, label, tone}], selected,
5
+ // unit ("USD", "$" or empty for plain numbers), locale, empty, emptyTitle, busy, readonly, label. on: pick(id).
6
+ const NS = 'cx-stb';
7
+ const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
8
+ const list = (v) => (Array.isArray(v) ? v : []);
9
+ const SKELETON = [38, 52, 44, 61, 57, 70, 48, 66, 59, 74, 30, 22];
10
+
11
+ // a round top for the axis: 4 steps of 1, 2, 2.5 or 5 × a power of ten
12
+ const niceTop = (max) => {
13
+ if (max <= 0) return 4;
14
+ const rough = max / 4, mag = Math.pow(10, Math.floor(Math.log10(rough))), norm = rough / mag;
15
+ const step = (norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 2.5 ? 2.5 : norm <= 5 ? 5 : 10) * mag;
16
+ return step * 4;
17
+ };
18
+
19
+ export function mount(el, inputs, on) {
20
+ let state = { ...inputs }, sel = inputs.selected ?? '', drawn = false;
21
+ const num = (locale) => new Intl.NumberFormat(locale || 'es', { useGrouping: 'always' }); // 1.800, not 1800 (es leaves 4 digits ungrouped)
22
+ // a word unit takes a space (USD 40), a symbol does not ($40)
23
+ const withUnit = (text, unit) => (!unit ? text : /^[A-Za-z]{2,}$/.test(unit) ? `${unit} ${text}` : `${unit}${text}`);
24
+ const fmt = (v, unit, locale) => withUnit(num(locale).format(v), unit);
25
+ const short = (v, unit, locale) => {
26
+ const a = Math.abs(v), r = (x) => num(locale).format(Math.round(x * 10) / 10);
27
+ const s = a >= 1e6 ? r(v / 1e6) + 'M' : a >= 1e3 ? r(v / 1e3) + 'K' : num(locale).format(v);
28
+ return v === 0 ? '0' : withUnit(s, unit);
29
+ };
30
+
31
+ const paint = () => {
32
+ const rows = list(state.rows), series = list(state.series), unit = state.unit || '', locale = state.locale || 'es';
33
+ const totals = rows.map((r) => series.reduce((sum, s) => sum + (Number(r[s.key]) || 0), 0));
34
+ const top = niceTop(Math.max(0, ...totals)), busy = !!state.busy, none = !busy && totals.every((t) => t === 0);
35
+ const legend = series.length > 1 ? `<ul class="${NS}-legend">${series.map((s) => `<li data-tone="${esc(s.tone || 'brand')}">${esc(s.label)}</li>`).join('')}</ul>` : '';
36
+ // nothing to measure yet: only the 0 stays on the axis
37
+ const ticks = [4, 3, 2, 1, 0].map((k) => `<span>${busy || (none && k) ? '' : esc(short((top / 4) * k, unit, locale))}</span>`).join('');
38
+ const bars = busy
39
+ ? SKELETON.slice(0, Math.max(rows.length, 7)).map((h) => `<span class="${NS}-bar"><span class="cn-skeleton ${NS}-sk" style="height:${h}%"></span></span>`).join('')
40
+ : rows.map((r, i) => {
41
+ const tip = `${r.label}: ${series.map((s) => `${s.label} ${fmt(Number(r[s.key]) || 0, unit, locale)}`).join(' · ')}`;
42
+ const segs = series.map((s) => { const v = Number(r[s.key]) || 0; return v > 0 ? `<span class="${NS}-seg" data-tone="${esc(s.tone || 'brand')}" style="flex-grow:${v}"></span>` : ''; }).join('');
43
+ return `<button type="button" class="${NS}-bar" data-id="${esc(r.id)}" aria-pressed="${String(r.id) === String(sel)}" aria-label="${esc(tip)}" data-name="${esc(tip)}"${totals[i] && !state.readonly ? '' : ' disabled'}><span class="${NS}-stack" style="height:${(totals[i] / top) * 100}%">${segs}</span></button>`;
44
+ }).join('');
45
+ const xs = (busy ? SKELETON.slice(0, Math.max(rows.length, 7)).map(() => ({ label: '' })) : rows).map((r) => `<span data-id="${esc(r.id ?? '')}"${String(r.id) === String(sel) ? ' data-on' : ''}>${esc(r.label)}</span>`).join('');
46
+ const n = busy ? Math.max(rows.length, 7) : rows.length;
47
+ el.innerHTML = `${legend}<div class="${NS}-body"><div class="${NS}-y" aria-hidden="true">${ticks}</div><div class="${NS}-plot"><div class="${NS}-grid" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i></div><div class="${NS}-bars" role="group" aria-label="${esc(state.label || '')}" style="--n:${n}"${busy ? ' aria-busy="true"' : ''}>${bars}</div>${none && state.empty ? `<div class="cx-veil-note"><div class="cx-veil-box">${state.emptyTitle ? `<span class="cx-veil-t">${esc(state.emptyTitle)}</span>` : ''}<span class="cx-veil-s">${esc(state.empty)}</span></div></div>` : ''}</div><span></span><div class="${NS}-x" aria-hidden="true" style="--n:${n}">${xs}</div></div>`;
48
+ if (!drawn && !busy && rows.length && !matchMedia('(prefers-reduced-motion: reduce)').matches) {
49
+ el.querySelectorAll(`.${NS}-stack`).forEach((s, i) => s.animate([{ transform: 'scaleY(0)' }, { transform: 'scaleY(1)' }], { duration: 480, delay: i * 24, easing: 'cubic-bezier(.22,1,.36,1)', fill: 'backwards' }));
50
+ drawn = true;
51
+ }
52
+ };
53
+
54
+ // only the pressed state moves on a pick: the bars stay where they are
55
+ const mark = () => {
56
+ el.querySelectorAll(`.${NS}-bar[data-id]`).forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.id === String(sel))));
57
+ el.querySelectorAll(`.${NS}-x > span`).forEach((x) => x.toggleAttribute('data-on', x.dataset.id === String(sel)));
58
+ };
59
+ el.addEventListener('click', (e) => {
60
+ const bar = e.target.closest(`.${NS}-bar[data-id]`);
61
+ if (!bar || bar.disabled) return;
62
+ sel = bar.dataset.id; mark(); on?.pick?.(sel);
63
+ });
64
+
65
+ paint();
66
+ return (next) => {
67
+ const selChanged = (next.selected ?? '') !== (state.selected ?? '');
68
+ state = { ...next };
69
+ if (selChanged) sel = next.selected ?? '';
70
+ // same rows and series, only the pick moved: no repaint
71
+ const old = el.querySelectorAll(`.${NS}-bar[data-id]`).length;
72
+ paint();
73
+ if (old && !state.busy) mark();
74
+ };
75
+ }
@@ -0,0 +1,13 @@
1
+ # StackedBars - bars over time with one or more series stacked in each: the year by month, citas and pedidos together.
2
+ # The axis is in the page's unit (USD 8K), a bar can be picked (it is pressed, and onPick gets its id), while nothing
3
+ # is there yet a calm note sits over the plot (`empty`, with `emptyTitle` on top), and while it loads (`busy`) a skeleton keeps the shape. When the
4
+ # data changes the bars slide to their new height. Each bar says its values to a screen reader and on hover.
5
+ # StackedBars(rows: @months, series: @kinds, selected: monthId, unit: "USD", onPick: pickMonth, label: "Ventas por mes")
6
+ # rows {id, label, <key>: number…} · series {key, label, tone}: tone brand | coral | teal | amber | blue | pink, the
7
+ # series palette (the first series sits at the bottom). One series is a plain bar chart. A legend shows from two.
8
+ # readonly: true shows the pick but lets no bar be pressed (a plan that cannot change month).
9
+ # For a single quick chart with no picking, muten's own `Chart` primitive is enough.
10
+ # shadcn-for-muten · Custom · no deps · host JS stacked-bars.js · kit.css (.cx-stb).
11
+ part StackedBars(rows: list, series: list, selected: text = "", unit: text = "", locale: text = "es", empty: text = "", emptyTitle: text = "", busy: bool = false, readonly: bool = false, label: text = "", onPick: action) {
12
+ Custom StackedBars inputs(rows: $rows, series: $series, selected: $selected, unit: $unit, locale: $locale, empty: $empty, emptyTitle: $emptyTitle, busy: $busy, readonly: $readonly, label: $label) on(pick: $onPick) class("cx-stb")
13
+ }
@@ -1,7 +1,10 @@
1
1
  # StampCard - a loyalty card: stamps toward a reward (good for any shop).
2
2
  # StampCard(store: "Barbería El Roble", owner: "Laura", total: 10, stamps: 7, reward: "un corte gratis")
3
+ # The words are templates (%owner %n %total %left %reward); the defaults are Spanish:
4
+ # StampCard(…, title: "%owner's card", progress: "%n of %total · %left to go for %reward", complete: "Complete! %reward earned.",
5
+ # count: "%n of %total stamps", redeem: "Redeem reward", stamp: "Stamp a visit")
3
6
  # demo "on": it stamps itself one visit at a time and starts over, for a landing; the first touch stops it.
4
7
  # shadcn-for-muten · part over KitWidget (kit core).
5
- part StampCard(store: text, owner: text, total: number = 10, stamps: number, reward: text, demo: text = "") {
6
- Custom KitWidget inputs(name: "stampCard", store: $store, owner: $owner, total: $total, stamps: $stamps, reward: $reward, demo: $demo) class("cx-stamp-host")
8
+ part StampCard(store: text, owner: text, total: number = 10, stamps: number, reward: text, demo: text = "", title: text = "Cartilla de %owner", progress: text = "%n de %total · faltan %left para %reward", complete: text = "¡Completa! Ganó %reward.", count: text = "%n de %total sellos", redeem: text = "Canjear premio", stamp: text = "Sellar visita") {
9
+ Custom KitWidget inputs(name: "stampCard", store: $store, owner: $owner, total: $total, stamps: $stamps, reward: $reward, demo: $demo, title: $title, progress: $progress, complete: $complete, count: $count, redeem: $redeem, stamp: $stamp) class("cx-stamp-host")
7
10
  }
@@ -1,6 +1,10 @@
1
1
  # StatBand - a row of big numbers split by hairlines (two per row on a phone). Each Stat holds its value in the slot
2
2
  # (a Span, or a CountUp that counts up when it comes into view) and its label under it.
3
3
  # StatBand { Stat(label: "citas al mes") { CountUp(to: "12,400") } Stat(label: "tiendas") { Span "850" } }
4
+ # look: "tiles" puts each number on its own soft tile, left-aligned, for a card inside an app (what the bot did this
5
+ # month: «0 reservas que cerró solo»); the default is the landing's band, centred between hairlines.
6
+ # StatBand(look: "tiles") { Stat(label: "reservas que cerró solo") { Span "12" } Stat(label: "pedidos que tomó solo") { Span "5" } }
7
+ # While the numbers load, put a Skeleton in each Stat's slot.
4
8
  # shadcn-for-muten · parts · no deps · kit.css (.cx-statband).
5
- part StatBand() { Stack class("cx-statband") { slot } }
9
+ part StatBand(look: text = "") { Stack class("cx-statband") data(look: $look) { slot } }
6
10
  part Stat(label: text) { Stack class("cx-statband-cell") { Stack class("cx-statband-value") { slot } Span "{$label}" class("cx-statband-label") } }
@@ -2,21 +2,33 @@
2
2
  # its line. The icon goes in the slot (or leave it empty). `tone` says if the change is good (ok) or bad (bad) -
3
3
  # for a cost or a wait, going down is the good one.
4
4
  # StatCard(label: "Ventas", value: "$1.284.000", delta: "▲ 12 %", tone: "ok", compare: "vs. semana pasada", series: "3,5,4,6,5,8,9") { Icon "lucide:credit-card" }
5
+ # A card that asks for something (what is owed, orders to confirm): `line` says what the number means and `to` + `cta`
6
+ # is the one thing to do about it, written at the foot of the card.
7
+ # StatCard(label: "Por cobrar", value: "USD 305", line: "4 citas sin marcar como pagadas", to: "/close-day", cta: "Cerrar el día") { Icon "lucide:clock" }
8
+ # busy: true keeps the card's shape with a skeleton while the number loads (the label stays).
5
9
  # Lay several in a StatGrid. Spark draws the series alone: Spark(series: "3,5,4", tone: "success").
6
10
  # shadcn-for-muten · part + Custom (Spark.js) · kit.css (.cx-stat).
7
- part StatCard(label: text, value: text, delta: text, tone: text = "ok", compare: text = "", series: text = "") {
8
- Section class("cn-card cx-stat") data(slot: "card", size: "sm") {
11
+ part StatCard(label: text, value: text = "", delta: text = "", tone: text = "ok", compare: text = "", series: text = "", line: text = "", to: text = "", cta: text = "", busy: bool = false) {
12
+ Section class("cn-card cx-stat") data(slot: "card", size: "sm", busy: $busy) {
9
13
  Stack class("cx-stat-body") {
10
14
  Stack class("cx-stat-row") {
11
15
  Span "{$label}" class("cx-text") data(slot: "text", variant: "muted")
12
16
  Stack class("cx-stat-ic") { slot }
13
17
  }
14
- Span "{$value}" class("cx-text") data(slot: "text", variant: "title")
15
- Stack class("cx-stat-row") {
16
- Span "{$delta}" class("cn-badge cn-badge-variant-ghost group/badge") data(slot: "badge", variant: "status", tone: $tone)
17
- Span "{$compare}" class("cx-text") data(slot: "text", variant: "caption")
18
+ when $busy {
19
+ Skeleton() class("cx-stat-sk-v")
20
+ Skeleton() class("cx-stat-sk-l")
21
+ }
22
+ when not $busy {
23
+ Span "{$value}" class("cx-text") data(slot: "text", variant: "title")
24
+ Span "{$line}" class("cx-text cx-stat-line") data(slot: "text", variant: "caption")
25
+ Stack class("cx-stat-row cx-stat-delta") {
26
+ Span "{$delta}" class("cn-badge cn-badge-variant-ghost group/badge") data(slot: "badge", variant: "status", tone: $tone)
27
+ Span "{$compare}" class("cx-text") data(slot: "text", variant: "caption")
28
+ }
29
+ Spark(series: $series, tone: $tone)
30
+ when $to != "" { Link "{$cta}" -> "{$to}" class("cx-stat-go") }
18
31
  }
19
- Spark(series: $series, tone: $tone)
20
32
  }
21
33
  }
22
34
  }