@heyramzi/ui 7.0.0 → 8.1.0

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.
Files changed (259) hide show
  1. package/COMPONENTS.md +28 -17
  2. package/README.md +6 -2
  3. package/package.json +25 -15
  4. package/scripts/sync-shadcn.mjs +17 -13
  5. package/scripts/sync-transitions.mjs +11 -11
  6. package/src/agents/agent-avatar.svelte +67 -114
  7. package/src/agents/agent-cast.svelte +31 -7
  8. package/src/agents/demo.svelte +55 -8
  9. package/src/agents/helpers.ts +58 -0
  10. package/src/agents/page-state.ts +70 -0
  11. package/src/agents/types.ts +3 -1
  12. package/src/ai/ai-button/AiButton.svelte +86 -0
  13. package/src/ai/ai-button/index.ts +4 -0
  14. package/src/ai/confirmation/Confirmation.svelte +8 -8
  15. package/src/ai/confirmation/confirmation-context.svelte.ts +2 -2
  16. package/src/ai/generation-progress/GenerationProgress.svelte +354 -0
  17. package/src/ai/generation-progress/index.ts +7 -0
  18. package/src/ai/index.ts +16 -6
  19. package/src/ai/prompt-input/PromptInput.svelte +19 -1
  20. package/src/ai/prompt-input/PromptInputSubmit.svelte +9 -3
  21. package/src/ai/reasoning/Reasoning.svelte +11 -8
  22. package/src/ambient.d.ts +4 -0
  23. package/src/animations/README.md +14 -14
  24. package/src/animations/index.css +3 -14
  25. package/src/animations/upstream-coverage.test.ts +1 -1
  26. package/src/components/copy/clipboard.test.ts +206 -0
  27. package/src/components/copy/clipboard.ts +49 -0
  28. package/src/components/copy/copied.svelte.ts +39 -0
  29. package/src/components/copy/copy-button.svelte +175 -0
  30. package/src/components/copy/copy-button.test.ts +110 -0
  31. package/src/components/copy/index.ts +3 -0
  32. package/src/components/data-table/data-table.test.ts +134 -0
  33. package/src/components/data-table/data-table.ts +77 -0
  34. package/src/components/data-table/index.ts +20 -0
  35. package/src/components/data-table/infinite-scroll.svelte +69 -0
  36. package/src/components/data-table/pagination.svelte +62 -0
  37. package/src/components/data-table/row-arrow.svelte +28 -0
  38. package/src/components/data-table/row-context-menu.svelte +106 -0
  39. package/src/components/data-table/row-context-menu.test.ts +18 -0
  40. package/src/components/data-table/sortable-head.svelte +56 -0
  41. package/src/components/data-table/table-skeleton.svelte +83 -0
  42. package/src/components/hold-to-confirm/hold-to-confirm.svelte +232 -0
  43. package/src/components/hold-to-confirm/index.ts +1 -0
  44. package/src/components/icon/icon.svelte +23 -0
  45. package/src/components/icon/index.ts +1 -0
  46. package/src/components/language-flag/index.ts +1 -0
  47. package/src/components/language-flag/language-flag.svelte +60 -0
  48. package/src/components/list-page/detail-empty.svelte +50 -0
  49. package/src/components/list-page/detail-header.svelte +107 -0
  50. package/src/components/list-page/detail-panel.svelte +90 -0
  51. package/src/components/list-page/detail-row.svelte +36 -0
  52. package/src/components/list-page/detail-stack.svelte +33 -0
  53. package/src/components/list-page/empty-state.svelte +91 -0
  54. package/src/components/list-page/filter-item.svelte +49 -0
  55. package/src/components/list-page/filter-popover.svelte +88 -0
  56. package/src/components/list-page/filter-section.ts +12 -0
  57. package/src/components/list-page/grouped-list.svelte +116 -0
  58. package/src/components/list-page/index.ts +28 -0
  59. package/src/components/list-page/list-page-shell.svelte +195 -0
  60. package/src/components/list-page/list-table.ts +19 -0
  61. package/src/components/list-page/master-detail.svelte +206 -0
  62. package/src/components/list-page/master-detail.test.ts +37 -0
  63. package/src/components/list-page/notice-bar.svelte +68 -0
  64. package/src/components/list-page/prev-next.svelte +104 -0
  65. package/src/components/list-page/queue-rail.ts +25 -0
  66. package/src/components/list-page/rail-body.svelte +29 -0
  67. package/src/components/list-page/rail-empty.svelte +51 -0
  68. package/src/components/list-page/rail-head.svelte +49 -0
  69. package/src/components/list-page/rail-row.svelte +163 -0
  70. package/src/components/list-page/section-host.ts +24 -0
  71. package/src/components/list-page/section-label.svelte +39 -0
  72. package/src/components/list-page/step.test.ts +21 -0
  73. package/src/components/list-page/step.ts +10 -0
  74. package/src/components/list-page/toolbar-search.svelte +63 -0
  75. package/src/components/list-page/toolbar-trigger.ts +23 -0
  76. package/src/components/list-page/types.ts +1 -0
  77. package/src/components/locale-switch/index.ts +1 -0
  78. package/src/components/locale-switch/locale-switch.svelte +59 -0
  79. package/src/components/mark/index.ts +1 -0
  80. package/src/components/mark/mark.svelte +70 -0
  81. package/src/components/open-in/index.ts +6 -0
  82. package/src/components/open-in/open-in.svelte +106 -0
  83. package/src/components/picker/index.ts +4 -0
  84. package/src/components/picker/mark-picker.svelte +84 -0
  85. package/src/components/picker/picker.svelte +143 -0
  86. package/src/components/picker/trigger.ts +42 -0
  87. package/src/components/picker/types.ts +26 -0
  88. package/src/components/segmented/animated-tabs.svelte +175 -0
  89. package/src/components/segmented/collapse.test.ts +49 -0
  90. package/src/components/segmented/collapse.ts +39 -0
  91. package/src/components/segmented/index.ts +41 -0
  92. package/src/components/segmented/section-runs.svelte +79 -0
  93. package/src/components/segmented/segment.svelte +106 -0
  94. package/src/components/segmented/segmented-filter.svelte +125 -0
  95. package/src/components/segmented/segmented-nav.svelte +82 -0
  96. package/src/components/segmented/segmented-overflow.svelte +85 -0
  97. package/src/components/segmented/segmented-track.svelte +181 -0
  98. package/src/components/segmented/segmented.test.ts +20 -0
  99. package/src/components/segmented/segmented.ts +80 -0
  100. package/src/components/star-rating-input/index.ts +9 -0
  101. package/src/components/star-rating-input/star-rating-input.svelte +256 -0
  102. package/src/components/svg/index.ts +1 -0
  103. package/src/components/svg/svg.svelte +9 -0
  104. package/src/effects/border-beam/BorderBeam.svelte +1 -1
  105. package/src/effects/image-comparison/ImageComparison.css +42 -8
  106. package/src/effects/image-comparison/ImageComparison.svelte +138 -21
  107. package/src/effects/image-comparison/compare.test.ts +17 -0
  108. package/src/effects/image-comparison/compare.ts +5 -0
  109. package/src/effects/interactive-hover-button/InteractiveHoverButton.svelte +338 -0
  110. package/src/effects/magic-card/MagicCard.css +47 -0
  111. package/src/effects/magic-card/MagicCard.svelte +57 -0
  112. package/src/effects/marquee/Marquee.css +27 -1
  113. package/src/effects/marquee/Marquee.svelte +67 -3
  114. package/src/effects/reveal/index.ts +2 -1
  115. package/src/effects/reveal/observe.test.ts +60 -0
  116. package/src/effects/reveal/observe.ts +33 -1
  117. package/src/effects/reveal/types.ts +7 -0
  118. package/src/effects/theme-toggle/ThemeToggle.css +199 -6
  119. package/src/effects/theme-toggle/ThemeToggle.svelte +51 -26
  120. package/src/gallery.test.ts +52 -0
  121. package/src/index.ts +5 -1
  122. package/src/lib/panel.ts +7 -1
  123. package/src/lib/theme-transition.ts +37 -11
  124. package/src/lib/tone.ts +48 -0
  125. package/src/shadcn/accordion/accordion-trigger.svelte +15 -6
  126. package/src/shadcn/alert/alert.svelte +3 -1
  127. package/src/shadcn/badge/badge.svelte +8 -7
  128. package/src/shadcn/breadcrumb/breadcrumb-ellipsis.svelte +2 -2
  129. package/src/shadcn/breadcrumb/breadcrumb-separator.svelte +2 -2
  130. package/src/shadcn/calendar/calendar-month-select.svelte +2 -2
  131. package/src/shadcn/calendar/calendar-next-button.svelte +2 -2
  132. package/src/shadcn/calendar/calendar-prev-button.svelte +2 -2
  133. package/src/shadcn/calendar/calendar-year-select.svelte +2 -2
  134. package/src/shadcn/calendar/calendar.svelte +6 -0
  135. package/src/shadcn/code-block/code-block-copy-button.svelte +6 -55
  136. package/src/shadcn/command/index.ts +3 -0
  137. package/src/shadcn/field/index.ts +3 -0
  138. package/src/shadcn/field/simple-field.svelte +71 -0
  139. package/src/shadcn/field/simple-field.test.ts +47 -0
  140. package/src/shadcn/message-scroller/message-scroller-button.svelte +2 -2
  141. package/src/shadcn/radio-group/radio-group-item.svelte +3 -2
  142. package/src/shadcn/sheet/sheet-content.svelte +1 -0
  143. package/src/shadcn/sheet/sheet.css +48 -0
  144. package/src/shadcn/sonner/sonner.svelte +7 -10
  145. package/src/shadcn/toggle-group/toggle-group.svelte +10 -3
  146. package/src/shadcn/trace/trace-collapsible-trigger.svelte +2 -2
  147. package/src/ai/composer/Composer.css +0 -108
  148. package/src/ai/composer/Composer.svelte +0 -111
  149. package/src/ai/composer/index.ts +0 -10
  150. package/src/animations/accordion.css +0 -58
  151. package/src/animations/icon-swap.css +0 -43
  152. package/src/animations/notification-badge.css +0 -81
  153. package/src/animations/panel.css +0 -86
  154. package/src/animations/reasoning-stream.css +0 -49
  155. package/src/animations/skeleton-reveal.css +0 -97
  156. package/src/animations/streaming-text.css +0 -31
  157. package/src/animations/tabs-sliding.css +0 -83
  158. package/src/animations/texts-reveal.css +0 -70
  159. package/src/animations/tooltip.css +0 -72
  160. package/src/animations/view-transition-reveal.ts +0 -74
  161. package/src/lib/utils.ts +0 -45
  162. package/src/showcase/AiSection.svelte +0 -150
  163. package/src/showcase/AnimationsSection.svelte +0 -1658
  164. package/src/showcase/EffectsSection.svelte +0 -152
  165. package/src/showcase/ShadcnSection.svelte +0 -27
  166. package/src/showcase/ShowcaseImport.svelte +0 -126
  167. package/src/showcase/ShowcaseList.svelte +0 -132
  168. package/src/showcase/ShowcaseNav.svelte +0 -247
  169. package/src/showcase/ShowcaseSection.svelte +0 -112
  170. package/src/showcase/ShowcaseShell.svelte +0 -55
  171. package/src/showcase/ShowcaseStage.svelte +0 -342
  172. package/src/showcase/ShowcaseVariant.svelte +0 -109
  173. package/src/showcase/assets/image-reveal/art-1.jpg +0 -0
  174. package/src/showcase/assets/image-reveal/art-2.jpg +0 -0
  175. package/src/showcase/assets/image-reveal/art-3.jpg +0 -0
  176. package/src/showcase/check.svg +0 -1
  177. package/src/showcase/chevron-arms.svg +0 -2
  178. package/src/showcase/chevron-down.svg +0 -1
  179. package/src/showcase/copy.svg +0 -2
  180. package/src/showcase/demos/ai/confirmation.svelte +0 -59
  181. package/src/showcase/demos/ai/loading-state.svelte +0 -19
  182. package/src/showcase/demos/ai/prompt-input.svelte +0 -57
  183. package/src/showcase/demos/ai/reasoning.svelte +0 -19
  184. package/src/showcase/demos/ai/thinking-dots.svelte +0 -15
  185. package/src/showcase/demos/ai/voice-waveform.svelte +0 -24
  186. package/src/showcase/demos/effects/animated-beam.svelte +0 -50
  187. package/src/showcase/demos/effects/animated-group.svelte +0 -47
  188. package/src/showcase/demos/effects/border-beam-default.svelte +0 -20
  189. package/src/showcase/demos/effects/border-beam-line.svelte +0 -20
  190. package/src/showcase/demos/effects/border-beam-mono.svelte +0 -20
  191. package/src/showcase/demos/effects/border-beam-sunset.svelte +0 -20
  192. package/src/showcase/demos/effects/image-comparison.svelte +0 -25
  193. package/src/showcase/demos/effects/image-reveal.svelte +0 -49
  194. package/src/showcase/demos/effects/in-view.svelte +0 -41
  195. package/src/showcase/demos/effects/integrations-hub.svelte +0 -82
  196. package/src/showcase/demos/effects/magnetic.svelte +0 -31
  197. package/src/showcase/demos/effects/marquee.svelte +0 -33
  198. package/src/showcase/demos/effects/progressive-blur.svelte +0 -47
  199. package/src/showcase/demos/effects/spotlight.svelte +0 -40
  200. package/src/showcase/demos/effects/text-effect.svelte +0 -55
  201. package/src/showcase/demos/shadcn/accordion.svelte +0 -45
  202. package/src/showcase/demos/shadcn/alert-dialog.svelte +0 -23
  203. package/src/showcase/demos/shadcn/alert.svelte +0 -30
  204. package/src/showcase/demos/shadcn/attachment.svelte +0 -71
  205. package/src/showcase/demos/shadcn/avatar.svelte +0 -30
  206. package/src/showcase/demos/shadcn/badge.svelte +0 -26
  207. package/src/showcase/demos/shadcn/breadcrumb.svelte +0 -34
  208. package/src/showcase/demos/shadcn/bubble.svelte +0 -36
  209. package/src/showcase/demos/shadcn/button.svelte +0 -11
  210. package/src/showcase/demos/shadcn/calendar.svelte +0 -8
  211. package/src/showcase/demos/shadcn/card.svelte +0 -39
  212. package/src/showcase/demos/shadcn/checkbox.svelte +0 -39
  213. package/src/showcase/demos/shadcn/code-block.svelte +0 -80
  214. package/src/showcase/demos/shadcn/collapsible.svelte +0 -20
  215. package/src/showcase/demos/shadcn/command.svelte +0 -48
  216. package/src/showcase/demos/shadcn/context-menu.svelte +0 -54
  217. package/src/showcase/demos/shadcn/demo-harness.svelte +0 -12
  218. package/src/showcase/demos/shadcn/demos.test.ts +0 -20
  219. package/src/showcase/demos/shadcn/dialog.svelte +0 -38
  220. package/src/showcase/demos/shadcn/dropdown-menu.svelte +0 -59
  221. package/src/showcase/demos/shadcn/empty.svelte +0 -29
  222. package/src/showcase/demos/shadcn/field.svelte +0 -115
  223. package/src/showcase/demos/shadcn/hover-card.svelte +0 -32
  224. package/src/showcase/demos/shadcn/index.ts +0 -382
  225. package/src/showcase/demos/shadcn/input-otp.svelte +0 -19
  226. package/src/showcase/demos/shadcn/input.svelte +0 -5
  227. package/src/showcase/demos/shadcn/label.svelte +0 -11
  228. package/src/showcase/demos/shadcn/marker.svelte +0 -30
  229. package/src/showcase/demos/shadcn/message-scroller.svelte +0 -44
  230. package/src/showcase/demos/shadcn/message.svelte +0 -75
  231. package/src/showcase/demos/shadcn/popover.svelte +0 -36
  232. package/src/showcase/demos/shadcn/progress.svelte +0 -13
  233. package/src/showcase/demos/shadcn/radio-group.svelte +0 -19
  234. package/src/showcase/demos/shadcn/scroll-area.svelte +0 -18
  235. package/src/showcase/demos/shadcn/select.svelte +0 -29
  236. package/src/showcase/demos/shadcn/separator.svelte +0 -12
  237. package/src/showcase/demos/shadcn/sheet.svelte +0 -48
  238. package/src/showcase/demos/shadcn/skeleton.svelte +0 -11
  239. package/src/showcase/demos/shadcn/slider.svelte +0 -7
  240. package/src/showcase/demos/shadcn/sonner.svelte +0 -18
  241. package/src/showcase/demos/shadcn/spinner.svelte +0 -11
  242. package/src/showcase/demos/shadcn/switch.svelte +0 -9
  243. package/src/showcase/demos/shadcn/table.svelte +0 -76
  244. package/src/showcase/demos/shadcn/tabs.svelte +0 -62
  245. package/src/showcase/demos/shadcn/textarea.svelte +0 -5
  246. package/src/showcase/demos/shadcn/toggle-group.svelte +0 -18
  247. package/src/showcase/demos/shadcn/toggle.svelte +0 -14
  248. package/src/showcase/demos/shadcn/tooltip.svelte +0 -13
  249. package/src/showcase/demos/shadcn/trace.svelte +0 -51
  250. package/src/showcase/heart.svg +0 -1
  251. package/src/showcase/index.ts +0 -38
  252. package/src/showcase/kit.test.ts +0 -72
  253. package/src/showcase/kit.ts +0 -16
  254. package/src/showcase/plus.svg +0 -1
  255. package/src/showcase/showcase-shell.css +0 -91
  256. package/src/showcase/stacked-section.css +0 -15
  257. package/src/showcase/success-check.svg +0 -1
  258. package/src/showcase/types.ts +0 -29
  259. package/src/svg-raw.d.ts +0 -7
