@vvedantb/ui 0.26.1 → 0.27.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/README.md CHANGED
@@ -1184,8 +1184,31 @@ import { DatePicker, DateTimePicker, MonthPicker } from "@vvedantb/ui";
1184
1184
  - `DatePicker`: `value` "YYYY-MM-DD" or "". The trigger matches `TimePicker` and shows the date with `dateStyle: "medium"` (`locale`) or `placeholder` ("Pick a date"). `min` and `max` disable days; `clearable` adds a clear control (`onChange("")`); `name` adds a hidden input. `className` goes on the wrapper, `triggerClassName` merges onto the button (default `w-40`). A width on either sizes the field, and `w-full` on either fills the row; pill inputs can pass `triggerClassName="h-10 w-full rounded-full px-4 text-sm"`. `ariaLabel` names the trigger, followed by the shown text.
1185
1185
  - `DateTimePicker`: `value` "YYYY-MM-DDTHH:mm" or "". A date with no time uses `defaultTime` ("00:00"); a time with no date uses today. Only the date part of `min` and `max` applies. `triggerClassName` goes to both triggers, a width only to the date.
1186
1186
  - `MonthPicker`: `value` "YYYY-MM" or "", with year navigation and a month grid. `min` and `max` are "YYYY-MM".
1187
+ - `TimePicker`: `value` "HH:mm" (24h) or "". `hourCycle={12}` (0.27.0) shows "02:30 PM" and adds an AM/PM column; `value` stays 24h. Each column opens centred on the selected item.
1187
1188
  - `Calendar`: the styled `DayPicker` for custom layouts (every `DayPicker` prop). `parseDateValue` and `formatDateValue` convert between "YYYY-MM-DD" and a local `Date`.
1188
1189
 
