@muten/shadcn 0.1.10 → 0.1.12

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 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
- .btn-group > * + * { @apply -ml-px; }
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
@@ -432,8 +432,19 @@ html.cx-locked { overflow: hidden; }
432
432
  .cx-who b { display: block; font-weight: 500; }
433
433
  .cx-who small { color: var(--muted-foreground); font-size: 13px; }
434
434
  .cx-online { background: var(--success) !important; box-shadow: 0 0 0 2px var(--background); }
435
- .cx-tip { position: fixed; z-index: var(--z-toast); padding: 6px 10px; border-radius: calc(var(--radius) * .8); background: var(--foreground); color: var(--background); font-size: 12px; font-weight: 500; white-space: nowrap; pointer-events: none; opacity: 0; transform: translateY(2px); transition: opacity 150ms ease, transform 150ms ease; }
435
+ .cx-tip { position: fixed; z-index: var(--z-toast); padding: 6px 12px; border-radius: calc(var(--radius) - 2px); background: var(--foreground); color: var(--background); font-size: 12px; font-weight: 500; line-height: 1.35; white-space: nowrap; pointer-events: none; opacity: 0; transform: translateY(3px) scale(.96); transform-origin: var(--ax, 50%) 100%; transition: opacity 120ms ease-out, transform 160ms cubic-bezier(.23, 1, .32, 1); }
436
+ /* its arrow, toward the trigger (shadcn's TooltipArrow: the pill's own colour, a softly rounded square half out) */
437
+ .cx-tip::after { content: ""; position: absolute; left: calc(var(--ax, 50%) - 5px); bottom: -4px; width: 10px; height: 10px; border-radius: 2px; background: inherit; transform: rotate(45deg); }
438
+ .cx-tip[data-side="bottom"] { transform: translateY(-3px) scale(.96); transform-origin: var(--ax, 50%) 0; }
439
+ .cx-tip[data-side="bottom"]::after { bottom: auto; top: -4px; }
440
+ .cx-tip[data-side="right"] { transform: translateX(-3px) scale(.96); transform-origin: 0 50%; }
441
+ .cx-tip[data-side="right"]::after { left: -4px; top: calc(50% - 5px); bottom: auto; }
436
442
  .cx-tip[data-state="open"] { opacity: 1; transform: none; }
443
+ @media (prefers-reduced-motion: reduce) { .cx-tip { transition: opacity 120ms ease-out; transform: none; } }
444
+ .cx-tip[data-side="right"] { transform: translateX(-4px); }
445
+ .cx-tip[data-side="right"][data-state="open"] { transform: none; }
446
+ /* after the first one, the names follow the pointer at once: no fade, no slide */
447
+ .cx-tip[data-instant] { transition-duration: 0ms; }
437
448
 
438
449
  /* ── Fields ── */
439
450
  .cx-form { display: flex; flex-direction: column; gap: 18px; width: 100%; max-width: 380px; }
@@ -3706,11 +3717,11 @@ a.cx-bc-item:hover { color: var(--foreground); }
3706
3717
  align-items: center;
3707
3718
  min-height: 420px;
3708
3719
  }
3709
- /* EVERYTHING IN A STAGE SITS IN ITS MIDDLE. A part that picks its markup per language (`when ui.lang …`) wraps it in a
3710
- bare container: that box does not count for layout, so what it holds is centred like a direct child. Each piece keeps
3720
+ /* EVERYTHING IN A STAGE SITS IN ITS MIDDLE. A part that picks its markup per language (`when ui.lang …`) has several
3721
+ 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
3711
3722
  its own max width and is centred inside the stage. */
