@muten/shadcn 0.1.1 → 0.1.2

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 (39) hide show
  1. package/AGENTS.md +64 -1
  2. package/README.md +7 -0
  3. package/kit.css +2284 -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/inbox-play.muten +56 -0
  19. package/registry/input-group.muten +3 -1
  20. package/registry/kit/collections.js +10 -2
  21. package/registry/kit/core.js +519 -99
  22. package/registry/kit/structure.js +3 -3
  23. package/registry/kit-widget.js +1 -1
  24. package/registry/legal-doc.muten +27 -0
  25. package/registry/modal-behavior.js +11 -0
  26. package/registry/modal.muten +1 -1
  27. package/registry/order-play.muten +33 -0
  28. package/registry/permission-matrix.muten +31 -5
  29. package/registry/phone-bar.muten +18 -0
  30. package/registry/phone-shell.muten +1 -1
  31. package/registry/product-play.muten +20 -0
  32. package/registry/screen-tour.js +59 -0
  33. package/registry/screen-tour.muten +10 -0
  34. package/registry/sidebar.muten +9 -0
  35. package/registry/site-footer.muten +37 -0
  36. package/registry/stamp-card.muten +5 -2
  37. package/registry/zoom-control.muten +3 -3
  38. package/registry.json +78 -0
  39. 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) }
@@ -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") }
@@ -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 } }
@@ -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
  }
@@ -4,8 +4,8 @@
4
4
  # shadcn-for-muten · part · no deps · kit.css (.cx-zoom).
5
5
  part ZoomControl(value: number, onOut: action, onIn: action, onReset: action) {
6
6
  Stack class("cx-zoom") data(slot: "zoom-control") aria(role: "group", label: "Zoom") {
7
- Button -> $onOut class("cn-button cn-button-variant-ghost cn-button-size-icon-sm group/button") data(name: "Alejar") aria(label: "Alejar") { Icon "lucide:minus" }
8
- Button "{$value} %" -> $onReset class("cn-button cn-button-variant-ghost cn-button-size-sm group/button cx-zoom-value") data(name: "Volver a 100 %") aria(label: "Zoom {$value} %, volver a 100")
9
- Button -> $onIn class("cn-button cn-button-variant-ghost cn-button-size-icon-sm group/button") data(name: "Acercar") aria(label: "Acercar") { Icon "lucide:plus" }
7
+ Btn(variant: "ghost", size: "icon-sm", label: "Alejar", onClick: $onOut) { Icon "lucide:minus" }
8
+ Button "{$value} %" -> $onReset class("cn-button cn-button-variant-ghost cn-button-size-sm group/button cx-zoom-value") data(name: "Volver a 100 %") aria(label: "Zoom {$value} %, volver a 100") # its words carry $value: a part arg cannot interpolate an outer param, so it stays a Button
9
+ Btn(variant: "ghost", size: "icon-sm", label: "Acercar", onClick: $onIn) { Icon "lucide:plus" }
10
10
  }
11
11
  }
package/registry.json CHANGED
@@ -93,6 +93,20 @@
93
93
  "file": "registry/bento.muten",
94
94
  "description": "BLOCK: fixed-row-height tiling gallery; cells pick their col-span. Parts: BentoGallery, BentoCell."
95
95
  },
96
+ {
97
+ "name": "bio-play",
98
+ "deps": [],
99
+ "part": "BioPlay",
100
+ "file": "registry/bio-play.muten",
101
+ "description": "BioPlay - a social profile shown, not used: the photo, the handle, the followers, the bio with the store's link"
102
+ },
103
+ {
104
+ "name": "booking-play",
105
+ "deps": [],
106
+ "part": "BookingPlay",
107
+ "file": "registry/booking-play.muten",
108
+ "description": "BookingPlay - a booking shown, not used: one service, a few days and hours, and a booking that plays itself in a"
109
+ },
96
110
  {
97
111
  "name": "booking",
98
112
  "deps": [],
@@ -162,6 +176,13 @@
162
176
  "component": "Carousel",
163
177
  "description": "Carousel(label, perView, center, loop, autoplay, indicator, arrows) { CarouselSlide }: one component, options instead of copies."
164
178
  },
