@muten/shadcn 0.1.7 → 0.1.9

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/AGENTS.md CHANGED
@@ -113,7 +113,7 @@ props are in the header comment of its `registry/<name>.muten`; the data shapes
113
113
  | Notices | `Alert`, `Band`, `Island`, `Modal`/`ModalFooter`, `SettingsForm`/`DirtyBar` |
114
114
  | Menus and content | `Combobox`, `RowMenu`, `Accordion`, `StoreQr` |
115
115
  | Values | `ScaleSlider`, `RangeSlider`, `Slider`, `ColorPicker` |
116
- | Collections | `TagInput`, `PaginationNav`, `VirtualList`, `Carousel`/`CarouselSlide` |
116
+ | Collections | `TagInput`, `PaginationNav`, `VirtualList`, `VirtualWindow`, `Carousel`/`CarouselSlide` |
117
117
  | Data | `TableView` (filters as data, chips, bulk), `OrderBoard`, `OrderCards` |
118
118
  | Dates and files | `Booking`, `Calendar`, `DateField`, `TimeField`, `ImageUpload` |
119
119
  | Structure | `PhoneShell`, `ShareButton`, `AppShell` (Sidebar, page header, detail Sheet, ⌘K) |
package/README.md CHANGED
@@ -161,7 +161,7 @@ props are in the header comment of its `registry/<name>.muten`; the data shapes
161
161
  | Notices | `Alert`, `Band`, `Island`, `Modal`/`ModalFooter`, `SettingsForm`/`DirtyBar` |
162
162
  | Menus and content | `Combobox`, `RowMenu`, `Accordion`, `StoreQr` |
163
163
  | Values | `ScaleSlider`, `RangeSlider`, `Slider`, `ColorPicker` |
164
- | Collections | `TagInput`, `PaginationNav`, `VirtualList`, `Carousel`/`CarouselSlide` |
164
+ | Collections | `TagInput`, `PaginationNav`, `VirtualList`, `VirtualWindow`, `Carousel`/`CarouselSlide` |
165
165
  | Data | `TableView` (filters as data, chips, bulk), `OrderBoard`, `OrderCards` |
166
166
  | Dates and files | `Booking`, `Calendar`, `DateField`, `TimeField`, `ImageUpload` |
167
167
  | Structure | `PhoneShell`, `ShareButton`, `AppShell` (Sidebar, page header, detail Sheet, ⌘K) |
package/kit.css CHANGED
@@ -4892,7 +4892,7 @@ a.cx-bc-item:hover { color: var(--foreground); }
4892
4892
  .cx-ag-skhead { display: inline-flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; }
4893
4893
  /* phone "nav": on a narrow agenda the app's phone bar carries the actions and the arrows */
4894
4894
  @container (max-width: 700px) { .cx-agenda[data-phone="nav"] [data-ag-act], .cx-agenda[data-phone="nav"] [data-ag-move] { display: none; } }
4895
- .cx-agenda[data-loading] .cx-ag-day, .cx-agenda[data-loading] .cx-ag-bar .cn-button, .cx-agenda[data-loading] .cx-tab { cursor: progress; }
4895
+ .cx-agenda[data-loading]:not([data-shown]) .cx-ag-day, .cx-agenda[data-loading]:not([data-shown]) .cx-ag-bar .cn-button, .cx-agenda[data-loading]:not([data-shown]) .cx-tab { cursor: progress; } /* only the first load waits */
4896
4896
  .cx-agenda[data-loading] [disabled] { opacity: .5; pointer-events: none; }
4897
4897
  .cx-ag-frame .skel { background: color-mix(in oklab, var(--muted) 85%, var(--foreground) 6%); }
4898
4898
  @media (prefers-reduced-motion: no-preference) { .cx-ag-frame[aria-busy="true"] .skel { animation: cx-ag-skel 1.2s ease-in-out infinite alternate; } }
@@ -5214,3 +5214,37 @@ a.cx-bc-item:hover { color: var(--foreground); }
5214
5214
  /* a narrow column (the week on a laptop) keeps the client's name: the price shows only where there is room for both */
5215
5215
  .cx-ag-col { container-type: inline-size; }
5216
5216
  @container (max-width: 190px) { .cx-ag-evprice { display: none; } .cx-ag-ev:has(> .cx-ag-evprice) { padding-right: 8px; } }
5217
+
5218
+ /* VirtualWindow: the marker that stands in for the rows above (its height is set by virtual-window.js) */
5219
+ .cx-vw { display: none; flex: none; pointer-events: none; margin: 0; padding: 0; }
5220
+
5221
+ /* ── ASSISTANT (registry/assistant-orb.muten, assistant-play.muten) ──────────────────────────────────────────────────
5222
+ The orb: a sphere of light with eyes. Its box is --orb; the sphere is the middle 69% of it, the halo the rest. The JS
5223
+ moves it with --orb-x/--orb-y (lean and float) and --orb-sx/--orb-sy (breath and squash). */
5224
+ .cx-orb { position: relative; display: block; flex: none; width: var(--orb, 240px); height: var(--orb, 240px); transform: translate(var(--orb-x, 0), var(--orb-y, 0)) scale(var(--orb-sx, 1), var(--orb-sy, 1)); will-change: transform; }
5225
+ .cx-orb-base { position: absolute; inset: 15.6%; border-radius: 50%; background: radial-gradient(circle at 34% 28%, #C9B8FF 0%, #8E6BFF 30%, #6B46F2 62%, #4A27D0 100%); box-shadow: 0 0 calc(var(--orb, 240px) * .19) calc(var(--orb, 240px) * .03) rgb(107 70 242 / .45), inset 0 0 0 2px rgb(255 255 255 / .25); }
5226
+ .cx-orb[data-gl="on"] .cx-orb-base { opacity: 0; transition: opacity .4s ease; }
5227
+ .cx-orb-canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
5228
+ .cx-orb-eyes { position: absolute; left: 31.25%; top: 35%; width: 37.5%; height: 25%; overflow: visible; }
5229
+ .cx-orb-eyes path { fill: #fff; }
5230
+ .cx-orb-cheeks ellipse { fill: #FF9AD5; }
5231
+ /* The play: it takes the rest of its panel (a flex column with padding); the card in the flow at the top, the orb past
5232
+ the panel's bottom-right corner (the panel clips it), the bubble beside the orb. */
5233
+ .cx-ap { position: relative; flex: 1 1 auto; min-height: 340px; display: flex; flex-direction: column; }
5234
+ .cx-ap .cx-ap-card { width: min(430px, 100%); color: var(--foreground); box-shadow: 0 30px 60px -30px rgb(0 0 0 / .7); pointer-events: auto; }
5235
+ .cx-ap-count { position: relative; display: block; width: 150px; height: 18px; text-align: right; font-size: 13px; color: var(--muted-foreground); }
5236
+ .cx-ap-count > span { position: absolute; right: 0; top: 0; white-space: nowrap; }
5237
+ .cx-ap-count-a { opacity: var(--k, 1); }
5238
+ .cx-ap-count-b { opacity: var(--k, 0); color: var(--success); transform: translateY(calc((1 - var(--k, 0)) * 6px)); }
5239
+ .cx-ap-card .cn-item-group { flex-direction: column; }
5240
+ .cx-ap-new { display: block; overflow: hidden; opacity: var(--k, 0); max-height: calc(var(--k, 0) * 64px); transform: translateY(calc((1 - var(--k, 0)) * -10px)); }
5241
+ .cx-ap-flash { border-radius: 10px; background: rgb(18 185 129 / calc(var(--f, 0) * .12)); }
5242
+ .cx-ap-typing, .cx-ap-said { position: absolute; right: 194px; bottom: 112px; transform-origin: 100% 100%; border-radius: 22px 22px 6px 22px; background: #fff; }
5243
+ .cx-ap-typing { height: 44px; padding: 0 16px; display: flex; flex-direction: row; align-items: center; gap: 5px; opacity: var(--k, 0); transform: translateY(calc((1 - var(--k, 0)) * 8px)) scale(calc(.92 + var(--k, 0) * .08)); }
5244
+ .cx-ap-dot { width: 7px; height: 7px; border-radius: 4px; background: #6B46F2; opacity: calc(.45 + var(--b, 0) * .55); transform: translateY(calc(var(--b, 0) * -3px)); }
5245
+ .cx-ap-said { max-width: 280px; padding: 14px 18px; color: #0B0B0D; font-size: 15px; line-height: 1.4; font-weight: 600; box-shadow: 0 20px 40px -20px rgb(0 0 0 / .6); opacity: var(--k, 0); transform: translateY(calc((1 - var(--k, 0)) * 10px)) scale(calc(.94 + var(--k, 0) * .06)); }
5246
+ .cx-ap .cx-ap-orb { position: absolute; right: -80px; bottom: -96px; }
5247
+ .cx-ap-run { display: none; }
5248
+ @media (max-width: 760px) { .cx-ap .cx-ap-orb { --orb: 220px !important; right: -60px; bottom: -70px; } .cx-ap-typing, .cx-ap-said { right: 110px; bottom: 90px; } }
5249
+ /* A Field whose label lives elsewhere (a FieldHead above the control, with its action beside it): no empty label row. */
5250
+ .cn-field > .cn-label:empty { display: none; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@muten/shadcn",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
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",
@@ -40,6 +40,11 @@
40
40
  // lang, currency, locale and cents change live too.
41
41
  export function mount(el, inputs, on) {
42
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);
43
48
  import('@muten/shadcn/registry/kit/core.js').then(({ kit, today, addDays }) => {
44
49
  const iso = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
45
50
  const list = (v) => (Array.isArray(v) ? v : []);
@@ -82,8 +87,8 @@ export function mount(el, inputs, on) {
82
87
  onRange: (r) => send('range', { from: iso(r.from), to: iso(r.to), view: r.view }),
83
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 || '' }))),
84
89
  });
85
- 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; }
86
91
  });
87
92
  const command = (next) => { const c = String(next.command || ''); if (c === lastCmd) return; lastCmd = c; if (c) api.run(c.split('#')[0]); };
88
- 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); };
89
94
  }
