@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
package/motion.css CHANGED
@@ -77,3 +77,18 @@
77
77
  .animate-fade-in, .animate-rise, .animate-scale-in, .animate-blur-in, .stagger > *, .words > *, .reveal, .reveal-stagger > *, .drift, .photo-in img { animation: none !important; }
78
78
  .hover-lift, .hover-zoom img { transition: none !important; }
79
79
  }
80
+
81
+ /* smooth-scroll - on any element of the page: a jump to an anchor of the same page glides instead of snapping. */
82
+ @media (prefers-reduced-motion: no-preference) {
83
+ html:has(.smooth-scroll) { scroll-behavior: smooth; }
84
+ }
85
+
86
+ /* reveal-blur - on a page's root: as you scroll down, each block of the marketing system (headings, rows, cards,
87
+ steps, tables, questions, the closer; never the hero) comes in from a blur, rising a little. Kit core marks each
88
+ block (data-rv) and lets it in as it enters the screen; what is on screen when the page opens is shown whole, so the
89
+ first paint, the LCP and a contrast checker never meet a half-faded block. No script or reduced motion: all shown. */
90
+ @media (prefers-reduced-motion: no-preference) {
91
+ /* hiding is instant (it happens off screen, while the page loads); only the entrance moves */
92
+ [data-rv=""] { opacity: 0; filter: blur(14px); transform: translateY(28px); }
93
+ [data-rv="in"] { transition: opacity 700ms cubic-bezier(.22, 1, .36, 1), filter 700ms cubic-bezier(.22, 1, .36, 1), transform 700ms cubic-bezier(.22, 1, .36, 1); transition-delay: calc(var(--rv-i, 0) * 70ms); }
94
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@muten/shadcn",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "shadcn-style component registry for muten - own-the-source parts you add with `muten add`.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -20,7 +20,8 @@
20
20
  "motion.css",
21
21
  "themes",
22
22
  "README.md",
23
- "AGENTS.md"
23
+ "AGENTS.md",
24
+ "scripts/reuse-audit.mjs"
24
25
  ],
25
26
  "exports": {
26
27
  "./globals.css": "./globals.css",
@@ -1,6 +1,8 @@
1
1
  # Accordion - stacked questions; each opens to its content's exact height (200 ms) and the arrow turns instead of
2
2
  # swapping icons. The page owns a bool per item and the toggle.
3
3
  # Accordion { AccordionItem(title: "¿Puedo cancelar mi cita?", open: a1, onToggle: t1) { Text "Sí, hasta 3 horas antes…" } }
4
+ # No state to own (or a search engine must read the answers closed)? The same look on the native details:
5
+ # Accordion(size: "lg") { Details "¿…?" group("faq") class("cn-accordion-item") { Text "…" class("cn-accordion-content-inner") } }
4
6
  # size "lg": taller rows and bigger type, for the questions of a landing page.
5
7
  # shadcn-for-muten · part · no deps · Nova (.cn-accordion) + kit.css.
6
8
  part Accordion(size: text = "default") { Stack class("cn-accordion") data(slot: "accordion", size: $size) { slot } }
@@ -15,3 +17,11 @@ part AccordionItem(title: text, open: bool, onToggle: action) {
15
17
  }
16
18
  }
17
19
  }
20
+ # FaqItem - one question of an Accordion on the native <details>: no state to keep, and items sharing a `group` open one
21
+ # at a time (they share the group «faq»). The answer goes in the slot (text, or text with a button). FaqItemOpen starts open (the first of a list).
22
+ part FaqItem(question: text) {
23
+ Details "{$question}" group("faq") class("cn-accordion-item") { Stack class("cn-accordion-content-inner") { slot } }
24
+ }
25
+ part FaqItemOpen(question: text) {
26
+ Details "{$question}" group("faq") open class("cn-accordion-item") { Stack class("cn-accordion-content-inner") { slot } }
27
+ }
@@ -1,35 +1,83 @@
1
- // Agenda (muten Custom) - the kit's agenda (day columns per person, week, list; drag to move, edge to resize, the
2
- // appointment sheet with its lifecycle, payment, move and cancel), from kit/core.js. Everything is data:
3
- // resources [{id, name}] · hours [{id, days: [1..6], ranges: [[540, 780], [840, 1140]]}] (minutes from midnight)
4
- // events [{id, resource, day, start "HH:MM", dur, title, subtitle, price, tone, steps, actions, at, payment, contact}]
1
+ // Agenda / AgendaLive (muten Custom) - the kit's agenda (kit/core.js): day columns per person, week, list; drag to move,
2
+ // edge to resize. Everything is data:
3
+ // resources [{id, name, photo, kind, capacity, group}] - each one is an AGENDA: kind "business" (the shop's own, shown
4
+ // first), "person" (a worker, the default) or any other (a room: say its `group`, «Salas»). capacity = how many citas
5
+ // it holds at once: 1 (default, never overlap) · 3 (a class of three) · 0 (no limit). overlap "never" | "allow"
6
+ // overrides every capacity. Citas that break one (the server sent them crossed) say «Se cruza».
7
+ // resources [{id, name, photo}] · hours [{id, days: [1..6], ranges: [[540, 780], [840, 1140]]}] (minutes from midnight)
8
+ // events [{id, resource, with: [ids], day, start "HH:MM", dur, title, subtitle, price, status, tint, ink, tone, steps, actions, at, payment, contact}]
5
9
  // day = "yyyy-mm-dd", or a number of days from today (it lands on the next day that person works)
