@muten/shadcn 0.1.6 → 0.1.8

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
@@ -1537,10 +1537,11 @@ p.cx-text { margin: 0; }
1537
1537
 
1538
1538
  /* ═════ Calendar ═════ */
1539
1539
  .cx-cal { position: relative; display: inline-flex; flex-direction: column; gap: 8px; padding: 12px; border-radius: calc(var(--radius) * 1.2); box-shadow: 0 0 0 1px var(--border); background: var(--background); }
1540
- .cx-cal-nav { position: absolute; top: 10px; left: 8px; right: 8px; display: flex; justify-content: space-between; pointer-events: none; }
1541
- .cx-cal-nav .cn-button { pointer-events: auto; }
1540
+ /* the title row: ‹ month › (with two months, ‹ beside the first and › beside the last) */
1541
+ .cx-cal-head { display: grid; grid-template-columns: 28px 1fr 28px; align-items: center; gap: 4px; margin-bottom: 4px; }
1542
+ .cx-cal-head > .cn-button { width: 28px; height: 28px; }
1542
1543
  .cx-cal-months { display: flex; gap: 24px; flex-wrap: wrap; }
1543
- .cx-cal-title { height: 28px; display: grid; place-items: center; margin-bottom: 4px; }
1544
+ .cx-cal-title { height: 28px; display: grid; place-items: center; }
1544
1545
  .cx-cal-grid { display: grid; grid-template-columns: repeat(7, 32px); row-gap: 2px; }
1545
1546
  .cx-cal-wd { height: 28px; display: grid; place-items: center; }
1546
1547
  .cx-cal-day { font-variant-numeric: tabular-nums; font-weight: 400; position: relative; }
@@ -1554,7 +1555,7 @@ p.cx-text { margin: 0; }
1554
1555
  .cx-range-presets .cn-button { width: 9.5rem; }
1555
1556
 
1556
1557
  /* ═════ Agenda ═════ */
1557
- .cx-agenda { display: flex; flex-direction: column; gap: 12px; width: 100%; max-width: 1080px; margin: 0 auto; }
1558
+ .cx-agenda { display: flex; flex-direction: column; gap: 12px; width: 100%; max-width: var(--agenda-max, none); margin: 0 auto; }
1558
1559
  .cx-ag-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px; }
1559
1560
  .cx-ag-scroll { max-height: 520px; overflow: auto; }
1560
1561
  .cx-ag { position: relative; display: grid; grid-template-rows: 36px 1fr; min-width: calc(56px + var(--cols) * 140px); }
@@ -1800,7 +1801,7 @@ p.cx-text { margin: 0; }
1800
1801
  .cx-ag-row[data-kind="free"]:hover { border-color: var(--selected); color: var(--selected); }
1801
1802
  .cx-ag-row[data-kind="block"] { --tone: var(--muted-foreground); cursor: default; background: repeating-linear-gradient(135deg, color-mix(in srgb, var(--foreground) 5%, transparent) 0 5px, transparent 5px 10px); }
1802
1803
  .cx-rcpt-mini { display: block; width: 120px; aspect-ratio: 3 / 4; border-radius: 8px; box-shadow: inset 0 0 0 1px var(--border); }
1803
- @container (max-width: 700px) { .cx-ag-bar .cx-text[data-variant="heading"] { flex-basis: 100%; order: 9; } }
1804
+ @container (max-width: 700px) { .cx-ag-bar > .cx-text[data-variant="heading"] { flex-basis: 100%; order: 9; } }
1804
1805
 
1805
1806
  /* time and menu triggers: field-like buttons, icon and chevron with real padding */
1806
1807
  .cx-time-btn { gap: 6px; padding-inline: 10px 12px; font-variant-numeric: tabular-nums; }
@@ -1963,6 +1964,12 @@ p.cx-text { margin: 0; }
1963
1964
  .cx-ag-listwrap { padding: 12px; }
1964
1965
  .cx-ag-head { background: var(--card, var(--background)); }
1965
1966
  .cx-ag-off[data-all] { display: block; text-align: center; padding-top: 14px; }
1967
+ /* a closed day: the whole column hatched, «Cerrado» in the middle where the eye lands (no veil on top, past or not) */
1968
+ .cx-ag-shut { display: grid; place-items: center; }
1969
+ .cx-ag-shutpill { position: sticky; top: 40%; display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; border-radius: 99px; background: var(--card, var(--background)); box-shadow: 0 0 0 1px var(--border), 0 1px 2px color-mix(in oklab, var(--foreground) 6%, transparent); color: var(--muted-foreground); }
1970
+ .cx-ag-shutlist { display: grid; place-items: center; padding: 40px 12px; }
1971
+ .cx-ag-shutpill svg { width: 14px; height: 14px; }
1972
+ .cx-ag-shutpill .cx-text { color: inherit; font-weight: 500; }
1966
1973
  .cx-ag-off[data-all] > .cx-text { display: inline-block; padding: 2px 10px; border-radius: 99px; background: var(--card, var(--background)); box-shadow: 0 0 0 1px var(--border); }
1967
1974
  .cx-ag-ev { touch-action: pan-y; user-select: none; -webkit-user-select: none; cursor: grab; }
1968
1975
  .cx-ag-ev[data-dragging] { opacity: .35; box-shadow: none; }