179
+ {
180
+ "name": "chat-play",
181
+ "deps": [],
182
+ "part": "ChatPlay",
183
+ "file": "registry/chat-play.muten",
184
+ "description": "ChatPlay - a conversation shown, not used: the messages come in one by one (in `step` order, 1-6) and the"
185
+ },
165
186
  {
166
187
  "name": "chatbot",
167
188
  "deps": [
@@ -258,6 +279,13 @@
258
279
  "component": "DateField",
259
280
  "description": "DateField(label, value, min, years, onChange): the long date; opens the date wheels."
260
281
  },
282
+ {
283
+ "name": "day-plan",
284
+ "deps": [],
285
+ "part": "DayPlan",
286
+ "file": "registry/day-plan.muten",
287
+ "description": "DayPlan - the owner's day as a list in order: hour, a dot on a line, who and what, a tag on the right. The hours"
288
+ },
261
289
  {
262
290
  "name": "device-frame",
263
291
  "deps": [],
@@ -386,6 +414,13 @@
386
414
  "component": "ImageUpload",
387
415
  "description": "ImageUpload(title, hint, maxMb, size, uploadUrl, sample, onReady): validate, decode, crop, shrink, upload with progress."
388
416
  },
417
+ {
418
+ "name": "inbox-play",
419
+ "deps": [],
420
+ "part": "InboxPlay",
421
+ "file": "registry/inbox-play.muten",
422
+ "description": "InboxPlay - a messages list shown, not used: the shop's inbox as the channel's app draws it (skin: instagram |"
423
+ },
389
424
  {
390
425
  "name": "indicator",
391
426
  "deps": [],
@@ -578,6 +613,20 @@
578
613
  "file": "registry/navpill.muten",
579
614
  "description": "BLOCK: the floating glass navbar (sticky wrapper + glass pill + scroll animation baked in). Drop logo/links/CTA inside."
580
615
  },
616
+ {
617
+ "name": "legal-doc",
618
+ "deps": [],
619
+ "part": "DocHead",
620
+ "file": "registry/legal-doc.muten",
621
+ "description": "A legal document (terms, privacy): the title and its line, the contents that follow the reading, the sections at reading width."
622
+ },
623
+ {
624
+ "name": "phone-bar",
625
+ "deps": [],
626
+ "part": "PhoneBar",
627
+ "file": "registry/phone-bar.muten",
628
+ "description": "The app's bar at the bottom of a phone: three fixed cells (menu · the screen's tools · its create). Screens hand it their toolbar buttons on a phone."
629
+ },
581
630
  {
582
631
  "name": "new-appointment",
583
632
  "deps": [],
@@ -602,6 +651,13 @@
602
651
  "component": "OrderBoard",
603
652
  "description": "OrderBoard(lanes, cards, onChange, onAction) / OrderCards(cards, sizes): order board and card with its modals."
604
653
  },
654
+ {
655
+ "name": "order-play",
656
+ "deps": [],
657
+ "part": "OrderPlay",
658
+ "file": "registry/order-play.muten",
659
+ "description": "OrderPlay - an order shown, not used: who ordered, the products with their photos, how it is paid and delivered,"
660
+ },
605
661
  {
606
662
  "name": "overlay-behavior",
607
663
  "deps": [],
@@ -689,6 +745,13 @@
689
745
  "file": "registry/pricing.muten",
690
746
  "description": "BLOCK: a row of pricing tiers (name/price/features/CTA), featured highlight. Parts: Pricing, PricingTier, PricingFeature."
691
747
  },
748
+ {
749
+ "name": "product-play",
750
+ "deps": [],
751
+ "part": "ProductPlay",
752
+ "file": "registry/product-play.muten",
753
+ "description": "ProductPlay - a product sheet shown, not used: the photo, the shop, the name with its price, a line about it, its"
754
+ },
692
755
  {
693
756
  "name": "progress",
694
757
  "deps": [],
@@ -759,6 +822,14 @@
759
822
  "component": "ScaleSlider",
760
823
  "description": "ScaleSlider(label, min, max, step, values, gap, format, onChange): value rides on the thumb; two bubbles merge."
761
824
  },
825
+ {
826
+ "name": "screen-tour",
827
+ "deps": [],
828
+ "part": "ScreenTour",
829
+ "file": "registry/screen-tour.muten",
830
+ "component": "ScreenTour",
831
+ "description": "ScreenTour - how a product is used, played on its real screens: numbered steps on one side, a phone on the other"
832
+ },
762
833
  {
763
834
  "name": "scroll-area",
764
835
  "deps": [],
@@ -1066,6 +1137,13 @@
1066
1137
  "part": "ZoomControl",
1067
1138
  "file": "registry/zoom-control.muten",
1068
1139
  "description": "A - 100% + zoom widget; page owns the zoom number."
1140
+ },
1141
+ {
1142
+ "name": "site-footer",
1143
+ "deps": [],
1144
+ "part": "SiteFooter",
1145
+ "file": "registry/site-footer.muten",
1146
+ "description": "A site's footer: the brand and its two ways in over a hairline, every group of links on one row, then a bar with the company and contacts (FooterHead, FooterBrand, FooterActions, FooterLinks, FooterCol, FooterBar)."
1069
1147
  }
1070
1148
  ],