@@ -0,0 +1,150 @@
1
+ // AssistantOrb (muten Custom) - the assistant as a sphere of light with eyes (see assistant-orb.muten).
2
+ // The sphere is a small WebGL shader (flowing light, rim, halo); the eyes are an SVG whose capsules lift into a smile.
3
+ // Everything moves on springs. Its mood comes from outside through the `orb:mood` event; with no mood it idles.
4
+ const VERT = 'attribute vec2 p;void main(){gl_Position=vec4(p,0.,1.);}';
5
+ const FRAG = `precision highp float;
6
+ uniform vec2 res;uniform float t;uniform float e;uniform vec2 look;
7
+ float h(vec3 p){p=fract(p*.3183099+.1);p*=17.;return fract(p.x*p.y*p.z*(p.x+p.y+p.z));}
8
+ float n3(vec3 x){vec3 i=floor(x),f=fract(x);f=f*f*(3.-2.*f);
9
+ return mix(mix(mix(h(i),h(i+vec3(1,0,0)),f.x),mix(h(i+vec3(0,1,0)),h(i+vec3(1,1,0)),f.x),f.y),
10
+ mix(mix(h(i+vec3(0,0,1)),h(i+vec3(1,0,1)),f.x),mix(h(i+vec3(0,1,1)),h(i+vec3(1,1,1)),f.x),f.y),f.z);}
11
+ float fbm(vec3 p){float a=.55,s=0.;for(int k=0;k<3;k++){s+=a*n3(p);p=p*1.9+vec3(1.7,9.2,3.1);a*=.45;}return s/.95;}
12
+ void main(){
13
+ vec2 uv=(gl_FragCoord.xy-.5*res)/(.5*res.y);
14
+ float R=.66; float r=length(uv)/R;
15
+ vec3 deep=vec3(.30,.16,.82), brand=vec3(.47,.31,1.), lav=vec3(.80,.72,1.), pink=vec3(1.,.62,.88), cyan=vec3(.50,.88,1.), mint=vec3(.30,.95,.70);
16
+ vec4 outc=vec4(0.);
17
+ float halo=exp(-max(r-1.,0.)*5.5)*(.38+.25*e);
18
+ vec3 hc=mix(brand,mint,e*.6);
19
+ outc=vec4(hc*halo,halo);
20
+ if(r<1.){
21
+ vec3 n=vec3(uv/R,sqrt(max(1.-r*r,0.)));
22
+ n.xy+=look*.12; n=normalize(n);
23
+ float sp=.10+.22*e;
24
+ vec3 q=n*1.05+vec3(0.,0.,t*sp);
25
+ float w=fbm(q+vec3(t*.05,-t*.04,0.));
26
+ float f=fbm(q*1.1+w*1.6);
27
+ vec3 col=mix(deep,brand,smoothstep(.2,.6,f));
28
+ col=mix(col,lav,smoothstep(.55,.85,f)*.75);
29
+ col=mix(col,pink,smoothstep(.45,.85,fbm(q*.8+5.))*.45*(.5+.5*n.x));
30
+ col=mix(col,cyan,smoothstep(.6,.9,fbm(q*.9+11.))*.35*(.5-.5*n.x));
31
+ col=mix(col,lav,.18*(1.-n.y)*.5);
32
+ vec3 L=normalize(vec3(-.8,.86,.08));
33
+ float dif=.72+.28*max(dot(n,L),0.);
34
+ float rv=max(dot(reflect(-L,n),vec3(0.,0.,1.)),0.);
35
+ float spec=pow(rv,70.)*.85+pow(rv,6.)*.10;
36
+ float fres=pow(1.-n.z,2.4);
37
+ vec3 rim=mix(pink,cyan,.5+.5*sin(t*.6+n.y*3.+n.x*2.));
38
+ col=col*dif+spec+fres*rim*.65;
39
+ col+=vec3(.06,.04,.12)*(1.-fres);
40
+ col+=e*mint*fres*.7+e*.08;
41
+ float a=1.-smoothstep(.985,1.,r);
42
+ outc=mix(outc,vec4(col,1.)*a+outc*(1.-a),a);
43
+ }
44
+ gl_FragColor=outc;
45
+ }`;
46
+ const NS = 'http://www.w3.org/2000/svg';
47
+ const clamp = (v) => Math.max(0, Math.min(1, v));
48
+ const spring = (k, d) => ({ x: 0, v: 0, to: 0, k, d });
49
+ const step = (s, dt) => { const a = -s.k * (s.x - s.to) - s.d * s.v; s.v += a * dt; s.x += s.v * dt; };
50
+ // an eye: a capsule whose underside lifts into a smile as `hp` goes 0 → 1; `open` 0 → 1 is the blink
51
+ const eyePath = (cx, hp, open) => {
52
+ const w = 26, hgt = 46 * Math.max(open, 0.08), r = w / 2, top = -hgt / 2, bot = hgt / 2;
53
+ const y1 = bot - r + (top + r - (bot - r)) * hp * 0.62;
54
+ const k = r * 1.333 + (-(hgt * 0.34) - r * 1.333) * hp;
55
+ const x0 = cx - r, x1 = cx + r;
56
+ return `M${x0} ${top + r} A${r} ${r} 0 0 1 ${x1} ${top + r} L${x1} ${y1} C${x1} ${y1 + k} ${x0} ${y1 + k} ${x0} ${y1} Z`;
57
+ };
58
+
59
+ export function mount(el, inputs) {
60
+ const size = Math.max(80, Number(inputs.size) || 240);
61
+ const still = matchMedia('(prefers-reduced-motion: reduce)').matches;
62
+ el.style.setProperty('--orb', size + 'px');
63
+ el.setAttribute('aria-hidden', 'true');
64
+ // the sphere without WebGL (and under the canvas while it starts)
65
+ const base = document.createElement('span'); base.className = 'cx-orb-base';
66
+ const canvas = document.createElement('canvas'); canvas.className = 'cx-orb-canvas';
67
+ const dpr = Math.min(2, window.devicePixelRatio || 1); canvas.width = canvas.height = Math.round(size * dpr);
68
+ const svg = document.createElementNS(NS, 'svg'); svg.setAttribute('class', 'cx-orb-eyes'); svg.setAttribute('viewBox', '-60 -40 120 80');
69
+ const eyes = document.createElementNS(NS, 'g'), cheeks = document.createElementNS(NS, 'g');
70
+ const eyeL = document.createElementNS(NS, 'path'), eyeR = document.createElementNS(NS, 'path');
71
+ eyes.append(eyeL, eyeR);
72
+ for (const cx of [-46, 46]) { const c = document.createElementNS(NS, 'ellipse'); c.setAttribute('cx', cx); c.setAttribute('cy', 28); c.setAttribute('rx', 13); c.setAttribute('ry', 6.5); cheeks.append(c); }
73
+ cheeks.setAttribute('class', 'cx-orb-cheeks'); cheeks.setAttribute('opacity', '0');
74
+ svg.append(eyes, cheeks);
75
+ el.append(base, canvas, svg);
76
+
77
+ // the shader
78
+ const gl = canvas.getContext('webgl', { premultipliedAlpha: true, antialias: true });
79
+ let prog = null, uT, uE, uLook, uRes;
80
+ if (gl) {
81
+ const sh = (type, src) => { const o = gl.createShader(type); gl.shaderSource(o, src); gl.compileShader(o); return o; };
82
+ prog = gl.createProgram(); gl.attachShader(prog, sh(gl.VERTEX_SHADER, VERT)); gl.attachShader(prog, sh(gl.FRAGMENT_SHADER, FRAG)); gl.linkProgram(prog);
83
+ if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) prog = null;
84
+ if (prog) {
85
+ gl.useProgram(prog);
86
+ gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
87
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
88
+ const loc = gl.getAttribLocation(prog, 'p'); gl.enableVertexAttribArray(loc); gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
89
+ uT = gl.getUniformLocation(prog, 't'); uE = gl.getUniformLocation(prog, 'e'); uLook = gl.getUniformLocation(prog, 'look'); uRes = gl.getUniformLocation(prog, 'res');
90
+ gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
91
+ el.dataset.gl = 'on';
92
+ }
93
+ }
94
+
95
+ // springs: critically damped, a little bounce where it is earned
96
+ const gx = spring(120, 22), gy = spring(120, 22), happy = spring(90, 14), blink = spring(900, 60), squash = spring(260, 9), energy = spring(30, 11);
97
+ blink.x = blink.to = 1;
98
+ let look = null, pointer = null, gaze = { x: 0, y: 0 };
99
+ el.addEventListener('orb:mood', (ev) => {
100
+ const d = ev.detail || {};
101
+ if (typeof d.happy === 'number') happy.to = clamp(d.happy);
102
+ if (typeof d.energy === 'number') energy.to = clamp(d.energy);
103
+ if ('look' in d) look = d.look;
104
+ if (d.bump) squash.v += 7;
105
+ if (still) { happy.x = happy.to; energy.x = energy.to; draw(0, 0); }
106
+ });
107
+ const area = el.closest('[data-area="orb"]') || el.parentElement;
108
+ const onMove = (ev) => { const b = el.getBoundingClientRect(); pointer = { x: (ev.clientX - (b.left + b.width / 2)) / 260, y: (ev.clientY - (b.top + b.height / 2)) / 260 }; };
109
+ const onLeave = () => { pointer = null; };
110
+ if (area && !still) { area.addEventListener('pointermove', onMove); area.addEventListener('pointerleave', onLeave); }
111
+
112
+ let T = 0, nextBlink = 1.2, nextSaccade = 0.6;
113
+ function draw(t, breatheT) {
114
+ const breathe = 1 + Math.sin(breatheT * 1.6) * 0.012, sq = squash.x * 0.06;
115
+ el.style.setProperty('--orb-x', (gx.x * 6).toFixed(2) + 'px');
116
+ el.style.setProperty('--orb-y', (gy.x * 4 + Math.sin(breatheT * 1.1) * 6).toFixed(2) + 'px');
117
+ el.style.setProperty('--orb-sx', (breathe + sq).toFixed(4)); el.style.setProperty('--orb-sy', (breathe - sq).toFixed(4));
118
+ if (prog) {
119
+ gl.viewport(0, 0, canvas.width, canvas.height); gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT);
120
+ gl.uniform2f(uRes, canvas.width, canvas.height); gl.uniform1f(uT, t); gl.uniform1f(uE, clamp(energy.x)); gl.uniform2f(uLook, gx.x, -gy.x);
121
+ gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
122
+ }
123
+ const hp = clamp(happy.x), open = clamp(blink.x);
124
+ eyeL.setAttribute('d', eyePath(-24, hp, open)); eyeR.setAttribute('d', eyePath(24, hp, open));
125
+ eyes.setAttribute('transform', `translate(${(gx.x * 14).toFixed(2)} ${(gy.x * 9).toFixed(2)})`);
126
+ cheeks.setAttribute('opacity', (hp * 0.8).toFixed(3));
127
+ cheeks.setAttribute('transform', `translate(${(gx.x * 9).toFixed(2)} ${(gy.x * 6).toFixed(2)})`);
128
+ }
129
+ if (still) { draw(6, 0); return () => {}; }
130
+
131
+ // the loop runs only while the orb is on screen and the tab is visible
132
+ let raf = 0, last = 0, visible = true;
133
+ const frame = (now) => {
134
+ const dt = Math.min((now - (last || now)) / 1000, 1 / 30); last = now; T += dt;
135
+ if (pointer) gaze = { x: Math.max(-1, Math.min(1, pointer.x)), y: Math.max(-1, Math.min(1, pointer.y)) };
136
+ else if (look) gaze = look;
137
+ else if (T > nextSaccade) { gaze = { x: (Math.random() - 0.5) * 1.1, y: (Math.random() - 0.6) * 0.7 }; nextSaccade = T + 1.1 + Math.random() * 1.8; }
138
+ gx.to = gaze.x; gy.to = gaze.y;
139
+ if (T > nextBlink && happy.to < 0.5) { blink.x = 0.15; blink.v = 0; nextBlink = T + (Math.random() < 0.2 ? 0.28 : 2.4 + Math.random() * 2.6); }
140
+ [gx, gy, happy, blink, squash, energy].forEach((s) => step(s, dt));
141
+ blink.to = 1;
142
+ draw(T, T);
143
+ raf = visible && !document.hidden ? requestAnimationFrame(frame) : 0;
144
+ };
145
+ const wake = () => { if (!raf && visible && !document.hidden) { last = 0; raf = requestAnimationFrame(frame); } };
146
+ new IntersectionObserver((es) => { visible = es.some((e) => e.isIntersecting); wake(); }).observe(el);
147
+ document.addEventListener('visibilitychange', wake);
148
+ wake();
149
+ return () => {};
150
+ }
@@ -0,0 +1,12 @@
1
+ # AssistantOrb - the assistant, as a living sphere of light with eyes: light flows inside it (a WebGL shader), it
2
+ # breathes, blinks, glances around and follows the pointer over the area around it, and smiles when it has done
3
+ # something. Without WebGL it is the same sphere as a gradient; with reduced motion it stays still, eyes open.
4
+ # AssistantOrb(size: "240")
5
+ # Its mood is set from outside with an event on its element: el.dispatchEvent(new CustomEvent("orb:mood",
6
+ # { detail: { happy: 1, energy: 1, look: { x: -1, y: -.5 }, bump: true } })) - happy 0..1 (the smile), energy 0..1
7
+ # (the light speeds up and turns mint), look -1..1 (null = its own glances), bump (a little jump). AssistantPlay
8
+ # drives it that way.
9
+ # size: the sphere's box in px (the sphere and its halo fill it). The pointer area is the closest element with
10
+ # data-area="orb", else its parent.
11
+ # shadcn-for-muten · Custom · kit.css (.cx-orb) · host JS assistant-orb.js.
12
+ part AssistantOrb(size: text = "240") { Custom AssistantOrb inputs(size: $size) class("cx-orb") }
@@ -0,0 +1,41 @@
1
+ // AssistantPlay (muten Custom) - the clock of the scene in assistant-play.muten. It only moves what the part drew:
2
+ // the typing bubble, the message, the new row, the counter, and the orb's mood (through `orb:mood`).
3
+ const clamp = (v) => Math.max(0, Math.min(1, v));
4
+ const outExpo = (v) => (v >= 1 ? 1 : 1 - Math.pow(2, -10 * v));
5
+ const win = (t, a, b, inDur, outDur) => outExpo(clamp((t - a) / inDur)) * (1 - clamp((t - b) / outDur));
6
+ const LOOP = 10;
7
+
8
+ export function mount(el) {
9
+ const root = el.closest('.cx-ap');
10
+ if (!root) return () => {};
11
+ const $ = (s) => root.querySelector(s);
12
+ const E = { typing: $('.cx-ap-typing'), said: $('.cx-ap-said'), row: $('.cx-ap-new'), flash: $('.cx-ap-flash'), ca: $('.cx-ap-count-a'), cb: $('.cx-ap-count-b'), orb: $('.cx-orb') };
13
+ const dots = [...root.querySelectorAll('.cx-ap-dot')];
14
+ const still = matchMedia('(prefers-reduced-motion: reduce)').matches;
15
+ let mood = '';
16
+ const setMood = (happy, energy, look, bump) => {
17
+ const key = `${happy}|${energy}|${look ? 1 : 0}`;
18
+ if (key === mood && !bump) return; mood = key;
19
+ E.orb && E.orb.dispatchEvent(new CustomEvent('orb:mood', { detail: { happy, energy, look, bump } }));
20
+ };
21
+ const paint = (t, T) => {
22
+ const typing = win(t, 3.0, 4.35, 0.35, 0.15), said = win(t, 4.4, 8.6, 0.45, 0.35), row = win(t, 4.75, 8.9, 0.6, 0.45);
23
+ const isHappy = t > 4.45 && t < 8.6;
24
+ // while it works it looks at the day (top left); happy and bright when it is done
25
+ setMood(isHappy ? 1 : 0, isHappy ? (t < 5.6 ? 1 : 0.35) : 0, t > 2.6 && t < 8.6 ? { x: -0.95, y: -0.55 } : null, t > 4.45 && t < 4.6);
26
+ E.typing.style.setProperty('--k', typing.toFixed(3));
27
+ dots.forEach((d, k) => { const b = Math.max(0, Math.sin(T * 7 - k * 0.7)); d.style.setProperty('--b', b.toFixed(3)); });
28
+ E.said.style.setProperty('--k', said.toFixed(3));
29
+ E.row.style.setProperty('--k', row.toFixed(3));
30
+ E.flash.style.setProperty('--f', clamp(1 - Math.abs(t - 5.4) / 1.2).toFixed(3));
31
+ const c = win(t, 4.85, 8.9, 0.4, 0.4); E.ca.style.setProperty('--k', (1 - c).toFixed(3)); E.cb.style.setProperty('--k', c.toFixed(3));
32
+ };
33
+ if (still) { requestAnimationFrame(() => paint(6, 0)); return () => {}; }
34
+ let raf = 0, start = 0, visible = true;
35
+ const frame = (now) => { if (!start) start = now; const T = (now - start) / 1000; paint(T % LOOP, T); raf = visible && !document.hidden ? requestAnimationFrame(frame) : 0; };
36
+ const wake = () => { if (!raf && visible && !document.hidden) raf = requestAnimationFrame(frame); };
37
+ new IntersectionObserver((es) => { visible = es.some((e) => e.isIntersecting); wake(); }).observe(root);
38
+ document.addEventListener('visibilitychange', wake);
39
+ wake();
40
+ return () => {};
41
+ }
@@ -0,0 +1,41 @@
1
+ # AssistantPlay - the assistant at work, shown (not used): a card with the day, the assistant typing, saying what it did
2
+ # and the new booking dropping into the day, the counter going up, the orb smiling. It loops every 10 s; reduced
3
+ # motion shows the end (the booking in, the message said). The rows of the day go in the slot (Item + ItemSeparator).
4
+ # AssistantPlay(title: "Hoy, martes 14", before: "3 citas · 2 pedidos", after: "4 citas · 2 pedidos",
5
+ # newTitle: "Carla S.", newLine: "4:30 PM · Corte clásico · la agendó tu asistente", newBadge: "Nueva",
6
+ # message: "¡Listo! Agendé a Carla hoy a las 4:30 PM.") {
7
+ # Item(size: "sm") { ItemContent() { ItemTitle(label: "Laura M.") ItemDescription(label: "9:00 AM · Corte clásico") } }
8
+ # ItemSeparator()
9
+ # …
10
+ # }
11
+ # It fills its container (relative): the card at the top left, the orb at the bottom right, the bubble beside it. Put
12
+ # it on a dark panel (the orb glows); the pointer over that panel is followed by the orb's eyes.
13
+ # shadcn-for-muten · parts (Card, Item, StatusBadge, AssistantOrb) + Custom AssistantPlay · kit.css (.cx-ap).
14
+ part AssistantPlay(title: text, before: text, after: text, newTitle: text, newLine: text, newBadge: text = "", message: text) {
15
+ Stack class("cx-ap") data(area: "orb") {
16
+ Card(size: "sm") class("cx-ap-card") {
17
+ CardHeader() {
18
+ CardTitle(label: $title)
19
+ CardAction() {
20
+ Stack class("cx-ap-count") { Span "{$before}" class("cx-ap-count-a") Span "{$after}" class("cx-ap-count-b") }
21
+ }
22
+ }
23
+ CardContent() {
24
+ ItemGroup() {
25
+ slot
26
+ Stack class("cx-ap-new") {
27
+ ItemSeparator()
28
+ Item(size: "sm") class("cx-ap-flash") {
29
+ ItemContent() { ItemTitle(label: $newTitle) ItemDescription(label: $newLine) }
30
+ when $newBadge != "" { ItemActions() { StatusBadge(label: $newBadge, tone: "ok") } }
31
+ }
32
+ }
33
+ }
34
+ }
35
+ }
36
+ Stack class("cx-ap-typing") { Span "" class("cx-ap-dot") Span "" class("cx-ap-dot") Span "" class("cx-ap-dot") }
37
+ Span "{$message}" class("cx-ap-said") aria(live: "polite")
38
+ AssistantOrb(size: "320") class("cx-ap-orb")
39
+ Custom AssistantPlay class("cx-ap-run")
40
+ }
41
+ }
@@ -12,6 +12,14 @@ part InputAddon(align: text = "inline-start") { Stack class("cn-input-group-addo
12
12
  # GroupInput - the field inside an InputGroup. `label` names it for a screen reader when the group shows no label of its own
13
13
  # (a literal: an interpolated arg cannot reach aria(); without it the placeholder names the field).
14
14
  part GroupInput(value: text, placeholder: text = "", label: text = "") { SearchField bind($value) "{$placeholder}" class("cn-input cn-input-group-input cx-num") data(slot: "input-group-control") aria(label: $label) }
15
+ # GroupPassword - a password inside an InputGroup; `shown` (the page's bool) shows what was typed. Put the eye in an
16
+ # InputAddon(align: "inline-end") with an InputGroupButton that flips that bool.
17
+ # InputGroup { InputAddon { Icon "lucide:lock" } GroupPassword(value: pass, shown: showPass, placeholder: "Tu contraseña")
18
+ # InputAddon(align: "inline-end") { InputGroupButton(onClick: toggleShow, size: "icon-xs", label: "Mostrar contraseña") { Icon "lucide:eye" } } }
19
+ part GroupPassword(value: text, shown: bool = false, placeholder: text = "", label: text = "") {
20
+ when $shown { SearchField bind($value) "{$placeholder}" class("cn-input cn-input-group-input") data(slot: "input-group-control") aria(label: $label) }
21
+ when not $shown { Password bind($value) "{$placeholder}" class("cn-input cn-input-group-input") data(slot: "input-group-control") aria(label: $label) }
22
+ }
15
23
  part SearchInput(value: text, placeholder: text = "Buscar", onClear: action, size: text = "default", variant: text = "default") {
16
24
  Stack class("cn-input-group group/input-group") data(slot: "input-group", size: $size, variant: $variant) aria(role: "group") {
17
25
  Stack class("cn-input-group-addon cn-input-group-addon-align-inline-start") data(slot: "input-group-addon", align: "inline-start") { Icon "lucide:search" }
@@ -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
 
@@ -432,8 +434,8 @@ let wheelsNow = [];
432
434
  // the arrows sit in the title's own row, so they can never land on the month's name
433
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}"` });
434
436
  const month = (m) => {
435
- 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();
436
- const cells = Array.from({ length: 42 }, (_, i) => addDays(first, i - lead)).slice(0, Math.ceil((lead + days) / 7) * 7);
437
+ const first = new Date(view.getFullYear(), view.getMonth() + m, 1), lead = (first.getDay() + 6) % 7;
438
+ const cells = Array.from({ length: 42 }, (_, i) => addDays(first, i - lead)); // always six weeks: the box keeps its height from month to month (a five-week month made it jump)
437
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) => {
438
440
  const out = d.getMonth() !== first.getMonth(), off = min && d < startOfDay(min);
439
441
  const st = isEdge(d) ? 'edge' : inRange(d) ? 'mid' : '';
@@ -631,7 +633,10 @@ let wheelsNow = [];
631
633
  const ghostHtml = (d, rid) => { const g = placing; if (!g || !g.start || !sameDay(g.day, d) || (rid && g.resource !== rid)) return ''; return `<div class="cx-ag-ghostnew" style="${g.tint ? `--tint:${g.tint};` : ''}top:${y(toMin(g.start))}px;height:${((Number(g.dur) || slot) / 60) * hourPx - 3}px" aria-hidden="true">${ui.text(`${g.title || w.newItem} · ${clk(g.start)}`, 'label')}</div>`; };
632
634
  const blockHtml = (b) => `<div class="cx-ag-block" style="top:${y(toMin(b.start))}px;height:${(b.dur / 60) * hourPx - 2}px">${ui.text(`${b.reason || w.blocked} · ${clk(b.start)}`, 'caption')}</div>`;
633
635
  // while loading, everything that would ask the server for something is shut (days, views, arrows, actions, hours)
634
- const shut = () => (loading ? ' disabled aria-disabled="true"' : '');
636
+ // the toolbar locks only on the very first load: once the agenda has shown data, a skeleton while the next week
637
+ // arrives leaves the arrows, the date and the views free (tapping «next» three times in a row must work)
638
+ let shownOnce = false;
639
+ const shut = () => (loading && !shownOnce ? ' disabled aria-disabled="true"' : '');
635
640
  const title = () => (eff() === 'range' ? `${fmtD(rangeSel[0])} → ${fmtD(rangeSel[1])}` : view === 'week' && !narrow ? w.weekOf(fmtD(weekDays()[0])) : w.dayTitle(w.dayL[day.getDay()], day.getDate(), w.mon[day.getMonth()]));
636
641
  const toolbar = () => `<div class="cx-ag-bar">${ui.button({ label: w.today, size: 'sm', attrs: `data-ag="today"${shut()}` })}<div class="cx-hstack" style="gap:2px;flex-wrap:nowrap">${ui.button({ icon: 'left', variant: 'ghost', size: 'icon-sm', attrs: `data-ag="prev" data-ag-move aria-label="${view === 'week' && !narrow ? w.prevWeek : w.prevDay}"${shut()}` })}${ui.button({ icon: 'right', variant: 'ghost', size: 'icon-sm', attrs: `data-ag="next" data-ag-move aria-label="${view === 'week' && !narrow ? w.nextWeek : w.nextDay}"${shut()}` })}</div><button type="button" class="cx-ag-title" data-ag-date aria-haspopup="dialog" aria-expanded="false" aria-label="${w.goTo}: ${title()}"${shut()}>${ui.text(title(), 'heading')}${ui.icon('chev')}</button><span class="cx-grow"></span><div class="cx-tabs" role="tablist" aria-label="${w.view}">${[['day', w.vDay], ['week', w.vWeek], ['list', w.vList]].filter(([k]) => !narrow || k !== 'week').map(([k, l]) => `<button class="cn-button cn-button-variant-ghost cn-button-size-sm cx-tab" role="tab" aria-selected="${eff() === k}" data-ag-view="${k}"${shut()}>${l}</button>`).join('')}</div>${bar()}</div>`;
637
642
  // the toolbar's own buttons are data: [{id, label, icon, primary}]; «block» and «new» keep their behaviour, any other id goes to onAction
@@ -640,15 +645,17 @@ let wheelsNow = [];
640
645
  const groupName = (k) => (k === 'business' ? w.business : k === 'person' ? w.team : resources.find((r) => kindOf(r) === k)?.group || k);
641
646
  // the filter: everyone, then each kind of agenda (the business, the team, rooms…) with its own button when there is more than one kind
642
647
  const people = () => {
643
- if (loading) return `<div class="cx-hstack cx-ag-who" aria-hidden="true">${[64, 92, 92, 92].map((wd, i) => `<span class="cx-ag-skpill">${i ? '<s class="skel" style="width:20px;height:20px;border-radius:50%"></s>' : ''}<s class="skel" style="width:${i ? wd - 30 : wd - 20}px"></s></span>`).join('')}</div>`;
648
+ // the loading row keeps the real row's shape: none for one agenda (it has no filter), one pill per agenda otherwise,
649
+ // so nothing below it jumps when the data lands
644
650
  if (resources.length < 2) return '';
651
+ if (loading) return `<div class="cx-hstack cx-ag-who" aria-hidden="true">${[64, ...Array(Math.min(4, resources.length)).fill(92)].map((wd, i) => `<span class="cx-ag-skpill">${i ? '<s class="skel" style="width:20px;height:20px;border-radius:50%"></s>' : ''}<s class="skel" style="width:${i ? wd - 30 : wd - 20}px"></s></span>`).join('')}</div>`;
645
652
  const kinds = [...new Set(ordered().map(kindOf))];
646
653
  const all = ui.button({ label: w.all, size: 'sm', variant: 'ghost', attrs: `role="radio" aria-checked="${who === 'all'}" data-who="all"` });
647
654
  if (kinds.length < 2) 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}${resources.map(personBtn).join('')}</div>`;
648
655
  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>`;
649
656
  };
650
657
  // the week strip: each day with how many citas it has (a plain number), closed days dimmed
651
- 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>`;
658
+ 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 && !shownOnce ? ' 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>`;
652
659
  // «Ahora»: on today, the cita in progress (started, or whose time is now), first thing, one tap to open it
653
660
  const nowCard = () => {
654
661
  if (!sameDay(day, today)) return '';
@@ -663,10 +670,12 @@ let wheelsNow = [];
663
670
  // the loading head keeps the real head's shape: the face, the name and, when the agenda has one, its role line
664
671
  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>`; };
665
672
  const v0 = eff;
673
+ // the line at the current time, in today's column (closed or not: the time still passes), its dot in the first one
674
+ const nowLine = (c, ci, cols) => { const nowM = nowMin(); return 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>` : ''; };
666
675
  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>`;
667
676
  const offRid = rid || (shown().length === 1 && !freeList ? shown()[0].id : null);
668
- 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>`;
669
- 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>`; };
677
+ 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()}${nowLine(c, ci, cols)}</div>`;
678
+ 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)}${nowLine(c, ci, cols)}</div>`; }).join('')}</div></div></div>`; };
670
679
  // list = the phone's agenda: a solid row is sold, a dashed one is still free
