@muten/shadcn 0.1.12 → 0.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/kit.css +4 -54
- package/package.json +1 -1
- package/registry/chat-play.muten +7 -3
- package/registry/kit/core.js +27 -0
- package/registry/tabs-behavior.js +2 -0
- package/registry.json +0 -24
- package/registry/assistant-orb.js +0 -198
- package/registry/assistant-orb.muten +0 -14
- package/registry/assistant-play.js +0 -41
- package/registry/assistant-play.muten +0 -41
- package/registry/mascot.js +0 -291
- package/registry/mascot.muten +0 -28
package/kit.css
CHANGED
|
@@ -5012,6 +5012,7 @@ a.cx-bc-item:hover { color: var(--foreground); }
|
|
|
5012
5012
|
.cx-cardform > .cx-msg svg { width: 14px; height: 14px; flex: none; }
|
|
5013
5013
|
.cx-cardform > .cn-button { margin-top: 4px; }
|
|
5014
5014
|
.cx-dp { width: 100%; max-width: 440px; } /* a plan of the day has a width of its own: in a centred row it shrank to its narrowest word */
|
|
5015
|
+
.cx-mk-widget .cx-dp, .cx-bento-cell .cx-dp { max-width: none; } /* …but inside a card it takes the card's width, so it never drops to its two-line narrow layout in a wide cell */
|
|
5015
5016
|
/* An alert's action (Responder, Reintentar…) has its own column: the words wrap beside it, never under it */
|
|
5016
5017
|
.cx-alert:has(> .cn-alert-action) { padding-right: 10px; grid-template-columns: 1fr auto; }
|
|
5017
5018
|
.cx-alert:has(> svg):has(> .cn-alert-action) { grid-template-columns: 16px 1fr auto; }
|
|
@@ -5243,34 +5244,6 @@ a.cx-bc-item:hover { color: var(--foreground); }
|
|
|
5243
5244
|
/* VirtualWindow: the marker that stands in for the rows above (its height is set by virtual-window.js) */
|
|
5244
5245
|
.cx-vw { display: none; flex: none; pointer-events: none; margin: 0; padding: 0; }
|
|
5245
5246
|
|
|
5246
|
-
/* ── ASSISTANT (registry/assistant-orb.muten, assistant-play.muten) ──────────────────────────────────────────────────
|
|
5247
|
-
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
|
|
5248
|
-
moves it with --orb-x/--orb-y (lean and float) and --orb-sx/--orb-sy (breath and squash). */
|
|
5249
|
-
.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; }
|
|
5250
|
-
.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); }
|
|
5251
|
-
.cx-orb[data-gl="on"] .cx-orb-base { opacity: 0; transition: opacity .4s ease; }
|
|
5252
|
-
.cx-orb-canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
|
|
5253
|
-
.cx-orb-eyes { position: absolute; left: 31.25%; top: 35%; width: 37.5%; height: 25%; overflow: visible; }
|
|
5254
|
-
.cx-orb-eyes path { fill: #fff; }
|
|
5255
|
-
.cx-orb-cheeks ellipse { fill: #FF9AD5; }
|
|
5256
|
-
/* 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
|
|
5257
|
-
the panel's bottom-right corner (the panel clips it), the bubble beside the orb. */
|
|
5258
|
-
.cx-ap { position: relative; flex: 1 1 auto; min-height: 340px; display: flex; flex-direction: column; }
|
|
5259
|
-
.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; }
|
|
5260
|
-
.cx-ap-count { position: relative; display: block; width: 150px; height: 18px; text-align: right; font-size: 13px; color: var(--muted-foreground); }
|
|
5261
|
-
.cx-ap-count > span { position: absolute; right: 0; top: 0; white-space: nowrap; }
|
|
5262
|
-
.cx-ap-count-a { opacity: var(--k, 1); }
|
|
5263
|
-
.cx-ap-count-b { opacity: var(--k, 0); color: var(--success); transform: translateY(calc((1 - var(--k, 0)) * 6px)); }
|
|
5264
|
-
.cx-ap-card .cn-item-group { flex-direction: column; }
|
|
5265
|
-
.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)); }
|
|
5266
|
-
.cx-ap-flash { border-radius: 10px; background: rgb(18 185 129 / calc(var(--f, 0) * .12)); }
|
|
5267
|
-
.cx-ap-typing, .cx-ap-said { position: absolute; right: 194px; bottom: 112px; transform-origin: 100% 100%; border-radius: 22px 22px 6px 22px; background: #fff; }
|
|
5268
|
-
.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)); }
|
|
5269
|
-
.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)); }
|
|
5270
|
-
.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)); }
|
|
5271
|
-
.cx-ap .cx-ap-orb { position: absolute; right: -80px; bottom: -96px; }
|
|
5272
|
-
.cx-ap-run { display: none; }
|
|
5273
|
-
@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; } }
|
|
5274
5247
|
/* A Field whose label lives elsewhere (a FieldHead above the control, with its action beside it): no empty label row. */
|
|
5275
5248
|
.cn-field > .cn-label:empty { display: none; }
|
|
5276
5249
|
|
|
@@ -5321,29 +5294,6 @@ a.cx-bc-item:hover { color: var(--foreground); }
|
|
|
5321
5294
|
light surfaces). Other skins keep Nova's look. */
|
|
5322
5295
|
[data-skin="storio"] .cn-toggle:is([aria-pressed="true"], [data-state="on"]) { background: var(--selected-soft); color: var(--selected); border-color: color-mix(in srgb, var(--selected) 45%, transparent); }
|
|
5323
5296
|
|
|
5324
|
-
/*
|
|
5325
|
-
|
|
5326
|
-
.cx-
|
|
5327
|
-
.cx-mascot-rig, .cx-mascot-squash { position: absolute; inset: 0; will-change: transform; }
|
|
5328
|
-
.cx-mascot-rig { transform-origin: 50% 50%; }
|
|
5329
|
-
.cx-mascot-squash { transform-origin: 50% 83%; }
|
|
5330
|
-
.cx-mascot .cx-orb { transform: none !important; }
|
|
5331
|
-
.cx-orb[data-face="surface"] .cx-orb-eyes { display: none; }
|
|
5332
|
-
.cx-mascot-floor { position: absolute; left: 22%; right: 22%; top: 85%; height: 8%; border-radius: 50%; background: radial-gradient(closest-side, rgb(16 8 40 / .5), transparent); pointer-events: none; }
|
|
5333
|
-
.cx-mascot-fx { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }
|
|
5334
|
-
.cx-mascot-run { display: none; }
|
|
5335
|
-
/* how it speaks: a «paper» bubble (white, its name on top in violet, a drawn tail) placed by mascot.js up and to the
|
|
5336
|
-
left of the sphere, the tail's tip on its rim; it lives beside the box (not scaled with it) and pops in from the tail */
|
|
5337
|
-
.cx-mascot-say { position: absolute; left: 0; top: 0; z-index: 3; display: none; box-sizing: border-box; width: max-content; max-width: min(270px, calc(100vw - 48px)); padding: 11px 15px 12px; border-radius: 20px; background: #fff; color: #18122b; box-shadow: 0 1px 0 rgb(255 255 255 / .6) inset, 0 18px 40px -20px rgb(10 4 40 / .7); pointer-events: none; }
|
|
5338
|
-
.cx-mascot-say.on { display: block; }
|
|
5339
|
-
.cx-mascot-who { display: block; font-size: 11.5px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; margin-bottom: 3px; color: var(--selected, var(--primary, #6B46F2)); }
|
|
5340
|
-
.cx-mascot-who:empty { display: none; }
|
|
5341
|
-
.cx-mascot-line { display: block; font-size: 15px; line-height: 1.42; font-weight: 500; overflow-wrap: anywhere; }
|
|
5342
|
-
.cx-mascot-tail { position: absolute; left: var(--tail, 50%); bottom: -15px; width: 24px; height: 16px; overflow: visible; }
|
|
5343
|
-
.cx-mascot-tail path { fill: #fff; }
|
|
5344
|
-
.cx-mascot-say[data-side="right"] .cx-mascot-tail { transform: scaleX(-1); }
|
|
5345
|
-
.cx-mascot-tail-side { position: absolute; display: none; top: var(--tail, 40%); right: -15px; width: 16px; height: 24px; overflow: visible; }
|
|
5346
|
-
.cx-mascot-tail-side path { fill: #fff; }
|
|
5347
|
-
.cx-mascot-say[data-at="low"] .cx-mascot-tail { display: none; }
|
|
5348
|
-
.cx-mascot-say[data-at="low"] .cx-mascot-tail-side { display: block; }
|
|
5349
|
-
.cx-mascot-say[data-at="low"][data-side="right"] .cx-mascot-tail-side { right: auto; left: -15px; transform: scaleX(-1); }
|
|
5297
|
+
/* ChatPlay(face: "host"): an empty face slot the page fills with its own face (a brand mascot, say). The kit sends
|
|
5298
|
+
cx-cp:think / cx-cp:answer / cx-cp:rest on it on the play's clock, so that face can act along with the chat. */
|
|
5299
|
+
.cx-cp-face.cx-cp-face-host { position: relative; background: none; overflow: visible; }
|
package/package.json
CHANGED
package/registry/chat-play.muten
CHANGED
|
@@ -18,18 +18,22 @@
|
|
|
18
18
|
# (Telegram draws them as its keyboard, two per row), and ChatPlayNote adds «Visto» or who answered. The header carries
|
|
19
19
|
# the app's back arrow and call buttons, and the composer is that app's own. With a skin it is a phone screen (300 × 600,
|
|
20
20
|
# a status bar with `clock`), and Instagram puts `photo` beside the client's last message of each turn.
|
|
21
|
+
# face: "" (the kit's two-dot face) | host - an empty face slot (.cx-cp-face-host) for the page's own face, a brand
|
|
22
|
+
# mascot say: the kit sends it cx-cp:think before each of the assistant's messages, cx-cp:answer when one lands and
|
|
23
|
+
# cx-cp:rest when the play leaves the screen. bot: out (default) | in - which side the assistant writes from.
|
|
21
24
|
# shadcn-for-muten · part · no deps · kit.css (.cx-cp).
|
|
22
|
-
part ChatPlay(title: text, status: text = "", placeholder: text = "", label: text = "", skin: text = "", theme: text = "", photo: text = "", clock: text = "9:41") {
|
|
25
|
+
part ChatPlay(title: text, status: text = "", placeholder: text = "", label: text = "", skin: text = "", theme: text = "", photo: text = "", clock: text = "9:41", face: text = "", bot: text = "out") {
|
|
23
26
|
# With a skin, the chat is a phone screen inside the library's one phone (the same iPhone as DeviceFrame), drawn at
|
|
24
27
|
# a real phone's 390 × 844 and scaled to the frame. Without one the wrappers do not count for layout.
|
|
25
28
|
Stack class("cx-cp-phone") data(skin: $skin) {
|
|
26
29
|
Stack class("cx-cp-frame") {
|
|
27
30
|
Stack class("cx-cp-screen") {
|
|
28
|
-
Stack class("cx-cp cx-play") data(skin: $skin, theme: $theme) style(face: "url('{$photo}')") aria(role: "img", label: $label) {
|
|
31
|
+
Stack class("cx-cp cx-play") data(skin: $skin, theme: $theme, bot: $bot) style(face: "url('{$photo}')") aria(role: "img", label: $label) {
|
|
29
32
|
Stack class("cx-cp-sb") aria(hidden: true) { Span "{$clock}" Stack class("cx-cp-sbi") { Icon "lucide:signal" Icon "lucide:wifi" Icon "lucide:battery-full" } }
|
|
30
33
|
Stack class("cx-cp-head") aria(hidden: true) {
|
|
31
34
|
Stack class("cx-cp-back") { Icon "lucide:chevron-left" }
|
|
32
|
-
when $photo == "" { Stack class("cx-cp-face") { Span "" Span "" } }
|
|
35
|
+
when $photo == "" and $face != "host" { Stack class("cx-cp-face") { Span "" Span "" } }
|
|
36
|
+
when $photo == "" and $face == "host" { Stack class("cx-cp-face cx-cp-face-host") }
|
|
33
37
|
when $photo != "" { Stack class("cx-cp-ring") { Image "{$photo}" alt("") class("cx-cp-photo") } }
|
|
34
38
|
Stack class("cx-cp-name") { Span "{$title}" class("cx-cp-t") Span "{$status}" class("cx-cp-s") }
|
|
35
39
|
Stack class("cx-cp-acts") { Icon "lucide:video" Icon "lucide:phone" Icon "lucide:ellipsis-vertical" }
|
package/registry/kit/core.js
CHANGED
|
@@ -76,6 +76,31 @@ const watchPlays = (root) => root.querySelectorAll?.('.cx-play:not([data-watched
|
|
|
76
76
|
new MutationObserver((records) => records.forEach((r) => r.addedNodes.forEach((n) => { if (n.nodeType === 1) { if (n.matches('.cx-play')) watchPlays(n.parentElement || document); else watchPlays(n); } }))).observe(document.body, { subtree: true, childList: true });
|
|
77
77
|
watchPlays(document);
|
|
78
78
|
|
|
79
|
+
// ── ChatPlay(face: "host"): the page's own face in the header acts along with the chat. On the conversation's own 12s
|
|
80
|
+
// clock (the steps' keyframes in kit.css: 8% 22% 36% 50% 59% 69%) the face slot gets cx-cp:think a moment before each of
|
|
81
|
+
// the assistant's messages, cx-cp:answer when it lands, and cx-cp:rest when the play leaves the screen ──
|
|
82
|
+
const CP_AT = [0, 0.96, 2.64, 4.32, 6, 7.08, 8.28];
|
|
83
|
+
const cpFace = (play) => {
|
|
84
|
+
const face = play.querySelector(':scope .cx-cp-face-host');
|
|
85
|
+
if (!face || play.dataset.faced !== undefined) return;
|
|
86
|
+
play.dataset.faced = '';
|
|
87
|
+
const bot = play.dataset.bot || 'out';
|
|
88
|
+
const steps = [...new Set([...play.querySelectorAll('.cx-cp-msg')].filter((m) => (m.dataset.who || 'out') === bot).map((m) => +m.dataset.step))];
|
|
89
|
+
const send = (type) => face.dispatchEvent(new CustomEvent('cx-cp:' + type));
|
|
90
|
+
let timers = [];
|
|
91
|
+
const stop = () => { timers.forEach(clearTimeout); timers = []; };
|
|
92
|
+
const run = () => {
|
|
93
|
+
stop();
|
|
94
|
+
steps.forEach((s) => { const at = (CP_AT[s] || 0) * 1000; timers.push(setTimeout(() => send('think'), Math.max(0, at - 1300)), setTimeout(() => send('answer'), at)); });
|
|
95
|
+
timers.push(setTimeout(run, 12000));
|
|
96
|
+
};
|
|
97
|
+
new MutationObserver(() => { if (play.dataset.in !== undefined) run(); else { stop(); send('rest'); } }).observe(play, { attributes: true, attributeFilter: ['data-in'] });
|
|
98
|
+
if (play.dataset.in !== undefined) run();
|
|
99
|
+
};
|
|
100
|
+
const cpFaces = (root) => root.querySelectorAll?.('.cx-cp.cx-play').forEach(cpFace);
|
|
101
|
+
new MutationObserver((records) => records.forEach((r) => r.addedNodes.forEach((n) => { if (n.nodeType === 1) cpFaces(n.parentElement || document); }))).observe(document.body, { subtree: true, childList: true });
|
|
102
|
+
cpFaces(document);
|
|
103
|
+
|
|
79
104
|
// ── reveal-blur: under a .reveal-blur root, each block of the marketing system comes in from a blur as it enters the
|
|
80
105
|
// screen. What is on screen when it is found shows at once (never half-faded at the fold, never holding the first
|
|
81
106
|
// paint); without this script, or with reduced motion, nothing is ever hidden. Siblings enter a beat apart. ──
|
|
@@ -160,6 +185,8 @@ watchReveal();
|
|
|
160
185
|
const cur = tabs.find((t) => t.getAttribute('aria-selected') === 'true') || tabs[0];
|
|
161
186
|
if (!animate) ind.style.transition = 'none';
|
|
162
187
|
ind.style.left = cur.offsetLeft + 'px'; ind.style.width = cur.offsetWidth + 'px';
|
|
188
|
+
// the pill takes the tab's own height, so a large SegmentedControl (52px) is not left with a 28px pill at its top
|
|
189
|
+
if (!list.classList.contains('line')) { ind.style.top = cur.offsetTop + 'px'; ind.style.height = cur.offsetHeight + 'px'; }
|
|
163
190
|
if (!animate) { ind.offsetWidth; ind.style.transition = ''; }
|
|
164
191
|
};
|
|
165
192
|
const pick = (tab, focus) => {
|
|
@@ -12,6 +12,8 @@ export function mount(el) {
|
|
|
12
12
|
ind.style.opacity = '';
|
|
13
13
|
if (first) ind.style.transition = 'none';
|
|
14
14
|
ind.style.left = t.offsetLeft + 'px'; ind.style.width = t.offsetWidth + 'px';
|
|
15
|
+
// the pill takes the tab's own height, so a large SegmentedControl (52px) is not left with a 28px pill at its top
|
|
16
|
+
if (!list.classList.contains('line')) { ind.style.top = t.offsetTop + 'px'; ind.style.height = t.offsetHeight + 'px'; }
|
|
15
17
|
if (first) { void ind.offsetWidth; ind.style.transition = ''; first = false; }
|
|
16
18
|
tabs().forEach((x) => { x.tabIndex = x === t ? 0 : -1; });
|
|
17
19
|
if (list.scrollWidth > list.clientWidth) t.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' });
|
package/registry.json
CHANGED
|
@@ -40,22 +40,6 @@
|
|
|
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
|
-
},
|
|
59
43
|
{
|
|
60
44
|
"name": "attachment",
|
|
61
45
|
"deps": [],
|
|
@@ -571,14 +555,6 @@
|
|
|
571
555
|
"file": "registry/marketing.muten",
|
|
572
556
|
"description": "Marketing - the pieces a site's pages are built from (the cx-mk-* system in kit.css): a section with its title, the"
|
|
573
557
|
},
|
|
574
|
-
{
|
|
575
|
-
"name": "mascot",
|
|
576
|
-
"deps": [],
|
|
577
|
-
"part": "Mascot",
|
|
578
|
-
"file": "registry/mascot.muten",
|
|
579
|
-
"component": "Mascot",
|
|
580
|
-
"description": "Mascot - the brand's mascot (in Storio, Storio): an AssistantOrb that acts. Its gestures are hand-set keyframes that"
|
|
581
|
-
},
|
|
582
558
|
{
|
|
583
559
|
"name": "media-query",
|
|
584
560
|
"deps": [],
|
|
@@ -1,198 +0,0 @@
|
|
|
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
|
-
// `orb:face` lets a page (Mascot) paint the face itself, worn on the sphere's surface, with a blush in its shader.
|
|
5
|
-
const VERT = 'attribute vec2 p;void main(){gl_Position=vec4(p,0.,1.);}';
|
|
6
|
-
const FRAG = `precision highp float;
|
|
7
|
-
uniform vec2 res;uniform float t;uniform float e;uniform vec2 look;uniform vec2 bl;uniform vec2 co;uniform sampler2D face;uniform float hasFace;
|
|
8
|
-
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));}
|
|
9
|
-
float n3(vec3 x){vec3 i=floor(x),f=fract(x);f=f*f*(3.-2.*f);
|
|
10
|
-
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),
|
|
11
|
-
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);}
|
|
12
|
-
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;}
|
|
13
|
-
void main(){
|
|
14
|
-
vec2 uv=(gl_FragCoord.xy-.5*res)/(.5*res.y);
|
|
15
|
-
float R=.66; float r=length(uv)/R;
|
|
16
|
-
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);
|
|
17
|
-
vec4 outc=vec4(0.);
|
|
18
|
-
float halo=exp(-max(r-1.,0.)*5.5)*(.38+.25*e);
|
|
19
|
-
vec3 hc=mix(brand,mint,e*.6);
|
|
20
|
-
outc=vec4(hc*halo,halo);
|
|
21
|
-
if(r<1.){
|
|
22
|
-
vec3 n=vec3(uv/R,sqrt(max(1.-r*r,0.)));
|
|
23
|
-
n.xy+=look*.12; n=normalize(n);
|
|
24
|
-
float sp=.10+.22*e;
|
|
25
|
-
vec3 q=n*1.05+vec3(0.,0.,t*sp);
|
|
26
|
-
float w=fbm(q+vec3(t*.05,-t*.04,0.));
|
|
27
|
-
float f=fbm(q*1.1+w*1.6);
|
|
28
|
-
vec3 col=mix(deep,brand,smoothstep(.2,.6,f));
|
|
29
|
-
col=mix(col,lav,smoothstep(.55,.85,f)*.75);
|
|
30
|
-
col=mix(col,pink,smoothstep(.45,.85,fbm(q*.8+5.))*.45*(.5+.5*n.x));
|
|
31
|
-
col=mix(col,cyan,smoothstep(.6,.9,fbm(q*.9+11.))*.35*(.5-.5*n.x));
|
|
32
|
-
col=mix(col,lav,.18*(1.-n.y)*.5);
|
|
33
|
-
vec3 L=normalize(vec3(-.8,.86,.08));
|
|
34
|
-
float dif=.72+.28*max(dot(n,L),0.);
|
|
35
|
-
float rv=max(dot(reflect(-L,n),vec3(0.,0.,1.)),0.);
|
|
36
|
-
float spec=pow(rv,70.)*.85+pow(rv,6.)*.10;
|
|
37
|
-
float fres=pow(1.-n.z,2.4);
|
|
38
|
-
vec3 rim=mix(pink,cyan,.5+.5*sin(t*.6+n.y*3.+n.x*2.));
|
|
39
|
-
// THE FACE ON THE SURFACE (opt-in, orb:face): the blush is painted on the sphere's normal, so it curves and
|
|
40
|
-
// foreshortens toward the rim, warm at its core; the eyes come as a texture wrapped by longitude and latitude, so
|
|
41
|
-
// they curve, turn with the face (co) and take the same light and highlight as the body.
|
|
42
|
-
vec2 cq=n.xy-co;
|
|
43
|
-
float dL=length((cq-vec2(-.56,-.19))*vec2(1.,1.5)), dR=length((cq-vec2(.56,-.19))*vec2(1.,1.5));
|
|
44
|
-
float bm=clamp(pow(1.-smoothstep(0.,.34,dL),1.35)*bl.x+pow(1.-smoothstep(0.,.34,dR),1.35)*bl.y,0.,1.);
|
|
45
|
-
vec3 bcol=mix(vec3(1.,.38,.70),vec3(1.,.62,.76),smoothstep(.0,.7,1.-bm));
|
|
46
|
-
col=mix(col,bcol,bm*.92);
|
|
47
|
-
col=col*dif+spec+fres*rim*.65;
|
|
48
|
-
col+=bcol*bm*.18;
|
|
49
|
-
if(hasFace>.5){
|
|
50
|
-
float lon=atan(n.x,n.z)-co.x*1.15, lat=asin(clamp(n.y,-1.,1.))-co.y*1.15;
|
|
51
|
-
vec2 fuv=vec2(lon,-lat)*.33+.5;
|
|
52
|
-
vec4 fc=texture2D(face,fuv);
|
|
53
|
-
float fa=fc.a*step(0.,fuv.x)*step(fuv.x,1.)*step(0.,fuv.y)*step(fuv.y,1.);
|
|
54
|
-
col=mix(col,fc.rgb*(.84+.16*max(dot(n,L),0.))+vec3(spec*.7),fa*(1.-smoothstep(.86,.99,r)*.35));
|
|
55
|
-
}
|
|
56
|
-
col+=vec3(.06,.04,.12)*(1.-fres);
|
|
57
|
-
col+=e*mint*fres*.7+e*.08;
|
|
58
|
-
float a=1.-smoothstep(.985,1.,r);
|
|
59
|
-
outc=mix(outc,vec4(col,1.)*a+outc*(1.-a),a);
|
|
60
|
-
}
|
|
61
|
-
gl_FragColor=outc;
|
|
62
|
-
}`;
|
|
63
|
-
const NS = 'http://www.w3.org/2000/svg';
|
|
64
|
-
const clamp = (v) => Math.max(0, Math.min(1, v));
|
|
65
|
-
const spring = (k, d) => ({ x: 0, v: 0, to: 0, k, d });
|
|
66
|
-
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; };
|
|
67
|
-
// an eye: a capsule whose underside lifts into a smile as `hp` goes 0 → 1; `open` 0 → 1 is the blink
|
|
68
|
-
const eyePath = (cx, hp, open) => {
|
|
69
|
-
const w = 26, hgt = 46 * Math.max(open, 0.08), r = w / 2, top = -hgt / 2, bot = hgt / 2;
|
|
70
|
-
const y1 = bot - r + (top + r - (bot - r)) * hp * 0.62;
|
|
71
|
-
const k = r * 1.333 + (-(hgt * 0.34) - r * 1.333) * hp;
|
|
72
|
-
const x0 = cx - r, x1 = cx + r;
|
|
73
|
-
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`;
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
export function mount(el, inputs) {
|
|
77
|
-
const size = Math.max(80, Number(inputs.size) || 240);
|
|
78
|
-
// where its eyes rest when nothing else asks for them (look "left" | "right"): it still glances around that point
|
|
79
|
-
const rest = inputs.look === 'left' ? { x: -1, y: -0.12 } : inputs.look === 'right' ? { x: 1, y: -0.12 } : null;
|
|
80
|
-
const still = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
81
|
-
el.style.setProperty('--orb', size + 'px');
|
|
82
|
-
el.setAttribute('aria-hidden', 'true');
|
|
83
|
-
// the sphere without WebGL (and under the canvas while it starts)
|
|
84
|
-
const base = document.createElement('span'); base.className = 'cx-orb-base';
|
|
85
|
-
const canvas = document.createElement('canvas'); canvas.className = 'cx-orb-canvas';
|
|
86
|
-
const dpr = Math.min(2, window.devicePixelRatio || 1); canvas.width = canvas.height = Math.round(size * dpr);
|
|
87
|
-
const svg = document.createElementNS(NS, 'svg'); svg.setAttribute('class', 'cx-orb-eyes'); svg.setAttribute('viewBox', '-60 -40 120 80');
|
|
88
|
-
const eyes = document.createElementNS(NS, 'g'), cheeks = document.createElementNS(NS, 'g');
|
|
89
|
-
const eyeL = document.createElementNS(NS, 'path'), eyeR = document.createElementNS(NS, 'path');
|
|
90
|
-
eyes.append(eyeL, eyeR);
|
|
91
|
-
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); }
|
|
92
|
-
cheeks.setAttribute('class', 'cx-orb-cheeks'); cheeks.setAttribute('opacity', '0');
|
|
93
|
-
svg.append(eyes, cheeks);
|
|
94
|
-
el.append(base, canvas, svg);
|
|
95
|
-
|
|
96
|
-
// the shader
|
|
97
|
-
const gl = canvas.getContext('webgl', { premultipliedAlpha: true, antialias: true });
|
|
98
|
-
let prog = null, uT, uE, uLook, uRes, uBl, uCo, uFace, uHas, ftex;
|
|
99
|
-
// the face on the surface (see orb:face): the page paints it flat into this canvas (a 220-unit box centred on the
|
|
100
|
-
// sphere, whose radius is 72.6 of them) and the shader wraps it; the blush and the face's turn are uniforms
|
|
101
|
-
const TEX = size > 300 ? 512 : 256, fcv = document.createElement('canvas'); fcv.width = fcv.height = TEX;
|
|
102
|
-
const fctx = fcv.getContext('2d');
|
|
103
|
-
let facePaint = null, blush = [0, 0], turn = [0, 0];
|
|
104
|
-
if (gl) {
|
|
105
|
-
const sh = (type, src) => { const o = gl.createShader(type); gl.shaderSource(o, src); gl.compileShader(o); return o; };
|
|
106
|
-
prog = gl.createProgram(); gl.attachShader(prog, sh(gl.VERTEX_SHADER, VERT)); gl.attachShader(prog, sh(gl.FRAGMENT_SHADER, FRAG)); gl.linkProgram(prog);
|
|
107
|
-
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) prog = null;
|
|
108
|
-
if (prog) {
|
|
109
|
-
gl.useProgram(prog);
|
|
110
|
-
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
|
|
111
|
-
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
|
|
112
|
-
const loc = gl.getAttribLocation(prog, 'p'); gl.enableVertexAttribArray(loc); gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
|
|
113
|
-
uT = gl.getUniformLocation(prog, 't'); uE = gl.getUniformLocation(prog, 'e'); uLook = gl.getUniformLocation(prog, 'look'); uRes = gl.getUniformLocation(prog, 'res');
|
|
114
|
-
uBl = gl.getUniformLocation(prog, 'bl'); uCo = gl.getUniformLocation(prog, 'co'); uFace = gl.getUniformLocation(prog, 'face'); uHas = gl.getUniformLocation(prog, 'hasFace');
|
|
115
|
-
ftex = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, ftex);
|
|
116
|
-
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
|
117
|
-
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
118
|
-
gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
|
|
119
|
-
el.dataset.gl = 'on';
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// springs: critically damped, a little bounce where it is earned
|
|
124
|
-
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);
|
|
125
|
-
blink.x = blink.to = 1;
|
|
126
|
-
let look = null, pointer = null, gaze = rest ? { ...rest } : { x: 0, y: 0 };
|
|
127
|
-
el.addEventListener('orb:mood', (ev) => {
|
|
128
|
-
const d = ev.detail || {};
|
|
129
|
-
if (typeof d.happy === 'number') happy.to = clamp(d.happy);
|
|
130
|
-
if (typeof d.energy === 'number') energy.to = clamp(d.energy);
|
|
131
|
-
if ('look' in d) look = d.look;
|
|
132
|
-
if (d.bump) squash.v += 7;
|
|
133
|
-
if (still) { happy.x = happy.to; energy.x = energy.to; draw(0, 0); }
|
|
134
|
-
});
|
|
135
|
-
// orb:face hands the face over to the page (Mascot): { paint: (ctx) => … | null, blush: [left, right], turn: [x, y] }.
|
|
136
|
-
// With a paint the sphere's own eyes step aside and the painted face is worn on the surface instead.
|
|
137
|
-
el.addEventListener('orb:face', (ev) => {
|
|
138
|
-
const d = ev.detail || {};
|
|
139
|
-
if ('paint' in d) { facePaint = prog ? d.paint : null; el.dataset.face = facePaint ? 'surface' : ''; }
|
|
140
|
-
if (d.blush) blush = d.blush;
|
|
141
|
-
if (d.turn) turn = d.turn;
|
|
142
|
-
if (still) draw(6, 0);
|
|
143
|
-
});
|
|
144
|
-
const area = el.closest('[data-area="orb"]') || el.parentElement;
|
|
145
|
-
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 }; };
|
|
146
|
-
const onLeave = () => { pointer = null; };
|
|
147
|
-
if (area && !still) { area.addEventListener('pointermove', onMove); area.addEventListener('pointerleave', onLeave); }
|
|
148
|
-
|
|
149
|
-
let T = 0, nextBlink = 1.2, nextSaccade = 0.6;
|
|
150
|
-
function draw(t, breatheT) {
|
|
151
|
-
const breathe = 1 + Math.sin(breatheT * 1.6) * 0.012, sq = squash.x * 0.06;
|
|
152
|
-
el.style.setProperty('--orb-x', (gx.x * 6).toFixed(2) + 'px');
|
|
153
|
-
el.style.setProperty('--orb-y', (gy.x * 4 + Math.sin(breatheT * 1.1) * 6).toFixed(2) + 'px');
|
|
154
|
-
el.style.setProperty('--orb-sx', (breathe + sq).toFixed(4)); el.style.setProperty('--orb-sy', (breathe - sq).toFixed(4));
|
|
155
|
-
if (prog) {
|
|
156
|
-
gl.viewport(0, 0, canvas.width, canvas.height); gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT);
|
|
157
|
-
gl.uniform2f(uRes, canvas.width, canvas.height); gl.uniform1f(uT, t); gl.uniform1f(uE, clamp(energy.x)); gl.uniform2f(uLook, gx.x, -gy.x);
|
|
158
|
-
gl.uniform2f(uBl, blush[0], blush[1]); gl.uniform2f(uCo, turn[0], turn[1]);
|
|
159
|
-
if (facePaint) {
|
|
160
|
-
fctx.setTransform(1, 0, 0, 1, 0, 0); fctx.clearRect(0, 0, TEX, TEX);
|
|
161
|
-
fctx.setTransform(TEX / 220, 0, 0, TEX / 220, TEX / 2, TEX / 2); facePaint(fctx);
|
|
162
|
-
gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, ftex);
|
|
163
|
-
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, fcv);
|
|
164
|
-
}
|
|
165
|
-
gl.uniform1i(uFace, 0); gl.uniform1f(uHas, facePaint ? 1 : 0);
|
|
166
|
-
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
|
167
|
-
}
|
|
168
|
-
const hp = clamp(happy.x), open = clamp(blink.x);
|
|
169
|
-
eyeL.setAttribute('d', eyePath(-24, hp, open)); eyeR.setAttribute('d', eyePath(24, hp, open));
|
|
170
|
-
eyes.setAttribute('transform', `translate(${(gx.x * 14).toFixed(2)} ${(gy.x * 9).toFixed(2)})`);
|
|
171
|
-
cheeks.setAttribute('opacity', (hp * 0.8).toFixed(3));
|
|
172
|
-
cheeks.setAttribute('transform', `translate(${(gx.x * 9).toFixed(2)} ${(gy.x * 6).toFixed(2)})`);
|
|
173
|
-
}
|
|
174
|
-
if (still) { if (rest) { gx.x = rest.x; gy.x = rest.y; } draw(6, 0); return () => {}; }
|
|
175
|
-
|
|
176
|
-
// the loop runs only while the orb is on screen and the tab is visible
|
|
177
|
-
let raf = 0, last = 0, visible = true;
|
|
178
|
-
const frame = (now) => {
|
|
179
|
-
const dt = Math.min((now - (last || now)) / 1000, 1 / 30); last = now; T += dt;
|
|
180
|
-
if (pointer) gaze = { x: Math.max(-1, Math.min(1, pointer.x)), y: Math.max(-1, Math.min(1, pointer.y)) };
|
|
181
|
-
else if (look) gaze = look;
|
|
182
|
-
else if (T > nextSaccade) {
|
|
183
|
-
gaze = rest ? { x: rest.x + (Math.random() - 0.5) * 0.3, y: rest.y + (Math.random() - 0.5) * 0.3 } : { x: (Math.random() - 0.5) * 1.1, y: (Math.random() - 0.6) * 0.7 };
|
|
184
|
-
nextSaccade = T + 1.1 + Math.random() * 1.8;
|
|
185
|
-
}
|
|
186
|
-
gx.to = gaze.x; gy.to = gaze.y;
|
|
187
|
-
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); }
|
|
188
|
-
[gx, gy, happy, blink, squash, energy].forEach((s) => step(s, dt));
|
|
189
|
-
blink.to = 1;
|
|
190
|
-
draw(T, T);
|
|
191
|
-
raf = visible && !document.hidden ? requestAnimationFrame(frame) : 0;
|
|
192
|
-
};
|
|
193
|
-
const wake = () => { if (!raf && visible && !document.hidden) { last = 0; raf = requestAnimationFrame(frame); } };
|
|
194
|
-
new IntersectionObserver((es) => { visible = es.some((e) => e.isIntersecting); wake(); }).observe(el);
|
|
195
|
-
document.addEventListener('visibilitychange', wake);
|
|
196
|
-
wake();
|
|
197
|
-
return () => {};
|
|
198
|
-
}
|
|
@@ -1,14 +0,0 @@
|
|
|
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
|
-
# look: "" | left | right - where its eyes rest when idle (toward the form beside it, say); it still glances around
|
|
10
|
-
# that point, blinks, and follows the pointer.
|
|
11
|
-
# size: the sphere's box in px (the sphere and its halo fill it). The pointer area is the closest element with
|
|
12
|
-
# data-area="orb", else its parent.
|
|
13
|
-
# shadcn-for-muten · Custom · kit.css (.cx-orb) · host JS assistant-orb.js.
|
|
14
|
-
part AssistantOrb(size: text = "240", look: text = "") { Custom AssistantOrb inputs(size: $size, look: $look) class("cx-orb") }
|
|
@@ -1,41 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,41 +0,0 @@
|
|
|
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
|
-
}
|
package/registry/mascot.js
DELETED
|
@@ -1,291 +0,0 @@
|
|
|
1
|
-
// Mascot (muten Custom) - the brand's mascot: an AssistantOrb that acts. Its gestures are hand-set keyframes (the
|
|
2
|
-
// principles of character animation: anticipation, squash and stretch that keeps the volume, arcs, overlap, a shadow
|
|
3
|
-
// that tells the height) and its face is worn on the sphere's surface (the orb's `orb:face`), so the eyes and the
|
|
4
|
-
// blush curve with the body and take its light. See mascot.muten.
|
|
5
|
-
const E = {
|
|
6
|
-
lin: (u) => u,
|
|
7
|
-
io: (u) => (u < 0.5 ? 4 * u * u * u : 1 - Math.pow(-2 * u + 2, 3) / 2),
|
|
8
|
-
o: (u) => 1 - Math.pow(1 - u, 3),
|
|
9
|
-
i: (u) => u * u * u,
|
|
10
|
-
bk: (u) => { const c = 1.9; return 1 + (c + 1) * Math.pow(u - 1, 3) + c * Math.pow(u - 1, 2); },
|
|
11
|
-
};
|
|
12
|
-
// a track is [[time 0..1, value, easing into this key], …]
|
|
13
|
-
function at(keys, t, def) {
|
|
14
|
-
if (!keys) return def;
|
|
15
|
-
if (t <= keys[0][0]) return keys[0][1];
|
|
16
|
-
for (let k = 1; k < keys.length; k++) {
|
|
17
|
-
const a = keys[k - 1], b = keys[k];
|
|
18
|
-
if (t <= b[0]) return a[1] + (b[1] - a[1]) * E[b[2] || 'io']((t - a[0]) / (b[0] - a[0] || 1));
|
|
19
|
-
}
|
|
20
|
-
return keys[keys.length - 1][1];
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
// THE GESTURES. d: seconds · energy: how fast the light inside flows · key: the pose shown with reduced motion ·
|
|
24
|
-
// hold: the stretch a held mood loops in (shy, thinking); a gesture without hold plays once and settles into idle.
|
|
25
|
-
const G = {
|
|
26
|
-
idle: { d: 3.6, energy: 0.15, key: 0.2, loop: true, t: {
|
|
27
|
-
sy: [[0, 1], [.25, 1.025], [.5, 1], [.75, 1.025], [1, 1]],
|
|
28
|
-
lookX: [[0, -.8], [.3, -.8], [.33, .55, 'o'], [.62, .55], [.65, -.8, 'o'], [1, -.8]],
|
|
29
|
-
lookY: [[0, 0], [.33, -.1, 'o'], [.62, -.1], [.65, 0, 'o'], [1, 0]],
|
|
30
|
-
x: [[0, -1.5], [.34, -1.5], [.42, 1.8], [.65, 1.8], [.73, -1.5], [1, -1.5]],
|
|
31
|
-
open: [[0, 1], [.46, 1], [.485, .05, 'i'], [.52, 1, 'o'], [.86, 1], [.875, .05, 'i'], [.9, 1, 'o'], [1, 1]] } },
|
|
32
|
-
happy: { d: 2.4, energy: 0.6, key: 0.45, t: {
|
|
33
|
-
y: [[0, 0], [.12, 4], [.32, -24, 'o'], [.48, -26], [.66, 0, 'i'], [.77, -5, 'o'], [.87, 0, 'i'], [1, 0]],
|
|
34
|
-
sy: [[0, 1], [.12, .88], [.22, 1.12, 'o'], [.42, 1.02], [.64, 1.06, 'i'], [.69, .86, 'o'], [.79, 1.04], [.9, .98], [1, 1]],
|
|
35
|
-
open: [[0, 1], [.11, 1], [.15, .1, 'i'], [.86, .1], [.92, 1, 'o'], [1, 1]],
|
|
36
|
-
smile: [[0, 0], [.14, 0], [.17, 1, 'o'], [.85, 1], [.88, 0, 'i'], [1, 0]],
|
|
37
|
-
cheek: [[0, 0], [.18, 0], [.3, .9, 'o'], [.84, .9], [1, 0]],
|
|
38
|
-
spark: [[0, 0], [.3, 0], [.42, 1, 'bk'], [.62, 0, 'i'], [1, 0]] } },
|
|
39
|
-
shy: { d: 3.4, energy: 0.2, key: 0.55, hold: [.3, .86], t: {
|
|
40
|
-
lookX: [[0, 0], [.1, -.6, 'o'], [.9, -.6], [1, 0]], lookY: [[0, 0], [.1, .7, 'o'], [.9, .7], [1, 0]],
|
|
41
|
-
open: [[0, 1], [.18, 1], [.25, .08, 'i'], [.9, .08], [1, 1]],
|
|
42
|
-
shy: [[0, 0], [.23, 0], [.27, 1, 'o'], [.88, 1], [.92, 0, 'i'], [1, 0]],
|
|
43
|
-
cheek: [[0, 0], [.22, 0], [.42, 1], [.6, .78], [.76, 1], [.9, 1], [1, 0]],
|
|
44
|
-
rot: [[0, 0], [.25, -8], [.55, -5], [.8, -8], [1, 0]],
|
|
45
|
-
y: [[0, 0], [.25, 6], [.9, 6], [1, 0]],
|
|
46
|
-
s: [[0, 1], [.25, .93], [.9, .93], [1, 1]] } },
|
|
47
|
-
surprised: { d: 2.4, energy: 1, key: 0.24, t: {
|
|
48
|
-
y: [[0, 0], [.1, 4], [.2, -18, 'o'], [.36, -13], [.56, 0], [1, 0]],
|
|
49
|
-
sy: [[0, 1], [.1, .92], [.2, 1.17, 'o'], [.3, .95], [.4, 1.05], [.5, .99], [.6, 1], [1, 1]],
|
|
50
|
-
grow: [[0, 0], [.12, 0], [.2, 1, 'bk'], [.7, 1], [.84, 0], [1, 0]],
|
|
51
|
-
open: [[0, 1], [.1, .55, 'i'], [.18, 1, 'o'], [.72, 1], [.745, .05, 'i'], [.78, 1, 'o'], [1, 1]],
|
|
52
|
-
lookY: [[0, 0], [.2, -.35, 'o'], [.7, -.35], [.85, 0], [1, 0]],
|
|
53
|
-
pop: [[0, 0], [.17, 0], [.24, 1, 'o'], [.46, 0, 'i'], [1, 0]] } },
|
|
54
|
-
thinking: { d: 3.8, energy: 0.05, key: 0.5, hold: [.2, .84], t: {
|
|
55
|
-
lookX: [[0, 0], [.12, .7], [.9, .7], [1, 0]], lookY: [[0, 0], [.12, -.85], [.9, -.85], [1, 0]],
|
|
56
|
-
rBot: [[0, 0], [.16, .42], [.86, .42], [1, 0]], lBot: [[0, 0], [.16, .16], [.86, .16], [1, 0]],
|
|
57
|
-
rot: [[0, 0], [.2, 7], [.5, 4.5], [.8, 7], [1, 0]],
|
|
58
|
-
x: [[0, 0], [.2, 3], [.8, 3], [1, 0]],
|
|
59
|
-
dots: [[0, 0], [.16, 0], [.72, 1, 'lin'], [.9, 1], [.97, 0], [1, 0]] } },
|
|
60
|
-
wink: { d: 2.2, energy: 0.4, key: 0.45, t: {
|
|
61
|
-
rOpen: [[0, 1], [.18, 1], [.23, .08, 'i'], [.7, .08], [.78, 1, 'o'], [1, 1]],
|
|
62
|
-
rSmile: [[0, 0], [.22, 0], [.25, 1, 'o'], [.7, 1], [.73, 0, 'i'], [1, 0]],
|
|
63
|
-
rot: [[0, 0], [.15, -3], [.27, 8, 'bk'], [.7, 8], [.86, 0], [1, 0]],
|
|
64
|
-
sy: [[0, 1], [.16, .95], [.27, 1.04, 'o'], [.4, 1], [1, 1]],
|
|
65
|
-
rCheek: [[0, 0], [.25, 0], [.32, .9, 'o'], [.72, .9], [.86, 0], [1, 0]],
|
|
66
|
-
wspark: [[0, 0], [.25, 0], [.33, 1, 'bk'], [.56, 0, 'i'], [1, 0]],
|
|
67
|
-
lookX: [[0, 0], [.2, .25], [.8, .25], [1, 0]] } },
|
|
68
|
-
celebrate: { d: 2.6, energy: 1, key: 0.5, t: {
|
|
69
|
-
y: [[0, 0], [.14, 8], [.42, -60, 'o'], [.5, -62], [.7, 0, 'i'], [.78, -7, 'o'], [.86, 0, 'i'], [1, 0]],
|
|
70
|
-
sy: [[0, 1], [.14, .8], [.24, 1.2, 'o'], [.4, 1], [.66, 1.12, 'i'], [.71, .8, 'o'], [.8, 1.05], [.9, .98], [1, 1]],
|
|
71
|
-
rot: [[0, 0], [.16, 0], [.44, 360, 'o'], [1, 360]],
|
|
72
|
-
open: [[0, 1], [.1, 1], [.14, .1, 'i'], [.9, .1], [.95, 1, 'o'], [1, 1]],
|
|
73
|
-
smile: [[0, 0], [.13, 0], [.16, 1, 'o'], [.9, 1], [.93, 0, 'i'], [1, 0]],
|
|
74
|
-
cheek: [[0, 0], [.2, 1], [.9, 1], [1, 0]],
|
|
75
|
-
burst: [[0, 0], [.4, 0], [.5, 1, 'o'], [.78, 0, 'i'], [1, 0]] } },
|
|
76
|
-
oops: { d: 3.0, energy: 0.05, key: 0.5, t: {
|
|
77
|
-
lLid: [[0, 0], [.15, .34], [.85, .34], [1, 0]], rLid: [[0, 0], [.15, .34], [.85, .34], [1, 0]], droop: [[0, 0], [.15, 9], [.85, 9], [1, 0]],
|
|
78
|
-
lookY: [[0, 0], [.15, .6], [.85, .6], [1, 0]],
|
|
79
|
-
y: [[0, 0], [.15, 6], [.85, 6], [1, 0]],
|
|
80
|
-
sy: [[0, 1], [.15, .94], [.85, .94], [1, 1]],
|
|
81
|
-
x: [[0, 0], [.3, 0], [.36, -6], [.42, 5], [.48, -3.5], [.54, 2], [.6, 0], [1, 0]],
|
|
82
|
-
drop: [[0, 0], [.2, 0], [.3, 1, 'o'], [.8, 1], [.9, 0], [1, 0]],
|
|
83
|
-
dropY: [[0, 0], [.3, 0], [.85, 1], [1, 1]] } },
|
|
84
|
-
};
|
|
85
|
-
|
|
86
|
-
// THE FACE, painted in a 220-unit box centred on the sphere (radius 72.6 of them). The eyes change shape only through
|
|
87
|
-
// a blink (the expression swap of animation): a capsule, a soft happy dome, a calm closed curve; the lids are cuts,
|
|
88
|
-
// the top one droops toward each eye's OUTER corner when sad (mirrored), the lower one rises in a squint.
|
|
89
|
-
const EX = 22, EY = -6, W = 17, H = 30;
|
|
90
|
-
function paintEye(ctx, cx, o) {
|
|
91
|
-
const w = W * (1 + 0.2 * o.grow), h = H * (1 + 0.32 * o.grow), x = cx, y = EY;
|
|
92
|
-
const cap = Math.max(0, 1 - o.smile - o.shy);
|
|
93
|
-
ctx.fillStyle = '#fff'; ctx.strokeStyle = '#fff'; ctx.lineCap = 'round';
|
|
94
|
-
if (cap > 0.01) {
|
|
95
|
-
const hh = Math.max(3, h * o.open), top = y - hh / 2, bot = y + hh / 2;
|
|
96
|
-
const tl = top + hh * o.lid, bl = bot - hh * o.bot, outer = cx < 0 ? -1 : 1;
|
|
97
|
-
ctx.save(); ctx.globalAlpha = cap;
|
|
98
|
-
ctx.beginPath(); ctx.moveTo(x - w, tl + (outer < 0 ? o.droop : 0)); ctx.lineTo(x + w, tl + (outer > 0 ? o.droop : 0)); ctx.lineTo(x + w, bl); ctx.lineTo(x - w, bl); ctx.closePath(); ctx.clip();
|
|
99
|
-
ctx.beginPath(); ctx.roundRect(x - w / 2, top, w, hh, Math.min(w / 2, hh / 2)); ctx.fill();
|
|
100
|
-
ctx.restore();
|
|
101
|
-
}
|
|
102
|
-
if (o.smile > 0.01) { ctx.save(); ctx.globalAlpha = o.smile; ctx.lineWidth = 6; ctx.beginPath(); ctx.moveTo(x - w * .66, y + 4); ctx.bezierCurveTo(x - w * .5, y - 6.5, x + w * .5, y - 6.5, x + w * .66, y + 4); ctx.stroke(); ctx.restore(); }
|
|
103
|
-
if (o.shy > 0.01) { ctx.save(); ctx.globalAlpha = o.shy; ctx.lineWidth = 6; ctx.beginPath(); ctx.moveTo(x - w * .62, y - 1); ctx.quadraticCurveTo(x, y + 10, x + w * .62, y - 1); ctx.stroke(); ctx.restore(); }
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// the effects around it (they follow the body but never squash with it)
|
|
107
|
-
const star = (cx, cy, r, a) => `<path d="M${cx} ${cy - r} Q${cx + r * .18} ${cy - r * .18} ${cx + r} ${cy} Q${cx + r * .18} ${cy + r * .18} ${cx} ${cy + r} Q${cx - r * .18} ${cy + r * .18} ${cx - r} ${cy} Q${cx - r * .18} ${cy - r * .18} ${cx} ${cy - r}Z" fill="#fff" opacity="${a.toFixed(3)}"/>`;
|
|
108
|
-
function effects(p) {
|
|
109
|
-
let fx = '';
|
|
110
|
-
if (p.spark > 0.01) fx += star(62, -58, 10 * p.spark, p.spark) + star(-66, -44, 6 * p.spark, p.spark * .8);
|
|
111
|
-
if (p.wspark > 0.01) fx += star(48, -34, 9 * p.wspark, p.wspark);
|
|
112
|
-
if (p.pop > 0.01) for (let k = 0; k < 5; k++) { const a = (-150 + k * 30) * Math.PI / 180, r1 = 84 + 10 * p.pop, r2 = r1 + 14 * p.pop; fx += `<path d="M${(Math.cos(a) * r1).toFixed(1)} ${(Math.sin(a) * r1 - 4).toFixed(1)} L${(Math.cos(a) * r2).toFixed(1)} ${(Math.sin(a) * r2 - 4).toFixed(1)}" stroke="#fff" stroke-width="4" stroke-linecap="round" opacity="${p.pop.toFixed(3)}"/>`; }
|
|
113
|
-
if (p.dots > 0.01) [[58, -62, 4.5, .2], [72, -80, 6, .5], [90, -100, 8.5, .8]].forEach(([dx, dy, r, th]) => { const a = Math.min(1, Math.max(0, (p.dots - th) / .2)); if (a > 0) fx += `<circle cx="${dx}" cy="${(dy - 3 * a).toFixed(1)}" r="${(r * (0.6 + 0.4 * a)).toFixed(2)}" fill="#fff" opacity="${(a * .92).toFixed(3)}"/>`; });
|
|
114
|
-
if (p.burst > 0.01) for (let k = 0; k < 8; k++) { const a = k * Math.PI / 4 + .3, r = 70 + 46 * p.burst; fx += star(+(Math.cos(a) * r).toFixed(1), +(Math.sin(a) * r).toFixed(1), (k % 2 ? 6 : 9) * (1 - p.burst * .4), Math.min(1, p.burst * 1.6) * (1 - p.burst * .5)); }
|
|
115
|
-
if (p.drop > 0.01) { const dy = -26 + 34 * p.dropY, dx = 62 - 4 * p.dropY; fx += `<path d="M${dx} ${dy - 11} Q${dx + 7} ${dy - 1} ${dx + 6} ${dy + 4} A6 6 0 1 1 ${dx - 6} ${dy + 4} Q${dx - 7} ${dy - 1} ${dx} ${dy - 11}Z" fill="#BFE6FF" opacity="${(p.drop * .95).toFixed(3)}"/>`; }
|
|
116
|
-
return fx;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function pose(g, t, flip) {
|
|
120
|
-
const v = (k, d) => at(g.t[k], t, d);
|
|
121
|
-
const open = v('open', 1), smile = v('smile', 0), lookX = v('lookX', 0) * flip, lookY = v('lookY', 0), grow = v('grow', 0), shy = v('shy', 0);
|
|
122
|
-
const eye = (side) => ({ open: v(side + 'Open', open), smile: v(side + 'Smile', smile), shy, lid: v(side + 'Lid', 0), bot: v(side + 'Bot', 0), droop: v('droop', 0), grow });
|
|
123
|
-
return {
|
|
124
|
-
x: v('x', 0) * flip, y: v('y', 0), rot: v('rot', 0) * flip, sy: v('sy', 1), s: v('s', 1), lookX, lookY,
|
|
125
|
-
L: eye('l'), R: eye('r'), cheekL: v('cheek', 0), cheekR: Math.max(v('cheek', 0), v('rCheek', 0)),
|
|
126
|
-
spark: v('spark', 0), wspark: v('wspark', 0), pop: v('pop', 0), dots: v('dots', 0), burst: v('burst', 0), drop: v('drop', 0), dropY: v('dropY', 0),
|
|
127
|
-
};
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
// ── HOW IT SPEAKS. A line is a «paper» bubble (white, its name on top in violet, a drawn tail) that comes out of its
|
|
132
|
-
// side as in a comic: the tail's tip lands on the sphere's upper-left rim and the bubble sits up and to its left. It
|
|
133
|
-
// pops in like in games: laid out complete, born at the tail's tip, inflated past its size, springing back, settling;
|
|
134
|
-
// a new line first swells a hair and sucks back into the tail. It lives OUTSIDE the scaled box (so its text is never
|
|
135
|
-
// shrunk with the sphere), follows the body when it jumps and stays inside the panel that clips it. With `voice` on,
|
|
136
|
-
// a soft blip per syllable follows the sentence's tune (it only sounds after the person has touched the page).
|
|
137
|
-
const POP_IN = [
|
|
138
|
-
{ transform: 'scale(.06, .04)', opacity: 0, offset: 0 },
|
|
139
|
-
{ transform: 'scale(1.16, .88)', opacity: 1, offset: .34 },
|
|
140
|
-
{ transform: 'scale(.9, 1.1)', offset: .52 },
|
|
141
|
-
{ transform: 'scale(1.05, .96)', offset: .68 },
|
|
142
|
-
{ transform: 'scale(.98, 1.02)', offset: .84 },
|
|
143
|
-
{ transform: 'scale(1, 1)', opacity: 1, offset: 1 },
|
|
144
|
-
];
|
|
145
|
-
const POP_OUT = [{ transform: 'scale(1, 1)', opacity: 1, offset: 0 }, { transform: 'scale(1.06, 1.06)', opacity: 1, offset: .3 }, { transform: 'scale(.05, .05)', opacity: 0, offset: 1 }];
|
|
146
|
-
const TAIL = '<svg class="cx-mascot-tail" viewBox="0 0 24 16" aria-hidden="true"><path d="M0 0 H20 C18.5 5 19.5 10 23 15.5 C15 13.5 7 8 0 0 Z"/></svg>';
|
|
147
|
-
// the same tail turned on its side, for a bubble that sits beside the sphere (it leaves the bubble's edge, toward it)
|
|
148
|
-
const TAIL_SIDE = '<svg class="cx-mascot-tail-side" viewBox="0 0 16 24" aria-hidden="true"><path d="M0 0 V20 C5 18.5 10 19.5 15.5 23 C13.5 15 8 7 0 0 Z"/></svg>';
|
|
149
|
-
const VOWEL = { a: 1.12, e: 1.06, i: 1.0, o: 0.96, u: 0.92, á: 1.14, é: 1.08, í: 1.02, ó: 0.98, ú: 0.94 };
|
|
150
|
-
let audio = null, touched = false;
|
|
151
|
-
if (typeof document !== 'undefined') document.addEventListener('pointerdown', () => { touched = true; }, { once: true, capture: true });
|
|
152
|
-
function ctx() {
|
|
153
|
-
if (!touched) return null;
|
|
154
|
-
try { audio = audio || new (window.AudioContext || window.webkitAudioContext)(); if (audio.state === 'suspended') audio.resume(); } catch (_) { audio = null; }
|
|
155
|
-
return audio;
|
|
156
|
-
}
|
|
157
|
-
function blip(ac, freq, when, len, vol) {
|
|
158
|
-
const o = ac.createOscillator(), o2 = ac.createOscillator(), g = ac.createGain(), g2 = ac.createGain(), f = ac.createBiquadFilter();
|
|
159
|
-
o.type = 'triangle'; o2.type = 'sine'; f.type = 'lowpass'; f.frequency.value = 2400; g2.gain.value = 0.25;
|
|
160
|
-
o.frequency.setValueAtTime(freq, when); o.frequency.exponentialRampToValueAtTime(freq * 0.92, when + len); o2.frequency.setValueAtTime(freq * 2, when);
|
|
161
|
-
g.gain.setValueAtTime(0.0001, when); g.gain.exponentialRampToValueAtTime(vol, when + 0.012); g.gain.exponentialRampToValueAtTime(0.0001, when + len);
|
|
162
|
-
o.connect(g); o2.connect(g2); g2.connect(g); g.connect(f); f.connect(ac.destination);
|
|
163
|
-
o.start(when); o2.start(when); o.stop(when + len + 0.02); o2.stop(when + len + 0.02);
|
|
164
|
-
}
|
|
165
|
-
function speakAloud(text) {
|
|
166
|
-
const ac = ctx(); if (!ac) return;
|
|
167
|
-
const t0 = ac.currentTime, o = ac.createOscillator(), g = ac.createGain(); // the pop
|
|
168
|
-
o.type = 'sine'; o.frequency.setValueAtTime(520, t0); o.frequency.exponentialRampToValueAtTime(980, t0 + 0.07);
|
|
169
|
-
g.gain.setValueAtTime(0.0001, t0); g.gain.exponentialRampToValueAtTime(0.06, t0 + 0.01); g.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.09);
|
|
170
|
-
o.connect(g); g.connect(ac.destination); o.start(t0); o.stop(t0 + 0.1);
|
|
171
|
-
const question = /\?\s*$/.test(text), excl = /^¡/.test(text);
|
|
172
|
-
let at = t0 + 0.08, syll = 0;
|
|
173
|
-
for (let i = 0; i < text.length; i++) {
|
|
174
|
-
const ch = text[i], v = VOWEL[ch.toLowerCase()];
|
|
175
|
-
if (v) {
|
|
176
|
-
const pos = i / text.length;
|
|
177
|
-
let pitch = 300 * v * (excl && pos < .2 ? 1.18 : 1);
|
|
178
|
-
if (question && pos > .7) pitch *= 1 + (pos - .7) * 1.1; else if (!question && pos > .75) pitch *= 1 - (pos - .75) * .5;
|
|
179
|
-
blip(ac, pitch * (1 + Math.sin(syll++ * 1.7) * 0.05), at, 0.07, 0.05);
|
|
180
|
-
}
|
|
181
|
-
at += ',;'.includes(ch) ? 0.17 : '.!?…'.includes(ch) ? 0.3 : 0.034;
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
const clipOf = (el) => { for (let n = el.parentElement; n; n = n.parentElement) { const o = getComputedStyle(n).overflow; if (o !== 'visible') return n; } return document.documentElement; };
|
|
185
|
-
|
|
186
|
-
export function mount(el, inputs) {
|
|
187
|
-
const root = el.closest('.cx-mascot');
|
|
188
|
-
if (!root) return () => {};
|
|
189
|
-
const orb = root.querySelector('.cx-orb'), rig = root.querySelector('.cx-mascot-rig'), squash = root.querySelector('.cx-mascot-squash'), floor = root.querySelector('.cx-mascot-floor');
|
|
190
|
-
const NS = 'http://www.w3.org/2000/svg', fxSvg = document.createElementNS(NS, 'svg');
|
|
191
|
-
fxSvg.setAttribute('class', 'cx-mascot-fx'); fxSvg.setAttribute('viewBox', '-110 -110 220 220'); fxSvg.setAttribute('aria-hidden', 'true');
|
|
192
|
-
root.append(fxSvg);
|
|
193
|
-
const still = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
194
|
-
let flip = inputs.look === 'right' ? -1 : 1; // the gestures are drawn looking left (toward a form beside it)
|
|
195
|
-
// its bubble: outside the scaled box, in the positioned box that holds it
|
|
196
|
-
const host = root.offsetParent || root.parentElement, bub = document.createElement('div');
|
|
197
|
-
bub.className = 'cx-mascot-say'; bub.setAttribute('role', 'status'); bub.setAttribute('aria-live', 'polite');
|
|
198
|
-
bub.innerHTML = `<span class="cx-mascot-who"></span><span class="cx-mascot-line"></span>${TAIL}${TAIL_SIDE}`;
|
|
199
|
-
host.append(bub);
|
|
200
|
-
let saying = '', sayT = 0, sayN = 0, voiceOn = inputs.voice === 'on';
|
|
201
|
-
const placeBubble = () => {
|
|
202
|
-
if (!root.isConnected) { bub.remove(); removeEventListener('resize', placeBubble); return; }
|
|
203
|
-
if (!bub.classList.contains('on')) return;
|
|
204
|
-
const hb = host.getBoundingClientRect(), o = orb.getBoundingClientRect(), clip = clipOf(host).getBoundingClientRect();
|
|
205
|
-
const R = o.width * 0.33, cx = o.left + o.width / 2, cy = o.top + o.height / 2, w = bub.offsetWidth, h = bub.offsetHeight;
|
|
206
|
-
const k = hb.width / (host.offsetWidth || hb.width) || 1; // the host may be scaled: place in its own units
|
|
207
|
-
// where it speaks from, set by the page's CSS per screen: `side` (default) up and to the side, the tail's tip on the
|
|
208
|
-
// sphere's upper rim; `low` level with it, beside it, the tail on its side (when there is no room above it)
|
|
209
|
-
const at = getComputedStyle(root).getPropertyValue('--cx-mascot-say').trim(), low = at === 'low', top0 = at === 'top';
|
|
210
|
-
bub.dataset.side = flip > 0 ? 'left' : 'right'; bub.dataset.at = low ? 'low' : 'side';
|
|
211
|
-
let left, top, ox, oy;
|
|
212
|
-
if (low) {
|
|
213
|
-
const tipX = cx - R * 0.97 * flip, tipY = cy + R * 0.32, tail = Math.max(12, Math.min(h - 30, h * 0.42));
|
|
214
|
-
left = flip > 0 ? tipX - 15.5 - w : tipX + 15.5;
|
|
215
|
-
top = tipY - 23 - tail;
|
|
216
|
-
bub.style.setProperty('--tail', tail + 'px');
|
|
217
|
-
ox = flip > 0 ? w + 15.5 : -15.5; oy = tail + 23;
|
|
218
|
-
} else {
|
|
219
|
-
// `top`: right over its head, the tail dropping onto the crown; `side`: up and out of its shoulder
|
|
220
|
-
const tipX = cx - R * (top0 ? 0.12 : 0.66) * flip, tipY = cy - R * (top0 ? 1.02 : 0.74);
|
|
221
|
-
left = top0 ? (flip > 0 ? tipX - 23 - w * 0.55 : tipX - w * 0.45) : (flip > 0 ? tipX - w + 44 : tipX - 44);
|
|
222
|
-
left = Math.max(clip.left + 12, Math.min(clip.right - w - 12, left));
|
|
223
|
-
const tail = Math.max(14, Math.min(w - 30, flip > 0 ? tipX - left - 23 : tipX - left - 1));
|
|
224
|
-
top = tipY - h - 14;
|
|
225
|
-
bub.style.setProperty('--tail', tail + 'px');
|
|
226
|
-
ox = tail + (flip > 0 ? 23 : 1); oy = h + 15;
|
|
227
|
-
}
|
|
228
|
-
bub.style.left = (left - hb.left) / k + 'px'; bub.style.top = (top - hb.top) / k + 'px';
|
|
229
|
-
bub.style.transformOrigin = `${ox}px ${oy}px`;
|
|
230
|
-
};
|
|
231
|
-
const anim = (frames, ms, ease) => bub.animate(still ? [{ opacity: frames === POP_IN ? 0 : 1 }, { opacity: frames === POP_IN ? 1 : 0 }] : frames, { duration: still ? 150 : ms, easing: ease, fill: 'both' }).finished.catch(() => {});
|
|
232
|
-
async function say(text, name) {
|
|
233
|
-
const n = ++sayN;
|
|
234
|
-
if (bub.classList.contains('on')) { await anim(POP_OUT, 200, 'cubic-bezier(.5,0,.75,0)'); if (n !== sayN) return; bub.classList.remove('on'); }
|
|
235
|
-
if (!text) return;
|
|
236
|
-
bub.querySelector('.cx-mascot-who').textContent = name || '';
|
|
237
|
-
bub.querySelector('.cx-mascot-line').textContent = text;
|
|
238
|
-
bub.getAnimations().forEach((x) => x.cancel()); bub.classList.add('on'); placeBubble();
|
|
239
|
-
orb.dispatchEvent(new CustomEvent('orb:mood', { detail: { bump: true } }));
|
|
240
|
-
if (voiceOn) speakAloud(text);
|
|
241
|
-
await anim(POP_IN, 560, 'cubic-bezier(.2,.7,.3,1)');
|
|
242
|
-
}
|
|
243
|
-
// a line that changes while the person types waits for a pause, so the bubble doesn't pop on every key
|
|
244
|
-
const queueSay = (text, name, now) => { if (text === saying) return; clearTimeout(sayT); saying = text; sayT = setTimeout(() => say(text, name), now ? 0 : 450); };
|
|
245
|
-
queueSay(inputs.say || '', inputs.name || '', true);
|
|
246
|
-
addEventListener('resize', placeBubble);
|
|
247
|
-
let name = G[inputs.mood] ? inputs.mood : 'idle', g = G[name], start = performance.now();
|
|
248
|
-
const k = () => (orb.getBoundingClientRect().width || 220) / 220; // px per face unit
|
|
249
|
-
|
|
250
|
-
function draw(p) {
|
|
251
|
-
const u = k(), sx = Math.pow(p.sy, -0.9) * p.s, sy = p.sy * p.s;
|
|
252
|
-
rig.style.transform = `translate(${(p.x * u).toFixed(2)}px, ${(p.y * u).toFixed(2)}px) rotate(${p.rot.toFixed(2)}deg)`;
|
|
253
|
-
squash.style.transform = `scale(${sx.toFixed(4)}, ${sy.toFixed(4)})`;
|
|
254
|
-
const lift = Math.max(0, -p.y), f = Math.max(0.45, 1 - lift / 110);
|
|
255
|
-
floor.style.transform = `translateX(${(p.x * u * 0.6).toFixed(2)}px) scale(${f.toFixed(3)}, ${(f * 0.9).toFixed(3)})`;
|
|
256
|
-
floor.style.opacity = (0.35 + 0.65 * f).toFixed(3);
|
|
257
|
-
fxSvg.style.transform = `translate(${(p.x * u).toFixed(2)}px, ${(p.y * u).toFixed(2)}px)`;
|
|
258
|
-
placeBubble(); // its bubble rides along when it jumps
|
|
259
|
-
fxSvg.innerHTML = effects(p);
|
|
260
|
-
orb.dispatchEvent(new CustomEvent('orb:face', { detail: { paint: (ctx) => { paintEye(ctx, -EX, p.L); paintEye(ctx, EX, p.R); }, blush: [p.cheekL, p.cheekR], turn: [p.lookX * 0.16, -p.lookY * 0.14] } }));
|
|
261
|
-
}
|
|
262
|
-
function play(next) {
|
|
263
|
-
name = G[next] ? next : 'idle'; g = G[name]; start = performance.now();
|
|
264
|
-
orb.dispatchEvent(new CustomEvent('orb:mood', { detail: { energy: g.energy, bump: name !== 'idle' && !g.hold } }));
|
|
265
|
-
if (still) draw(pose(g, g.key, flip));
|
|
266
|
-
}
|
|
267
|
-
play(name);
|
|
268
|
-
|
|
269
|
-
// the clock: a one-shot gesture plays once and settles into idle; a held one loops its middle; idle loops whole
|
|
270
|
-
let raf = 0, visible = true;
|
|
271
|
-
const frame = (now) => {
|
|
272
|
-
let t = (now - start) / 1000 / g.d;
|
|
273
|
-
if (g.hold && t > g.hold[0]) t = g.hold[0] + ((t - g.hold[0]) % (g.hold[1] - g.hold[0]));
|
|
274
|
-
else if (g.loop) t %= 1;
|
|
275
|
-
else if (t >= 1) { name = 'idle'; g = G.idle; start = now; t = 0; orb.dispatchEvent(new CustomEvent('orb:mood', { detail: { energy: g.energy } })); }
|
|
276
|
-
draw(pose(g, t, flip));
|
|
277
|
-
raf = visible && !document.hidden ? requestAnimationFrame(frame) : 0;
|
|
278
|
-
};
|
|
279
|
-
const wake = () => { if (!still && !raf && visible && !document.hidden) raf = requestAnimationFrame(frame); };
|
|
280
|
-
new IntersectionObserver((es) => { visible = es.some((e) => e.isIntersecting); wake(); }).observe(root);
|
|
281
|
-
document.addEventListener('visibilitychange', wake);
|
|
282
|
-
wake();
|
|
283
|
-
return (next) => {
|
|
284
|
-
if (next.look !== undefined) flip = next.look === 'right' ? -1 : 1;
|
|
285
|
-
if (next.voice !== undefined) voiceOn = next.voice === 'on';
|
|
286
|
-
if (next.say !== undefined || next.name !== undefined) queueSay(next.say ?? inputs.say ?? '', next.name ?? inputs.name ?? '');
|
|
287
|
-
if (next.say !== undefined) inputs.say = next.say;
|
|
288
|
-
if (next.name !== undefined) inputs.name = next.name;
|
|
289
|
-
if (next.mood !== undefined && next.mood !== inputs.mood) { inputs.mood = next.mood; play(next.mood); wake(); }
|
|
290
|
-
};
|
|
291
|
-
}
|
package/registry/mascot.muten
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
# Mascot - the brand's mascot (in Storio, Storio): an AssistantOrb that acts. Its gestures are hand-set keyframes that
|
|
2
|
-
# follow the principles of character animation (it gets ready before it moves, squashes and stretches without losing
|
|
3
|
-
# volume, travels in arcs, its shadow tells the height) and its face is worn on the sphere's surface, so the eyes and
|
|
4
|
-
# the blush curve with the body and take its light.
|
|
5
|
-
# Mascot(mood: "happy")
|
|
6
|
-
# Mascot(mood: storioMood, size: "360", look: "left", say: asSay, name: "Storio")
|
|
7
|
-
# mood: idle | happy | shy | surprised | thinking | wink | celebrate | oops - happy, surprised, wink, celebrate and oops
|
|
8
|
-
# play once and settle into idle; shy and thinking stay while the mood is set; changing it plays the new one.
|
|
9
|
-
# idle (waiting) · happy (it went well) · shy (the password) · surprised (something unexpected) · thinking (saving) ·
|
|
10
|
-
# wink («leave it to me») · celebrate (the end of a flow) · oops (it failed, apologetic, no drama).
|
|
11
|
-
# size: the box in px (the sphere fills two thirds of it) · look: left | right - the side it faces (toward the form)
|
|
12
|
-
# say: what it says ("" = quiet). It speaks in a paper bubble that comes out of its side (up and toward where it looks),
|
|
13
|
-
# pops in like in games and swaps with a little pop when the line changes; a line changing while someone types waits
|
|
14
|
-
# for a pause. name: the name on top of the bubble ("Storio"). voice: on | off - a soft blip per syllable that follows
|
|
15
|
-
# the sentence's tune (off by default; it only sounds after the person has touched the page).
|
|
16
|
-
# Where the bubble sits is the page's call, per screen, in CSS: `--cx-mascot-say: side` (default: up and to the side,
|
|
17
|
-
# tail on the sphere's upper rim) `top` (right over its head) or `low` (level with it, beside it, the tail on its side; for when there's no room above).
|
|
18
|
-
# With reduced motion each gesture shows its key pose, still, and the bubble fades.
|
|
19
|
-
# shadcn-for-muten · part over AssistantOrb + Custom Mascot · kit.css (.cx-mascot).
|
|
20
|
-
part Mascot(mood: text = "idle", size: text = "240", look: text = "left", say: text = "", name: text = "", voice: text = "off") {
|
|
21
|
-
Stack class("cx-mascot") style(m: "{$size}px") {
|
|
22
|
-
Stack class("cx-mascot-floor")
|
|
23
|
-
Stack class("cx-mascot-rig") {
|
|
24
|
-
Stack class("cx-mascot-squash") { AssistantOrb(size: $size) }
|
|
25
|
-
}
|
|
26
|
-
Custom Mascot inputs(mood: $mood, look: $look, say: $say, name: $name, voice: $voice) class("cx-mascot-run")
|
|
27
|
-
}
|
|
28
|
-
}
|