1190
+ ## Hooks, media and list motion (0.27.0)
1191
+
1192
+ Generic pieces lifted from an app. Components that use `m` need the app's `LazyMotion`.
1193
+
1194
+ ```tsx
1195
+ import {
1196
+ ErrorBoundary, ListEnter, useFirstPaintGate, CountPop, countLabel, UnreadDot,
1197
+ MarqueeOnHover, RelativeDateTime, ImageLightbox, ImageGalleryPreview, ScreenshotPreview, VideoPreview,
1198
+ useMediaQuery, useDocumentVisible, useIdleCallback, useSingleFlight, usePersistedScrollParent,
1199
+ convexErrorMessage, userFacingErrorMessage, useHeldResult,
1200
+ } from "@vvedantb/ui";
1201
+ ```
1202
+
1203
+ - `ErrorBoundary` (`fallback` node or `(retry) => node`, `onError`): contains a failure to its subtree; `retry` clears the error.
1204
+ - `ListEnter` (`index`, `fast`, `firstPaint`, `staggerStep`, `staggerMax`, `slide`, `as` `div | tr | li`) and `useFirstPaintGate()`: first-paint fade and 8px slide, staggered with `staggerDelay`. Pass `firstPaint={gate.current}` so recycled rows do not restagger.
1205
+ - `CountPop` (`label`, `className`, `children`): pop-in and shrink-out for a live count, re-popping on each change. `countLabel(n)` gives `null`, the number or `"99+"`. `UnreadDot` (`show`): a `bg-primary` dot on `CountPop`.
1206
+ - `MarqueeOnHover`: a single-line label that scrolls its hidden tail into view on hover (or on hover of a `group` ancestor). Import `@vvedantb/ui/tokens/css/marquee.css`; `--mq-delay` sets the dwell.
1207
+ - `RelativeDateTime` (`at`, `emptyLabel` default "—", `exactFormat` default "DD/MM/YYYY HH:mm"): "15m" / "2d" with the exact time in a tooltip.
1208
+ - `ImageLightbox` (`images`, `index` or `null`, `onIndexChange`, `onClose`): fullscreen viewer with arrow keys, buttons and swipe. `ImageGalleryPreview` (`images`): up to a 2×2 grid with a "+N" tile. `ScreenshotPreview` (`url`, `alt`) and `VideoPreview` (`url`).
1209
+ - Hooks: `useMediaQuery(query)`, `useDocumentVisible()`, `useIdleCallback(ms, fn)` (trailing debounce; `createIdleCallback` is the React-free core), `useSingleFlight(fn)` (one call at a time, latest args run next), `usePersistedScrollParent(storageKey)` (restores a container's `scrollTop` from `sessionStorage`).
1210
+ - Convex: `convexErrorMessage(error, fallback)` reads `ConvexError` data first; `userFacingErrorMessage(error, fallback)` strips the client envelope and stack. `useHeldResult(result, key)` keeps the last result while a query is skipped (`key` `null`). None of these import `convex`.
1211
+
1189
1212
  ## Slider (0.16.0, dark contrast and className props 0.16.1)
1190
1213
 
1191
1214
  shadcn's slider on `@radix-ui/react-slider` (a peer dependency), with package tokens.
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The message to show the user for a failed Convex call.
3
+ *
4
+ * Production Convex redacts plain `Error` messages to "Server Error", so
5
+ * `error.message` alone leaves the user with a request id and no reason. Only
6
+ * `ConvexError` data crosses the wire intact — read that first. Matched by
7
+ * `name` rather than `instanceof`, so this module does not import `convex`.
8
+ */
9
+ export function convexErrorMessage(error: unknown, fallback: string): string {
10
+ if (
11
+ error instanceof Error &&
12
+ error.name === "ConvexError" &&
13
+ "data" in error &&
14
+ typeof error.data === "string"
15
+ ) {
16
+ return error.data;
17
+ }
18
+ return error instanceof Error ? error.message : fallback;
19
+ }
20
+
21
+ const UNCAUGHT = "Uncaught Error:";
22
+ const PLAIN = "Error:";
23
+
24
+ /**
25
+ * The same failure, stripped of everything the Convex client wraps around it.
26
+ *
27
+ * A client-side rejection reads
28
+ * `[CONVEX M(teams:create)] [Request ID: abc] Server Error\nUncaught Error: Team
29
+ * name is required\n at handler (../convex/teams.ts:107:12)`. Shown verbatim
30
+ * in a form's error slot that is a stack trace with the sentence buried in the
31
+ * middle, so take the text the handler actually threw: after the last
32
+ * `Uncaught Error:` / `Error:`, up to the newline or the stack frame.
33
+ *
34
+ * `error` is deliberately not `unknown` — callers catch `unknown` and narrow at
35
+ * the boundary with `err instanceof Error ? err : null`.
36
+ */
37
+ export function userFacingErrorMessage(
38
+ error: Error | string | null | undefined,
39
+ fallback: string,
40
+ ): string {
41
+ const raw = typeof error === "string" ? error : (error?.message ?? "");
42
+
43
+ let start = 0;
44
+ const uncaught = raw.lastIndexOf(UNCAUGHT);
45
+ if (uncaught !== -1) {
46
+ start = uncaught + UNCAUGHT.length;
47
+ } else {
48
+ const plain = raw.lastIndexOf(PLAIN);
49
+ if (plain !== -1) start = plain + PLAIN.length;
50
+ }
51
+
52
+ let text = raw.slice(start);
53
+ const newline = text.indexOf("\n");
54
+ if (newline !== -1) text = text.slice(0, newline);
55
+ const frame = text.indexOf(" at ");
56
+ if (frame !== -1) text = text.slice(0, frame);
57
+ text = text.trim();
58
+
59
+ // Nothing survived the strip, or the whole thing was the client's envelope.
60
+ if (text.length === 0 || text.startsWith("[CONVEX")) return fallback;
61
+ return text;
62
+ }
@@ -0,0 +1,26 @@
1
+ import { useState } from "react";
2
+
3
+ /**
4
+ * Keeps the last loaded `result` while a query is skipped, instead of
5
+ * flipping to `undefined`. Pair it with any `useQuery`:
6
+ *
7
+ * const result = useQuery(api.x.get, args);
8
+ * const shown = useHeldResult(result, args === "skip" ? null : JSON.stringify(args));
9
+ *
10
+ * `key` names what the result is for, and `null` means skipped. While skipped
11
+ * the last result stays (so a hidden pane that pauses its subscription does
12
+ * not flash a spinner when shown again). A new non-null key loads as
13
+ * `undefined` rather than showing the previous key's data; pass a stable key
14
+ * on purpose to keep the last result across argument changes.
15
+ */
16
+ export function useHeldResult<T>(result: T | undefined, key: string | null): T | undefined {
17
+ // State, not a ref: reading a ref during render makes the React Compiler
18
+ // skip the file. Adjusting state during render is the supported way to
19
+ // remember a previous value.
20
+ const [held, setHeld] = useState({ key, value: result });
21
+ if (result !== undefined && (result !== held.value || key !== held.key)) {
22
+ setHeld({ key, value: result });
23
+ }
24
+ if (result !== undefined) return result;
25
+ return key === null || key === held.key ? held.value : undefined;
26
+ }
package/app/src/index.ts CHANGED
@@ -22,11 +22,17 @@ export {
22
22
  type UpdateToastOptions,
23
23
  } from "./update/updateWatcher.js";
24
24
 
25
- export { SettingsSection } from "./settings/SettingsSection.js";
25
+ export {
26
+ SettingsSection,
27
+ type SettingsSectionConfig,
28
+ } from "./settings/SettingsSection.js";
26
29
  export { SettingsField } from "./settings/SettingsField.js";
27
30
  export { SettingsStack } from "./settings/SettingsStack.js";
28
31
  export { SettingsToggleRow } from "./settings/SettingsToggleRow.js";
29
- export { SettingsEmptyState } from "./settings/SettingsEmptyState.js";
32
+ export {
33
+ SettingsEmptyState,
34
+ type SettingsEmptyStateConfig,
35
+ } from "./settings/SettingsEmptyState.js";
30
36
  export {
31
37
  DefaultSettingsLayout,
32
38
  SettingsPage,
@@ -89,3 +95,5 @@ export {
89
95
  type ViteEnvName,
90
96
  } from "./convex/env.js";
91
97
  export { useEnsureUser, type EnsureUserMutation } from "./convex/useEnsureUser.js";
98
+ export { convexErrorMessage, userFacingErrorMessage } from "./convex/errorMessage.js";
99
+ export { useHeldResult } from "./convex/useHeldResult.js";
@@ -1,4 +1,15 @@
1
1
  import type { ComponentType, ReactNode } from "react";
2
+ import { useUiConfig } from "../../../src/ui/ui-config";
3
+
4
+ const ICON_CLASS = "mb-5 text-muted-foreground opacity-50";
5
+
6
+ /** `UiConfig.SettingsEmptyState`: replaces the built-in classes of a part. */
7
+ export interface SettingsEmptyStateConfig {
8
+ classes?: {
9
+ /** The icon above the title (colour, opacity, spacing). */
10
+ icon?: string;
11
+ };
12
+ }
2
13
 
3
14
  interface SettingsEmptyStateProps {
4
15
  /** Tabler icon component, rendered muted above the copy. */
@@ -23,9 +34,11 @@ export function SettingsEmptyState({
23
34
  description,
24
35
  action,
25
36
  }: SettingsEmptyStateProps) {
37
+ const iconClass =
38
+ useUiConfig("SettingsEmptyState")?.classes?.icon ?? ICON_CLASS;
26
39
  return (
27
40
  <div className="flex flex-col items-center justify-center px-4 py-16 text-center">
28
- <Icon size={28} className="mb-5 text-muted-foreground opacity-50" />
41
+ <Icon size={28} className={iconClass} />
29
42
  <h4 className="text-sm font-medium text-foreground">{title}</h4>
30
43
  {description ? (
31
44
  <p className="mt-2 max-w-sm text-xs leading-relaxed text-muted-foreground">
@@ -1,6 +1,6 @@
1
1
  import type { ReactNode } from "react";
2
2
 
3
- import { cn } from "../cn.js";
3
+ import { useCn, useUiConfig } from "../../../src/ui/ui-config";
4
4
 
5
5
  type SettingsSectionBodyVariant = "form" | "list" | "compact";
6
6
 
@@ -10,6 +10,23 @@ const BODY_VARIANT_CLASS: Record<SettingsSectionBodyVariant, string> = {
10
10
  compact: "px-4 py-3",
11
11
  };
12
12
 
13
+ const CARD_CLASS =
14
+ "rounded-lg bg-surface-card [&_input]:bg-field-background [&_textarea]:bg-field-background [&_[data-slot=select-trigger]]:bg-field-background [&_[role=combobox]]:border-border [&_[role=combobox]]:bg-field-background";
15
+ const FOOTER_CLASS =
16
+ "flex items-center justify-end gap-2 rounded-b-lg bg-surface-secondary px-4 py-3";
17
+
18
+ /** `UiConfig.SettingsSection`: replaces the built-in classes of a part. */
19
+ export interface SettingsSectionConfig {
20
+ classes?: {
21
+ /** The card behind the body and footer (tone, radius, nested field fills). */
22
+ card?: string;
23
+ /** The bottom bar that holds `footer`. */
24
+ footer?: string;
25
+ /** Body padding per `bodyVariant`. */
26
+ body?: Partial<Record<SettingsSectionBodyVariant, string>>;
27
+ };
28
+ }
29
+
13
30
  interface SettingsSectionProps {
14
31
  /** Section heading. Kept short — the description carries the detail. */
15
32
  title: ReactNode;
@@ -49,6 +66,9 @@ export function SettingsSection({
49
66
  bodyClassName,
50
67
  className,
51
68
  }: SettingsSectionProps) {
69
+ const config = useUiConfig("SettingsSection");
70
+ const cn = useCn();
71
+ const classes = config?.classes;
52
72
  const hasBody = children != null;
53
73
  const hasCard = hasBody || footer != null;
54
74
 
@@ -71,17 +91,22 @@ export function SettingsSection({
71
91
  {hasCard ? (
72
92
  <div
73
93
  className={cn(
74
- "rounded-lg bg-surface-card [&_input]:bg-field-background [&_textarea]:bg-field-background [&_[data-slot=select-trigger]]:bg-field-background [&_[role=combobox]]:border-border [&_[role=combobox]]:bg-field-background",
94
+ classes?.card ?? CARD_CLASS,
75
95
  bodyVariant === "list" && "overflow-hidden",
76
96
  )}
77
97
  >
78
98
  {hasBody ? (
79
- <div className={cn(BODY_VARIANT_CLASS[bodyVariant], bodyClassName)}>
99
+ <div
100
+ className={cn(
101
+ classes?.body?.[bodyVariant] ?? BODY_VARIANT_CLASS[bodyVariant],
102
+ bodyClassName,
103
+ )}
104
+ >
80
105
  {children}
81
106
  </div>
82
107
  ) : null}
83
108
  {footer ? (
84
- <div className="flex items-center justify-end gap-2 rounded-b-lg bg-surface-secondary px-4 py-3">
109
+ <div className={classes?.footer ?? FOOTER_CLASS}>
85
110
  {footer}
86
111
  </div>
87
112
  ) : null}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vvedantb/ui",
3
- "version": "0.26.1",
3
+ "version": "0.27.0",
4
4
  "license": "MIT",
5
5
  "description": "Shared React 19 UI for web apps: one JavaScript entry (primitives, shell pieces, app helpers including the Convex and Clerk provider, VITE env parsing and ensure-user, artifact viewers over the @vvedantb/server artifacts core, native tokens), design-token CSS, shared Tailwind v4 CSS and a Tailwind preset.",
6
6
  "type": "module",
@@ -34,6 +34,7 @@
34
34
  "./tokens/css/compat-shadcn-rgb.css": "./tokens/src/css/compat-shadcn-rgb.css",
35
35
  "./tokens/css/border-beam.css": "./tokens/src/css/border-beam.css",
36
36
  "./tokens/css/markdown.css": "./tokens/src/css/markdown.css",
37
+ "./tokens/css/marquee.css": "./tokens/src/css/marquee.css",
37
38
  "./tokens/compat/shadcn-rgb.css": "./tokens/src/css/compat-shadcn-rgb.css",
38
39
  "./tokens/css/*.css": "./tokens/src/css/*.css",
39
40
  "./tokens/tailwind-preset": {
@@ -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";
@@ -0,0 +1,65 @@
1
+ import { useEffect, useRef, type ReactNode } from "react";
2
+ import { m } from "motion/react";
3
+ import { staggerDelay, tokenTransition } from "./presets";
4
+
5
+ /**
6
+ * `true` until the list has painted once. Pass `firstPaint.current` to
7
+ * `ListEnter` so a virtualised list's recycled rows (and later overscan) do
8
+ * not restagger. Stable keys already skip a remount on a data refresh.
9
+ */
10
+ export function useFirstPaintGate() {
11
+ const firstPaint = useRef(true);
12
+ useEffect(() => {
13
+ firstPaint.current = false;
14
+ }, []);
15
+ return firstPaint;
16
+ }
17
+
18
+ export interface ListEnterProps {
19
+ index: number;
20
+ children: ReactNode;
21
+ className?: string;
22
+ /** `tokenTransition.fast` instead of `.base`. */
23
+ fast?: boolean;
24
+ /** When `false`, skip the enter (recycled rows, later overscan). */
25
+ firstPaint?: boolean;
26
+ staggerStep?: number;
27
+ staggerMax?: number;
28
+ /** Fade only, for rows that must not shift (e.g. gantt bars). Default `true`. */
29
+ slide?: boolean;
30
+ /** `tr` / `li` when the parent is a table or list; an `m.div` there is invalid HTML. */
31
+ as?: "div" | "tr" | "li";
32
+ }
33
+
34
+ /**
35
+ * First-paint fade and 8px slide for a list item, staggered by `index`.
36
+ * Uses `m`, so the app must render it inside `LazyMotion`.
37
+ */
38
+ export function ListEnter({
39
+ index,
40
+ children,
41
+ className,
42
+ fast = false,
43
+ firstPaint,
44
+ staggerStep,
45
+ staggerMax,
46
+ slide = true,
47
+ as = "div",
48
+ }: ListEnterProps) {
49
+ const skip = firstPaint === false;
50
+ const transition = fast ? tokenTransition.fast : tokenTransition.base;
51
+ const MotionTag = as === "tr" ? m.tr : as === "li" ? m.li : m.div;
52
+ return (
53
+ <MotionTag
54
+ className={className}
55
+ initial={skip ? false : { opacity: 0, y: slide ? 8 : 0 }}
56
+ animate={{ opacity: 1, y: 0 }}
57
+ transition={{
58
+ ...transition,
59
+ delay: skip ? 0 : staggerDelay(index, staggerStep, staggerMax),
60
+ }}
61
+ >
62
+ {children}
63
+ </MotionTag>
64
+ );
65
+ }
@@ -0,0 +1,67 @@
1
+ import { AnimatePresence, m } from "motion/react";
2
+ import type { ReactNode } from "react";
3
+ import { tokenTransition } from "../motion/presets";
4
+ import { useCn } from "./ui-config";
5
+
6
+ /**
7
+ * The entrance and exit for a live count badge (unread dots, a drafts pill, a
8
+ * running count). The number usually changes while the user looks elsewhere,
9
+ * so the badge says *that it changed*: it pops from `scale(0.5)` on an
10
+ * emphasized curve, and pops again on every change because `label` is the
11
+ * key (2 → 3 remounts). Exit is a plain shrink on `tokenTransition.fast`.
12
+ *
13
+ * `mode="popLayout"` takes the leaving badge out of flow, so 9 → 10 does not
14
+ * push its neighbours sideways during the crossfade. Only the motion lives
15
+ * here; the caller owns the look through `className`. Uses `m`, so the app
16
+ * must render it inside `LazyMotion`.
17
+ */
18
+ export function CountPop({
19
+ label,
20
+ className,
21
+ children,
22
+ }: {
23
+ /** Rendered content, and the remount key. `null` renders nothing. */
24
+ label: string | null;
25
+ className?: string;
26
+ /** Defaults to `label`; pass children for a badge with more than a number. */
27
+ children?: ReactNode;
28
+ }) {
29
+ return (
30
+ <AnimatePresence mode="popLayout">
31
+ {label === null ? null : (
32
+ <m.span
33
+ key={label}
34
+ initial={{ opacity: 0, scale: 0.5 }}
35
+ animate={{ opacity: 1, scale: 1 }}
36
+ exit={{ opacity: 0, scale: 0.5, transition: tokenTransition.fast }}
37
+ transition={{ ...tokenTransition.base, ease: [0.2, 0.8, 0.2, 1] }}
38
+ className={className}
39
+ >
40
+ {children ?? label}
41
+ </m.span>
42
+ )}
43
+ </AnimatePresence>
44
+ );
45
+ }
46
+
47
+ /** A count badge label: `null` for nothing to show, `"99+"` above 99. */
48
+ export function countLabel(count: number | undefined): string | null {
49
+ if (count === undefined || count <= 0) return null;
50
+ return count > 99 ? "99+" : String(count);
51
+ }
52
+
53
+ /**
54
+ * The unread mark for rows, cards and tabs: a `bg-primary` dot, so it follows
55
+ * the theme accent. `CountPop` gives it the pop-in and the exit.
56
+ */
57
+ export function UnreadDot({ show, className }: { show: boolean; className?: string }) {
58
+ const cn = useCn();
59
+ return (
60
+ <CountPop
61
+ label={show ? "unread" : null}
62
+ className={cn("size-1.5 shrink-0 rounded-full bg-primary", className)}
63
+ >
64
+ <span className="sr-only">Unread</span>
65
+ </CountPop>
66
+ );
67
+ }