@muten/shadcn 0.1.3 → 0.1.5

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.
package/kit.css CHANGED
@@ -5057,7 +5057,8 @@ a.cx-bc-item:hover { color: var(--foreground); }
5057
5057
  .cx-heat-c[data-level="4"] { background: color-mix(in oklab, var(--series) 72%, var(--muted)); }
5058
5058
  .cx-heat-c[data-level="5"] { background: var(--series); }
5059
5059
  .cx-heat-c[data-level="6"] { background: color-mix(in oklab, var(--series) 78%, black); }
5060
- .cx-heat-grid > .cx-heat-c:hover { outline: 2px solid var(--foreground); outline-offset: 1px; }
5060
+ /* only a cell with something to say reacts: never while loading or under the empty note */
5061
+ .cx-heat:not([data-busy="true"]) .cx-heat-grid:not(:has(> .cx-veil-note)) > .cx-heat-c[data-name]:not([data-name=""]):hover { outline: 2px solid var(--foreground); outline-offset: 1px; }
5061
5062
  .cx-heat-scale { display: flex; flex-direction: row; align-items: center; gap: 5px; font-size: 12.5px; color: var(--muted-foreground); }
5062
5063
  .cx-heat-scale > .cx-heat-c { width: 16px; aspect-ratio: auto; height: 12px; border-radius: 3px; }
5063
5064
  .cx-heat-scale > span:first-child { margin-right: 3px; } .cx-heat-scale > span:last-child { margin-left: 3px; }
@@ -5078,7 +5079,8 @@ a.cx-bc-item:hover { color: var(--foreground); }
5078
5079
  .cx-stb-bar:disabled { cursor: default; }
5079
5080
  .cx-stb-bar:focus-visible { outline: 2px solid var(--ring); outline-offset: 3px; }
5080
5081
  .cx-stb-stack { display: flex; flex-direction: column-reverse; gap: 2px; border-radius: 6px 6px 3px 3px; overflow: hidden; transform-origin: bottom; opacity: .55; transition: height .5s cubic-bezier(.22, 1, .36, 1), opacity .2s; }
5081
- .cx-stb-bars:not(:has([aria-pressed="true"])) .cx-stb-stack, .cx-stb-bar[aria-pressed="true"] .cx-stb-stack, .cx-stb-bar:hover .cx-stb-stack { opacity: 1; }
5082
+ .cx-stb-bars:not(:has([aria-pressed="true"])) .cx-stb-stack, .cx-stb-bar[aria-pressed="true"] .cx-stb-stack, .cx-stb-bar:not(:disabled):hover .cx-stb-stack { opacity: 1; }
5083
+ .cx-stb-bar:disabled { pointer-events: none; }
5082
5084
  .cx-stb-seg { display: block; flex: 1 1 0; min-height: 2px; background: var(--series); }
5083
5085
  .cx-stb-sk { display: block; width: 100%; border-radius: 6px 6px 3px 3px; }
5084
5086
  .cx-stb-x { display: grid; grid-template-columns: repeat(var(--n, 12), minmax(0, 1fr)); gap: clamp(4px, 1.2vw, 14px); font-size: 12px; color: var(--muted-foreground); text-align: center; }
@@ -5147,3 +5149,60 @@ a.cx-bc-item:hover { color: var(--foreground); }
5147
5149
  /* StackedBars in a narrow card (a phone): twelve month labels still fit, a size smaller and never cut with «…» */
5148
5150
  .cx-stb { container-type: inline-size; }
5149
5151
  @container (max-width: 520px) { .cx-stb-x { gap: 2px; font-size: 10px; } .cx-stb-x > span { text-overflow: clip; overflow: visible; } .cx-stb-bars { gap: 4px; } .cx-stb-y { font-size: 10.5px; } }