3712
- .style-nova .cx-mk-stage > [class="mu-stack"] { display: contents; }
3713
- .style-nova .cx-mk-stage > :not(.cx-device, [class="mu-stack"]), .style-nova .cx-mk-stage > [class="mu-stack"] > * { width: 100%; margin-inline: auto; }
3723
+ .style-nova .cx-mk-stage > .mu-part { display: contents; }
3724
+ .style-nova .cx-mk-stage > :not(.cx-device, .mu-part), .style-nova .cx-mk-stage > .mu-part > * { width: 100%; margin-inline: auto; }
3714
3725
  .style-nova .cx-mk-stage-narrow > * {
3715
3726
  max-width: 340px;
3716
3727
  margin-inline: auto;
@@ -3893,10 +3904,11 @@ a.cx-bc-item:hover { color: var(--foreground); }
3893
3904
  .style-nova .cx-mk-step-p { margin: 0; font-size: 15px; line-height: 1.55; color: var(--muted-foreground); }
3894
3905
  .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; }
3895
3906
  /* A STEP SHOWS ITS PIECE WHOLE, never chopped at the bottom: the box grows with it (the row of steps keeps the tallest),
3896
- and each piece takes the size a third of a column allows. A part that picks its markup per language wraps it in a
3897
- bare container, which does not count for layout. */
3898
- .style-nova .cx-mk-step-vis > [class="mu-stack"] { display: contents; }
3899
- .style-nova .cx-mk-step-vis > *, .style-nova .cx-mk-step-vis > [class="mu-stack"] > * { width: 100%; max-width: 280px; }
3907
+ and each piece takes the size a third of a column allows. A part that picks its markup per language has several roots,
3908
+ wrapped by muten in a `.mu-part` Stack, which does not count for layout. (Named by class: `[class="mu-stack"] > *`
3909
+ made Chrome restyle a whole subtree on every class change in the page.) */
3910
+ .style-nova .cx-mk-step-vis > .mu-part { display: contents; }
3911
+ .style-nova .cx-mk-step-vis > *, .style-nova .cx-mk-step-vis > .mu-part > * { width: 100%; max-width: 280px; }
3900
3912
  .style-nova .cx-mk-step-vis .cx-pdp { max-width: 250px; }
3901
3913
  .style-nova .cx-mk-step-vis .cx-bio { max-width: 260px; }
3902
3914
  /* «Connect your channel»: each channel as its connection row (logo, account, switch); «It learns your store»: the store
@@ -3958,7 +3970,7 @@ a.cx-bc-item:hover { color: var(--foreground); }
3958
3970
  .style-nova .cx-mk-ig-bio > .cx-bio { border-radius: 20px; box-shadow: 0 30px 60px -30px rgb(11 11 13 / .45); }
3959
3971
  .style-nova .cx-mk-ig-hero > .cx-device { position: absolute; right: 24px; bottom: 24px; }
3960
3972
  .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); }
3961
- .style-nova .cx-mk-stage > .cn-item-group, .style-nova .cx-mk-stage > [class="mu-stack"] > .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); }
3973
+ .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); }
3962
3974
  .style-nova .cx-mk-stage .cn-item-group .cn-item + .cn-item { border-top: 1px solid var(--border); border-radius: 0; }
3963
3975
  .style-nova .cx-mk-edit { width: 100%; max-width: 340px; margin-inline: auto; display: flex; flex-direction: column; gap: 16px; padding: 20px; }
3964
3976
  .style-nova .cx-mk-edit .cn-field { display: flex; flex-direction: column; gap: 8px; }
@@ -4940,6 +4952,15 @@ a.cx-bc-item:hover { color: var(--foreground); }
4940
4952
  .cx-sb-label { white-space: nowrap; overflow: hidden; }
4941
4953
  .cx-sidebar[data-collapsed="true"] .cx-sb-label, .cx-sidebar[data-collapsed="true"] .cx-sb-glabel { display: block; opacity: 0; pointer-events: none; }
4942
4954
  .cx-sidebar[data-collapsed="true"] .cx-sb-count:not(:empty) { display: inline-grid; opacity: 0; }
4955
+ /* FOLDED, A SECTION'S TITLE BECOMES A SHORT LINE between its icons and the ones above: its words fade where they are and
4956
+ its height closes with the fold (200 ms), so the icon column has no holes where the titles were. A group with no
4957
+ title (the first) takes no room at all. */
4958
+ .cx-sidebar .cx-sb-glabel { position: relative; transition: opacity .16s ease, height .2s cubic-bezier(.22, 1, .36, 1), padding .2s cubic-bezier(.22, 1, .36, 1); }
4959
+ .cx-sidebar[data-collapsed="true"] .cx-sb-glabel { height: 17px; padding-top: 0; padding-bottom: 0; overflow: hidden; color: transparent; opacity: 1; }
4960
+ .cx-sidebar[data-collapsed="true"] .cx-sb-glabel::after { content: ""; position: absolute; left: 50%; top: 8px; width: 20px; height: 1px; margin-left: -10px; background: var(--border); }
4961
+ .cx-sidebar[data-collapsed="true"] .cx-sb-glabel:empty { display: none; }
4962
+ .cx-sidebar .cx-sb-group { transition: padding .2s cubic-bezier(.22, 1, .36, 1); }
4963
+ .cx-sidebar[data-collapsed="true"] .cx-sb-group { padding-top: 2px; padding-bottom: 2px; }
4943
4964
  /* A SECTION THAT LOADS: while its page is on its way (the router marks the link with data-loading), the row's icon
4944
4965
  turns into a small ring that spins, and turns back when the page is there. */
