@vvedantb/ui 0.1.5 → 0.2.1

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 (87) hide show
  1. package/README.md +49 -0
  2. package/app/README.md +100 -0
  3. package/app/src/auth/useStableAuth.ts +69 -0
  4. package/app/src/cn.ts +6 -0
  5. package/app/src/confirm/ConfirmSkipHint.tsx +35 -0
  6. package/app/src/confirm/confirmSkip.ts +26 -0
  7. package/app/src/confirm/useAltHeld.ts +128 -0
  8. package/app/src/confirm/useRequestConfirm.ts +25 -0
  9. package/app/src/index.ts +43 -0
  10. package/app/src/settings/SettingsEmptyState.tsx +38 -0
  11. package/app/src/settings/SettingsField.tsx +42 -0
  12. package/app/src/settings/SettingsPage.tsx +88 -0
  13. package/app/src/settings/SettingsSection.tsx +92 -0
  14. package/app/src/settings/SettingsStack.tsx +17 -0
  15. package/app/src/settings/SettingsToggleRow.tsx +51 -0
  16. package/app/src/update/deployFingerprint.ts +27 -0
  17. package/app/src/update/updateWatcher.ts +100 -0
  18. package/package.json +71 -11
  19. package/shell/src/PillTabs.tsx +86 -0
  20. package/shell/src/SidebarHeader.tsx +21 -0
  21. package/shell/src/cn.ts +5 -0
  22. package/shell/src/index.ts +6 -0
  23. package/src/ai-elements/artifact.tsx +1 -1
  24. package/src/ai-elements/attachments.tsx +3 -3
  25. package/src/ai-elements/chain-of-thought.tsx +5 -5
  26. package/src/ai-elements/code-block.tsx +2 -2
  27. package/src/ai-elements/conversation.tsx +3 -3
  28. package/src/ai-elements/inline-citation.tsx +6 -6
  29. package/src/ai-elements/message.tsx +2 -2
  30. package/src/ai-elements/prompt-input.tsx +3 -3
  31. package/src/ai-elements/queue.tsx +2 -2
  32. package/src/ai-elements/reasoning.tsx +1 -1
  33. package/src/ai-elements/shimmer.tsx +5 -5
  34. package/src/ai-elements/sources.tsx +2 -2
  35. package/src/ai-elements/speech-input.tsx +1 -1
  36. package/src/ai-elements/suggestion.tsx +2 -2
  37. package/src/index.ts +18 -18
  38. package/src/ui/{_menu-classes.vibot.ts → _menu-classes.alt.ts} +4 -4
  39. package/src/ui/_menu-classes.ts +4 -4
  40. package/src/ui/{accordion.verve.tsx → accordion.alt.tsx} +2 -2
  41. package/src/ui/badge.tsx +1 -1
  42. package/src/ui/breadcrumb.tsx +2 -2
  43. package/src/ui/button.tsx +3 -3
  44. package/src/ui/checkbox.tsx +1 -1
  45. package/src/ui/{clear-input.vibot.tsx → clear-input.alt.tsx} +1 -1
  46. package/src/ui/clear-input.tsx +1 -1
  47. package/src/ui/command.tsx +5 -5
  48. package/src/ui/dialog.tsx +1 -1
  49. package/src/ui/{input-group.verve.tsx → input-group.alt.tsx} +1 -1
  50. package/src/ui/input-group.tsx +2 -2
  51. package/src/ui/kbd.tsx +1 -1
  52. package/src/ui/labeled-switch-row.tsx +2 -2
  53. package/src/ui/progress.tsx +1 -1
  54. package/src/ui/radix-select.tsx +3 -3
  55. package/src/ui/search-input.tsx +1 -1
  56. package/src/ui/select.tsx +4 -4
  57. package/src/ui/sonner.tsx +5 -5
  58. package/src/ui/spinner.tsx +1 -1
  59. package/src/ui/switch.tsx +2 -2
  60. package/src/ui/table.tsx +2 -2
  61. package/src/ui/time-picker.tsx +3 -3
  62. package/tokens/README.md +98 -0
  63. package/tokens/examples/compat-shadcn-rgb/Fixture.tsx +45 -0
  64. package/tokens/examples/compat-shadcn-rgb/README.md +11 -0
  65. package/tokens/examples/compat-shadcn-rgb/index.html +12 -0
  66. package/tokens/examples/compat-shadcn-rgb/main.tsx +5 -0
  67. package/tokens/examples/compat-shadcn-rgb/package.json +14 -0
  68. package/tokens/examples/compat-shadcn-rgb/styles.css +67 -0
  69. package/tokens/examples/compat-shadcn-rgb/tsconfig.json +14 -0
  70. package/tokens/examples/compat-shadcn-rgb/vite.config.ts +11 -0
  71. package/tokens/examples/migration.md +303 -0
  72. package/tokens/src/css/animations.css +55 -0
  73. package/tokens/src/css/base.css +51 -0
  74. package/tokens/src/css/compat-shadcn-rgb.css +75 -0
  75. package/tokens/src/css/dialog.css +13 -0
  76. package/tokens/src/css/index.css +19 -0
  77. package/tokens/src/css/modal.css +44 -0
  78. package/tokens/src/css/scrollbar-thin.css +40 -0
  79. package/tokens/src/css/tabs-pills.css +58 -0
  80. package/tokens/src/css/tabs.css +57 -0
  81. package/tokens/src/css/utilities.css +34 -0
  82. package/tokens/src/native.ts +39 -0
  83. package/tokens/src/tailwind-preset.d.mts +25 -0
  84. package/tokens/src/tailwind-preset.mjs +183 -0
  85. package/tokens/src/tokens.css +207 -0
  86. /package/src/motion/{presets.vibot.ts → presets.alt.ts} +0 -0
  87. /package/src/ui/{scroll-area.verve.tsx → scroll-area.alt.tsx} +0 -0