671
680
  // one cita as a row of the list (the day's list and the range's)
672
681
  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>`;
@@ -703,7 +712,8 @@ let wheelsNow = [];
703
712
  // every toolbar command in one place: the toolbar's own buttons and an app's phone bar (api.run) go through here.
704
713
  // While loading, nothing that would ask the server runs.
705
714
  function run(k) {
706
- if (loading || !k) return;
715
+ // while the next week arrives, moving on stays free; making something (new, block, an app action) waits for the data
716
+ if (!k || (loading && (!shownOnce || !(k === 'prev' || k === 'next' || k === 'today' || k.startsWith('view:'))))) return;
707
717
  if (k.startsWith('view:')) { const v = k.slice(5); if (['day', 'week', 'list'].includes(v)) { view = v; picked = true; paint(); } return; }
708
718
  if (k === 'today') { day = new Date(today); if (view === 'range') { view = 'day'; rangeSel = null; } }
709
719
  else if ((k === 'prev' || k === 'next') && eff() === 'range') { const len = Math.round((rangeSel[1] - rangeSel[0]) / 864e5) + 1, s2 = (k === 'next' ? 1 : -1) * len; rangeSel = [addDays(rangeSel[0], s2), addDays(rangeSel[1], s2)]; day = new Date(rangeSel[0]); }
@@ -717,7 +727,7 @@ let wheelsNow = [];
717
727
  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>` : ''}`; };
718
728
  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 }); };
719
729
  const paint = () => {
720
- refit();
730
+ if (fit) { const k = `${eff()}|${from}|${to}|${narrow}`; if (k !== fitKey && fitSeen.has(k)) { hourPx = fitSeen.get(k); fitKey = k; } }
721
731
  if (typeof drag !== 'undefined' && drag) { const d = drag; drag = null; clearTimeout(d.timer); cancelAnimationFrame(scrollRaf); d.ghost?.remove(); }
722
732
  const v = eff(); // the phone keeps the calendar: day grid or list, the week needs a wide screen
723
733
  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 }));
@@ -727,9 +737,10 @@ let wheelsNow = [];
727
737
  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' }) : '';
728
738
  if (!loading) { hadNow = top.includes('cx-ag-nowcard'); hadQueue = top.includes('cx-ag-queue'); }
729
739
  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>`;