4945
4966
  @keyframes cx-sb-spin { to { transform: rotate(360deg); } }
@@ -4947,7 +4968,7 @@ a.cx-bc-item:hover { color: var(--foreground); }
4947
4968
  .cx-sb-item[data-loading] > .mu-icon { opacity: 0; transform: scale(.4) rotate(-90deg); }
4948
4969
  .cx-sb-item[data-loading]::before { content: ""; position: absolute; left: var(--sb-pad, 8px); top: 50%; width: 16px; height: 16px; margin-top: -8px; box-sizing: border-box; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: cx-sb-spin .7s linear infinite; }
4949
4970
  @media (prefers-reduced-motion: reduce) {
4950
- .cx-sidebar .cx-sb-item, .cx-sb-label, .cx-sb-glabel, .cx-sb-count, .cx-sb-item > .mu-icon { transition: none; }
4971
+ .cx-sidebar .cx-sb-item, .cx-sb-label, .cx-sb-glabel, .cx-sidebar .cx-sb-glabel, .cx-sb-count, .cx-sb-item > .mu-icon, .cx-tip { transition: none; }
4951
4972
  .cx-sb-item[data-loading]::before { animation-duration: 1.6s; }
4952
4973
  }
4953
4974
  /* THE MONOGRAM: a soft tile in the name's own hue, the letters in a deep shade of the same hue, a hairline ring of it.
@@ -5252,3 +5273,77 @@ a.cx-bc-item:hover { color: var(--foreground); }
5252
5273
  @media (max-width: 760px) { .cx-ap .cx-ap-orb { --orb: 220px !important; right: -60px; bottom: -70px; } .cx-ap-typing, .cx-ap-said { right: 110px; bottom: 90px; } }
5253
5274
  /* A Field whose label lives elsewhere (a FieldHead above the control, with its action beside it): no empty label row. */
5254
5275
  .cn-field > .cn-label:empty { display: none; }
