@vvedantb/ui 0.26.1 → 0.28.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.
package/src/client.ts ADDED
@@ -0,0 +1,393 @@
1
+ // @vvedantb/ui/client: the root without `@vvedantb/server`, convex or Clerk, for
2
+ // Chrome extensions and other pages that cannot load them. It leaves out the
3
+ // artifact viewers (`src/artifacts`), `ConvexClerkProvider`, `useStableAuth`
4
+ // and `useEnsureUser`; everything else matches the root. Keep it in step with
5
+ // src/index.ts: src/client-exports.test.ts compares the two, and
6
+ // app/tests-node/client-graph.test.mjs checks the module graph.
7
+ export { cn } from "./utils/cn";
8
+ export { UiConfigProvider, useUiConfig } from "./ui/ui-config";
9
+ export type { UiConfig, UiConfigProviderProps } from "./ui/ui-config";
10
+ export { floatingSurfaceClass } from "./ui/_menu-classes";
11
+ export { fieldFocusClass, fieldFocusWithinClass } from "./ui/_field-classes";
12
+ export * from "./ui/surface-classes";
13
+ export * from "./motion/presets";
14
+ export * from "./motion/panel-transition";
15
+ export { ListEnter, useFirstPaintGate } from "./motion/list-enter";
16
+ export type { ListEnterProps } from "./motion/list-enter";
17
+ // Gesture physics and animation budget helpers (shared clock, on-screen and
18
+ // page-visibility pausing for infinite animations).
19
+ export { DRAG_ACTIVATION_DISTANCE_PX, projectVelocity, rubberband } from "./utils/gesture";
20
+ export {
21
+ bindRuntimeAnimation,
22
+ ensureRuntimeVisibility,
23
+ observeAnimVisibility,
24
+ useAnimOffscreenRef,
25
+ } from "./utils/runtimeVisibility";
26
+ export { msUntilNextBoundary, quantizedSnapshot, subscribeQuantized } from "./utils/sharedClock";
27
+ export { CAT_SLOT_COUNT, catColorForId, catColorForSlot, catSlotForId } from "./utils/cat-color";
28
+ export type { CatColorClasses, CatSlot } from "./utils/cat-color";
29
+ export { CONTROL_RADIUS_CLASS, SURFACE_RADIUS_CLASS } from "./utils/surface-radius";
30
+ export { restoreModalScrollLock } from "./ui/overlay-a11y";
31
+ // Generic hooks: media query, page visibility, trailing debounce,
32
+ // single-flight async calls and a persisted scroll container.
33
+ export * from "./hooks";
34
+ export { ErrorBoundary } from "./ui/error-boundary";
35
+ export type { ErrorBoundaryProps } from "./ui/error-boundary";
36
+ export { CountPop, countLabel, UnreadDot } from "./ui/count-pop";
37
+ export { MarqueeOnHover } from "./ui/marquee-on-hover";
38
+ export { RelativeDateTime } from "./ui/relative-date-time";
39
+ export type { RelativeDateTimeProps } from "./ui/relative-date-time";
40
+ export { ImageLightbox } from "./ui/image-lightbox";
41
+ export type { GalleryImage } from "./ui/image-lightbox";
42
+ export { ImageGalleryPreview, ScreenshotPreview, VideoPreview } from "./ui/media-preview";
43
+
44
+ export * from "./ui/avatar";
45
+ export * from "./ui/badge";
46
+ export * from "./ui/border-beam";
47
+ export * from "./ui/breadcrumb";
48
+ export * from "./ui/button";
49
+ export * from "./ui/button-group";
50
+ export * from "./ui/card";
51
+ export * from "./ui/carousel";
52
+ export * from "./ui/centered-spinner";
53
+ export * from "./ui/checkbox";
54
+ export * from "./ui/collapsible";
55
+ export * from "./ui/command";
56
+ export * from "./ui/context-menu";
57
+ export * from "./ui/crossfade-icon";
58
+ export * from "./ui/dialog";
59
+ export * from "./ui/alert-dialog";
60
+ export * from "./ui/confirm-dialog";
61
+ export * from "./ui/dropdown-menu";
62
+ export * from "./ui/empty-state";
63
+ export * from "./ui/hover-card";
64
+ export * from "./ui/input";
65
+ export * from "./ui/label";
66
+ export * from "./ui/labeled-slider-row";
67
+ export * from "./ui/labeled-switch-row";
68
+ export * from "./ui/list-row";
69
+ export * from "./ui/loading-state";
70
+ export * from "./ui/pagination";
71
+ export * from "./ui/popover";
72
+ export * from "./ui/progress";
73
+ export * from "./ui/refresh-spin-icon";
74
+ export {
75
+ Select as RadixSelect,
76
+ SelectContent as RadixSelectContent,
77
+ SelectGroup as RadixSelectGroup,
78
+ SelectItem as RadixSelectItem,
79
+ SelectLabel as RadixSelectLabel,
80
+ SelectScrollDownButton as RadixSelectScrollDownButton,
81
+ SelectScrollUpButton as RadixSelectScrollUpButton,
82
+ SelectSeparator as RadixSelectSeparator,
83
+ SelectTrigger as RadixSelectTrigger,
84
+ SelectValue as RadixSelectValue,
85
+ } from "./ui/radix-select";
86
+ export * from "./ui/search-input";
87
+ export * from "./ui/select";
88
+ export * from "./ui/select-field";
89
+ export * from "./ui/separator";
90
+ export * from "./ui/skeleton";
91
+ export * from "./ui/slide-over-panel";
92
+ export * from "./ui/slider";
93
+ export * from "./ui/sonner";
94
+ export * from "./ui/spinner";
95
+ export * from "./ui/status-dot";
96
+ export * from "./ui/surface";
97
+ export * from "./ui/switch";
98
+ export * from "./ui/table";
99
+ export * from "./ui/tabs";
100
+ export * from "./ui/textarea";
101
+ export * from "./ui/toolbar-search-input";
102
+ export * from "./ui/time-picker";
103
+ export * from "./ui/calendar";
104
+ export * from "./ui/date-picker";
105
+ export * from "./ui/tooltip";
106
+ export * from "./ui/kbd";
107
+ export * from "./ui/sheet";
108
+ export * from "./ui/visually-hidden";
109
+ export * from "./ui/clearInputDissolve";
110
+
111
+ // Animated nav icons. Styles: `@vvedantb/ui/tokens/css/nav-icons.css`.
112
+ export * from "./icons";
113
+
114
+ // Dual copies: the richer file uses the plain name. The deprecated `Alt` exports
115
+ // (accordion, clear input, input group) wrap the main parts with the old markup.
116
+ export * from "./ui/accordion";
117
+ export {
118
+ AccordionAlt,
119
+ AccordionItemAlt,
120
+ AccordionTriggerAlt,
121
+ AccordionContentAlt,
122
+ } from "./ui/accordion.alt";
123
+ export * from "./ui/clear-input";
124
+ export { ClearInputAlt } from "./ui/clear-input.alt";
125
+ export * from "./ui/input-group";
126
+ export {
127
+ InputGroupAlt,
128
+ InputGroupAddonAlt,
129
+ InputGroupButtonAlt,
130
+ InputGroupTextAlt,
131
+ InputGroupInputAlt,
132
+ InputGroupTextareaAlt,
133
+ } from "./ui/input-group.alt";
134
+ export * from "./ui/scroll-area";
135
+ export {
136
+ ScrollArea as ScrollAreaAlt,
137
+ ScrollBar as ScrollBarAlt,
138
+ } from "./ui/scroll-area.alt";
139
+
140
+ export * from "./ai-elements/artifact";
141
+ export * from "./ai-elements/attachments";
142
+ export * from "./ai-elements/chain-of-thought";
143
+ export * from "./ai-elements/code-block";
144
+ export * from "./ai-elements/conversation";
145
+ export * from "./ai-elements/file-chip";
146
+ export * from "./ai-elements/image";
147
+ export * from "./ai-elements/inline-citation";
148
+ export * from "./ai-elements/jump-rail";
149
+ export * from "./ai-elements/jump-rail-geometry";
150
+ export * from "./ai-elements/chat-jump-rail";
151
+ export * from "./ai-elements/message";
152
+ export * from "./ai-elements/model-selector";
153
+ export * from "./ai-elements/prompt-input";
154
+ export * from "./ai-elements/prompt-input-settings";
155
+ export * from "./ai-elements/prompt-input-speech";
156
+ export * from "./ai-elements/prompt-input-traits";
157
+ export * from "./ai-elements/queue";
158
+ export * from "./ai-elements/reasoning";
159
+ export * from "./ai-elements/shimmer";
160
+ export * from "./ai-elements/sources";
161
+ export * from "./ai-elements/speech-input";
162
+ export * from "./ai-elements/suggestion";
163
+
164
+ // Agent activity and run surfaces (moved from an app): the activity timeline and
165
+ // its step model, Trace lines, plan, test results, web preview, context usage
166
+ // and the model picker.
167
+ export * from "./ai-elements/activity-shared";
168
+ export * from "./ai-elements/activity-question";
169
+ export * from "./ai-elements/activity-tasks";
170
+ export * from "./ai-elements/trace";
171
+ export * from "./ai-elements/use-streaming-disclosure";
172
+ export * from "./ai-elements/plan";
173
+ export * from "./ai-elements/test-results";
174
+ export * from "./ai-elements/web-preview";
175
+ export * from "./ai-elements/context";
176
+ export * from "./ai-elements/model-picker";
177
+ export * from "./ai-elements/provider-icon";
178
+
179
+ // Kibo UI blocks: gantt, kanban, list, contribution graph, video player,
180
+ // cursor, avatar stack, theme switcher. Kanban, list and gantt drag with
181
+ // `useDragSensors`, the shared dnd-kit sensor split.
182
+ export * from "./kibo";
183
+ export { useDragSensors } from "./utils/useDragSensors";
184
+
185
+ // Markdown renderer (Streamdown + code blocks, callouts, links, KaTeX).
186
+ // Styles: `@vvedantb/ui/tokens/css/markdown.css`.
187
+ export * from "./markdown";
188
+
189
+ export * from "./audio";
190
+
191
+ export * from "./composer";
192
+ export { toast } from "sonner";
193
+
194
+ // Shell: router-free pieces. Shell's own `cn` and `ClassValue` are not re-exported;
195
+ // use the root `cn` (clsx + tailwind-merge).
196
+ export { SidebarHeader } from "../shell/src/SidebarHeader";
197
+ export type { SidebarHeaderProps } from "../shell/src/SidebarHeader";
198
+ export { SidebarUpgradeCard } from "../shell/src/SidebarUpgradeCard";
199
+ export type { SidebarUpgradeCardProps } from "../shell/src/SidebarUpgradeCard";
200
+ export { PillTabs } from "../shell/src/PillTabs";
201
+ export type { PillTabItem, PillTabsProps } from "../shell/src/PillTabs";
202
+ export { SessionsHoverCard } from "../shell/src/SessionsHoverCard";
203
+ export type {
204
+ SessionsHoverCardContext,
205
+ SessionsHoverCardProps,
206
+ SessionsHoverCardSurface,
207
+ } from "../shell/src/SessionsHoverCard";
208
+
209
+ // App shell: sidebar and main page layout. Widths come from the
210
+ // `--app-shell-*` CSS variables that `AppShell` sets.
211
+ export { AppShell } from "../shell/src/AppShell";
212
+ export type {
213
+ AppShellContentProps,
214
+ AppShellMainProps,
215
+ AppShellProps,
216
+ AppShellRootProps,
217
+ AppShellSkipLinkProps,
218
+ } from "../shell/src/AppShell";
219
+ export { AppSidebar } from "../shell/src/AppSidebar";
220
+ export type { AppSidebarProps } from "../shell/src/AppSidebar";
221
+ // Composable sidebar parts (`Sidebar.Root`, `Sidebar.Rail`, ...) and their prop types.
222
+ export * from "../shell/src/Sidebar";
223
+ export { SidebarRail, RailTile, RailDivider, RailCollapseTile } from "../shell/src/SidebarRail";
224
+ export type {
225
+ SidebarRailProps,
226
+ RailTileProps,
227
+ RailDividerProps,
228
+ RailCollapseTileProps,
229
+ } from "../shell/src/SidebarRail";
230
+ export { SidebarResizeHandle, useAppShellResize } from "../shell/src/SidebarResize";
231
+ export type {
232
+ AppShellResize,
233
+ SidebarResizeHandleProps,
234
+ SidebarResizeOptions,
235
+ } from "../shell/src/SidebarResize";
236
+ export { SidebarIconTooltip } from "../shell/src/SidebarIconTooltip";
237
+ export type { SidebarIconTooltipProps } from "../shell/src/SidebarIconTooltip";
238
+ export { SidebarPanel } from "../shell/src/SidebarPanel";
239
+ export type { SidebarPanelProps } from "../shell/src/SidebarPanel";
240
+ export { SidebarAccountMenu, SidebarAccountMenuPlaceholder } from "../shell/src/SidebarAccountMenu";
241
+ export type {
242
+ SidebarAccountMenuProps,
243
+ SidebarAccountMenuPlaceholderProps,
244
+ } from "../shell/src/SidebarAccountMenu";
245
+ export { SignOutDialog } from "../shell/src/SignOutDialog";
246
+ export type { SignOutDialogProps } from "../shell/src/SignOutDialog";
247
+ export {
248
+ railBadgeClass,
249
+ railTileClass,
250
+ railTileStateClass,
251
+ sidebarNavIconClass,
252
+ sidebarNavItemActionsClass,
253
+ sidebarNavItemClass,
254
+ sidebarNavItemIndentStyle,
255
+ sidebarNavItemLabelClass,
256
+ sidebarNavItemLeadingClass,
257
+ sidebarNavItemSubtitleClass,
258
+ sidebarNavItemTrailingClass,
259
+ sidebarNavLinkTextClass,
260
+ sidebarNavRowClass,
261
+ sidebarSectionLabelClass,
262
+ } from "../shell/src/app-shell-classes";
263
+ export type { SidebarNavItemClassOptions } from "../shell/src/app-shell-classes";
264
+
265
+ // Marketing: landing kit, site footer and pricing dialog. Router- and Motion-free;
266
+ // `createLandingKit` takes the app's `motion`. Styles: `@vvedantb/ui/landing.css`.
267
+ export {
268
+ LandingFeatureCard,
269
+ LandingFooter,
270
+ LandingLattice,
271
+ LandingNavMenu,
272
+ LandingPageLayout,
273
+ LandingSection,
274
+ LandingSectionHeading,
275
+ landingItemVariants,
276
+ } from "./marketing/landing";
277
+ export type {
278
+ LandingFeature,
279
+ LandingFooterProps,
280
+ LandingLatticeProps,
281
+ LandingLink,
282
+ LandingNavMenuProps,
283
+ LandingPageLayoutProps,
284
+ LandingSectionHeadingProps,
285
+ LandingSectionProps,
286
+ } from "./marketing/landing";
287
+ export { createLandingKit } from "./marketing/createLandingKit";
288
+ export type {
289
+ LandingCtaProps,
290
+ LandingHeroProps,
291
+ LandingKit,
292
+ LandingKitOptions,
293
+ LandingMotion,
294
+ LandingNavProps,
295
+ LandingRevealItemProps,
296
+ LandingRevealProps,
297
+ } from "./marketing/createLandingKit";
298
+ export {
299
+ landingFooterButtonClass,
300
+ landingFooterLinkClass,
301
+ landingPanelClass,
302
+ landingShellClass,
303
+ } from "./marketing/marketing-classes";
304
+ export type { PricingModalTone, SiteFooterVariant } from "./marketing/marketing-classes";
305
+ export { SiteFooter } from "./marketing/SiteFooter";
306
+ export type { SiteFooterLink, SiteFooterProps, SiteFooterSocial } from "./marketing/SiteFooter";
307
+ export { PricingModal, V_PRO_APP_BENEFITS } from "./marketing/PricingModal";
308
+ export type {
309
+ PricingModalLegacyProps,
310
+ PricingModalMultiProps,
311
+ PricingModalPeriod,
312
+ PricingModalProps,
313
+ PricingPlan,
314
+ PricingPlanAppBenefit,
315
+ PricingPlanBenefit,
316
+ } from "./marketing/PricingModal";
317
+
318
+ // App helpers: confirm skip, update watcher, settings chrome, theme, page title,
319
+ // page motion, time, VITE env and error messages. Not here: `ConvexClerkProvider`,
320
+ // `useStableAuth` and `useEnsureUser`, which import convex or Clerk.
321
+ export {
322
+ bindToWindow,
323
+ createAltHeldStore,
324
+ getAltHeld,
325
+ useAltHeld,
326
+ type AltHeldBinding,
327
+ type AltHeldSignals,
328
+ type AltHeldStore,
329
+ type AltKeyEvent,
330
+ } from "../app/src/confirm/useAltHeld";
331
+ export { requestConfirm, shouldSkipConfirm } from "../app/src/confirm/confirmSkip";
332
+ export { useRequestConfirm } from "../app/src/confirm/useRequestConfirm";
333
+ export { ConfirmSkipHint, skipConfirmTitle } from "../app/src/confirm/ConfirmSkipHint";
334
+ export { fetchDeployFingerprint, fingerprintFromHtml } from "../app/src/update/deployFingerprint";
335
+ export {
336
+ startUpdateAvailableWatcher,
337
+ type UpdateAvailableWatcherOptions,
338
+ type UpdateToastOptions,
339
+ } from "../app/src/update/updateWatcher";
340
+ export { SettingsSection, type SettingsSectionConfig } from "../app/src/settings/SettingsSection";
341
+ export { SettingsField } from "../app/src/settings/SettingsField";
342
+ export { SettingsStack } from "../app/src/settings/SettingsStack";
343
+ export { SettingsToggleRow } from "../app/src/settings/SettingsToggleRow";
344
+ export { SettingsEmptyState, type SettingsEmptyStateConfig } from "../app/src/settings/SettingsEmptyState";
345
+ export {
346
+ DefaultSettingsLayout,
347
+ SettingsPage,
348
+ type SettingsLayoutProps,
349
+ type SettingsPageProps,
350
+ } from "../app/src/settings/SettingsPage";
351
+ export {
352
+ applyThemeClass,
353
+ createThemeProvider,
354
+ getSystemTheme,
355
+ type ThemeAttribute,
356
+ type ThemeProviderKit,
357
+ type ThemeProviderOptions,
358
+ type ThemeProviderProps,
359
+ type ThemeState,
360
+ } from "../app/src/theme/createThemeProvider";
361
+ export {
362
+ createPageTitleContext,
363
+ type DocumentTitleOptions,
364
+ type PageTitleContextKit,
365
+ type PageTitleContextOptions,
366
+ type PageTitleValue,
367
+ } from "../app/src/page-title/createPageTitleContext";
368
+ export {
369
+ PageMotionProvider,
370
+ useDisablePageMotion,
371
+ type PageMotionConfigProps,
372
+ type PageMotionProviderProps,
373
+ } from "../app/src/page-motion/PageMotionProvider";
374
+ export { formatCompactRelativeTime, type CompactRelativeTimeOptions } from "../app/src/time/compactRelativeTime";
375
+ export {
376
+ parseClientEnv,
377
+ VITE_ENV_NAMES,
378
+ type ClientEnv,
379
+ type ClientEnvOptions,
380
+ type ClientEnvSource,
381
+ type ClientEnvWithoutViteEnv,
382
+ type ViteEnvMode,
383
+ type ViteEnvName,
384
+ } from "../app/src/convex/env";
385
+ export { convexErrorMessage, userFacingErrorMessage } from "../app/src/convex/errorMessage";
386
+ export { useHeldResult } from "../app/src/convex/useHeldResult";
387
+
388
+ // Family app marks (Icon* components, deprecated AppMark and raw SVG strings for favicons).
389
+ export * from "./brand";
390
+
391
+ // Native tokens (JS values that match tokens.css).
392
+ export { tokens } from "../tokens/src/native";
393
+ export type { Tokens } from "../tokens/src/native";
@@ -0,0 +1,5 @@
1
+ export { useMediaQuery } from "./use-media-query";
2
+ export { useDocumentVisible } from "./use-document-visible";
3
+ export { createIdleCallback, useIdleCallback } from "./use-idle-callback";
4
+ export { useSingleFlight } from "./use-single-flight";
5
+ export { usePersistedScrollParent } from "./use-persisted-scroll-parent";
@@ -0,0 +1,15 @@
1
+ import { useSyncExternalStore } from "react";
2
+
3
+ function subscribe(onStoreChange: () => void) {
4
+ document.addEventListener("visibilitychange", onStoreChange);
5
+ return () => document.removeEventListener("visibilitychange", onStoreChange);
6
+ }
7
+
8
+ /** True while the tab is visible. Server snapshot is `true`, so SSR renders as visible. */
9
+ export function useDocumentVisible(): boolean {
10
+ return useSyncExternalStore(
11
+ subscribe,
12
+ () => document.visibilityState === "visible",
13
+ () => true,
14
+ );
15
+ }
@@ -0,0 +1,51 @@
1
+ import { useState } from "react";
2
+
3
+ /** Anything worth passing through a debounced callback. */
4
+ type CallbackArgs = ReadonlyArray<string | number | boolean | null | object>;
5
+
6
+ /**
7
+ * The debounce itself, free of React so it can be tested with fake timers.
8
+ * `getFn` is read at fire time rather than captured, so the caller always runs
9
+ * the latest render's closure.
10
+ */
11
+ export function createIdleCallback<Args extends CallbackArgs>(
12
+ ms: number,
13
+ getFn: () => (...args: Args) => void,
14
+ ): (...args: Args) => void {
15
+ let timer: ReturnType<typeof setTimeout> | null = null;
16
+ return (...args: Args) => {
17
+ if (timer !== null) clearTimeout(timer);
18
+ timer = setTimeout(() => {
19
+ timer = null;
20
+ getFn()(...args);
21
+ }, ms);
22
+ };
23
+ }
24
+
25
+ /**
26
+ * Returns a stable callback that runs `fn` once `ms` has passed since the last
27
+ * call; each call cancels the pending one. Trailing edge only — nothing fires
28
+ * until the caller goes quiet.
29
+ */
30
+ export function useIdleCallback<Args extends CallbackArgs>(
31
+ ms: number,
32
+ fn: (...args: Args) => void,
33
+ ): (...args: Args) => void {
34
+ // No refs and no direct writes to state: the React Compiler skips a file
35
+ // that does either during render. The holder keeps the latest `fn` in a
36
+ // closure; the caller stores it on each call (always an event handler
37
+ // holding the current render's closure), and the timer reads it on fire.
38
+ const [idle] = useState(() => {
39
+ let current = fn;
40
+ return {
41
+ setFn: (next: (...args: Args) => void) => {
42
+ current = next;
43
+ },
44
+ run: createIdleCallback<Args>(ms, () => current),
45
+ };
46
+ });
47
+ return (...args: Args) => {
48
+ idle.setFn(fn);
49
+ idle.run(...args);
50
+ };
51
+ }
@@ -0,0 +1,17 @@
1
+ import { useSyncExternalStore } from "react";
2
+
3
+ /**
4
+ * Subscribe to a CSS media query without setState-in-effect.
5
+ * `useSyncExternalStore` is the React 18+ recommended pattern for browser APIs.
6
+ */
7
+ export function useMediaQuery(query: string): boolean {
8
+ return useSyncExternalStore(
9
+ (onStoreChange) => {
10
+ const mql = window.matchMedia(query);
11
+ mql.addEventListener("change", onStoreChange);
12
+ return () => mql.removeEventListener("change", onStoreChange);
13
+ },
14
+ () => window.matchMedia(query).matches,
15
+ () => false,
16
+ );
17
+ }
@@ -0,0 +1,70 @@
1
+ import { useLayoutEffect, useState, type RefCallback } from "react";
2
+
3
+ /** Re-apply after a virtualised list measures; first paint often resets scrollTop. */
4
+ function applyScrollTop(el: HTMLElement, scrollTop: number): void {
5
+ el.scrollTop = scrollTop;
6
+ requestAnimationFrame(() => {
7
+ el.scrollTop = scrollTop;
8
+ requestAnimationFrame(() => {
9
+ el.scrollTop = scrollTop;
10
+ });
11
+ });
12
+ }
13
+
14
+ function readScrollTop(key: string): number {
15
+ try {
16
+ // Stored as JSON; a JSON number is also a plain numeric string.
17
+ const value = Number(sessionStorage.getItem(key) ?? 0);
18
+ return Number.isFinite(value) ? value : 0;
19
+ } catch {
20
+ return 0;
21
+ }
22
+ }
23
+
24
+ /** JSON, so a value written by an older `useSessionStorage` reads back. */
25
+ function writeScrollTop(key: string, scrollTop: number): void {
26
+ try {
27
+ sessionStorage.setItem(key, JSON.stringify(Math.round(scrollTop)));
28
+ } catch {
29
+ // Ignore quota / private-mode failures.
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Scroll-container ref that saves `scrollTop` to `sessionStorage` under
35
+ * `storageKey` and restores it when the container mounts again, so a list keeps
36
+ * its place when a route swaps it out for a detail page and back. Per tab:
37
+ * closing the tab clears it. Writes are throttled to one per frame and do not
38
+ * re-render.
39
+ */
40
+ export function usePersistedScrollParent(storageKey: string): {
41
+ scrollParent: HTMLDivElement | null;
42
+ scrollRef: RefCallback<HTMLDivElement>;
43
+ } {
44
+ const [scrollParent, scrollRef] = useState<HTMLDivElement | null>(null);
45
+
46
+ useLayoutEffect(() => {
47
+ if (!scrollParent) return;
48
+
49
+ const initial = readScrollTop(storageKey);
50
+ if (initial > 0) applyScrollTop(scrollParent, initial);
51
+
52
+ let rafId = 0;
53
+ const onScroll = () => {
54
+ if (rafId !== 0) return;
55
+ rafId = requestAnimationFrame(() => {
56
+ rafId = 0;
57
+ writeScrollTop(storageKey, scrollParent.scrollTop);
58
+ });
59
+ };
60
+
61
+ scrollParent.addEventListener("scroll", onScroll, { passive: true });
62
+ return () => {
63
+ if (rafId !== 0) cancelAnimationFrame(rafId);
64
+ writeScrollTop(storageKey, scrollParent.scrollTop);
65
+ scrollParent.removeEventListener("scroll", onScroll);
66
+ };
67
+ }, [scrollParent, storageKey]);
68
+
69
+ return { scrollParent, scrollRef };
70
+ }
@@ -0,0 +1,59 @@
1
+ import { useRef } from "react";
2
+
3
+ type NoPending = { has: false };
4
+ type HasPending<TArgs> = { has: true; args: TArgs };
5
+ type Pending<TArgs> = NoPending | HasPending<TArgs>;
6
+
7
+ const NO_PENDING: NoPending = { has: false };
8
+
9
+ /**
10
+ * Wraps an async function so at most one invocation runs at a time.
11
+ * While a call is in flight, subsequent calls are coalesced — only the
12
+ * latest args are kept, and they run once the current invocation settles.
13
+ *
14
+ * Designed for autosave-on-keystroke: prevents stampeding mutations when
15
+ * the user types quickly, while ensuring the final state is always saved.
16
+ */
17
+ export function useSingleFlight<TArgs, TRet>(
18
+ fn: (args: TArgs) => Promise<TRet>,
19
+ ): (args: TArgs) => Promise<TRet | undefined> {
20
+ const inFlight = useRef(false);
21
+ const pending = useRef<Pending<TArgs>>(NO_PENDING);
22
+
23
+ const invoke = async (args: TArgs): Promise<TRet | undefined> => {
24
+ const runPending = (): void => {
25
+ const next: Pending<TArgs> = pending.current;
26
+ pending.current = NO_PENDING;
27
+ if (next.has) {
28
+ // Recurse with the latest coalesced args — return value discarded
29
+ // intentionally since the original caller already received undefined.
30
+ void invoke(next.args);
31
+ }
32
+ };
33
+
34
+ if (inFlight.current) {
35
+ pending.current = { has: true, args };
36
+ return undefined;
37
+ }
38
+
39
+ inFlight.current = true;
40
+ pending.current = NO_PENDING;
41
+
42
+ // Avoid `finally` here: TypeScript narrows discriminated unions to `never`
43
+ // inside `finally` blocks when the try body is async, making `next.args`
44
+ // inaccessible. Use explicit try/catch instead.
45
+ let result: TRet | undefined;
46
+ try {
47
+ result = await fn(args);
48
+ } catch (err) {
49
+ inFlight.current = false;
50
+ runPending();
51
+ throw err;
52
+ }
53
+ inFlight.current = false;
54
+ runPending();
55
+ return result;
56
+ };
57
+
58
+ return invoke;
59
+ }
package/src/index.ts CHANGED
@@ -8,6 +8,8 @@ export { fieldFocusClass, fieldFocusWithinClass } from "./ui/_field-classes";
8
8
  export * from "./ui/surface-classes";
9
9
  export * from "./motion/presets";
10
10
  export * from "./motion/panel-transition";
11
+ export { ListEnter, useFirstPaintGate } from "./motion/list-enter";
12
+ export type { ListEnterProps } from "./motion/list-enter";
11
13
  // Gesture physics and animation budget helpers (shared clock, on-screen and
12
14
  // page-visibility pausing for infinite animations).
13
15
  export { DRAG_ACTIVATION_DISTANCE_PX, projectVelocity, rubberband } from "./utils/gesture";
@@ -22,6 +24,18 @@ export { CAT_SLOT_COUNT, catColorForId, catColorForSlot, catSlotForId } from "./
22
24
  export type { CatColorClasses, CatSlot } from "./utils/cat-color";
23
25
  export { CONTROL_RADIUS_CLASS, SURFACE_RADIUS_CLASS } from "./utils/surface-radius";
24
26
  export { restoreModalScrollLock } from "./ui/overlay-a11y";
27
+ // Generic hooks: media query, page visibility, trailing debounce,
28
+ // single-flight async calls and a persisted scroll container.
29
+ export * from "./hooks";
30
+ export { ErrorBoundary } from "./ui/error-boundary";
31
+ export type { ErrorBoundaryProps } from "./ui/error-boundary";
32
+ export { CountPop, countLabel, UnreadDot } from "./ui/count-pop";
33
+ export { MarqueeOnHover } from "./ui/marquee-on-hover";
34
+ export { RelativeDateTime } from "./ui/relative-date-time";
35
+ export type { RelativeDateTimeProps } from "./ui/relative-date-time";
36
+ export { ImageLightbox } from "./ui/image-lightbox";
37
+ export type { GalleryImage } from "./ui/image-lightbox";
38
+ export { ImageGalleryPreview, ScreenshotPreview, VideoPreview } from "./ui/media-preview";
25
39
 
26
40
  export * from "./ui/avatar";
27
41
  export * from "./ui/badge";