730
- root.toggleAttribute('data-loading', !!loading);
731
- $$('.cx-tabs', root).forEach(initTabs);
740
+ root.toggleAttribute('data-loading', !!loading); if (!loading) { shownOnce = true; root.setAttribute('data-shown', ''); }
741
+ $$('.cx-tabs', root).forEach((tl) => initTabs(tl, { redraws: true }));
732
742
  const now = $('.cx-ag-now', root); if (now) $('.cx-ag-scroll', root).scrollTop = Math.max(0, parseFloat(now.style.top) - 120);
743
+ if (refit()) { paint(); return; } // the hours' height changed: once more at the right size
733
744
  tellRange();
734
745
  };
735
746
  // drag to move · drag the bottom edge to resize (15 min). A ghost shows where it lands and turns red where the
@@ -825,7 +836,9 @@ let wheelsNow = [];
825
836
  root.addEventListener('pointercancel', endPan);
826
837
  root.addEventListener('click', (e) => {
827
838
  if (justSwiped) { e.preventDefault(); return; } // the end of a pan is not a tap on an hour
828
- if (loading && !e.target.closest('[data-who]')) return; // only the person filter works while loading: it asks nothing
839
+ // while loading: the person filter (it asks nothing) and, once the agenda has shown data, moving on (arrows, today,
840
+ // the date, the views, a day of the strip); what makes or opens something waits for the data
841
+ if (loading && !e.target.closest('[data-who]') && !(shownOnce && e.target.closest('[data-ag], [data-ag-view], [data-ag-date], [data-ag-day]'))) return;
829
842
  const evb = e.target.closest('.cx-ag-ev, .cx-ag-nowcard, .cx-ag-row[data-ev]'); if (evb) { if (!justDragged) openEvent(events.find((x) => x.id === evb.dataset.ev)); return; }
830
843
  const a = e.target.closest('[data-ag]'); if (a) { run(a.dataset.ag); return; }
831
844
  if (e.target.closest('[data-ag-date]')) { openDate(); return; }
@@ -848,7 +861,16 @@ let wheelsNow = [];
848
861
  $$('.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)); }
849
862
  $$('.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'; });
850
863
  }, 30000);