@@ -0,0 +1,338 @@
1
+ <script lang="ts">
2
+ // =========================
3
+ // IMPORTS
4
+ // =========================
5
+ import { ArrowRight } from "phosphor-svelte";
6
+
7
+ // =========================
8
+ // TYPES
9
+ // =========================
10
+ type Props = {
11
+ href?: string;
12
+ /** `submit` posts the form the button sits in. */
13
+ type?: "button" | "submit";
14
+ disabled?: boolean;
15
+ size?: "default" | "md" | "sm";
16
+ tone?: "ink" | "coral";
17
+ beam?: boolean;
18
+ dot?: boolean;
19
+ class?: string;
20
+ onclick?: (event: MouseEvent) => void;
21
+ children: import("svelte").Snippet;
22
+ };
23
+
24
+ // =========================
25
+ // PROPS
26
+ // =========================
27
+ let { href, type = "button", disabled = false, size = "default", tone = "ink", beam = true, dot = true, class: className, onclick, children }: Props = $props();
28
+
29
+ // =========================
30
+ // DERIVED
31
+ // =========================
32
+ let classes = $derived(
33
+ [
34
+ "cta-hover",
35
+ size === "md" && "cta-hover--md",
36
+ size === "sm" && "cta-hover--sm",
37
+ tone === "coral" && "cta-hover--coral",
38
+ !beam && "cta-hover--no-beam",
39
+ !dot && "cta-hover--no-dot",
40
+ className,
41
+ ]
42
+ .filter(Boolean)
43
+ .join(" "),
44
+ );
45
+ </script>
46
+
47
+ <!-- =========================== -->
48
+ <!-- MARKUP -->
49
+ <!-- =========================== -->
50
+ {#if href}
51
+ <a {href} {onclick} class={classes}>
52
+ <span class="cta-hover__resting">
53
+ {#if dot}<span class="cta-hover__dot"></span>{/if}
54
+ <span class="cta-hover__label">{@render children()}</span>
55
+ {#if !dot}<ArrowRight class="cta-hover__arrow cta-hover__arrow--inline" weight="bold" />{/if}
56
+ </span>
57
+ {#if dot}
58
+ <span class="cta-hover__reveal" aria-hidden="true">
59
+ <span class="cta-hover__label">{@render children()}</span>
60
+ <ArrowRight class="cta-hover__arrow" weight="bold" />
61
+ </span>
62
+ {/if}
63
+ </a>
64
+ {:else}
65
+ <button {type} {disabled} {onclick} class={classes}>
66
+ <span class="cta-hover__resting">
67
+ {#if dot}<span class="cta-hover__dot"></span>{/if}
68
+ <span class="cta-hover__label">{@render children()}</span>
69
+ {#if !dot}<ArrowRight class="cta-hover__arrow cta-hover__arrow--inline" weight="bold" />{/if}
70
+ </span>
71
+ {#if dot}
72
+ <span class="cta-hover__reveal" aria-hidden="true">
73
+ <span class="cta-hover__label">{@render children()}</span>
74
+ <ArrowRight class="cta-hover__arrow" weight="bold" />
75
+ </span>
76
+ {/if}
77
+ </button>
78
+ {/if}
79
+
80
+ <!-- =========================== -->
81
+ <!-- STYLE -->
82
+ <!-- =========================== -->
83
+ <style>
84
+ @keyframes rotate-chime {
85
+ from {
86
+ transform: translate(-50%, -50%) rotate(0deg);
87
+ }
88
+ to {
89
+ transform: translate(-50%, -50%) rotate(360deg);
90
+ }
91
+ }
92
+
93
+ .cta-hover {
94
+ position: relative;
95
+ display: inline-flex;
96
+ align-items: center;
97
+ justify-content: center;
98
+ height: 44px;
99
+ padding: 0 24px;
100
+ border-radius: var(--radius-lg, 8px);
101
+ font-family: var(--font-sans);
102
+ font-size: 14px;
103
+ font-weight: 550;
104
+ letter-spacing: -0.01em;
105
+ cursor: pointer;
106
+ overflow: hidden;
107
+ background: transparent;
108
+ color: var(--color-background);
109
+ text-decoration: none;
110
+ will-change: transform;
111
+ transition: transform var(--duration-quick) var(--ease-smooth-out);
112
+ }
113
+
114
+ /* Spinning gradient circle, clipped by overflow:hidden to button shape */
115
+ .cta-hover::before {
116
+ content: "";
117
+ position: absolute;
118
+ top: 50%;
119
+ left: 50%;
120
+ width: max(200%, 150px);
121
+ aspect-ratio: 1;
122
+ border-radius: 50%;
123
+ background: conic-gradient(
124
+ transparent 0deg,
125
+ transparent 220deg,
126
+ oklch(0.65 0.20 30) 280deg,
127
+ oklch(0.82 0.10 30) 320deg,
128
+ transparent 360deg
129
+ );
130
+ transform: translate(-50%, -50%);
131
+ animation: rotate-chime 5s linear infinite;
132
+ pointer-events: none;
133
+ z-index: 0;
134
+ transition: opacity var(--duration-fast) var(--ease-smooth-out);
135
+ }
136
+
137
+ :global(.dark) .cta-hover::before {
138
+ background: conic-gradient(
139
+ transparent 0deg,
140
+ transparent 220deg,
141
+ oklch(0.78 0.08 40) 280deg,
142
+ oklch(0.88 0.04 40) 320deg,
143
+ transparent 360deg
144
+ );
145
+ }
146
+
147
+ /* Inner fill: leaves 1.5px border gap showing the spinning gradient */
148
+ .cta-hover::after {
149
+ content: "";
150
+ position: absolute;
151
+ inset: 1.5px;
152
+ border-radius: calc(var(--radius-lg, 8px) - 1.5px);
153
+ background: var(--color-foreground);
154
+ z-index: 1;
155
+ pointer-events: none;
156
+ }
157
+
158
+ /* 40px, between 44px and 32px, for a CTA in a condensed bar. 32px is too
159
+ small to read as the main action, so only the label shrinks, to 13px. */
160
+ .cta-hover--md {
161
+ height: 40px;
162
+ padding: 0 24px;
163
+ font-size: 13px;
164
+ }
165
+
166
+ .cta-hover--md .cta-hover__dot {
167
+ width: 7px;
168
+ height: 7px;
169
+ }
170
+
171
+ .cta-hover--sm {
172
+ height: 32px;
173
+ padding: 0 20px;
174
+ font-size: 13px;
175
+ }
176
+
177
+ .cta-hover--sm .cta-hover__dot {
178
+ width: 6px;
179
+ height: 6px;
180
+ }
181
+
182
+ .cta-hover--no-beam::before {
183
+ display: none;
184
+ }
185
+
186
+ .cta-hover--no-beam {
187
+ background: var(--color-foreground);
188
+ }
189
+
190
+ /* No-dot variant: arrow inline, shifts right on hover */
191
+ :global(.cta-hover__arrow--inline) {
192
+ width: 14px;
193
+ height: 14px;
194
+ flex-shrink: 0;
195
+ transition: transform var(--duration-quick) var(--ease-smooth-out);
196
+ }
197
+
198
+ @media (hover: hover) and (pointer: fine) {
199
+ .cta-hover--no-dot:hover :global(.cta-hover__arrow--inline) {
200
+ transform: translateX(3px);
201
+ }
202
+ }
203
+
204
+ .cta-hover:active {
205
+ transform: scale(0.97);
206
+ }
207
+
208
+ /* In flight: the label still reads, the button stops answering. */
209
+ .cta-hover:disabled {
210
+ opacity: 0.7;
211
+ cursor: default;
212
+ }
213
+
214
+ .cta-hover:disabled:active {
215
+ transform: none;
216
+ }
217
+
218
+ /* The CTA shipped with no focus ring. This is shadcn's ring-3 ring-ring/50,
219
+ written as a box-shadow because this component is plain CSS. */
220
+ .cta-hover:focus-visible {
221
+ outline: none;
222
+ box-shadow: 0 0 0 3px color-mix(in oklch, var(--color-ring) 50%, transparent);
223
+ }
224
+
225
+ .cta-hover__resting {
226
+ position: relative;
227
+ z-index: 2;
228
+ display: flex;
229
+ align-items: center;
230
+ justify-content: center;
231
+ gap: 8px;
232
+ width: 100%;
233
+ transition: opacity var(--duration-fast) var(--ease-smooth-out), transform var(--duration-fast) var(--ease-smooth-out);
234
+ }
235
+
236
+ /* terra-600, not terra-400: the dot becomes the hover fill under a cream
237
+ label, and cream on terra-400 is 3.24:1. It works in both themes. */
238
+ .cta-hover__dot {
239
+ width: 8px;
240
+ height: 8px;
241
+ border-radius: 50%;
242
+ background: var(--color-terra-600);
243
+ flex-shrink: 0;
244
+ transition: transform var(--duration-fast) var(--ease-smooth-out);
245
+ }
246
+
247
+ .cta-hover__label {
248
+ white-space: nowrap;
249
+ transition:
250
+ transform var(--duration-fast) var(--ease-smooth-out),
251
+ opacity var(--duration-fast) var(--ease-smooth-out);
252
+ }
253
+
254
+ .cta-hover__reveal {
255
+ position: absolute;
256
+ inset: 0;
257
+ z-index: 3;
258
+ display: flex;
259
+ align-items: center;
260
+ justify-content: center;
261
+ gap: 8px;
262
+ opacity: 0;
263
+ transform: translateX(48px);
264
+ transition:
265
+ transform var(--duration-fast) var(--ease-smooth-out),
266
+ opacity var(--duration-fast) var(--ease-smooth-out);
267
+ /* The reveal label always lands on the terra-600 dot fill, where cream
268
+ clears AA at button-label sizes. */
269
+ color: var(--color-brand-cream);
270
+ pointer-events: none;
271
+ }
272
+
273
+ :global(.cta-hover__arrow) {
274
+ width: 16px;
275
+ height: 16px;
276
+ flex-shrink: 0;
277
+ }
278
+
279
+ /* Coral fill, heavier than the ink default. It comes after the .dark rules
280
+ so one set of colors covers both themes. */
281
+ .cta-hover--coral {
282
+ /* Cream label on terra-600: on terra-400 it's 3.24:1 and fails AA. */
283
+ color: var(--color-brand-cream);
284
+ }
285
+
286
+ .cta-hover--coral::after {
287
+ background: var(--color-terra-600);
288
+ }
289
+
290
+ .cta-hover--coral.cta-hover--no-beam {
291
+ background: var(--color-terra-600);
292
+ }
293
+
294
+ .cta-hover--coral::before,
295
+ :global(.dark) .cta-hover--coral::before {
296
+ background: conic-gradient(
297
+ transparent 0deg,
298
+ transparent 220deg,
299
+ var(--color-brand-cream) 280deg,
300
+ var(--color-terra-200) 320deg,
301
+ transparent 360deg
302
+ );
303
+ }
304
+
305
+ /* Ink dot on coral, so hover turns the button black under a cream label
306
+ instead of washing it out to cream. */
307
+ .cta-hover--coral .cta-hover__dot,
308
+ :global(.dark) .cta-hover--coral .cta-hover__dot {
309
+ background: var(--color-brand-ink);
310
+ }
311
+
312
+ @media (hover: hover) and (pointer: fine) {
313
+ .cta-hover:hover::before {
314
+ opacity: 0;
315
+ }
316
+
317
+ .cta-hover:hover .cta-hover__dot {
318
+ transform: scale(100);
319
+ }
320
+
321
+ .cta-hover:not(.cta-hover--no-dot):hover .cta-hover__resting .cta-hover__label {
322
+ transform: translateX(48px);
323
+ opacity: 0;
324
+ }
325
+
326
+ .cta-hover:not(.cta-hover--no-dot):hover .cta-hover__reveal {
327
+ opacity: 1;
328
+ transform: translateX(-4px);
329
+ }
330
+ }
331
+
332
+ @media (prefers-reduced-motion: reduce) {
333
+ .cta-hover::before {
334
+ animation: none;
335
+ opacity: 0;
336
+ }
337
+ }
338
+ </style>
@@ -0,0 +1,47 @@
1
+ /* The glow and the hover edge read `--magic-card-glow`, so a caller picks the
2
+ brand colour with a class: `[--magic-card-glow:var(--brand-accent)]`. */
3
+ .magic-card {
4
+ transition:
5
+ border-color 300ms ease,
6
+ box-shadow 300ms ease;
7
+ }
8
+
9
+ .magic-card:hover {
10
+ border-color: color-mix(in oklch, var(--magic-card-glow, var(--color-primary)) 30%, var(--color-border));
11
+ box-shadow: 0 0 0 1px color-mix(in oklch, var(--magic-card-glow, var(--color-primary)) 8%, transparent);
12
+ }
13
+
14
+ .magic-card:focus-within {
15
+ outline: 2px solid var(--magic-card-glow, var(--color-primary));
16
+ outline-offset: 2px;
17
+ }
18
+
19
+ .magic-card__glow {
20
+ position: absolute;
21
+ inset: 0;
22
+ border-radius: inherit;
23
+ pointer-events: none;
24
+ opacity: 0;
25
+ transition: opacity 300ms ease;
26
+ background: radial-gradient(
27
+ var(--magic-card-radius, 500px) circle at var(--magic-card-x, 50%) var(--magic-card-y, 50%),
28
+ color-mix(
29
+ in oklch,
30
+ var(--magic-card-glow, var(--color-primary)) var(--magic-card-strength, 6%),
31
+ transparent
32
+ )
33
+ 0%,
34
+ transparent 40%
35
+ );
36
+ }
37
+
38
+ .magic-card:hover .magic-card__glow {
39
+ opacity: 1;
40
+ }
41
+
42
+ @media (prefers-reduced-motion: reduce) {
43
+ .magic-card,
44
+ .magic-card__glow {
45
+ transition: none;
46
+ }
47
+ }
@@ -0,0 +1,57 @@
1
+ <script lang="ts">
2
+ // =========================
3
+ // IMPORTS
4
+ // =========================
5
+ import "./MagicCard.css";
6
+ import { cn } from "@heyramzi/utils";
7
+ import type { Snippet } from "svelte";
8
+
9
+ // =========================
10
+ // TYPES
11
+ // =========================
12
+ interface Props {
13
+ children: Snippet;
14
+ /** Draw the pointer-following glow. Off leaves the frosted card and the hover edge. */
15
+ glow?: boolean;
16
+ /** Glow radius in px. */
17
+ radius?: number;
18
+ class?: string;
19
+ /** Classes for the padded box around the children. */
20
+ contentClass?: string;
21
+ }
22
+
23
+ // =========================
24
+ // PROPS
25
+ // =========================
26
+ let { children, glow = true, radius = 500, class: className, contentClass }: Props = $props();
27
+
28
+ // =========================
29
+ // FUNCTIONS
30
+ // =========================
31
+ function handlePointerMove(event: PointerEvent) {
32
+ const card = event.currentTarget as HTMLElement;
33
+ const rect = card.getBoundingClientRect();
34
+ card.style.setProperty("--magic-card-x", `${event.clientX - rect.left}px`);
35
+ card.style.setProperty("--magic-card-y", `${event.clientY - rect.top}px`);
36
+ }
37
+ </script>
38
+
39
+ <!-- =========================== -->
40
+ <!-- MARKUP -->
41
+ <!-- =========================== -->
42
+ <div
43
+ role="group"
44
+ onpointermove={glow ? handlePointerMove : undefined}
45
+ style:--magic-card-radius="{radius}px"
46
+ class={cn(
47
+ "magic-card relative overflow-hidden rounded-lg border border-border/50 bg-card/85 shadow-xs backdrop-blur-md [--magic-card-strength:6%] dark:border-border/40 dark:bg-card/70 dark:[--magic-card-strength:10%]",
48
+ className,
49
+ )}
50
+ >
51
+ {#if glow}
52
+ <div class="magic-card__glow" aria-hidden="true"></div>
53
+ {/if}
54
+ <div class={cn("relative z-[1] p-6", contentClass)}>
55
+ {@render children()}
56
+ </div>
57
+ </div>
@@ -14,7 +14,7 @@
14
14
  flex-shrink: 0;
15
15
  justify-content: space-around;
16
16
  gap: var(--gap, 1rem);
17
- animation: marquee-x var(--duration, 40s) linear infinite;
17
+ animation: marquee-x var(--duration, 40s) linear var(--marquee-delay, 0s) infinite;
18
18
  }
19
19
  .marquee--vertical .marquee__track {
20
20
  flex-direction: column;
@@ -23,6 +23,32 @@
23
23
  .marquee__track--reverse {
24
24
  animation-direction: reverse;
25
25
  }
26
+ .marquee[data-overflow-only] {
27
+ width: fit-content;
28
+ max-width: 100%;
29
+ padding: 0;
30
+ }
31
+ .marquee[data-still] .marquee__track {
32
+ animation: none;
33
+ }
34
+ .marquee[data-fade] {
35
+ mask-image: linear-gradient(
36
+ to right,
37
+ transparent,
38
+ black var(--marquee-fade),
39
+ black calc(100% - var(--marquee-fade)),
40
+ transparent
41
+ );
42
+ }
43
+ .marquee--vertical[data-fade] {
44
+ mask-image: linear-gradient(
45
+ to bottom,
46
+ transparent,
47
+ black var(--marquee-fade),
48
+ black calc(100% - var(--marquee-fade)),
49
+ transparent
50
+ );
51
+ }
26
52
  .marquee[data-pause-hover]:hover .marquee__track {
27
53
  animation-play-state: paused;
28
54
  }
@@ -18,6 +18,18 @@
18
18
  repeat?: number;
19
19
  duration?: string;
20
20
  gap?: string;
21
+ /** Scroll speed in px/s. Sets the loop time from the measured track, and wins over `duration`. */
22
+ speed?: number;
23
+ /** Fades both ends along the scroll axis. Any CSS length, such as `16px` or `13%`. */
24
+ fade?: string;
25
+ /** Waits this many ms before the first scroll starts. */
26
+ delay?: number;
27
+ /**
28
+ * Scrolls only while the content is longer than the box, for a line of text with a max width.
29
+ * The box shrinks to its content, drops its padding, and shows one still copy when it fits.
30
+ */
31
+ overflowOnly?: boolean;
32
+ style?: string;
21
33
  }
22
34
 
23
35
  // =========================
@@ -32,6 +44,11 @@
32
44
  repeat = 4,
33
45
  duration,
34
46
  gap,
47
+ speed,
48
+ fade,
49
+ delay,
50
+ overflowOnly = false,
51
+ style,
35
52
  }: MarqueeProps = $props();
36
53
 
37
54
  // =========================
@@ -40,6 +57,9 @@
40
57
  let rootEl = $state<HTMLDivElement | null>(null);
41
58
  let isVisible = $state(true);
42
59
  let tabActive = $state(true);
60
+ let boxSize = $state(0);
61
+ let trackSize = $state(0);
62
+ let gapSize = $state(0);
43
63
 
44
64
  // =========================
45
65
  // DERIVED
@@ -48,10 +68,27 @@
48
68
  // background-tab marquee stops burning paint/compositor budget.
49
69
  let paused = $derived(!isVisible || !tabActive);
50
70
 
71
+ // WHY: the 0.5px slack absorbs the rounding between a fractional text width and its box.
72
+ let overflowing = $derived(trackSize > boxSize + 0.5);
73
+ let still = $derived(overflowOnly && !overflowing);
74
+ // WHY: two copies loop seamlessly once one copy is wider than the box.
75
+ let copies = $derived(overflowOnly ? (overflowing ? 2 : 1) : repeat);
76
+ let loopSeconds = $derived(
77
+ speed && trackSize > 0 ? (trackSize + gapSize) / speed : undefined,
78
+ );
79
+
51
80
  // WHY: only emit the CSS vars that were passed as props, so a consumer can
52
81
  // still override timing or spacing with a `[--duration:24s]` utility class.
53
82
  let styleVars = $derived(
54
- [duration && `--duration:${duration}`, gap && `--gap:${gap}`].filter(Boolean).join(";"),
83
+ [
84
+ loopSeconds ? `--duration:${loopSeconds}s` : duration && `--duration:${duration}`,
85
+ gap && `--gap:${gap}`,
86
+ fade && `--marquee-fade:${fade}`,
87
+ delay && `--marquee-delay:${delay}ms`,
88
+ style,
89
+ ]
90
+ .filter(Boolean)
91
+ .join(";"),
55
92
  );
56
93
 
57
94
  // =========================
@@ -69,6 +106,27 @@
69
106
  return () => observer.disconnect();
70
107
  });
71
108
 
109
+ $effect(() => {
110
+ const firstTrack = rootEl?.firstElementChild;
111
+ if (!rootEl || !firstTrack || !(speed || overflowOnly)) return;
112
+ const root = rootEl;
113
+ const measure = () => {
114
+ const computed = getComputedStyle(root);
115
+ const box = root.getBoundingClientRect();
116
+ const track = firstTrack.getBoundingClientRect();
117
+ const padStart = parseFloat(vertical ? computed.paddingTop : computed.paddingLeft) || 0;
118
+ const padEnd = parseFloat(vertical ? computed.paddingBottom : computed.paddingRight) || 0;
119
+ boxSize = (vertical ? box.height : box.width) - padStart - padEnd;
120
+ trackSize = vertical ? track.height : track.width;
121
+ gapSize = parseFloat(vertical ? computed.rowGap : computed.columnGap) || 0;
122
+ };
123
+ measure();
124
+ const observer = new ResizeObserver(measure);
125
+ observer.observe(root);
126
+ observer.observe(firstTrack);
127
+ return () => observer.disconnect();
128
+ });
129
+
72
130
  $effect(() => {
73
131
  const onVisibility = () => {
74
132
  tabActive = document.visibilityState === "visible";
@@ -87,9 +145,15 @@
87
145
  style={styleVars}
88
146
  data-pause-hover={pauseOnHover ? "" : undefined}
89
147
  data-paused={paused ? "" : undefined}
148
+ data-overflow-only={overflowOnly ? "" : undefined}
149
+ data-still={still ? "" : undefined}
150
+ data-fade={fade && !still ? "" : undefined}
90
151
  >
91
- {#each Array(repeat).fill(0) as _, i (i)}
92
- <div class={cn("marquee__track", reverse && "marquee__track--reverse")}>
152
+ {#each Array(copies).fill(0) as _, i (i)}
153
+ <div
154
+ class={cn("marquee__track", reverse && "marquee__track--reverse")}
155
+ aria-hidden={i > 0 ? "true" : undefined}
156
+ >
93
157
  {@render children?.()}
94
158
  </div>
95
159
  {/each}
@@ -1,10 +1,11 @@
1
1
  export { default as InView } from "./InView.svelte";
2
2
  export { default as AnimatedGroup } from "./AnimatedGroup.svelte";
3
3
  export { default as TextEffect } from "./TextEffect.svelte";
4
- export { observeInView } from "./observe.js";
4
+ export { observeInView, onInView } from "./observe.js";
5
5
  export type {
6
6
  RevealPreset,
7
7
  RevealPer,
8
8
  RevealTiming,
9
9
  RevealViewOptions,
10
+ InViewActionOptions,
10
11
  } from "./types.js";
@@ -0,0 +1,60 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
2
+ import { onInView } from "./observe.js";
3
+
4
+ type Callback = (entries: { isIntersecting: boolean }[]) => void;
5
+
6
+ let callbacks: Callback[] = [];
7
+
8
+ class FakeObserver {
9
+ constructor(callback: Callback) {
10
+ callbacks.push(callback);
11
+ }
12
+ observe() {}
13
+ disconnect() {}
14
+ }
15
+
16
+ function fire(isIntersecting: boolean) {
17
+ for (const callback of callbacks) callback([{ isIntersecting }]);
18
+ }
19
+
20
+ describe("onInView", () => {
21
+ beforeEach(() => {
22
+ callbacks = [];
23
+ vi.stubGlobal("IntersectionObserver", FakeObserver);
24
+ });
25
+
26
+ afterEach(() => {
27
+ vi.unstubAllGlobals();
28
+ });
29
+
30
+ it("calls onEnter once when the node first shows", () => {
31
+ const onEnter = vi.fn();
32
+ onInView(document.createElement("div"), { onEnter });
33
+ fire(true);
34
+ fire(true);
35
+ expect(onEnter).toHaveBeenCalledTimes(1);
36
+ });
37
+
38
+ it("calls onLeave only after an enter, and only when once is off", () => {
39
+ const onEnter = vi.fn();
40
+ const onLeave = vi.fn();
41
+ onInView(document.createElement("div"), { once: false, onEnter, onLeave });
42
+ fire(false);
43
+ expect(onLeave).not.toHaveBeenCalled();
44
+ fire(true);
45
+ fire(false);
46
+ fire(true);
47
+ expect(onEnter).toHaveBeenCalledTimes(2);
48
+ expect(onLeave).toHaveBeenCalledTimes(1);
49
+ });
50
+
51
+ it("reads the callbacks from the latest update", () => {
52
+ const first = vi.fn();
53
+ const second = vi.fn();
54
+ const action = onInView(document.createElement("div"), { onEnter: first });
55
+ action?.update?.({ onEnter: second });
56
+ fire(true);
57
+ expect(first).not.toHaveBeenCalled();
58
+ expect(second).toHaveBeenCalledTimes(1);
59
+ });
60
+ });