@@ -4792,8 +4799,9 @@ a.cx-bc-item:hover { color: var(--foreground); }
4792
4799
  .cx-ag-count { display: flex; align-items: center; justify-content: center; height: 12px; font-size: 11px; font-weight: 500; line-height: 1; color: var(--muted-foreground); font-variant-numeric: tabular-nums; }
4793
4800
  .cx-ag-day[aria-pressed="true"] .cx-ag-count { color: inherit; opacity: .8; }
4794
4801
  .cx-ag-count .skel { width: 10px; height: 8px; }
4795
- .cx-ag-day[data-closed]:not([aria-pressed="true"]) { opacity: .45; }
4796
- .cx-ag-day[data-closed] b { text-decoration: line-through; text-decoration-thickness: 1px; }
4802
+ /* a closed day in the strip: the grid's hatch and quieter ink, never struck through (it reads as an error) */
4803
+ .cx-ag-day[data-closed]:not([aria-pressed="true"]) { color: var(--muted-foreground); background: repeating-linear-gradient(135deg, color-mix(in srgb, var(--muted) 90%, transparent) 0 5px, transparent 5px 10px); }
4804
+ .cx-ag-day[data-closed]:not([aria-pressed="true"]) b { font-weight: 500; }
4797
4805
  .cx-ag-colhead[data-closed] { color: var(--muted-foreground); }
4798
4806
  /* «Ahora»: the cita in progress, first thing on today: a plain row on the muted fill, no colour, no motion */
4799
4807
  .cx-ag-nowcard { display: flex; align-items: center; gap: 12px; width: 100%; padding: 12px 14px; border: 1px solid var(--border); border-radius: calc(var(--radius) * 1.1); background: var(--muted); color: var(--foreground); text-align: left; cursor: pointer; font: inherit; }
@@ -4843,7 +4851,7 @@ a.cx-bc-item:hover { color: var(--foreground); }
4843
4851
  .cx-ag-datefoot { display: flex; justify-content: flex-end; }
4844
4852
  .cx-ag-datesheet { display: flex; flex-direction: column; gap: 12px; align-items: stretch; }
4845
4853
  .cx-ag-datesheet .cx-ag-datecal, .cx-ag-pop .cx-ag-datecal { display: flex; justify-content: center; }
4846
- .cx-cal-pick { display: inline-flex; align-items: center; gap: 4px; margin: 0 auto; padding: 4px 8px; border: 0; border-radius: calc(var(--radius) * .7); background: transparent; color: inherit; font: inherit; cursor: pointer; }
4854
+ .cx-cal-pick { display: inline-flex; align-items: center; justify-self: center; gap: 4px; margin: 0 auto; padding: 4px 8px; border: 0; border-radius: calc(var(--radius) * .7); background: transparent; color: inherit; font: inherit; cursor: pointer; }
4847
4855
  .cx-cal-pick:hover { background: color-mix(in oklab, var(--foreground) 6%, transparent); }
4848
4856
  .cx-cal-pick svg { width: 14px; height: 14px; opacity: .6; }
4849
4857
  .cx-cal-mgrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; padding-top: 8px; min-width: 252px; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@muten/shadcn",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
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",
@@ -21,6 +21,7 @@
21
21
  // sends them back through `command`: "new", "block", "prev", "next", "today", "view:list", or an action id. The
22
22
  // agenda runs a command when its value changes, so repeat one with a suffix after # ("new#1", "new#2"…)
23
23
  // hour12 "yes" - times written 3:00 PM (the data stays "HH:MM") · timeZone "America/Bogota" - today and now are the shop's
24
+ // fit "yes" - the hours share the height the app gives the agenda (give it one: a flex parent), never under 56px
24
25
  // phoneStrip "dots" - the phone's week strip marks each day with a dot per cita in its service's colour (default: the count)
25
26
  // phoneView "list" - a phone opens on the list until a view is picked · blockScope "all" - blocks are for the whole
26
27
  // business (no «Para» picker: a backend with no per-person blocks)
@@ -33,11 +34,17 @@
33
34
  // create "sheet" - a showcase: «new» and a tap on a free hour open a short built-in form and the cita lands at once
34
35
  // handlers: create(json {resource, day, start}) · open(json event) · block(json {resource, day, start, dur, reason})
35
36
  // move(json {id, resource, day, start, dur}) · range(json {from, to, view}: view is day | week | list | range) · change(json [...]) · action(id)
36
- // The title opens a month (floating; a sheet on a phone): one day, or «Varios días», a range listed day by day.
37
+ // ranges "yes" - the month under the title also offers «Varios días» (a range listed day by day); off by default
38
+ // The title opens a month (floating; a sheet on a phone) to jump to a day.
37
39
  // LIVE: new inputs repaint the same agenda (same day, view and person): an app sends its data again and it shows;
38
40
  // lang, currency, locale and cents change live too.