851
- 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) paint(); })).observe(root);
864
+ // only the agenda's own box decides: crossing the phone width repaints, and with fit a new HEIGHT of the box measures
865
+ // again. Reading the inner scroll's height here looped: new hour size → a scrollbar shows or hides → the scroll's
866
+ // height changes → measure → paint again, many times a second on a long day (and the grid kept jumping)
867
+ // and a box that grows with what it holds (an app that gave it no fixed height) can never chase itself: three
868
+ // re-measures a second at most
869
+ let boxH = 0, refits = [];
870
+ new ResizeObserver(([entry]) => requestAnimationFrame(() => { const n = root.clientWidth < 700; const bh = Math.round(entry.contentRect.height); const grew = fit && boxH && Math.abs(bh - boxH) > 1; boxH = bh;
871
+ if (n !== narrow) { narrow = n; paint(); return; }
872
+ if (!grew) return; const now = performance.now(); refits = refits.filter((t) => now - t < 1000); if (refits.length >= 3) return; refits.push(now);
873
+ fitKey = ''; fitSeen.clear(); paint(); })).observe(root);
852
874
 
853
875
  // ── the event sheet (showcase): detail · lifecycle · payment · move (second step) · cancel (second step).
854
876
  // In app mode a tapped cita goes to onOpen and the app opens its own sheet. ──
