@muten/shadcn 0.1.8 → 0.1.10
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 +1 -1
- package/README.md +1 -1
- package/kit.css +40 -2
- package/package.json +1 -1
- package/registry/assistant-orb.js +150 -0
- package/registry/assistant-orb.muten +12 -0
- package/registry/assistant-play.js +41 -0
- package/registry/assistant-play.muten +41 -0
- package/registry/input-group.muten +8 -0
- package/registry/kit/core.js +32 -13
- package/registry/virtual-window.js +83 -0
- package/registry/virtual-window.muten +23 -0
- package/registry.json +24 -0
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
|
@@ -1299,6 +1299,9 @@ html.cx-locked { overflow: hidden; }
|
|
|
1299
1299
|
.cx-tab { position: relative; z-index: 1; color: color-mix(in srgb, var(--foreground) 62%, transparent); }
|
|
1300
1300
|
.cx-tab:hover { background: transparent; color: var(--foreground); }
|
|
1301
1301
|
.cx-tab[aria-selected="true"] { color: var(--foreground); }
|
|
1302
|
+
/* a segment picks with the sliding pill, not with a filled button: beats the generic «pressed = selected» fill above */
|
|
1303
|
+
.cx-tabs > .cn-button.cx-tab[aria-pressed="true"][aria-pressed][aria-pressed],
|
|
1304
|
+
.cx-tabs > .cn-button.cx-tab[aria-pressed="true"][aria-pressed][aria-pressed]:hover { background: transparent; border-color: transparent; color: var(--foreground); }
|
|
1302
1305
|
.cx-tabs.line .cx-tab { height: 36px; border-radius: 0; }
|
|
1303
1306
|
.cx-tab .cx-count { min-width: 18px; height: 18px; padding: 0 5px; border-radius: 99px; background: color-mix(in srgb, var(--foreground) 9%, transparent); font-size: 11px; font-variant-numeric: tabular-nums; display: inline-grid; place-items: center; }
|
|
1304
1307
|
.cx-tab[aria-selected="true"] .cx-count { background: var(--selected); color: var(--selected-foreground); }
|
|
@@ -4869,7 +4872,8 @@ a.cx-bc-item:hover { color: var(--foreground); }
|
|
|
4869
4872
|
/* the crossed-citas alert sits under the strip, full width, with its button */
|
|
4870
4873
|
.cx-ag-top > [data-ag-alert] { margin: 0; }
|
|
4871
4874
|
/* no view picked (a range is on): the tabs show none, not the first */
|
|
4872
|
-
|
|
4875
|
+
/* the pill shows while a tab is chosen: Tabs mark it aria-selected, SegmentedItem aria-pressed */
|
|
4876
|
+
.cx-tabs:not(:has(.cx-tab:is([aria-selected="true"], [aria-pressed="true"]))) > .cx-tab-ind { opacity: 0; }
|
|
4873
4877
|
/* who waits that day: two faces and how many */
|
|
4874
4878
|
.cx-ag-queue { display: flex; align-items: center; gap: 0; }
|
|
4875
4879
|
.cx-ag-queue > .cn-avatar, .cx-ag-queue > .cx-ag-face { width: 24px; height: 24px; min-width: 24px; font-size: 10px; letter-spacing: 0; overflow: hidden; box-shadow: 0 0 0 2px var(--card, var(--background)); }
|
|
@@ -4892,7 +4896,7 @@ a.cx-bc-item:hover { color: var(--foreground); }
|
|
|
4892
4896
|
.cx-ag-skhead { display: inline-flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; }
|
|
4893
4897
|
/* phone "nav": on a narrow agenda the app's phone bar carries the actions and the arrows */
|
|
4894
4898
|
@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; }
|
|
4899
|
+
.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
4900
|
.cx-agenda[data-loading] [disabled] { opacity: .5; pointer-events: none; }
|
|
4897
4901
|
.cx-ag-frame .skel { background: color-mix(in oklab, var(--muted) 85%, var(--foreground) 6%); }
|
|
4898
4902
|
@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 +5218,37 @@ a.cx-bc-item:hover { color: var(--foreground); }
|
|
|
5214
5218
|
/* a narrow column (the week on a laptop) keeps the client's name: the price shows only where there is room for both */
|
|
5215
5219
|
.cx-ag-col { container-type: inline-size; }
|
|
5216
5220
|
@container (max-width: 190px) { .cx-ag-evprice { display: none; } .cx-ag-ev:has(> .cx-ag-evprice) { padding-right: 8px; } }
|
|
5221
|
+
|
|
5222
|
+
/* VirtualWindow: the marker that stands in for the rows above (its height is set by virtual-window.js) */
|
|
5223
|
+
.cx-vw { display: none; flex: none; pointer-events: none; margin: 0; padding: 0; }
|
|
5224
|
+
|
|
5225
|
+
/* ── ASSISTANT (registry/assistant-orb.muten, assistant-play.muten) ──────────────────────────────────────────────────
|
|
5226
|
+
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
|
|
5227
|
+
moves it with --orb-x/--orb-y (lean and float) and --orb-sx/--orb-sy (breath and squash). */
|
|
5228
|
+
.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; }
|
|
5229
|
+
.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); }
|
|
5230
|
+
.cx-orb[data-gl="on"] .cx-orb-base { opacity: 0; transition: opacity .4s ease; }
|
|
5231
|
+
.cx-orb-canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
|
|
5232
|
+
.cx-orb-eyes { position: absolute; left: 31.25%; top: 35%; width: 37.5%; height: 25%; overflow: visible; }
|
|
5233
|
+
.cx-orb-eyes path { fill: #fff; }
|
|
5234
|
+
.cx-orb-cheeks ellipse { fill: #FF9AD5; }
|
|
5235
|
+
/* 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
|
|
5236
|
+
the panel's bottom-right corner (the panel clips it), the bubble beside the orb. */
|
|
5237
|
+
.cx-ap { position: relative; flex: 1 1 auto; min-height: 340px; display: flex; flex-direction: column; }
|
|
5238
|
+
.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; }
|
|
5239
|
+
.cx-ap-count { position: relative; display: block; width: 150px; height: 18px; text-align: right; font-size: 13px; color: var(--muted-foreground); }
|
|
5240
|
+
.cx-ap-count > span { position: absolute; right: 0; top: 0; white-space: nowrap; }
|
|
5241
|
+
.cx-ap-count-a { opacity: var(--k, 1); }
|
|
5242
|
+
.cx-ap-count-b { opacity: var(--k, 0); color: var(--success); transform: translateY(calc((1 - var(--k, 0)) * 6px)); }
|
|
5243
|
+
.cx-ap-card .cn-item-group { flex-direction: column; }
|
|
5244
|
+
.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)); }
|
|
5245
|
+
.cx-ap-flash { border-radius: 10px; background: rgb(18 185 129 / calc(var(--f, 0) * .12)); }
|
|
5246
|
+
.cx-ap-typing, .cx-ap-said { position: absolute; right: 194px; bottom: 112px; transform-origin: 100% 100%; border-radius: 22px 22px 6px 22px; background: #fff; }
|
|
5247
|
+
.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)); }
|
|
5248
|
+
.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)); }
|
|
5249
|
+
.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)); }
|
|
5250
|
+
.cx-ap .cx-ap-orb { position: absolute; right: -80px; bottom: -96px; }
|
|
5251
|
+
.cx-ap-run { display: none; }
|
|
5252
|
+
@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; } }
|
|
5253
|
+
/* A Field whose label lives elsewhere (a FieldHead above the control, with its action beside it): no empty label row. */
|
|
5254
|
+
.cn-field > .cn-label:empty { display: none; }
|
package/package.json
CHANGED
|
@@ -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" }
|
package/registry/kit/core.js
CHANGED
|
@@ -434,8 +434,8 @@ let wheelsNow = [];
|
|
|
434
434
|
// the arrows sit in the title's own row, so they can never land on the month's name
|
|
435
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}"` });
|
|
436
436
|
const month = (m) => {
|
|
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))
|
|
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)
|
|
439
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) => {
|
|
440
440
|
const out = d.getMonth() !== first.getMonth(), off = min && d < startOfDay(min);
|
|
441
441
|
const st = isEdge(d) ? 'edge' : inRange(d) ? 'mid' : '';
|
|
@@ -633,7 +633,10 @@ let wheelsNow = [];
|
|
|
633
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>`; };
|
|
634
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>`;
|
|
635
635
|
// while loading, everything that would ask the server for something is shut (days, views, arrows, actions, hours)
|
|
636
|
-
|
|
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"' : '');
|
|
637
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()]));
|
|
638
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>`;
|
|
639
642
|
// the toolbar's own buttons are data: [{id, label, icon, primary}]; «block» and «new» keep their behaviour, any other id goes to onAction
|
|
@@ -642,15 +645,17 @@ let wheelsNow = [];
|
|
|
642
645
|
const groupName = (k) => (k === 'business' ? w.business : k === 'person' ? w.team : resources.find((r) => kindOf(r) === k)?.group || k);
|
|
643
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
|
|
644
647
|
const people = () => {
|
|
645
|
-
|
|
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
|
|
646
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>`;
|
|
647
652
|
const kinds = [...new Set(ordered().map(kindOf))];
|
|
648
653
|
const all = ui.button({ label: w.all, size: 'sm', variant: 'ghost', attrs: `role="radio" aria-checked="${who === 'all'}" data-who="all"` });
|
|
649
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>`;
|
|
650
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>`;
|
|
651
656
|
};
|
|
652
657
|
// the week strip: each day with how many citas it has (a plain number), closed days dimmed
|
|
653
|
-
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>`;
|
|
654
659
|
// «Ahora»: on today, the cita in progress (started, or whose time is now), first thing, one tap to open it
|
|
655
660
|
const nowCard = () => {
|
|
656
661
|
if (!sameDay(day, today)) return '';
|
|
@@ -665,10 +670,12 @@ let wheelsNow = [];
|
|
|
665
670
|
// the loading head keeps the real head's shape: the face, the name and, when the agenda has one, its role line
|
|
666
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>`; };
|
|
667
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>` : ''; };
|
|
668
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>`;
|
|
669
676
|
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)}${
|
|
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>`; };
|
|
672
679
|
// list = the phone's agenda: a solid row is sold, a dashed one is still free
|
|
673
680
|
// one cita as a row of the list (the day's list and the range's)
|
|
674
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>`;
|
|
@@ -705,7 +712,8 @@ let wheelsNow = [];
|
|
|
705
712
|
// every toolbar command in one place: the toolbar's own buttons and an app's phone bar (api.run) go through here.
|
|
706
713
|
// While loading, nothing that would ask the server runs.
|
|
707
714
|
function run(k) {
|
|
708
|
-
|
|
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;
|
|
709
717
|
if (k.startsWith('view:')) { const v = k.slice(5); if (['day', 'week', 'list'].includes(v)) { view = v; picked = true; paint(); } return; }
|
|
710
718
|
if (k === 'today') { day = new Date(today); if (view === 'range') { view = 'day'; rangeSel = null; } }
|
|
711
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]); }
|
|
@@ -729,7 +737,7 @@ let wheelsNow = [];
|
|
|
729
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' }) : '';
|
|
730
738
|
if (!loading) { hadNow = top.includes('cx-ag-nowcard'); hadQueue = top.includes('cx-ag-queue'); }
|
|
731
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>`;
|
|
732
|
-
root.toggleAttribute('data-loading', !!loading);
|
|
740
|
+
root.toggleAttribute('data-loading', !!loading); if (!loading) { shownOnce = true; root.setAttribute('data-shown', ''); }
|
|
733
741
|
$$('.cx-tabs', root).forEach((tl) => initTabs(tl, { redraws: true }));
|
|
734
742
|
const now = $('.cx-ag-now', root); if (now) $('.cx-ag-scroll', root).scrollTop = Math.max(0, parseFloat(now.style.top) - 120);
|
|
735
743
|
if (refit()) { paint(); return; } // the hours' height changed: once more at the right size
|
|
@@ -828,7 +836,9 @@ let wheelsNow = [];
|
|
|
828
836
|
root.addEventListener('pointercancel', endPan);
|
|
829
837
|
root.addEventListener('click', (e) => {
|
|
830
838
|
if (justSwiped) { e.preventDefault(); return; } // the end of a pan is not a tap on an hour
|
|
831
|
-
|
|
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;
|
|
832
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; }
|
|
833
843
|
const a = e.target.closest('[data-ag]'); if (a) { run(a.dataset.ag); return; }
|
|
834
844
|
if (e.target.closest('[data-ag-date]')) { openDate(); return; }
|
|
@@ -851,7 +861,16 @@ let wheelsNow = [];
|
|
|
851
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)); }
|
|
852
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'; });
|
|
853
863
|
}, 30000);
|
|
854
|
-
|
|
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);
|
|
855
874
|
|
|
856
875
|
// ── the event sheet (showcase): detail · lifecycle · payment · move (second step) · cancel (second step).
|
|
857
876
|
// In app mode a tapped cita goes to onOpen and the app opens its own sheet. ──
|
|
@@ -977,14 +996,14 @@ let wheelsNow = [];
|
|
|
977
996
|
// fit: the hours share the height the app gives the agenda (never under baseHour), as a day view on a monitor should.
|
|
978
997
|
// Measured after a paint and only when what decides it changed (view, hours, width, the agenda's height): reading
|
|
979
998
|
// the layout on every paint forced the whole page to lay out each time
|
|
980
|
-
let hourPx = baseHour,
|
|
999
|
+
let hourPx = baseHour, fitKey = '';
|
|
981
1000
|
const fitSeen = new Map(); // each view's measured hour, so going back to a view paints once at its size
|
|
982
1001
|
const refit = () => {
|
|
983
1002
|
if (!fit) return false;
|
|
984
1003
|
const key = `${eff()}|${from}|${to}|${narrow}`; if (key === fitKey) return false;
|
|
985
1004
|
const sc = root.querySelector('.cx-ag-scroll'), ag = sc ? sc.querySelector('.cx-ag') : null, hours = sc ? sc.querySelector('.cx-ag-body') : null;
|
|
986
1005
|
if (!sc || !sc.clientHeight || !ag || !hours) return false;
|
|
987
|
-
fitKey = key;
|
|
1006
|
+
fitKey = key;
|
|
988
1007
|
// everything in the scroll that is not the hours (the day heads, gaps, padding) keeps its height
|
|
989
1008
|
const around = ag.offsetHeight - hours.offsetHeight;
|
|
990
1009
|
const next = Math.max(baseHour, Math.floor((sc.clientHeight - around) / Math.max(1, to - from)));
|
|
@@ -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": [
|