39
41
  export function mount(el, inputs, on) {
40
42
  let api = null, map = null, pending = null, lastCmd = String(inputs.command || ''); // the command it was born with never runs
43
+ // an app sends its inputs again on every change of its own, often the same data: repaint only when something drawn
44
+ // changed (`start` only places the first paint, `command` runs apart)
45
+ const SKIP = new Set(['command', 'start']);
46
+ const sigOf = (i) => JSON.stringify(Object.keys(i).filter((k) => !SKIP.has(k)).sort().map((k) => [k, i[k]]));
47
+ let lastSig = sigOf(inputs);
41
48
  import('@muten/shadcn/registry/kit/core.js').then(({ kit, today, addDays }) => {
42
49
  const iso = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
43
50
  const list = (v) => (Array.isArray(v) ? v : []);
@@ -70,7 +77,7 @@ export function mount(el, inputs, on) {
70
77
  actions: list(inputs.actions).length ? list(inputs.actions) : null,
71
78
  from: Number(inputs.from) || 9, to: Number(inputs.to) || 19, slot: Number(inputs.slot) || 30, view: inputs.view || 'day',
72
79
  createSheet: inputs.create === 'sheet', services: list(inputs.services),
73
- sheet: inputs.sheet === 'app' ? 'app' : 'built-in', drag: inputs.drag !== 'off', start: inputs.start ? at(inputs.start) : null, phone: inputs.phone === 'nav' ? 'nav' : '',
80
+ sheet: inputs.sheet === 'app' ? 'app' : 'built-in', drag: inputs.drag !== 'off', start: inputs.start ? at(inputs.start) : null, phone: inputs.phone === 'nav' ? 'nav' : '', fit: yes(inputs.fit), ranges: yes(inputs.ranges),
74
81
  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',
75
82
  onAction: (id) => send('action', id),
76
83
  onCreate: (x) => send('create', day1(x)),
@@ -80,8 +87,8 @@ export function mount(el, inputs, on) {
80
87
  onRange: (r) => send('range', { from: iso(r.from), to: iso(r.to), view: r.view }),
81
88
  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 || '' }))),
82
89
  });
83
- if (pending) { api.update(data(pending)); pending = null; }
90
+ if (pending) { const sig = sigOf(pending); if (sig !== lastSig) { lastSig = sig; api.update(data(pending)); } pending = null; }
84
91
  });
85
92
  const command = (next) => { const c = String(next.command || ''); if (c === lastCmd) return; lastCmd = c; if (c) api.run(c.split('#')[0]); };
86
- return (next) => { if (api && map) { api.update(map(next)); command(next); } else pending = next; };
93
+ return (next) => { if (!api || !map) { pending = next; return; } const sig = sigOf(next); if (sig !== lastSig) { lastSig = sig; api.update(map(next)); } command(next); };
87
94
  }
@@ -24,6 +24,8 @@ 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
+ # fit "yes" the hours stretch to the height the app gives the agenda (a flex parent that grows), never under 56px
28
+ # ranges "yes" the date under the title also offers «Varios días» (a range, listed day by day); off by default
27
29
  # actions [{id, label, icon, primary}] the toolbar's buttons (default: Bloquear and Nueva cita; [{id: "block", …}] alone
28
30
  # leaves «Nueva cita» to the app's own button) · an id other than block or new fires onAction(id)
29
31
  # phone "nav": on a phone its actions (Bloquear, Nueva cita, yours) and the arrows leave the toolbar for the app's
@@ -35,6 +37,6 @@ part AgendaDemo(resources: list, hours: list, events: list, blocks: list, action
35
37
  # (the dashed cita while the app's new-cita sheet is open) · events take tags [words] and photo; resources role, hideEmpty
36
38
  # AgendaLive(resources: @team, hours: @hours, events: @citas, blocks: @blocks, free: @free, queue: @queue, closed: @closed,
37
39
  # lang: ui.lang, currency: "USD", onOpen: openCita, onCreate: newCita, onBlock: block, onMove: moveCita, onRange: loadDays)
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")
40
+ 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 = "", fit: text = "", ranges: text = "", blockScope: text = "", resize: text = "", moveAcross: text = "", placing: list, onOpen: action, onCreate: action, onBlock: action, onMove: action, onRange: action, onAction: action) {
41
+ 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, fit: $fit, ranges: $ranges, 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")
40
42
  }
@@ -152,7 +152,8 @@ watchReveal();
152
152
  };
153
153
 
154
154
  // ── tabs with a sliding indicator (for panels a Custom builds) ──