@@ -971,16 +993,23 @@ let wheelsNow = [];
971
993
  body.innerHTML = '<div class="cx-ag-datesheet"></div>';
972
994
  dateUI($('.cx-ag-datesheet', body), (r) => { closeModal(); applyDate(r); });
973
995
  };
974
- // fit: the hours share the height the app gives the agenda (never under baseHour), as a day view on a monitor should
975
- let hourPx = baseHour, fitH = 0;
996
+ // fit: the hours share the height the app gives the agenda (never under baseHour), as a day view on a monitor should.
997
+ // Measured after a paint and only when what decides it changed (view, hours, width, the agenda's height): reading
998
+ // the layout on every paint forced the whole page to lay out each time
999
+ let hourPx = baseHour, fitKey = '';
1000
+ const fitSeen = new Map(); // each view's measured hour, so going back to a view paints once at its size
976
1001
  const refit = () => {
977
- if (!fit) return;
1002
+ if (!fit) return false;
1003
+ const key = `${eff()}|${from}|${to}|${narrow}`; if (key === fitKey) return false;
978
1004
  const sc = root.querySelector('.cx-ag-scroll'), ag = sc ? sc.querySelector('.cx-ag') : null, hours = sc ? sc.querySelector('.cx-ag-body') : null;
979
- if (!sc || !sc.clientHeight || !ag || !hours) return;
980
- fitH = sc.clientHeight;
1005
+ if (!sc || !sc.clientHeight || !ag || !hours) return false;
1006
+ fitKey = key;
981
1007
  // everything in the scroll that is not the hours (the day heads, gaps, padding) keeps its height
982
1008
  const around = ag.offsetHeight - hours.offsetHeight;
983
- hourPx = Math.max(baseHour, Math.floor((sc.clientHeight - around) / Math.max(1, to - from)));
1009
+ const next = Math.max(baseHour, Math.floor((sc.clientHeight - around) / Math.max(1, to - from)));
1010
+ fitSeen.set(key, next);
1011
+ if (next === hourPx) return false;
1012
+ hourPx = next; return true;
984
1013
  };