5276
+
5277
+ /* ══ INPUT GROUP · TILE: the field as a tile, its icon on a soft tile at the left, its label inside above the value and
5278
+ whatever helps it at the right (a «Libre» badge, a chevron, an eye). The same InputGroup and the same addons:
5279
+ InputAddon (inline-start) holds the Icon, InputAddon(align: "block-start") holds InputGroupText, inline-end the rest.
5280
+ A Combobox, a PhoneField or a PasswordField inside a tile group keep their own behaviour. */
5281
+ .style-nova .cn-input-group[data-variant="tile"] {
5282
+ display: grid; grid-template-columns: auto minmax(0, 1fr) auto; grid-template-areas: "media label end" "media input end";
5283
+ align-items: center; column-gap: 12px; row-gap: 0; height: auto; min-height: 62px; padding: 10px 14px;
5284
+ border-radius: calc(var(--radius) * 1.6); background: var(--background); border-color: var(--border);
5285
+ }
5286
+ .style-nova .cn-input-group[data-variant="tile"] > [data-align="inline-start"] {
5287
+ grid-area: media; width: 36px; height: 36px; padding: 0; margin: 0; border-radius: calc(var(--radius) * 1.1);
5288
+ background: var(--selected-soft); color: var(--selected);
5289
+ }
5290
+ .style-nova .cn-input-group[data-variant="tile"] > [data-align="block-start"] { grid-area: label; width: auto; padding: 0; }
5291
+ .style-nova .cn-input-group[data-variant="tile"] > [data-align="block-start"] .cn-input-group-text { font-size: 12.5px; font-weight: 500; }
5292
+ .style-nova .cn-input-group[data-variant="tile"] > [data-slot="input-group-control"],
5293
+ .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; }
5294
+ .style-nova .cn-input-group[data-variant="tile"] > [data-align="inline-end"] { grid-area: end; padding: 0; margin: 0; }
5295
+ .style-nova .cn-input-group[data-variant="tile"]:focus-within { border-color: var(--selected); box-shadow: 0 0 0 4px var(--selected-soft); }
5296
+ .style-nova .cn-input-group[data-variant="tile"]:has(> [data-slot="input-group-control"][aria-invalid="true"]) { border-color: var(--destructive); }
5297
+ /* a picker as a tile: the value is an InputGroupText where the input would go (cx-placeholder when nothing is chosen
5298
+ yet), and the whole tile sits in a Button with class cx-tile-btn that opens the page's Drawer or Sheet. */
5299
+ .style-nova .cn-input-group[data-variant="tile"] > .cn-input-group-text { grid-area: input; font-size: 16px; font-weight: 500; color: var(--foreground); }
5300
+ .style-nova .cn-input-group[data-variant="tile"] > .cn-input-group-text.cx-placeholder { color: var(--muted-foreground); font-weight: 400; }
5301
+ .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); }
5302
+ .cx-tile-btn:focus-visible { outline: none; }
5303
+ .style-nova .cx-tile-btn:hover > .cn-input-group[data-variant="tile"] { border-color: color-mix(in srgb, var(--selected) 35%, var(--border)); }
5304
+ .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); }
5305
+
5306
+ /* ChoiceItem with a picture: an ItemMedia in its slot sits first and the radio moves to the end, so the row reads as
5307
+ «what it is» first and the choice last (the sign-up's «Tengo un local / Trabajo por mi cuenta»). */
5308
+ .cx-choice:has(> .cn-item-media) { gap: 12px; }
5309
+ .cx-choice:has(> .cn-item-media) > .cx-radio { order: 9; margin-left: auto; }
5310
+ .cx-choice:has(> .cn-item-media) { align-items: center; }
5311
+ .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); }
5312
+
5313
+ /* Btn shape "pill": fully rounded ends, for the one call to action at the foot of a flow. */
5314
+ .cn-button[data-shape="pill"] { border-radius: 999px; }
5315
+
5316
+ /* Stepper (horizontal): its bars are «you are here», so they take the selected colour like the vertical one's
5317
+ current dot (--selected is --primary unless a skin says otherwise; Storio's is violet). */
5318
+ .cx-stp-bar > .cn-progress-indicator { background: var(--selected, var(--primary)); }
5319
+
5320
+ /* Storio's skin: a pressed Toggle reads as «chosen» in the selected violet (shadcn's muted grey vanishes on this skin's
5321
+ light surfaces). Other skins keep Nova's look. */
5322
+ [data-skin="storio"] .cn-toggle:is([aria-pressed="true"], [data-state="on"]) { background: var(--selected-soft); color: var(--selected); border-color: color-mix(in srgb, var(--selected) 45%, transparent); }
5323
+
5324
+ /* ══ MASCOT: the brand's mascot (an AssistantOrb that acts). The box is --m square; the sphere fills 2/3 of it (its
5325
+ radius is 33% of the box), so the body squashes from its bottom (83%) and the shadow sits just under it. */
5326
+ .cx-mascot { position: relative; width: var(--m, 240px); height: var(--m, 240px); flex: none; }
5327
+ .cx-mascot-rig, .cx-mascot-squash { position: absolute; inset: 0; will-change: transform; }
5328
+ .cx-mascot-rig { transform-origin: 50% 50%; }
5329
+ .cx-mascot-squash { transform-origin: 50% 83%; }
5330
+ .cx-mascot .cx-orb { transform: none !important; }
5331
+ .cx-orb[data-face="surface"] .cx-orb-eyes { display: none; }
5332
+ .cx-mascot-floor { position: absolute; left: 22%; right: 22%; top: 85%; height: 8%; border-radius: 50%; background: radial-gradient(closest-side, rgb(16 8 40 / .5), transparent); pointer-events: none; }
5333
+ .cx-mascot-fx { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }
5334
+ .cx-mascot-run { display: none; }
5335
+ /* how it speaks: a «paper» bubble (white, its name on top in violet, a drawn tail) placed by mascot.js up and to the
5336
+ left of the sphere, the tail's tip on its rim; it lives beside the box (not scaled with it) and pops in from the tail */
5337
+ .cx-mascot-say { position: absolute; left: 0; top: 0; z-index: 3; display: none; box-sizing: border-box; width: max-content; max-width: min(270px, calc(100vw - 48px)); padding: 11px 15px 12px; border-radius: 20px; background: #fff; color: #18122b; box-shadow: 0 1px 0 rgb(255 255 255 / .6) inset, 0 18px 40px -20px rgb(10 4 40 / .7); pointer-events: none; }
5338
+ .cx-mascot-say.on { display: block; }
5339
+ .cx-mascot-who { display: block; font-size: 11.5px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; margin-bottom: 3px; color: var(--selected, var(--primary, #6B46F2)); }
5340
+ .cx-mascot-who:empty { display: none; }
5341
+ .cx-mascot-line { display: block; font-size: 15px; line-height: 1.42; font-weight: 500; overflow-wrap: anywhere; }
5342
+ .cx-mascot-tail { position: absolute; left: var(--tail, 50%); bottom: -15px; width: 24px; height: 16px; overflow: visible; }
5343
+ .cx-mascot-tail path { fill: #fff; }
5344
+ .cx-mascot-say[data-side="right"] .cx-mascot-tail { transform: scaleX(-1); }
5345
+ .cx-mascot-tail-side { position: absolute; display: none; top: var(--tail, 40%); right: -15px; width: 16px; height: 24px; overflow: visible; }
5346
+ .cx-mascot-tail-side path { fill: #fff; }
5347
+ .cx-mascot-say[data-at="low"] .cx-mascot-tail { display: none; }
5348
+ .cx-mascot-say[data-at="low"] .cx-mascot-tail-side { display: block; }
5349
+ .cx-mascot-say[data-at="low"][data-side="right"] .cx-mascot-tail-side { right: auto; left: -15px; transform: scaleX(-1); }
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-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100; }
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 group-has-data-horizontal/field:text-balance last:mt-0 nth-last-2:-mt-1 [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary; }
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-has-[>svg]/alert:col-start-2; }
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 sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2; }
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 group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3; }
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 group-has-disabled/field:opacity-50 focus-visible:ring-3 aria-invalid:ring-3 group-has-[:focus-visible]/field-label:ring-0 group-has-[:focus-visible]/field-label:not-data-checked:border-input group-has-[:focus-visible]/field-label:data-checked:border-primary; }
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-has-data-[slot=item-description]/item:translate-y-0.5 group-has-data-[slot=item-description]/item:self-start; }
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-[:focus-visible]/field-label:ring-0 group-has-[:focus-visible]/field-label:not-data-checked:border-input group-has-[:focus-visible]/field-label:data-checked:border-primary; }
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-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! focus-visible:ring-2 data-active:font-medium; }
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 group-has-[:focus-visible]/field-label:ring-0 group-has-[:focus-visible]/field-label:border-transparent data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px]; }
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 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2; }
1283
- .style-nova .cn-input-group-addon-align-block-end { @apply px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-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-data-[slot=questionnaire-choice-description]/questionnaire-choice:translate-y-0.5 rounded-[4px]; }
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)] group-has-data-[slot=questionnaire-choice-description]/questionnaire-choice:translate-y-0.5 items-center justify-center rounded-md border font-mono text-[0.625rem] font-medium leading-none; }
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-has-data-[slot=message-footer]/message:-translate-y-8; }
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-has-data-[variant=ghost]/message:px-0; }
1349
- .style-nova .cn-message-footer { @apply text-xs font-medium text-muted-foreground px-3 group-has-data-[variant=ghost]/message:px-0; }
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@muten/shadcn",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "shadcn-style component registry for muten - own-the-source parts you add with `muten add`.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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) { gaze = { x: (Math.random() - 0.5) * 1.1, y: (Math.random() - 0.6) * 0.7 }; nextSaccade = T + 1.1 + Math.random() * 1.8; }
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") }
@@ -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 } }
@@ -182,10 +182,37 @@ watchReveal();
182
182
  };