155
- const initTabs = (list) => {
155
+ // redraws: the owner paints the tabs again on a click (the agenda), so a click does not animate tabs about to go
156
+ const initTabs = (list, { redraws = false } = {}) => {
156
157
  const tabs = $$('.cx-tab', list);
157
158
  const ind = document.createElement('span'); ind.className = 'cx-tab-ind'; list.prepend(ind);
158
159
  const place = (animate) => {
@@ -168,14 +169,15 @@ watchReveal();
168
169
  place(true);
169
170
  list.dispatchEvent(new CustomEvent('pick', { detail: tabs.indexOf(tab) }));
170
171
  };
171
- tabs.forEach((t) => { t.tabIndex = t.getAttribute('aria-selected') === 'true' ? 0 : -1; t.onclick = () => pick(t); });
172
+ tabs.forEach((t) => { t.tabIndex = t.getAttribute('aria-selected') === 'true' ? 0 : -1; if (!redraws) t.onclick = () => pick(t); });
172
173
  list.addEventListener('keydown', (e) => {
173
174
  const i = tabs.indexOf(document.activeElement); if (i < 0) return;
174
175
  const to = { ArrowRight: i + 1, ArrowLeft: i - 1, Home: 0, End: tabs.length - 1 }[e.key];
175
176
  if (to === undefined) return; e.preventDefault(); pick(tabs[(to + tabs.length) % tabs.length], true);
176
177
  });
178
+ // the first placing comes from the observer too: it runs once the browser has laid the tabs out, so it measures
179
+ // without forcing a layout of the whole page (a list painted again and again paid two each time)
177
180
  new ResizeObserver(() => place(false)).observe(list);
178
- place(false);
179
181
  return pick;
180
182
  };
181
183
 
@@ -429,19 +431,21 @@ let wheelsNow = [];
429
431
  const WD = weekdays || ['lu', 'ma', 'mi', 'ju', 'vi', 'sá', 'do'];
430
432
  const inRange = (d) => mode === 'range' && sel?.[0] && (sel[1] || hover) && d > startOfDay(sel[0]) && d < startOfDay(sel[1] || hover) ;
431
433
  const isEdge = (d) => mode === 'range' ? (sameDay(d, sel?.[0]) || sameDay(d, sel?.[1])) : sameDay(d, sel);
434
+ // the arrows sit in the title's own row, so they can never land on the month's name
435
+ const navBtn = (dir, icon, label, kind) => ui.button({ icon, variant: 'ghost', size: 'icon-sm', attrs: `data-cal-${kind}="${dir}" aria-label="${label}" title="${label}"` });
432
436
  const month = (m) => {
433
437
  const first = new Date(view.getFullYear(), view.getMonth() + m, 1), lead = (first.getDay() + 6) % 7, days = new Date(first.getFullYear(), first.getMonth() + 1, 0).getDate();
434
438
  const cells = Array.from({ length: 42 }, (_, i) => addDays(first, i - lead)).slice(0, Math.ceil((lead + days) / 7) * 7);
435
- return `<div class="cx-cal-month" role="grid" aria-label="${mon[first.getMonth()]} ${first.getFullYear()}"><button type="button" class="cx-cal-title cx-cal-pick" data-cal-pick aria-label="${pickLabel}: ${mon[first.getMonth()]} ${first.getFullYear()}">${ui.text(`${mon[first.getMonth()][0].toUpperCase()}${mon[first.getMonth()].slice(1)} ${first.getFullYear()}`, 'heading')}${ui.icon('chev')}</button><div class="cx-cal-grid" role="row">${WD.map((w) => `<span class="cx-cal-wd" role="columnheader">${ui.text(w, 'caption')}</span>`).join('')}</div><div class="cx-cal-grid">${cells.map((d) => {
439
+ return `<div class="cx-cal-month" role="grid" aria-label="${mon[first.getMonth()]} ${first.getFullYear()}"><div class="cx-cal-head">${m === 0 ? navBtn(-1, 'left', prevLabel, 'nav') : '<span></span>'}<button type="button" class="cx-cal-title cx-cal-pick" data-cal-pick aria-label="${pickLabel}: ${mon[first.getMonth()]} ${first.getFullYear()}">${ui.text(`${mon[first.getMonth()][0].toUpperCase()}${mon[first.getMonth()].slice(1)} ${first.getFullYear()}`, 'heading')}${ui.icon('chev')}</button>${m === months - 1 ? navBtn(1, 'right', nextLabel, 'nav') : '<span></span>'}</div><div class="cx-cal-grid" role="row">${WD.map((w) => `<span class="cx-cal-wd" role="columnheader">${ui.text(w, 'caption')}</span>`).join('')}</div><div class="cx-cal-grid">${cells.map((d) => {
436
440
  const out = d.getMonth() !== first.getMonth(), off = min && d < startOfDay(min);
437
441
  const st = isEdge(d) ? 'edge' : inRange(d) ? 'mid' : '';
438
442
  return `<button class="cn-button cn-button-variant-ghost cn-button-size-icon cx-cal-day" role="gridcell" data-day="${+d}" ${out ? 'data-out' : ''} ${sameDay(d, today) ? 'data-today' : ''} ${st ? `data-range="${st}"` : ''} aria-pressed="${st === 'edge'}" aria-label="${DAYLONG[d.getDay()]} ${d.getDate()} de ${mon[d.getMonth()]}" tabindex="${sameDay(d, focusDay) && !out ? 0 : -1}" ${off ? 'disabled' : ''}>${d.getDate()}</button>`;
439
443
  }).join('')}</div></div>`;
440
444
  };
441
- const picker = () => `<div class="cx-cal cx-cal-picker"><div class="cx-cal-nav">${ui.button({ icon: 'left', variant: 'ghost', size: 'icon-sm', attrs: `data-cal-year="-1" aria-label="${prevYear}" title="${prevYear}"` })}${ui.button({ icon: 'right', variant: 'ghost', size: 'icon-sm', attrs: `data-cal-year="1" aria-label="${nextYear}" title="${nextYear}"` })}</div><div class="cx-cal-title">${ui.text(String(view.getFullYear()), 'heading')}</div><div class="cx-cal-mgrid">${mon.map((m, k) => `<button type="button" class="cn-button cn-button-variant-ghost cn-button-size-sm cx-cal-m" data-cal-month="${k}" ${k === view.getMonth() ? 'aria-current="true"' : ''} ${today.getFullYear() === view.getFullYear() && today.getMonth() === k ? 'data-today' : ''}>${m[0].toUpperCase()}${m.slice(1, 3)}</button>`).join('')}</div></div>`;
445
+ const picker = () => `<div class="cx-cal cx-cal-picker"><div class="cx-cal-head">${navBtn(-1, 'left', prevYear, 'year')}<div class="cx-cal-title">${ui.text(String(view.getFullYear()), 'heading')}</div>${navBtn(1, 'right', nextYear, 'year')}</div><div class="cx-cal-mgrid">${mon.map((m, k) => `<button type="button" class="cn-button cn-button-variant-ghost cn-button-size-sm cx-cal-m" data-cal-month="${k}" ${k === view.getMonth() ? 'aria-current="true"' : ''} ${today.getFullYear() === view.getFullYear() && today.getMonth() === k ? 'data-today' : ''}>${m[0].toUpperCase()}${m.slice(1, 3)}</button>`).join('')}</div></div>`;
442
446
  const paint = (focus) => {
443
447
  if (picking) { root.innerHTML = picker(); root.querySelector('[aria-current="true"]')?.focus(); return; }
444
- root.innerHTML = `<div class="cx-cal" data-months="${months}"><div class="cx-cal-nav">${ui.button({ icon: 'left', variant: 'ghost', size: 'icon-sm', attrs: `data-cal-nav="-1" aria-label="${prevLabel}" title="${prevLabel}"` })}${ui.button({ icon: 'right', variant: 'ghost', size: 'icon-sm', attrs: `data-cal-nav="1" aria-label="${nextLabel}" title="${nextLabel}"` })}</div><div class="cx-cal-months">${Array.from({ length: months }, (_, m) => month(m)).join('')}</div></div>`;
448
+ root.innerHTML = `<div class="cx-cal" data-months="${months}"><div class="cx-cal-months">${Array.from({ length: months }, (_, m) => month(m)).join('')}</div></div>`;
445
449
  if (focus) root.querySelector(`[data-day="${+focusDay}"]:not([data-out])`)?.focus();
446
450
  };
447
451
  const pick = (d) => {
@@ -542,7 +546,7 @@ let wheelsNow = [];
542
546
  lang = 'es', words = {}, currency = '', locale = '', cents = false, overlap = 'auto', placing = null } = opts; // all of these can change later through update()
543
547
  // the first and last hour can change with the data (a shop's hours arrive after the first paint): update() takes them
544
548
  let { from = 9, to = 19 } = opts;
545
- const { slot = 30, hourPx = 56, onCreate = () => {}, onChange = () => {}, onAction = () => {}, onOpen = null, onBlock = null, onMove = null, onRange = () => {},
549
+ const { slot = 30, hourPx: baseHour = 56, fit = false, ranges = false, onCreate = () => {}, onChange = () => {}, onAction = () => {}, onOpen = null, onBlock = null, onMove = null, onRange = () => {},
546
550
  actions = null, createSheet = false, services = [], view: startView = 'day', sheet: sheetMode = 'built-in', drag: dragOn = true,
547
551
  resize: resizeMode = 'on', moveAcross = 'on',
548
552
  start: startDay = null, phone: phoneMode = '', hour12 = false, timeZone = '', phoneView = '', blockScope = 'each', phoneStrip = '' } = opts;
@@ -610,9 +614,12 @@ let wheelsNow = [];
610
614
  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);
611
615
  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>`; };
612
616
  // the hours a person does not work: hatched. With server free hours, everything that is neither free nor booked.
617
+ // a day the business does not open: the column hatched, «Cerrado» in the middle (grid) or as the list's only line
618
+ const shutPill = () => `<span class="cx-ag-shutpill">${ui.icon('caloff')}${ui.text(w.closed, 'label')}</span>`;
619
+ const shutHtml = () => `<div class="cx-ag-off cx-ag-shut" style="top:0;height:100%">${shutPill()}</div>`;
613
620
  const offHtml = (d, rid) => {
614
621
  const av = freeList ? null : availability(rid, d);
615
- if (av && !av.length) return `<div class="cx-ag-off" data-all style="top:0;height:100%">${ui.text(w.off, 'caption')}</div>`;
622
+ if (av && !av.length) return isClosed(d) ? shutHtml() : `<div class="cx-ag-off" data-all style="top:0;height:100%">${ui.text(w.off, 'caption')}</div>`;
616
623
  if (!av) return '';
617
624
  const out = []; let cur = from * 60; av.forEach(([a, b]) => { if (a > cur) out.push([cur, a]); cur = Math.max(cur, b); }); if (cur < to * 60) out.push([cur, to * 60]);
618
625
  return out.map(([a, b]) => `<div class="cx-ag-off" style="top:${y(a)}px;height:${y(b) - y(a)}px"></div>`).join('');
@@ -659,7 +666,9 @@ let wheelsNow = [];
659
666
  const skHead = (c) => { const r = resources.find((x) => x.id === c.rid); return v0() === 'week' ? `<span class="cx-ag-skhead" aria-hidden="true"><s class="skel" style="width:56px"></s></span>` : `<span class="cx-ag-skhead" aria-hidden="true"><s class="skel" style="width:24px;height:24px;border-radius:50%"></s><span class="cx-ag-colname"><b><s class="skel cx-ag-skline" style="width:64px"></s></b>${r && roleOf(r) ? `<small><s class="skel cx-ag-skline" style="width:48px"></s></small>` : ''}</span></span>`; };
660
667
  const v0 = eff;
661
668
  const grid = (cols) => { const hours = Array.from({ length: to - from }, (_, i) => from + i); const nowM = nowMin(); return `<div class="cx-ag-scroll cx-scroll"><div class="cx-ag" style="--cols:${cols.length};--h:${hourPx}px;grid-template-rows:auto auto"><div class="cx-ag-head"><span></span>${cols.map((c) => `<div class="cx-ag-colhead" ${c.today ? 'data-today' : ''} ${c.closed ? 'data-closed' : ''}>${loading ? skHead(c) : c.head}</div>`).join('')}</div><div class="cx-ag-body" style="height:${(to - from) * hourPx}px"><div class="cx-ag-times">${cols.some((k) => sameDay(k.day, today)) && nowM > from * 60 && nowM < to * 60 ? `<b class="cx-ag-nowtime" style="top:${y(nowM)}px">${clk(toHM(nowM))}</b>` : ''}${hours.map((h) => `<span style="top:${(h - from) * hourPx}px">${ui.text(hour12 ? `${((h + 11) % 12) + 1} ${h < 12 ? 'AM' : 'PM'}` : `${String(h).padStart(2, '0')}:00`, 'caption')}</span>`).join('')}</div>${cols.map((c, ci) => { const rid = c.rid === 'all' ? null : c.rid; const evs = evOn(c.day, rid); const cap = rid ? capOf(rid) : 1; const ln = lanes(evs); if (cap > 1) ln.forEach((v) => { v[1] = Math.max(v[1], Math.min(cap, 4)); }); const rids = rid ? [rid] : shown().map((r) => r.id); if (loading) return `<div class="cx-ag-col" data-day="${+c.day}" data-rid="${c.rid}" aria-hidden="true">${rid && !freeList ? offHtml(c.day, rid) : ''}${skelCol(ci)}</div>`;
662
- return `<div class="cx-ag-col" data-day="${+c.day}" data-rid="${c.rid}" role="group" aria-label="${c.label}">${rid ? offHtml(c.day, rid) : ''}${!rids.some(bookable) ? '' : freeList ? rids.filter(bookable).map((r) => freeHtml(c.day, r, c.label)).join('') : Array.from({ length: ((to - from) * 60) / slot }, (_, i) => gone(c.day, from * 60 + i * slot) ? '' : `<button class="cx-ag-slot" data-slot-time="${from * 60 + i * slot}" style="top:${y(from * 60 + i * slot)}px;height:${(slot / 60) * hourPx}px" aria-label="${w.newItem}, ${c.label}, ${toHM(from * 60 + i * slot)}" tabindex="-1"></button>`).join('')}${blOn(c.day, rid).map(blockHtml).join('')}${evs.map((e) => evHtml(e, ln, c.rid === 'all' && resources.length > 1, rid, cap === 0)).join('')}${ghostHtml(c.day, rid)}${goneHtml(c.day)}${sameDay(c.day, today) && nowM > from * 60 && nowM < to * 60 ? `<div class="cx-ag-now" style="top:${y(nowM)}px" aria-hidden="true">${cols.findIndex((k) => sameDay(k.day, today)) === ci ? '<i></i>' : ''}</div>` : ''}</div>`; }).join('')}</div></div></div>`; };
669
+ const offRid = rid || (shown().length === 1 && !freeList ? shown()[0].id : null);
670
+ if (c.closed && !evs.length) return `<div class="cx-ag-col" data-day="${+c.day}" data-rid="${c.rid}" data-closed role="group" aria-label="${c.label}, ${w.closed}">${shutHtml()}</div>`;
671
+ return `<div class="cx-ag-col" data-day="${+c.day}" data-rid="${c.rid}" role="group" aria-label="${c.label}">${offRid ? offHtml(c.day, offRid) : ''}${!rids.some(bookable) ? '' : freeList ? rids.filter(bookable).map((r) => freeHtml(c.day, r, c.label)).join('') : Array.from({ length: ((to - from) * 60) / slot }, (_, i) => gone(c.day, from * 60 + i * slot) ? '' : `<button class="cx-ag-slot" data-slot-time="${from * 60 + i * slot}" style="top:${y(from * 60 + i * slot)}px;height:${(slot / 60) * hourPx}px" aria-label="${w.newItem}, ${c.label}, ${toHM(from * 60 + i * slot)}" tabindex="-1"></button>`).join('')}${blOn(c.day, rid).map(blockHtml).join('')}${evs.map((e) => evHtml(e, ln, c.rid === 'all' && resources.length > 1, rid, cap === 0)).join('')}${ghostHtml(c.day, rid)}${goneHtml(c.day)}${sameDay(c.day, today) && nowM > from * 60 && nowM < to * 60 ? `<div class="cx-ag-now" style="top:${y(nowM)}px" aria-hidden="true">${cols.findIndex((k) => sameDay(k.day, today)) === ci ? '<i></i>' : ''}</div>` : ''}</div>`; }).join('')}</div></div></div>`; };
663
672
  // list = the phone's agenda: a solid row is sold, a dashed one is still free
664
673
  // one cita as a row of the list (the day's list and the range's)
665
674
  const evRow = (e) => `<button class="cx-ag-row" data-ev="${e.id}"${clash.has(e.id) ? ' data-conflict' : ''} data-tone="${toneOf(e)}" ${e.status ? `data-status="${e.status}"` : ''}${paint1(e)}"><span class="cx-ag-row-t">${ui.text(clk(e.start), 'strong')}${ui.text(`${e.dur} min`, 'caption')}</span>${e.photo ? `<span class="cn-avatar cx-ag-face cx-ag-rowface" style="background-image:url('${e.photo}')" aria-hidden="true"></span>` : ''}<span class="cx-vstack" style="gap:0;min-width:0">${ui.text(e.title, 'heading')}${ui.text(`${e.subtitle || ''}${resources.length > 1 ? ' · ' + resName(e.resource) : ''}${tagsOf(e) ? ' · ' + tagsOf(e) : ''}`, 'muted')}</span>${e.status && w.status[e.status] ? ui.badge(w.status[e.status], { tone: { info: 'info', live: 'wait', done: 'ok', bad: 'bad', ok: 'ok' }[toneOf(e)] || 'ok' }) : e.steps ? ui.badge(e.steps[e.at], { tone: e.tone === 'wait' ? 'wait' : e.tone === 'info' ? 'info' : 'ok' }) : ''}${e.price ? ui.text(fmtMoney(e.price), 'strong') : ''}</button>`;