5152
+
5153
+ /* ── MARKETING PARTS (registry/marketing.muten) ──────────────────────────────────────────────────────────────────────
5154
+ The pieces that had lived in Storio's landing as its own classes, now the library's: a lead under a section title,
5155
+ a note after a block, the tag on a fund card, columns of facts, outlined boxes, a path of numbered steps and a
5156
+ checked list with explanations. */
5157
+ .style-nova .cx-mk-sub { margin-top: -12px; max-width: 60ch; }
5158
+ .style-nova .cx-mk-note { margin: -8px 0 0; font-size: 14px; color: var(--muted-foreground); max-width: 70ch; }
5159
+ .style-nova .cx-mk-fund-c:has(> .cx-mk-fund-tag) { grid-row: span 3; }
5160
+ .style-nova .cx-mk-fund-tag { justify-self: start; align-self: start; font-size: 12px; font-weight: 600; letter-spacing: .02em; color: var(--muted-foreground); background: var(--background); border: 1px solid var(--border); padding: 3px 10px; border-radius: 999px; }
5161
+ .style-nova .cx-mk-facts { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border-top: 1px solid var(--border); }
5162
+ .style-nova .cx-mk-fact { display: flex; flex-direction: column; gap: 6px; padding: 22px 20px 4px 0; min-width: 0; }
5163
+ .style-nova .cx-mk-fact + .cx-mk-fact { padding-left: 20px; border-left: 1px solid var(--border); }
5164
+ .style-nova .cx-mk-fact-k { font-weight: 700; font-size: 16px; }
5165
+ .style-nova .cx-mk-fact-p { margin: 0; font-size: 15px; line-height: 1.55; color: var(--muted-foreground); }
5166
+ .style-nova .cx-mk-boxes { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
5167
+ .style-nova .cx-mk-box { display: flex; flex-direction: column; gap: 6px; padding: 20px 20px 22px; border: 1px solid var(--border); border-radius: 18px; min-width: 0; }
5168
+ .style-nova .cx-mk-box-t { font-weight: 700; font-size: 16px; }
5169
+ .style-nova .cx-mk-box-p { margin: 0; font-size: 15px; line-height: 1.55; color: var(--muted-foreground); }
5170
+ .style-nova .cx-mk-path { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; }
5171
+ .style-nova .cx-mk-path-s { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
5172
+ .style-nova .cx-mk-path-n { width: 34px; height: 34px; border-radius: 50%; background: var(--foreground); color: var(--background); display: grid; place-items: center; font-weight: 700; font-size: 14px; }
5173
+ .style-nova .cx-mk-path-p { margin: 0; font-size: 15px; line-height: 1.55; }
5174
+ .style-nova .cx-mk-donelist { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px 28px; }
5175
+ .style-nova .cx-mk-done { display: flex; flex-direction: row; align-items: flex-start; gap: 14px; padding: 20px 0; border-top: 1px solid var(--border); min-width: 0; }
5176
+ .style-nova .cx-mk-done-i { flex: none; width: 26px; height: 26px; border-radius: 50%; background: var(--success-soft); color: var(--success); display: grid; place-items: center; font-size: 15px; margin-top: 1px; }
5177
+ .style-nova .cx-mk-done-tx { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
5178
+ .style-nova .cx-mk-done-t { font-weight: 700; font-size: 16px; }
5179
+ .style-nova .cx-mk-done-p { margin: 0; font-size: 15px; line-height: 1.55; color: var(--muted-foreground); }
5180
+ @media (max-width: 900px) {
5181
+ .style-nova .cx-mk-facts, .style-nova .cx-mk-path { grid-template-columns: 1fr 1fr; row-gap: 22px; }
5182
+ .style-nova .cx-mk-fact:nth-child(3) { padding-left: 0; border-left: 0; }
5183
+ .style-nova .cx-mk-boxes { grid-template-columns: 1fr 1fr; }
5184
+ }
5185
+ @media (max-width: 760px) { .style-nova .cx-mk-donelist { grid-template-columns: 1fr; gap: 0; } }
5186
+ @media (max-width: 560px) {
5187
+ .style-nova .cx-mk-facts, .style-nova .cx-mk-path, .style-nova .cx-mk-boxes { grid-template-columns: 1fr; }
5188
+ .style-nova .cx-mk-fact + .cx-mk-fact { padding-left: 0; border-left: 0; border-top: 1px solid var(--border); }
5189
+ }
5190
+ /* The pricing table's note under a value (cx-pr-*): how or how much, stacked under it, never beside it. */
5191
+ .style-nova .cx-pr-td .cx-pr-note { display: block; margin-top: 3px; font-size: 12.5px; line-height: 1.45; color: var(--muted-foreground); font-weight: 400; }
5192
+ .style-nova .cx-pr-table > .cx-pr-tr > .cx-pr-td:has(> .cx-pr-note), .style-nova .cx-pr-table > .cx-pr-tr > .cx-pr-rh:has(> .cx-pr-note) { flex-direction: column; align-items: flex-start; justify-content: center; gap: 2px; text-align: left; }
5193
+ .style-nova .cx-pr-rh > .cx-pr-note { display: block; font-size: 12.5px; line-height: 1.45; color: var(--muted-foreground); font-weight: 400; max-width: 30ch; }
5194
+
5195
+ /* Agenda: a week column says how many citas it holds and who waits; a cita card carries its price; the phone's week
5196
+ strip can mark each day with a dot per cita in its service's colour (phoneStrip "dots") */
5197
+ .cx-ag-colhead { display: flex; flex-direction: column; align-items: center; gap: 1px; }
5198
+ .cx-ag-colday { font-weight: 600; }
5199
+ .cx-ag-colsub { font-size: 11px; font-weight: 500; color: var(--muted-foreground); white-space: nowrap; }
5200
+ .cx-ag-colsub[data-queue] { color: var(--warning-foreground, var(--foreground)); }
5201
+ .cx-ag-ev { position: absolute; }
5202
+ .cx-ag-evprice { position: absolute; top: 5px; right: 7px; font-size: 11.5px; font-weight: 650; font-variant-numeric: tabular-nums; opacity: .85; }
5203
+ .cx-ag-dots { display: flex; flex-direction: row; justify-content: center; gap: 3px; min-height: 6px; }
5204
+ .cx-ag-dots > i { width: 6px; height: 6px; border-radius: 99px; flex: none; }
5205
+ .cx-ag-ev:has(> .cx-ag-evprice) { padding-right: 62px; }
5206
+ /* a narrow column (the week on a laptop) keeps the client's name: the price shows only where there is room for both */
5207
+ .cx-ag-col { container-type: inline-size; }
5208
+ @container (max-width: 190px) { .cx-ag-evprice { display: none; } .cx-ag-ev:has(> .cx-ag-evprice) { padding-right: 8px; } }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@muten/shadcn",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
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",
@@ -4,7 +4,8 @@
4
4
  // first), "person" (a worker, the default) or any other (a room: say its `group`, «Salas»). capacity = how many citas
5
5
  // it holds at once: 1 (default, never overlap) · 3 (a class of three) · 0 (no limit). overlap "never" | "allow"
6
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)
7
+ // resources [{id, name, photo}] · hours [{id, days: [1..6], ranges: [[540, 780], [840, 1140]]}] (minutes from midnight);
8
+ // a person can have several entries, one per group of days, when the days do not share one schedule
8
9
  // events [{id, resource, with: [ids], day, start "HH:MM", dur, title, subtitle, price, status, tint, ink, tone, steps, actions, at, payment, contact}]
9
10
  // day = "yyyy-mm-dd", or a number of days from today (it lands on the next day that person works)
10
11
  // with = the other agendas the cita also takes (the room it uses, a second worker): checked and shown in each
@@ -20,6 +21,7 @@
20
21
  // sends them back through `command`: "new", "block", "prev", "next", "today", "view:list", or an action id. The
21
22
  // agenda runs a command when its value changes, so repeat one with a suffix after # ("new#1", "new#2"…)
22
23
  // hour12 "yes" - times written 3:00 PM (the data stays "HH:MM") · timeZone "America/Bogota" - today and now are the shop's
24
+ // phoneStrip "dots" - the phone's week strip marks each day with a dot per cita in its service's colour (default: the count)
23
25
  // phoneView "list" - a phone opens on the list until a view is picked · blockScope "all" - blocks are for the whole
24
26
  // business (no «Para» picker: a backend with no per-person blocks)
25
27
  // events also take tags ["A domicilio", "Llegó 10:02"] (words next to the time) and photo (the client's face, in the list)
@@ -42,8 +44,9 @@ export function mount(el, inputs, on) {
42
44
  const at = (s) => new Date(String(s) + 'T00:00:00');
43
45
  const yes = (v) => v === true || v === 'yes' || v === 'true';
44
46
  const data = (i) => {
45
- const hours = Object.fromEntries(list(i.hours).map((h) => [h.id, h]));
46
- const work = (rid, d) => { const h = hours[rid]; return h && list(h.days).includes(d.getDay()) ? list(h.ranges) : []; };
47
+ // a person can have several entries, one per group of days (Monday 9 to 18, Saturday 9 to 13): the first that names the day wins
48
+ const hours = list(i.hours);
49
+ const work = (rid, d) => { const h = hours.find((x) => x.id === rid && list(x.days).includes(d.getDay())); return h ? list(h.ranges) : []; };
47
50
  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); };
48
51
  const plain = (d) => (typeof d === 'number' || /^-?\d+$/.test(String(d)) ? addDays(today, Number(d)) : at(d));
49
52
  const dayOf = (rid, d) => (typeof d === 'number' || /^-?\d+$/.test(String(d)) ? land(rid, d) : at(d));
@@ -67,7 +70,7 @@ export function mount(el, inputs, on) {
67
70
  from: Number(inputs.from) || 9, to: Number(inputs.to) || 19, slot: Number(inputs.slot) || 30, view: inputs.view || 'day',
68
71
  createSheet: inputs.create === 'sheet', services: list(inputs.services),
69
72
  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',
73
+ resize: inputs.resize === 'off' ? 'off' : 'on', moveAcross: inputs.moveAcross === 'off' ? 'off' : 'on', hour12: yes(inputs.hour12), timeZone: inputs.timeZone || '', phoneStrip: inputs.phoneStrip === 'dots' ? 'dots' : '', phoneView: ['list', 'day'].includes(inputs.phoneView) ? inputs.phoneView : '', blockScope: inputs.blockScope === 'all' ? 'all' : 'each',
71
74
  onAction: (id) => send('action', id),
72
75
  onCreate: (x) => send('create', day1(x)),
73
76
  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,
@@ -24,15 +24,17 @@ part AgendaDemo(resources: list, hours: list, events: list, blocks: list, action
24
24
  # onOpen(json {id, …}) a cita tapped (open your sheet) · onCreate(json {resource, day, start}) a free hour tapped
25
25
  # onBlock(json {resource, day, start, dur, reason}) · onMove(json {id, resource, day, start, dur}) dragged (answer with
26
26
  # the data: it stays where it landed, or goes back if you send it unchanged) · onRange(json {from, to, view}) days shown
27
+ # actions [{id, label, icon, primary}] the toolbar's buttons (default: Bloquear and Nueva cita; [{id: "block", …}] alone
28
+ # leaves «Nueva cita» to the app's own button) · an id other than block or new fires onAction(id)
27
29
  # phone "nav": on a phone its actions (Bloquear, Nueva cita, yours) and the arrows leave the toolbar for the app's
28
30
  # phone bar (PhoneBar), which sends them back through `command` ("new", "block", "prev", "next", "today", "view:list",
29
31
  # an action id; repeat one with a suffix after #: "new#2"). Nothing runs while loading.
30
32
  # several agendas: resources carry kind (business | person | a room…) and capacity (1 never overlaps, 0 no limit);
31
33
  # 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 [{…}]
34
+ # 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" · phoneStrip "dots" (the phone's week: a dot per cita in its colour) · blockScope "all" · placing [{…}]
33
35
  # (the dashed cita while the app's new-cita sheet is open) · events take tags [words] and photo; resources role, hideEmpty
34
36
  # AgendaLive(resources: @team, hours: @hours, events: @citas, blocks: @blocks, free: @free, queue: @queue, closed: @closed,
35
37
  # 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")
38
+ part AgendaLive(resources: list, hours: list, events: list, blocks: list, actions: 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 = "", phoneStrip: text = "", blockScope: text = "", resize: text = "", moveAcross: text = "", placing: list, onOpen: action, onCreate: action, onBlock: action, onMove: action, onRange: action, onAction: action) {
39
+ Custom Agenda inputs(resources: $resources, hours: $hours, events: $events, blocks: $blocks, actions: $actions, 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, phoneStrip: $phoneStrip, blockScope: $blockScope, resize: $resize, moveAcross: $moveAcross, placing: $placing, sheet: "app") on(open: $onOpen, create: $onCreate, block: $onBlock, move: $onMove, range: $onRange, action: $onAction) class("cx-agenda")
38
40
  }
@@ -0,0 +1,19 @@
1
+ # InfoTable - shadcn's Table for content (not a data grid: for rows from a list with filters, use TableView). A header row
2
+ # of InfoHead, then rows of InfoCell. variant "compare" is the comparison look (first column strong, the rest
3
+ # softer, stacked with their column's name on a phone); a cell's tone picks "strong" or "soft" inside it.
4
+ # InfoTable(variant: "compare") {
5
+ # InfoHeadRow { InfoHead(label: "") InfoHead(label: "Storio") InfoHead(label: "Linktree") }
6
+ # InfoRow { InfoCell(label: "Agenda", tone: "strong") InfoCell(label: "Incluida") InfoCell(label: "No", tone: "soft") }
7
+ # }
8
+ # shadcn-for-muten · parts · no deps · Nova (.cn-table) + kit.css (.cx-mk-vs-table).
9
+ part InfoTable(variant: text = "default") {
10
+ Stack class("cn-table-container") data(slot: "table-container") {
11
+ Stack class("cn-table", "cx-mk-vs-table" when $variant == "compare") data(slot: "table") aria(role: "table") { slot }
12
+ }
13
+ }
14
+ part InfoHeadRow() { Stack class("cn-table-row cx-mk-vs-head") data(slot: "table-row") aria(role: "row") { slot } }
15
+ part InfoRow() { Stack class("cn-table-row") data(slot: "table-row") aria(role: "row") { slot } }
16
+ part InfoHead(label: text) { Span "{$label}" class("cn-table-head") data(slot: "table-head") aria(role: "columnheader") }
17
+ part InfoCell(label: text, tone: text = "") {
18
+ Span "{$label}" class("cn-table-cell", "cx-mk-vs-what" when $tone == "strong", "cx-mk-vs-them" when $tone == "soft") data(slot: "table-cell") aria(role: "cell")
19
+ }
@@ -181,7 +181,7 @@ watchReveal();
181
181
 
182
182
  // ── tooltip on any [data-name] ──
183
183
  const tip = document.createElement('div'); tip.className = 'cx-tip'; tip.setAttribute('role', 'tooltip'); tip.hidden = true; lib.appendChild(tip);
184
- const tipOn = (e) => { const w = e.target.closest?.('[data-name]'); if (!w) return; tip.textContent = w.dataset.name; tip.hidden = false; const r = w.getBoundingClientRect(); if (w.closest('[data-slot="sidebar"]')) { tip.style.left = (r.right + 8) + 'px'; tip.style.top = (r.top + r.height / 2 - tip.offsetHeight / 2) + 'px'; } else { place(tip, w, 'center', 6); tip.style.top = (r.top - tip.offsetHeight - 6) + 'px'; } requestAnimationFrame(() => tip.dataset.state = 'open'); };
184
+ const tipOn = (e) => { const w = e.target.closest?.('[data-name]'); if (!w || !w.dataset.name) return; /* an empty name (no data yet) says nothing */ tip.textContent = w.dataset.name; tip.hidden = false; const r = w.getBoundingClientRect(); if (w.closest('[data-slot="sidebar"]')) { tip.style.left = (r.right + 8) + 'px'; tip.style.top = (r.top + r.height / 2 - tip.offsetHeight / 2) + 'px'; } else { place(tip, w, 'center', 6); tip.style.top = (r.top - tip.offsetHeight - 6) + 'px'; } requestAnimationFrame(() => tip.dataset.state = 'open'); };
185
185
  const tipOff = (e) => { if (e.target.closest?.('[data-name]')) { tip.dataset.state = 'closed'; tip.hidden = true; } };
186
186
  ['pointerover', 'focusin'].forEach((t) => document.addEventListener(t, tipOn)); ['pointerout', 'focusout'].forEach((t) => document.addEventListener(t, tipOff));
187
187
 
@@ -491,7 +491,7 @@ let wheelsNow = [];
491
491
  weekOf: (d) => `Semana del ${d}`, dayTitle: (wd, n, m) => `${wd} ${n} de ${m}`,
492
492
  off: 'No trabaja este día', blocked: 'Bloqueado', minutes: 'minutos', with: 'con', notFree: 'no está libre', notFreeLong: 'Ese horario no está libre',
493
493
  nowLasts: (m) => `Ahora dura ${m} min`, movedTo: (t, who) => `Movida a las ${t}${who ? ` con ${who}` : ''}`,
494
- free: 'Libre', freeFromTo: (a, b) => `Libre de ${a} a ${b}, agendar`, until: 'a', noHours: 'No hay horario este día', freeSlot: 'Hora libre',
494
+ free: 'Libre', freeFromTo: (a, b) => `Libre de ${a} a ${b}, agendar`, until: 'a', noHours: 'No hay horario este día', dayOver: 'Este día ya pasó', todayOver: 'Ya no quedan horas libres hoy', freeSlot: 'Hora libre',
495
495
  now: 'Ahora', waiting: (n) => (n === 1 ? '1 en espera' : `${n} en espera`), closed: 'Cerrado', countOf: (n) => (n === 1 ? '1 cita' : `${n} citas`),
496
496
  move: 'Cambiar la hora', day: 'Día', current: 'Es su hora actual', noneFree: 'No queda una hora libre ese día.', back: 'Volver', saveMove: 'Guardar nueva hora',
497
497
  cancelOf: 'Cancelar la cita', why: '¿Por qué?', reasons: ['El cliente pidió cancelar', 'No vino', 'Me surgió algo', 'Otro'], reasonLabel: 'Motivo', tellClient: 'Le avisamos al cliente con este motivo.',
@@ -514,7 +514,7 @@ let wheelsNow = [];
514
514
  weekOf: (d) => `Week of ${d}`, dayTitle: (wd, n, m) => `${wd}, ${m} ${n}`,
515
515
  off: 'Not working this day', blocked: 'Blocked', minutes: 'minutes', with: 'with', notFree: 'not free', notFreeLong: 'That time is not free',
516
516
  nowLasts: (m) => `Now ${m} min long`, movedTo: (t, who) => `Moved to ${t}${who ? ` with ${who}` : ''}`,
517
- free: 'Free', freeFromTo: (a, b) => `Free from ${a} to ${b}, book`, until: 'to', noHours: 'No hours this day', freeSlot: 'Free time',
517
+ free: 'Free', freeFromTo: (a, b) => `Free from ${a} to ${b}, book`, until: 'to', noHours: 'No hours this day', dayOver: 'This day is over', todayOver: 'No free hours left today', freeSlot: 'Free time',
518
518
  now: 'Now', waiting: (n) => `${n} waiting`, closed: 'Closed', countOf: (n) => (n === 1 ? '1 appointment' : `${n} appointments`),
519
519
  move: 'Change the time', day: 'Day', current: 'Its current time', noneFree: 'No free time left that day.', back: 'Back', saveMove: 'Save new time',
520
520
  cancelOf: 'Cancel the appointment', why: 'Why?', reasons: ['The client asked to cancel', 'No-show', 'Something came up', 'Other'], reasonLabel: 'Reason', tellClient: 'We tell the client with this reason.',
@@ -543,7 +543,7 @@ let wheelsNow = [];
543
543
  const { from = 9, to = 19, slot = 30, hourPx = 56, onCreate = () => {}, onChange = () => {}, onAction = () => {}, onOpen = null, onBlock = null, onMove = null, onRange = () => {},
544
544
  actions = null, createSheet = false, services = [], view: startView = 'day', sheet: sheetMode = 'built-in', drag: dragOn = true,
545
545
  resize: resizeMode = 'on', moveAcross = 'on',
546
- start: startDay = null, phone: phoneMode = '', hour12 = false, timeZone = '', phoneView = '', blockScope = 'each' } = opts;
546
+ start: startDay = null, phone: phoneMode = '', hour12 = false, timeZone = '', phoneView = '', blockScope = 'each', phoneStrip = '' } = opts;
547
547
  // THE SHOP'S CLOCK: with a timeZone, «today», «now» and «the past» are the shop's, not the browser's (an owner
548
548
  // travelling, or a phone set to another zone, still sees the shop's day)
549
549
  const clockNow = () => { if (!timeZone) return new Date(); try { const p = Object.fromEntries(new Intl.DateTimeFormat('en-US', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).formatToParts(new Date()).map((x) => [x.type, x.value])); return new Date(+p.year, +p.month - 1, +p.day, +p.hour % 24, +p.minute); } catch { return new Date(); } };
@@ -606,7 +606,7 @@ let wheelsNow = [];
606
606
  const tagsOf = (e) => (Array.isArray(e.tags) ? e.tags.filter(Boolean).join(' · ') : '');
607
607
  const SAY = ['started', 'awaiting_payment', 'noshow']; // the statuses a cita SAYS in words (no stripes, no glow)
608
608
  const evHtml = (e, ln, showWho, rid = null, gut = false) => { const [l, n] = ln.get(e.id) || [0, 1]; const short = e.dur < 45; const said = clash.has(e.id) ? w.clashOne : SAY.includes(e.status) && w.status[e.status] ? w.status[e.status] : ''; const others = rid ? occ(e).filter((x) => x !== rid).map(resName).filter(Boolean).join(', ') : ''; const tg = tagsOf(e);
609
- const span = gut ? '(100% - 22px)' : '100%'; /* an agenda with no limit keeps a strip on the right to book beside */ return `<button class="cx-ag-ev" data-ev="${e.id}" data-tone="${toneOf(e)}" ${e.status ? `data-status="${e.status}"` : ''}${paint1(e)}top:${y(toMin(e.start))}px;height:${(e.dur / 60) * hourPx - 3}px;left:calc(${span} * ${l / n} + 3px);width:calc(${span} / ${n} - 6px)"${clash.has(e.id) ? ' data-conflict' : ''} aria-label="${e.title}, ${clk(e.start)}, ${e.dur} ${w.minutes}${tg ? ', ' + tg : ''}${showWho ? `, ${w.with} ${resName(e.resource)}` : ''}${others ? `, ${w.with} ${others}` : ''}${e.status && w.status[e.status] ? `, ${w.status[e.status]}` : ''}${clash.has(e.id) ? `, ${w.clashOne}` : ''}">${short ? ui.text(`${e.title} · ${said || tg || clk(e.start)}`, 'label') : `${ui.text(e.title, 'label')}${ui.text(`${said ? said + ' · ' : ''}${tg ? tg + ' · ' : ''}${clk(e.start)} · ${e.subtitle || ''}${showWho ? ' · ' + resName(e.resource) : ''}${others ? ' · ' + others : ''}`, 'caption')}`}${dragOn && resizeMode !== 'off' ? '<span class="cx-ag-grip" aria-hidden="true"></span>' : ''}</button>`; };
609
+ const span = gut ? '(100% - 22px)' : '100%'; /* an agenda with no limit keeps a strip on the right to book beside */ return `<button class="cx-ag-ev" data-ev="${e.id}" data-tone="${toneOf(e)}" ${e.status ? `data-status="${e.status}"` : ''}${paint1(e)}top:${y(toMin(e.start))}px;height:${(e.dur / 60) * hourPx - 3}px;left:calc(${span} * ${l / n} + 3px);width:calc(${span} / ${n} - 6px)"${clash.has(e.id) ? ' data-conflict' : ''} aria-label="${e.title}, ${clk(e.start)}, ${e.dur} ${w.minutes}${tg ? ', ' + tg : ''}${showWho ? `, ${w.with} ${resName(e.resource)}` : ''}${others ? `, ${w.with} ${others}` : ''}${e.status && w.status[e.status] ? `, ${w.status[e.status]}` : ''}${clash.has(e.id) ? `, ${w.clashOne}` : ''}">${short ? ui.text(`${e.title} · ${said || tg || clk(e.start)}`, 'label') : `${ui.text(e.title, 'label')}${ui.text(`${said ? said + ' · ' : ''}${tg ? tg + ' · ' : ''}${clk(e.start)} · ${e.subtitle || ''}${showWho ? ' · ' + resName(e.resource) : ''}${others ? ' · ' + others : ''}`, 'caption')}`}${e.price && !short ? `<span class="cx-ag-evprice">${fmtMoney(e.price)}</span>` : ''}${dragOn && resizeMode !== 'off' ? '<span class="cx-ag-grip" aria-hidden="true"></span>' : ''}</button>`; };
610
610
  // the hours a person does not work: hatched. With server free hours, everything that is neither free nor booked.
611
611
  const offHtml = (d, rid) => {
612
612
  const av = freeList ? null : availability(rid, d);
@@ -641,7 +641,7 @@ let wheelsNow = [];
641
641
  return `<div class="cx-hstack cx-ag-who" role="radiogroup" aria-label="${w.whose}"><span class="cx-ag-wholabel" aria-hidden="true">${w.whose}</span>${all}${kinds.map((k) => { const rs = ordered().filter((r) => kindOf(r) === k); return `<span class="cx-ag-sep" aria-hidden="true"></span>${rs.length > 1 ? ui.button({ label: groupName(k), size: 'sm', variant: 'ghost', attrs: `role="radio" aria-checked="${who === 'kind:' + k}" data-who="kind:${k}"`, cls: 'cx-ag-group' }) : ''}${rs.map(personBtn).join('')}`; }).join('')}</div>`;
642
642
  };
643
643
  // the week strip: each day with how many citas it has (a plain number), closed days dimmed
644
- const strip = () => `<div class="cx-ag-strip" role="group" aria-label="${w.days}">${weekDays().map((d) => { const evs = loading ? [] : evOn(d); const n = evs.length; const shut = isClosed(d); return `<button class="cn-button cn-button-variant-ghost cx-size-tile cx-ag-day" data-ag-day="${+d}" aria-pressed="${sameDay(d, day)}" ${sameDay(d, today) ? 'data-today' : ''} ${shut ? 'data-closed' : ''} aria-label="${w.dayL[d.getDay()]} ${d.getDate()}, ${shut && !n ? w.closed : w.countOf(n)}"${loading ? ' disabled aria-disabled="true"' : ''}><small>${sameDay(d, today) ? w.todayShort : w.dayS[d.getDay()]}</small><b>${d.getDate()}</b><span class="cx-ag-count" aria-hidden="true">${loading ? '<s class="skel"></s>' : n || ''}</span></button>`; }).join('')}</div>`;
644
+ const strip = () => `<div class="cx-ag-strip" role="group" aria-label="${w.days}">${weekDays().map((d) => { const evs = loading ? [] : evOn(d); const n = evs.length; const shut = isClosed(d); return `<button class="cn-button cn-button-variant-ghost cx-size-tile cx-ag-day" data-ag-day="${+d}" aria-pressed="${sameDay(d, day)}" ${sameDay(d, today) ? 'data-today' : ''} ${shut ? 'data-closed' : ''} aria-label="${w.dayL[d.getDay()]} ${d.getDate()}, ${shut && !n ? w.closed : w.countOf(n)}"${loading ? ' disabled aria-disabled="true"' : ''}><small>${sameDay(d, today) ? w.todayShort : w.dayS[d.getDay()]}</small><b>${d.getDate()}</b>${phoneStrip === 'dots' ? `<span class="cx-ag-dots" aria-hidden="true">${loading ? '' : evs.slice(0, 4).map((e) => `<i style="background:${e.tint || 'var(--selected, var(--primary))'}"></i>`).join('')}</span>` : `<span class="cx-ag-count" aria-hidden="true">${loading ? '<s class="skel"></s>' : n || ''}</span>`}</button>`; }).join('')}</div>`;
645
645
  // «Ahora»: on today, the cita in progress (started, or whose time is now), first thing, one tap to open it
646
646
  const nowCard = () => {
647
647
  if (!sameDay(day, today)) return '';
@@ -679,7 +679,7 @@ let wheelsNow = [];
679
679
  }
680
680
  }
681
681
  const html = rows.map((r) => (typeof r === 'string' ? r : `<button class="cx-ag-row" data-kind="free" data-free="${r.start}" data-rid="${r.free[0]}" aria-label="${w.freeFromTo(clk(toHM(r.start)), clk(toHM(r.end)))}"><span class="cx-ag-row-t">${ui.text(clk(toHM(r.start)), 'strong')}${r.end - r.start > slot ? ui.text(`${w.until} ${clk(toHM(r.end))}`, 'caption') : ''}</span>${ui.text(`${w.free}${rids.length > 1 ? ` · ${r.free.map(resName).join(', ')}` : ''}`, 'muted')}<span class="cx-grow"></span>${ui.icon('plus')}</button>`)).join('');
682
- return `<div class="cx-ag-list" role="list">${html || `<div class="cn-empty" style="padding:32px 12px"><div class="cn-empty-header"><div class="cn-empty-title">${isClosed(day) ? w.closed : w.noHours}</div></div></div>`}</div>`;
682
+ return `<div class="cx-ag-list" role="list">${html || `<div class="cn-empty" style="padding:32px 12px"><div class="cn-empty-header"><div class="cn-empty-title">${isClosed(day) ? w.closed : !rids.some((rid) => availability(rid, day).length) ? w.noHours : +day < +today && !sameDay(day, today) ? w.dayOver : sameDay(day, today) ? w.todayOver : w.noHours}</div></div></div>`}</div>`;
683
683
  };
