@muten/shadcn 0.1.9 → 0.1.11
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/globals.css +2 -1
- package/kit.css +89 -10
- package/nova.css +21 -17
- package/package.json +1 -1
- package/registry/assistant-orb.js +53 -5
- package/registry/assistant-orb.muten +3 -1
- package/registry/button.muten +3 -2
- package/registry/choice-list.muten +5 -0
- package/registry/input-group.muten +8 -0
- package/registry/kit/core.js +1 -1
- package/registry/link-btn.muten +3 -2
- package/registry/mascot.js +291 -0
- package/registry/mascot.muten +28 -0
- package/registry/media-query.js +17 -0
- package/registry/media-query.muten +11 -0
- package/registry/tooltip.muten +3 -1
- package/registry/virtual-window.js +20 -5
- package/registry.json +16 -0
package/globals.css
CHANGED
|
@@ -678,7 +678,8 @@
|
|
|
678
678
|
.btn-group > * { @apply rounded-none shadow-none; }
|
|
679
679
|
.btn-group > *:first-child { @apply rounded-l-md; }
|
|
680
680
|
.btn-group > *:last-child { @apply rounded-r-md; }
|
|
681
|
-
|
|
681
|
+
/* not `* + *`: a sibling rule ending in `*` makes Chrome recheck siblings on every insert in the page */
|
|
682
|
+
.btn-group > :where(:not(:first-child)) { @apply -ml-px; }
|
|
682
683
|
.input-group { @apply flex flex-row h-8 w-full items-center gap-2 rounded-md border border-input bg-transparent px-3 text-sm shadow-sm outline-none transition-[color,box-shadow] duration-200 focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50; }
|
|
683
684
|
.input-group-control { @apply h-full flex-1 border-0 bg-transparent p-0 text-sm shadow-none outline-none placeholder:text-muted-foreground; }
|
|
684
685
|
|
package/kit.css
CHANGED
|
@@ -1299,6 +1299,9 @@ html.cx-locked { overflow: hidden; }
|
|
|
1299
1299
|
.cx-tab { position: relative; z-index: 1; color: color-mix(in srgb, var(--foreground) 62%, transparent); }
|
|
1300
1300
|
.cx-tab:hover { background: transparent; color: var(--foreground); }
|
|
1301
1301
|
.cx-tab[aria-selected="true"] { color: var(--foreground); }
|
|
1302
|
+
/* a segment picks with the sliding pill, not with a filled button: beats the generic «pressed = selected» fill above */
|
|
1303
|
+
.cx-tabs > .cn-button.cx-tab[aria-pressed="true"][aria-pressed][aria-pressed],
|
|
1304
|
+
.cx-tabs > .cn-button.cx-tab[aria-pressed="true"][aria-pressed][aria-pressed]:hover { background: transparent; border-color: transparent; color: var(--foreground); }
|
|
1302
1305
|
.cx-tabs.line .cx-tab { height: 36px; border-radius: 0; }
|
|
1303
1306
|
.cx-tab .cx-count { min-width: 18px; height: 18px; padding: 0 5px; border-radius: 99px; background: color-mix(in srgb, var(--foreground) 9%, transparent); font-size: 11px; font-variant-numeric: tabular-nums; display: inline-grid; place-items: center; }
|
|
1304
1307
|
.cx-tab[aria-selected="true"] .cx-count { background: var(--selected); color: var(--selected-foreground); }
|
|
@@ -3703,11 +3706,11 @@ a.cx-bc-item:hover { color: var(--foreground); }
|
|
|
3703
3706
|
align-items: center;
|
|
3704
3707
|
min-height: 420px;
|
|
3705
3708
|
}
|
|
3706
|
-
/* EVERYTHING IN A STAGE SITS IN ITS MIDDLE. A part that picks its markup per language (`when ui.lang …`)
|
|
3707
|
-
|
|
3709
|
+
/* EVERYTHING IN A STAGE SITS IN ITS MIDDLE. A part that picks its markup per language (`when ui.lang …`) has several
|
|
3710
|
+
roots, and muten wraps them in a Stack wearing `mu-part` (muten ≥ 0.0.49): that box does not count for layout, so what it holds is centred like a direct child. Each piece keeps
|
|
3708
3711
|
its own max width and is centred inside the stage. */
|
|
3709
|
-
.style-nova .cx-mk-stage >
|
|
3710
|
-
.style-nova .cx-mk-stage > :not(.cx-device,
|
|
3712
|
+
.style-nova .cx-mk-stage > .mu-part { display: contents; }
|
|
3713
|
+
.style-nova .cx-mk-stage > :not(.cx-device, .mu-part), .style-nova .cx-mk-stage > .mu-part > * { width: 100%; margin-inline: auto; }
|
|
3711
3714
|
.style-nova .cx-mk-stage-narrow > * {
|
|
3712
3715
|
max-width: 340px;
|
|
3713
3716
|
margin-inline: auto;
|
|
@@ -3890,10 +3893,11 @@ a.cx-bc-item:hover { color: var(--foreground); }
|
|
|
3890
3893
|
.style-nova .cx-mk-step-p { margin: 0; font-size: 15px; line-height: 1.55; color: var(--muted-foreground); }
|
|
3891
3894
|
.style-nova .cx-mk-step-vis { align-self: stretch; margin-top: 8px; min-height: 240px; border-radius: 16px; background: var(--background); overflow: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 20px 16px; }
|
|
3892
3895
|
/* A STEP SHOWS ITS PIECE WHOLE, never chopped at the bottom: the box grows with it (the row of steps keeps the tallest),
|
|
3893
|
-
and each piece takes the size a third of a column allows. A part that picks its markup per language
|
|
3894
|
-
|
|
3895
|
-
|
|
3896
|
-
.style-nova .cx-mk-step-vis >
|
|
3896
|
+
and each piece takes the size a third of a column allows. A part that picks its markup per language has several roots,
|
|
3897
|
+
wrapped by muten in a `.mu-part` Stack, which does not count for layout. (Named by class: `[class="mu-stack"] > *`
|
|
3898
|
+
made Chrome restyle a whole subtree on every class change in the page.) */
|
|
3899
|
+
.style-nova .cx-mk-step-vis > .mu-part { display: contents; }
|
|
3900
|
+
.style-nova .cx-mk-step-vis > *, .style-nova .cx-mk-step-vis > .mu-part > * { width: 100%; max-width: 280px; }
|
|
3897
3901
|
.style-nova .cx-mk-step-vis .cx-pdp { max-width: 250px; }
|
|
3898
3902
|
.style-nova .cx-mk-step-vis .cx-bio { max-width: 260px; }
|
|
3899
3903
|
/* «Connect your channel»: each channel as its connection row (logo, account, switch); «It learns your store»: the store
|
|
@@ -3955,7 +3959,7 @@ a.cx-bc-item:hover { color: var(--foreground); }
|
|
|
3955
3959
|
.style-nova .cx-mk-ig-bio > .cx-bio { border-radius: 20px; box-shadow: 0 30px 60px -30px rgb(11 11 13 / .45); }
|
|
3956
3960
|
.style-nova .cx-mk-ig-hero > .cx-device { position: absolute; right: 24px; bottom: 24px; }
|
|
3957
3961
|
.style-nova .cx-mk-web-serp { position: absolute; left: -28px; bottom: 36px; z-index: 2; width: 340px; padding: 16px 18px; border-radius: 16px; background: var(--background); box-shadow: 0 0 0 1px var(--border), 0 30px 60px -30px rgb(11 11 13 / .45); }
|
|
3958
|
-
.style-nova .cx-mk-stage > .cn-item-group, .style-nova .cx-mk-stage >
|
|
3962
|
+
.style-nova .cx-mk-stage > .cn-item-group, .style-nova .cx-mk-stage > .mu-part > .cn-item-group { width: 100%; max-width: 380px; margin-inline: auto; padding: 6px; border-radius: 18px; background: var(--background); box-shadow: 0 1px 2px rgb(11 11 13 / .06), 0 24px 48px -28px rgb(11 11 13 / .35); }
|
|
3959
3963
|
.style-nova .cx-mk-stage .cn-item-group .cn-item + .cn-item { border-top: 1px solid var(--border); border-radius: 0; }
|
|
3960
3964
|
.style-nova .cx-mk-edit { width: 100%; max-width: 340px; margin-inline: auto; display: flex; flex-direction: column; gap: 16px; padding: 20px; }
|
|
3961
3965
|
.style-nova .cx-mk-edit .cn-field { display: flex; flex-direction: column; gap: 8px; }
|
|
@@ -4869,7 +4873,8 @@ a.cx-bc-item:hover { color: var(--foreground); }
|
|
|
4869
4873
|
/* the crossed-citas alert sits under the strip, full width, with its button */
|
|
4870
4874
|
.cx-ag-top > [data-ag-alert] { margin: 0; }
|
|
4871
4875
|
/* no view picked (a range is on): the tabs show none, not the first */
|
|
4872
|
-
|
|
4876
|
+
/* the pill shows while a tab is chosen: Tabs mark it aria-selected, SegmentedItem aria-pressed */
|
|
4877
|
+
.cx-tabs:not(:has(.cx-tab:is([aria-selected="true"], [aria-pressed="true"]))) > .cx-tab-ind { opacity: 0; }
|
|
4873
4878
|
/* who waits that day: two faces and how many */
|
|
4874
4879
|
.cx-ag-queue { display: flex; align-items: center; gap: 0; }
|
|
4875
4880
|
.cx-ag-queue > .cn-avatar, .cx-ag-queue > .cx-ag-face { width: 24px; height: 24px; min-width: 24px; font-size: 10px; letter-spacing: 0; overflow: hidden; box-shadow: 0 0 0 2px var(--card, var(--background)); }
|
|
@@ -5248,3 +5253,77 @@ a.cx-bc-item:hover { color: var(--foreground); }
|
|
|
5248
5253
|
@media (max-width: 760px) { .cx-ap .cx-ap-orb { --orb: 220px !important; right: -60px; bottom: -70px; } .cx-ap-typing, .cx-ap-said { right: 110px; bottom: 90px; } }
|
|
5249
5254
|
/* A Field whose label lives elsewhere (a FieldHead above the control, with its action beside it): no empty label row. */
|
|
5250
5255
|
.cn-field > .cn-label:empty { display: none; }
|
|
5256
|
+
|
|
5257
|
+
/* ══ INPUT GROUP · TILE: the field as a tile, its icon on a soft tile at the left, its label inside above the value and
|
|
5258
|
+
whatever helps it at the right (a «Libre» badge, a chevron, an eye). The same InputGroup and the same addons:
|
|
5259
|
+
InputAddon (inline-start) holds the Icon, InputAddon(align: "block-start") holds InputGroupText, inline-end the rest.
|
|
5260
|
+
A Combobox, a PhoneField or a PasswordField inside a tile group keep their own behaviour. */
|
|
5261
|
+
.style-nova .cn-input-group[data-variant="tile"] {
|
|
5262
|
+
display: grid; grid-template-columns: auto minmax(0, 1fr) auto; grid-template-areas: "media label end" "media input end";
|
|
5263
|
+
align-items: center; column-gap: 12px; row-gap: 0; height: auto; min-height: 62px; padding: 10px 14px;
|
|
5264
|
+
border-radius: calc(var(--radius) * 1.6); background: var(--background); border-color: var(--border);
|
|
5265
|
+
}
|
|
5266
|
+
.style-nova .cn-input-group[data-variant="tile"] > [data-align="inline-start"] {
|
|
5267
|
+
grid-area: media; width: 36px; height: 36px; padding: 0; margin: 0; border-radius: calc(var(--radius) * 1.1);
|
|
5268
|
+
background: var(--selected-soft); color: var(--selected);
|
|
5269
|
+
}
|
|
5270
|
+
.style-nova .cn-input-group[data-variant="tile"] > [data-align="block-start"] { grid-area: label; width: auto; padding: 0; }
|
|
5271
|
+
.style-nova .cn-input-group[data-variant="tile"] > [data-align="block-start"] .cn-input-group-text { font-size: 12.5px; font-weight: 500; }
|
|
5272
|
+
.style-nova .cn-input-group[data-variant="tile"] > [data-slot="input-group-control"],
|
|
5273
|
+
.style-nova .cn-input-group[data-variant="tile"] > .cn-input-group-input { grid-area: input; height: auto; padding: 0; font-size: 16px; font-weight: 500; }
|
|
5274
|
+
.style-nova .cn-input-group[data-variant="tile"] > [data-align="inline-end"] { grid-area: end; padding: 0; margin: 0; }
|
|
5275
|
+
.style-nova .cn-input-group[data-variant="tile"]:focus-within { border-color: var(--selected); box-shadow: 0 0 0 4px var(--selected-soft); }
|
|
5276
|
+
.style-nova .cn-input-group[data-variant="tile"]:has(> [data-slot="input-group-control"][aria-invalid="true"]) { border-color: var(--destructive); }
|
|
5277
|
+
/* a picker as a tile: the value is an InputGroupText where the input would go (cx-placeholder when nothing is chosen
|
|
5278
|
+
yet), and the whole tile sits in a Button with class cx-tile-btn that opens the page's Drawer or Sheet. */
|
|
5279
|
+
.style-nova .cn-input-group[data-variant="tile"] > .cn-input-group-text { grid-area: input; font-size: 16px; font-weight: 500; color: var(--foreground); }
|
|
5280
|
+
.style-nova .cn-input-group[data-variant="tile"] > .cn-input-group-text.cx-placeholder { color: var(--muted-foreground); font-weight: 400; }
|
|
5281
|
+
.cx-tile-btn { display: block; width: 100%; margin: 0; padding: 0; border: 0; background: none; color: inherit; font: inherit; text-align: left; cursor: pointer; border-radius: calc(var(--radius) * 1.6); }
|
|
5282
|
+
.cx-tile-btn:focus-visible { outline: none; }
|
|
5283
|
+
.style-nova .cx-tile-btn:hover > .cn-input-group[data-variant="tile"] { border-color: color-mix(in srgb, var(--selected) 35%, var(--border)); }
|
|
5284
|
+
.style-nova .cx-tile-btn:focus-visible > .cn-input-group[data-variant="tile"] { border-color: var(--selected); box-shadow: 0 0 0 4px var(--selected-soft); }
|
|
5285
|
+
|
|
5286
|
+
/* ChoiceItem with a picture: an ItemMedia in its slot sits first and the radio moves to the end, so the row reads as
|
|
5287
|
+
«what it is» first and the choice last (the sign-up's «Tengo un local / Trabajo por mi cuenta»). */
|
|
5288
|
+
.cx-choice:has(> .cn-item-media) { gap: 12px; }
|
|
5289
|
+
.cx-choice:has(> .cn-item-media) > .cx-radio { order: 9; margin-left: auto; }
|
|
5290
|
+
.cx-choice:has(> .cn-item-media) { align-items: center; }
|
|
5291
|
+
.style-nova .cx-choice > .cn-item-media.cn-item-media-variant-icon { width: 40px; height: 40px; flex: none; display: grid; place-items: center; border-radius: calc(var(--radius) * 1.1); background: var(--selected-soft); color: var(--selected); }
|
|
5292
|
+
|
|
5293
|
+
/* Btn shape "pill": fully rounded ends, for the one call to action at the foot of a flow. */
|
|
5294
|
+
.cn-button[data-shape="pill"] { border-radius: 999px; }
|
|
5295
|
+
|
|
5296
|
+
/* Stepper (horizontal): its bars are «you are here», so they take the selected colour like the vertical one's
|
|
5297
|
+
current dot (--selected is --primary unless a skin says otherwise; Storio's is violet). */
|
|
5298
|
+
.cx-stp-bar > .cn-progress-indicator { background: var(--selected, var(--primary)); }
|
|
5299
|
+
|
|
5300
|
+
/* Storio's skin: a pressed Toggle reads as «chosen» in the selected violet (shadcn's muted grey vanishes on this skin's
|
|
5301
|
+
light surfaces). Other skins keep Nova's look. */
|
|
5302
|
+
[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); }
|
|
5303
|
+
|
|
5304
|
+
/* ══ MASCOT: the brand's mascot (an AssistantOrb that acts). The box is --m square; the sphere fills 2/3 of it (its
|
|
5305
|
+
radius is 33% of the box), so the body squashes from its bottom (83%) and the shadow sits just under it. */
|
|
5306
|
+
.cx-mascot { position: relative; width: var(--m, 240px); height: var(--m, 240px); flex: none; }
|
|
5307
|
+
.cx-mascot-rig, .cx-mascot-squash { position: absolute; inset: 0; will-change: transform; }
|
|
5308
|
+
.cx-mascot-rig { transform-origin: 50% 50%; }
|
|
5309
|
+
.cx-mascot-squash { transform-origin: 50% 83%; }
|
|
5310
|
+
.cx-mascot .cx-orb { transform: none !important; }
|
|
5311
|
+
.cx-orb[data-face="surface"] .cx-orb-eyes { display: none; }
|
|
5312
|
+
.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; }
|
|
5313
|
+
.cx-mascot-fx { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }
|
|
5314
|
+
.cx-mascot-run { display: none; }
|
|
5315
|
+
/* how it speaks: a «paper» bubble (white, its name on top in violet, a drawn tail) placed by mascot.js up and to the
|
|
5316
|
+
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 */
|
|
5317
|
+
.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; }
|
|
5318
|
+
.cx-mascot-say.on { display: block; }
|
|
5319
|
+
.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)); }
|
|
5320
|
+
.cx-mascot-who:empty { display: none; }
|
|
5321
|
+
.cx-mascot-line { display: block; font-size: 15px; line-height: 1.42; font-weight: 500; overflow-wrap: anywhere; }
|
|
5322
|
+
.cx-mascot-tail { position: absolute; left: var(--tail, 50%); bottom: -15px; width: 24px; height: 16px; overflow: visible; }
|
|
5323
|
+
.cx-mascot-tail path { fill: #fff; }
|
|
5324
|
+
.cx-mascot-say[data-side="right"] .cx-mascot-tail { transform: scaleX(-1); }
|
|
5325
|
+
.cx-mascot-tail-side { position: absolute; display: none; top: var(--tail, 40%); right: -15px; width: 16px; height: 24px; overflow: visible; }
|
|
5326
|
+
.cx-mascot-tail-side path { fill: #fff; }
|
|
5327
|
+
.cx-mascot-say[data-at="low"] .cx-mascot-tail { display: none; }
|
|
5328
|
+
.cx-mascot-say[data-at="low"] .cx-mascot-tail-side { display: block; }
|
|
5329
|
+
.cx-mascot-say[data-at="low"][data-side="right"] .cx-mascot-tail-side { right: auto; left: -15px; transform: scaleX(-1); }
|
package/nova.css
CHANGED
|
@@ -55,6 +55,10 @@
|
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/* NO `group-has-*` HERE: each one is written out as `:where(.group\/x):has(…) & { … }` in its block. Tailwind turns
|
|
59
|
+
`group-has-…` into `&:is(:where(.group\/x):has(…) *)`, and with that trailing `*` Chrome restyles the WHOLE page on
|
|
60
|
+
every change to it (one of these rules alone took a node insert from 2 ms to 125 ms in a real app). The written-out
|
|
61
|
+
form means the same and weighs the same, but ends in the component's own class. Keep new ones in this form. */
|
|
58
62
|
/* Custom variants */
|
|
59
63
|
@custom-variant data-open {
|
|
60
64
|
&:where([data-state="open"]),
|
|
@@ -739,7 +743,7 @@
|
|
|
739
743
|
.cn-command-input { @apply outline-hidden disabled:cursor-not-allowed disabled:opacity-50; }
|
|
740
744
|
.cn-command-list { @apply overflow-x-hidden overflow-y-auto; }
|
|
741
745
|
.cn-command-item { @apply data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0; }
|
|
742
|
-
.cn-command-item-indicator { @apply ml-auto opacity-0 group-
|
|
746
|
+
.cn-command-item-indicator { @apply ml-auto opacity-0 group-data-[checked=true]/command-item:opacity-100; :where(.group\/command-item):has([data-slot="command-shortcut"]) & { @apply hidden; } }
|
|
743
747
|
.cn-context-menu-trigger { @apply select-none; }
|
|
744
748
|
.cn-context-menu-content { @apply z-50 max-h-(--radix-context-menu-content-available-height) origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto; }
|
|
745
749
|
.cn-context-menu-item { @apply relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0; }
|
|
@@ -774,7 +778,7 @@
|
|
|
774
778
|
.cn-field-content { @apply flex flex-1 flex-col leading-snug; }
|
|
775
779
|
.cn-field-label { @apply flex w-fit has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col; }
|
|
776
780
|
.cn-field-title { @apply flex w-fit items-center; }
|
|
777
|
-
.cn-field-description { @apply leading-normal font-normal
|
|
781
|
+
.cn-field-description { @apply leading-normal font-normal last:mt-0 nth-last-2:-mt-1 [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary; :where(.group\/field):has(:where([data-orientation="horizontal"])) & { @apply text-balance; } }
|
|
778
782
|
.cn-field-separator { @apply relative; }
|
|
779
783
|
.cn-field-error { @apply font-normal; }
|
|
780
784
|
.cn-field-separator-content { @apply relative mx-auto block w-fit bg-background; }
|
|
@@ -946,21 +950,21 @@
|
|
|
946
950
|
.style-nova .cn-alert { @apply grid gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4; }
|
|
947
951
|
.style-nova .cn-alert-variant-default { @apply bg-card text-card-foreground; }
|
|
948
952
|
.style-nova .cn-alert-variant-destructive { @apply text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current; }
|
|
949
|
-
.style-nova .cn-alert-title { @apply font-medium group
|
|
953
|
+
.style-nova .cn-alert-title { @apply font-medium; :where(.group\/alert):has(> svg) & { @apply col-start-2; } }
|
|
950
954
|
.style-nova .cn-alert-description { @apply text-muted-foreground text-sm text-balance md:text-pretty [&_p:not(:last-child)]:mb-4; }
|
|
951
955
|
.style-nova .cn-alert-action { @apply absolute top-2 right-2; }
|
|
952
956
|
.style-nova .cn-alert-dialog-overlay { @apply data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs; }
|
|
953
957
|
.style-nova .cn-alert-dialog-content { @apply data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 bg-popover text-popover-foreground ring-foreground/10 gap-4 rounded-xl p-4 ring-1 duration-100 data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm; }
|
|
954
958
|
.style-nova .cn-alert-dialog-header { @apply grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]; }
|
|
955
959
|
.style-nova .cn-alert-dialog-media { @apply bg-muted mb-2 inline-flex size-10 items-center justify-center rounded-md sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6; }
|
|
956
|
-
.style-nova .cn-alert-dialog-title { @apply text-base font-medium
|
|
960
|
+
.style-nova .cn-alert-dialog-title { @apply text-base font-medium; :where(.group\/alert-dialog-content)[data-size="default"]:has([data-slot="alert-dialog-media"]) & { @apply sm:col-start-2; } }
|
|
957
961
|
.style-nova .cn-alert-dialog-description { @apply text-muted-foreground *:[a]:hover:text-foreground text-sm text-balance md:text-pretty *:[a]:underline *:[a]:underline-offset-3; }
|
|
958
962
|
.style-nova .cn-alert-dialog-footer { @apply bg-muted/50 -mx-4 -mb-4 rounded-b-xl border-t p-4; }
|
|
959
963
|
.style-nova .cn-avatar { @apply size-8 rounded-full after:rounded-full data-[size=lg]:size-10 data-[size=sm]:size-6; }
|
|
960
964
|
.style-nova .cn-avatar-fallback { @apply bg-muted text-muted-foreground rounded-full; }
|
|
961
965
|
.style-nova .cn-avatar-image { @apply rounded-full; }
|
|
962
966
|
.style-nova .cn-avatar-badge { @apply bg-primary text-primary-foreground ring-background; }
|
|
963
|
-
.style-nova .cn-avatar-group-count { @apply bg-muted text-muted-foreground size-8 rounded-full text-sm
|
|
967
|
+
.style-nova .cn-avatar-group-count { @apply bg-muted text-muted-foreground size-8 rounded-full text-sm [&>svg]:size-4; :where(.group\/avatar-group):has([data-size="lg"]) & { @apply size-10 [&>svg]:size-5; } :where(.group\/avatar-group):has([data-size="sm"]) & { @apply size-6 [&>svg]:size-3; } }
|
|
964
968
|
.style-nova .cn-badge { @apply h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3!; }
|
|
965
969
|
.style-nova .cn-badge-variant-default { @apply bg-primary text-primary-foreground [a]:hover:bg-primary/80; }
|
|
966
970
|
.style-nova .cn-badge-variant-secondary { @apply bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80; }
|
|
@@ -1006,7 +1010,7 @@
|
|
|
1006
1010
|
.style-nova .cn-carousel-previous { @apply rounded-full; }
|
|
1007
1011
|
.style-nova .cn-carousel-next { @apply rounded-full; }
|
|
1008
1012
|
.style-nova .cn-chart-tooltip { @apply border-border/50 bg-background gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl; }
|
|
1009
|
-
.style-nova .cn-checkbox { @apply border-input dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 flex size-4 items-center justify-center rounded-[4px] border transition-colors
|
|
1013
|
+
.style-nova .cn-checkbox { @apply border-input dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 flex size-4 items-center justify-center rounded-[4px] border transition-colors focus-visible:ring-3 aria-invalid:ring-3; :where(.group\/field):has(:disabled) & { @apply opacity-50; } :where(.group\/field-label):has(:focus-visible) & { @apply ring-0 not-data-checked:border-input data-checked:border-primary; } }
|
|
1010
1014
|
.style-nova .cn-checkbox-aria { @apply data-selected:bg-primary data-selected:text-primary-foreground dark:data-selected:bg-primary data-selected:border-primary data-focus-visible:border-ring data-focus-visible:ring-ring/50 data-focus-visible:ring-3 data-invalid:data-selected:border-primary data-invalid:border-destructive dark:data-invalid:border-destructive/50 data-invalid:ring-destructive/20 dark:data-invalid:ring-destructive/40 data-invalid:ring-3; }
|
|
1011
1015
|
.style-nova .cn-checkbox-indicator { @apply [&>svg]:size-3.5; }
|
|
1012
1016
|
.style-nova .cn-combobox-content { @apply bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:border-input/30 max-h-72 min-w-36 overflow-hidden rounded-lg shadow-md ring-1 duration-100 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:shadow-none; }
|
|
@@ -1115,7 +1119,7 @@
|
|
|
1115
1119
|
.style-nova .cn-item-size-default { @apply gap-2.5 px-3 py-2.5; }
|
|
1116
1120
|
.style-nova .cn-item-size-sm { @apply gap-2.5 px-3 py-2.5; }
|
|
1117
1121
|
.style-nova .cn-item-size-xs { @apply gap-2 px-2.5 py-2 in-data-[slot=dropdown-menu-content]:p-0; }
|
|
1118
|
-
.style-nova .cn-item-media { @apply gap-2 group
|
|
1122
|
+
.style-nova .cn-item-media { @apply gap-2; :where(.group\/item):has([data-slot="item-description"]) & { @apply translate-y-0.5 self-start; } }
|
|
1119
1123
|
.style-nova .cn-item-media-variant-default { @apply bg-transparent; }
|
|
1120
1124
|
.style-nova .cn-item-media-variant-icon { @apply [&_svg:not([class*='size-'])]:size-4; }
|
|
1121
1125
|
.style-nova .cn-item-media-variant-image { @apply size-10 overflow-hidden rounded-sm group-data-[size=sm]/item:size-8 group-data-[size=xs]/item:size-6 [&_img]:size-full [&_img]:object-cover; }
|
|
@@ -1173,7 +1177,7 @@
|
|
|
1173
1177
|
.style-nova .cn-progress-label { @apply text-sm font-medium; }
|
|
1174
1178
|
.style-nova .cn-progress-value { @apply text-muted-foreground ml-auto text-sm tabular-nums; }
|
|
1175
1179
|
.style-nova .cn-radio-group { @apply grid gap-2; }
|
|
1176
|
-
.style-nova .cn-radio-group-item { @apply border-input dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 dark:aria-invalid:border-destructive/50 flex size-4 rounded-full focus-visible:ring-3 aria-invalid:ring-3 group-has
|
|
1180
|
+
.style-nova .cn-radio-group-item { @apply border-input dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 dark:aria-invalid:border-destructive/50 flex size-4 rounded-full focus-visible:ring-3 aria-invalid:ring-3; :where(.group\/field-label):has(:focus-visible) & { @apply ring-0 not-data-checked:border-input data-checked:border-primary; } }
|
|
1177
1181
|
.style-nova .cn-radio-group-item-aria { @apply data-selected:bg-primary data-selected:text-primary-foreground dark:data-selected:bg-primary data-selected:border-primary data-focus-visible:border-ring data-focus-visible:ring-ring/50 data-focus-visible:ring-3 data-invalid:data-selected:border-primary data-invalid:border-destructive data-invalid:ring-destructive/20 dark:data-invalid:ring-destructive/40 dark:data-invalid:border-destructive/50 data-invalid:ring-3; }
|
|
1178
1182
|
.style-nova .cn-radio-group-indicator { @apply flex size-4 items-center justify-center; }
|
|
1179
1183
|
.style-nova .cn-radio-group-indicator-icon { @apply bg-primary-foreground absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full; }
|
|
@@ -1220,7 +1224,7 @@
|
|
|
1220
1224
|
.style-nova .cn-sidebar-group-content { @apply text-sm; }
|
|
1221
1225
|
.style-nova .cn-sidebar-group-label { @apply text-sidebar-foreground/70 ring-sidebar-ring h-8 rounded-md px-2 text-xs font-medium transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4; }
|
|
1222
1226
|
.style-nova .cn-sidebar-group-action { @apply text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 w-5 rounded-md p-0 focus-visible:ring-2 [&>svg]:size-4; }
|
|
1223
|
-
.style-nova .cn-sidebar-menu-button { @apply ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground gap-2 rounded-md p-2 text-left text-sm transition-[width,height,padding] group-
|
|
1227
|
+
.style-nova .cn-sidebar-menu-button { @apply ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground gap-2 rounded-md p-2 text-left text-sm transition-[width,height,padding] group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! focus-visible:ring-2 data-active:font-medium; :where(.group\/menu-item):has([data-sidebar="menu-action"]) & { @apply pr-8; } }
|
|
1224
1228
|
.style-nova .cn-sidebar-menu-button-aria { @apply aria-expanded:hover:bg-sidebar-accent aria-expanded:hover:text-sidebar-accent-foreground; }
|
|
1225
1229
|
.style-nova .cn-sidebar-menu-button-variant-default { @apply hover:bg-sidebar-accent hover:text-sidebar-accent-foreground; }
|
|
1226
1230
|
.style-nova .cn-sidebar-menu-button-variant-outline { @apply bg-background hover:bg-sidebar-accent hover:text-sidebar-accent-foreground shadow-[0_0_0_1px_var(--sidebar-border)] hover:shadow-[0_0_0_1px_var(--sidebar-accent)]; }
|
|
@@ -1240,7 +1244,7 @@
|
|
|
1240
1244
|
.style-nova .cn-slider-range { @apply bg-primary; }
|
|
1241
1245
|
.style-nova .cn-slider-thumb { @apply border-ring ring-ring/50 relative size-3 rounded-full border bg-white transition-[color,box-shadow] after:absolute after:-inset-2 hover:ring-3 focus-visible:ring-3 focus-visible:outline-hidden active:ring-3; }
|
|
1242
1246
|
.style-nova .cn-toast { @apply rounded-2xl; }
|
|
1243
|
-
.style-nova .cn-switch { @apply data-checked:bg-primary data-unchecked:bg-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 dark:data-unchecked:bg-input/80 shrink-0 rounded-full border border-transparent focus-visible:ring-3 aria-invalid:ring-3
|
|
1247
|
+
.style-nova .cn-switch { @apply data-checked:bg-primary data-unchecked:bg-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 dark:data-unchecked:bg-input/80 shrink-0 rounded-full border border-transparent focus-visible:ring-3 aria-invalid:ring-3 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px]; :where(.group\/field-label):has(:focus-visible) & { @apply ring-0 border-transparent; } }
|
|
1244
1248
|
.style-nova .cn-switch-aria { @apply data-selected:bg-primary not-data-selected:bg-input data-focus-visible:border-ring data-focus-visible:ring-ring/50 dark:not-data-selected:bg-input/80 data-focus-visible:ring-3 data-invalid:ring-destructive/20 dark:data-invalid:ring-destructive/40 data-invalid:border-destructive dark:data-invalid:border-destructive/50 data-invalid:ring-3; }
|
|
1245
1249
|
.style-nova .cn-switch-thumb { @apply bg-background dark:data-unchecked:bg-foreground dark:data-checked:bg-primary-foreground rounded-full group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0; }
|
|
1246
1250
|
.style-nova .cn-switch-thumb-aria { @apply dark:not-data-selected:bg-foreground dark:data-selected:bg-primary-foreground group-data-[size=default]/switch:data-selected:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-selected:translate-x-[calc(100%-2px)] group-data-[size=default]/switch:not-data-selected:translate-x-0 group-data-[size=sm]/switch:not-data-selected:translate-x-0; }
|
|
@@ -1279,8 +1283,8 @@
|
|
|
1279
1283
|
.style-nova .cn-input-group-addon { @apply text-muted-foreground h-auto gap-2 py-1.5 text-sm font-medium group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4; }
|
|
1280
1284
|
.style-nova .cn-input-group-addon-align-inline-start { @apply pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]; }
|
|
1281
1285
|
.style-nova .cn-input-group-addon-align-inline-end { @apply pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]; }
|
|
1282
|
-
.style-nova .cn-input-group-addon-align-block-start { @apply px-2.5 pt-2
|
|
1283
|
-
.style-nova .cn-input-group-addon-align-block-end { @apply px-2.5 pb-2
|
|
1286
|
+
.style-nova .cn-input-group-addon-align-block-start { @apply px-2.5 pt-2 [.border-b]:pb-2; :where(.group\/input-group):has(> input) & { @apply pt-2; } }
|
|
1287
|
+
.style-nova .cn-input-group-addon-align-block-end { @apply px-2.5 pb-2 [.border-t]:pt-2; :where(.group\/input-group):has(> input) & { @apply pb-2; } }
|
|
1284
1288
|
.style-nova .cn-input-group-button { @apply gap-2 text-sm; }
|
|
1285
1289
|
.style-nova .cn-input-group-button-size-xs { @apply h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5; }
|
|
1286
1290
|
.style-nova .cn-input-group-button-size-icon-xs { @apply size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0; }
|
|
@@ -1331,22 +1335,22 @@
|
|
|
1331
1335
|
.style-nova .cn-questionnaire-description { @apply text-muted-foreground text-sm; }
|
|
1332
1336
|
.style-nova .cn-questionnaire-choices { @apply gap-2; }
|
|
1333
1337
|
.style-nova .cn-questionnaire-choice { @apply border-input dark:bg-input/20 hover:bg-muted/50 data-checked:border-primary/40 data-checked:bg-muted dark:data-checked:bg-muted data-invalid:border-destructive has-[>input:focus-visible]:border-ring has-[>input:focus-visible]:ring-ring/50 gap-2.5 rounded-lg border bg-transparent px-3 py-2.5 text-sm has-[>input:focus-visible]:ring-3; }
|
|
1334
|
-
.style-nova .cn-questionnaire-choice-indicator { @apply border-input dark:bg-input/30 group-data-checked/questionnaire-choice:bg-primary dark:group-data-checked/questionnaire-choice:bg-primary group-data-checked/questionnaire-choice:text-primary-foreground group-data-checked/questionnaire-choice:border-primary size-4 translate-y-[--spacing(0.45)] group-has
|
|
1338
|
+
.style-nova .cn-questionnaire-choice-indicator { @apply border-input dark:bg-input/30 group-data-checked/questionnaire-choice:bg-primary dark:group-data-checked/questionnaire-choice:bg-primary group-data-checked/questionnaire-choice:text-primary-foreground group-data-checked/questionnaire-choice:border-primary size-4 translate-y-[--spacing(0.45)] rounded-[4px]; :where(.group\/questionnaire-choice):has([data-slot="questionnaire-choice-description"]) & { @apply translate-y-0.5; } }
|
|
1335
1339
|
.style-nova .cn-questionnaire-choice-indicator-dot { @apply bg-primary-foreground size-2; }
|
|
1336
1340
|
.style-nova .cn-questionnaire-choice-indicator-check { @apply size-3.5; }
|
|
1337
1341
|
.style-nova .cn-questionnaire-choice-content { @apply gap-0.5; }
|
|
1338
1342
|
.style-nova .cn-questionnaire-choice-description { @apply text-muted-foreground; }
|
|
1339
|
-
.style-nova .cn-questionnaire-shortcut { @apply border-input bg-background text-muted-foreground size-5 translate-y-[--spacing(0.45)]
|
|
1343
|
+
.style-nova .cn-questionnaire-shortcut { @apply border-input bg-background text-muted-foreground size-5 translate-y-[--spacing(0.45)] items-center justify-center rounded-md border font-mono text-[0.625rem] font-medium leading-none; :where(.group\/questionnaire-choice):has([data-slot="questionnaire-choice-description"]) & { @apply translate-y-0.5; } }
|
|
1340
1344
|
.style-nova .cn-questionnaire-input-wrapper { @apply w-full; }
|
|
1341
1345
|
.style-nova .cn-questionnaire-input { @apply dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-8 rounded-lg border bg-transparent px-2.5 py-1 text-base focus-visible:ring-3 aria-invalid:ring-3 md:text-sm; }
|
|
1342
1346
|
.style-nova .cn-questionnaire-error { @apply mt-2 text-sm; }
|
|
1343
1347
|
.style-nova .cn-questionnaire-actions { @apply gap-2 sm:min-h-8; }
|
|
1344
1348
|
.style-nova .cn-message-scroller-content { @apply gap-6; }
|
|
1345
1349
|
.style-nova .cn-message { @apply text-sm gap-2; }
|
|
1346
|
-
.style-nova .cn-message-avatar { @apply min-w-8 group
|
|
1350
|
+
.style-nova .cn-message-avatar { @apply min-w-8; :where(.group\/message):has([data-slot="message-footer"]) & { @apply -translate-y-8; } }
|
|
1347
1351
|
.style-nova .cn-message-content { @apply gap-2.5 group-data-[align=end]/message:*:data-slot:self-end; }
|
|
1348
|
-
.style-nova .cn-message-header { @apply text-xs font-medium text-muted-foreground px-3 group
|
|
1349
|
-
.style-nova .cn-message-footer { @apply text-xs font-medium text-muted-foreground px-3 group
|
|
1352
|
+
.style-nova .cn-message-header { @apply text-xs font-medium text-muted-foreground px-3; :where(.group\/message):has([data-variant="ghost"]) & { @apply px-0; } }
|
|
1353
|
+
.style-nova .cn-message-footer { @apply text-xs font-medium text-muted-foreground px-3; :where(.group\/message):has([data-variant="ghost"]) & { @apply px-0; } }
|
|
1350
1354
|
.style-nova .cn-message-group { @apply gap-2; }
|
|
1351
1355
|
.style-nova .cn-alert-dialog-overlay-aria { @apply data-entering:animate-in data-exiting:animate-out data-exiting:fade-out-0 data-entering:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs; }
|
|
1352
1356
|
.style-nova .cn-alert-dialog-content-aria { @apply data-entering:animate-in data-exiting:animate-out data-exiting:fade-out-0 data-entering:fade-in-0 data-exiting:zoom-out-95 data-entering:zoom-in-95 bg-popover text-popover-foreground ring-foreground/10 gap-4 rounded-xl p-4 ring-1 duration-100 data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm; }
|
package/package.json
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// AssistantOrb (muten Custom) - the assistant as a sphere of light with eyes (see assistant-orb.muten).
|
|
2
2
|
// The sphere is a small WebGL shader (flowing light, rim, halo); the eyes are an SVG whose capsules lift into a smile.
|
|
3
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.
|
|
4
5
|
const VERT = 'attribute vec2 p;void main(){gl_Position=vec4(p,0.,1.);}';
|
|
5
6
|
const FRAG = `precision highp float;
|
|
6
|
-
uniform vec2 res;uniform float t;uniform float e;uniform vec2 look;
|
|
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;
|
|
7
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));}
|
|
8
9
|
float n3(vec3 x){vec3 i=floor(x),f=fract(x);f=f*f*(3.-2.*f);
|
|
9
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),
|
|
@@ -35,7 +36,23 @@ void main(){
|
|
|
35
36
|
float spec=pow(rv,70.)*.85+pow(rv,6.)*.10;
|
|
36
37
|
float fres=pow(1.-n.z,2.4);
|
|
37
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);
|
|
38
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
|
+
}
|
|
39
56
|
col+=vec3(.06,.04,.12)*(1.-fres);
|
|
40
57
|
col+=e*mint*fres*.7+e*.08;
|
|
41
58
|
float a=1.-smoothstep(.985,1.,r);
|
|
@@ -58,6 +75,8 @@ const eyePath = (cx, hp, open) => {
|
|
|
58
75
|
|
|
59
76
|
export function mount(el, inputs) {
|
|
60
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;
|
|
61
80
|
const still = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
62
81
|
el.style.setProperty('--orb', size + 'px');
|
|
63
82
|
el.setAttribute('aria-hidden', 'true');
|
|
@@ -76,7 +95,12 @@ export function mount(el, inputs) {
|
|
|
76
95
|
|
|
77
96
|
// the shader
|
|
78
97
|
const gl = canvas.getContext('webgl', { premultipliedAlpha: true, antialias: true });
|
|
79
|
-
let prog = null, uT, uE, uLook, uRes;
|
|
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];
|
|
80
104
|
if (gl) {
|
|
81
105
|
const sh = (type, src) => { const o = gl.createShader(type); gl.shaderSource(o, src); gl.compileShader(o); return o; };
|
|
82
106
|
prog = gl.createProgram(); gl.attachShader(prog, sh(gl.VERTEX_SHADER, VERT)); gl.attachShader(prog, sh(gl.FRAGMENT_SHADER, FRAG)); gl.linkProgram(prog);
|
|
@@ -87,6 +111,10 @@ export function mount(el, inputs) {
|
|
|
87
111
|
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
|
|
88
112
|
const loc = gl.getAttribLocation(prog, 'p'); gl.enableVertexAttribArray(loc); gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
|
|
89
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);
|
|
90
118
|
gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
|
|
91
119
|
el.dataset.gl = 'on';
|
|
92
120
|
}
|
|
@@ -95,7 +123,7 @@ export function mount(el, inputs) {
|
|
|
95
123
|
// springs: critically damped, a little bounce where it is earned
|
|
96
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);
|
|
97
125
|
blink.x = blink.to = 1;
|
|
98
|
-
let look = null, pointer = null, gaze = { x: 0, y: 0 };
|
|
126
|
+
let look = null, pointer = null, gaze = rest ? { ...rest } : { x: 0, y: 0 };
|
|
99
127
|
el.addEventListener('orb:mood', (ev) => {
|
|
100
128
|
const d = ev.detail || {};
|
|
101
129
|
if (typeof d.happy === 'number') happy.to = clamp(d.happy);
|
|
@@ -104,6 +132,15 @@ export function mount(el, inputs) {
|
|
|
104
132
|
if (d.bump) squash.v += 7;
|
|
105
133
|
if (still) { happy.x = happy.to; energy.x = energy.to; draw(0, 0); }
|
|
106
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
|
+
});
|
|
107
144
|
const area = el.closest('[data-area="orb"]') || el.parentElement;
|
|
108
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 }; };
|
|
109
146
|
const onLeave = () => { pointer = null; };
|
|
@@ -118,6 +155,14 @@ export function mount(el, inputs) {
|
|
|
118
155
|
if (prog) {
|
|
119
156
|
gl.viewport(0, 0, canvas.width, canvas.height); gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT);
|
|
120
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);
|
|
121
166
|
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
|
122
167
|
}
|
|
123
168
|
const hp = clamp(happy.x), open = clamp(blink.x);
|
|
@@ -126,7 +171,7 @@ export function mount(el, inputs) {
|
|
|
126
171
|
cheeks.setAttribute('opacity', (hp * 0.8).toFixed(3));
|
|
127
172
|
cheeks.setAttribute('transform', `translate(${(gx.x * 9).toFixed(2)} ${(gy.x * 6).toFixed(2)})`);
|
|
128
173
|
}
|
|
129
|
-
if (still) { draw(6, 0); return () => {}; }
|
|
174
|
+
if (still) { if (rest) { gx.x = rest.x; gy.x = rest.y; } draw(6, 0); return () => {}; }
|
|
130
175
|
|
|
131
176
|
// the loop runs only while the orb is on screen and the tab is visible
|
|
132
177
|
let raf = 0, last = 0, visible = true;
|
|
@@ -134,7 +179,10 @@ export function mount(el, inputs) {
|
|
|
134
179
|
const dt = Math.min((now - (last || now)) / 1000, 1 / 30); last = now; T += dt;
|
|
135
180
|
if (pointer) gaze = { x: Math.max(-1, Math.min(1, pointer.x)), y: Math.max(-1, Math.min(1, pointer.y)) };
|
|
136
181
|
else if (look) gaze = look;
|
|
137
|
-
else if (T > nextSaccade) {
|
|
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
|
+
}
|
|
138
186
|
gx.to = gaze.x; gy.to = gaze.y;
|
|
139
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); }
|
|
140
188
|
[gx, gy, happy, blink, squash, energy].forEach((s) => step(s, dt));
|
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
# { detail: { happy: 1, energy: 1, look: { x: -1, y: -.5 }, bump: true } })) - happy 0..1 (the smile), energy 0..1
|
|
7
7
|
# (the light speeds up and turns mint), look -1..1 (null = its own glances), bump (a little jump). AssistantPlay
|
|
8
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.
|
|
9
11
|
# size: the sphere's box in px (the sphere and its halo fill it). The pointer area is the closest element with
|
|
10
12
|
# data-area="orb", else its parent.
|
|
11
13
|
# shadcn-for-muten · Custom · kit.css (.cx-orb) · host JS assistant-orb.js.
|
|
12
|
-
part AssistantOrb(size: text = "240") { Custom AssistantOrb inputs(size: $size) class("cx-orb") }
|
|
14
|
+
part AssistantOrb(size: text = "240", look: text = "") { Custom AssistantOrb inputs(size: $size, look: $look) class("cx-orb") }
|
package/registry/button.muten
CHANGED
|
@@ -10,9 +10,10 @@
|
|
|
10
10
|
# tone: "" | info | ok | wait | bad | note - a tinted icon button (a pending thing on a card)
|
|
11
11
|
# disabled: a bool (a state or a getter: a part argument can't be an expression) - the button can't be pressed.
|
|
12
12
|
# label: the name of an icon-only button (aria-label, and the kit's tooltip on hover). Leave it empty when there is text.
|
|
13
|
+
# shape: "" | pill - fully rounded ends, for the one call to action at the foot of a flow (Btn(size: "2xl", shape: "pill")).
|
|
13
14
|
# shadcn-for-muten · part · no deps · Nova classes (nova.css) + kit.css.
|
|
14
|
-
part Btn(variant: text = "default", size: text = "default", tone: text = "", label: text = "", onClick: action, disabled: bool = false) {
|
|
15
|
-
Button -> $onClick class("cn-button cn-button-variant-{$variant} cn-button-size-{$size} group/button", "cx-tonal cx-has-indicator" when $tone != "") data(slot: "button", tone: $tone, name: $label) aria(label: $label) disabled when $disabled { slot }
|
|
15
|
+
part Btn(variant: text = "default", size: text = "default", tone: text = "", label: text = "", onClick: action, disabled: bool = false, shape: text = "") {
|
|
16
|
+
Button -> $onClick class("cn-button cn-button-variant-{$variant} cn-button-size-{$size} group/button", "cx-tonal cx-has-indicator" when $tone != "") data(slot: "button", tone: $tone, name: $label, shape: $shape) aria(label: $label) disabled when $disabled { slot }
|
|
16
17
|
}
|
|
17
18
|
# TriggerBtn - a Btn that only opens what sits next to it (a Popover, a DropdownMenu, a SplitButton's menu): it has no
|
|
18
19
|
# action of its own, so opening never runs something else. Same variants and sizes as Btn.
|
|
@@ -2,11 +2,16 @@
|
|
|
2
2
|
# ChoiceList(label: "Servicio") {
|
|
3
3
|
# ChoiceItem(value: "cut", current: service, onPick: pick, title: "Corte clásico", description: "30 min", price: "$25.000")
|
|
4
4
|
# }
|
|
5
|
+
# A picture before the text: put an ItemMedia in its slot and the radio moves to the end of the row.
|
|
6
|
+
# ChoiceItem(value: "shop", current: role, onPick: pickRole, title: "Tengo un local", description: "La gente viene a ti") {
|
|
7
|
+
# ItemMedia(variant: "icon") { Icon "lucide:store" }
|
|
8
|
+
# }
|
|
5
9
|
# shadcn-for-muten · part + Custom (RovingBehavior) · kit.css (.cx-radiogroup, .cx-radio).
|
|
6
10
|
part ChoiceList(label: text) { Stack class("cx-radiogroup") aria(role: "radiogroup", label: $label) { slot RovingBehavior() } }
|
|
7
11
|
part ChoiceItem(value: text, current: text, onPick: action, title: text, description: text = "", price: text = "") {
|
|
8
12
|
Button -> $onPick($value) class("cn-item cn-item-variant-outline cn-item-size-sm group/item cx-item cx-choice") data(slot: "item") aria(role: "radio", checked: $value == $current) {
|
|
9
13
|
Span "" class("cx-radio")
|
|
14
|
+
slot
|
|
10
15
|
Stack class("cn-item-content") data(slot: "item-content") {
|
|
11
16
|
Span "{$title}" class("cn-item-title") data(slot: "item-title")
|
|
12
17
|
Text "{$description}" class("cn-item-description") data(slot: "item-description")
|
|
@@ -6,6 +6,14 @@
|
|
|
6
6
|
# align: inline-start | inline-end (beside the text) | block-start | block-end (a row above / below, for a GroupTextArea).
|
|
7
7
|
# InputGroup { GroupTextArea(value: msg, placeholder: "Escribe…") InputAddon(align: "block-end") { InputGroupText(label: "0/160") InputGroupButton(label: "Enviar", onClick: send) { Icon "lucide:send" } } }
|
|
8
8
|
# InputGroup and SearchInput take size (default | xl | 2xl) and variant (default | filled) like Input.
|
|
9
|
+
# variant "tile": the field as a tile, its icon on a soft tile, its label inside above the value, a helper at the end:
|
|
10
|
+
# InputGroup(variant: "tile") { InputAddon { Icon "lucide:store" } InputAddon(align: "block-start") { InputGroupText(label: "Nombre del negocio") }
|
|
11
|
+
# GroupInput(value: name, placeholder: "Estudio Luna") InputAddon(align: "inline-end") { StatusBadge(label: "Libre", tone: "ok") } }
|
|
12
|
+
# A picker as a tile (it opens the page's Drawer): the value is an InputGroupText (class cx-placeholder while empty) and
|
|
13
|
+
# the tile goes inside a Button with class cx-tile-btn:
|
|
14
|
+
# Button "" -> openCountry class("cx-tile-btn") { InputGroup(variant: "tile") { InputAddon { Icon "lucide:globe" }
|
|
15
|
+
# InputAddon(align: "block-start") { InputGroupText(label: "País") } InputGroupText(label: countryName)
|
|
16
|
+
# InputAddon(align: "inline-end") { Icon "lucide:chevron-down" } } }
|
|
9
17
|
# shadcn-for-muten · part · no deps · Nova (.cn-input-group).
|
|
10
18
|
part InputGroup(size: text = "default", variant: text = "default") { Stack class("cn-input-group group/input-group") data(slot: "input-group", size: $size, variant: $variant) aria(role: "group") { slot } }
|
|
11
19
|
part InputAddon(align: text = "inline-start") { Stack class("cn-input-group-addon cn-input-group-addon-align-{$align}") data(slot: "input-group-addon", align: $align) { slot } }
|
package/registry/kit/core.js
CHANGED
|
@@ -183,7 +183,7 @@ watchReveal();
|
|
|
183
183
|
|
|
184
184
|
// ── tooltip on any [data-name] ──
|
|
185
185
|
const tip = document.createElement('div'); tip.className = 'cx-tip'; tip.setAttribute('role', 'tooltip'); tip.hidden = true; lib.appendChild(tip);
|
|
186
|
-
const tipOn = (e) => { const w = e.target.closest?.('[data-name]'); if (!w || !w.dataset.name) return; /* an empty name (no data yet) says nothing */ tip.textContent = w.dataset.name; tip.hidden = false; const r = w.getBoundingClientRect(); if (w.closest('[data-slot="sidebar"]')) { tip.style.left = (r.right + 8) + 'px'; tip.style.top = (r.top + r.height / 2 - tip.offsetHeight / 2) + 'px'; } else { place(tip, w, 'center', 6); tip.style.top = (r.top - tip.offsetHeight - 6) + 'px'; } requestAnimationFrame(() => tip.dataset.state = 'open'); };
|
|
186
|
+
const tipOn = (e) => { const w = e.target.closest?.('[data-name]'); if (!w || !w.dataset.name) return; /* an empty name (no data yet) says nothing */ tip.textContent = w.dataset.name; tip.hidden = false; const r = w.getBoundingClientRect(); if (w.dataset.side === 'right' || w.closest('[data-slot="sidebar"]')) { tip.style.left = (r.right + 8) + 'px'; tip.style.top = (r.top + r.height / 2 - tip.offsetHeight / 2) + 'px'; } else { place(tip, w, 'center', 6); tip.style.top = (r.top - tip.offsetHeight - 6) + 'px'; } requestAnimationFrame(() => tip.dataset.state = 'open'); };
|
|
187
187
|
const tipOff = (e) => { if (e.target.closest?.('[data-name]')) { tip.dataset.state = 'closed'; tip.hidden = true; } };
|
|
188
188
|
['pointerover', 'focusin'].forEach((t) => document.addEventListener(t, tipOn)); ['pointerout', 'focusout'].forEach((t) => document.addEventListener(t, tipOff));
|
|
189
189
|
|
package/registry/link-btn.muten
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
# LinkBtn(to: "/signup") { Span "Crear cuenta" }
|
|
4
4
|
# LinkBtn(to: "/docs", variant: "outline", size: "sm") { Span "Ver la guía" Icon "lucide:arrow-right" }
|
|
5
5
|
# variant: default | outline | secondary | ghost | destructive | link · size: xs | sm | default | lg | xl (44) | 2xl (52)
|
|
6
|
+
# shape: "" | pill - fully rounded ends, like Btn(shape: "pill").
|
|
6
7
|
# shadcn-for-muten · part · no deps · Nova classes (nova.css).
|
|
7
|
-
part LinkBtn(to: text, variant: text = "default", size: text = "default") {
|
|
8
|
-
Link -> "{$to}" class("cn-button cn-button-variant-{$variant} cn-button-size-{$size} group/button") data(slot: "button") { slot }
|
|
8
|
+
part LinkBtn(to: text, variant: text = "default", size: text = "default", shape: text = "") {
|
|
9
|
+
Link -> "{$to}" class("cn-button cn-button-variant-{$variant} cn-button-size-{$size} group/button") data(slot: "button", shape: $shape) { slot }
|
|
9
10
|
}
|
|
@@ -0,0 +1,291 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// MediaQuery (muten Custom) - says "yes"/"no" for a media query at mount and on every change (see media-query.muten).
|
|
2
|
+
// Plain DOM: one matchMedia listener, which drops itself the first time it fires after the element left the page.
|
|
3
|
+
export function mount(el, inputs, on) {
|
|
4
|
+
el.hidden = true;
|
|
5
|
+
el.setAttribute('aria-hidden', 'true');
|
|
6
|
+
let mq = null, last = '';
|
|
7
|
+
const say = () => { if (mq && !el.isConnected && last) { mq.removeEventListener('change', say); return; } const now = mq && mq.matches ? 'yes' : 'no'; if (now !== last) { last = now; on.change?.(now); } };
|
|
8
|
+
const watch = (query) => {
|
|
9
|
+
if (mq) mq.removeEventListener('change', say);
|
|
10
|
+
mq = typeof matchMedia === 'function' && query ? matchMedia(String(query)) : null;
|
|
11
|
+
last = '';
|
|
12
|
+
if (mq) mq.addEventListener('change', say);
|
|
13
|
+
say();
|
|
14
|
+
};
|
|
15
|
+
watch(inputs.query);
|
|
16
|
+
return (next) => { if (next.query !== inputs.query) { inputs = next; watch(next.query); } };
|
|
17
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# MediaQuery - tells the page whether a CSS media query matches, now and every time that changes, so a page can DRAW
|
|
2
|
+
# one version or the other (a phone layout, a desktop one) instead of drawing both and hiding one with CSS.
|
|
3
|
+
# state { onPhone = false : bool }
|
|
4
|
+
# action seePhone(m: text) mutates onPhone { onPhone.set(m == "yes") }
|
|
5
|
+
# MediaQuery(query: "(max-width: 63.99rem)", onChange: seePhone)
|
|
6
|
+
# when onPhone { … the phone's version … } when not onPhone { … the desktop's … }
|
|
7
|
+
# onChange gets "yes" or "no": once at mount and again whenever the answer flips (a rotated phone, a resized window).
|
|
8
|
+
# It draws nothing. shadcn-for-muten · Custom (host JS media-query.js).
|
|
9
|
+
part MediaQuery(query: text, onChange: action) {
|
|
10
|
+
Custom MediaQuery inputs(query: $query) on(change: $onChange) class("cx-mq")
|
|
11
|
+
}
|
package/registry/tooltip.muten
CHANGED
|
@@ -3,4 +3,6 @@
|
|
|
3
3
|
# The kit's single tooltip (KitLayer) does it for anything with a name; Tooltip just gives that name.
|
|
4
4
|
# Tooltip(text: "Editar servicio") { Btn(variant: "ghost", size: "icon", onClick: edit) { Icon "lucide:pencil" } }
|
|
5
5
|
# A Btn with label already has one. shadcn-for-muten · part · the kit's tooltip (.cx-tip).
|
|
6
|
-
|
|
6
|
+
# side: "right" puts it beside the trigger instead of above (a vertical rail, where above covers the item before it).
|
|
7
|
+
# Tooltip(text: "Traer de Linktree o Shopify", side: "right") { Link … }
|
|
8
|
+
part Tooltip(text: text, side: text = "") { Stack class("cx-tip-wrap") data(slot: "tooltip-trigger", name: $text, side: $side) { slot } }
|
|
@@ -7,9 +7,15 @@ export function mount(el, inputs, on) {
|
|
|
7
7
|
let count = Math.max(0, Number(inputs.count) || 0), guess = Number(inputs.item) || 72, over = Math.max(0, Number(inputs.over ?? 6));
|
|
8
8
|
// -1: the first range is always said, even «0,0» (nothing on screen yet), or the page keeps its own default
|
|
9
9
|
let from = -1, to = -1, step = 0, raf = 0, list = null, scroller = null, basePad = 0, wired = false;
|
|
10
|
+
// the real height (plus gap, screen px) of every row already drawn, by its place in the list. Rows of very different
|
|
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)
|
|
13
|
+
let sizes = [];
|
|
10
14
|
el.setAttribute('aria-hidden', 'true');
|
|
11
15
|
|
|
12
|
-
|
|
16
|
+
// the list's gap, read once per pass (getComputedStyle forces the page's styles; it ran several times per scroll frame)
|
|
17
|
+
let gapNow = -1;
|
|
18
|
+
const gapOf = () => { if (gapNow < 0) gapNow = parseFloat(getComputedStyle(list).rowGap) || 0; return gapNow; };
|
|
13
19
|
// the rows muten drew after the marker (anything else the app puts in the list is left alone)
|
|
14
20
|
const rows = () => { const out = []; for (let n = el.nextElementSibling; n; n = n.nextElementSibling) out.push(n); return out; };
|
|
15
21
|
// the real height of one row plus its gap, from the rows on screen; the guess until there are some
|
|
@@ -21,7 +27,12 @@ export function mount(el, inputs, on) {
|
|
|
21
27
|
const n = to > from ? to - from : r.length;
|
|
22
28
|
if (r.length && n > 0) { const a = r[0].getBoundingClientRect(), b = r[r.length - 1].getBoundingClientRect(); const span = b.bottom - a.top; if (span > 0) { const seen = (span + gap) / n; step = step ? step * 0.7 + seen * 0.3 : seen; } }
|
|
23
29
|
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; }
|
|
24
31
|
};
|
|
32
|
+
// 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; };
|
|
34
|
+
// 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; };
|
|
25
36
|
// what is on screen: the scroller's box, never past the window (a scroller taller than the screen grew with the rows)
|
|
26
37
|
const view = () => { if (scroller) { const b = scroller.getBoundingClientRect(); return [Math.max(0, b.top), Math.min(innerHeight, b.bottom)]; } return [0, innerHeight]; };
|
|
27
38
|
// a list inside a scaled box (a phone preview drawn at 80 %): what is measured comes in screen px, what is set goes in
|
|
@@ -30,16 +41,20 @@ export function mount(el, inputs, on) {
|
|
|
30
41
|
const place = () => {
|
|
31
42
|
const gap = gapOf(), k = scale();
|
|
32
43
|
// the marker is the rows above; flex adds one gap after it, so it gives that gap back (and leaves when it is empty)
|
|
33
|
-
if (from > 0) { el.style.display = 'block'; el.style.height = `${Math.max(0, from
|
|
34
|
-
list.style.paddingBottom = `${basePad + Math.max(0, (count - to)
|
|
44
|
+
if (from > 0) { el.style.display = 'block'; el.style.height = `${Math.max(0, offset(from) / k - gap)}px`; } else { el.style.display = 'none'; el.style.height = '0px'; }
|
|
45
|
+
list.style.paddingBottom = `${basePad + Math.max(0, (offset(count) - offset(to)) / k)}px`;
|
|
35
46
|
};
|
|
36
47
|
const compute = () => {
|
|
37
48
|
raf = 0; if (!list) return;
|
|
49
|
+
gapNow = -1;
|
|
50
|
+
// a list that is not on the page right now (a hidden panel, a closed tab: no box at all) keeps the rows it has. Its
|
|
51
|
+
// scroller measures 0 × 0, which read as «scrolled to the end» and drew EVERY row into a panel nobody could see
|
|
52
|
+
if (!list.getClientRects().length || (scroller && !scroller.clientHeight)) return;
|
|
38
53
|
learn();
|
|
39
54
|
const [top, bottom] = view();
|
|
40
55
|
// where row 0 starts: the marker's top while it shows, else the first row's (or the list's content box)
|
|
41
56
|
const start = from > 0 ? el.getBoundingClientRect().top : (el.nextElementSibling || list).getBoundingClientRect().top;
|
|
42
|
-
const a =
|
|
57
|
+
const a = indexAt(top - start) - over, b = indexAt(bottom - start) + 1 + over;
|
|
43
58
|
let nf = Math.max(0, Math.min(count, a)), nt = Math.max(nf, Math.min(count, b));
|
|
44
59
|
// the estimate is an average: check it against the rows really drawn. Blank space showing below the last row (or
|
|
45
60
|
// above the first) means more rows belong there; and at either end of the scroll the list always reaches its end
|
|
@@ -77,7 +92,7 @@ export function mount(el, inputs, on) {
|
|
|
77
92
|
return (next) => {
|
|
78
93
|
const c = Math.max(0, Number(next.count) || 0), g = Number(next.item) || guess, o = Math.max(0, Number(next.over ?? over));
|
|
79
94
|
if (c === count && g === guess && o === over) return;
|
|
80
|
-
count = c; guess = g; over = o; from = -1; to = -1; // force a fresh range (a filter changed the list)
|
|
95
|
+
count = c; guess = g; over = o; from = -1; to = -1; sizes = []; // force a fresh range (a filter changed the list: other rows, other heights)
|
|
81
96
|
if (list) soon(); else requestAnimationFrame(wire);
|
|
82
97
|
};
|
|
83
98
|
}
|
package/registry.json
CHANGED
|
@@ -571,6 +571,14 @@
|
|
|
571
571
|
"file": "registry/marketing.muten",
|
|
572
572
|
"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
573
|
},
|
|
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
|
+
},
|
|
574
582
|
{
|
|
575
583
|
"name": "member-list",
|
|
576
584
|
"deps": [
|
|
@@ -1190,6 +1198,14 @@
|
|
|
1190
1198
|
"component": "VirtualList",
|
|
1191
1199
|
"description": "VirtualList(rows, total, rowHeight, label, jump): only visible rows in the DOM."
|
|
1192
1200
|
},
|
|
1201
|
+
{
|
|
1202
|
+
"name": "media-query",
|
|
1203
|
+
"deps": [],
|
|
1204
|
+
"part": "MediaQuery",
|
|
1205
|
+
"file": "registry/media-query.muten",
|
|
1206
|
+
"component": "MediaQuery",
|
|
1207
|
+
"description": "MediaQuery(query, onChange): says \"yes\"/\"no\" for a CSS media query at mount and on every change, so a page draws one version (phone or desktop) instead of both."
|
|
1208
|
+
},
|
|
1193
1209
|
{
|
|
1194
1210
|
"name": "virtual-window",
|
|
1195
1211
|
"deps": [],
|