@@ -681,7 +690,7 @@ let wheelsNow = [];
681
690
  }
682
691
  }
683
692
  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('');
684
- 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>`;
693
+ return `<div class="cx-ag-list" role="list">${html || (isClosed(day) ? `<div class="cx-ag-shutlist">${shutPill()}</div>` : '') || `<div class="cn-empty" style="padding:32px 12px"><div class="cn-empty-header"><div class="cn-empty-title">${!rids.some((rid) => availability(rid, day).length) ? w.noHours : +day < +today && !sameDay(day, today) ? w.dayOver : sameDay(day, today) ? w.todayOver : w.noHours}</div></div></div>`}</div>`;
685
694
  };
686
695
  // LOADING keeps the very layout it is about to show: the same toolbar, strip, column heads, hours and height,
687
696
  // with grey citas where the real ones will land. Nothing jumps when the data arrives, nothing shrinks while it loads.
@@ -710,6 +719,7 @@ let wheelsNow = [];
710
719
  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>` : ''}`; };
711
720
  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 }); };
712
721
  const paint = () => {
722
+ if (fit) { const k = `${eff()}|${from}|${to}|${narrow}`; if (k !== fitKey && fitSeen.has(k)) { hourPx = fitSeen.get(k); fitKey = k; } }
713
723
  if (typeof drag !== 'undefined' && drag) { const d = drag; drag = null; clearTimeout(d.timer); cancelAnimationFrame(scrollRaf); d.ghost?.remove(); }
714
724
  const v = eff(); // the phone keeps the calendar: day grid or list, the week needs a wide screen
715
725
  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 }));
@@ -720,8 +730,9 @@ let wheelsNow = [];
720
730
  if (!loading) { hadNow = top.includes('cx-ag-nowcard'); hadQueue = top.includes('cx-ag-queue'); }
721
731
  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>`;
