@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,256 @@
1
+ <script lang="ts" module>
2
+ /**
3
+ * Two sizes, picked by the job: `sm` reads a value back in a row or cell,
4
+ * `lg` is the ask.
5
+ */
6
+ // A middle `md` step used to exist. Nobody could place it, so the same question
7
+ // ended up asked at three sizes.
8
+ export type StarRatingSize = "sm" | "lg";
9
+
10
+ const STAR_SIZE: Record<StarRatingSize, string> = {
11
+ sm: "size-4",
12
+ lg: "size-7",
13
+ };
14
+
15
+ const HIT_SIZE: Record<StarRatingSize, string> = {
16
+ sm: "p-0.5",
17
+ lg: "p-1",
18
+ };
19
+
20
+ /** Pop duration plus the longest stagger, after which the class can come off. */
21
+ const POP_WINDOW_MS = 350 + 4 * 40;
22
+ </script>
23
+
24
+ <script lang="ts">
25
+ import { Star } from "phosphor-svelte";
26
+ import { cn } from "@heyramzi/utils";
27
+
28
+ type Props = {
29
+ /** Current rating value (0 = unrated). */
30
+ value: number;
31
+ /** Maximum rating. Defaults to 5. */
32
+ max?: number;
33
+ /** Visual size of each star. `sm` reads back, `lg` asks. */
34
+ size?: StarRatingSize;
35
+ /** Render as static display, no hover or click. */
36
+ readonly?: boolean;
37
+ /** Disable interaction. */
38
+ disabled?: boolean;
39
+ /** Form field name for native submission. When set, a hidden input is rendered. */
40
+ name?: string;
41
+ /** ARIA label for the radiogroup. */
42
+ ariaLabel?: string;
43
+ /** Per-step descriptive labels (length must equal max). */
44
+ labels?: readonly string[];
45
+ /** Show the label of the currently hovered/selected step beside the stars. */
46
+ showLabel?: boolean;
47
+ class?: string;
48
+ onchange?: (value: number) => void;
49
+ };
50
+
51
+ let {
52
+ value = $bindable(0),
53
+ max = 5,
54
+ size = "sm",
55
+ readonly = false,
56
+ disabled = false,
57
+ name,
58
+ ariaLabel,
59
+ labels,
60
+ showLabel = false,
61
+ class: className,
62
+ onchange,
63
+ }: Props = $props();
64
+
65
+ let hover = $state(0);
66
+ /** Held for the length of the pop, then dropped so the next pick can replay it. */
67
+ let popping = $state(false);
68
+ let popTimer: ReturnType<typeof setTimeout> | undefined;
69
+ let displayed = $derived(hover || value);
70
+ let starSize = $derived(STAR_SIZE[size]);
71
+ let hitSize = $derived(HIT_SIZE[size]);
72
+ let groupAriaLabel = $derived(ariaLabel ?? `Rating, ${max} stars`);
73
+ let labelText = $derived(
74
+ displayed > 0 && labels && labels.length >= displayed ? labels[displayed - 1] : "",
75
+ );
76
+
77
+ function buttonAriaLabel(step: number): string {
78
+ const stepLabel = labels?.[step - 1];
79
+ const base = `${step} of ${max}`;
80
+ return stepLabel ? `${base} (${stepLabel})` : base;
81
+ }
82
+
83
+ function setRating(step: number) {
84
+ if (readonly || disabled) return;
85
+ value = step;
86
+ onchange?.(step);
87
+
88
+ // Drop the class before re-adding it, or a second pick inside the animation
89
+ // window finds it already set and nothing replays.
90
+ popping = false;
91
+ clearTimeout(popTimer);
92
+ requestAnimationFrame(() => {
93
+ popping = true;
94
+ popTimer = setTimeout(() => (popping = false), POP_WINDOW_MS);
95
+ });
96
+ }
97
+
98
+ function handleEnter(step: number) {
99
+ if (readonly || disabled) return;
100
+ hover = step;
101
+ }
102
+
103
+ function handleLeave() {
104
+ if (readonly || disabled) return;
105
+ hover = 0;
106
+ }
107
+ </script>
108
+
109
+ <!-- One star, drawn once. It used to be written twice, once per branch, and the
110
+ two copies had already drifted apart on the glow. -->
111
+ {#snippet star(step: number)}
112
+ <Star
113
+ class={cn("star", starSize, step <= displayed && "is-filled")}
114
+ weight={step <= displayed ? "fill" : "regular"}
115
+ />
116
+ {/snippet}
117
+
118
+ <div class={cn("rating", `rating-${size}`, className)}>
119
+ <div
120
+ class={cn("stars", popping && "is-popping")}
121
+ role={readonly ? "img" : "radiogroup"}
122
+ aria-label={groupAriaLabel}
123
+ onmouseleave={handleLeave}
124
+ >
125
+ {#each Array(max) as _, i (i)}
126
+ {@const step = i + 1}
127
+ {#if readonly}
128
+ <span class={cn("hit", hitSize)}>{@render star(step)}</span>
129
+ {:else}
130
+ <button
131
+ type="button"
132
+ role="radio"
133
+ aria-checked={value === step}
134
+ aria-label={buttonAriaLabel(step)}
135
+ {disabled}
136
+ data-on={step <= value}
137
+ onclick={() => setRating(step)}
138
+ onmouseenter={() => handleEnter(step)}
139
+ onfocus={() => handleEnter(step)}
140
+ onblur={handleLeave}
141
+ style:--pop-delay="{i * 40}ms"
142
+ class={cn("hit hit-button", hitSize, disabled && "is-disabled")}
143
+ >
144
+ {@render star(step)}
145
+ </button>
146
+ {/if}
147
+ {/each}
148
+ </div>
149
+
150
+ {#if showLabel && labelText}
151
+ <span class="rating-caption">{labelText}</span>
152
+ {/if}
153
+
154
+ {#if name}
155
+ <input type="hidden" {name} value={value} />
156
+ {/if}
157
+ </div>
158
+
159
+ <style>
160
+ .rating {
161
+ --like-pop: var(--duration-medium);
162
+ --like-pop-ease: var(--ease-smooth-out);
163
+ --like-fill: var(--duration-quick);
164
+ --like-ease: var(--ease-smooth-out);
165
+
166
+ display: flex;
167
+ align-items: center;
168
+ gap: 0.25rem;
169
+ }
170
+
171
+ .stars {
172
+ display: flex;
173
+ align-items: center;
174
+ }
175
+
176
+ .hit {
177
+ display: inline-flex;
178
+ border-radius: var(--radius-sm);
179
+ }
180
+
181
+ .hit-button {
182
+ transition: transform var(--duration-quick, 150ms) var(--ease-smooth-out, ease);
183
+ }
184
+
185
+ .hit-button:focus-visible {
186
+ outline: none;
187
+ box-shadow: 0 0 0 2px var(--color-ring);
188
+ }
189
+
190
+ .hit-button:not(.is-disabled):hover {
191
+ transform: scale(1.1);
192
+ }
193
+
194
+ .hit-button:not(.is-disabled):active {
195
+ transform: scale(0.95);
196
+ }
197
+
198
+ .is-disabled {
199
+ cursor: not-allowed;
200
+ opacity: 0.6;
201
+ }
202
+
203
+ .rating :global(.star) {
204
+ color: var(--color-border);
205
+ transition: color var(--like-fill) var(--like-ease);
206
+ }
207
+
208
+ .rating :global(.star.is-filled) {
209
+ color: var(--color-warning-400);
210
+ }
211
+
212
+ /* The glow is the size's business, not a prop: it belongs to the ask, where
213
+ the stars are large enough to carry it, and it turned to mud at 16px. */
214
+ .rating-lg :global(.star.is-filled) {
215
+ filter: drop-shadow(0 0 4px color-mix(in oklab, var(--color-warning-400) 45%, transparent));
216
+ }
217
+
218
+ /* transitions.dev like-button pop. Every selected star pops, left to right,
219
+ as one gesture. A class on the group drives it, since rebuilding the row
220
+ would drop keyboard focus. */
221
+ .is-popping .hit-button[data-on="true"] {
222
+ animation: t-like-pop var(--like-pop) var(--like-pop-ease) var(--pop-delay, 0ms);
223
+ }
224
+
225
+ @keyframes t-like-pop {
226
+ 0% {
227
+ transform: scale(1);
228
+ }
229
+ 30% {
230
+ transform: scale(0.82);
231
+ }
232
+ 100% {
233
+ transform: scale(1);
234
+ }
235
+ }
236
+
237
+ .rating-caption {
238
+ font-size: 0.75rem;
239
+ font-weight: 500;
240
+ font-variant-numeric: tabular-nums;
241
+ color: var(--color-muted-foreground);
242
+ }
243
+
244
+ @media (prefers-reduced-motion: reduce) {
245
+ .hit-button,
246
+ .is-popping .hit-button[data-on="true"] {
247
+ animation: none;
248
+ transition: none;
249
+ }
250
+
251
+ .hit-button:not(.is-disabled):hover,
252
+ .hit-button:not(.is-disabled):active {
253
+ transform: none;
254
+ }
255
+ }
256
+ </style>
@@ -0,0 +1 @@
1
+ export { default as Svg } from "./svg.svelte";
@@ -0,0 +1,9 @@
1
+ <script lang="ts">
2
+ // Renders a drawing kept in its own .svg file, imported with `?raw`.
3
+ // The markup is ours and static, so rendering it as HTML is safe.
4
+ let { src, class: className = "" }: { src: string; class?: string } = $props();
5
+
6
+ const markup = $derived(className ? src.replace("<svg", `<svg class="${className}"`) : src);
7
+ </script>
8
+
9
+ {@html markup}
@@ -37,7 +37,7 @@
37
37
 
38
38
  // STATE
39
39
  const uid = `bb-${Math.random().toString(36).slice(2, 10)}`;
40
- let container = null;
40
+ let container = $state<HTMLElement | null>(null);
41
41
  let systemTheme = $state<ResolvedTheme>("dark");
42
42
  let isActive = $state(false);
43
43
  let isFading = $state(false);
@@ -1,9 +1,18 @@
1
+ /*
2
+ * Brand hooks, all optional, set on the element's class:
3
+ * --hr-compare-handle-color, --hr-compare-handle-width, --hr-compare-handle-shadow,
4
+ * --hr-compare-grip-bg, --hr-compare-grip-color, --hr-compare-grip-size, --hr-compare-grip-shadow,
5
+ * --hr-compare-badge-inset, --hr-compare-hint-duration.
6
+ */
1
7
  .hr-compare {
2
8
  position: relative;
3
9
  overflow: hidden;
4
10
  user-select: none;
5
11
  isolation: isolate;
6
12
  }
13
+ .hr-compare picture {
14
+ display: contents;
15
+ }
7
16
  .hr-compare__img {
8
17
  display: block;
9
18
  width: 100%;
@@ -15,14 +24,32 @@
15
24
  inset: 0;
16
25
  clip-path: inset(0 0 0 calc(var(--hr-compare-pos) * 1%));
17
26
  }
27
+ .hr-compare__badge {
28
+ position: absolute;
29
+ z-index: 1;
30
+ pointer-events: none;
31
+ }
32
+ .hr-compare__badge--before {
33
+ inset-inline-start: var(--hr-compare-badge-inset, 0.75rem);
34
+ }
35
+ .hr-compare__badge--after {
36
+ inset-inline-end: var(--hr-compare-badge-inset, 0.75rem);
37
+ }
38
+ .hr-compare[data-badges="top"] .hr-compare__badge {
39
+ inset-block-start: var(--hr-compare-badge-inset, 0.75rem);
40
+ }
41
+ .hr-compare[data-badges="bottom"] .hr-compare__badge {
42
+ inset-block-end: var(--hr-compare-badge-inset, 0.75rem);
43
+ }
18
44
  .hr-compare__handle {
19
45
  position: absolute;
46
+ z-index: 2;
20
47
  inset-block: 0;
21
48
  left: calc(var(--hr-compare-pos) * 1%);
22
- width: 2px;
23
- translate: -1px 0;
49
+ width: var(--hr-compare-handle-width, 2px);
50
+ translate: calc(var(--hr-compare-handle-width, 2px) / -2) 0;
24
51
  background: var(--hr-compare-handle-color, white);
25
- box-shadow: var(--elev-2, 0 2px 8px rgb(0 0 0 / 0.25));
52
+ box-shadow: var(--hr-compare-handle-shadow, var(--elev-2, 0 2px 8px rgb(0 0 0 / 0.25)));
26
53
  pointer-events: none;
27
54
  }
28
55
  .hr-compare__grip {
@@ -32,18 +59,18 @@
32
59
  translate: -50% -50%;
33
60
  display: grid;
34
61
  place-items: center;
35
- width: 2rem;
36
- height: 2rem;
62
+ width: var(--hr-compare-grip-size, 2rem);
63
+ height: var(--hr-compare-grip-size, 2rem);
37
64
  border-radius: 999px;
38
- background: inherit;
65
+ background: var(--hr-compare-grip-bg, var(--hr-compare-handle-color, white));
39
66
  color: var(--hr-compare-grip-color, black);
40
- font-size: 0.75rem;
41
- line-height: 1;
67
+ box-shadow: var(--hr-compare-grip-shadow, none);
42
68
  }
43
69
  /* The range covers the frame so pointer drag, touch and arrow keys all work. */
44
70
  .hr-compare__range {
45
71
  position: absolute;
46
72
  inset: 0;
73
+ z-index: 3;
47
74
  width: 100%;
48
75
  height: 100%;
49
76
  margin: 0;
@@ -51,6 +78,8 @@
51
78
  cursor: ew-resize;
52
79
  appearance: none;
53
80
  background: transparent;
81
+ /* A vertical swipe over the frame still scrolls the page. */
82
+ touch-action: pan-y pinch-zoom;
54
83
  }
55
84
  .hr-compare__range:focus-visible ~ .hr-compare__handle {
56
85
  outline: 2px solid var(--hr-compare-handle-color, white);
@@ -63,4 +92,9 @@
63
92
  clip-path var(--duration-quick, 150ms) var(--ease-smooth-out, ease-out),
64
93
  left var(--duration-quick, 150ms) var(--ease-smooth-out, ease-out);
65
94
  }
95
+ /* A slower glide while the handle hints, so the sweep reads as a gesture. */
96
+ .hr-compare[data-hinting] .hr-compare__img--after,
97
+ .hr-compare[data-hinting] .hr-compare__handle {
98
+ transition-duration: var(--hr-compare-hint-duration, 800ms);
99
+ }
66
100
  }
@@ -1,3 +1,18 @@
1
+ <script lang="ts" module>
2
+ import type { Snippet } from "svelte";
3
+
4
+ /** One side of the comparison. `sources` turns the image into a `<picture>`. */
5
+ export interface ComparisonImage {
6
+ src: string;
7
+ alt: string;
8
+ /** Modern formats tried before `src`, such as an AVIF with the PNG as the fallback. */
9
+ sources?: readonly { srcset: string; type: string }[];
10
+ /** Intrinsic size, so the browser reserves the frame before the image lands. */
11
+ width?: number;
12
+ height?: number;
13
+ }
14
+ </script>
15
+
1
16
  <script lang="ts">
2
17
  // =========================
3
18
  // IMPORTS
@@ -5,21 +20,35 @@
5
20
  import "./ImageComparison.css";
6
21
  import { cn } from "@heyramzi/utils";
7
22
  import { untrack } from "svelte";
23
+ import { positionAt } from "./compare.js";
8
24
 
9
25
  // =========================
10
26
  // TYPES
11
27
  // =========================
12
28
  interface ImageComparisonProps {
13
29
  /** Image shown on the left of the handle. */
14
- before: { src: string; alt: string };
30
+ before: ComparisonImage;
15
31
  /** Image shown on the right of the handle. */
16
- after: { src: string; alt: string };
17
- /** Handle position at rest, 0-100. */
18
- start?: number;
19
- /** Track the pointer without a press. */
32
+ after: ComparisonImage;
33
+ /** Handle position, 0-100. Bind it to read it or to move the handle from outside. */
34
+ position?: number;
35
+ /** Track the pointer without a press. The handle returns to where it began. */
20
36
  hover?: boolean;
37
+ /**
38
+ * Positions the handle visits once on mount, then it returns to where it began.
39
+ * It stops for good at the first touch. Tells a visitor the frame moves.
40
+ */
41
+ hint?: readonly number[];
42
+ /** Load both images now, ahead of the rest of the page. For a frame above the fold. */
43
+ eager?: boolean;
21
44
  /** Label announced for the slider. */
22
45
  label?: string;
46
+ /** A label drawn over the left side, such as "Before". */
47
+ beforeBadge?: Snippet;
48
+ /** A label drawn over the right side, such as "After". */
49
+ afterBadge?: Snippet;
50
+ /** Which edge the badges sit on. */
51
+ badgeEdge?: "top" | "bottom";
23
52
  class?: string;
24
53
  }
25
54
 
@@ -29,64 +58,152 @@
29
58
  let {
30
59
  before,
31
60
  after,
32
- start = 50,
61
+ position = $bindable(50),
33
62
  hover = false,
63
+ hint,
64
+ eager = false,
34
65
  label = "Compare before and after",
66
+ beforeBadge,
67
+ afterBadge,
68
+ badgeEdge = "bottom",
35
69
  class: className,
36
70
  }: ImageComparisonProps = $props();
37
71
 
38
72
  // =========================
39
73
  // STATE
40
74
  // =========================
41
- // WHY: `start` seeds the handle and then stops mattering; the pointer and the
42
- // range own the position from there.
43
- let position = $state(untrack(() => start));
75
+ // WHY: the handle's first position is where `hover` and `hint` bring it back to.
76
+ const rest = untrack(() => position);
44
77
  let dragging = $state(false);
78
+ let hinting = $state(false);
79
+ let touched = $state(false);
80
+
81
+ // =========================
82
+ // EFFECTS
83
+ // =========================
84
+ const HINT_DELAY = 800;
85
+ const HINT_STEP = 900;
86
+
87
+ $effect(() => {
88
+ const steps = hint;
89
+ if (touched || !steps?.length) return;
90
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
91
+ const timers: ReturnType<typeof setTimeout>[] = [];
92
+ hinting = true;
93
+ [...steps, rest].forEach((stop, index) => {
94
+ timers.push(
95
+ setTimeout(
96
+ () => {
97
+ position = stop;
98
+ },
99
+ HINT_DELAY + index * HINT_STEP,
100
+ ),
101
+ );
102
+ });
103
+ timers.push(
104
+ setTimeout(
105
+ () => {
106
+ hinting = false;
107
+ },
108
+ HINT_DELAY + steps.length * HINT_STEP + HINT_STEP / 2,
109
+ ),
110
+ );
111
+ return () => {
112
+ timers.forEach(clearTimeout);
113
+ hinting = false;
114
+ };
115
+ });
45
116
 
46
117
  // =========================
47
118
  // FUNCTIONS
48
119
  // =========================
120
+ // WHY: the effect reads `touched`, so setting it runs the effect's cleanup and cancels the timers.
121
+ function touch() {
122
+ touched = true;
123
+ }
124
+
49
125
  function trackPointer(event: PointerEvent) {
50
126
  if (!hover && !dragging) return;
51
127
  const rect =
52
128
  event.currentTarget instanceof HTMLElement && event.currentTarget.getBoundingClientRect();
53
129
  if (!rect) return;
54
- position = Math.min(Math.max(((event.clientX - rect.left) / rect.width) * 100, 0), 100);
130
+ position = positionAt(event.clientX, rect.left, rect.width);
55
131
  }
56
132
  </script>
57
133
 
58
134
  <!-- =========================== -->
59
135
  <!-- MARKUP -->
60
136
  <!-- =========================== -->
137
+ {#snippet picture(side: ComparisonImage, cls: string)}
138
+ {#if side.sources?.length}
139
+ <picture>
140
+ {#each side.sources as source (source.type)}
141
+ <source srcset={source.srcset} type={source.type} />
142
+ {/each}
143
+ {@render img(side, cls)}
144
+ </picture>
145
+ {:else}
146
+ {@render img(side, cls)}
147
+ {/if}
148
+ {/snippet}
149
+
150
+ {#snippet img(side: ComparisonImage, cls: string)}
151
+ <img
152
+ class={cls}
153
+ src={side.src}
154
+ alt={side.alt}
155
+ width={side.width}
156
+ height={side.height}
157
+ draggable="false"
158
+ loading={eager ? "eager" : undefined}
159
+ fetchpriority={eager ? "high" : undefined}
160
+ />
161
+ {/snippet}
162
+
61
163
  <div
62
164
  role="group"
63
165
  aria-label={label}
64
166
  class={cn("hr-compare", className)}
65
167
  style="--hr-compare-pos:{position}"
66
168
  data-eased={dragging ? undefined : ""}
169
+ data-hinting={hinting ? "" : undefined}
170
+ data-badges={badgeEdge}
67
171
  onpointermove={trackPointer}
68
- onpointerdown={() => (dragging = true)}
172
+ onpointerdown={() => {
173
+ dragging = true;
174
+ touch();
175
+ }}
69
176
  onpointerup={() => (dragging = false)}
177
+ onpointercancel={() => (dragging = false)}
70
178
  onpointerleave={() => {
71
179
  dragging = false;
72
- if (hover) position = start;
180
+ if (hover) position = rest;
73
181
  }}
182
+ onkeydown={touch}
74
183
  >
75
- <img class="hr-compare__img" src={before.src} alt={before.alt} draggable="false" />
76
- <img
77
- class="hr-compare__img hr-compare__img--after"
78
- src={after.src}
79
- alt={after.alt}
80
- draggable="false"
81
- />
184
+ {@render picture(before, "hr-compare__img")}
185
+ {@render picture(after, "hr-compare__img hr-compare__img--after")}
186
+ {#if beforeBadge}
187
+ <div class="hr-compare__badge hr-compare__badge--before">{@render beforeBadge()}</div>
188
+ {/if}
189
+ {#if afterBadge}
190
+ <div class="hr-compare__badge hr-compare__badge--after">{@render afterBadge()}</div>
191
+ {/if}
82
192
  <input
83
193
  class="hr-compare__range"
84
194
  type="range"
85
195
  min="0"
86
196
  max="100"
87
- step="0.1"
197
+ step="1"
88
198
  aria-label={label}
89
199
  bind:value={position}
90
200
  />
91
- <div class="hr-compare__handle"><span class="hr-compare__grip">◀▶</span></div>
201
+ <div class="hr-compare__handle">
202
+ <span class="hr-compare__grip">
203
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
204
+ <polyline points="10 6 4 12 10 18"></polyline>
205
+ <polyline points="14 6 20 12 14 18"></polyline>
206
+ </svg>
207
+ </span>
208
+ </div>
92
209
  </div>
@@ -0,0 +1,17 @@
1
+ import { describe, expect, it } from "vite-plus/test";
2
+ import { positionAt } from "./compare.js";
3
+
4
+ describe("positionAt", () => {
5
+ it("maps the pointer to a percentage of the frame", () => {
6
+ expect(positionAt(150, 100, 200)).toBe(25);
7
+ });
8
+
9
+ it("clamps a pointer outside the frame", () => {
10
+ expect(positionAt(50, 100, 200)).toBe(0);
11
+ expect(positionAt(400, 100, 200)).toBe(100);
12
+ });
13
+
14
+ it("rests in the middle of a frame with no width", () => {
15
+ expect(positionAt(10, 0, 0)).toBe(50);
16
+ });
17
+ });
@@ -0,0 +1,5 @@
1
+ /** Where the handle sits for a pointer at `clientX`, as a 0-100 percentage of the frame. */
2
+ export function positionAt(clientX: number, left: number, width: number): number {
3
+ if (width <= 0) return 50;
4
+ return Math.min(Math.max(((clientX - left) / width) * 100, 0), 100);
5
+ }