684
684
  // LOADING keeps the very layout it is about to show: the same toolbar, strip, column heads, hours and height,
685
685
  // with grey citas where the real ones will land. Nothing jumps when the data arrives, nothing shrinks while it loads.
@@ -704,17 +704,19 @@ let wheelsNow = [];
704
704
  else return onAction(k);
705
705
  paint();
706
706
  }
707
+ // a week column says its day and, under it, how many citas it holds and who waits for a free hour (closed: «Cerrado»)
708
+ const weekHead = (d, rid) => { const n = evOn(d, rid).length, q = queue.filter((x) => sameDay(x.day, d)).length, shut = isClosed(d); const sub = [shut && !n ? w.closed : n ? w.countOf(n) : '', q ? w.waiting(q) : ''].filter(Boolean).join(' · '); return `<span class="cx-ag-colday">${w.dayS[d.getDay()]} ${d.getDate()}</span>${sub ? `<small class="cx-ag-colsub"${q ? ' data-queue' : ''}>${sub}</small>` : ''}`; };
707
709
  const tellRange = () => { const v = eff(); const a = v === 'range' ? rangeSel[0] : v === 'week' ? weekDays()[0] : day, b = v === 'range' ? rangeSel[1] : v === 'week' ? weekDays()[6] : day; const key = `${v}|${+a}|${+b}`; if (key === lastRange) return; lastRange = key; onRange({ from: a, to: b, view: v }); };
