@slugbase/ui 0.0.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 (181) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/.turbo/turbo-lint.log +4 -0
  3. package/.turbo/turbo-typecheck.log +4 -0
  4. package/dist/components/AppShell.d.ts +17 -0
  5. package/dist/components/AppShell.d.ts.map +1 -0
  6. package/dist/components/AppShell.js +11 -0
  7. package/dist/components/AppShell.js.map +1 -0
  8. package/dist/components/Badge.d.ts +13 -0
  9. package/dist/components/Badge.d.ts.map +1 -0
  10. package/dist/components/Badge.js +21 -0
  11. package/dist/components/Badge.js.map +1 -0
  12. package/dist/components/Button.d.ts +11 -0
  13. package/dist/components/Button.d.ts.map +1 -0
  14. package/dist/components/Button.js +21 -0
  15. package/dist/components/Button.js.map +1 -0
  16. package/dist/components/Checkbox.d.ts +16 -0
  17. package/dist/components/Checkbox.d.ts.map +1 -0
  18. package/dist/components/Checkbox.js +19 -0
  19. package/dist/components/Checkbox.js.map +1 -0
  20. package/dist/components/ColorPicker.d.ts +14 -0
  21. package/dist/components/ColorPicker.d.ts.map +1 -0
  22. package/dist/components/ColorPicker.js +29 -0
  23. package/dist/components/ColorPicker.js.map +1 -0
  24. package/dist/components/ConfirmDialog.d.ts +17 -0
  25. package/dist/components/ConfirmDialog.d.ts.map +1 -0
  26. package/dist/components/ConfirmDialog.js +8 -0
  27. package/dist/components/ConfirmDialog.js.map +1 -0
  28. package/dist/components/Dialog.d.ts +15 -0
  29. package/dist/components/Dialog.d.ts.map +1 -0
  30. package/dist/components/Dialog.js +12 -0
  31. package/dist/components/Dialog.js.map +1 -0
  32. package/dist/components/EmptyState.d.ts +12 -0
  33. package/dist/components/EmptyState.d.ts.map +1 -0
  34. package/dist/components/EmptyState.js +6 -0
  35. package/dist/components/EmptyState.js.map +1 -0
  36. package/dist/components/FieldError.d.ts +7 -0
  37. package/dist/components/FieldError.d.ts.map +1 -0
  38. package/dist/components/FieldError.js +5 -0
  39. package/dist/components/FieldError.js.map +1 -0
  40. package/dist/components/FolderGlyph.d.ts +8 -0
  41. package/dist/components/FolderGlyph.d.ts.map +1 -0
  42. package/dist/components/FolderGlyph.js +14 -0
  43. package/dist/components/FolderGlyph.js.map +1 -0
  44. package/dist/components/IconButton.d.ts +14 -0
  45. package/dist/components/IconButton.d.ts.map +1 -0
  46. package/dist/components/IconButton.js +15 -0
  47. package/dist/components/IconButton.js.map +1 -0
  48. package/dist/components/IconPicker.d.ts +16 -0
  49. package/dist/components/IconPicker.d.ts.map +1 -0
  50. package/dist/components/IconPicker.js +32 -0
  51. package/dist/components/IconPicker.js.map +1 -0
  52. package/dist/components/Input.d.ts +10 -0
  53. package/dist/components/Input.d.ts.map +1 -0
  54. package/dist/components/Input.js +33 -0
  55. package/dist/components/Input.js.map +1 -0
  56. package/dist/components/Kbd.d.ts +11 -0
  57. package/dist/components/Kbd.d.ts.map +1 -0
  58. package/dist/components/Kbd.js +18 -0
  59. package/dist/components/Kbd.js.map +1 -0
  60. package/dist/components/Label.d.ts +6 -0
  61. package/dist/components/Label.d.ts.map +1 -0
  62. package/dist/components/Label.js +5 -0
  63. package/dist/components/Label.js.map +1 -0
  64. package/dist/components/Popover.d.ts +14 -0
  65. package/dist/components/Popover.d.ts.map +1 -0
  66. package/dist/components/Popover.js +9 -0
  67. package/dist/components/Popover.js.map +1 -0
  68. package/dist/components/Skeleton.d.ts +22 -0
  69. package/dist/components/Skeleton.d.ts.map +1 -0
  70. package/dist/components/Skeleton.js +14 -0
  71. package/dist/components/Skeleton.js.map +1 -0
  72. package/dist/components/SlugBadge.d.ts +13 -0
  73. package/dist/components/SlugBadge.d.ts.map +1 -0
  74. package/dist/components/SlugBadge.js +17 -0
  75. package/dist/components/SlugBadge.js.map +1 -0
  76. package/dist/components/Tag.d.ts +11 -0
  77. package/dist/components/Tag.d.ts.map +1 -0
  78. package/dist/components/Tag.js +16 -0
  79. package/dist/components/Tag.js.map +1 -0
  80. package/dist/components/ThemeSwitcher.d.ts +11 -0
  81. package/dist/components/ThemeSwitcher.d.ts.map +1 -0
  82. package/dist/components/ThemeSwitcher.js +25 -0
  83. package/dist/components/ThemeSwitcher.js.map +1 -0
  84. package/dist/components/ToastProvider.d.ts +19 -0
  85. package/dist/components/ToastProvider.d.ts.map +1 -0
  86. package/dist/components/ToastProvider.js +62 -0
  87. package/dist/components/ToastProvider.js.map +1 -0
  88. package/dist/components/Tooltip.d.ts +13 -0
  89. package/dist/components/Tooltip.d.ts.map +1 -0
  90. package/dist/components/Tooltip.js +9 -0
  91. package/dist/components/Tooltip.js.map +1 -0
  92. package/dist/components/auth-kit.d.ts +59 -0
  93. package/dist/components/auth-kit.d.ts.map +1 -0
  94. package/dist/components/auth-kit.js +101 -0
  95. package/dist/components/auth-kit.js.map +1 -0
  96. package/dist/constants.d.ts +2 -0
  97. package/dist/constants.d.ts.map +1 -0
  98. package/dist/constants.js +2 -0
  99. package/dist/constants.js.map +1 -0
  100. package/dist/icons/folder-icon-names.d.ts +4 -0
  101. package/dist/icons/folder-icon-names.d.ts.map +1 -0
  102. package/dist/icons/folder-icon-names.js +164 -0
  103. package/dist/icons/folder-icon-names.js.map +1 -0
  104. package/dist/icons/lucide-icon.d.ts +8 -0
  105. package/dist/icons/lucide-icon.d.ts.map +1 -0
  106. package/dist/icons/lucide-icon.js +38 -0
  107. package/dist/icons/lucide-icon.js.map +1 -0
  108. package/dist/index.d.ts +32 -0
  109. package/dist/index.d.ts.map +1 -0
  110. package/dist/index.js +31 -0
  111. package/dist/index.js.map +1 -0
  112. package/dist/tailwind/token-theme.d.ts +167 -0
  113. package/dist/tailwind/token-theme.d.ts.map +1 -0
  114. package/dist/tailwind/token-theme.js +109 -0
  115. package/dist/tailwind/token-theme.js.map +1 -0
  116. package/dist/theme/resolve-theme.d.ts +6 -0
  117. package/dist/theme/resolve-theme.d.ts.map +1 -0
  118. package/dist/theme/resolve-theme.js +24 -0
  119. package/dist/theme/resolve-theme.js.map +1 -0
  120. package/dist/theme/theme-context.d.ts +15 -0
  121. package/dist/theme/theme-context.d.ts.map +1 -0
  122. package/dist/theme/theme-context.js +51 -0
  123. package/dist/theme/theme-context.js.map +1 -0
  124. package/dist/theme/theme-types.d.ts +5 -0
  125. package/dist/theme/theme-types.d.ts.map +1 -0
  126. package/dist/theme/theme-types.js +3 -0
  127. package/dist/theme/theme-types.js.map +1 -0
  128. package/package.json +55 -0
  129. package/postcss.config.js +6 -0
  130. package/src/components/AppShell.spec.tsx +28 -0
  131. package/src/components/AppShell.tsx +49 -0
  132. package/src/components/Badge.tsx +41 -0
  133. package/src/components/Button.tsx +56 -0
  134. package/src/components/Checkbox.tsx +70 -0
  135. package/src/components/ColorPicker.spec.tsx +53 -0
  136. package/src/components/ColorPicker.tsx +87 -0
  137. package/src/components/ConfirmDialog.spec.tsx +44 -0
  138. package/src/components/ConfirmDialog.tsx +67 -0
  139. package/src/components/Dialog.tsx +63 -0
  140. package/src/components/EmptyState.spec.tsx +22 -0
  141. package/src/components/EmptyState.tsx +54 -0
  142. package/src/components/FieldError.tsx +19 -0
  143. package/src/components/FolderGlyph.tsx +38 -0
  144. package/src/components/IconButton.tsx +42 -0
  145. package/src/components/IconPicker.spec.tsx +54 -0
  146. package/src/components/IconPicker.tsx +132 -0
  147. package/src/components/Input.tsx +76 -0
  148. package/src/components/Kbd.tsx +30 -0
  149. package/src/components/Label.tsx +17 -0
  150. package/src/components/Popover.tsx +35 -0
  151. package/src/components/Skeleton.spec.tsx +18 -0
  152. package/src/components/Skeleton.tsx +94 -0
  153. package/src/components/SlugBadge.tsx +35 -0
  154. package/src/components/Tag.tsx +31 -0
  155. package/src/components/ThemeSwitcher.tsx +60 -0
  156. package/src/components/ToastProvider.spec.tsx +60 -0
  157. package/src/components/ToastProvider.tsx +167 -0
  158. package/src/components/Tooltip.tsx +33 -0
  159. package/src/components/__snapshots__/AppShell.spec.tsx.snap +41 -0
  160. package/src/components/auth-kit.spec.tsx +24 -0
  161. package/src/components/auth-kit.tsx +475 -0
  162. package/src/constants.ts +1 -0
  163. package/src/icons/folder-icon-names.ts +165 -0
  164. package/src/icons/lucide-icon.spec.ts +32 -0
  165. package/src/icons/lucide-icon.tsx +47 -0
  166. package/src/index.ts +100 -0
  167. package/src/styles/app.css +5 -0
  168. package/src/styles/colors-and-type.css +140 -0
  169. package/src/styles/ibm-plex-fonts.css +8 -0
  170. package/src/tailwind/token-theme.spec.ts +13 -0
  171. package/src/tailwind/token-theme.ts +114 -0
  172. package/src/theme/resolve-theme.spec.ts +17 -0
  173. package/src/theme/resolve-theme.ts +30 -0
  174. package/src/theme/theme-context.tsx +99 -0
  175. package/src/theme/theme-types.ts +7 -0
  176. package/tailwind.config.ts +11 -0
  177. package/tsconfig.build.json +4 -0
  178. package/tsconfig.build.tsbuildinfo +1 -0
  179. package/tsconfig.json +11 -0
  180. package/tsconfig.tsbuildinfo +1 -0
  181. package/vitest.config.ts +8 -0