985
1014
  let blockDay = null; // the one open day of the week, when the form had nothing to choose from
986
1015
  const openBlock = () => { blockOpen = true; sheetOf('agBlock'); };
@@ -0,0 +1,83 @@
1
+ // VirtualWindow (muten Custom) - only the rows on screen exist, in a list muten draws (see virtual-window.muten).
2
+ // It sits FIRST in the list. It watches where the list is inside the thing that scrolls (an `overflow: auto` parent, or
3
+ // the page) and says which rows to draw ("from,to"); it stands in for the rows above with its own height and pads the
4
+ // list below for the rows after, so the scrollbar keeps the whole length. Row heights may differ: it learns the real
5
+ // average from the rows on screen. Plain DOM, no kit.
6
+ export function mount(el, inputs, on) {
7
+ let count = Math.max(0, Number(inputs.count) || 0), guess = Number(inputs.item) || 72, over = Math.max(0, Number(inputs.over ?? 6));
8
+ // -1: the first range is always said, even «0,0» (nothing on screen yet), or the page keeps its own default
9
+ let from = -1, to = -1, step = 0, raf = 0, list = null, scroller = null, basePad = 0, wired = false;
10
+ el.setAttribute('aria-hidden', 'true');
11
+
12
+ const gapOf = () => parseFloat(getComputedStyle(list).rowGap) || 0;
13
+ // the rows muten drew after the marker (anything else the app puts in the list is left alone)
14
+ const rows = () => { const out = []; for (let n = el.nextElementSibling; n; n = n.nextElementSibling) out.push(n); return out; };
15
+ // the real height of one row plus its gap, from the rows on screen; the guess until there are some
16
+ const learn = () => {
17
+ const r = rows(); const gap = gapOf() * scale();
18
+ // rows of very different heights (a group of 2 next to one of 12) would make the length jump at each draw: the
19
+ // measure moves the average a little each time instead of replacing it
20
+ // per ROW asked for, not per element: a row may be several elements (a title and its carousel), or none (empty)
21
+ const n = to > from ? to - from : r.length;
22
+ if (r.length && n > 0) { const a = r[0].getBoundingClientRect(), b = r[r.length - 1].getBoundingClientRect(); const span = b.bottom - a.top; if (span > 0) { const seen = (span + gap) / n; step = step ? step * 0.7 + seen * 0.3 : seen; } }
23
+ if (!step) step = (guess + gapOf()) * scale();
24
+ };
25
+ // what is on screen: the scroller's box, never past the window (a scroller taller than the screen grew with the rows)
26
+ const view = () => { if (scroller) { const b = scroller.getBoundingClientRect(); return [Math.max(0, b.top), Math.min(innerHeight, b.bottom)]; } return [0, innerHeight]; };
27
+ // a list inside a scaled box (a phone preview drawn at 80 %): what is measured comes in screen px, what is set goes in
28
+ // CSS px. Every measure stays in screen px; heights are divided by the scale when they are written
29
+ const scale = () => { const h = list.offsetHeight; const r = list.getBoundingClientRect().height; return h > 0 && r > 0 ? r / h : 1; };
30
+ const place = () => {
31
+ const gap = gapOf(), k = scale();
32
+ // the marker is the rows above; flex adds one gap after it, so it gives that gap back (and leaves when it is empty)
33
+ if (from > 0) { el.style.display = 'block'; el.style.height = `${Math.max(0, from * step / k - gap)}px`; } else { el.style.display = 'none'; el.style.height = '0px'; }
34
+ list.style.paddingBottom = `${basePad + Math.max(0, (count - to) * step / k)}px`;
35
+ };
36
+ const compute = () => {
37
+ raf = 0; if (!list) return;
38
+ learn();
39
+ const [top, bottom] = view();
40
+ // where row 0 starts: the marker's top while it shows, else the first row's (or the list's content box)
41
+ const start = from > 0 ? el.getBoundingClientRect().top : (el.nextElementSibling || list).getBoundingClientRect().top;
42
+ const a = Math.floor((top - start) / step) - over, b = Math.ceil((bottom - start) / step) + over;
43
+ let nf = Math.max(0, Math.min(count, a)), nt = Math.max(nf, Math.min(count, b));
44
+ // the estimate is an average: check it against the rows really drawn. Blank space showing below the last row (or
45
+ // above the first) means more rows belong there; and at either end of the scroll the list always reaches its end
46
+ const r = rows();
47
+ if (r.length && from >= 0 && to > from) {
48
+ const firstTop = r[0].getBoundingClientRect().top, lastBottom = r[r.length - 1].getBoundingClientRect().bottom;
49
+ if (to < count && lastBottom < bottom) nt = Math.min(count, Math.max(nt, to + Math.ceil((bottom - lastBottom) / step) + 1));
50
+ if (from > 0 && firstTop > top) nf = Math.max(0, Math.min(nf, from - Math.ceil((firstTop - top) / step) - 1));
51
+ }
52
+ const box = scroller || document.scrollingElement;
53
+ if (box && box.scrollTop + box.clientHeight >= box.scrollHeight - 2) nt = count;
54
+ if (box && box.scrollTop <= 2 && start >= top) nf = 0;
55
+ nt = Math.max(nf, nt);
56
+ if (nf === from && nt === to) return;
57
+ from = nf; to = nt; place(); el.dataset.range = `${from},${to}/${count}`;
58
+ on.range?.(`${from},${to}`);
59
+ };
60
+ const soon = () => { if (!raf) raf = requestAnimationFrame(compute); };
61
+ const wire = () => {
62
+ list = el.parentElement; if (!list || wired) return; wired = true;
63
+ for (let n = list.parentElement; n && n !== document.body && n !== document.documentElement; n = n.parentElement) { const o = getComputedStyle(n).overflowY; if (o === 'auto' || o === 'scroll') { scroller = n; break; } }
64
+ basePad = parseFloat(getComputedStyle(list).paddingBottom) || 0;
65
+ // the browser must not «hold» a row in place while rows above it come and go: that fights the window
66
+ list.style.overflowAnchor = 'none';
67
+ // and a flex parent must not shrink the list to fit: its length IS the scrollbar's (the space for the rows not drawn)
68
+ list.style.flexShrink = '0';
69
+ (scroller || window).addEventListener('scroll', soon, { passive: true });
70
+ (scroller || window).addEventListener('scrollend', soon, { passive: true });
71
+ addEventListener('resize', soon, { passive: true });
72
+ new ResizeObserver(soon).observe(list);
73
+ soon();
74
+ };
75
+ // muten mounts the Custom before it is in the page: wait for the list around it
76
+ requestAnimationFrame(wire);
77
+ return (next) => {
78
+ const c = Math.max(0, Number(next.count) || 0), g = Number(next.item) || guess, o = Math.max(0, Number(next.over ?? over));
79
+ if (c === count && g === guess && o === over) return;
80
+ count = c; guess = g; over = o; from = -1; to = -1; // force a fresh range (a filter changed the list)
81
+ if (list) soon(); else requestAnimationFrame(wire);
82
+ };
83
+ }
@@ -0,0 +1,23 @@
1
+ # VirtualWindow - a long list written in muten like any other, where only the rows on screen exist. Rows keep all
2
+ # their muten (photos, switches, menus, drag to reorder): the window only decides WHICH rows the page draws.
3
+ # Put it FIRST inside the list's container and draw the list as always, keeping only the rows it asks for:
4
+ # state { seen = "0,20" : text }
5
+ # action seeRows(r: text) mutates seen { seen.set(r) }
6
+ # get inWindow = windowIds(products.data, seen) # two tiny `use` functions in the app (below)
7
+ # Stack class("gap-3") {
8
+ # VirtualWindow(count: products.length, item: 104, onRange: seeRows)
9
+ # each products as p where inWindow contains rowKey(p.id) { … the row, as before … }
10
+ # }
11
+ # The list stays the real typed list (rows keep their keys, fields and drag); only the `where` changes. In lib.ts:
12
+ # export function windowIds(rows: { id: string }[], seen: string): string { const [a, b] = String(seen).split(',').map(Number); return ' ' + (rows || []).slice(Number.isFinite(a) ? a : 0, Number.isFinite(b) ? b : 20).map((r) => r.id).join(' ') + ' '; }
13
+ # export function rowKey(id: string): string { return ` ${id} `; }
14
+ # (A sorted or filtered list: give windowIds the same list, in the same order, that the `each` draws. And «0,0» is a
15
+ # real answer, nothing on screen yet: never read a 0 as «missing».)
16
+ # It stands in for the rows above (its own height) and pads the list below, so the scrollbar keeps the whole length.
17
+ # count = how many rows the list has (after any filter) · item = a first guess of one row's height in px (it learns the
18
+ # real one) · over = rows kept beyond each edge (6). The list scrolls inside its nearest `overflow: auto` parent or the
19
+ # page. Drag to reorder keeps working when the drop sends ids (the rows drawn are the ones you can drag).
20
+ # shadcn-for-muten · Custom (host JS virtual-window.js) · kit.css (.cx-vw).
21
+ part VirtualWindow(count: number, item: number = 72, over: number = 6, onRange: action) {
22
+ Custom VirtualWindow inputs(count: $count, item: $item, over: $over) on(range: $onRange) class("cx-vw")
23
+ }
package/registry.json CHANGED
@@ -40,6 +40,22 @@
40
40
  "component": "AppShell",
