@muten/shadcn 0.1.12 → 0.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/kit.css CHANGED
@@ -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
- /* ══ MASCOT: the brand's mascot (an AssistantOrb that acts). The box is --m square; the sphere fills 2/3 of it (its
5325
- radius is 33% of the box), so the body squashes from its bottom (83%) and the shadow sits just under it. */
5326
- .cx-mascot { position: relative; width: var(--m, 240px); height: var(--m, 240px); flex: none; }
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@muten/shadcn",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "shadcn-style component registry for muten - own-the-source parts you add with `muten add`.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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" }
@@ -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' });
@@ -9,7 +9,7 @@ export function mount(el, inputs, on) {
9
9
  let from = -1, to = -1, step = 0, raf = 0, list = null, scroller = null, basePad = 0, wired = false;
10
10
  // the real height (plus gap, screen px) of every row already drawn, by its place in the list. Rows of very different
11
11
  // heights (a group of 2 next to one of 50) no longer move the scrollbar as they come and go: a row seen once keeps its
12
- // own height, and only rows never drawn use the average. Only when each row is ONE element (else: the average alone)
12
+ // own height, and only rows never drawn use the average. A row is what muten marks as one (title + carousel too)
13
13
  let sizes = [];
14
14
  el.setAttribute('aria-hidden', 'true');
15
15
 
@@ -18,6 +18,19 @@ export function mount(el, inputs, on) {
18
18
  const gapOf = () => { if (gapNow < 0) gapNow = parseFloat(getComputedStyle(list).rowGap) || 0; return gapNow; };
19
19
  // the rows muten drew after the marker (anything else the app puts in the list is left alone)
20
20
  const rows = () => { const out = []; for (let n = el.nextElementSibling; n; n = n.nextElementSibling) out.push(n); return out; };
21
+ // THE ROWS AS ROWS. Every row of a muten `each` sits between two markers (<!--row--> … <!--/row-->), and a row may be
22
+ // several elements: a store's group is its title AND its carousel. Measured by element, a two-piece row never had its
23
+ // own height learnt (only the average), so a group leaving above the screen left a gap of the wrong size behind and
24
+ // the page jumped under the finger (CLS 3.8 scrolling a 28-group store). A row is measured from its first piece to its
25
+ // last; an element outside any markers is a row of its own.
26
+ const rowParts = () => {
27
+ const out = []; let cur = null;
28
+ for (let n = el.nextSibling; n; n = n.nextSibling) {
29
+ if (n.nodeType === 8) { if (n.data === 'row') cur = []; else if (n.data === '/row' && cur) { out.push(cur); cur = null; } continue; }
30
+ if (n.nodeType === 1) { if (cur) cur.push(n); else out.push([n]); }
31
+ }
32
+ return out;
33
+ };
21
34
  // the real height of one row plus its gap, from the rows on screen; the guess until there are some
22
35
  const learn = () => {
23
36
  const r = rows(); const gap = gapOf() * scale();
@@ -27,12 +40,22 @@ export function mount(el, inputs, on) {
27
40
  const n = to > from ? to - from : r.length;
28
41
  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; } }
29
42
  if (!step) step = (guess + gapOf()) * scale();
30
- if (r.length && to > from && r.length === to - from) for (let i = 0; i < r.length; i++) { const h = r[i].getBoundingClientRect().height; if (h > 0) sizes[from + i] = h + gap; }
43
+ const parts = rowParts();
44
+ if (to > from && parts.length === to - from) {
45
+ for (let i = 0; i < parts.length; i++) {
46
+ const shown = parts[i].filter((n) => n.getClientRects().length);
47
+ // a row with nothing shown (an empty group) takes no room and no gap: it is 0, not «unknown»
48
+ if (!shown.length) { sizes[from + i] = 0; continue; }
49
+ const h = shown[shown.length - 1].getBoundingClientRect().bottom - shown[0].getBoundingClientRect().top;
50
+ if (h > 0) sizes[from + i] = h + gap;
51
+ }
52
+ }
31
53
  };
32
54
  // where row i starts, from the top of row 0 (screen px): the measured rows, the average for the rest
33
- const offset = (i) => { let y = 0; for (let j = 0; j < i; j++) y += sizes[j] || step; return y; };
55
+ const size = (j) => (sizes[j] === undefined ? step : sizes[j]);
56
+ const offset = (i) => { let y = 0; for (let j = 0; j < i; j++) y += size(j); return y; };
34
57
  // the row at a distance from the top of row 0
35
- const indexAt = (y) => { let acc = 0; for (let j = 0; j < count; j++) { const h = sizes[j] || step; if (acc + h > y) return j; acc += h; } return count; };
58
+ const indexAt = (y) => { let acc = 0; for (let j = 0; j < count; j++) { const h = size(j); if (acc + h > y) return j; acc += h; } return count; };
36
59
  // what is on screen: the scroller's box, never past the window (a scroller taller than the screen grew with the rows)
37
60
  const view = () => { if (scroller) { const b = scroller.getBoundingClientRect(); return [Math.max(0, b.top), Math.min(innerHeight, b.bottom)]; } return [0, innerHeight]; };
38
61
  // a list inside a scaled box (a phone preview drawn at 80 %): what is measured comes in screen px, what is set goes in
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
- }
@@ -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
- }
@@ -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
- }