6
- // blocks [{id, resource | "all", day, start, dur, reason}] · from / to (hours) · slot (minutes)
7
- // actions [{id, label, icon, primary}] - the toolbar's buttons (default: block and new); «block» and «new» keep their
8
- // built-in behaviour, any other id fires the action handler with that id
9
- // handlers: create(json {resource, day, start}) · change(json [{id, resource, day, start, dur, status}]) · action(id)
10
+ // with = the other agendas the cita also takes (the room it uses, a second worker): checked and shown in each
11
+ // status = awaiting_payment | confirmed | started | done | noshow | cancelled · tint/ink = the service's colours
12
+ // blocks [{id, resource | "all", day, start, dur, reason}] · from / to (hours) · slot (minutes) · view: day | week | list
13
+ // free [{resource, day, start, end}] - the server's free hours; when sent they are the only free hours shown
14
+ // queue [{day, name, photo}] - who waits for a free hour that day · closed ["yyyy-mm-dd"] - days the shop is shut
15
+ // (blocks, queue and closed take a number of days from today too)
16
+ // lang es | en · currency (ISO, e.g. "USD") + locale · cents "yes" (prices in cents) · sheet "app" · drag "off"
17
+ // loading "yes" (the calendar's shape in grey until the data arrives) · start "yyyy-mm-dd" (the day it opens on)
18
+ // actions [{id, label, icon, primary}] - the toolbar's buttons (default: block and new); any other id fires `action`
19
+ // phone "nav" - on a phone the actions and the arrows leave the agenda's toolbar: the app's phone bar carries them and
20
+ // sends them back through `command`: "new", "block", "prev", "next", "today", "view:list", or an action id. The
21
+ // agenda runs a command when its value changes, so repeat one with a suffix after # ("new#1", "new#2"…)
22
+ // hour12 "yes" - times written 3:00 PM (the data stays "HH:MM") · timeZone "America/Bogota" - today and now are the shop's
23
+ // phoneView "list" - a phone opens on the list until a view is picked · blockScope "all" - blocks are for the whole
24
+ // business (no «Para» picker: a backend with no per-person blocks)
25
+ // events also take tags ["A domicilio", "Llegó 10:02"] (words next to the time) and photo (the client's face, in the list)
26
+ // resources also take role (read under the name), hideEmpty (the column shows only on days it has citas) and
27
+ // bookable false (an agenda that only holds citas, «Sin asignar»: never offered as free)
28
+ // resize "off" - a cita's length is not dragged (a backend that only moves day and time) · moveAcross "off" - a
29
+ // dragged cita stays in its own agenda (only its time, and its day in the week view, change)
30
+ // placing [{resource, day, start, dur, title, tint}] - while the app's «new cita» sheet is open, where it will land
31
+ // create "sheet" - a showcase: «new» and a tap on a free hour open a short built-in form and the cita lands at once
32
+ // handlers: create(json {resource, day, start}) · open(json event) · block(json {resource, day, start, dur, reason})
33
+ // move(json {id, resource, day, start, dur}) · range(json {from, to, view}: view is day | week | list | range) · change(json [...]) · action(id)
34
+ // The title opens a month (floating; a sheet on a phone): one day, or «Varios días», a range listed day by day.
35
+ // LIVE: new inputs repaint the same agenda (same day, view and person): an app sends its data again and it shows;
36
+ // lang, currency, locale and cents change live too.
10
37
  export function mount(el, inputs, on) {
11
- let latest = inputs;
38
+ let api = null, map = null, pending = null, lastCmd = String(inputs.command || ''); // the command it was born with never runs
12
39
  import('@muten/shadcn/registry/kit/core.js').then(({ kit, today, addDays }) => {
13
40
  const iso = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
14
41
  const list = (v) => (Array.isArray(v) ? v : []);
15
- const build = (i) => {
42
+ const at = (s) => new Date(String(s) + 'T00:00:00');
43
+ const yes = (v) => v === true || v === 'yes' || v === 'true';
44
+ const data = (i) => {
16
45
  const hours = Object.fromEntries(list(i.hours).map((h) => [h.id, h]));
17
46
  const work = (rid, d) => { const h = hours[rid]; return h && list(h.days).includes(d.getDay()) ? list(h.ranges) : []; };
18
47
  const land = (rid, off) => { let o = Number(off); for (let k = 0; k < 14 && !work(rid, addDays(today, o)).length; k++) o++; return addDays(today, o); };
19
- const dayOf = (rid, d) => (typeof d === 'number' || /^-?\d+$/.test(String(d)) ? land(rid, d) : new Date(String(d) + 'T00:00:00'));
20
- const events = list(i.events).map((e) => ({ tone: 'ok', at: 0, ...e, day: dayOf(e.resource, e.day) }));
21
- const blocks = list(i.blocks).map((b) => ({ ...b, day: typeof b.day === 'number' || /^-?\d+$/.test(String(b.day)) ? addDays(today, Number(b.day)) : new Date(String(b.day) + 'T00:00:00') }));
22
- el.innerHTML = '';
23
- kit.agenda(el, {
24
- resources: list(i.resources), events, blocks, availability: work,
25
- actions: list(i.actions).length ? list(i.actions) : null,
26
- onAction: (id) => on.action?.(id),
27
- from: Number(i.from) || 9, to: Number(i.to) || 19, slot: Number(i.slot) || 30,
28
- onCreate: (x) => on.create?.(JSON.stringify({ resource: x.resource, day: iso(x.day), start: x.start })),
29
- onChange: (evs) => on.change?.(JSON.stringify(evs.map((e) => ({ id: e.id, resource: e.resource, day: iso(e.day), start: e.start, dur: e.dur, status: e.status || '' })))),
30
- });
48
+ const plain = (d) => (typeof d === 'number' || /^-?\d+$/.test(String(d)) ? addDays(today, Number(d)) : at(d));
49
+ const dayOf = (rid, d) => (typeof d === 'number' || /^-?\d+$/.test(String(d)) ? land(rid, d) : at(d));
50
+ const free = Array.isArray(i.free) && i.free.length ? i.free.map((f) => ({ ...f, day: dayOf(f.resource, f.day) })) : null;
51
+ return {
52
+ resources: list(i.resources), availability: work, free,
53
+ events: list(i.events).map((e) => ({ tone: 'ok', at: 0, ...e, day: dayOf(e.resource, e.day) })),
54
+ blocks: list(i.blocks).map((b) => ({ ...b, day: plain(b.day) })),
55
+ queue: list(i.queue).map((q) => ({ ...q, day: plain(q.day) })), closed: list(i.closed).map(plain), loading: yes(i.loading),
56
+ placing: (() => { const g = list(i.placing)[0]; return g && g.start ? { ...g, day: dayOf(g.resource, g.day) } : null; })(),
57
+ lang: i.lang === 'en' ? 'en' : 'es', overlap: ['never', 'allow'].includes(i.overlap) ? i.overlap : 'auto', currency: i.currency || '', locale: i.locale || '', cents: yes(i.cents),
58
+ };
31
59
  };
32
- build(latest);
60
+ map = data;
61
+ const send = (name, value) => on[name]?.(typeof value === 'string' ? value : JSON.stringify(value));
62
+ const day1 = (x) => ({ ...x, day: x.day instanceof Date ? iso(x.day) : x.day });
63
+ el.innerHTML = '';
64
+ api = kit.agenda(el, {
65
+ ...data(inputs),
66
+ actions: list(inputs.actions).length ? list(inputs.actions) : null,
67
+ from: Number(inputs.from) || 9, to: Number(inputs.to) || 19, slot: Number(inputs.slot) || 30, view: inputs.view || 'day',
68
+ createSheet: inputs.create === 'sheet', services: list(inputs.services),
69
+ sheet: inputs.sheet === 'app' ? 'app' : 'built-in', drag: inputs.drag !== 'off', start: inputs.start ? at(inputs.start) : null, phone: inputs.phone === 'nav' ? 'nav' : '',
70
+ resize: inputs.resize === 'off' ? 'off' : 'on', moveAcross: inputs.moveAcross === 'off' ? 'off' : 'on', hour12: yes(inputs.hour12), timeZone: inputs.timeZone || '', phoneView: ['list', 'day'].includes(inputs.phoneView) ? inputs.phoneView : '', blockScope: inputs.blockScope === 'all' ? 'all' : 'each',
71
+ onAction: (id) => send('action', id),
72
+ onCreate: (x) => send('create', day1(x)),
73
+ onOpen: on.open ? (e) => send('open', day1({ id: e.id, resource: e.resource, day: e.day, start: e.start, dur: e.dur, status: e.status || '' })) : null,
74
+ onBlock: on.block ? (b) => send('block', day1(b)) : null,
75
+ onMove: (m) => send('move', day1(m)),
76
+ onRange: (r) => send('range', { from: iso(r.from), to: iso(r.to), view: r.view }),
77
+ onChange: (evs) => send('change', evs.map((e) => ({ id: e.id, resource: e.resource, day: iso(e.day), start: e.start, dur: e.dur, status: e.status || '' }))),
78
+ });
79
+ if (pending) { api.update(data(pending)); pending = null; }
33
80
  });
34
- return (next) => { latest = next; };
81
+ const command = (next) => { const c = String(next.command || ''); if (c === lastCmd) return; lastCmd = c; if (c) api.run(c.split('#')[0]); };
82
+ return (next) => { if (api && map) { api.update(map(next)); command(next); } else pending = next; };
35
83
  }
@@ -4,7 +4,35 @@
4
4
  # Everything is data (see agenda.js for the shapes); pass lists with @: Agenda(resources: @team, hours: @hours, events: @appointments, blocks: @blocks, onCreate: newAppointment, onChange: saveAgenda)
5
5
  # actions: the toolbar's buttons as data, [{id, label, icon, primary}] (default: Bloquear and Nueva cita); an id other
6
6
  # than block or new fires onAction with it: Agenda(…, actions: @agendaActions, onAction: agendaAction)
7
+ # view: how it opens, day (default) | week | list.
7
8
  # shadcn-for-muten · Custom (kit core) · host JS is agenda.js.
8
- part Agenda(resources: list, hours: list, events: list, blocks: list, actions: list, from: number = 9, to: number = 19, slot: number = 30, onCreate: action, onChange: action, onAction: action) {
9
- Custom Agenda inputs(resources: $resources, hours: $hours, events: $events, blocks: $blocks, actions: $actions, from: $from, to: $to, slot: $slot) on(create: $onCreate, change: $onChange, action: $onAction) class("cx-agenda")
9
+ part Agenda(resources: list, hours: list, events: list, blocks: list, actions: list, from: number = 9, to: number = 19, slot: number = 30, view: text = "day", onCreate: action, onChange: action, onAction: action) {
10
+ Custom Agenda inputs(resources: $resources, hours: $hours, events: $events, blocks: $blocks, actions: $actions, from: $from, to: $to, slot: $slot, view: $view) on(create: $onCreate, change: $onChange, action: $onAction) class("cx-agenda")
11
+ }
12
+ # AgendaDemo - the same agenda for a showcase with no app behind it: «Nueva cita» and a tap on a free hour open a short
13
+ # built-in form (the service from `services` [{name, dur}], the client, the time) and the appointment lands on the
14
+ # agenda at once, with its notice. Block, move and the appointment sheet work as in Agenda. Nothing is saved anywhere.
15
+ part AgendaDemo(resources: list, hours: list, events: list, blocks: list, actions: list, services: list, from: number = 9, to: number = 19, slot: number = 30, view: text = "day", onChange: action, onAction: action) {
16
+ Custom Agenda inputs(resources: $resources, hours: $hours, events: $events, blocks: $blocks, actions: $actions, from: $from, to: $to, slot: $slot, view: $view, create: "sheet", services: $services) on(change: $onChange, action: $onAction) class("cx-agenda")
17
+ }
18
+ # AgendaLive - the agenda for an APP: it paints what it is given and tells the app what happened; the app keeps the
19
+ # data and its own sheets. Send the lists again and it repaints on the same day, view and person.
20
+ # free the server's free hours [{resource, day, start, end}] (the only free hours shown; [] = from `hours`)
21
+ # queue who waits for a free hour [{day, name, photo}] · closed days the shop is shut ["yyyy-mm-dd"]
22
+ # events carry status (awaiting_payment | confirmed | started | done | noshow | cancelled) and tint/ink (the service's colours)
23
+ # lang es | en · currency + locale (prices written with Intl) · cents "yes" · drag "off" · loading "yes" · start "yyyy-mm-dd"
24
+ # onOpen(json {id, …}) a cita tapped (open your sheet) · onCreate(json {resource, day, start}) a free hour tapped
25
+ # onBlock(json {resource, day, start, dur, reason}) · onMove(json {id, resource, day, start, dur}) dragged (answer with
26
+ # the data: it stays where it landed, or goes back if you send it unchanged) · onRange(json {from, to, view}) days shown
27
+ # phone "nav": on a phone its actions (Bloquear, Nueva cita, yours) and the arrows leave the toolbar for the app's
28
+ # phone bar (PhoneBar), which sends them back through `command` ("new", "block", "prev", "next", "today", "view:list",
29
+ # an action id; repeat one with a suffix after #: "new#2"). Nothing runs while loading.
30
+ # several agendas: resources carry kind (business | person | a room…) and capacity (1 never overlaps, 0 no limit);
31
+ # a cita takes its resource plus every id in `with`. overlap "never" | "allow" overrides all capacities.
32
+ # resize "off" · moveAcross "off" (a backend that only moves day and time) · hour12 "yes" (3:00 PM) · timeZone (the shop's today and now) · phoneView "list" · blockScope "all" · placing [{…}]
33
+ # (the dashed cita while the app's new-cita sheet is open) · events take tags [words] and photo; resources role, hideEmpty
34
+ # AgendaLive(resources: @team, hours: @hours, events: @citas, blocks: @blocks, free: @free, queue: @queue, closed: @closed,
35
+ # lang: ui.lang, currency: "USD", onOpen: openCita, onCreate: newCita, onBlock: block, onMove: moveCita, onRange: loadDays)
36
+ part AgendaLive(resources: list, hours: list, events: list, blocks: list, free: list, queue: list, closed: list, lang: text = "es", currency: text = "", locale: text = "", cents: text = "", from: number = 9, to: number = 19, slot: number = 30, view: text = "day", loading: text = "", start: text = "", drag: text = "", phone: text = "", command: text = "", overlap: text = "", hour12: text = "", timeZone: text = "", phoneView: text = "", blockScope: text = "", resize: text = "", moveAcross: text = "", placing: list, onOpen: action, onCreate: action, onBlock: action, onMove: action, onRange: action) {
37
+ Custom Agenda inputs(resources: $resources, hours: $hours, events: $events, blocks: $blocks, free: $free, queue: $queue, closed: $closed, lang: $lang, currency: $currency, locale: $locale, cents: $cents, from: $from, to: $to, slot: $slot, view: $view, loading: $loading, start: $start, drag: $drag, phone: $phone, command: $command, overlap: $overlap, hour12: $hour12, timeZone: $timeZone, phoneView: $phoneView, blockScope: $blockScope, resize: $resize, moveAcross: $moveAcross, placing: $placing, sheet: "app") on(open: $onOpen, create: $onCreate, block: $onBlock, move: $onMove, range: $onRange) class("cx-agenda")
10
38
  }
@@ -9,7 +9,7 @@ part Band(open: bool, tone: text = "muted", text: text, action: text = "", onAct
9
9
  Stack class("cx-band") data(tone: $tone) aria(role: "status") {
10
10
  slot
11
11
  Span "{$text}" class("cx-band-text")
12
- when $action != "" { Button "{$action}" -> $onAction class("cn-button cn-button-variant-outline cn-button-size-xs group/button") }
12
+ when $action != "" { Btn(variant: "outline", size: "xs", onClick: $onAction) { Span "{$action}" } }
13
13
  }
14
14
  }
15
15
  }
@@ -0,0 +1,16 @@
1
+ # BioPlay - a social profile shown, not used: the photo, the handle, the followers, the bio with the store's link
2
+ # and the two buttons, as Instagram draws a profile. For a landing; a picture (aria-hidden), `label` says what.
3
+ # BioPlay(photo: "/owner.webp", handle: "brumabarberia", followers: "1.284 seguidores", bio: "Cortes y barba.",
4
+ # link: "storio.ink/bruma-barberia", follow: "Seguir", message: "Mensaje", label: "…")
5
+ # shadcn-for-muten · part · no deps · kit.css (.cx-bio).
6
+ part BioPlay(photo: text, handle: text, followers: text, bio: text, link: text, follow: text, message: text, label: text = "") {
7
+ Stack class("cx-bio cx-play") aria(role: "img", label: $label) {
8
+ Stack class("cx-bio-top") aria(hidden: true) {
9
+ Image "{$photo}" alt("") class("cx-bio-av")
10
+ Stack class("cx-bio-n") { Span "{$handle}" class("cx-bio-h") Span "{$followers}" }
11
+ }
12
+ Span "{$bio}" class("cx-bio-text") aria(hidden: true)
13
+ Stack class("cx-bio-link") aria(hidden: true) { Icon "lucide:link" Span "{$link}" }
14
+ Stack class("cx-bio-btns") aria(hidden: true) { Span "{$follow}" data(tone: "primary") Span "{$message}" }
15
+ }
16
+ }
@@ -0,0 +1,43 @@
1
+ # BookingPlay - a booking shown, not used: one service, a few days and hours, and a booking that plays itself in a
2
+ # loop (a day is picked, then an hour, «Reservar» is pressed and the confirmation comes up with its pass). For a
3
+ # landing or a bento cell, where the real Booking would be a form nobody fills. Taken hours are struck through.
4
+ # BookingPlay(service: "Corte clásico", place: "Bruma Barbería · 30 min", price: "USD 15", cta: "Reservar",
5
+ # doneTitle: "¡Listo, Carla!", doneText: "Corte clásico · hoy · 4:30 PM", code: "STO-4F2K", label: "Así reserva tu cliente") {
6
+ # BookingPlayDays { BookingPlayDay(weekday: "lun", day: "5", state: "off") … BookingPlayDay(weekday: "hoy", day: "9", state: "pick") }
7
+ # BookingPlayHours { BookingPlayHour(time: "10:00", state: "taken") … BookingPlayHour(time: "4:30", state: "pick", picked: "4:30 PM") }
8
+ # }
9
+ # photo: the owner's face in the circle (empty = the service's initial).
10
+ # state: "" | off (closed day) | taken (hour) | pick (the one the loop chooses; one day and one hour).
11
+ # Reduced motion: the pick is shown, the confirmation is not. shadcn-for-muten · part · no deps · kit.css (.cx-bkp).
12
+ part BookingPlay(service: text, photo: text = "", place: text = "", price: text = "", cta: text = "Reservar", doneTitle: text = "", doneText: text = "", code: text = "", label: text = "") {
13
+ Stack class("cx-bkp cx-play") aria(role: "img", label: $label) {
14
+ Stack class("cx-bkp-who") aria(hidden: true) {
15
+ when $photo != "" { Image "{$photo}" alt("") class("cx-bkp-av cx-play-face") }
16
+ when $photo == "" { Span "{initial($service)}" class("cx-bkp-av") }
17
+ Stack class("cx-bkp-name") { Span "{$service}" class("cx-bkp-t") Span "{$place}" class("cx-bkp-s") }
18
+ Span "{$price}" class("cx-bkp-price")
19
+ }
20
+ slot
21
+ Span "{$cta}" class("cx-bkp-go") aria(hidden: true)
22
+ Stack class("cx-bkp-done") aria(hidden: true) {
23
+ Stack class("cx-bkp-ok") { Icon "lucide:check" }
24
+ Span "{$doneTitle}" class("cx-bkp-dt")
25
+ Span "{$doneText}" class("cx-bkp-dd")
26
+ when $code != "" { Stack class("cx-bkp-pass") { Icon "lucide:qr-code" Span "{$code}" } }
27
+ }
28
+ }
29
+ }
30
+ part BookingPlayDays() { Stack class("cx-bkp-days") aria(hidden: true) { slot } }
31
+ part BookingPlayDay(weekday: text, day: text, state: text = "") {
32
+ Stack class("cx-bkp-day") data(state: $state) {
33
+ Span "{$weekday}" class("cx-bkp-wd") Span "{$day}" class("cx-bkp-d")
34
+ when $state == "pick" { Stack class("cx-bkp-on") { Span "{$weekday}" class("cx-bkp-wd") Span "{$day}" class("cx-bkp-d") } }
35
+ }
36
+ }
37
+ part BookingPlayHours() { Stack class("cx-bkp-hours") aria(hidden: true) { slot } }
38
+ part BookingPlayHour(time: text, state: text = "", picked: text = "") {
39
+ Stack class("cx-bkp-hour") data(state: $state) {
40
+ Span "{$time}"
41
+ when $state == "pick" { Span "{$picked}" class("cx-bkp-on") }
42
+ }
43
+ }
@@ -8,10 +8,11 @@
8
8
  # size: xs (24) | sm (28) | default (32) | lg (36) | xl (44) | 2xl (52) | icon | icon-xs | icon-sm | icon-lg | icon-xl | icon-2xl
9
9
  # xl and 2xl are for big surfaces (a landing, a sign-in) and match the fields' xl / 2xl.
10
10
  # tone: "" | info | ok | wait | bad | note - a tinted icon button (a pending thing on a card)
11
+ # disabled: a bool (a state or a getter: a part argument can't be an expression) - the button can't be pressed.
11
12
  # label: the name of an icon-only button (aria-label, and the kit's tooltip on hover). Leave it empty when there is text.
12
13
  # shadcn-for-muten · part · no deps · Nova classes (nova.css) + kit.css.
13
- part Btn(variant: text = "default", size: text = "default", tone: text = "", label: text = "", onClick: action) {
14
- Button -> $onClick class("cn-button cn-button-variant-{$variant} cn-button-size-{$size} group/button", "cx-tonal cx-has-indicator" when $tone != "") data(slot: "button", tone: $tone, name: $label) aria(label: $label) { slot }
14
+ part Btn(variant: text = "default", size: text = "default", tone: text = "", label: text = "", onClick: action, disabled: bool = false) {
15
+ Button -> $onClick class("cn-button cn-button-variant-{$variant} cn-button-size-{$size} group/button", "cx-tonal cx-has-indicator" when $tone != "") data(slot: "button", tone: $tone, name: $label) aria(label: $label) disabled when $disabled { slot }
15
16
  }
16
17
  # TriggerBtn - a Btn that only opens what sits next to it (a Popover, a DropdownMenu, a SplitButton's menu): it has no
17
18
  # action of its own, so opening never runs something else. Same variants and sizes as Btn.
@@ -0,0 +1,77 @@
1
+ # ChatPlay - a conversation shown, not used: the messages come in one by one (in `step` order, 1-6) and the
2
+ # assistant closes with a confirmation card, then it starts over. Its height is the conversation's: no empty chat.
3
+ # For a landing; the real assistant is Chatbot. The suggestions and the composer are a picture (not focusable).
4
+ # ChatPlay(title: "Bruma Barbería", status: "Asistente · en línea", placeholder: "Escribe un mensaje", label: "…") {
5
+ # ChatPlayLog {
6
+ # ChatPlayMsg(text: "Hola, ¿tienes hora hoy?", who: "in", step: 1)
7
+ # ChatPlayMsg(text: "Hoy quedan 4:30 PM y 6:00 PM.", step: 2)
8
+ # ChatPlayCard(title: "Corte clásico", text: "Hoy · 4:30 PM", step: 5)
9
+ # }
10
+ # ChatPlayChips { ChatPlayChip(label: "¿Cuánto cuesta?") … }
11
+ # }
12
+ # who: out (the shop and its assistant: right, dark, the default) | in (the client: left). Seen from the shop, as
13
+ # its inbox shows it. The confirmation card is the shop's, so it sits right too. Reduced motion: the whole conversation, still.
14
+ # skin: "" (the kit's own) | whatsapp | instagram | telegram - the conversation as that app shows it on the shop's phone
15
+ # (its wallpaper, bubbles with their tail, the hour and the ticks inside, its composer); theme: "" | dark (WhatsApp at night).
16
+ # With a skin, `photo` puts the person in the header instead of the assistant's face, ChatPlayDay sets a day chip,
17
+ # ChatPlayMsg takes a `time`, ChatPlayButtons + ChatPlayButton hang reply buttons under the message before them
18
+ # (Telegram draws them as its keyboard, two per row), and ChatPlayNote adds «Visto» or who answered. The header carries
19
+ # the app's back arrow and call buttons, and the composer is that app's own. With a skin it is a phone screen (300 × 600,
20
+ # a status bar with `clock`), and Instagram puts `photo` beside the client's last message of each turn.
21
+ # shadcn-for-muten · part · no deps · kit.css (.cx-cp).
22
+ part ChatPlay(title: text, status: text = "", placeholder: text = "", label: text = "", skin: text = "", theme: text = "", photo: text = "", clock: text = "9:41") {
23
+ # With a skin, the chat is a phone screen inside the library's one phone (the same iPhone as DeviceFrame), drawn at
24
+ # a real phone's 390 × 844 and scaled to the frame. Without one the wrappers do not count for layout.
25
+ Stack class("cx-cp-phone") data(skin: $skin) {
26
+ Stack class("cx-cp-frame") {
27
+ Stack class("cx-cp-screen") {
28
+ Stack class("cx-cp cx-play") data(skin: $skin, theme: $theme) style(face: "url('{$photo}')") aria(role: "img", label: $label) {
29
+ Stack class("cx-cp-sb") aria(hidden: true) { Span "{$clock}" Stack class("cx-cp-sbi") { Icon "lucide:signal" Icon "lucide:wifi" Icon "lucide:battery-full" } }
30
+ Stack class("cx-cp-head") aria(hidden: true) {
31
+ Stack class("cx-cp-back") { Icon "lucide:chevron-left" }
32
+ when $photo == "" { Stack class("cx-cp-face") { Span "" Span "" } }
33
+ when $photo != "" { Stack class("cx-cp-ring") { Image "{$photo}" alt("") class("cx-cp-photo") } }
34
+ Stack class("cx-cp-name") { Span "{$title}" class("cx-cp-t") Span "{$status}" class("cx-cp-s") }
35
+ Stack class("cx-cp-acts") { Icon "lucide:video" Icon "lucide:phone" Icon "lucide:ellipsis-vertical" }
36
+ }
37
+ slot
38
+ # The composer as each app draws it (the skin decides which of these show): Instagram's blue camera, WhatsApp's
39
+ # field with its emoji, clip and camera beside the green mic, Telegram's flat bar.
40
+ Stack class("cx-cp-comp") aria(hidden: true) {
41
+ Stack class("cx-cp-cam") { Icon "lucide:camera" }
42
+ Stack class("cx-cp-emo") { Icon "lucide:smile" }
43
+ Span "{$placeholder}" class("cx-cp-ph")
44
+ Stack class("cx-cp-tools") { Icon "lucide:paperclip" Icon "lucide:camera" }
45
+ Stack class("cx-cp-igtools") { Icon "lucide:mic" Icon "lucide:image" Icon "lucide:sticker" }
46
+ Stack class("cx-cp-menu") { Icon "lucide:menu" Span "Menú" }
47
+ Stack class("cx-cp-send") { Icon "lucide:send-horizontal" }
48
+ Stack class("cx-cp-mic") { Icon "lucide:mic" }
49
+ }
50
+ }
51
+ }
52
+ }
53
+ }
54
+ }
55
+
56
+ part ChatPlayLog() { Stack class("cx-cp-log") aria(hidden: true) { slot } }
57
+ part ChatPlayMsg(text: text, who: text = "out", step: number = 1, time: text = "") {
58
+ Stack class("cx-cp-msg") data(who: $who, step: $step) {
59
+ Span "{$text}"
60
+ when $time != "" { Stack class("cx-cp-time") { Span "{$time}" Icon "lucide:check-check" } }
61
+ }
62
+ }
63
+ part ChatPlayDay(label: text) { Span "{$label}" class("cx-cp-day") }
64
+ # The start of a conversation as Instagram shows it: the person's photo (ChatPlay's `photo`), name, handle and «Ver perfil».
65
+ part ChatPlayProfile(name: text, sub: text = "", action: text = "") { Stack class("cx-cp-prof") { Span "" class("cx-cp-prof-ph") Span "{$name}" class("cx-cp-prof-n") Span "{$sub}" class("cx-cp-prof-s") Span "{$action}" class("cx-cp-prof-a") } }
66
+ # A small line under a message: Instagram's «Visto», or who answered («Respondió el asistente de Bruma»).
67
+ part ChatPlayNote(label: text, step: number = 1) { Span "{$label}" class("cx-cp-note") data(step: $step) }
68
+ part ChatPlayButtons(step: number = 1) { Stack class("cx-cp-btns") data(step: $step) { slot } }
69
+ part ChatPlayButton(label: text) { Stack class("cx-cp-btn") { Icon "lucide:reply" Span "{$label}" } }
70
+ part ChatPlayCard(title: text, text: text = "", step: number = 1) {
71
+ Stack class("cx-cp-card") data(step: $step) {
72
+ Stack class("cx-cp-ok") { Icon "lucide:check" }
73
+ Stack class("cx-cp-name") { Span "{$title}" class("cx-cp-ct") Span "{$text}" class("cx-cp-cd") }
74
+ }
75
+ }
76
+ part ChatPlayChips() { Stack class("cx-cp-chips") aria(hidden: true) { slot } }
77
+ part ChatPlayChip(label: text) { Span "{$label}" class("cx-cp-chip") }
@@ -0,0 +1,40 @@
1
+ # 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
2
+ # already gone are faded, a red line says what time it is now, and one free slot can fill itself in a loop (a new
3
+ # booking drops in with a green ring and the count steps up) - a picture of «your day keeps itself», not the Agenda.
4
+ # DayPlan(title: "Hoy", date: "viernes 9 de octubre", count: "5 citas", countNext: "6 citas") {
5
+ # DayPlanRow(time: "9:00 AM", title: "Pedro Ruiz", detail: "Corte clásico", tag: "Atendido", state: "done")
6
+ # DayPlanNow(time: "3:12 PM")
7
+ # DayPlanRow(time: "3:30 PM", title: "Laura Gómez", detail: "Corte y barba", tag: "Confirmada", tone: "ok")
8
+ # DayPlanRow(time: "4:30 PM", title: "Carla Méndez", detail: "Corte clásico", tag: "La agendó tu asistente", tone: "ai", state: "fill", free: "Libre")
9
+ # }
10
+ # state: "" | done (gone, faded) | fill (shows `free` dashed, then fills; one per plan). tone: "" | ok | ai.
11
+ # countNext: the count after the fill (empty = no count change). shadcn-for-muten · part · no deps · kit.css (.cx-dp).
12
+ part DayPlan(title: text, date: text = "", count: text = "", countNext: text = "", label: text = "") {
13
+ Stack class("cx-dp cx-play") aria(label: $label) {
14
+ Stack class("cx-dp-top") {
15
+ Span "{$title}" class("cx-dp-t")
16
+ Span "{$date}" class("cx-dp-date")
17
+ when $count != "" {
18
+ Stack class("cx-dp-count") data(next: $countNext != "") { Span "{$count}" class("cx-dp-c1") Span "{$countNext}" class("cx-dp-c2") aria(hidden: true) }
19
+ }
20
+ }
21
+ Stack class("cx-dp-list") aria(role: "list") { slot }
22
+ }
23
+ }
24
+ part DayPlanRow(time: text, title: text, detail: text = "", tag: text = "", tone: text = "", state: text = "", free: text = "") {
25
+ Stack class("cx-dp-row") data(state: $state) aria(role: "listitem") {
26
+ Span "{$time}" class("cx-dp-time")
27
+ Span "" class("cx-dp-dot")
28
+ Stack class("cx-dp-card") {
29
+ when $state == "fill" { Span "{$free}" class("cx-dp-free") aria(hidden: true) }
30
+ Stack class("cx-dp-body") {
31
+ Span "{$title}" class("cx-dp-who")
32
+ Span "{$detail}" class("cx-dp-what")
33
+ when $tag != "" { Span "{$tag}" class("cx-dp-tag") data(tone: $tone) }
34
+ }
35
+ }
36
+ }
37
+ }
38
+ part DayPlanNow(time: text) {
39
+ Stack class("cx-dp-now") aria(hidden: true) { Span "{$time}" Span "" class("cx-dp-now-dot") Span "" class("cx-dp-now-line") }
40
+ }
@@ -1,8 +1,26 @@
1
- # DeviceFrame - a device around a picture of a product: a phone (rounded bezel) or a browser window (a bar with
2
- # three dots). Put an Image, or any content, in the slot; an Image fills the screen from the top. height crops it
3
- # (the rest fades out at the bottom), so a tall screenshot can sit beside a short text.
1
+ # DeviceFrame - a device around a picture of a product, drawn like the real thing: a phone (the iPhone 18 Pro, to scale:
2
+ # aluminium band, black border, the side buttons, an optional Dynamic Island) or a browser window (the bar with its three
3
+ # dots and, when given, the address). Put an Image, or any content, in the slot; an Image fills the screen from the top.
4
+ # height crops it (the whole device fades out at the bottom), so a tall screenshot can sit beside a short text. Every
5
+ # measure of the phone follows its width, so it keeps the same proportions at any size. ChatPlay wears this same phone.
4
6
  # DeviceFrame { Image "~/shots/store.png" alt("Tu tienda en un teléfono") }
5
- # DeviceFrame(kind: "browser", height: "420px") { Image "~/shots/panel.png" alt("Tu panel") }
6
- # width: empty (a phone is 280 px, a browser fills its column) or a width like «320px».
7
- # kind: phone | browser. shadcn-for-muten · part · no deps · kit.css (.cx-device).
8
- part DeviceFrame(kind: text = "phone", height: text = "", width: text = "") { Stack class("cx-device") data(kind: $kind, crop: $height != "", sized: $width != "") style(h: "{$height}", w: "{$width}") { slot } }
7
+ # DeviceFrame(island: "on", width: "300px") { Image "~/shots/app.png" alt("La app") }
8
+ # DeviceFrame(kind: "browser", url: "storio.ink/elroble", height: "420px") { Image "~/shots/panel.png" alt("Tu panel") }
9
+ # DeviceFrame(href: "/aurora", label: "Estudio Aurora, una tienda de muestra", shot: "/stores/aurora.webp")
10
+ # width: empty (a phone is 280 px, a browser fills its column) or a width like «320px» or «100%». island: "on" draws the
11
+ # island (leave it off over a page that has its own top bar). kind: phone | browser.
12
+ # href: the whole device is a link there (it lifts on hover); label says where, for a screen reader.
13
+ # shot: a picture of the screen (390 × 844 at twice the pixels) laid as its background. Unlike an Image it is not
14
+ # downloaded until the device comes near the screen (content-visibility), so a long row of phones costs nothing.
15
+ # A name under the phone: wrap it with its spans in `Stack class("cx-mk-store")`.
16
+ # shadcn-for-muten · part · no deps · kit.css (.cx-device).
17
+ part DeviceFrame(kind: text = "phone", height: text = "", width: text = "", url: text = "", island: text = "", href: text = "", label: text = "", shot: text = "") {
18
+ Stack class("cx-device") data(kind: $kind, crop: $height != "", sized: $width != "", island: $island, link: $href != "", shot: $shot != "") style(h: "{$height}", w: "{$width}", shot: "url('{$shot}')") {
19
+ when $url != "" { Span "{$url}" class("cx-device-url") aria(hidden: true) }
20
+ when $href != "" { Link "" -> "{$href}" class("cx-device-link") aria(label: $label) }
21
+ Stack class("cx-device-screen") {
22
+ when $shot != "" { Stack class("cx-device-shot") }
23
+ slot
24
+ }
25
+ }
26
+ }