41
41
  "description": "AppShell(brand, user, nav, pages, rows, commands, narrow, onNavigate, onOpen, onAction): Sidebar, page header, detail Sheet, ⌘K Command."
42
42
  },
43
+ {
44
+ "name": "assistant-orb",
45
+ "deps": [],
46
+ "part": "AssistantOrb",
47
+ "file": "registry/assistant-orb.muten",
48
+ "component": "AssistantOrb",
49
+ "description": "AssistantOrb - the assistant, as a living sphere of light with eyes: light flows inside it (a WebGL shader), it"
50
+ },
51
+ {
52
+ "name": "assistant-play",
53
+ "deps": [],
54
+ "part": "AssistantPlay",
55
+ "file": "registry/assistant-play.muten",
56
+ "component": "AssistantPlay",
57
+ "description": "AssistantPlay - the assistant at work, shown (not used): a card with the day, the assistant typing, saying what it did"
58
+ },
43
59
  {
44
60
  "name": "attachment",
45
61
  "deps": [],
@@ -1174,6 +1190,14 @@
1174
1190
  "component": "VirtualList",
1175
1191
  "description": "VirtualList(rows, total, rowHeight, label, jump): only visible rows in the DOM."
1176
1192
  },
1193
+ {
1194
+ "name": "virtual-window",
1195
+ "deps": [],
1196
+ "part": "VirtualWindow",
1197
+ "file": "registry/virtual-window.muten",
1198
+ "component": "VirtualWindow",
1199
+ "description": "VirtualWindow(count, item, over, onRange): a muten list where only the rows on screen exist; it says which rows to draw (\"from,to\") and keeps the scrollbar's full length."
1200
+ },
1177
1201
  {
1178
1202
  "name": "weekly-hours",
1179
1203
  "deps": [