183
183
 
184
184
  // ── tooltip on any [data-name] ──
185
+ // shadcn's: the foreground pill with its arrow, above the trigger (beside it in a rail, below when there is no room).
186
+ // It holds while the pointer travels inside its trigger (icon to label is not leaving, so it never blinks), it goes on
187
+ // a press, and in a sidebar it only speaks while the rail is folded to icons: open, the label is already on screen.
185
188
  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'); };
187
- const tipOff = (e) => { if (e.target.closest?.('[data-name]')) { tip.dataset.state = 'closed'; tip.hidden = true; } };
189
+ let tipFor = null;
190
+ const tipHide = () => { tipFor = null; tip.dataset.state = 'closed'; tip.hidden = true; };
191
+ const tipOn = (e) => {
192
+ const w = e.target.closest?.('[data-name]');
193
+ if (!w || !w.dataset.name || w === tipFor) return; /* an empty name (no data yet) says nothing */
194
+ const rail = w.closest('[data-slot="sidebar"]');
195
+ if (rail && rail.dataset.collapsed !== 'true' && w.dataset.slot !== 'tooltip-trigger') return;
196
+ tipFor = w; tip.textContent = w.dataset.name; tip.hidden = false; tip.dataset.state = 'closed';
197
+ const r = w.getBoundingClientRect();
198
+ if (w.dataset.side === 'right' || rail) {
199
+ tip.dataset.side = 'right'; tip.style.left = (r.right + 8) + 'px'; tip.style.top = (r.top + r.height / 2 - tip.offsetHeight / 2) + 'px';
200
+ } else {
201
+ place(tip, w, 'center', 8);
202
+ const above = r.top - tip.offsetHeight - 8 > 8;
203
+ tip.dataset.side = above ? 'top' : 'bottom';
204
+ tip.style.top = (above ? r.top - tip.offsetHeight - 8 : r.bottom + 8) + 'px';
205
+ tip.style.setProperty('--ax', (r.left + r.width / 2 - parseFloat(tip.style.left)) + 'px');
206
+ }
207
+ requestAnimationFrame(() => { if (tipFor === w) tip.dataset.state = 'open'; });
208
+ };
209
+ const tipOff = (e) => {
210
+ const w = e.target.closest?.('[data-name]');
211
+ if (!w || w !== tipFor || (e.relatedTarget && w.contains(e.relatedTarget))) return;
212
+ tipHide();
213
+ };
188
214
  ['pointerover', 'focusin'].forEach((t) => document.addEventListener(t, tipOn)); ['pointerout', 'focusout'].forEach((t) => document.addEventListener(t, tipOff));
