@terpjs/react-core 0.1.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 (154) hide show
  1. package/README.md +190 -0
  2. package/package.json +44 -0
  3. package/src/AppShell.test.tsx +152 -0
  4. package/src/AppShell.tsx +554 -0
  5. package/src/Authorized.test.tsx +60 -0
  6. package/src/Authorized.tsx +21 -0
  7. package/src/Breadcrumbs.test.tsx +45 -0
  8. package/src/Breadcrumbs.tsx +110 -0
  9. package/src/ConfirmDialog.tsx +170 -0
  10. package/src/DetailPage.tsx +28 -0
  11. package/src/EmptyState.tsx +74 -0
  12. package/src/ErrorState.tsx +108 -0
  13. package/src/Field.test.tsx +53 -0
  14. package/src/Field.tsx +51 -0
  15. package/src/HubPage.test.tsx +108 -0
  16. package/src/HubPage.tsx +204 -0
  17. package/src/LoadingState.test.tsx +40 -0
  18. package/src/LoadingState.tsx +96 -0
  19. package/src/LoginView.test.tsx +57 -0
  20. package/src/LoginView.tsx +203 -0
  21. package/src/ModuleNav.test.tsx +96 -0
  22. package/src/ModuleNav.tsx +88 -0
  23. package/src/OverviewPage.tsx +26 -0
  24. package/src/Page.test.tsx +147 -0
  25. package/src/Page.tsx +158 -0
  26. package/src/PageActions.test.tsx +104 -0
  27. package/src/PageActions.tsx +72 -0
  28. package/src/ProfileView.test.tsx +112 -0
  29. package/src/ProfileView.tsx +84 -0
  30. package/src/RequireAuth.test.tsx +89 -0
  31. package/src/RequireAuth.tsx +22 -0
  32. package/src/ResourceList.test.tsx +176 -0
  33. package/src/ResourceList.tsx +123 -0
  34. package/src/TerpProvider.tsx +320 -0
  35. package/src/UserMenu.test.tsx +166 -0
  36. package/src/UserMenu.tsx +125 -0
  37. package/src/admin/AdminHub.tsx +108 -0
  38. package/src/admin/AuditLogAdmin.tsx +116 -0
  39. package/src/admin/GroupCreate.tsx +90 -0
  40. package/src/admin/GroupDetail.tsx +446 -0
  41. package/src/admin/GroupsAdmin.tsx +109 -0
  42. package/src/admin/UserCreate.tsx +115 -0
  43. package/src/admin/UserDetail.tsx +228 -0
  44. package/src/admin/UsersAdmin.tsx +111 -0
  45. package/src/admin/admin.test.tsx +537 -0
  46. package/src/admin/crumbs.tsx +14 -0
  47. package/src/admin/module.tsx +51 -0
  48. package/src/admin/roles.ts +19 -0
  49. package/src/bootstrap.test.tsx +67 -0
  50. package/src/bootstrap.tsx +270 -0
  51. package/src/capabilities.test.ts +24 -0
  52. package/src/capabilities.ts +26 -0
  53. package/src/createAuthClient.test.ts +176 -0
  54. package/src/createAuthClient.ts +105 -0
  55. package/src/dataview/DataView.test.tsx +392 -0
  56. package/src/dataview/DataView.tsx +467 -0
  57. package/src/dataview/DataViewCardList.tsx +189 -0
  58. package/src/dataview/DataViewColumnSettings.tsx +118 -0
  59. package/src/dataview/DataViewExpandableRow.tsx +67 -0
  60. package/src/dataview/DataViewPagination.tsx +113 -0
  61. package/src/dataview/DataViewRowActions.tsx +131 -0
  62. package/src/dataview/DataViewTable.tsx +359 -0
  63. package/src/dataview/DataViewToolbar.tsx +260 -0
  64. package/src/dataview/README.md +138 -0
  65. package/src/dataview/glyphs.tsx +175 -0
  66. package/src/dataview/hooks/hooks.test.tsx +240 -0
  67. package/src/dataview/hooks/useDataViewQuery.ts +72 -0
  68. package/src/dataview/hooks/useDataViewState.ts +310 -0
  69. package/src/dataview/hooks/useServerDataView.ts +154 -0
  70. package/src/dataview/hooks/useViewSearch.ts +68 -0
  71. package/src/dataview/index.ts +62 -0
  72. package/src/dataview/internal.tsx +96 -0
  73. package/src/dataview/repositories/HttpDataViewRepository.ts +110 -0
  74. package/src/dataview/repositories/InMemoryDataViewRepository.ts +145 -0
  75. package/src/dataview/repositories/repositories.test.ts +158 -0
  76. package/src/dataview/repositories/viewState.test.ts +90 -0
  77. package/src/dataview/repositories/viewState.ts +128 -0
  78. package/src/dataview/types.ts +249 -0
  79. package/src/errorMessages.test.tsx +83 -0
  80. package/src/errorMessages.tsx +79 -0
  81. package/src/feedback.test.tsx +167 -0
  82. package/src/files.test.tsx +142 -0
  83. package/src/files.tsx +174 -0
  84. package/src/icons.test.tsx +46 -0
  85. package/src/icons.tsx +533 -0
  86. package/src/index.ts +155 -0
  87. package/src/layout.test.tsx +72 -0
  88. package/src/layout.tsx +90 -0
  89. package/src/layoutContract.test.tsx +179 -0
  90. package/src/layoutContract.ts +137 -0
  91. package/src/locale.test.tsx +97 -0
  92. package/src/locale.tsx +246 -0
  93. package/src/nav.test.ts +21 -0
  94. package/src/nav.ts +13 -0
  95. package/src/pageMarker.ts +15 -0
  96. package/src/raw.d.ts +7 -0
  97. package/src/realtime-hook.test.tsx +226 -0
  98. package/src/realtime.test.ts +44 -0
  99. package/src/realtime.ts +307 -0
  100. package/src/refresh-session.test.tsx +114 -0
  101. package/src/revocation.test.tsx +81 -0
  102. package/src/router.test.tsx +307 -0
  103. package/src/router.tsx +222 -0
  104. package/src/sso.test.tsx +128 -0
  105. package/src/sso.ts +142 -0
  106. package/src/ssr.test.tsx +45 -0
  107. package/src/styles.test.ts +21 -0
  108. package/src/styles.ts +302 -0
  109. package/src/theme.test.tsx +74 -0
  110. package/src/theme.tsx +143 -0
  111. package/src/toast.test.tsx +94 -0
  112. package/src/toast.tsx +214 -0
  113. package/src/tokens.guard.test.ts +51 -0
  114. package/src/ui/Alert.test.tsx +19 -0
  115. package/src/ui/Alert.tsx +115 -0
  116. package/src/ui/Badge.test.tsx +14 -0
  117. package/src/ui/Badge.tsx +48 -0
  118. package/src/ui/Button.test.tsx +36 -0
  119. package/src/ui/Button.tsx +95 -0
  120. package/src/ui/Card.test.tsx +40 -0
  121. package/src/ui/Card.tsx +92 -0
  122. package/src/ui/Checkbox.test.tsx +17 -0
  123. package/src/ui/Checkbox.tsx +51 -0
  124. package/src/ui/Combobox.test.tsx +58 -0
  125. package/src/ui/Combobox.tsx +313 -0
  126. package/src/ui/DatePicker.test.tsx +60 -0
  127. package/src/ui/DatePicker.tsx +421 -0
  128. package/src/ui/Input.tsx +30 -0
  129. package/src/ui/Markdown.test.tsx +32 -0
  130. package/src/ui/Markdown.tsx +213 -0
  131. package/src/ui/Menu.test.tsx +85 -0
  132. package/src/ui/Menu.tsx +216 -0
  133. package/src/ui/Popover.tsx +218 -0
  134. package/src/ui/Radio.test.tsx +29 -0
  135. package/src/ui/Radio.tsx +127 -0
  136. package/src/ui/Select.tsx +40 -0
  137. package/src/ui/Switch.test.tsx +17 -0
  138. package/src/ui/Switch.tsx +53 -0
  139. package/src/ui/Tabs.test.tsx +29 -0
  140. package/src/ui/Tabs.tsx +128 -0
  141. package/src/ui/Textarea.tsx +27 -0
  142. package/src/ui/Tooltip.test.tsx +28 -0
  143. package/src/ui/Tooltip.tsx +67 -0
  144. package/src/ui/controlStyles.ts +9 -0
  145. package/src/uiText.test.tsx +93 -0
  146. package/src/uiText.tsx +342 -0
  147. package/src/unwrap.test.ts +67 -0
  148. package/src/unwrap.ts +101 -0
  149. package/src/useResource.test.tsx +118 -0
  150. package/src/useResource.ts +110 -0
  151. package/src/useTerpClient.test.ts +35 -0
  152. package/tsconfig.json +17 -0
  153. package/vite.config.ts +14 -0
  154. package/vitest.setup.ts +58 -0