708
710
  const paint = () => {
709
711
  if (typeof drag !== 'undefined' && drag) { const d = drag; drag = null; clearTimeout(d.timer); cancelAnimationFrame(scrollRaf); d.ghost?.remove(); }
710
712
  const v = eff(); // the phone keeps the calendar: day grid or list, the week needs a wide screen
711
- const cols = v === 'week' ? weekDays().map((d) => ({ day: d, rid: who === 'all' || who.startsWith('kind:') ? 'all' : who, head: `${w.dayS[d.getDay()]} ${d.getDate()}`, label: `${w.dayL[d.getDay()]} ${d.getDate()}`, today: sameDay(d, today), closed: isClosed(d) })) : dayCols().map((r) => ({ day, rid: r.id, head: `${r.photo ? `<span class="cn-avatar cx-ag-face" style="background-image:url('${r.photo}')"></span>` : ui.avatar(r.name, { size: 'sm' })}<span class="cx-ag-colname"><b>${r.name}</b>${roleOf(r) ? `<small>${roleOf(r)}</small>` : ''}</span>`, label: r.name }));
713
+ const cols = v === 'week' ? weekDays().map((d) => ({ day: d, rid: who === 'all' || who.startsWith('kind:') ? 'all' : who, head: weekHead(d, who === 'all' || who.startsWith('kind:') ? null : who), label: `${w.dayL[d.getDay()]} ${d.getDate()}`, today: sameDay(d, today), closed: isClosed(d) })) : dayCols().map((r) => ({ day, rid: r.id, head: `${r.photo ? `<span class="cn-avatar cx-ag-face" style="background-image:url('${r.photo}')"></span>` : ui.avatar(r.name, { size: 'sm' })}<span class="cx-ag-colname"><b>${r.name}</b>${roleOf(r) ? `<small>${roleOf(r)}</small>` : ''}</span>`, label: r.name }));
712
714
  clash = loading ? new Set() : findClashes();
713
715
  const crossed = loading ? 0 : evOn(day).filter((e) => clash.has(e.id)).length;
714
716
  const top = loading ? skelTop() : nowCard() + queueLine();
715
717
  const alert = crossed ? ui.alert({ icon: 'info', title: w.clash(crossed), description: w.clashHow, action: ui.button({ label: w.clashSee, size: 'sm', variant: 'outline', attrs: 'data-ag-clash' }), tone: 'bad', attrs: 'data-ag-alert' }) : '';
716
718
  if (!loading) { hadNow = top.includes('cx-ag-nowcard'); hadQueue = top.includes('cx-ag-queue'); }
717
- root.innerHTML = toolbar() + `<div class="cx-ag-frame"${loading ? ` aria-busy="true" aria-label="${w.loading}"` : ''}><div class="cx-ag-top">${people()}${strip()}${top}${alert}</div>${v === 'range' ? `<div class="cx-ag-listwrap">${loading ? skelList() : rangeList()}</div>` : v === 'list' ? `<div class="cx-ag-listwrap">${loading ? skelList() : list()}</div>` : grid(cols)}</div>`;
719
+ root.innerHTML = toolbar() + `<div class="cx-ag-frame"${loading ? ` aria-busy="true" aria-label="${w.loading}"` : ''}><div class="cx-ag-top">${people()}${v === 'week' ? '' : strip()}${top}${alert}</div>${v === 'range' ? `<div class="cx-ag-listwrap">${loading ? skelList() : rangeList()}</div>` : v === 'list' ? `<div class="cx-ag-listwrap">${loading ? skelList() : list()}</div>` : grid(cols)}</div>`;
718
720
  root.toggleAttribute('data-loading', !!loading);
719
721
  $$('.cx-tabs', root).forEach(initTabs);
720
722
  const now = $('.cx-ag-now', root); if (now) $('.cx-ag-scroll', root).scrollTop = Math.max(0, parseFloat(now.style.top) - 120);
@@ -4,7 +4,7 @@
4
4
  # DocHead(title: "Términos y condiciones") { Text "Última actualización: 7 de octubre de 2026" class("cx-doc-line") }
5
5
  # DocBody {
6
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") } }
7
+ # DocArticle { DocSection(anchor: "s1", title: "1. Quiénes somos") { DocText(text: "Storio es un producto de Candhrly LLC.") } }
8
8
  # }
9
9
  # shadcn-for-muten · parts · no deps · kit.css (.cx-doc-*).
10
10
  part DocHead(title: text) {
@@ -19,6 +19,8 @@ part DocTocLink(label: text, to: text) {
19
19
  Link "{$label}" -> "{$to}" class("cn-button cn-button-variant-ghost cn-button-size-sm group/button cx-nav cx-doc-toc-item") data(slot: "button", marker: "fill")
20
20
  }
21
21
  part DocArticle() { Stack class("cx-doc-article") { slot } }
22
+ # DocText - one paragraph of a section, at reading width.
23
+ part DocText(text: text) { Text "{$text}" class("cx-doc-p") }
22
24
  part DocSection(anchor: text, title: text) {
23
25
  Stack class("cx-doc-sec") id("{$anchor}") {
24
26
  Title "{$title}" h2 class("cx-doc-h2")
@@ -0,0 +1,167 @@
1
+ # Marketing - the pieces a site's pages are built from (the cx-mk-* system in kit.css): a section with its title, the
2
+ # hero's pill and two-line headline, the address field with its button, a list of checks, numbered steps, cards,
3
+ # columns of facts, a path of steps, a checked list with explanations and the closing call. Every argument is text,
4
+ # so the words can come from a translation function: MkCheck(label: "{t(lang, "x")}").
5
+ # MkSection(anchor: "how", title: "Así funciona") {
6
+ # MkChecks { MkCheck(label: "Sin app para tu cliente") MkCheck(label: "Confirma solo") }
7
+ # }
8
+ # MkHero { MkHeroCopy { MkPill(label: "Para músicos") { Icon "lucide:music" } MkHeadline(first: "Tu música,", second: "en un link.")
9
+ # MkClaim(value: handle, to: joinUrl, cta: "Crear mi tienda") class("animate-rise") } … }
10
+ # MkCta(title: "Tu negocio,", soft: "vende y agenda solo.") { MkClaim(value: handle, to: joinUrl, cta: "Crear mi tienda") }
11
+ # shadcn-for-muten · parts · no deps · kit.css (.cx-mk-*), uses InputGroup and LinkBtn.
12
+
13
+ # MkSection - a page section at reading width with its h2. anchor is its id (a link can go to #anchor).
14
+ part MkSection(anchor: text, title: text = "") {
15
+ Section class("cx-mk-sec cx-mk-col") id("{$anchor}") {
16
+ when $title != "" { Title "{$title}" h2 class("cx-mk-h2") }
17
+ slot
18
+ }
19
+ }
20
+ # MkHead - a section's heading block when it has more than the title: an eyebrow above, a lead under it, and in the
21
+ # slot whatever follows in the same block (a button, a field).
22
+ part MkHead(title: text, lead: text = "", eyebrow: text = "") {
23
+ Stack class("cx-mk-head") {
24
+ when $eyebrow != "" { Span "{$eyebrow}" class("cx-mk-eyebrow") }
25
+ Title "{$title}" h2 class("cx-mk-h2")
26
+ when $lead != "" { Text "{$lead}" class("cx-mk-lead") }
27
+ slot
28
+ }
29
+ }
30
+ # MkLead - the line under a section's title, a little closer to it than a paragraph.
31
+ part MkLead(text: text) { Text "{$text}" class("cx-mk-lead cx-mk-sub") }
32
+ # MkNote - a small line after a block: where a thing comes from, what it depends on.
33
+ part MkNote(text: text) { Text "{$text}" class("cx-mk-note") }
34
+
35
+ # ── the hero ──
36
+ part MkHero() { Section class("cx-mk-hero") { slot } }
37
+ part MkHeroCopy() { Stack class("cx-mk-hero-copy") { slot } }
38
+ # MkPill - the brand pill over a hero's headline; its icon goes in the slot.
39
+ part MkPill(label: text) { Stack class("cx-pill animate-rise cx-mk-pill") data(slot: "pill", tone: "brand") { slot Span "{$label}" } }
40
+ # MkHeadline - an h1 in two lines that fit the column (the second is part of the same sentence). fit: false keeps
41
+ # the poster size (a home page).
42
+ part MkHeadline(first: text, second: text, fit: bool = true) {
43
+ Stack class("words cx-mk-title") {
44
+ Title "{$first}" h1 class("cx-mk-h1", "cx-mk-h1-fit" when $fit)
45
+ Span "{$second}" class("cx-mk-h1", "cx-mk-h1-fit" when $fit)
46
+ }
47
+ }
48
+ # MkTitle - an h1 in one line of text. size: "fit" fits the column, "wrap" lets a whole sentence wrap a size smaller,
49
+ # "" keeps the poster size.
50
+ part MkTitle(text: text, size: text = "") {
51
+ Title "{$text}" h1 class("cx-mk-h1 words", "cx-mk-h1-fit" when $size == "fit", "cx-mk-h1-wrap" when $size == "wrap")
52
+ }
53
+ # MkClaim - the address field and its button: «storio.ink/ [your-name] [Create]». value is the page's text state.
54
+ part MkClaim(value: text, to: text, cta: text, placeholder: text = "", prefix: text = "storio.ink/") {
55
+ Stack class("cx-mk-claim") {
56
+ InputGroup(size: "2xl", variant: "filled") {
57
+ InputAddon { InputGroupText(label: $prefix) }
58
+ GroupInput(value: $value, placeholder: $placeholder)
59
+ }
60
+ LinkBtn(to: $to, variant: "default", size: "2xl") { Span "{$cta}" }
61
+ }
62
+ }
63
+ # MkCta - the closing call: two lines of title (the second softer) and, in the slot, what to do (an MkClaim).
64
+ part MkCta(title: text, soft: text) {
65
+ Stack class("cx-mk-cta") {
66
+ Stack class("cx-mk-cta-copy") { Span "{$title}" class("cx-mk-h2") Span "{$soft}" class("cx-mk-h2 cx-mk-soft") }
67
+ slot
68
+ }
69
+ }
70
+
71
+ # ── lists ──
72
+ # MkChecks / MkCheck - short claims, each with a check.
73
+ part MkChecks() { Stack class("cx-mk-checks") aria(role: "list") { slot } }
74
+ part MkCheck(label: text) { Stack class("cx-mk-check") aria(role: "listitem") { Stack class("cx-mk-ic") { Icon "lucide:check" } Span "{$label}" } }
75
+ # MkCheckList / MkCheckRow - two columns of things a product does, each a check, a title and how it works.
76
+ part MkCheckList() { Stack class("cx-mk-donelist") aria(role: "list") { slot } }
77
+ part MkCheckRow(title: text, text: text) {
78
+ Stack class("cx-mk-done") aria(role: "listitem") {
79
+ Icon "lucide:check" class("cx-mk-done-i")
80
+ Stack class("cx-mk-done-tx") { Span "{$title}" class("cx-mk-done-t") Text "{$text}" class("cx-mk-done-p") }
81
+ }
82
+ }
83
+ # MkSteps / MkStep - numbered steps in boxes; the step's picture goes in the slot (a Stack class("cx-mk-step-vis")).
84
+ part MkSteps() { Stack class("cx-mk-steps") aria(role: "list") { slot } }
85
+ part MkStep(n: text, title: text, text: text) {
86
+ Stack class("cx-mk-step") aria(role: "listitem") {
87
+ Span "{$n}" class("cx-mk-step-n") aria(hidden: true)
88
+ Span "{$title}" class("cx-mk-step-t")
89
+ Text "{$text}" class("cx-mk-step-p")
90
+ slot
91
+ }
92
+ }
93
+ # MkPath / MkPathStep - a short way through, one sentence per step, numbers in circles (four across, two, then one).
94
+ part MkPath() { Stack class("cx-mk-path") aria(role: "list") { slot } }
95
+ part MkPathStep(n: text, text: text) {
96
+ Stack class("cx-mk-path-s") aria(role: "listitem") { Span "{$n}" class("cx-mk-path-n") aria(hidden: true) Text "{$text}" class("cx-mk-path-p") }
97
+ }
98
+ # MkFund / MkFundCard - cards in a grid, each with a title and its paragraph; tag is a small label above (the piece
99
+ # that does it). MkFundSource is a card that also says where its number comes from.
100
+ part MkFund() { Stack class("cx-mk-fund") aria(role: "list") { slot } }
101
+ part MkFundCard(title: text, text: text, tag: text = "") {
102
+ Stack class("cx-mk-fund-c") aria(role: "listitem") {
103
+ when $tag != "" { Span "{$tag}" class("cx-mk-fund-tag") }
104
+ Title "{$title}" h3 class("cx-mk-fund-t")
105
+ Text "{$text}" class("cx-mk-fund-p")
106
+ }
107
+ }
108
+ part MkFundSource(n: text, title: text, text: text, source: text) {
109
+ Stack class("cx-mk-fund-c") aria(role: "listitem") {
110
+ Span "{$n}" class("cx-mk-fund-n")
111
+ Title "{$title}" h3 class("cx-mk-fund-t")
112
+ Text "{$text}" class("cx-mk-fund-p")
113
+ Span "{$source}" class("cx-mk-fund-src")
114
+ }
115
+ }
116
+ # MkFacts / MkFact - columns split by hairlines, a key word and its line (four across, two, then one).
117
+ part MkFacts() { Stack class("cx-mk-facts") aria(role: "list") { slot } }
118
+ part MkFact(key: text, text: text) { Stack class("cx-mk-fact") aria(role: "listitem") { Span "{$key}" class("cx-mk-fact-k") Text "{$text}" class("cx-mk-fact-p") } }
119
+ # MkBoxes / MkBox - outlined boxes with a title and a line (three across, two, then one).
120
+ part MkBoxes() { Stack class("cx-mk-boxes") aria(role: "list") { slot } }
121
+ part MkBox(title: text, text: text) { Stack class("cx-mk-box") aria(role: "listitem") { Span "{$title}" class("cx-mk-box-t") Text "{$text}" class("cx-mk-box-p") } }
122
+
123
+ # ── us and them ──
124
+ # VsTable / VsRow - a comparison of one thing per row: what, how we do it (with a check), how they do it. On a phone
125
+ # each row stacks and the cells carry their column's name. us is the first column's name, logo its picture.
126
+ part VsTable(us: text, them: text, logo: text = "") {
127
+ Stack class("cn-table-container") data(slot: "table-container") {
128
+ Stack class("cn-table cx-mk-vs-table") data(slot: "table") aria(role: "table") {
129
+ Stack class("cn-table-row cx-mk-vs-head") data(slot: "table-row") aria(role: "row") {
130
+ Span "" class("cn-table-head") data(slot: "table-head") aria(role: "columnheader")
131
+ Stack class("cn-table-head") data(slot: "table-head") aria(role: "columnheader") { Image "{$logo}" alt("{$us}") class("cx-mk-vs-logo") }
132
+ Span "{$them}" class("cn-table-head") data(slot: "table-head") aria(role: "columnheader")
133
+ }
134
+ slot
135
+ }
136
+ }
137
+ }
138
+ part VsRow(what: text, us: text, them: text, usName: text, themName: text) {
139
+ Stack class("cn-table-row") data(slot: "table-row") aria(role: "row") {
140
+ Span "{$what}" class("cn-table-cell cx-mk-vs-what") data(slot: "table-cell") aria(role: "cell")
141
+ Stack class("cn-table-cell cx-mk-vs-us") data(slot: "table-cell", label: $usName) aria(role: "cell") { Icon "lucide:check" Span "{$us}" }
142
+ Span "{$them}" class("cn-table-cell cx-mk-vs-them") data(slot: "table-cell", label: $themName) aria(role: "cell")
143
+ }
144
+ }
145
+
146
+ # ── rows, features, stores ──
147
+ # MkRows / MkRow - zigzag rows: the words on one side and a picture on the other (MkStage); reverse puts the words on
148
+ # the right. MkRowCopy is the words: its title (an h2, or h3 under a section that already has one), a line and, in the
149
+ # slot, anything after it (MkChecks).
150
+ # MkRows { MkRow { MkRowCopy(title: "Tu agenda", text: "Se llena sola.") { MkChecks { … } } MkStage { … } } }
151
+ part MkRows() { Stack class("cx-mk-rows") { slot } }
152
+ part MkRow(reverse: bool = false) { Stack class("cx-mk-row", "cx-mk-rev" when $reverse) { slot } }
153
+ part MkRowCopy(title: text, text: text, level: text = "h2") {
154
+ Stack class("cx-mk-row-copy") {
155
+ when $level == "h2" { Title "{$title}" h2 class("cx-mk-h3") }
156
+ when $level != "h2" { Title "{$title}" h3 class("cx-mk-h3") }
157
+ Text "{$text}" class("cx-mk-p")
158
+ slot
159
+ }
160
+ }
161
+ # MkStage - the soft panel a row's picture sits on; tone "dark" for a dark one.
162
+ part MkStage(tone: text = "") { Stack class("cx-mk-stage", "cx-mk-stage-dark" when $tone == "dark") { slot } }
163
+ # MkFeats / MkFeat - a grid of short features, each with its icon (in the slot) and a label.
164
+ part MkFeats() { Stack class("cx-mk-feats") aria(role: "list") { slot } }
165
+ part MkFeat(label: text) { Stack class("cx-mk-feat") aria(role: "listitem") { Stack class("cx-mk-ic") { slot } Span "{$label}" } }
166
+ # MkStore - a store in a carousel: its phone (a DeviceFrame in the slot), its name and what it is.
167
+ part MkStore(name: text, caption: text = "") { Stack class("cx-mk-store") { slot Span "{$name}" Span "{$caption}" } }
@@ -12,3 +12,19 @@ part NavButton(label: text = "", current: bool, onClick: action, variant: text =
12
12
  }
13
13
  }
14
14
  part NavBar(label: text = "Secciones", orientation: text = "horizontal") { Nav class("cx-navbar") data(orientation: $orientation) aria(label: $label) { slot } }
15
+
16
+ # NavLink - a NavButton that goes to another page (a Link): the tabs between sibling pages (Terms · Privacy · Delete).
17
+ # NavHere - the tab of the page you are on, which goes nowhere (aria-current="page", marked like a current NavButton).
18
+ # NavBar(label: "Documentos") { NavHere(label: "Términos", marker: "fill") NavLink(label: "Privacidad", to: "/privacy", marker: "fill") }
19
+ part NavLink(label: text, to: text, variant: text = "ghost", size: text = "default", marker: text = "dot") {
20
+ Link "" -> "{$to}" class("cn-button cn-button-variant-{$variant} cn-button-size-{$size} group/button cx-nav") data(slot: "button", marker: $marker) {
21
+ slot
22
+ Span "{$label}" class("cx-nav-label")
23
+ }
24
+ }
25
+ part NavHere(label: text, variant: text = "ghost", size: text = "default", marker: text = "dot") {
26
+ Stack class("cn-button cn-button-variant-{$variant} cn-button-size-{$size} group/button cx-nav") data(slot: "button", marker: $marker) aria(current: "page") {
27
+ slot
28
+ Span "{$label}" class("cx-nav-label")
29
+ }
30
+ }
@@ -4,9 +4,10 @@
4
4
  # SegmentedControl { SegmentedItem(value: "select", current: tool, label: "Select", onSelect: setTool) … }
5
5
  # SegmentedTabs fills its width; IconTab holds an Icon instead of a label.
6
6
  # shadcn-for-muten · part + Custom (TabsBehavior) · kit.css (.cx-tabs).
7
- part SegmentedControl() { Stack class("cx-tabs") data(slot: "segmented") aria(role: "group") { slot TabsBehavior() } }
8
- part SegmentedItem(value: text, current: text, label: text, onSelect: action) {
9
- Button "{$label}" -> $onSelect($value) class("cn-button cn-button-variant-ghost cn-button-size-sm cx-tab") aria(pressed: $value == $current)
7
+ # label names the group for a screen reader; fill: "phone" stretches it to its row on a phone (to line up with a field).
8
+ part SegmentedControl(label: text = "", fill: text = "") { Stack class("cx-tabs") data(slot: "segmented", fill: $fill) aria(role: "group", label: $label) { slot TabsBehavior() } }
9
+ part SegmentedItem(value: text, current: text, label: text, onSelect: action, size: text = "sm") {
10
+ Button "{$label}" -> $onSelect($value) class("cn-button cn-button-variant-ghost cn-button-size-{$size} cx-tab") aria(pressed: $value == $current)
10
11
  }
11
12
  part SegmentedTabs() { Stack class("cx-tabs cx-tabs-fill") data(slot: "segmented") { slot TabsBehavior() } }
12
13
  part IconTab(value: text, current: text, onSelect: action) {
@@ -40,7 +40,7 @@ export function mount(el, inputs, on) {
40
40
  : rows.map((r, i) => {
41
41
  const tip = `${r.label}: ${series.map((s) => `${s.label} ${fmt(Number(r[s.key]) || 0, unit, locale)}`).join(' · ')}`;
42
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>`;
43
+ return `<button type="button" class="${NS}-bar" data-id="${esc(r.id)}" aria-pressed="${String(r.id) === String(sel)}" aria-label="${esc(tip)}"${totals[i] && !state.readonly ? ` data-name="${esc(tip)}"` : ' disabled'}><span class="${NS}-stack" style="height:${(totals[i] / top) * 100}%">${segs}</span></button>`;
44
44
  }).join('');
45
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
46
  const n = busy ? Math.max(rows.length, 7) : rows.length;
package/registry.json CHANGED
@@ -391,6 +391,13 @@
391
391
  "component": "GradientBlinds",
392
392
  "description": "Animated WebGL gradient + diagonal-blinds hero background that follows the cursor and fades into the page (needs ogl)."
393
393
  },
394
+ {
395
+ "name": "heatmap",
396
+ "deps": [],
397
+ "part": "Heatmap",
398
+ "file": "registry/heatmap.muten",
399
+ "description": "Rows × columns (weekday × hour), each cell darker the more it holds, a less…more scale, a note while empty."
400
+ },
394
401
  {
395
402
  "name": "hotkey",
396
403
  "deps": [],
@@ -428,6 +435,13 @@
428
435
  "file": "registry/indicator.muten",
429
436
  "description": "Indicator(tone): the corner dot that says something needs you."
430
437
  },
438
+ {
439
+ "name": "info-table",
440
+ "deps": [],
441
+ "part": "InfoTable",
442
+ "file": "registry/info-table.muten",
443
+ "description": "InfoTable - shadcn's Table for content (not a data grid: for rows from a list with filters, use TableView). A header row"
444
+ },
431
445
  {
432
446
  "name": "input-group",
433
447
  "deps": [
@@ -505,6 +519,13 @@
505
519
  "component": "KitWidget",
506
520
  "description": "Host of the system kit widgets (Custom, kit core). Use the named parts."
507
521
  },
522
+ {
523
+ "name": "legal-doc",
524
+ "deps": [],
525
+ "part": "DocHead",
526
+ "file": "registry/legal-doc.muten",
527
+ "description": "A legal document (terms, privacy): the title and its line, the contents that follow the reading, the sections at reading width."
528
+ },
508
529
  {
509
530
  "name": "link-btn",
510
531
  "deps": [],
@@ -527,6 +548,13 @@
527
548
  "file": "registry/logo-cloud.muten",
528
549
  "description": "BLOCK: a 'trusted by' strip of logos, muted + grayscale until hover. Parts: LogoCloud, LogoCloudItem."
529
550
  },
551
+ {
552
+ "name": "marketing",
553
+ "deps": [],
554
+ "part": "MkSection",
555
+ "file": "registry/marketing.muten",
556
+ "description": "Marketing - the pieces a site's pages are built from (the cx-mk-* system in kit.css): a section with its title, the"
557
+ },
530
558
  {
531
559
  "name": "member-list",
532
560
  "deps": [
@@ -613,20 +641,6 @@
613
641
  "file": "registry/navpill.muten",
614
642
  "description": "BLOCK: the floating glass navbar (sticky wrapper + glass pill + scroll animation baked in). Drop logo/links/CTA inside."
615
643
  },
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
- },
630
644
  {
631
645
  "name": "new-appointment",
632
646
  "deps": [],
@@ -699,6 +713,13 @@
699
713
  "file": "registry/permission-matrix.muten",
700
714
  "description": "PermissionMatrix - any rows × any roles; one role can be locked (always allowed)."
701
715
  },
716
+ {
717
+ "name": "phone-bar",
718
+ "deps": [],
719
+ "part": "PhoneBar",
720
+ "file": "registry/phone-bar.muten",
721
+ "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."
722
+ },
702
723
  {
703
724
  "name": "phone-field",
704
725
  "deps": [],
@@ -783,6 +804,15 @@
783
804
  "component": "RangeSlider",
784
805
  "description": "RangeSlider(lo, hi, min, max, step, onLo, onHi): two thumbs on the kit ScaleSlider."
785
806
  },
807
+ {
808
+ "name": "rank-item",
809
+ "deps": [
810
+ "progress"
811
+ ],
812
+ "part": "RankItem",
813
+ "file": "registry/rank-item.muten",
814
+ "description": "A ranking row or a thing to act on: a lead (rank or count), title, tag, a line, an optional share bar, the value, and an action in the slot."
815
+ },
786
816
  {
787
817
  "name": "resizable",
788
818
  "deps": [],
@@ -853,6 +883,13 @@
853
883
  "file": "registry/separator.muten",
854
884
  "description": "Separator(orientation): a hairline. horizontal | vertical."
855
885
  },
886
+ {
887
+ "name": "share-bar",
888
+ "deps": [],
889
+ "part": "ShareBar",
890
+ "file": "registry/share-bar.muten",
891
+ "description": "How one whole splits: a bar at 100 % cut into its parts, each in its tone, with each part's value and name under it."
892
+ },
856
893
  {
857
894
  "name": "share-button",
858
895
  "deps": [],
@@ -875,6 +912,13 @@
875
912
  "file": "registry/sidebar.muten",
876
913
  "description": "AppSidebar - a collapsible nav panel (SidebarHeader / SidebarGroup / SidebarItem). Named AppSidebar so it never shadows muten's core Sidebar <aside> primitive."
877
914
  },
915
+ {
916
+ "name": "site-footer",
917
+ "deps": [],
918
+ "part": "SiteFooter",
919
+ "file": "registry/site-footer.muten",
920
+ "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)."
921
+ },
878
922
  {
879
923
  "name": "skeleton",
880
924
  "deps": [],
@@ -937,6 +981,14 @@
937
981
  "file": "registry/split-button.muten",
938
982
  "description": "A main action + a caret dropdown; page owns the open bool."
939
983
  },
984
+ {
985
+ "name": "stacked-bars",
986
+ "deps": [],
987
+ "part": "StackedBars",
988
+ "file": "registry/stacked-bars.muten",
989
+ "component": "StackedBars",
990
+ "description": "Bars over time with series stacked, an axis in the page's unit, a bar you can pick, an empty note and a skeleton."
991
+ },
940
992
  {
941
993
  "name": "stamp-card",
942
994
  "deps": [
@@ -1107,6 +1159,13 @@
1107
1159
  "file": "registry/vector-field.muten",
1108
1160
  "description": "Paired NumberFields (X/Y) for position or size."
1109
1161
  },
1162
+ {
1163
+ "name": "veil",
1164
+ "deps": [],
1165
+ "part": "Veil",
1166
+ "file": "registry/veil.muten",
1167
+ "description": "A calm note (title and line) over a card's content while it has nothing to show yet; the content stays faded behind."
1168
+ },
1110
1169
  {
1111
1170
  "name": "virtual-list",
1112
1171
  "deps": [],
@@ -1137,51 +1196,6 @@
1137
1196
  "part": "ZoomControl",
1138
1197
  "file": "registry/zoom-control.muten",
1139
1198
  "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)."
1147
- },
1148
- {
1149
- "name": "share-bar",
1150
- "deps": [],
1151
- "part": "ShareBar",
1152
- "file": "registry/share-bar.muten",
1153
- "description": "How one whole splits: a bar at 100 % cut into its parts, each in its tone, with each part's value and name under it."
1154
- },
1155
- {
1156
- "name": "heatmap",
1157
- "deps": [],
1158
- "part": "Heatmap",
1159
- "file": "registry/heatmap.muten",
1160
- "description": "Rows × columns (weekday × hour), each cell darker the more it holds, a less…more scale, a note while empty."
1161
- },
1162
- {
1163
- "name": "stacked-bars",
1164
- "deps": [],
1165
- "part": "StackedBars",
1166
- "file": "registry/stacked-bars.muten",
1167
- "description": "Bars over time with series stacked, an axis in the page's unit, a bar you can pick, an empty note and a skeleton.",
1168
- "component": "StackedBars"
1169
- },
1170
- {
1171
- "name": "rank-item",
1172
- "deps": [
1173
- "progress"
1174
- ],
1175
- "part": "RankItem",
1176
- "file": "registry/rank-item.muten",
1177
- "description": "A ranking row or a thing to act on: a lead (rank or count), title, tag, a line, an optional share bar, the value, and an action in the slot."
1178
- },
1179
- {
1180
- "name": "veil",
1181
- "deps": [],
1182
- "part": "Veil",
1183
- "file": "registry/veil.muten",
1184
- "description": "A calm note (title and line) over a card's content while it has nothing to show yet; the content stays faded behind."
1185
1199
  }
1186
1200
  ],
1187
1201
  "classComponents": {