@pablozaiden/webapp 0.6.0 → 0.6.2

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
@@ -22,8 +22,8 @@ Each application package must declare `react` and `react-dom`; they are peer dep
22
22
 
23
23
  | Export | Use |
24
24
  | --- | --- |
25
- | `@pablozaiden/webapp/server` | `createWebAppServer`, route helpers, responses, SQLite store |
26
- | `@pablozaiden/webapp/web` | `WebAppRoot`, `renderWebApp`, sidebar types, UI controls, realtime hooks |
25
+ | `@pablozaiden/webapp/server` | `createWebAppServer`, route helpers, responses, request-origin helpers, SQLite store |
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 |
package/docs/auth.md CHANGED
@@ -74,8 +74,9 @@ The same normalized request context drives expected same-origin origins,
74
74
  WebSocket origin checks, passkey expected origins and RP IDs, the `Secure`
75
75
  cookie attribute, cookie paths, setup links, and device/discovery URLs.
76
76
  `{PREFIX}_PUBLIC_BASE_URL` is authoritative for origin, hostname, and secure
77
- state even when forwarded headers are present; trusted prefixes remain
78
- policy-controlled for path-bearing cookies and links.
77
+ state even when forwarded headers are present. It must be an origin-only
78
+ absolute `http` or `https` URL; trusted prefixes remain policy-controlled for
79
+ path-bearing cookies and links.
79
80
 
80
81
  Trust mode is a deployment boundary setting, not a way to trust arbitrary
81
82
  client input. The reverse proxy must strip client-supplied forwarded headers
@@ -84,3 +85,10 @@ single-value headers. Do not expose the application directly to untrusted
84
85
  clients when trust mode is enabled. This release does not implement a
85
86
  proxy-address allowlist, so the network boundary and proxy sanitization are
86
87
  required.
88
+
89
+ Applications that generate their own public URLs should use
90
+ `getRequestOriginInfo` or `getRequestBaseUrl` from
91
+ `@pablozaiden/webapp/server` with the server's runtime config instead of
92
+ parsing `{PREFIX}_PUBLIC_BASE_URL` independently. The framework validates the
93
+ configured public base URL during startup and applies the same trusted-proxy
94
+ policy used by its authentication and origin checks.
@@ -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
@@ -186,7 +289,7 @@ The app configures an uppercase `envPrefix`; the framework reads only variables
186
289
  | `{PREFIX}_TRUST_PROXY` | `false` | Explicitly trust the documented `X-Forwarded-*` headers; keep disabled for direct deployments |
187
290
  | `{PREFIX}_TRUST_PROXY_HEADERS` | `proto,host,prefix` when enabled | Comma-separated subset of supported forwarded values: `proto`, `host`, `prefix` |
188
291
  | `{PREFIX}_TRUST_PROXY_CHAIN` | `first` | Select the left-most (`first`) or right-most (`last`) non-empty value from comma-separated forwarded headers |
189
- | `{PREFIX}_PUBLIC_BASE_URL` | request origin | Authoritative external origin for auth/device URLs; it overrides forwarded protocol and host values |
292
+ | `{PREFIX}_PUBLIC_BASE_URL` | request origin | Authoritative absolute `http` or `https` origin (without a path, query, or fragment) for auth/device URLs; it overrides forwarded protocol and host values |
190
293
  | `{PREFIX}_AUTH_ISSUER` | `urn:{prefix}:webapp` | JWT issuer override |
191
294
 
192
295
  The configured data directory contains the framework-owned
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.0",
3
+ "version": "0.6.2",
4
4
  "description": "Opinionated Bun + React webapp framework for single-server apps",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -36,7 +36,9 @@
36
36
  "build": "bun run tsc && bun run --cwd examples/kitchen-sink build && bun run --cwd examples/notes-todo build",
37
37
  "build:kitchen-sink": "bun run --cwd examples/kitchen-sink build",
38
38
  "build:notes-todo": "bun run --cwd examples/notes-todo build",