package/src/theme.tsx ADDED
@@ -0,0 +1,143 @@
1
+ import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
2
+ import type { ReactNode } from "react";
3
+
4
+ import { Icon } from "./icons";
5
+ import { Menu, MenuItem } from "./ui/Menu";
6
+ import { useStrings } from "./uiText";
7
+
8
+ /**
9
+ * The visual theme: an explicit choice, or "system" to follow the OS preference.
10
+ * The token stylesheet (`@terpjs/contract/tokens.css`) carries both palettes: it applies
11
+ * the dark colours under `<html data-theme="dark">` and — with no attribute — under
12
+ * `@media (prefers-color-scheme: dark)`, so "system" simply removes the attribute.
13
+ */
14
+ export type Theme = "light" | "dark" | "system";
15
+
16
+ const THEMES: readonly Theme[] = ["light", "dark", "system"];
17
+ const THEME_ICONS: Record<Theme, string> = {
18
+ light: "sun",
19
+ dark: "moon",
20
+ system: "monitor",
21
+ };
22
+
23
+ /** The `localStorage` key {@link ThemeProvider} persists the choice under. */
24
+ export const THEME_STORAGE_KEY = "terp.theme";
25
+
26
+ interface ThemeContextValue {
27
+ theme: Theme;
28
+ setTheme: (theme: Theme) => void;
29
+ }
30
+
31
+ const ThemeContext = createContext<ThemeContextValue | null>(null);
32
+
33
+ function readStoredTheme(): Theme {
34
+ try {
35
+ const stored = window.localStorage.getItem(THEME_STORAGE_KEY);
36
+ return THEMES.includes(stored as Theme) ? (stored as Theme) : "system";
37
+ } catch {
38
+ return "system";
39
+ }
40
+ }
41
+
42
+ export interface ThemeProviderProps {
43
+ /** Starting theme when the user has not chosen one yet; default "system". */
44
+ defaultTheme?: Theme;
45
+ children: ReactNode;
46
+ }
47
+
48
+ /**
49
+ * Owns the light/dark theme choice: applies it as `data-theme` on `<html>` (the token
50
+ * stylesheet does the rest — no component changes anywhere) and persists it in
51
+ * `localStorage`. `renderTerpApp` mounts one for every app; pair with {@link ThemeToggle}
52
+ * (the default {@link UserMenu} already includes it).
53
+ */
54
+ export function ThemeProvider({ defaultTheme = "system", children }: ThemeProviderProps) {
55
+ const [theme, setThemeState] = useState<Theme>(() => {
56
+ const stored = readStoredTheme();
57
+ return stored === "system" ? defaultTheme : stored;
58
+ });
59
+
60
+ useEffect(() => {
61
+ const root = document.documentElement;
62
+ if (theme === "system") {
63
+ root.removeAttribute("data-theme");
64
+ } else {
65
+ root.setAttribute("data-theme", theme);
66
+ }
67
+ }, [theme]);
68
+
69
+ const setTheme = useCallback((next: Theme) => {
70
+ setThemeState(next);
71
+ try {
72
+ window.localStorage.setItem(THEME_STORAGE_KEY, next);
73
+ } catch {
74
+ // Private mode / quota: the choice still applies for this session.
75
+ }
76
+ }, []);
77
+
78
+ const value = useMemo(() => ({ theme, setTheme }), [theme, setTheme]);
79
+ return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
80
+ }
81
+
82
+ /** The active theme + setter, or `null` outside a {@link ThemeProvider}. */
83
+ export function useTheme(): ThemeContextValue | null {
84
+ return useContext(ThemeContext);
85
+ }
86
+
87
+ export interface ThemeToggleProps {
88
+ /**
89
+ * `"stacked"` (default) renders a labelled icon menu for settings panels;
90
+ * `"inline"` renders only the compact icon trigger used by the shell header.
91
+ */
92
+ variant?: "stacked" | "inline";
93
+ }
94
+
95
+ /**
96
+ * The standard theme control: a token-themed light/dark/system menu. Renders nothing
97
+ * outside a {@link ThemeProvider}, so shared chrome (the shell header) can
98
+ * include it unconditionally.
99
+ */
100
+ export function ThemeToggle({ variant = "stacked" }: ThemeToggleProps) {
101
+ const context = useTheme();
102
+ const strings = useStrings();
103
+ if (context === null) {
104
+ return null;
105
+ }
106
+ const labels: Record<Theme, string> = {
107
+ light: strings.themeLight,
108
+ dark: strings.themeDark,
109
+ system: strings.themeSystem,
110
+ };
111
+ const menu = (
112
+ <Menu
113
+ trigger={<Icon name={THEME_ICONS[context.theme]} size="1.15rem" />}
114
+ triggerLabel={strings.theme}
115
+ >
116
+ {({ close }) => (
117
+ <>
118
+ {THEMES.map((theme) => (
119
+ <MenuItem
120
+ key={theme}
121
+ label={labels[theme]}
122
+ icon={<Icon name={THEME_ICONS[theme]} />}
123
+ selected={theme === context.theme}
124
+ onSelect={() => {
125
+ context.setTheme(theme);
126
+ close(true);
127
+ }}
128
+ />
129
+ ))}
130
+ </>
131
+ )}
132
+ </Menu>
133
+ );
134
+ if (variant === "inline") {
135
+ return menu;
136
+ }
137
+ return (
138
+ <div style={{ display: "grid", justifyItems: "start", gap: "var(--space-1)", fontSize: "var(--font-size-sm)" }}>
139
+ <span style={{ color: "var(--color-neutral-600)" }}>{strings.theme}</span>
140
+ {menu}
141
+ </div>
142
+ );
143
+ }
@@ -0,0 +1,94 @@
1
+ // @vitest-environment jsdom
2
+ import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { ToastProvider, useToast } from "./toast";
6
+ import { UiTextProvider } from "./uiText";
7
+
8
+ afterEach(() => {
9
+ cleanup();
10
+ vi.useRealTimers();
11
+ });
12
+
13
+ function Trigger() {
14
+ const toast = useToast();
15
+ return (
16
+ <div>
17
+ <button onClick={() => toast.success("Task created.")}>success</button>
18
+ <button onClick={() => toast.error("Save failed.")}>error</button>
19
+ <button onClick={() => toast.warning("Rows skipped.", { durationMs: 1000 })}>
20
+ warning
21
+ </button>
22
+ </div>
23
+ );
24
+ }
25
+
26
+ describe("useToast / ToastProvider", () => {
27
+ it("throws without a provider (fail closed)", () => {
28
+ expect(() => render(<Trigger />)).toThrow(/ToastProvider/);
29
+ });
30
+
31
+ it("shows a polite success toast with the default title", () => {
32
+ render(
33
+ <ToastProvider>
34
+ <Trigger />
35
+ </ToastProvider>,
36
+ );
37
+
38
+ fireEvent.click(screen.getByText("success"));
39
+
40
+ const toast = screen.getByRole("status");
41
+ expect(toast).toHaveTextContent("Success");
42
+ expect(toast).toHaveTextContent("Task created.");
43
+ });
44
+
45
+ it("announces error and warning toasts assertively with localisable titles", () => {
46
+ render(
47
+ <UiTextProvider strings={{ errorTitle: "Mislukt", warningTitle: "Waarschuwing" }}>
48
+ <ToastProvider>
49
+ <Trigger />
50
+ </ToastProvider>
51
+ </UiTextProvider>,
52
+ );
53
+
54
+ fireEvent.click(screen.getByText("error"));
55
+ fireEvent.click(screen.getByText("warning"));
56
+
57
+ const alerts = screen.getAllByRole("alert");
58
+ expect(alerts[0]).toHaveTextContent("Mislukt");
59
+ expect(alerts[0]).toHaveTextContent("Save failed.");
60
+ expect(alerts[1]).toHaveTextContent("Waarschuwing");
61
+ expect(alerts[1]).toHaveTextContent("Rows skipped.");
62
+ });
63
+
64
+ it("dismisses via the dismiss button", () => {
65
+ render(
66
+ <ToastProvider>
67
+ <Trigger />
68
+ </ToastProvider>,
69
+ );
70
+
71
+ fireEvent.click(screen.getByText("success"));
72
+ fireEvent.click(screen.getByLabelText("Dismiss"));
73
+
74
+ expect(screen.queryByRole("status")).not.toBeInTheDocument();
75
+ });
76
+
77
+ it("auto-dismisses after the configured duration", () => {
78
+ vi.useFakeTimers();
79
+ render(
80
+ <ToastProvider>
81
+ <Trigger />
82
+ </ToastProvider>,
83
+ );
84
+
85
+ fireEvent.click(screen.getByText("warning"));
86
+ expect(screen.getByRole("alert")).toBeInTheDocument();
87
+
88
+ act(() => {
89
+ vi.advanceTimersByTime(1000);
90
+ });
91
+
92
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
93
+ });
94
+ });
package/src/toast.tsx ADDED
@@ -0,0 +1,214 @@
1
+ import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
2
+ import type { CSSProperties, ReactNode } from "react";
3
+
4
+ import { Icon } from "./icons";
5
+ import { useStrings, useUiText } from "./uiText";
6
+ import type { UiText } from "./uiText";
7
+
8
+ export type ToastVariant = "success" | "error" | "warning";
9
+
10
+ export interface ToastOptions {
11
+ /** Override the variant's default title string. */
12
+ title?: UiText;
13
+ /** Auto-dismiss delay in milliseconds; default 5000. */
14
+ durationMs?: number;
15
+ }
16
+
17
+ /** Imperative toast API returned by {@link useToast}. */
18
+ export interface ToastApi {
19
+ /** Confirmation after a mutation settles ("Task created."). */
20
+ success: (description: ReactNode, options?: ToastOptions) => void;
21
+ /** Failure of a background or submitted action. */
22
+ error: (description: ReactNode, options?: ToastOptions) => void;
23
+ /** Non-blocking caution ("Some rows were skipped."). */
24
+ warning: (description: ReactNode, options?: ToastOptions) => void;
25
+ }
26
+
27
+ interface ToastItem {
28
+ id: number;
29
+ variant: ToastVariant;
30
+ title: UiText | undefined;
31
+ description: ReactNode;
32
+ durationMs: number;
33
+ }
34
+
35
+ const ToastContext = createContext<ToastApi | null>(null);
36
+
37
+ const DEFAULT_DURATION_MS = 5000;
38
+
39
+ const viewportStyle: CSSProperties = {
40
+ position: "fixed",
41
+ bottom: "var(--space-4)",
42
+ right: "var(--space-4)",
43
+ display: "grid",
44
+ gap: "var(--space-2)",
45
+ zIndex: 100,
46
+ maxWidth: "min(22.5rem, calc(100vw - 2 * var(--space-4)))",
47
+ };
48
+
49
+ const toastStyle = (variant: ToastVariant): CSSProperties => ({
50
+ display: "grid",
51
+ gridTemplateColumns: "auto 1fr auto",
52
+ alignItems: "start",
53
+ gap: "var(--space-2)",
54
+ padding: "var(--space-3) var(--space-4)",
55
+ borderRadius: "var(--radius-md)",
56
+ border: `1px solid ${borderColor[variant]}`,
57
+ borderInlineStart: `3px solid ${titleColor[variant]}`,
58
+ background: "var(--color-neutral-0)",
59
+ color: "var(--color-neutral-900)",
60
+ fontSize: "var(--font-size-sm)",
61
+ boxShadow: "var(--shadow-md)",
62
+ });
63
+
64
+ const titleColor: Record<ToastVariant, string> = {
65
+ success: "var(--color-status-success)",
66
+ error: "var(--color-status-danger)",
67
+ warning: "var(--color-status-warning)",
68
+ };
69
+
70
+ const borderColor: Record<ToastVariant, string> = {
71
+ success: "var(--color-status-success-soft)",
72
+ error: "var(--color-status-danger-soft)",
73
+ warning: "var(--color-status-warning-soft)",
74
+ };
75
+
76
+ const iconName: Record<ToastVariant, string> = {
77
+ success: "check",
78
+ error: "x",
79
+ warning: "bell",
80
+ };
81
+
82
+ const iconWrapStyle = (variant: ToastVariant): CSSProperties => ({
83
+ color: titleColor[variant],
84
+ display: "inline-flex",
85
+ alignItems: "center",
86
+ paddingTop: "2px",
87
+ });
88
+
89
+ const dismissStyle: CSSProperties = {
90
+ border: "none",
91
+ background: "none",
92
+ padding: "var(--space-1)",
93
+ cursor: "pointer",
94
+ color: "var(--color-neutral-500)",
95
+ fontSize: "var(--font-size-base)",
96
+ lineHeight: 1,
97
+ borderRadius: "var(--radius-sm)",
98
+ };
99
+
100
+ function ToastCard({ toast, onDismiss }: { toast: ToastItem; onDismiss: () => void }) {
101
+ const strings = useStrings();
102
+ const resolve = useUiText();
103
+ const defaultTitle: Record<ToastVariant, string> = {
104
+ success: strings.successTitle,
105
+ error: strings.errorTitle,
106
+ warning: strings.warningTitle,
107
+ };
108
+ return (
109
+ <div role={toast.variant === "success" ? "status" : "alert"} style={toastStyle(toast.variant)}>
110
+ <span aria-hidden="true" style={iconWrapStyle(toast.variant)}>
111
+ <Icon name={iconName[toast.variant]} size="1.1rem" />
112
+ </span>
113
+ <div style={{ display: "grid", gap: "var(--space-1)" }}>
114
+ <strong
115
+ style={{
116
+ color: titleColor[toast.variant],
117
+ fontWeight: "var(--font-weight-semibold)" as never,
118
+ }}
119
+ >
120
+ {resolve(toast.title ?? defaultTitle[toast.variant])}
121
+ </strong>
122
+ {toast.description !== null && toast.description !== undefined && (
123
+ <div>{toast.description}</div>
124
+ )}
125
+ </div>
126
+ <button
127
+ type="button"
128
+ data-terp="iconbutton"
129
+ aria-label={strings.dismiss}
130
+ style={dismissStyle}
131
+ onClick={onDismiss}
132
+ >
133
+ ×
134
+ </button>
135
+ </div>
136
+ );
137
+ }
138
+
139
+ export interface ToastProviderProps {
140
+ children: ReactNode;
141
+ }
142
+
143
+ /**
144
+ * Hosts the toast queue and renders the fixed viewport. `renderTerpApp` mounts
145
+ * one automatically; wrap your tree yourself when composing providers manually.
146
+ * Toasts auto-dismiss (default 5s) and can always be dismissed by hand; success
147
+ * announces politely (`status`), error and warning assertively (`alert`).
148
+ */
149
+ export function ToastProvider({ children }: ToastProviderProps) {
150
+ const [toasts, setToasts] = useState<ToastItem[]>([]);
151
+ const nextId = useRef(0);
152
+ const timers = useRef(new Map<number, ReturnType<typeof setTimeout>>());
153
+
154
+ const dismiss = useCallback((id: number) => {
155
+ const timer = timers.current.get(id);
156
+ if (timer !== undefined) {
157
+ clearTimeout(timer);
158
+ timers.current.delete(id);
159
+ }
160
+ setToasts((current) => current.filter((toast) => toast.id !== id));
161
+ }, []);
162
+
163
+ const push = useCallback(
164
+ (variant: ToastVariant, description: ReactNode, options?: ToastOptions) => {
165
+ const id = nextId.current++;
166
+ const durationMs = options?.durationMs ?? DEFAULT_DURATION_MS;
167
+ setToasts((current) => [
168
+ ...current,
169
+ { id, variant, title: options?.title, description, durationMs },
170
+ ]);
171
+ timers.current.set(
172
+ id,
173
+ setTimeout(() => dismiss(id), durationMs),
174
+ );
175
+ },
176
+ [dismiss],
177
+ );
178
+
179
+ const api = useMemo<ToastApi>(
180
+ () => ({
181
+ success: (description, options) => push("success", description, options),
182
+ error: (description, options) => push("error", description, options),
183
+ warning: (description, options) => push("warning", description, options),
184
+ }),
185
+ [push],
186
+ );
187
+
188
+ return (
189
+ <ToastContext.Provider value={api}>
190
+ {children}
191
+ {toasts.length > 0 && (
192
+ <div style={viewportStyle}>
193
+ {toasts.map((toast) => (
194
+ <ToastCard key={toast.id} toast={toast} onDismiss={() => dismiss(toast.id)} />
195
+ ))}
196
+ </div>
197
+ )}
198
+ </ToastContext.Provider>
199
+ );
200
+ }
201
+
202
+ /**
203
+ * The standard transient-feedback channel: call after mutations settle instead
204
+ * of ad-hoc banners or `alert()`. Titles default to the framework strings
205
+ * (`successTitle` / `errorTitle` / `warningTitle`) so feedback reads the same
206
+ * platform-wide. Throws when no {@link ToastProvider} is mounted (fail closed).
207
+ */
208
+ export function useToast(): ToastApi {
209
+ const api = useContext(ToastContext);
210
+ if (api === null) {
211
+ throw new Error("useToast must be used within a <ToastProvider>.");
212
+ }
213
+ return api;
214
+ }
@@ -0,0 +1,51 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ // Vitest stubs .css imports to empty modules, so the sheet is read from disk.
5
+ const tokensCss = readFileSync(
6
+ new URL("../../contract/src/tokens.css", import.meta.url),
7
+ "utf-8",
8
+ );
9
+
10
+ // Typed locally so the package keeps its deliberate `"types": []` isolation:
11
+ // react-core source must never see ambient Node globals. The source scan uses
12
+ // Vite's raw glob; only the CSS sheet needs fs (declared minimally in raw.d.ts).
13
+ declare global {
14
+ interface ImportMeta {
15
+ glob: (
16
+ pattern: string,
17
+ options: { query: "?raw"; import: "default"; eager: true },
18
+ ) => Record<string, string>;
19
+ }
20
+ }
21
+
22
+ const sources = import.meta.glob("./**/*.{ts,tsx}", {
23
+ query: "?raw",
24
+ import: "default",
25
+ eager: true,
26
+ });
27
+
28
+ /** Every custom property the token sheet declares (any palette block). */
29
+ const declared = new Set(
30
+ [...tokensCss.matchAll(/(--[a-z0-9-]+)\s*:/g)].map((match) => match[1]!),
31
+ );
32
+
33
+ describe("design tokens", () => {
34
+ it("only references custom properties the contract token sheet declares", () => {
35
+ // A fallback-less var() against an undeclared token silently computes to the
36
+ // inherited/initial value — the exact class of bug this guard pins down
37
+ // (e.g. a font-weight token typo reintroducing parent-font inheritance).
38
+ expect(declared.size).toBeGreaterThan(0);
39
+ expect(Object.keys(sources).length).toBeGreaterThan(0);
40
+ const offenders: string[] = [];
41
+ for (const [file, text] of Object.entries(sources)) {
42
+ for (const match of text.matchAll(/var\((--[a-z0-9-]+)\)/g)) {
43
+ const token = match[1]!;
44
+ if (!declared.has(token)) {
45
+ offenders.push(`${file}: ${token}`);
46
+ }
47
+ }
48
+ }
49
+ expect(offenders).toEqual([]);
50
+ });
51
+ });
@@ -0,0 +1,19 @@
1
+ // @vitest-environment jsdom
2
+ import { cleanup, render, screen } from "@testing-library/react";
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+
5
+ import { Alert } from "./Alert";
6
+
7
+ afterEach(cleanup);
8
+
9
+ describe("Alert", () => {
10
+ it("uses status for informational banners", () => {
11
+ render(<Alert title="Saved">All changes persisted.</Alert>);
12
+ expect(screen.getByRole("status")).toHaveTextContent("Saved");
13
+ });
14
+
15
+ it("uses alert for dangerous banners", () => {
16
+ render(<Alert tone="danger">Delete failed.</Alert>);
17
+ expect(screen.getByRole("alert")).toHaveTextContent("Delete failed.");
18
+ });
19
+ });
@@ -0,0 +1,115 @@
1
+ import type { CSSProperties, ReactNode } from "react";
2
+
3
+ import { useUiText } from "../uiText";
4
+ import type { UiText } from "../uiText";
5
+ import type { BadgeTone } from "./Badge";
6
+
7
+ export type AlertTone = BadgeTone;
8
+
9
+ export interface AlertProps {
10
+ tone?: AlertTone;
11
+ title?: UiText;
12
+ children: ReactNode;
13
+ }
14
+
15
+ const toneColor: Record<AlertTone, string> = {
16
+ neutral: "var(--color-neutral-600)",
17
+ info: "var(--color-status-info)",
18
+ success: "var(--color-status-success)",
19
+ warning: "var(--color-status-warning)",
20
+ danger: "var(--color-status-danger)",
21
+ };
22
+
23
+ const toneSoft: Record<AlertTone, string> = {
24
+ neutral: "var(--color-neutral-50)",
25
+ info: "var(--color-status-info-soft)",
26
+ success: "var(--color-status-success-soft)",
27
+ warning: "var(--color-status-warning-soft)",
28
+ danger: "var(--color-status-danger-soft)",
29
+ };
30
+
31
+ const alertStyle = (tone: AlertTone): CSSProperties => ({
32
+ display: "grid",
33
+ gridTemplateColumns: "auto 1fr",
34
+ gap: "var(--space-3)",
35
+ padding: "var(--space-3) var(--space-4)",
36
+ border: `1px solid ${toneColor[tone]}`,
37
+ borderRadius: "var(--radius-md)",
38
+ color: "var(--color-neutral-900)",
39
+ background: toneSoft[tone],
40
+ });
41
+
42
+ const iconWrapStyle = (tone: AlertTone): CSSProperties => ({
43
+ color: toneColor[tone],
44
+ display: "inline-flex",
45
+ alignItems: "flex-start",
46
+ paddingTop: "2px",
47
+ });
48
+
49
+ const bodyStyle: CSSProperties = { display: "grid", gap: "var(--space-1)", minWidth: 0 };
50
+ const titleStyle: CSSProperties = { fontWeight: "var(--font-weight-semibold)" as never };
51
+
52
+ const glyphProps = {
53
+ width: 20,
54
+ height: 20,
55
+ viewBox: "0 0 24 24",
56
+ fill: "none",
57
+ stroke: "currentColor",
58
+ strokeWidth: 1.8,
59
+ strokeLinecap: "round",
60
+ strokeLinejoin: "round",
61
+ "aria-hidden": true,
62
+ focusable: false,
63
+ } as const;
64
+
65
+ const toneIcon: Record<AlertTone, ReactNode> = {
66
+ neutral: (
67
+ <svg {...glyphProps}>
68
+ <circle cx="12" cy="12" r="9" />
69
+ <path d="M12 8h.01M11 12h1v5h1" />
70
+ </svg>
71
+ ),
72
+ info: (
73
+ <svg {...glyphProps}>
74
+ <circle cx="12" cy="12" r="9" />
75
+ <path d="M12 8h.01M11 12h1v5h1" />
76
+ </svg>
77
+ ),
78
+ success: (
79
+ <svg {...glyphProps}>
80
+ <circle cx="12" cy="12" r="9" />
81
+ <path d="m8 12 3 3 5-6" />
82
+ </svg>
83
+ ),
84
+ warning: (
85
+ <svg {...glyphProps}>
86
+ <path d="M12 3 2.5 20.5h19L12 3Z" />
87
+ <path d="M12 10v5M12 18h.01" />
88
+ </svg>
89
+ ),
90
+ danger: (
91
+ <svg {...glyphProps}>
92
+ <path d="M17.5 3.5H6.5L3 7v10l3.5 3.5h11L21 17V7l-3.5-3.5Z" />
93
+ <path d="M12 8v5M12 16h.01" />
94
+ </svg>
95
+ ),
96
+ };
97
+
98
+ /** Inline banner for persistent feedback; warnings and errors announce as alerts. */
99
+ export function Alert({ tone = "info", title, children }: AlertProps) {
100
+ const resolve = useUiText();
101
+ return (
102
+ <div
103
+ role={tone === "warning" || tone === "danger" ? "alert" : "status"}
104
+ data-terp="alert"
105
+ style={alertStyle(tone)}
106
+ >
107
+ <span style={iconWrapStyle(tone)}>{toneIcon[tone]}</span>
108
+ <div style={bodyStyle}>
109
+ {title !== undefined && <strong style={titleStyle}>{resolve(title)}</strong>}
110
+ <div>{children}</div>
111
+ </div>
112
+ </div>
113
+ );
114
+ }
115
+
@@ -0,0 +1,14 @@
1
+ // @vitest-environment jsdom
2
+ import { cleanup, render, screen } from "@testing-library/react";
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+
5
+ import { Badge } from "./Badge";
6
+
7
+ afterEach(cleanup);
8
+
9
+ describe("Badge", () => {
10
+ it("renders a token-styled status pill", () => {
11
+ render(<Badge label="Active" tone="success" />);
12
+ expect(screen.getByText("Active").style.color).toContain("var(--color-status-success)");
13
+ });
14
+ });