722
732
  root.toggleAttribute('data-loading', !!loading);
723
- $$('.cx-tabs', root).forEach(initTabs);
733
+ $$('.cx-tabs', root).forEach((tl) => initTabs(tl, { redraws: true }));
724
734
  const now = $('.cx-ag-now', root); if (now) $('.cx-ag-scroll', root).scrollTop = Math.max(0, parseFloat(now.style.top) - 120);
735
+ if (refit()) { paint(); return; } // the hours' height changed: once more at the right size
725
736
  tellRange();
726
737
  };
727
738
  // drag to move · drag the bottom edge to resize (15 min). A ghost shows where it lands and turns red where the
@@ -840,7 +851,7 @@ let wheelsNow = [];
840
851
  $$('.cx-ag-now', root).forEach((x) => { x.style.top = y(m) + 'px'; }); const t = $('.cx-ag-nowtime', root); if (t) { t.style.top = y(m) + 'px'; t.textContent = clk(toHM(m)); }
841
852
  $$('.cx-ag-col', root).forEach((c) => { if (!sameDay(new Date(+c.dataset.day), today)) return; const g = $('.cx-ag-gone', c); if (g) g.style.height = y(m) + 'px'; });
842
853
  }, 30000);
843
- new ResizeObserver(() => requestAnimationFrame(() => { const n = root.clientWidth < 700; if (n !== narrow) { narrow = n; paint(); } })).observe(root);
854
+ new ResizeObserver(() => requestAnimationFrame(() => { const n = root.clientWidth < 700; const h = fit ? root.querySelector('.cx-ag-scroll')?.clientHeight || 0 : 0; if (n !== narrow) { narrow = n; paint(); } else if (fit && h && h !== fitH) { fitKey = ''; fitSeen.clear(); paint(); } })).observe(root);
844
855
 
