@pablozaiden/webapp 0.6.1 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -23,7 +23,7 @@ Each application package must declare `react` and `react-dom`; they are peer dep
23
23
  | Export | Use |
24
24
  | --- | --- |
25
25
  | `@pablozaiden/webapp/server` | `createWebAppServer`, route helpers, responses, request-origin helpers, SQLite store |
26
- | `@pablozaiden/webapp/web` | `WebAppRoot`, `renderWebApp`, sidebar types, UI controls, realtime hooks |
26
+ | `@pablozaiden/webapp/web` | `WebAppRoot`, `renderWebApp`, `useToast`, sidebar types, UI controls, realtime hooks |
27
27
  | `@pablozaiden/webapp/contracts` | Shared auth/config/device/API-key types |
28
28
  | `@pablozaiden/webapp/cli` | One-binary command helpers, device/environment auth credentials and generic API CLI caller |
29
29
  | `@pablozaiden/webapp/build` | Bun single-binary compile helper |
@@ -122,6 +122,109 @@ renderWebApp(
122
122
  ```
123
123
 
124
124
  `renderWebApp` renders into `#root` by default and reuses the existing React root across hot reloads. Pass a custom element id or `Element` only when the app uses a different mount point.
125
+
126
+ ### Theme state
127
+
128
+ The framework applies the local or system theme before the first React render and
129
+ loads the signed-in user's saved preference in the background. Components
130
+ rendered inside `WebAppRoot` can use the public hook when JavaScript needs the
131
+ effective theme, such as for a terminal, editor, or chart:
132
+
133
+ ```tsx
134
+ import { Page, useTheme } from "@pablozaiden/webapp/web";
135
+
136
+ function ThemeAwareRoute() {
137
+ const { preference, resolvedTheme } = useTheme();
138
+
139
+ return (
140
+ <Page>
141
+ <p>Preference: {preference}</p>
142
+ <p>Resolved theme: {resolvedTheme}</p>
143
+ </Page>
144
+ );
145
+ }
146
+ ```
147
+
148
+ `preference` is `system`, `light`, or `dark`; `resolvedTheme` is always
149
+ `light` or `dark` and follows operating-system changes while `preference` is
150
+ `system`. Prefer CSS dark-mode styling when JavaScript does not need the
151
+ resolved value. Do not inspect or mutate framework-owned root classes or
152
+ attributes to infer theme state.
153
+
154
+ ### Client log-level state
155
+
156
+ The framework loads the effective client log level as part of the shared
157
+ `WebAppRoot` configuration request. Components rendered inside `WebAppRoot`
158
+ can subscribe with `useLogLevel()`; they should not fetch framework
159
+ configuration or `/api/preferences/log-level` in an app-local initializer:
160
+
161
+ ```tsx
162
+ import { useEffect } from "react";
163
+ import { useLogLevel } from "@pablozaiden/webapp/web";
164
+ import type { LogLevelName } from "@pablozaiden/webapp/contracts";
165
+
166
+ function LoggerAdapter({ setLevel }: { setLevel: (level: LogLevelName) => void }) {
167
+ const { level, fromEnv, loading, error, retry } = useLogLevel();
168
+
169
+ useEffect(() => {
170
+ if (level !== undefined) {
171
+ setLevel(level);
172
+ }
173
+ }, [level, setLevel]);
174
+
175
+ if (error) {
176
+ return <button type="button" onClick={() => void retry()}>Retry log-level configuration</button>;
177
+ }
178
+ if (loading || level === undefined) {
179
+ return null;
180
+ }
181
+
182
+ return <span aria-hidden="true">{fromEnv ? "Log level is environment-controlled." : null}</span>;
183
+ }
184
+ ```
185
+
186
+ `level` and `fromEnv` are unavailable until valid framework configuration has
187
+ loaded. Do not substitute a default level when `error` is set. The framework
188
+ Settings control owns persistence and updates the shared state without a page
189
+ reload; `fromEnv` indicates that the server environment locks the value.
190
+ `LoggerAdapter` remains application-owned and can adapt the state to any
191
+ logging library.
192
+
193
+ ### Transient notifications
194
+
195
+ The standard `renderWebApp` runtime provides the framework notification service
196
+ to the application tree. Use the public hook for short-lived action outcomes;
197
+ do not add an application-owned provider, queue, timer system, or notification
198
+ CSS:
199
+
200
+ ```tsx
201
+ import { useToast } from "@pablozaiden/webapp/web";
202
+
203
+ function SaveButton() {
204
+ const toast = useToast();
205
+
206
+ async function save() {
207
+ await saveRecord();
208
+ toast.success("Saved");
209
+ }
210
+
211
+ return <button type="button" onClick={() => void save()}>Save</button>;
212
+ }
213
+ ```
214
+
215
+ `useToast()` exposes `success`, `error`, `warning`, and `info` helpers. Each
216
+ returns a stable ID that can be passed to `dismiss`; `dismissAll` removes every
217
+ active notification. Pass an `id` to replace an existing notification and
218
+ reset its timer. Notifications dismiss after 8 seconds by default; provide a
219
+ positive `duration` in milliseconds for a custom timeout or use
220
+ `duration: 0` for a persistent notification that requires explicit dismissal.
221
+ The framework keeps at most five active notifications.
222
+
223
+ Use `ErrorState`, loading states, and field validation for persistent page or
224
+ form state. Use `ConfirmDialog` for destructive confirmation. Do not report the
225
+ same failure in both an inline error state and a toast unless both surfaces
226
+ serve distinct, intentional purposes.
227
+
125
228
  `WebAppRoot` owns the shell and `.wapp-main-content`; each route component should return a `Page` wrapper so standard content margins, mobile padding and scroll behavior stay consistent. `Page` uses the padded layout by default. For routes whose child content fills the available shell viewport and owns its own spacing or scrolling, use the framework full layout instead of overriding framework CSS:
126
229
 
127
230
  ```tsx
package/docs/release.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  1. Install dependencies with `bun install --frozen-lockfile`.
8
8
  2. Run `bun run tsc`.
9
- 3. Run `bun test`.
9
+ 3. Run `bun run test`, which runs the build-binary integration tests in an isolated process before the remaining suites.
10
10
  4. Build both example apps.
11
11
  5. Start the compiled example binaries and smoke-test `/api/health` plus one app endpoint.
12
12
 
package/docs/server.md CHANGED
@@ -124,6 +124,12 @@ Built-in endpoints include:
124
124
  | `/api/server/kill` | Authenticated server shutdown |
125
125
  | `/api/ws` | Realtime websocket by default |
126
126
 
127
+ The effective log level in `GET /api/config` and `GET
128
+ /api/preferences/log-level` is resolved identically: an environment-provided
129
+ `{PREFIX}_LOG_LEVEL` wins over the persisted preference, and sets `fromEnv`
130
+ to `true`. PUT remains an authenticated, same-origin admin mutation and
131
+ returns a conflict when the environment controls the value.
132
+
127
133
  ## Framework-owned web document and PWA metadata
128
134
 
129
135
  `createWebAppServer` owns the browser document. Apps do not provide `index.html`; the framework generates a Bun `HTMLBundle` internally so Bun hot reload and asset rewriting keep working. By default the frontend entrypoint is `./web/main.tsx` relative to the Bun entry file. The generated document also initializes the shared `data-wapp-mobile` state before client styles load so CSS and `WebAppRoot` use the same mobile breakpoint. The generated viewport keeps the app at `initial-scale=1` with `maximum-scale=1` and `user-scalable=no`; clients and mobile browsers that honor those viewport scaling tokens, including iPhone and iPad, cannot change the app scale with pinch-to-zoom while normal scrolling remains enabled. Clients that ignore the tokens are unaffected.
package/docs/settings.md CHANGED
@@ -16,6 +16,23 @@ user preference loads in the background. If that request fails, the current
16
16
  theme remains active and Display Settings shows a non-blocking error with a
17
17
  retry action; the rest of Settings remains usable.
18
18
 
19
+ Application code rendered inside `WebAppRoot` can use `useTheme()` from
20
+ `@pablozaiden/webapp/web` when JavaScript needs theme state. The hook exposes
21
+ the selected `preference` and concrete `resolvedTheme`; the latter updates
22
+ when the operating-system color scheme changes in `system` mode. Prefer CSS
23
+ dark-mode styling when no JavaScript theme value is required, and do not infer
24
+ theme state by observing framework-owned DOM classes or attributes.
25
+
26
+ Application code can use `useLogLevel()` from `@pablozaiden/webapp/web` to
27
+ adapt an application-owned logger to the framework's effective client setting.
28
+ The hook exposes `level`, `fromEnv`, `loading`, `error`, and `retry`. The level
29
+ and environment metadata are unavailable until `/api/config` has loaded and
30
+ validated; configuration failures remain visible through `error` instead of
31
+ falling back to a fabricated level. The framework Settings selector updates
32
+ the shared hook state after a successful save, and `fromEnv` is true when
33
+ `{PREFIX}_LOG_LEVEL` locks the value. Applications should not add a separate
34
+ configuration fetch or initializer component.
35
+
19
36
  Security lists only show useful active credentials. Expired API keys are purged before listing, and revoked or expired device-auth refresh sessions are not shown. Revoked refresh sessions may remain in storage when needed for token-reuse protection, but they are hidden from Settings.
20
37
 
21
38
  Destructive actions in Settings use the framework `ConfirmDialog` before mutating. This includes deleting users, deleting API keys, deleting passkeys, revoking device-auth sessions, and killing the server.
@@ -11,6 +11,20 @@ The framework intentionally provides a consistent base UI:
11
11
  - Main content should prefer panels, toolbars, badges and simple forms over custom one-off layouts.
12
12
  - `WebAppRoot` owns the fixed main title bar and `.wapp-main-content`; app routes should not render or style those shell elements directly.
13
13
 
14
+ ## Transient notifications
15
+
16
+ Use `useToast()` from `@pablozaiden/webapp/web` for short-lived success,
17
+ error, warning, or informational feedback. The standard `renderWebApp` runtime
18
+ already owns the provider, queue, timers, live region, dismissal controls, and
19
+ styling, so applications must not add a parallel toast provider, queue, or
20
+ notification CSS. Use the returned ID with `dismiss` when an operation needs
21
+ to close a persistent notification, or use `dismissAll` to clear the queue.
22
+
23
+ Use inline `ErrorState`, loading states, and field validation when the
24
+ information belongs to persistent page or form state. Use `ConfirmDialog` for
25
+ destructive confirmation rather than a toast, and avoid reporting one failure
26
+ twice without a distinct user-facing reason.
27
+
14
28
  ## Mobile breakpoint and viewport synchronization
15
29
 
16
30
  The framework owns the mobile shell breakpoint. `MOBILE_BREAKPOINT_PX` and `MOBILE_MEDIA_QUERY` are exported from `@pablozaiden/webapp/web` for application JavaScript that needs to coordinate with the shell; do not add an independent `innerWidth` threshold for shell behavior. The generated document initializes the `data-wapp-mobile` marker on the root element before the client and styles load, and `WebAppRoot` keeps it synchronized with media-query changes. Custom CSS that follows the framework mobile mode should use that marker rather than repeating a numeric media query.
@@ -64,7 +78,26 @@ Reference screenshots live in `artifacts/screenshots`:
64
78
  | `kitchen-dialog-dark.png` | Confirm dialog overlay |
65
79
  | `kitchen-device-light.png` | Device auth approval flow |
66
80
 
67
- Use the temporary Playwright harness described in `skills/webapp/SKILL.md` when new visual captures are needed. Its default output is disposable; to intentionally update these checked-in reference captures, run the application-specific temporary harness with `PLAYWRIGHT_OUT_DIR="$PWD/artifacts/screenshots"` and review every changed image before committing it. Browser automation and screenshot capture must stay Playwright-based and cross-platform; do not hard-code local Chrome or OS-specific browser paths. The framework does not ship a screenshot command or Playwright dependency.
81
+ Use the temporary Playwright harness described in `skills/webapp/SKILL.md` when new visual captures are needed. Its default output is disposable; to intentionally update these checked-in reference captures, run the application-specific temporary harness with `PLAYWRIGHT_OUT_DIR="$PWD/artifacts/screenshots"` and review every changed image before committing it. Browser automation and screenshot capture must stay Playwright-based and cross-platform; do not hard-code local Chrome or OS-specific browser paths.
82
+
83
+ The framework does not add Playwright to an application's dependencies or ship a
84
+ screenshot command. Install the official Playwright package and Chromium in an
85
+ isolated temporary directory instead:
86
+
87
+ ```bash
88
+ playwright_workdir="$(mktemp -d)"
89
+ trap 'rm -rf "$playwright_workdir"' EXIT
90
+ cd "$playwright_workdir"
91
+ npm init -y >/dev/null
92
+ npm install --no-save --package-lock=false playwright
93
+ npx playwright install chromium
94
+ ```
95
+
96
+ Run the Playwright Node API from that temporary harness against the already
97
+ running application, and keep screenshots and browser state there. Do not add
98
+ Playwright, browser binaries, scripts, or configuration files to the
99
+ application repository. If Linux browser system libraries are missing, run
100
+ `npx playwright install-deps` from the same harness environment.
68
101
 
69
102
  Use these captures as the manual visual baseline before changing shell, sidebar, settings or dialog styles. When screenshots are captured to validate a visual change, review them against the specific goal; capturing files without checking the result is not validation.
70
103
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pablozaiden/webapp",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
4
4
  "description": "Opinionated Bun + React webapp framework for single-server apps",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -33,11 +33,15 @@
33
33
  "dev": "bun run --cwd examples/notes-todo dev",
34
34
  "dev:kitchen-sink": "bun run --cwd examples/kitchen-sink dev",
35
35
  "dev:notes-todo": "bun run --cwd examples/notes-todo dev",
36
- "build": "bun run tsc && bun run --cwd examples/kitchen-sink build && bun run --cwd examples/notes-todo build",
36
+ "build": "bun run typecheck && bun run build:examples",
37
+ "build:examples": "bun run --cwd examples/kitchen-sink build && bun run --cwd examples/notes-todo build",
37
38
  "build:kitchen-sink": "bun run --cwd examples/kitchen-sink build",
38
39
  "build:notes-todo": "bun run --cwd examples/notes-todo build",
39
- "test": "bun test",
40
- "tsc": "mkdir -p .cache/tsc && bunx tsc --noEmit --pretty false --incremental --tsBuildInfoFile .cache/tsc/build.tsbuildinfo"
40
+ "test": "bun run test:build-binary && bun run test:remaining",
41
+ "test:build-binary": "bun test tests/build-binary.test.ts",
42
+ "test:remaining": "bun test --path-ignore-patterns=**/build-binary.test.ts",
43
+ "typecheck": "mkdir -p .cache/tsc && bunx tsc --noEmit --pretty false --incremental --tsBuildInfoFile .cache/tsc/build.tsbuildinfo",
44
+ "tsc": "bun run typecheck"
41
45
  },
42
46
  "dependencies": {
43
47
  "@simplewebauthn/browser": "13.3.0",
@@ -1,7 +1,7 @@
1
1
  import type { Server } from "bun";
2
2
  import { sqliteWebAppStore } from "./auth/sqlite-store";
3
3
  import { createRealtimeBus } from "./realtime/bus";
4
- import { readRuntimeConfig } from "./runtime-config";
4
+ import { readRuntimeConfig, resolveEffectiveLogLevel } from "./runtime-config";
5
5
  import {
6
6
  WEBAPP_SOCKET_HANDLER,
7
7
  type WebAppServer,
@@ -45,7 +45,7 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
45
45
  const store = input.store ?? sqliteWebAppStore({ dataDir: config.dataDir });
46
46
  store.initialize();
47
47
  const savedLogLevel = store.getLogLevelPreference();
48
- const activeLogLevel = config.logLevelFromEnv ? config.logLevel : savedLogLevel ?? config.logLevel;
48
+ const activeLogLevel = resolveEffectiveLogLevel(config, savedLogLevel);
49
49
  setLogLevel(activeLogLevel);
50
50
  input.logLevel?.onChange?.(activeLogLevel);
51
51
  const realtime = createRealtimeBus<TEvent>();
@@ -59,7 +59,7 @@ import {
59
59
  tokenRequestSchema,
60
60
  userRoleRequestSchema,
61
61
  } from "./request-schemas";
62
- import type { RuntimeConfig } from "./runtime-config";
62
+ import { resolveEffectiveLogLevel, type RuntimeConfig } from "./runtime-config";
63
63
  import { setLogLevel } from "./logger";
64
64
  import { errorResponse, jsonResponse, notFound, parseJson, successResponse } from "./responses";
65
65
  import type { WebSocketData } from "./realtime/bus";
@@ -112,7 +112,7 @@ export function createFrameworkEndpointHandler(dependencies: FrameworkEndpointDe
112
112
  canManageUsers: Boolean(user?.isAdmin),
113
113
  },
114
114
  logLevel: {
115
- level: (config.logLevelFromEnv ? config.logLevel : store.getLogLevelPreference() ?? config.logLevel) as LogLevelName,
115
+ level: resolveEffectiveLogLevel(config, store.getLogLevelPreference()),
116
116
  fromEnv: config.logLevelFromEnv,
117
117
  },
118
118
  apiKeys: { enabled: Boolean(apiKeysEnabled) },
@@ -416,7 +416,10 @@ export function createFrameworkEndpointHandler(dependencies: FrameworkEndpointDe
416
416
  if (auth instanceof Response) return auth;
417
417
  ensureAdmin(auth);
418
418
  if (req.method === "GET") {
419
- return jsonResponse({ level: store.getLogLevelPreference() ?? config.logLevel, fromEnv: config.logLevelFromEnv });
419
+ return jsonResponse({
420
+ level: resolveEffectiveLogLevel(config, store.getLogLevelPreference()),
421
+ fromEnv: config.logLevelFromEnv,
422
+ });
420
423
  }
421
424
  if (req.method === "PUT") {
422
425
  const originFailure = checkSameOrigin(req, config, auth, "mutations");
@@ -26,7 +26,22 @@ export interface RuntimeConfig {
26
26
  development: false | { hmr: true; console: true };
27
27
  }
28
28
 
29
- const LOG_LEVELS = new Set(["trace", "debug", "info", "warn", "error"]);
29
+ const LOG_LEVELS = new Set<LogLevelName>(["trace", "debug", "info", "warn", "error"]);
30
+
31
+ function isSupportedLogLevel(value: unknown): value is LogLevelName {
32
+ return typeof value === "string" && LOG_LEVELS.has(value as LogLevelName);
33
+ }
34
+
35
+ export function resolveEffectiveLogLevel(
36
+ config: Pick<RuntimeConfig, "logLevel" | "logLevelFromEnv">,
37
+ savedLogLevel?: unknown,
38
+ ): LogLevelName {
39
+ return config.logLevelFromEnv
40
+ ? config.logLevel
41
+ : isSupportedLogLevel(savedLogLevel)
42
+ ? savedLogLevel
43
+ : config.logLevel;
44
+ }
30
45
 
31
46
  export function isTruthyEnv(value: string | undefined): boolean {
32
47
  const normalized = value?.trim().toLowerCase();
@@ -64,10 +79,10 @@ function parseLogLevel(raw: string | undefined, fallback: LogLevelName, name: st
64
79
  if (!raw) {
65
80
  return fallback;
66
81
  }
67
- if (!LOG_LEVELS.has(raw)) {
82
+ if (!isSupportedLogLevel(raw)) {
68
83
  throw new Error(`${name} must be one of trace, debug, info, warn, error; received "${raw}"`);
69
84
  }
70
- return raw as LogLevelName;
85
+ return raw;
71
86
  }
72
87
 
73
88
  function parseBoolean(raw: string | undefined, fallback: boolean, name: string): boolean {
@@ -1,6 +1,5 @@
1
1
  import { useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
2
- import type { ThemePreference, WebAppConfigResponse } from "../contracts";
3
- import { appJson } from "./api-client";
2
+ import type { WebAppConfigResponse } from "../contracts";
4
3
  import { AppShell } from "./app-shell";
5
4
  import { DeviceVerificationScreen, PasskeyAuthScreen, UserSetupScreen } from "./auth-screens";
6
5
  import { EmptyState, Panel } from "./components";
@@ -10,35 +9,12 @@ import { flattenSidebarItems, toStoredPin, useSidebarCollapsedState, useSidebarP
10
9
  import { SettingsView } from "./settings/settings-view";
11
10
  import type { HeaderContext, WebAppRootProps } from "./root-types";
12
11
  import type { ActionMenuItem, SidebarNode, WebAppRoute } from "./sidebar/types";
12
+ import { ThemeProvider } from "./theme";
13
+ import { WebAppConfigProvider, useWebAppConfig } from "./webapp-config";
13
14
 
14
15
  export { replaceHashRoute, replaceWebAppRoute, routeToHash } from "./routing";
15
16
  export type { HeaderContext, SettingsAction, SettingsRow, SettingsSection, WebAppRootProps } from "./root-types";
16
17
 
17
- function useConfig() {
18
- const [config, setConfig] = useState<WebAppConfigResponse>();
19
- const [error, setError] = useState<string>();
20
- const refresh = useCallback(async () => {
21
- try {
22
- setConfig(await appJson<WebAppConfigResponse>("/api/config"));
23
- setError(undefined);
24
- } catch (err) {
25
- setError(err instanceof Error ? err.message : String(err));
26
- }
27
- }, []);
28
- useEffect(() => void refresh(), [refresh]);
29
- return { config, error, refresh };
30
- }
31
-
32
- function useTheme() {
33
- const [theme, setTheme] = useState<ThemePreference>(() => (localStorage.getItem("webapp.theme") as ThemePreference | null) ?? "system");
34
- useEffect(() => {
35
- const dark = theme === "dark" || (theme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches);
36
- document.documentElement.classList.toggle("dark", dark);
37
- localStorage.setItem("webapp.theme", theme);
38
- }, [theme]);
39
- return { theme, setTheme };
40
- }
41
-
42
18
  function routeMatches(left: WebAppRoute | undefined, right: WebAppRoute): boolean {
43
19
  if (!left) {
44
20
  return false;
@@ -46,14 +22,26 @@ function routeMatches(left: WebAppRoute | undefined, right: WebAppRoute): boolea
46
22
  return left.view === right.view && Object.entries(left).every(([key, value]) => key === "view" || right[key] === value);
47
23
  }
48
24
 
49
- export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, onRouteChange, settings, version }: WebAppRootProps) {
25
+ function WebAppRootContent({
26
+ appName,
27
+ homeRoute,
28
+ sidebar,
29
+ routes,
30
+ header,
31
+ onRouteChange,
32
+ settings,
33
+ version,
34
+ config,
35
+ error,
36
+ refresh,
37
+ }: WebAppRootProps & {
38
+ config?: WebAppConfigResponse;
39
+ error?: Error;
40
+ refresh: () => Promise<void>;
41
+ }) {
50
42
  const isMobile = useMobileBreakpoint();
51
43
  useMobileViewportHeight(isMobile);
52
- const { config, error, refresh } = useConfig();
53
44
  const { route, navigate } = useRoute(homeRoute);
54
- const { theme, setTheme } = useTheme();
55
- const [themeLoading, setThemeLoading] = useState(false);
56
- const [themeLoadError, setThemeLoadError] = useState<Error>();
57
45
  const [search, setSearch] = useState("");
58
46
  const sidebarSearchId = useId();
59
47
  const sidebarSearchInputRef = useRef<HTMLInputElement>(null);
@@ -125,35 +113,12 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, onRout
125
113
  }, [augmentPinningActions, baseNodes, currentPins, filteredNodes, pinningEnabled, sidebar.pinning, sidebarSearchActive]);
126
114
  const activeActionNodes = useMemo(() => augmentPinningActions(baseNodes), [augmentPinningActions, baseNodes]);
127
115
 
128
- const retryThemeLoad = useCallback(async () => {
129
- if (!config?.currentUser) {
130
- setThemeLoading(false);
131
- setThemeLoadError(undefined);
132
- return;
133
- }
134
-
135
- setThemeLoading(true);
136
- setThemeLoadError(undefined);
137
- try {
138
- const result = await appJson<{ theme: ThemePreference }>("/api/preferences/theme");
139
- setTheme(result.theme);
140
- } catch (err) {
141
- setThemeLoadError(err instanceof Error ? err : new Error(String(err)));
142
- } finally {
143
- setThemeLoading(false);
144
- }
145
- }, [config?.currentUser?.id, setTheme]);
146
-
147
- useEffect(() => {
148
- void retryThemeLoad();
149
- }, [retryThemeLoad]);
150
-
151
116
  useEffect(() => {
152
117
  onRouteChange?.(route);
153
118
  }, [onRouteChange, route]);
154
119
 
155
- if (error) {
156
- return <main className="wapp-auth-screen"><Panel title="Unable to load app" description={error} /></main>;
120
+ if (error && !config) {
121
+ return <main className="wapp-auth-screen"><Panel title="Unable to load app" description={error.message} /></main>;
157
122
  }
158
123
  if (!config) {
159
124
  return <main className="wapp-auth-screen">Loading...</main>;
@@ -171,7 +136,7 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, onRout
171
136
  const effectiveVersion = version ?? config.version;
172
137
  let view: ReactNode;
173
138
  if (route.view === "settings") {
174
- view = <SettingsView config={config} refresh={refresh} customSections={settings?.sections ?? []} theme={theme} setTheme={setTheme} themeLoading={themeLoading} themeLoadError={themeLoadError} retryThemeLoad={retryThemeLoad} />;
139
+ view = <SettingsView config={config} refresh={refresh} customSections={settings?.sections ?? []} />;
175
140
  } else {
176
141
  const registeredView = routes[route.view];
177
142
  view = typeof registeredView === "function"
@@ -220,3 +185,20 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, onRout
220
185
  />
221
186
  );
222
187
  }
188
+
189
+ function WebAppRootWithConfig(props: WebAppRootProps) {
190
+ const { config, error, refresh } = useWebAppConfig();
191
+ return (
192
+ <ThemeProvider userId={config?.currentUser?.id}>
193
+ <WebAppRootContent {...props} config={config} error={error} refresh={refresh} />
194
+ </ThemeProvider>
195
+ );
196
+ }
197
+
198
+ export function WebAppRoot(props: WebAppRootProps) {
199
+ return (
200
+ <WebAppConfigProvider>
201
+ <WebAppRootWithConfig {...props} />
202
+ </WebAppConfigProvider>
203
+ );
204
+ }
package/src/web/index.ts CHANGED
@@ -4,6 +4,12 @@ export * from "./render";
4
4
  export * from "./api-client";
5
5
  export * from "./sidebar/types";
6
6
  export * from "./realtime/useRealtime";
7
+ export { useToast } from "./toast";
8
+ export type { Toast, ToastId, ToastOptions, ToastService, ToastShowOptions, ToastVariant } from "./toast";
7
9
  export { MOBILE_BREAKPOINT_PX, MOBILE_MEDIA_QUERY, MOBILE_STATE_ATTRIBUTE } from "./mobile";
10
+ export { useTheme } from "./theme";
11
+ export type { ResolvedTheme, WebAppThemeState } from "./theme";
12
+ export { useLogLevel } from "./log-level";
13
+ export type { WebAppLogLevelState } from "./log-level";
8
14
  export { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
9
15
  export type { ThemePreference } from "../contracts";
@@ -0,0 +1,22 @@
1
+ import { useMemo } from "react";
2
+ import type { LogLevelName } from "../contracts";
3
+ import { useWebAppConfig } from "./webapp-config";
4
+
5
+ export interface WebAppLogLevelState {
6
+ level?: LogLevelName;
7
+ fromEnv?: boolean;
8
+ loading: boolean;
9
+ error?: Error;
10
+ retry: () => Promise<void>;
11
+ }
12
+
13
+ export function useLogLevel(): WebAppLogLevelState {
14
+ const { config, loading, error, refresh } = useWebAppConfig("useLogLevel");
15
+ return useMemo(() => ({
16
+ level: config?.logLevel.level,
17
+ fromEnv: config?.logLevel.fromEnv,
18
+ loading,
19
+ error,
20
+ retry: refresh,
21
+ }), [config?.logLevel.fromEnv, config?.logLevel.level, error, loading, refresh]);
22
+ }
@@ -1,5 +1,6 @@
1
1
  import type { ReactNode } from "react";
2
2
  import type { Root } from "react-dom/client";
3
+ import { ToastProvider } from "./toast";
3
4
 
4
5
  declare global {
5
6
  var __pablozaidenWebAppRoots: WeakMap<Element, Root> | undefined;
@@ -11,6 +12,10 @@ function roots(): WeakMap<Element, Root> {
11
12
  return globalThis.__pablozaidenWebAppRoots;
12
13
  }
13
14
 
15
+ function WebAppRuntime({ element }: { element: ReactNode }) {
16
+ return <ToastProvider>{element}</ToastProvider>;
17
+ }
18
+
14
19
  export function configureWebAppRenderer(createRoot: (target: Element) => Root): void {
15
20
  globalThis.__pablozaidenCreateWebAppRoot = createRoot;
16
21
  }
@@ -30,6 +35,6 @@ export function renderWebApp(element: ReactNode, container: Element | string = "
30
35
  root = createRoot(target);
31
36
  registry.set(target, root);
32
37
  }
33
- root.render(element);
38
+ root.render(<WebAppRuntime element={element} />);
34
39
  return root;
35
40
  }
@@ -1,19 +1,44 @@
1
- import type { ThemePreference, WebAppConfigResponse } from "../../contracts";
1
+ import { useState } from "react";
2
+ import type { LogLevelName, WebAppConfigResponse } from "../../contracts";
2
3
  import { appJson } from "../api-client";
3
4
  import { Badge, Button, ErrorState, FormSection, LoadingState, SelectField } from "../components";
5
+ import { useLogLevel } from "../log-level";
6
+ import { isThemePreference, useTheme } from "../theme";
7
+
8
+ const LOG_LEVEL_NAMES = ["trace", "debug", "info", "warn", "error"] as const satisfies readonly LogLevelName[];
9
+
10
+ function isLogLevelName(value: string): value is LogLevelName {
11
+ return LOG_LEVEL_NAMES.some((level) => level === value);
12
+ }
4
13
 
5
14
  export interface AccountSectionProps {
6
15
  config: WebAppConfigResponse;
7
- theme: ThemePreference;
8
- setTheme: (theme: ThemePreference) => void;
9
- themeLoading: boolean;
10
- themeLoadError?: Error;
11
- retryThemeLoad: () => Promise<void>;
12
16
  refresh: () => Promise<void>;
13
17
  setError: (error: string | undefined) => void;
14
18
  }
15
19
 
16
- export function AccountSection({ config, theme, setTheme, themeLoading, themeLoadError, retryThemeLoad, refresh, setError }: AccountSectionProps) {
20
+ export function AccountSection({ config, refresh, setError }: AccountSectionProps) {
21
+ const { preference, setPreference, loading, error, retry } = useTheme();
22
+ const logLevel = useLogLevel();
23
+ const [logLevelSaving, setLogLevelSaving] = useState(false);
24
+
25
+ async function updateLogLevel(value: string) {
26
+ if (!isLogLevelName(value)) {
27
+ setError(`Unknown log level: ${value}`);
28
+ return;
29
+ }
30
+ try {
31
+ setError(undefined);
32
+ setLogLevelSaving(true);
33
+ await appJson<unknown>("/api/preferences/log-level", { method: "PUT", body: JSON.stringify({ level: value }) });
34
+ await refresh();
35
+ } catch (err) {
36
+ setError(err instanceof Error ? err.message : String(err));
37
+ } finally {
38
+ setLogLevelSaving(false);
39
+ }
40
+ }
41
+
17
42
  return (
18
43
  <>
19
44
  {config.currentUser ? (
@@ -22,29 +47,47 @@ export function AccountSection({ config, theme, setTheme, themeLoading, themeLoa
22
47
  </FormSection>
23
48
  ) : null}
24
49
  <FormSection title="Display Settings">
25
- <SelectField label="Theme" value={theme} onChange={(event) => {
26
- const next = event.currentTarget.value as ThemePreference;
27
- setTheme(next);
50
+ <SelectField label="Theme" value={preference} onChange={(event) => {
51
+ const next = event.currentTarget.value;
52
+ if (!isThemePreference(next)) {
53
+ setError(`Unknown theme preference: ${next}`);
54
+ return;
55
+ }
56
+ setPreference(next);
28
57
  void appJson("/api/preferences/theme", { method: "PUT", body: JSON.stringify({ theme: next }) }).catch((err) => setError(String(err)));
29
58
  }}>
30
59
  <option value="system">System</option>
31
60
  <option value="light">Light</option>
32
61
  <option value="dark">Dark</option>
33
62
  </SelectField>
34
- {themeLoading ? <LoadingState title="Loading saved theme" /> : null}
35
- {themeLoadError ? (
63
+ {loading ? <LoadingState title="Loading saved theme" /> : null}
64
+ {error ? (
36
65
  <ErrorState
37
- description={themeLoadError.message}
38
- action={<Button type="button" loading={themeLoading} onClick={() => void retryThemeLoad()}>Retry</Button>}
66
+ description={error.message}
67
+ action={<Button type="button" loading={loading} onClick={() => void retry()}>Retry</Button>}
39
68
  />
40
69
  ) : null}
41
70
  </FormSection>
42
71
 
43
72
  {config.currentUser?.isAdmin ? (
44
73
  <FormSection title="Developer Settings">
45
- <SelectField label={config.logLevel.fromEnv ? `Log level (${config.logLevel.level}, controlled by env)` : "Log level"} value={config.logLevel.level} disabled={config.logLevel.fromEnv} onChange={(event) => void appJson("/api/preferences/log-level", { method: "PUT", body: JSON.stringify({ level: event.currentTarget.value }) }).then(refresh)}>
46
- {["trace", "debug", "info", "warn", "error"].map((level) => <option key={level} value={level}>{level}</option>)}
47
- </SelectField>
74
+ {logLevel.error ? (
75
+ <ErrorState
76
+ description={logLevel.error.message}
77
+ action={<Button type="button" loading={logLevel.loading} onClick={() => void logLevel.retry()}>Retry</Button>}
78
+ />
79
+ ) : null}
80
+ {logLevel.loading && logLevel.level === undefined ? <LoadingState title="Loading log level" /> : null}
81
+ {logLevel.level !== undefined ? (
82
+ <SelectField
83
+ label={logLevel.fromEnv ? `Log level (${logLevel.level}, controlled by env)` : "Log level"}
84
+ value={logLevel.level}
85
+ disabled={logLevel.fromEnv === true || logLevel.loading || logLevelSaving}
86
+ onChange={(event) => void updateLogLevel(event.currentTarget.value)}
87
+ >
88
+ {LOG_LEVEL_NAMES.map((level) => <option key={level} value={level}>{level}</option>)}
89
+ </SelectField>
90
+ ) : null}
48
91
  </FormSection>
49
92
  ) : null}
50
93
  </>
@@ -1,5 +1,5 @@
1
1
  import { useState, type ReactNode } from "react";
2
- import type { ThemePreference, WebAppConfigResponse } from "../../contracts";
2
+ import type { WebAppConfigResponse } from "../../contracts";
3
3
  import { Button, DangerZone, FormSection } from "../components";
4
4
  import type { SettingsRow, SettingsScope, SettingsSection } from "../root-types";
5
5
  import { AccountSection } from "./account-section";
@@ -64,20 +64,15 @@ export interface SettingsViewProps {
64
64
  config: WebAppConfigResponse;
65
65
  refresh: () => Promise<void>;
66
66
  customSections: SettingsSection[];
67
- theme: ThemePreference;
68
- setTheme: (theme: ThemePreference) => void;
69
- themeLoading: boolean;
70
- themeLoadError?: Error;
71
- retryThemeLoad: () => Promise<void>;
72
67
  }
73
68
 
74
- export function SettingsView({ config, refresh, customSections, theme, setTheme, themeLoading, themeLoadError, retryThemeLoad }: SettingsViewProps) {
69
+ export function SettingsView({ config, refresh, customSections }: SettingsViewProps) {
75
70
  const [error, setError] = useState<string>();
76
71
 
77
72
  return (
78
73
  <div className="wapp-settings">
79
74
  {error ? <p className="wapp-error">{error}</p> : null}
80
- <AccountSection config={config} theme={theme} setTheme={setTheme} themeLoading={themeLoading} themeLoadError={themeLoadError} retryThemeLoad={retryThemeLoad} refresh={refresh} setError={setError} />
75
+ <AccountSection config={config} refresh={refresh} setError={setError} />
81
76
  <FormSection title="Security">
82
77
  <SecuritySection config={config} refresh={refresh} setError={setError} />
83
78
  <SessionsSection config={config} setError={setError} />
@@ -11,6 +11,18 @@
11
11
  --wapp-muted-2: #64748b;
12
12
  --wapp-danger: #991b1b;
13
13
  --wapp-danger-bg: #991b1b;
14
+ --wapp-toast-success: #166534;
15
+ --wapp-toast-success-surface: #f0fdf4;
16
+ --wapp-toast-success-border: #86efac;
17
+ --wapp-toast-error: #991b1b;
18
+ --wapp-toast-error-surface: #fef2f2;
19
+ --wapp-toast-error-border: #fca5a5;
20
+ --wapp-toast-warning: #92400e;
21
+ --wapp-toast-warning-surface: #fffbeb;
22
+ --wapp-toast-warning-border: #fcd34d;
23
+ --wapp-toast-info: #1e40af;
24
+ --wapp-toast-info-surface: #eff6ff;
25
+ --wapp-toast-info-border: #93c5fd;
14
26
  --wapp-primary: #111827;
15
27
  --wapp-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
16
28
  --wapp-overlay-bg: rgb(0 0 0 / 0.5);
@@ -49,6 +61,18 @@
49
61
  --wapp-muted-2: #71717a;
50
62
  --wapp-danger: #fca5a5;
51
63
  --wapp-danger-bg: #7f1d1d;
64
+ --wapp-toast-success: #bbf7d0;
65
+ --wapp-toast-success-surface: #14532d;
66
+ --wapp-toast-success-border: #166534;
67
+ --wapp-toast-error: #fecaca;
68
+ --wapp-toast-error-surface: #7f1d1d;
69
+ --wapp-toast-error-border: #991b1b;
70
+ --wapp-toast-warning: #fde68a;
71
+ --wapp-toast-warning-surface: #78350f;
72
+ --wapp-toast-warning-border: #92400e;
73
+ --wapp-toast-info: #bfdbfe;
74
+ --wapp-toast-info-surface: #1e3a8a;
75
+ --wapp-toast-info-border: #1e40af;
52
76
  --wapp-primary: #f9fafb;
53
77
  --wapp-shadow: none;
54
78
  }
@@ -1453,6 +1477,110 @@ textarea::placeholder {
1453
1477
  color: var(--wapp-danger);
1454
1478
  }
1455
1479
 
1480
+ .wapp-toast-viewport {
1481
+ position: fixed;
1482
+ right: max(1rem, var(--wapp-safe-area-right));
1483
+ bottom: max(1rem, var(--wapp-safe-area-bottom));
1484
+ z-index: 70;
1485
+ display: flex;
1486
+ flex-direction: column;
1487
+ align-items: stretch;
1488
+ gap: 0.5rem;
1489
+ width: min(24rem, calc(100vw - 2rem));
1490
+ max-height: calc(var(--wapp-viewport-height) - 2rem);
1491
+ overflow-y: auto;
1492
+ padding: 0.25rem;
1493
+ pointer-events: none;
1494
+ }
1495
+
1496
+ .wapp-toast {
1497
+ display: flex;
1498
+ align-items: flex-start;
1499
+ gap: 0.75rem;
1500
+ min-width: 0;
1501
+ border: 1px solid var(--wapp-border);
1502
+ border-radius: 0.75rem;
1503
+ background: var(--wapp-surface);
1504
+ color: var(--wapp-text);
1505
+ padding: 0.75rem;
1506
+ box-shadow: 0 8px 24px rgb(0 0 0 / 0.14);
1507
+ pointer-events: auto;
1508
+ animation: wapp-toast-enter 160ms ease-out;
1509
+ }
1510
+
1511
+ .wapp-toast-success {
1512
+ border-color: var(--wapp-toast-success-border);
1513
+ background: var(--wapp-toast-success-surface);
1514
+ color: var(--wapp-toast-success);
1515
+ }
1516
+
1517
+ .wapp-toast-error {
1518
+ border-color: var(--wapp-toast-error-border);
1519
+ background: var(--wapp-toast-error-surface);
1520
+ color: var(--wapp-toast-error);
1521
+ }
1522
+
1523
+ .wapp-toast-warning {
1524
+ border-color: var(--wapp-toast-warning-border);
1525
+ background: var(--wapp-toast-warning-surface);
1526
+ color: var(--wapp-toast-warning);
1527
+ }
1528
+
1529
+ .wapp-toast-info {
1530
+ border-color: var(--wapp-toast-info-border);
1531
+ background: var(--wapp-toast-info-surface);
1532
+ color: var(--wapp-toast-info);
1533
+ }
1534
+
1535
+ .wapp-toast-message {
1536
+ min-width: 0;
1537
+ flex: 1 1 auto;
1538
+ overflow-wrap: anywhere;
1539
+ color: inherit;
1540
+ font-size: 0.875rem;
1541
+ line-height: 1.35;
1542
+ }
1543
+
1544
+ .wapp-toast-dismiss {
1545
+ display: inline-flex;
1546
+ flex: 0 0 1.75rem;
1547
+ align-items: center;
1548
+ justify-content: center;
1549
+ width: 1.75rem;
1550
+ height: 1.75rem;
1551
+ border: 0;
1552
+ border-radius: 0.5rem;
1553
+ background: transparent;
1554
+ color: inherit;
1555
+ cursor: pointer;
1556
+ font-size: 1.25rem;
1557
+ line-height: 1;
1558
+ }
1559
+
1560
+ .wapp-toast-dismiss:hover,
1561
+ .wapp-toast-dismiss:focus-visible {
1562
+ background: rgb(0 0 0 / 0.1);
1563
+ outline: none;
1564
+ }
1565
+
1566
+ @keyframes wapp-toast-enter {
1567
+ from {
1568
+ opacity: 0;
1569
+ transform: translateY(0.5rem);
1570
+ }
1571
+
1572
+ to {
1573
+ opacity: 1;
1574
+ transform: translateY(0);
1575
+ }
1576
+ }
1577
+
1578
+ @media (prefers-reduced-motion: reduce) {
1579
+ .wapp-toast {
1580
+ animation: none;
1581
+ }
1582
+ }
1583
+
1456
1584
  .wapp-notice {
1457
1585
  color: var(--wapp-muted);
1458
1586
  }
@@ -1961,6 +2089,14 @@ textarea::placeholder {
1961
2089
  margin-top: 0.75rem;
1962
2090
  }
1963
2091
 
2092
+ :root[data-wapp-mobile] .wapp-toast-viewport {
2093
+ right: max(0.75rem, var(--wapp-safe-area-right));
2094
+ bottom: max(0.75rem, var(--wapp-safe-area-bottom));
2095
+ left: max(0.75rem, var(--wapp-safe-area-left));
2096
+ width: auto;
2097
+ max-height: calc(var(--wapp-viewport-height) - 1.5rem);
2098
+ }
2099
+
1964
2100
  @media (max-width: 640px) {
1965
2101
  .wapp-settings .wapp-list-row {
1966
2102
  display: grid;
@@ -0,0 +1,147 @@
1
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
2
+ import type { ThemePreference } from "../contracts";
3
+ import { appJson } from "./api-client";
4
+
5
+ const THEME_STORAGE_KEY = "webapp.theme";
6
+ const THEME_MEDIA_QUERY = "(prefers-color-scheme: dark)";
7
+
8
+ export type ResolvedTheme = "light" | "dark";
9
+
10
+ export interface WebAppThemeState {
11
+ preference: ThemePreference;
12
+ resolvedTheme: ResolvedTheme;
13
+ setPreference: (preference: ThemePreference) => void;
14
+ loading: boolean;
15
+ error?: Error;
16
+ retry: () => Promise<void>;
17
+ }
18
+
19
+ const ThemeContext = createContext<WebAppThemeState | null>(null);
20
+
21
+ export function isThemePreference(value: unknown): value is ThemePreference {
22
+ return value === "system" || value === "light" || value === "dark";
23
+ }
24
+
25
+ function isRecord(value: unknown): value is Record<string, unknown> {
26
+ return typeof value === "object" && value !== null && !Array.isArray(value);
27
+ }
28
+
29
+ function readStoredPreference(): ThemePreference {
30
+ if (typeof window === "undefined") {
31
+ return "system";
32
+ }
33
+
34
+ const stored = window.localStorage.getItem(THEME_STORAGE_KEY);
35
+ return isThemePreference(stored) ? stored : "system";
36
+ }
37
+
38
+ function readSystemTheme(): ResolvedTheme {
39
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
40
+ return "light";
41
+ }
42
+ return window.matchMedia(THEME_MEDIA_QUERY).matches ? "dark" : "light";
43
+ }
44
+
45
+ function parseThemeResponse(value: unknown): ThemePreference {
46
+ if (!isRecord(value) || !isThemePreference(value["theme"])) {
47
+ throw new Error("Theme preference response was invalid.");
48
+ }
49
+ return value["theme"];
50
+ }
51
+
52
+ function toError(value: unknown): Error {
53
+ return value instanceof Error ? value : new Error(String(value));
54
+ }
55
+
56
+ export function useTheme(): WebAppThemeState {
57
+ const context = useContext(ThemeContext);
58
+ if (!context) {
59
+ throw new Error("useTheme must be used within the framework WebAppRoot.");
60
+ }
61
+ return context;
62
+ }
63
+
64
+ export function ThemeProvider({ userId, children }: { userId?: string; children: ReactNode }) {
65
+ const [preference, setPreferenceState] = useState<ThemePreference>(readStoredPreference);
66
+ const [systemTheme, setSystemTheme] = useState<ResolvedTheme>(readSystemTheme);
67
+ const [loading, setLoading] = useState(false);
68
+ const [error, setError] = useState<Error>();
69
+ const requestIdRef = useRef(0);
70
+
71
+ useEffect(() => {
72
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
73
+ return;
74
+ }
75
+
76
+ const query = window.matchMedia(THEME_MEDIA_QUERY);
77
+ const sync = () => setSystemTheme(query.matches ? "dark" : "light");
78
+ sync();
79
+ query.addEventListener("change", sync);
80
+ return () => query.removeEventListener("change", sync);
81
+ }, []);
82
+
83
+ const setPreference = useCallback((nextPreference: ThemePreference) => {
84
+ if (!isThemePreference(nextPreference)) {
85
+ throw new TypeError(`Unknown theme preference: ${String(nextPreference)}.`);
86
+ }
87
+ setPreferenceState(nextPreference);
88
+ }, []);
89
+
90
+ const resolvedTheme = preference === "system" ? systemTheme : preference;
91
+
92
+ useEffect(() => {
93
+ if (typeof document === "undefined") {
94
+ return;
95
+ }
96
+
97
+ const root = document.documentElement;
98
+ root.classList.toggle("dark", resolvedTheme === "dark");
99
+ root.style.colorScheme = resolvedTheme;
100
+ root.dataset["theme"] = preference;
101
+ root.dataset["resolvedTheme"] = resolvedTheme;
102
+ window.localStorage.setItem(THEME_STORAGE_KEY, preference);
103
+ }, [preference, resolvedTheme]);
104
+
105
+ const retry = useCallback(async () => {
106
+ const requestId = requestIdRef.current + 1;
107
+ requestIdRef.current = requestId;
108
+ if (!userId) {
109
+ setLoading(false);
110
+ setError(undefined);
111
+ return;
112
+ }
113
+
114
+ setLoading(true);
115
+ setError(undefined);
116
+ try {
117
+ const response = await appJson<unknown>("/api/preferences/theme");
118
+ if (requestId !== requestIdRef.current) {
119
+ return;
120
+ }
121
+ setPreference(parseThemeResponse(response));
122
+ } catch (value) {
123
+ if (requestId === requestIdRef.current) {
124
+ setError(toError(value));
125
+ }
126
+ } finally {
127
+ if (requestId === requestIdRef.current) {
128
+ setLoading(false);
129
+ }
130
+ }
131
+ }, [setPreference, userId]);
132
+
133
+ useEffect(() => {
134
+ void retry();
135
+ }, [retry]);
136
+
137
+ const state = useMemo<WebAppThemeState>(() => ({
138
+ preference,
139
+ resolvedTheme,
140
+ setPreference,
141
+ loading,
142
+ error,
143
+ retry,
144
+ }), [error, loading, preference, resolvedTheme, retry, setPreference]);
145
+
146
+ return <ThemeContext.Provider value={state}>{children}</ThemeContext.Provider>;
147
+ }
@@ -0,0 +1,218 @@
1
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
2
+ import { createPortal } from "react-dom";
3
+
4
+ export type ToastVariant = "success" | "error" | "warning" | "info";
5
+ export type ToastId = string;
6
+
7
+ export interface Toast {
8
+ id: ToastId;
9
+ message: string;
10
+ variant: ToastVariant;
11
+ duration: number;
12
+ }
13
+
14
+ export interface ToastOptions {
15
+ id?: ToastId;
16
+ duration?: number;
17
+ }
18
+
19
+ export interface ToastShowOptions extends ToastOptions {
20
+ variant?: ToastVariant;
21
+ }
22
+
23
+ export interface ToastService {
24
+ toasts: readonly Toast[];
25
+ show: (message: string, options?: ToastShowOptions) => ToastId;
26
+ success: (message: string, options?: ToastOptions) => ToastId;
27
+ error: (message: string, options?: ToastOptions) => ToastId;
28
+ warning: (message: string, options?: ToastOptions) => ToastId;
29
+ info: (message: string, options?: ToastOptions) => ToastId;
30
+ dismiss: (id: ToastId) => void;
31
+ dismissAll: () => void;
32
+ }
33
+
34
+ const DEFAULT_TOAST_DURATION_MS = 8_000;
35
+ const MAX_TOASTS = 5;
36
+
37
+ const ToastContext = createContext<ToastService | null>(null);
38
+
39
+ function isToastVariant(value: unknown): value is ToastVariant {
40
+ return value === "success" || value === "error" || value === "warning" || value === "info";
41
+ }
42
+
43
+ function normalizeMessage(message: string): string {
44
+ if (typeof message !== "string") {
45
+ throw new TypeError("Toast messages must be strings.");
46
+ }
47
+ return message;
48
+ }
49
+
50
+ function normalizeId(id: ToastId | undefined, nextId: () => ToastId): ToastId {
51
+ if (id === undefined) {
52
+ return nextId();
53
+ }
54
+ if (typeof id !== "string" || id.length === 0) {
55
+ throw new TypeError("Toast IDs must be non-empty strings.");
56
+ }
57
+ return id;
58
+ }
59
+
60
+ function normalizeDuration(duration: number | undefined): number {
61
+ if (duration === undefined) {
62
+ return DEFAULT_TOAST_DURATION_MS;
63
+ }
64
+ if (!Number.isFinite(duration) || duration < 0) {
65
+ throw new RangeError("Toast duration must be a finite, non-negative number.");
66
+ }
67
+ return duration;
68
+ }
69
+
70
+ function ToastViewport({ toasts, onDismiss }: { toasts: Toast[]; onDismiss: (id: ToastId) => void }) {
71
+ if (toasts.length === 0) {
72
+ return null;
73
+ }
74
+
75
+ return createPortal(
76
+ <div className="wapp-toast-viewport" role="region" aria-label="Notifications">
77
+ {toasts.map((toast) => {
78
+ const isError = toast.variant === "error";
79
+ return (
80
+ <div
81
+ key={toast.id}
82
+ className={`wapp-toast wapp-toast-${toast.variant}`}
83
+ data-toast-variant={toast.variant}
84
+ role={isError ? "alert" : "status"}
85
+ aria-live={isError ? "assertive" : "polite"}
86
+ aria-atomic="true"
87
+ >
88
+ <span className="wapp-toast-message">{toast.message}</span>
89
+ <button type="button" className="wapp-toast-dismiss" aria-label="Dismiss notification" onClick={() => onDismiss(toast.id)}>
90
+ ×
91
+ </button>
92
+ </div>
93
+ );
94
+ })}
95
+ </div>,
96
+ document.body,
97
+ );
98
+ }
99
+
100
+ export function useToast(): ToastService {
101
+ const context = useContext(ToastContext);
102
+ if (!context) {
103
+ throw new Error("useToast must be used within the framework webapp runtime.");
104
+ }
105
+ return context;
106
+ }
107
+
108
+ export function ToastProvider({ children }: { children: ReactNode }) {
109
+ const [toasts, setToasts] = useState<Toast[]>([]);
110
+ const timersRef = useRef(new Map<ToastId, ReturnType<typeof setTimeout>>());
111
+ const nextIdRef = useRef(0);
112
+
113
+ const nextId = useCallback((): ToastId => {
114
+ nextIdRef.current += 1;
115
+ return `toast-${nextIdRef.current}`;
116
+ }, []);
117
+
118
+ const clearTimer = useCallback((id: ToastId) => {
119
+ const timer = timersRef.current.get(id);
120
+ if (timer === undefined) {
121
+ return;
122
+ }
123
+ clearTimeout(timer);
124
+ timersRef.current.delete(id);
125
+ }, []);
126
+
127
+ const dismiss = useCallback((id: ToastId) => {
128
+ clearTimer(id);
129
+ setToasts((current) => current.filter((toast) => toast.id !== id));
130
+ }, [clearTimer]);
131
+
132
+ const dismissAll = useCallback(() => {
133
+ for (const id of Array.from(timersRef.current.keys())) {
134
+ clearTimer(id);
135
+ }
136
+ setToasts([]);
137
+ }, [clearTimer]);
138
+
139
+ const scheduleDismiss = useCallback((toast: Toast) => {
140
+ clearTimer(toast.id);
141
+ if (toast.duration === 0) {
142
+ return;
143
+ }
144
+
145
+ const timer = setTimeout(() => {
146
+ if (timersRef.current.get(toast.id) !== timer) {
147
+ return;
148
+ }
149
+ timersRef.current.delete(toast.id);
150
+ setToasts((current) => current.filter((item) => item.id !== toast.id));
151
+ }, toast.duration);
152
+ timersRef.current.set(toast.id, timer);
153
+ }, [clearTimer]);
154
+
155
+ const show = useCallback((message: string, options?: ToastShowOptions): ToastId => {
156
+ const normalizedMessage = normalizeMessage(message);
157
+ const variant = options?.variant ?? "info";
158
+ if (!isToastVariant(variant)) {
159
+ throw new TypeError(`Unknown toast variant: ${String(variant)}.`);
160
+ }
161
+ const id = normalizeId(options?.id, nextId);
162
+ const duration = normalizeDuration(options?.duration);
163
+ const toast: Toast = { id, message: normalizedMessage, variant, duration };
164
+
165
+ clearTimer(id);
166
+ setToasts((current) => {
167
+ const existingIndex = current.findIndex((item) => item.id === id);
168
+ if (existingIndex >= 0) {
169
+ const next = [...current];
170
+ next[existingIndex] = toast;
171
+ return next;
172
+ }
173
+ const next = [...current, toast];
174
+ return next.length > MAX_TOASTS ? next.slice(next.length - MAX_TOASTS) : next;
175
+ });
176
+ scheduleDismiss(toast);
177
+ return id;
178
+ }, [clearTimer, nextId, scheduleDismiss]);
179
+
180
+ useEffect(() => {
181
+ const activeIds = new Set(toasts.map((toast) => toast.id));
182
+ for (const id of timersRef.current.keys()) {
183
+ if (activeIds.has(id)) {
184
+ continue;
185
+ }
186
+ clearTimer(id);
187
+ }
188
+ }, [clearTimer, toasts]);
189
+
190
+ useEffect(() => () => {
191
+ for (const timer of timersRef.current.values()) {
192
+ clearTimeout(timer);
193
+ }
194
+ timersRef.current.clear();
195
+ }, []);
196
+
197
+ const success = useCallback((message: string, options?: ToastOptions) => show(message, { ...options, variant: "success" }), [show]);
198
+ const error = useCallback((message: string, options?: ToastOptions) => show(message, { ...options, variant: "error" }), [show]);
199
+ const warning = useCallback((message: string, options?: ToastOptions) => show(message, { ...options, variant: "warning" }), [show]);
200
+ const info = useCallback((message: string, options?: ToastOptions) => show(message, { ...options, variant: "info" }), [show]);
201
+ const service = useMemo<ToastService>(() => ({
202
+ toasts,
203
+ show,
204
+ success,
205
+ error,
206
+ warning,
207
+ info,
208
+ dismiss,
209
+ dismissAll,
210
+ }), [dismiss, dismissAll, error, info, show, success, toasts, warning]);
211
+
212
+ return (
213
+ <ToastContext.Provider value={service}>
214
+ {children}
215
+ <ToastViewport toasts={toasts} onDismiss={dismiss} />
216
+ </ToastContext.Provider>
217
+ );
218
+ }
@@ -0,0 +1,117 @@
1
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
2
+ import type { WebAppConfigResponse } from "../contracts";
3
+ import { appJson } from "./api-client";
4
+
5
+ export interface WebAppConfigState {
6
+ config?: WebAppConfigResponse;
7
+ loading: boolean;
8
+ error?: Error;
9
+ refresh: () => Promise<void>;
10
+ }
11
+
12
+ const WebAppConfigContext = createContext<WebAppConfigState | null>(null);
13
+
14
+ function isRecord(value: unknown): value is Record<string, unknown> {
15
+ return typeof value === "object" && value !== null && !Array.isArray(value);
16
+ }
17
+
18
+ function isLogLevelName(value: unknown): boolean {
19
+ return value === "trace" || value === "debug" || value === "info" || value === "warn" || value === "error";
20
+ }
21
+
22
+ function isCurrentUser(value: unknown): boolean {
23
+ return isRecord(value)
24
+ && typeof value["id"] === "string"
25
+ && typeof value["username"] === "string"
26
+ && (value["role"] === "owner" || value["role"] === "admin" || value["role"] === "user")
27
+ && typeof value["isOwner"] === "boolean"
28
+ && typeof value["isAdmin"] === "boolean";
29
+ }
30
+
31
+ function hasBooleanFields(value: unknown, fields: readonly string[]): boolean {
32
+ return isRecord(value) && fields.every((field) => typeof value[field] === "boolean");
33
+ }
34
+
35
+ function isWebAppConfigResponse(value: unknown): value is WebAppConfigResponse {
36
+ return isRecord(value)
37
+ && typeof value["appName"] === "string"
38
+ && typeof value["version"] === "string"
39
+ && (value["currentUser"] === undefined || isCurrentUser(value["currentUser"]))
40
+ && hasBooleanFields(value["passkeyAuth"], [
41
+ "enabled",
42
+ "passkeyConfigured",
43
+ "passkeyDisabled",
44
+ "passkeyRequired",
45
+ "authenticated",
46
+ "bootstrapRequired",
47
+ "ownerPasskeySetupRequired",
48
+ ])
49
+ && hasBooleanFields(value["userManagement"], ["enabled", "canManageUsers"])
50
+ && isRecord(value["logLevel"])
51
+ && isLogLevelName(value["logLevel"]["level"])
52
+ && typeof value["logLevel"]["fromEnv"] === "boolean"
53
+ && hasBooleanFields(value["deviceAuth"], ["enabled"])
54
+ && hasBooleanFields(value["apiKeys"], ["enabled"]);
55
+ }
56
+
57
+ export function parseWebAppConfigResponse(value: unknown): WebAppConfigResponse {
58
+ if (!isWebAppConfigResponse(value)) {
59
+ throw new Error("Web app configuration response was invalid.");
60
+ }
61
+ return value;
62
+ }
63
+
64
+ function toError(value: unknown): Error {
65
+ return value instanceof Error ? value : new Error(String(value));
66
+ }
67
+
68
+ export function useWebAppConfig(consumer = "useWebAppConfig"): WebAppConfigState {
69
+ const context = useContext(WebAppConfigContext);
70
+ if (!context) {
71
+ throw new Error(`${consumer} must be used within the framework WebAppRoot.`);
72
+ }
73
+ return context;
74
+ }
75
+
76
+ export function WebAppConfigProvider({ children }: { children: ReactNode }) {
77
+ const [config, setConfig] = useState<WebAppConfigResponse>();
78
+ const [loading, setLoading] = useState(true);
79
+ const [error, setError] = useState<Error>();
80
+ const requestIdRef = useRef(0);
81
+
82
+ const refresh = useCallback(async () => {
83
+ const requestId = requestIdRef.current + 1;
84
+ requestIdRef.current = requestId;
85
+ setLoading(true);
86
+ setError(undefined);
87
+
88
+ try {
89
+ const response = parseWebAppConfigResponse(await appJson<unknown>("/api/config"));
90
+ if (requestId !== requestIdRef.current) {
91
+ return;
92
+ }
93
+ setConfig(response);
94
+ } catch (value) {
95
+ if (requestId === requestIdRef.current) {
96
+ setError(toError(value));
97
+ }
98
+ } finally {
99
+ if (requestId === requestIdRef.current) {
100
+ setLoading(false);
101
+ }
102
+ }
103
+ }, []);
104
+
105
+ useEffect(() => {
106
+ void refresh();
107
+ }, [refresh]);
108
+
109
+ const state = useMemo<WebAppConfigState>(() => ({
110
+ config,
111
+ loading,
112
+ error,
113
+ refresh,
114
+ }), [config, error, loading, refresh]);
115
+
116
+ return <WebAppConfigContext.Provider value={state}>{children}</WebAppConfigContext.Provider>;
117
+ }