package/README.md ADDED
@@ -0,0 +1,49 @@
1
+ # @vvedantb/ui
2
+
3
+ Shared React 19 web UI. Source-exported: no build step.
4
+
5
+ | Import | What it is |
6
+ | --- | --- |
7
+ | `@vvedantb/ui` | Primitives (Radix-based) and AI elements. |
8
+ | `@vvedantb/ui/cn` | `cn` class helper (clsx + tailwind-merge). |
9
+ | `@vvedantb/ui/shell` | Router-free `SidebarHeader` and `PillTabs`. |
10
+ | `@vvedantb/ui/app` | Alt-click confirm skip, update watcher, settings chrome, `useStableAuth`. See `app/README.md` in the repository. |
11
+ | `@vvedantb/ui/tokens` | `tokens.css`: colour, radius, shadow, ease and font tokens for Tailwind v4. |
12
+ | `@vvedantb/ui/tokens/css/*.css` | `index`, `base`, `dialog`, `modal`, `tabs`, `tabs-pills`, `utilities`, `scrollbar-thin`, `animations`, `compat-shadcn-rgb`. |
13
+ | `@vvedantb/ui/tokens/compat/shadcn-rgb.css` | Same file as `tokens/css/compat-shadcn-rgb.css`. |
14
+ | `@vvedantb/ui/tokens/tailwind-preset` | Tailwind preset for apps that load a JS config with `@config`. |
15
+ | `@vvedantb/ui/tokens/native` | JS colours, radii and spacing. No CSS, no React DOM. |
16
+
17
+ ```css
18
+ @import "tailwindcss";
19
+ @import "@vvedantb/ui/tokens";
20
+ @source "../node_modules/@vvedantb/ui/src/**/*.{ts,tsx}";
21
+ ```
22
+
23
+ ## Colour names in components
24
+
25
+ Components use `text-muted-foreground`, `primary` and `--color-*` theme variables. They no longer use `text-muted`, `*-accent` or raw `var(--muted)` reads, which clash with shadcn names. `tokens.css` still defines `--color-muted` and `--color-accent`. `pnpm --filter @vvedantb/ui check:tokens` fails if a component brings the old names back (it also runs in `build`, `test` and `verify`).
26
+
27
+ ## Using an existing shadcn RGB theme
28
+
29
+ Use this when the app defines shadcn-style variables as space-separated channels (`--background: 244 245 246`) and reads them as `rgb(var(--background) / 0.5)`.
30
+
31
+ 1. Import `compat-shadcn-rgb.css` **instead of** `tokens.css`. Never import both.
32
+ 2. The app keeps `--background`, `--foreground`, `--card`, `--popover`, `--primary`, `--secondary`, `--muted`, `--accent`, `--destructive`, `--border`, `--input`, `--ring` (each with its `-foreground` where shadcn has one) and `--radius`. The compat file does not define them. It only references them, so the app can change `--primary`, `--ring`, `--radius` or `--font-sans` at runtime.
33
+ 3. Add the `@source` line for the package sources.
34
+
35
+ ```css
36
+ @import "tailwindcss";
37
+ @import "@vvedantb/ui/tokens/css/compat-shadcn-rgb.css";
38
+ @source "../node_modules/@vvedantb/ui/src/**/*.{ts,tsx}";
39
+ ```
40
+
41
+ Shared CSS that is safe without `tokens.css`: `compat-shadcn-rgb.css`, `dialog.css`, `utilities.css`, `scrollbar-thin.css` (reads `--scrollbar`, which the compat file defines), `modal.css` and `tabs.css` (both need the app motion variables listed in `tokens/README.md`), and `animations.css` (not with the Tailwind preset). `base.css` and `tabs-pills.css` read raw `--accent`, `--foreground`, `--muted`, `--segment` and `--default`; skip them unless the app defines those as full colour values.
42
+
43
+ The compat file includes a fallback `glass-panel-strong` (`rgb(var(--card) / 0.95)` with a 12px blur) for toasts and dialogs. Declare your own `@utility glass-panel-strong` after the import to override it.
44
+
45
+ A fixture that renders the main components against sample light and dark RGB variables is in `tokens/examples/compat-shadcn-rgb/`.
46
+
47
+ `@clerk/clerk-react`, `@tanstack/react-hotkeys` and `zod` are optional peers. Install them only if you import `@vvedantb/ui/app`.
48
+
49
+ Full documentation: the repository `README.md`. Upgrading from `@vvedantb/tokens`, `@vvedantb/shell` or `@vvedantb/app-kit`: `MIGRATION.md`.
package/app/README.md ADDED
@@ -0,0 +1,100 @@
1
+ # `@vvedantb/ui/app`
2
+
3
+ Shared web React helpers that were duplicated across apps. Source-exported as the `./app` subpath of `@vvedantb/ui`.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ npm install @vvedantb/ui
9
+ ```
10
+
11
+ ## Peers
12
+
13
+ Required: `react` and `react-dom` (>=19). Optional peers of `@vvedantb/ui`, needed only for this subpath: `@clerk/clerk-react` (`useStableAuth`), `@tanstack/react-hotkeys` and `zod`.
14
+
15
+ `sonner` is not a dependency. The update watcher takes the toast API as an option. This subpath uses a local class join (`cn`), not the `cn` from `@vvedantb/ui`. Tailwind must scan `node_modules/@vvedantb/ui/app/src/**/*.{ts,tsx}` so the class names are generated, and the app needs the `@vvedantb/ui/tokens` names (`bg-surface-card`, `divide-separator`, `text-muted-foreground` and so on).
16
+
17
+ ```css
18
+ @source "../node_modules/@vvedantb/ui/app/src/**/*.{ts,tsx}";
19
+ ```
20
+
21
+ ## Confirm skip (Alt-click)
22
+
23
+ ```tsx
24
+ import { ConfirmSkipHint, skipConfirmTitle, useRequestConfirm } from "@vvedantb/ui/app";
25
+
26
+ const { request } = useRequestConfirm();
27
+
28
+ <DropdownMenuItem
29
+ title={skipConfirmTitle("Delete")}
30
+ onSelect={(event) => request(() => setOpen(true), () => remove(), event)}
31
+ >
32
+ Delete <ConfirmSkipHint />
33
+ </DropdownMenuItem>
34
+ ```
35
+
36
+ - `useRequestConfirm` does not subscribe to Alt. It reads `getAltHeld()` when the request happens, so the menu owner does not re-render when Alt goes down. It returns `{ request }` only.
37
+ - `ConfirmSkipHint` stays mounted and gains a `hidden` class when Alt is not held, so an open Radix menu keeps a stable child tree.
38
+ - The Alt store resets when the last subscriber leaves, clears on blur and tab hide, and cancels a bare Alt keydown only inside `[role='menu']` or `[data-radix-menu-content]`.
39
+ - Also exported: `useAltHeld`, `getAltHeld`, `createAltHeldStore`, `bindToWindow`, `shouldSkipConfirm`, `requestConfirm`.
40
+
41
+ ## Update available watcher
42
+
43
+ ```ts
44
+ import { toast } from "sonner";
45
+ import { startUpdateAvailableWatcher } from "@vvedantb/ui/app";
46
+
47
+ useEffect(
48
+ () =>
49
+ startUpdateAvailableWatcher({
50
+ origin: window.location.origin,
51
+ toast,
52
+ closeConvex: () => void convex.close(),
53
+ enabled: !import.meta.env.DEV,
54
+ }),
55
+ [],
56
+ );
57
+ ```
58
+
59
+ Options: `origin`, `toast`, `closeConvex?`, `pollMs?` (default 60000), `toastId?` (default `app-update-available`), `enabled?` (default true), `reload?` (default `window.location.reload`). On a changed asset fingerprint it shows a sticky "Update available" toast. Refresh calls `closeConvex` (errors ignored), then reloads. `fingerprintFromHtml` and `fetchDeployFingerprint` are exported too. The fingerprint looks for Vite `/assets/*.js` URLs in the index HTML.
60
+
61
+ ## Settings chrome
62
+
63
+ ```tsx
64
+ import { SettingsPage, SettingsSection, SettingsToggleRow } from "@vvedantb/ui/app";
65
+
66
+ <SettingsPage
67
+ title="Notifications"
68
+ layout={({ title, headerRight, toolbar, tabs, fillHeight, children }) => (
69
+ <PageContainer title={title} rightSection={headerRight} toolbar={toolbar} tabs={tabs} noScroll={fillHeight}>
70
+ {children}
71
+ </PageContainer>
72
+ )}
73
+ >
74
+ <SettingsSection title="Email" bodyVariant="list">
75
+ <SettingsToggleRow title="Weekly digest" action={<Switch />} />
76
+ </SettingsSection>
77
+ </SettingsPage>
78
+ ```
79
+
80
+ Exports: `SettingsSection`, `SettingsField`, `SettingsStack`, `SettingsToggleRow`, `SettingsEmptyState` (icon is `ComponentType<{ size?: number; className?: string }>`), `SettingsPage`. Without `layout`, `SettingsPage` renders a plain `div` with a heading and the optional slots.
81
+
82
+ ## useStableAuth
83
+
84
+ ```ts
85
+ import { useStableAuth } from "@vvedantb/ui/app"; // 2000 ms debounce, wraps Clerk useAuth
86
+ import { createUseStableAuth } from "@vvedantb/ui/app";
87
+
88
+ const useAuth = createUseStableAuth(useClerkAuth, { debounceMs: 3000 });
89
+ ```
90
+
91
+ After a signed-in session, a drop to signed-out reports `isLoaded: false` for the debounce window so Convex does not cascade. State is module-level. `resetStableAuthSession()` clears it and is for tests only.
92
+
93
+ ## Develop
94
+
95
+ ```sh
96
+ pnpm --filter @vvedantb/ui typecheck
97
+ pnpm --filter @vvedantb/ui test
98
+ ```
99
+
100
+ Source is in `app/src` and tests in `app/tests`. Publish steps are in the root `README.md`.
@@ -0,0 +1,69 @@
1
+ import { useAuth } from "@clerk/clerk-react";
2
+ import { useEffect, useState } from "react";
3
+
4
+ /** The two Clerk `useAuth` fields the hook reads. */
5
+ export type ClerkAuthSlice = {
6
+ isLoaded: boolean;
7
+ isSignedIn: boolean | undefined;
8
+ };
9
+
10
+ export type StableAuthOptions = {
11
+ /** How long a sign-in loss reads as "still loading". Default 2000 ms. */
12
+ debounceMs?: number;
13
+ };
14
+
15
+ // Tracks sign-in during this session to detect stale deploy auth loss.
16
+ let wasEverSignedIn = false;
17
+
18
+ /**
19
+ * Test-only. Clears the module-level "was ever signed in" flag so one test
20
+ * cannot leak state into the next. Do not call from app code.
21
+ */
22
+ export function resetStableAuthSession(): void {
23
+ wasEverSignedIn = false;
24
+ }
25
+
26
+ /**
27
+ * Build a `useAuth` wrapper that debounces a brief signed-in to signed-out
28
+ * flicker (for example after a deploy) by reporting `isLoaded: false` for
29
+ * `debounceMs`, so Convex does not cascade on a transient auth loss.
30
+ */
31
+ export function createUseStableAuth<T extends ClerkAuthSlice>(
32
+ useAuthHook: () => T,
33
+ options: StableAuthOptions = {},
34
+ ): () => T {
35
+ const debounceMs = options.debounceMs ?? 2000;
36
+
37
+ return function useStableAuth(): T {
38
+ const auth = useAuthHook();
39
+ const [overrideLoading, setOverrideLoading] = useState(false);
40
+
41
+ useEffect(() => {
42
+ if (auth.isSignedIn) {
43
+ wasEverSignedIn = true;
44
+ setOverrideLoading(false);
45
+ return;
46
+ }
47
+
48
+ if (wasEverSignedIn && auth.isLoaded && !auth.isSignedIn) {
49
+ setOverrideLoading(true);
50
+ const timer = setTimeout(() => {
51
+ wasEverSignedIn = false;
52
+ setOverrideLoading(false);
53
+ }, debounceMs);
54
+ return () => clearTimeout(timer);
55
+ }
56
+
57
+ setOverrideLoading(false);
58
+ }, [auth.isLoaded, auth.isSignedIn]);
59
+
60
+ if (overrideLoading) {
61
+ return { ...auth, isLoaded: false };
62
+ }
63
+ return auth;
64
+ };
65
+ }
66
+
67
+ /** `useAuth` from `@clerk/clerk-react` with the 2000 ms debounce. */
68
+ export const useStableAuth: () => ReturnType<typeof useAuth> =
69
+ createUseStableAuth(useAuth);
package/app/src/cn.ts ADDED
@@ -0,0 +1,6 @@
1
+ type ClassPart = string | false | null | undefined;
2
+
3
+ /** Minimal class join. Drops falsy parts; does not merge conflicting Tailwind classes. */
4
+ export function cn(...parts: ClassPart[]): string {
5
+ return parts.filter(Boolean).join(" ");
6
+ }
@@ -0,0 +1,35 @@
1
+ import { formatForDisplay, type Hotkey } from "@tanstack/react-hotkeys";
2
+ import { cn } from "../cn.js";
3
+ import { z } from "zod";
4
+ import { useAltHeld } from "./useAltHeld.js";
5
+
6
+ // `Hotkey` covers combinations, not a bare modifier, so the literal is parsed
7
+ // in rather than asserted. `formatForDisplay` renders it as ⌥ / Alt.
8
+ const ALT_HOTKEY = z.custom<Hotkey>().parse("Alt");
9
+
10
+ /** Platform-native "⌥-click skips confirmation" / "Alt-click skips confirmation". */
11
+ export function skipConfirmTitle(actionTitle?: string): string {
12
+ const skip = `${formatForDisplay(ALT_HOTKEY)}-click skips confirmation`;
13
+ return actionTitle ? `${actionTitle} · ${skip}` : skip;
14
+ }
15
+
16
+ /**
17
+ * Visible only while Alt is held. Stays mounted (hidden) otherwise so an open
18
+ * Radix menu item keeps a stable child tree. Sits after the action label so every
19
+ * confirmable control shows that this click will skip the dialog.
20
+ */
21
+ export function ConfirmSkipHint({ className }: { className?: string }) {
22
+ const altHeld = useAltHeld();
23
+ return (
24
+ <kbd
25
+ className={cn(
26
+ "ml-auto shrink-0 rounded bg-surface-secondary px-1 py-0.5 font-mono text-[10px] text-muted-foreground",
27
+ !altHeld && "hidden",
28
+ className,
29
+ )}
30
+ aria-hidden
31
+ >
32
+ {formatForDisplay(ALT_HOTKEY)}
33
+ </kbd>
34
+ );
35
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Whether this activation should skip the confirmation dialog.
3
+ *
4
+ * `event.altKey` covers the click itself. `altHeld` covers Radix `onSelect`
5
+ * (and keyboard activation) which do not carry the modifier.
6
+ */
7
+ export function shouldSkipConfirm(
8
+ altHeld: boolean,
9
+ event?: { altKey?: boolean } | null,
10
+ ): boolean {
11
+ return altHeld || event?.altKey === true;
12
+ }
13
+
14
+ /** Open the dialog, or run the action immediately when Alt is held. */
15
+ export function requestConfirm(
16
+ altHeld: boolean,
17
+ open: () => void,
18
+ confirm: () => void,
19
+ event?: { altKey?: boolean } | null,
20
+ ): void {
21
+ if (shouldSkipConfirm(altHeld, event)) {
22
+ confirm();
23
+ return;
24
+ }
25
+ open();
26
+ }
@@ -0,0 +1,128 @@
1
+ import { useSyncExternalStore } from "react";
2
+
3
+ /**
4
+ * Whether Alt (Option on macOS) is held right now.
5
+ *
6
+ * Subscribes to the window so every confirmable control can restyle when the
7
+ * modifier goes down, and so Alt+click still works when the activating event
8
+ * is a Radix `onSelect` that does not carry `altKey`.
9
+ *
10
+ * Cleared on blur / visibility hide so a missed keyup after alt-tab cannot
11
+ * leave the app stuck in skip-confirm mode.
12
+ */
13
+
14
+ /** The only two fields the store reads off a keyboard event. */
15
+ export type AltKeyEvent = { key: string; altKey: boolean };
16
+
17
+ /** What the binding feeds the store. `clear` is any event that can swallow a keyup. */
18
+ export type AltHeldSignals = {
19
+ keyDown: (event: AltKeyEvent) => void;
20
+ keyUp: (event: AltKeyEvent) => void;
21
+ clear: () => void;
22
+ };
23
+
24
+ /** Wires the signals to a real event source and returns the unbind. */
25
+ export type AltHeldBinding = (signals: AltHeldSignals) => () => void;
26
+
27
+ export type AltHeldStore = {
28
+ subscribe: (onStoreChange: () => void) => () => void;
29
+ getSnapshot: () => boolean;
30
+ };
31
+
32
+ /**
33
+ * The binding is injected so the state machine can be driven without a DOM.
34
+ * Listeners attach lazily and, crucially, the held flag resets when the last
35
+ * subscriber leaves: with nothing bound, a keyup can no longer be observed, so
36
+ * a flag left at `true` would survive into the next mount and silently skip the
37
+ * confirmation dialog on a plain click.
38
+ */
39
+ export function createAltHeldStore(bind: AltHeldBinding): AltHeldStore {
40
+ let altHeld = false;
41
+ let unbind: (() => void) | null = null;
42
+ const listeners = new Set<() => void>();
43
+
44
+ function setHeld(next: boolean): void {
45
+ if (altHeld === next) return;
46
+ altHeld = next;
47
+ for (const listener of listeners) listener();
48
+ }
49
+
50
+ const signals: AltHeldSignals = {
51
+ keyDown: (event) => {
52
+ if (event.key === "Alt" || event.altKey) setHeld(true);
53
+ },
54
+ keyUp: (event) => {
55
+ // A keyup for some other key still reports altKey while Alt is down, so
56
+ // only a released Alt — or a keyup without the modifier — clears.
57
+ if (event.key === "Alt" || !event.altKey) setHeld(false);
58
+ },
59
+ clear: () => setHeld(false),
60
+ };
61
+
62
+ return {
63
+ subscribe: (onStoreChange) => {
64
+ if (unbind === null) unbind = bind(signals);
65
+ listeners.add(onStoreChange);
66
+ return () => {
67
+ listeners.delete(onStoreChange);
68
+ if (listeners.size > 0) return;
69
+ unbind?.();
70
+ unbind = null;
71
+ // Nothing is listening for the keyup any more; start the next mount
72
+ // from a known-unheld state rather than a stale `true`.
73
+ altHeld = false;
74
+ };
75
+ },
76
+ getSnapshot: () => altHeld,
77
+ };
78
+ }
79
+
80
+ function isInOpenMenu(target: EventTarget | null): boolean {
81
+ return (
82
+ target instanceof Element &&
83
+ target.closest("[role='menu'], [data-radix-menu-content]") !== null
84
+ );
85
+ }
86
+
87
+ /** Default binding: window key events plus blur and tab-hide clears. */
88
+ export const bindToWindow: AltHeldBinding = (signals) => {
89
+ if (typeof window === "undefined") return () => {};
90
+ const onKeyDown = (event: KeyboardEvent): void => {
91
+ // A bare Alt press can steal focus to the browser menu bar and dismiss an
92
+ // open Radix menu. Cancel it only when it lands inside one.
93
+ if (event.key === "Alt" && isInOpenMenu(event.target)) {
94
+ event.preventDefault();
95
+ }
96
+ signals.keyDown(event);
97
+ };
98
+ const onKeyUp = (event: KeyboardEvent): void => signals.keyUp(event);
99
+ const onBlur = (): void => signals.clear();
100
+ const onVisibilityChange = (): void => {
101
+ if (document.hidden) signals.clear();
102
+ };
103
+ window.addEventListener("keydown", onKeyDown);
104
+ window.addEventListener("keyup", onKeyUp);
105
+ window.addEventListener("blur", onBlur);
106
+ document.addEventListener("visibilitychange", onVisibilityChange);
107
+ return () => {
108
+ window.removeEventListener("keydown", onKeyDown);
109
+ window.removeEventListener("keyup", onKeyUp);
110
+ window.removeEventListener("blur", onBlur);
111
+ document.removeEventListener("visibilitychange", onVisibilityChange);
112
+ };
113
+ };
114
+
115
+ const altHeldStore = createAltHeldStore(bindToWindow);
116
+
117
+ /** Current value without subscribing. Safe to read inside event handlers. */
118
+ export function getAltHeld(): boolean {
119
+ return altHeldStore.getSnapshot();
120
+ }
121
+
122
+ export function useAltHeld(): boolean {
123
+ return useSyncExternalStore(
124
+ altHeldStore.subscribe,
125
+ altHeldStore.getSnapshot,
126
+ () => false,
127
+ );
128
+ }
@@ -0,0 +1,25 @@
1
+ import { requestConfirm } from "./confirmSkip.js";
2
+ import { getAltHeld } from "./useAltHeld.js";
3
+
4
+ type ConfirmEvent = { altKey?: boolean } | null;
5
+
6
+ function request(
7
+ open: () => void,
8
+ confirm: () => void,
9
+ event?: ConfirmEvent,
10
+ ): void {
11
+ requestConfirm(getAltHeld(), open, confirm, event);
12
+ }
13
+
14
+ const api = { request } as const;
15
+
16
+ /**
17
+ * Confirm helper for menu owners. It must not subscribe to Alt: a re-render of
18
+ * the owner when Alt goes down remounts the open Radix menu and closes it.
19
+ * `ConfirmSkipHint` is the only subscriber and lives inside the open menu.
20
+ * The Alt state is read when the request happens. The returned object is
21
+ * stable and holds no React state, so it is safe to call anywhere.
22
+ */
23
+ export function useRequestConfirm() {
24
+ return api;
25
+ }
@@ -0,0 +1,43 @@
1
+ export {
2
+ bindToWindow,
3
+ createAltHeldStore,
4
+ getAltHeld,
5
+ useAltHeld,
6
+ type AltHeldBinding,
7
+ type AltHeldSignals,
8
+ type AltHeldStore,
9
+ type AltKeyEvent,
10
+ } from "./confirm/useAltHeld.js";
11
+ export { requestConfirm, shouldSkipConfirm } from "./confirm/confirmSkip.js";
12
+ export { useRequestConfirm } from "./confirm/useRequestConfirm.js";
13
+ export { ConfirmSkipHint, skipConfirmTitle } from "./confirm/ConfirmSkipHint.js";
14
+
15
+ export {
16
+ fetchDeployFingerprint,
17
+ fingerprintFromHtml,
18
+ } from "./update/deployFingerprint.js";
19
+ export {
20
+ startUpdateAvailableWatcher,
21
+ type UpdateAvailableWatcherOptions,
22
+ type UpdateToastOptions,
23
+ } from "./update/updateWatcher.js";
24
+
25
+ export { SettingsSection } from "./settings/SettingsSection.js";
26
+ export { SettingsField } from "./settings/SettingsField.js";
27
+ export { SettingsStack } from "./settings/SettingsStack.js";
28
+ export { SettingsToggleRow } from "./settings/SettingsToggleRow.js";
29
+ export { SettingsEmptyState } from "./settings/SettingsEmptyState.js";
30
+ export {
31
+ DefaultSettingsLayout,
32
+ SettingsPage,
33
+ type SettingsLayoutProps,
34
+ type SettingsPageProps,
35
+ } from "./settings/SettingsPage.js";
36
+
37
+ export {
38
+ createUseStableAuth,
39
+ resetStableAuthSession,
40
+ useStableAuth,
41
+ type ClerkAuthSlice,
42
+ type StableAuthOptions,
43
+ } from "./auth/useStableAuth.js";
@@ -0,0 +1,38 @@
1
+ import type { ComponentType, ReactNode } from "react";
2
+
3
+ interface SettingsEmptyStateProps {
4
+ /** Tabler icon component, rendered muted above the copy. */
5
+ icon: ComponentType<{ size?: number; className?: string }>;
6
+ title: string;
7
+ /** One line on what to do about it. */
8
+ description?: string;
9
+ /** Primary call to action, e.g. the same button as the section header. */
10
+ action?: ReactNode;
11
+ }
12
+
13
+ /**
14
+ * The "nothing here yet" state for a settings section body.
15
+ *
16
+ * Sits inside a SettingsSection rather than floating on the canvas, so an empty
17
+ * list still reads as part of the card it belongs to.
18
+ * Shared across apps.
19
+ */
20
+ export function SettingsEmptyState({
21
+ icon: Icon,
22
+ title,
23
+ description,
24
+ action,
25
+ }: SettingsEmptyStateProps) {
26
+ return (
27
+ <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" />
29
+ <h4 className="text-sm font-medium text-foreground">{title}</h4>
30
+ {description ? (
31
+ <p className="mt-2 max-w-sm text-xs leading-relaxed text-muted-foreground">
32
+ {description}
33
+ </p>
34
+ ) : null}
35
+ {action ? <div className="mt-6">{action}</div> : null}
36
+ </div>
37
+ );
38
+ }
@@ -0,0 +1,42 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ interface SettingsFieldProps {
4
+ /** Field name, shown above the control. */
5
+ label: ReactNode;
6
+ /** Help text shown under the control. */
7
+ description?: ReactNode;
8
+ /** `id` of the control, so clicking the label focuses it. */
9
+ htmlFor?: string;
10
+ children: ReactNode;
11
+ }
12
+
13
+ /**
14
+ * One labelled control inside a SettingsSection body.
15
+ *
16
+ * Every settings form repeats the same label / control / help-text stack, so it
17
+ * lives here rather than being re-spaced per field. Stack several inside a
18
+ * section body with `grid gap-5`. Shared across apps.
19
+ */
20
+ export function SettingsField({
21
+ label,
22
+ description,
23
+ htmlFor,
24
+ children,
25
+ }: SettingsFieldProps) {
26
+ return (
27
+ <div>
28
+ <label
29
+ htmlFor={htmlFor}
30
+ className="mb-2 block text-sm font-medium text-foreground"
31
+ >
32
+ {label}
33
+ </label>
34
+ {children}
35
+ {description ? (
36
+ <p className="mt-1.5 text-pretty text-xs leading-relaxed text-muted-foreground">
37
+ {description}
38
+ </p>
39
+ ) : null}
40
+ </div>
41
+ );
42
+ }
@@ -0,0 +1,88 @@
1
+ import type { ReactNode } from "react";
2
+ import { cn } from "../cn.js";
3
+ import { SettingsStack } from "./SettingsStack.js";
4
+
5
+ export type SettingsLayoutProps = {
6
+ title: string;
7
+ headerRight?: ReactNode;
8
+ toolbar?: ReactNode;
9
+ tabs?: ReactNode;
10
+ fillHeight?: boolean;
11
+ children: ReactNode;
12
+ };
13
+
14
+ export interface SettingsPageProps {
15
+ title: string;
16
+ /** Sparse primary action(s) in the title row (e.g. Add, Delete). */
17
+ headerRight?: ReactNode;
18
+ /** Filters / search / segmented controls. Sits under the title. */
19
+ toolbar?: ReactNode;
20
+ /** Route tabs. Sit under the title (and under toolbar if both exist). */
21
+ tabs?: ReactNode;
22
+ /**
23
+ * Wrap children in the standard settings section stack. Set false when the
24
+ * child already owns its own vertical rhythm.
25
+ */
26
+ stack?: boolean;
27
+ /** Fill the remaining shell height instead of scrolling the page. */
28
+ fillHeight?: boolean;
29
+ /**
30
+ * Page chrome supplied by the app (for example its own page container).
31
+ * Receives the stacked children. Defaults to a plain `div` layout.
32
+ */
33
+ layout?: (props: SettingsLayoutProps) => ReactNode;
34
+ children: ReactNode;
35
+ }
36
+
37
+ /** Plain fallback layout: heading, optional slots, then the body. */
38
+ export function DefaultSettingsLayout({
39
+ title,
40
+ headerRight,
41
+ toolbar,
42
+ tabs,
43
+ fillHeight = false,
44
+ children,
45
+ }: SettingsLayoutProps) {
46
+ return (
47
+ <div
48
+ className={cn(
49
+ "mx-auto flex w-full max-w-3xl flex-col gap-4 px-4 py-6",
50
+ fillHeight && "h-full min-h-0",
51
+ )}
52
+ >
53
+ <div className="flex items-center justify-between gap-4">
54
+ <h1 className="text-balance text-lg font-semibold text-foreground">
55
+ {title}
56
+ </h1>
57
+ {headerRight ? <div className="shrink-0">{headerRight}</div> : null}
58
+ </div>
59
+ {toolbar ? <div>{toolbar}</div> : null}
60
+ {tabs ? <div>{tabs}</div> : null}
61
+ <div className={cn(fillHeight ? "min-h-0 flex-1" : null)}>{children}</div>
62
+ </div>
63
+ );
64
+ }
65
+
66
+ /**
67
+ * Shared chrome for settings: title row, optional toolbar / tabs, then a
68
+ * consistent section stack. Apps pass `layout` to wrap their own page
69
+ * container; this package does not import one.
70
+ */
71
+ export function SettingsPage({
72
+ title,
73
+ headerRight,
74
+ toolbar,
75
+ tabs,
76
+ stack = true,
77
+ fillHeight = false,
78
+ layout,
79
+ children,
80
+ }: SettingsPageProps) {
81
+ const body = stack ? <SettingsStack>{children}</SettingsStack> : children;
82
+ const render = layout ?? DefaultSettingsLayout;
83
+ return (
84
+ <>
85
+ {render({ title, headerRight, toolbar, tabs, fillHeight, children: body })}
86
+ </>
87
+ );
88
+ }