845
856
  // ── the event sheet (showcase): detail · lifecycle · payment · move (second step) · cancel (second step).
846
857
  // In app mode a tapped cita goes to onOpen and the app opens its own sheet. ──
@@ -928,11 +939,11 @@ let wheelsNow = [];
928
939
  const seeClash = () => { const els = $$('.cx-ag-ev[data-conflict], .cx-ag-row[data-conflict]', root); if (!els.length) return; const el = els[clashAt++ % els.length]; const sc = $('.cx-ag-scroll', root);
929
940
  if (sc && sc.contains(el)) { const a = sc.getBoundingClientRect(), b = el.getBoundingClientRect(); sc.scrollTo({ top: sc.scrollTop + b.top - a.top - 60, left: sc.scrollLeft + b.left - a.left - 60, behavior: 'smooth' }); } else el.scrollIntoView({ block: 'center', behavior: 'smooth' });
930
941
  el.animate([{ outline: '2px solid var(--destructive)', outlineOffset: '2px' }, { outline: '2px solid transparent', outlineOffset: '2px' }], { duration: 1400, delay: 250 }); };
931
- let pop = null, dateMode = 'day';
942
+ let pop = null, dateMode = 'day'; // «range» only with the `ranges` option
932
943
  const calWords = () => ({ mon: w.mon, weekdays: w.dayS.slice(1).concat(w.dayS[0]).map((x) => x.slice(0, 2).toLowerCase()), prevLabel: w.prevMonth, nextLabel: w.nextMonth, prevYear: w.prevYear, nextYear: w.nextYear, pickLabel: w.pickMonth });