215
+ document.addEventListener('pointerdown', () => { if (tipFor) tipHide(); }, true);
189
216
 
190
217
  // ── island: one floating notice for the whole app; never stacks, morphs its width to the next text ──
191
218
  const isl = document.createElement('div'); isl.className = 'cx-island cx-island-app'; isl.setAttribute('aria-live', 'polite'); isl.dataset.state = 'hidden';
@@ -1324,7 +1351,7 @@ let wheelsNow = [];
1324
1351
  if (periods) { const tl = $('.cx-tabs', root); initTabs(tl); tl.addEventListener('pick', (e) => { const d = periods[e.detail][1]; paint(d); const t2 = $$('.cx-tab', root); t2.forEach((x, i) => x.setAttribute('aria-selected', i === e.detail)); }); }
1325
1352
  $$('.cx-chart-bar', root).forEach((b, i) => b.animate([{ transform: 'scaleY(0)' }, { transform: 'scaleY(1)' }], { duration: 420, delay: i * 18, easing: 'cubic-bezier(.22,1,.36,1)', fill: 'backwards' }));
1326
1353
  };
1327
- root.addEventListener('pointerover', (e) => { const b = e.target.closest('.cx-chart-bar'); if (b) { tip.textContent = b.dataset.tipV; tip.hidden = false; const r = b.getBoundingClientRect(); tip.style.left = (r.left + r.width / 2 - tip.offsetWidth / 2) + 'px'; tip.style.top = (r.top + (1 - parseFloat(getComputedStyle(b).getPropertyValue('--v')) / 100) * r.height - tip.offsetHeight - 6) + 'px'; requestAnimationFrame(() => tip.dataset.state = 'open'); } });
1354
+ root.addEventListener('pointerover', (e) => { const b = e.target.closest('.cx-chart-bar'); if (b) { tip.textContent = b.dataset.tipV; tip.hidden = false; tip.dataset.side = 'top'; tip.style.removeProperty('--ax'); const r = b.getBoundingClientRect(); tip.style.left = (r.left + r.width / 2 - tip.offsetWidth / 2) + 'px'; tip.style.top = (r.top + (1 - parseFloat(getComputedStyle(b).getPropertyValue('--v')) / 100) * r.height - tip.offsetHeight - 6) + 'px'; requestAnimationFrame(() => tip.dataset.state = 'open'); } });
1328
1355
  root.addEventListener('pointerout', (e) => { if (e.target.closest('.cx-chart-bar')) { tip.dataset.state = 'closed'; tip.hidden = true; } });
1329
1356
  root.addEventListener('focusin', (e) => e.target.closest('.cx-chart-bar')?.dispatchEvent(new PointerEvent('pointerover', { bubbles: true })));
1330
1357
  paint(data);
@@ -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
+ }
@@ -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
- part Tooltip(text: text) { Stack class("cx-tip-wrap") data(slot: "tooltip-trigger", name: $text) { slot } }
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
- const gapOf = () => parseFloat(getComputedStyle(list).rowGap) || 0;
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 * step / k - gap)}px`; } else { el.style.display = 'none'; el.style.height = '0px'; }
34
- list.style.paddingBottom = `${basePad + Math.max(0, (count - to) * step / k)}px`;
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 = Math.floor((top - start) / step) - over, b = Math.ceil((bottom - start) / step) + over;
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,22 @@
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
+ },
582
+ {
583
+ "name": "media-query",
584
+ "deps": [],
585
+ "part": "MediaQuery",
586
+ "file": "registry/media-query.muten",
587
+ "component": "MediaQuery",
588
+ "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."
589
+ },
574
590
  {
575
591
  "name": "member-list",
576
592
  "deps": [