@vvedantb/ui 0.1.5 → 0.2.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 (45) hide show
  1. package/README.md +24 -0
  2. package/app/src/auth/useStableAuth.ts +69 -0
  3. package/app/src/cn.ts +6 -0
  4. package/app/src/confirm/ConfirmSkipHint.tsx +35 -0
  5. package/app/src/confirm/confirmSkip.ts +26 -0
  6. package/app/src/confirm/useAltHeld.ts +128 -0
  7. package/app/src/confirm/useRequestConfirm.ts +25 -0
  8. package/app/src/index.ts +43 -0
  9. package/app/src/settings/SettingsEmptyState.tsx +38 -0
  10. package/app/src/settings/SettingsField.tsx +42 -0
  11. package/app/src/settings/SettingsPage.tsx +88 -0
  12. package/app/src/settings/SettingsSection.tsx +92 -0
  13. package/app/src/settings/SettingsStack.tsx +17 -0
  14. package/app/src/settings/SettingsToggleRow.tsx +51 -0
  15. package/app/src/update/deployFingerprint.ts +27 -0
  16. package/app/src/update/updateWatcher.ts +100 -0
  17. package/package.json +65 -9
  18. package/shell/src/PillTabs.tsx +86 -0
  19. package/shell/src/SidebarHeader.tsx +21 -0
  20. package/shell/src/cn.ts +5 -0
  21. package/shell/src/index.ts +6 -0
  22. package/src/ai-elements/conversation.tsx +1 -1
  23. package/src/ai-elements/suggestion.tsx +1 -1
  24. package/src/index.ts +18 -18
  25. package/src/ui/{_menu-classes.vibot.ts → _menu-classes.alt.ts} +2 -2
  26. package/src/ui/_menu-classes.ts +2 -2
  27. package/tokens/examples/migration.md +303 -0
  28. package/tokens/src/css/animations.css +55 -0
  29. package/tokens/src/css/base.css +51 -0
  30. package/tokens/src/css/dialog.css +13 -0
  31. package/tokens/src/css/index.css +19 -0
  32. package/tokens/src/css/modal.css +44 -0
  33. package/tokens/src/css/scrollbar-thin.css +40 -0
  34. package/tokens/src/css/tabs-pills.css +58 -0
  35. package/tokens/src/css/tabs.css +57 -0
  36. package/tokens/src/css/utilities.css +34 -0
  37. package/tokens/src/native.ts +39 -0
  38. package/tokens/src/tailwind-preset.d.mts +25 -0
  39. package/tokens/src/tailwind-preset.mjs +177 -0
  40. package/tokens/src/tokens.css +192 -0
  41. /package/src/motion/{presets.vibot.ts → presets.alt.ts} +0 -0
  42. /package/src/ui/{accordion.verve.tsx → accordion.alt.tsx} +0 -0
  43. /package/src/ui/{clear-input.vibot.tsx → clear-input.alt.tsx} +0 -0
  44. /package/src/ui/{input-group.verve.tsx → input-group.alt.tsx} +0 -0
  45. /package/src/ui/{scroll-area.verve.tsx → scroll-area.alt.tsx} +0 -0