@@ -0,0 +1,94 @@
1
+ import type { CSSProperties, ReactNode } from "react";
2
+
3
+ export type SkeletonProps = {
4
+ className?: string;
5
+ style?: CSSProperties;
6
+ testId?: string;
7
+ children?: ReactNode;
8
+ };
9
+
10
+ /** Shimmer placeholder block - prototype `.sk` pattern (spec §23). */
11
+ export function Skeleton({
12
+ className = "",
13
+ style,
14
+ testId,
15
+ children,
16
+ }: SkeletonProps) {
17
+ return (
18
+ <div
19
+ aria-hidden="true"
20
+ className={`animate-shimmer rounded-sm bg-[linear-gradient(90deg,var(--raised)_0%,var(--raised-2)_50%,var(--raised)_100%)] bg-[length:200%_100%] motion-reduce:animate-none ${className}`}
21
+ data-testid={testId}
22
+ style={style}
23
+ >
24
+ {children}
25
+ </div>
26
+ );
27
+ }
28
+
29
+ export type SkeletonListProps = {
30
+ rows?: number;
31
+ testId?: string;
32
+ };
33
+
34
+ /** Horizontal list-row skeletons for table/list surfaces. */
35
+ export function SkeletonList({ rows = 6, testId = "skeleton-list" }: SkeletonListProps) {
36
+ return (
37
+ <div className="flex flex-col gap-sp-3" data-testid={testId} aria-busy="true" aria-live="polite">
38
+ {Array.from({ length: rows }).map((_, index) => (
39
+ <div
40
+ key={index}
41
+ className="flex items-center gap-sp-5 rounded-lg border border-[color:var(--border-subtle)] bg-raised px-sp-5 py-sp-5"
42
+ >
43
+ <Skeleton className="h-8 w-8 shrink-0 rounded-md" />
44
+ <div className="flex min-w-0 flex-1 flex-col gap-sp-3">
45
+ <Skeleton className="h-3 w-[85%]" />
46
+ <Skeleton className="h-2.5 w-[55%]" />
47
+ </div>
48
+ <Skeleton className="ml-auto h-8 w-20 shrink-0" />
49
+ </div>
50
+ ))}
51
+ </div>
52
+ );
53
+ }
54
+
55
+ export type SkeletonCardGridProps = {
56
+ count?: number;
57
+ testId?: string;
58
+ };
59
+
60
+ /** Card grid skeleton matching bookmark grid loading state in the prototype. */
61
+ export function SkeletonCardGrid({
62
+ count = 9,
63
+ testId = "skeleton-card-grid",
64
+ }: SkeletonCardGridProps) {
65
+ return (
66
+ <div
67
+ className="grid gap-sp-4 sm:grid-cols-2 lg:grid-cols-3"
68
+ data-testid={testId}
69
+ aria-busy="true"
70
+ aria-live="polite"
71
+ >
72
+ {Array.from({ length: count }).map((_, index) => (
73
+ <div
74
+ key={index}
75
+ className="flex flex-col gap-sp-5 rounded-lg border border-[color:var(--border-subtle)] bg-raised p-sp-5"
76
+ >
77
+ <div className="flex gap-sp-4">
78
+ <Skeleton className="h-8 w-8 shrink-0 rounded-md" />
79
+ <div className="flex min-w-0 flex-1 flex-col gap-sp-3">
80
+ <Skeleton className="h-3 w-[85%]" />
81
+ <Skeleton className="h-2.5 w-[55%]" />
82
+ </div>
83
+ </div>
84
+ <Skeleton className="h-2.5 w-[70%]" />
85
+ <Skeleton className="h-[22px] w-[120px] rounded-sm" />
86
+ <div className="mt-sp-2 flex gap-sp-3">
87
+ <Skeleton className="h-2.5 w-[70px]" />
88
+ <Skeleton className="ml-auto h-4 w-10 rounded-sm" />
89
+ </div>
90
+ </div>
91
+ ))}
92
+ </div>
93
+ );
94
+ }
@@ -0,0 +1,35 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ export type SlugBadgeProps = {
4
+ slug: string;
5
+ icon?: ReactNode;
6
+ className?: string;
7
+ };
8
+
9
+ /**
10
+ * Inline slug address pill - accent-subtle bg, mono small font.
11
+ * Matches prototype `.slug-line` pattern from app.css.
12
+ * Used on bookmark cards and table rows.
13
+ */
14
+ export function SlugBadge({ slug, icon, className = "" }: SlugBadgeProps) {
15
+ return (
16
+ <span
17
+ className={`inline-flex max-w-full items-center gap-[6px] rounded-sm border border-[color:var(--accent-border)] bg-accent-subtle text-accent-text ${className}`}
18
+ style={{
19
+ height: "22px",
20
+ padding: "0 7px 0 6px",
21
+ fontFamily: "var(--font-mono)",
22
+ fontSize: "var(--text-small)",
23
+ fontWeight: "var(--weight-medium)",
24
+ lineHeight: "1",
25
+ }}
26
+ >
27
+ {icon ? (
28
+ <span aria-hidden="true" className="shrink-0 opacity-80">
29
+ {icon}
30
+ </span>
31
+ ) : null}
32
+ <span className="overflow-hidden text-ellipsis whitespace-nowrap">{slug}</span>
33
+ </span>
34
+ );
35
+ }
@@ -0,0 +1,31 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ export type TagProps = {
4
+ children: ReactNode;
5
+ className?: string;
6
+ };
7
+
8
+ /**
9
+ * Tag pill - `#` prefix at 45% opacity, mono micro font.
10
+ * Matches prototype `.tag` pattern from app.css.
11
+ */
12
+ export function Tag({ children, className = "" }: TagProps) {
13
+ return (
14
+ <span
15
+ className={`inline-flex items-center gap-1 whitespace-nowrap rounded-sm border border-[color:var(--border-subtle)] bg-raised-2 text-fg-muted ${className}`}
16
+ style={{
17
+ height: "19px",
18
+ padding: "0 7px",
19
+ fontFamily: "var(--font-mono)",
20
+ fontSize: "var(--text-micro)",
21
+ fontWeight: "var(--weight-medium)",
22
+ lineHeight: "1",
23
+ }}
24
+ >
25
+ <span aria-hidden="true" style={{ opacity: 0.45 }}>
26
+ #
27
+ </span>
28
+ {children}
29
+ </span>
30
+ );
31
+ }
@@ -0,0 +1,60 @@
1
+ import type { ThemePreference } from "../theme/theme-types.js";
2
+ import { useTheme } from "../theme/theme-context.js";
3
+
4
+ export type ThemeSwitcherLabels = {
5
+ group: string;
6
+ light: string;
7
+ dark: string;
8
+ auto: string;
9
+ };
10
+
11
+ const OPTIONS: ThemePreference[] = ["light", "dark", "auto"];
12
+
13
+ export type ThemeSwitcherProps = {
14
+ labels: ThemeSwitcherLabels;
15
+ };
16
+
17
+ export function ThemeSwitcher({ labels }: ThemeSwitcherProps) {
18
+ const { preference, setPreference } = useTheme();
19
+
20
+ const labelFor = (value: ThemePreference): string => {
21
+ switch (value) {
22
+ case "light":
23
+ return labels.light;
24
+ case "dark":
25
+ return labels.dark;
26
+ case "auto":
27
+ return labels.auto;
28
+ }
29
+ };
30
+
31
+ return (
32
+ <div
33
+ className="inline-flex rounded-md border border-[color:var(--border)] bg-raised p-sp-2"
34
+ role="group"
35
+ aria-label={labels.group}
36
+ data-testid="theme-switcher"
37
+ >
38
+ {OPTIONS.map((value) => {
39
+ const active = preference === value;
40
+ return (
41
+ <button
42
+ key={value}
43
+ type="button"
44
+ aria-pressed={active}
45
+ className={
46
+ active
47
+ ? "rounded-sm bg-accent px-sp-5 py-sp-3 text-small font-medium text-accent-fg transition-micro"
48
+ : "rounded-sm px-sp-5 py-sp-3 text-small text-fg-muted transition-micro hover:bg-raised-2 hover:text-fg"
49
+ }
50
+ onClick={() => {
51
+ setPreference(value);
52
+ }}
53
+ >
54
+ {labelFor(value)}
55
+ </button>
56
+ );
57
+ })}
58
+ </div>
59
+ );
60
+ }
@@ -0,0 +1,60 @@
1
+ import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+
4
+ import { ToastProvider, useToast } from "./ToastProvider.js";
5
+
6
+ function ToastHarness() {
7
+ const { toast } = useToast();
8
+ return (
9
+ <button
10
+ type="button"
11
+ onClick={() => {
12
+ toast({ title: "Saved", description: "Profile updated", kind: "success" });
13
+ }}
14
+ >
15
+ Show toast
16
+ </button>
17
+ );
18
+ }
19
+
20
+ describe("ToastProvider", () => {
21
+ afterEach(() => {
22
+ cleanup();
23
+ vi.useRealTimers();
24
+ });
25
+
26
+ it("shows and dismisses toasts with keyboard-friendly controls", () => {
27
+ render(
28
+ <ToastProvider dismissLabel="Dismiss notification">
29
+ <ToastHarness />
30
+ </ToastProvider>,
31
+ );
32
+
33
+ fireEvent.click(screen.getByRole("button", { name: "Show toast" }));
34
+ expect(screen.getByRole("status")).toBeTruthy();
35
+ expect(screen.getByText("Saved")).toBeTruthy();
36
+ expect(screen.getByText("Profile updated")).toBeTruthy();
37
+
38
+ fireEvent.click(screen.getByRole("button", { name: "Dismiss notification" }));
39
+ expect(screen.queryByRole("status")).toBeNull();
40
+ });
41
+
42
+ it("auto-dismisses after the default duration", () => {
43
+ vi.useFakeTimers();
44
+ render(
45
+ <ToastProvider dismissLabel="Dismiss notification">
46
+ <ToastHarness />
47
+ </ToastProvider>,
48
+ );
49
+
50
+ fireEvent.click(screen.getByRole("button", { name: "Show toast" }));
51
+ expect(screen.getByText("Saved")).toBeTruthy();
52
+
53
+ act(() => {
54
+ vi.advanceTimersByTime(3200);
55
+ });
56
+
57
+ expect(screen.queryByText("Saved")).toBeNull();
58
+ vi.useRealTimers();
59
+ });
60
+ });
@@ -0,0 +1,167 @@
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ type ReactNode,
10
+ } from "react";
11
+
12
+ export type ToastKind = "success" | "info" | "warning" | "danger";
13
+
14
+ export type ToastInput = {
15
+ title: ReactNode;
16
+ description?: ReactNode;
17
+ kind?: ToastKind;
18
+ durationMs?: number;
19
+ };
20
+
21
+ type ToastRecord = ToastInput & {
22
+ id: string;
23
+ };
24
+
25
+ export type ToastContextValue = {
26
+ toast: (input: ToastInput) => string;
27
+ dismiss: (id: string) => void;
28
+ };
29
+
30
+ const ToastContext = createContext<ToastContextValue | null>(null);
31
+
32
+ const DEFAULT_DURATION_MS = 3200;
33
+
34
+ const kindClasses: Record<ToastKind, string> = {
35
+ success: "bg-[color:var(--success-subtle)] text-success-text",
36
+ info: "bg-[color:var(--accent-subtle)] text-accent-text",
37
+ warning: "bg-[color:var(--warning-subtle)] text-warning-text",
38
+ danger: "bg-[color:var(--danger-subtle)] text-danger-text",
39
+ };
40
+
41
+ export type ToastProviderProps = {
42
+ children: ReactNode;
43
+ dismissLabel: string;
44
+ };
45
+
46
+ function ToastViewport({
47
+ items,
48
+ onDismiss,
49
+ dismissLabel,
50
+ }: {
51
+ items: ToastRecord[];
52
+ onDismiss: (id: string) => void;
53
+ dismissLabel: string;
54
+ }) {
55
+ const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
56
+
57
+ useEffect(() => {
58
+ const timers = timersRef.current;
59
+ for (const item of items) {
60
+ if (timers.has(item.id)) continue;
61
+ const duration = item.durationMs ?? DEFAULT_DURATION_MS;
62
+ timers.set(
63
+ item.id,
64
+ setTimeout(() => {
65
+ onDismiss(item.id);
66
+ timers.delete(item.id);
67
+ }, duration),
68
+ );
69
+ }
70
+ return () => {
71
+ for (const timer of timers.values()) {
72
+ clearTimeout(timer);
73
+ }
74
+ timers.clear();
75
+ };
76
+ }, [items, onDismiss]);
77
+
78
+ if (items.length === 0) return null;
79
+
80
+ return (
81
+ <div
82
+ className="pointer-events-none fixed bottom-sp-7 right-sp-7 z-[120] flex max-w-[min(100vw-2rem,360px)] flex-col gap-sp-4"
83
+ aria-live="polite"
84
+ aria-relevant="additions"
85
+ data-testid="toast-viewport"
86
+ >
87
+ {items.map((item) => {
88
+ const kind = item.kind ?? "success";
89
+ return (
90
+ <div
91
+ key={item.id}
92
+ className="pointer-events-auto flex animate-toast-in items-start gap-sp-4 rounded-lg border border-[color:var(--border-strong)] bg-overlay p-sp-5 shadow-overlay motion-reduce:animate-none"
93
+ data-testid={`toast-${item.id}`}
94
+ role="status"
95
+ >
96
+ <span
97
+ aria-hidden="true"
98
+ className={`grid h-[26px] w-[26px] shrink-0 place-items-center rounded-md ${kindClasses[kind]}`}
99
+ >
100
+ <span className="h-2 w-2 rounded-full bg-current" />
101
+ </span>
102
+ <div className="min-w-0 flex-1 text-left">
103
+ <p
104
+ className="m-0 text-fg"
105
+ style={{ fontSize: "var(--text-body)", lineHeight: "var(--lh-body)" }}
106
+ >
107
+ {item.title}
108
+ </p>
109
+ {item.description ? (
110
+ <p
111
+ className="mt-sp-1 m-0 text-fg-subtle"
112
+ style={{ fontSize: "var(--text-small)", lineHeight: "var(--lh-small)" }}
113
+ >
114
+ {item.description}
115
+ </p>
116
+ ) : null}
117
+ </div>
118
+ <button
119
+ type="button"
120
+ className="shrink-0 rounded-sm px-sp-2 py-sp-1 text-fg-subtle transition-colors hover:bg-raised hover:text-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
121
+ onClick={() => {
122
+ onDismiss(item.id);
123
+ }}
124
+ aria-label={dismissLabel}
125
+ >
126
+ ×
127
+ </button>
128
+ </div>
129
+ );
130
+ })}
131
+ </div>
132
+ );
133
+ }
134
+
135
+ export function ToastProvider({ children, dismissLabel }: ToastProviderProps) {
136
+ const [items, setItems] = useState<ToastRecord[]>([]);
137
+
138
+ const dismiss = useCallback((id: string) => {
139
+ setItems((current) => current.filter((item) => item.id !== id));
140
+ }, []);
141
+
142
+ const toast = useCallback((input: ToastInput) => {
143
+ const id =
144
+ typeof crypto !== "undefined" && "randomUUID" in crypto
145
+ ? crypto.randomUUID()
146
+ : `${String(Date.now())}-${Math.random().toString(16).slice(2)}`;
147
+ setItems((current) => [...current, { ...input, id }]);
148
+ return id;
149
+ }, []);
150
+
151
+ const value = useMemo(() => ({ toast, dismiss }), [toast, dismiss]);
152
+
153
+ return (
154
+ <ToastContext.Provider value={value}>
155
+ {children}
156
+ <ToastViewport items={items} onDismiss={dismiss} dismissLabel={dismissLabel} />
157
+ </ToastContext.Provider>
158
+ );
159
+ }
160
+
161
+ export function useToast(): ToastContextValue {
162
+ const context = useContext(ToastContext);
163
+ if (!context) {
164
+ throw new Error("useToast must be used within ToastProvider");
165
+ }
166
+ return context;
167
+ }
@@ -0,0 +1,33 @@
1
+ import * as TooltipPrimitive from "@radix-ui/react-tooltip";
2
+ import type { ReactNode } from "react";
3
+
4
+ export const TooltipProvider = TooltipPrimitive.Provider;
5
+ export const Tooltip = TooltipPrimitive.Root;
6
+ export const TooltipTrigger = TooltipPrimitive.Trigger;
7
+
8
+ export type TooltipContentProps = {
9
+ children: ReactNode;
10
+ className?: string;
11
+ testId?: string;
12
+ sideOffset?: number;
13
+ };
14
+
15
+ export function TooltipContent({
16
+ children,
17
+ className = "",
18
+ testId,
19
+ sideOffset = 4,
20
+ }: TooltipContentProps) {
21
+ return (
22
+ <TooltipPrimitive.Portal>
23
+ <TooltipPrimitive.Content
24
+ data-testid={testId}
25
+ sideOffset={sideOffset}
26
+ className={`z-[130] max-w-[280px] rounded-md border border-[color:var(--border-subtle)] bg-overlay px-sp-4 py-sp-3 text-small text-fg shadow-overlay ${className}`}
27
+ >
28
+ {children}
29
+ <TooltipPrimitive.Arrow className="fill-[color:var(--overlay)]" />
30
+ </TooltipPrimitive.Content>
31
+ </TooltipPrimitive.Portal>
32
+ );
33
+ }
@@ -0,0 +1,41 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`AppShell > matches base layout snapshot 1`] = `
4
+ <div>
5
+ <div
6
+ class="grid h-screen overflow-hidden bg-canvas font-sans text-body text-fg"
7
+ data-testid="app-shell"
8
+ style="grid-template-columns: 248px 1fr;"
9
+ >
10
+ <aside
11
+ class="flex min-h-0 flex-col overflow-y-auto border-r border-[color:var(--border-subtle)] bg-base"
12
+ data-testid="app-shell-sidebar"
13
+ >
14
+ <div>
15
+ Sidebar content
16
+ </div>
17
+ </aside>
18
+ <div
19
+ class="flex min-h-0 min-w-0 flex-col overflow-hidden"
20
+ >
21
+ <header
22
+ class="flex shrink-0 items-center border-b border-[color:var(--border-subtle)]"
23
+ data-testid="app-shell-topbar"
24
+ style="height: 52px;"
25
+ >
26
+ <div>
27
+ TopBar content
28
+ </div>
29
+ </header>
30
+ <main
31
+ class="flex-1 overflow-y-auto bg-canvas p-sp-7"
32
+ data-testid="app-shell-main"
33
+ >
34
+ <p>
35
+ Content
36
+ </p>
37
+ </main>
38
+ </div>
39
+ </div>
40
+ </div>
41
+ `;
@@ -0,0 +1,24 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { describe, expect, it } from "vitest";
3
+ import { AuthHeading, AuthShell, ErrorBanner } from "./auth-kit.js";
4
+
5
+ describe("AuthShell", () => {
6
+ it("renders brand copy and form children", () => {
7
+ render(
8
+ <AuthShell
9
+ brand={{
10
+ headline: "Headline",
11
+ subline: "Subline",
12
+ footer: "Footer",
13
+ }}
14
+ showSlugRows={false}
15
+ >
16
+ <AuthHeading title="Sign in" />
17
+ <ErrorBanner message="Invalid credentials" />
18
+ </AuthShell>,
19
+ );
20
+
21
+ expect(screen.getByRole("heading", { name: "Sign in" })).toBeTruthy();
22
+ expect(screen.getByRole("alert").textContent).toContain("Invalid credentials");
23
+ });
24
+ });