933
944
  const dateUI = (host, done) => {
934
945
  const draw = () => {
935
- host.innerHTML = `<div class="cx-tabs cx-ag-datemode" role="tablist" aria-label="${w.goTo}">${[['day', w.oneDay], ['range', w.someDays]].map(([k, l]) => `<button type="button" class="cn-button cn-button-variant-ghost cn-button-size-sm cx-tab" role="tab" aria-selected="${dateMode === k}" data-date-mode="${k}">${l}</button>`).join('')}</div><div class="cx-ag-datecal"></div>${dateMode === 'range' ? `<p class="cx-ag-datehint">${w.rangeHint}</p>` : ''}<div class="cx-ag-datefoot">${ui.button({ label: w.today, size: 'sm', variant: 'outline', attrs: 'data-date-today' })}</div>`;
946
+ host.innerHTML = `${ranges ? `<div class="cx-tabs cx-ag-datemode" role="tablist" aria-label="${w.goTo}">${[['day', w.oneDay], ['range', w.someDays]].map(([k, l]) => `<button type="button" class="cn-button cn-button-variant-ghost cn-button-size-sm cx-tab" role="tab" aria-selected="${dateMode === k}" data-date-mode="${k}">${l}</button>`).join('')}</div>` : ''}<div class="cx-ag-datecal"></div>${dateMode === 'range' ? `<p class="cx-ag-datehint">${w.rangeHint}</p>` : ''}<div class="cx-ag-datefoot">${ui.button({ label: w.today, size: 'sm', variant: 'outline', attrs: 'data-date-today' })}</div>`;
936
947
  $$('.cx-tabs', host).forEach(initTabs);
937
948
  const wide = dateMode === 'range' && innerWidth > 720;
938
949
  kit.calendar($('.cx-ag-datecal', host), { ...calWords(), mode: dateMode === 'range' ? 'range' : 'single', months: wide ? 2 : 1, value: dateMode === 'range' ? (rangeSel ? [new Date(rangeSel[0]), new Date(rangeSel[1])] : null) : new Date(day),
@@ -963,6 +974,24 @@ let wheelsNow = [];
963
974
  body.innerHTML = '<div class="cx-ag-datesheet"></div>';
964
975
  dateUI($('.cx-ag-datesheet', body), (r) => { closeModal(); applyDate(r); });
965
976
  };
977
+ // fit: the hours share the height the app gives the agenda (never under baseHour), as a day view on a monitor should.
978
+ // Measured after a paint and only when what decides it changed (view, hours, width, the agenda's height): reading
979
+ // the layout on every paint forced the whole page to lay out each time
980
+ let hourPx = baseHour, fitH = 0, fitKey = '';
981
+ const fitSeen = new Map(); // each view's measured hour, so going back to a view paints once at its size
982
+ const refit = () => {
983
+ if (!fit) return false;
984
+ const key = `${eff()}|${from}|${to}|${narrow}`; if (key === fitKey) return false;
985
+ const sc = root.querySelector('.cx-ag-scroll'), ag = sc ? sc.querySelector('.cx-ag') : null, hours = sc ? sc.querySelector('.cx-ag-body') : null;
986
+ if (!sc || !sc.clientHeight || !ag || !hours) return false;
987
+ fitKey = key; fitH = sc.clientHeight;
988
+ // everything in the scroll that is not the hours (the day heads, gaps, padding) keeps its height
989
+ const around = ag.offsetHeight - hours.offsetHeight;
990
+ const next = Math.max(baseHour, Math.floor((sc.clientHeight - around) / Math.max(1, to - from)));
991
+ fitSeen.set(key, next);
992
+ if (next === hourPx) return false;
993
+ hourPx = next; return true;
994
+ };
966
995
  let blockDay = null; // the one open day of the week, when the form had nothing to choose from
967
996
  const openBlock = () => { blockOpen = true; sheetOf('agBlock'); };
968
997
  CONTENT[K('agBlock')] = () => {