1071
1149
  "classComponents": {
@@ -0,0 +1,37 @@
1
+ // reuse-audit.mjs - atomic design kept honest: before something is written by hand, the library may already have it.
2
+ // node scripts/reuse-audit.mjs the library's own parts (from plugins/shadcn)
3
+ // node node_modules/@muten/shadcn/scripts/reuse-audit.mjs src an app's .muten files (from the app)
4
+ // It lists the markup that re-draws an existing piece: a Button wearing cn-button classes where Btn would do, a
5
+ // SearchField wearing cn-input where Input/GroupInput exist, a hand-made avatar, a phone or device frame of its own
6
+ // (DeviceFrame is the one phone of the library). A finding is not always wrong (a tab passes its value to the action,
7
+ // a close button carries its own role): each one is a question to answer before adding more.
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+
11
+ const REG = path.join(import.meta.dirname, '..', 'registry');
12
+ const OWN = { 'button.muten': true, 'input.muten': true, 'input-group.muten': true, 'avatar.muten': true, 'device-frame.muten': true, 'skeleton.muten': true, 'kit-skeleton.muten': true };
13
+ const RULES = [
14
+ { test: (l) => /\bButton\b/.test(l) && /->\s*[\w.$]+\s+class\("cn-button/.test(l) && !/aria\((pressed|role)|status:|marker:|"\{\$/.test(l), says: 'a Button with cn-button classes and a plain action: Btn(variant, size, label, onClick) does it' },
15
+ { re: /SearchField\b[^\n]*class\("cn-input/, says: 'a SearchField with cn-input classes: Input / GroupInput do it' },
16
+ { re: /class\("cn-avatar[ "]/, says: 'a hand-made avatar: Avatar / AvatarImage do it' },
17
+ { test: (l) => !/\bDeviceFrame\(/.test(l) && /class\("[^"]*\b(cx-phone-frame|cx-sphone[\w-]*|plt-phone|phone-frame|device-frame|iphone|bezel|notch)\b/.test(l), says: 'a phone frame of its own: DeviceFrame is the one phone of the library (island, href, shot, width)' },
18
+ { re: /class\("[^"]*\b(cx-sk|cn-skeleton)(?=[\s"])/, says: 'a skeleton drawn by hand: Skeleton / Skeletons(kind) / Loading do it' },
19
+ ];
20
+
21
+ const walk = (dir) => fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
22
+ if (e.name === 'node_modules' || e.name.startsWith('.')) return [];
23
+ const full = path.join(dir, e.name);
24
+ return e.isDirectory() ? walk(full) : e.name.endsWith('.muten') ? [full] : [];
25
+ });
26
+ const roots = process.argv.slice(2);
27
+ const files = roots.length ? roots.flatMap((r) => walk(path.resolve(r))) : fs.readdirSync(REG).filter((f) => f.endsWith('.muten') && !OWN[f]).map((f) => path.join(REG, f));
28
+
29
+ let total = 0;
30
+ for (const file of files.sort()) {
31
+ const lines = fs.readFileSync(file, 'utf8').split('\n');
32
+ lines.forEach((line, i) => {
33
+ if (line.trim().startsWith('#')) return;
34
+ for (const r of RULES) if (r.test ? r.test(line) : r.re.test(line)) { total++; console.log(`${path.relative(process.cwd(), file)}:${i + 1} ${r.says}\n ${line.trim().slice(0, 140)}`); }
35
+ });
36
+ }
37
+ console.log(total ? `\n${total} place(s) to look at.` : 'Nothing re-drawn by hand.');