package/README.md ADDED
@@ -0,0 +1,24 @@
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`. |
13
+ | `@vvedantb/ui/tokens/tailwind-preset` | Tailwind preset for apps that load a JS config with `@config`. |
14
+ | `@vvedantb/ui/tokens/native` | JS colours, radii and spacing. No CSS, no React DOM. |
15
+
16
+ ```css
17
+ @import "tailwindcss";
18
+ @import "@vvedantb/ui/tokens";
19
+ @source "../node_modules/@vvedantb/ui/src/**/*.{ts,tsx}";
20
+ ```
21
+
22
+ `@clerk/clerk-react`, `@tanstack/react-hotkeys` and `zod` are optional peers. Install them only if you import `@vvedantb/ui/app`.
23
+
24
+ Full documentation: the repository `README.md`. Upgrading from `@vvedantb/tokens`, `@vvedantb/shell` or `@vvedantb/app-kit`: `MIGRATION.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",
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 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">
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">
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
+ }
@@ -0,0 +1,92 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ import { cn } from "../cn.js";
4
+
5
+ type SettingsSectionBodyVariant = "form" | "list" | "compact";
6
+
7
+ const BODY_VARIANT_CLASS: Record<SettingsSectionBodyVariant, string> = {
8
+ form: "px-4 py-5",
9
+ list: "divide-y divide-separator/50 p-0",
10
+ compact: "px-4 py-3",
11
+ };
12
+
13
+ interface SettingsSectionProps {
14
+ /** Section heading. Kept short — the description carries the detail. */
15
+ title: ReactNode;
16
+ /** Supporting copy shown under the heading. */
17
+ description?: ReactNode;
18
+ /** Control pinned to the top-right of the heading, e.g. a switch or link. */
19
+ action?: ReactNode;
20
+ /** Controls pinned to a bottom bar, e.g. a Save button. */
21
+ footer?: ReactNode;
22
+ children?: ReactNode;
23
+ /**
24
+ * Body padding contract. Prefer this over freelancing `bodyClassName` padding.
25
+ * - form: default labelled fields
26
+ * - list: edge-to-edge divided rows (`p-0`)
27
+ * - compact: tighter padding for dense controls
28
+ */
29
+ bodyVariant?: SettingsSectionBodyVariant;
30
+ /** Applied to the body wrapper, for one-off layout (grids, etc.). */
31
+ bodyClassName?: string;
32
+ className?: string;
33
+ }
34
+
35
+ /**
36
+ * The single container for a block of settings.
37
+ *
38
+ * Title and description sit on the canvas above the card so they read as a
39
+ * section caption. The card is only the controls (and optional footer).
40
+ * Shared across apps.
41
+ */
42
+ export function SettingsSection({
43
+ title,
44
+ description,
45
+ action,
46
+ footer,
47
+ children,
48
+ bodyVariant = "form",
49
+ bodyClassName,
50
+ className,
51
+ }: SettingsSectionProps) {
52
+ const hasBody = children != null;
53
+ const hasCard = hasBody || footer != null;
54
+
55
+ return (
56
+ <section className={cn("flex flex-col gap-2", className)}>
57
+ <header className="flex items-start justify-between gap-4 px-4">
58
+ <div className="min-w-0">
59
+ <h3 className="text-balance text-sm font-semibold text-foreground">
60
+ {title}
61
+ </h3>
62
+ {description ? (
63
+ <p className="mt-0.5 text-pretty text-sm leading-relaxed text-muted">
64
+ {description}
65
+ </p>
66
+ ) : null}
67
+ </div>
68
+ {action ? <div className="shrink-0">{action}</div> : null}
69
+ </header>
70
+
71
+ {hasCard ? (
72
+ <div
73
+ 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",
75
+ bodyVariant === "list" && "overflow-hidden",
76
+ )}
77
+ >
78
+ {hasBody ? (
79
+ <div className={cn(BODY_VARIANT_CLASS[bodyVariant], bodyClassName)}>
80
+ {children}
81
+ </div>
82
+ ) : null}
83
+ {footer ? (
84
+ <div className="flex items-center justify-end gap-2 rounded-b-lg bg-surface-secondary px-4 py-3">
85
+ {footer}
86
+ </div>
87
+ ) : null}
88
+ </div>
89
+ ) : null}
90
+ </section>
91
+ );
92
+ }
@@ -0,0 +1,17 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ import { cn } from "../cn.js";
4
+
5
+ /**
6
+ * Vertical rhythm for every settings page body — one gap, no page-local freelancing.
7
+ * Shared across apps.
8
+ */
9
+ export function SettingsStack({
10
+ children,
11
+ className,
12
+ }: {
13
+ children: ReactNode;
14
+ className?: string;
15
+ }) {
16
+ return <div className={cn("space-y-8", className)}>{children}</div>;
17
+ }