39
- "test": "bun test",
39
+ "test": "bun run test:build-binary && bun run test:remaining",
40
+ "test:build-binary": "bun test tests/build-binary.test.ts",
41
+ "test:remaining": "bun test --path-ignore-patterns=**/build-binary.test.ts",
40
42
  "tsc": "mkdir -p .cache/tsc && bunx tsc --noEmit --pretty false --incremental --tsBuildInfoFile .cache/tsc/build.tsbuildinfo"
41
43
  },
42
44
  "dependencies": {
@@ -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");
@@ -3,6 +3,7 @@ export * from "./routes";
3
3
  export * from "./route-catalog";
4
4
  export * from "./responses";
5
5
  export * from "./create-web-app-server";
6
+ export { getRequestBaseUrl, getRequestOriginInfo, type RequestOriginInfo } from "./auth/request-origin";
6
7
  export * from "./auth/store";
7
8
  export * from "./auth/sqlite-store";
8
9
  export * from "./realtime/bus";
@@ -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 {
@@ -84,6 +99,30 @@ function parseBoolean(raw: string | undefined, fallback: boolean, name: string):
84
99
  throw new Error(`${name} must be true or false; received "${raw}"`);
85
100
  }
86
101
 
102
+ function parsePublicBaseUrl(raw: string | undefined, name: string): string | undefined {
103
+ if (!raw) {
104
+ return undefined;
105
+ }
106
+ let parsed: URL;
107
+ try {
108
+ parsed = new URL(raw);
109
+ } catch {
110
+ throw new Error(`${name} must be a valid absolute http(s) origin; received "${raw}"`);
111
+ }
112
+ if (
113
+ (parsed.protocol !== "http:" && parsed.protocol !== "https:") ||
114
+ parsed.username ||
115
+ parsed.password ||
116
+ parsed.origin === "null" ||
117
+ parsed.pathname !== "/" ||
118
+ parsed.search ||
119
+ parsed.hash
120
+ ) {
121
+ throw new Error(`${name} must be a valid absolute http(s) origin; received "${raw}"`);
122
+ }
123
+ return parsed.origin;
124
+ }
125
+
87
126
  function parseTrustProxyHeaders(raw: string | undefined, enabled: boolean, name: string): TrustProxyHeader[] {
88
127
  if (raw === undefined) {
89
128
  return enabled ? [...TRUST_PROXY_HEADERS] : [];
@@ -122,6 +161,7 @@ export function readRuntimeConfig(input: {
122
161
  const envPrefix = assertEnvPrefix(input.envPrefix);
123
162
  const logLevelRaw = readEnv(envPrefix, "LOG_LEVEL");
124
163
  const logLevel = parseLogLevel(logLevelRaw, input.defaultLogLevel ?? "info", envName(envPrefix, "LOG_LEVEL"));
164
+ const publicBaseUrl = parsePublicBaseUrl(readEnv(envPrefix, "PUBLIC_BASE_URL"), envName(envPrefix, "PUBLIC_BASE_URL"));
125
165
  const trustProxyEnabled = parseBoolean(readEnv(envPrefix, "TRUST_PROXY"), false, envName(envPrefix, "TRUST_PROXY"));
126
166
  const trustProxy = {
127
167
  enabled: trustProxyEnabled,
@@ -138,7 +178,7 @@ export function readRuntimeConfig(input: {
138
178
  logLevelFromEnv: Boolean(logLevelRaw),
139
179
  passkeyDisabled: isTruthyEnv(readEnv(envPrefix, "DISABLE_PASSKEY")),
140
180
  sameOriginDisabled: isTruthyEnv(readEnv(envPrefix, "DISABLE_SAME_ORIGIN_CHECK")),
141
- publicBaseUrl: readEnv(envPrefix, "PUBLIC_BASE_URL"),
181
+ publicBaseUrl,
142
182
  authIssuer: readEnv(envPrefix, "AUTH_ISSUER"),
143
183
  trustProxy,
144
184
  development: process.env["NODE_ENV"] === "production" ? false : { hmr: true, console: true },
@@ -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
  }