@pablozaiden/webapp 0.5.6 → 0.5.8

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.
@@ -41,7 +41,7 @@ const app = createWebAppServer({
41
41
  await app.runFromCli();
42
42
  ```
43
43
 
44
- The framework generates the HTML document, React mount point, viewport metadata, PWA manifest, default SVG icons, and the theme prepaint script. By default it uses `./web/main.tsx` relative to the Bun entry file as the frontend entrypoint, so apps only need to create that file. Override document defaults only when the app needs different metadata:
44
+ The framework generates the HTML document, React mount point, fixed-scale viewport metadata, PWA manifest, default SVG icons, and the theme prepaint script. On iPhone, iPad, and other mobile browsers that honor viewport scaling tokens, the generated viewport prevents pinch-to-zoom while preserving normal scrolling; clients that ignore those tokens are unaffected. By default it uses `./web/main.tsx` relative to the Bun entry file as the frontend entrypoint, so apps only need to create that file. Override document defaults only when the app needs different metadata:
45
45
 
46
46
  ```ts
47
47
  createWebAppServer({
@@ -67,6 +67,8 @@ createWebAppServer({
67
67
  });
68
68
  ```
69
69
 
70
+ Do not create an app-owned `index.html` or add global touch handlers to control zoom. The framework owns the viewport policy so the same metadata is emitted for development, compiled clients, and installed PWAs without disabling scrolling.
71
+
70
72
  PWA support is on by default, with generated initials icons unless the app provides `web.icons`. Icon paths are relative to the app package root; production apps should provide at least 192x192 and 512x512 PNG manifest icons plus an Apple touch icon. Set `web.pwa: false` only for apps that deliberately should not be installable. The standard theme preference key is `webapp.theme`; apps should not use app-specific theme storage keys.
71
73
 
72
74
  Apps should stay one app and one binary. Use subcommands for different modes:
package/docs/server.md CHANGED
@@ -96,7 +96,9 @@ Built-in endpoints include:
96
96
 
97
97
  ## Framework-owned web document and PWA metadata
98
98
 
99
- `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.
99
+ `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 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.
100
+
101
+ Do not replace this behavior with global touch event handlers, `preventDefault()` calls, or `touch-action: none`: those approaches can disable scrolling and other touch interactions. Applications should keep the document framework-owned rather than adding an app-owned `index.html`.
100
102
 
101
103
  ```ts
102
104
  createWebAppServer({
package/docs/sidebar.md CHANGED
@@ -38,6 +38,8 @@ Sidebar nodes support:
38
38
 
39
39
  Search is intentionally app-defined: `getNodes({ search })` receives raw search text and returns the tree that should be rendered. Set `sidebar.search: false` when an app has a small fixed navigation tree and should not show the sidebar search box.
40
40
 
41
+ On mobile widths, the drawer can be opened with a horizontal swipe starting within the first 24px of the viewport, in addition to the header button. The gesture must move at least 64px to the right, stay within 48px of vertical displacement, and remain more horizontal than vertical.
42
+
41
43
  Use `actions` when an entity needs commands in the sidebar. `WebAppRoot` finds the active route-backed sidebar node and automatically renders its `ActionMenuItem[]` in the title-bar three-line menu, so the sidebar right-click menu and header menu stay consistent from one source of truth. Use `header.getActions` only for extra route-level actions that are not represented by the active sidebar node.
42
44
 
43
45
  ```tsx
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pablozaiden/webapp",
3
- "version": "0.5.6",
3
+ "version": "0.5.8",
4
4
  "description": "Opinionated Bun + React webapp framework for single-server apps",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -510,7 +510,7 @@ function generatedHtml(
510
510
  <html lang="${escapeAttribute(web.lang ?? "en")}">
511
511
  <head>
512
512
  <meta charset="utf-8" />
513
- <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
513
+ <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
514
514
  ${themeMetaTag}\
515
515
  ${manifestTags} <title>${title}</title>
516
516
  <script>${themeBootScript(themeColor)}</script>
@@ -190,7 +190,7 @@ function useMobileViewportHeight() {
190
190
  timers.add(timer);
191
191
  };
192
192
 
193
- const handleKeyboardBoundary = () => {
193
+ const handleViewportTransition = () => {
194
194
  scheduleSync();
195
195
  scheduleDelayedSync(120);
196
196
  scheduleDelayedSync(320);
@@ -200,9 +200,10 @@ function useMobileViewportHeight() {
200
200
  viewport?.addEventListener("resize", scheduleSync);
201
201
  viewport?.addEventListener("scroll", scheduleSync);
202
202
  window.addEventListener("resize", scheduleSync);
203
+ window.addEventListener("orientationchange", handleViewportTransition);
203
204
  mobileQuery.addEventListener("change", scheduleSync);
204
- document.addEventListener("focusin", handleKeyboardBoundary);
205
- document.addEventListener("focusout", handleKeyboardBoundary);
205
+ document.addEventListener("focusin", handleViewportTransition);
206
+ document.addEventListener("focusout", handleViewportTransition);
206
207
 
207
208
  return () => {
208
209
  if (frame) {
@@ -214,14 +215,94 @@ function useMobileViewportHeight() {
214
215
  viewport?.removeEventListener("resize", scheduleSync);
215
216
  viewport?.removeEventListener("scroll", scheduleSync);
216
217
  window.removeEventListener("resize", scheduleSync);
218
+ window.removeEventListener("orientationchange", handleViewportTransition);
217
219
  mobileQuery.removeEventListener("change", scheduleSync);
218
- document.removeEventListener("focusin", handleKeyboardBoundary);
219
- document.removeEventListener("focusout", handleKeyboardBoundary);
220
+ document.removeEventListener("focusin", handleViewportTransition);
221
+ document.removeEventListener("focusout", handleViewportTransition);
220
222
  clearViewportHeight();
221
223
  };
222
224
  }, []);
223
225
  }
224
226
 
227
+ const MOBILE_SIDEBAR_BREAKPOINT = 900;
228
+ const SIDEBAR_SWIPE_EDGE_WIDTH = 24;
229
+ const SIDEBAR_SWIPE_DISTANCE = 64;
230
+ const SIDEBAR_SWIPE_VERTICAL_TOLERANCE = 48;
231
+
232
+ function useMobileSidebarSwipe(sidebarOpen: boolean, setSidebarOpen: (open: boolean) => void) {
233
+ useEffect(() => {
234
+ if (typeof window === "undefined" || typeof document === "undefined") {
235
+ return;
236
+ }
237
+
238
+ let tracking = false;
239
+ let startX = 0;
240
+ let startY = 0;
241
+
242
+ const reset = () => {
243
+ tracking = false;
244
+ };
245
+
246
+ const handleTouchStart = (event: TouchEvent) => {
247
+ if (sidebarOpen || window.innerWidth > MOBILE_SIDEBAR_BREAKPOINT || event.touches.length !== 1) {
248
+ reset();
249
+ return;
250
+ }
251
+
252
+ const touch = event.touches[0];
253
+ if (!touch || touch.clientX > SIDEBAR_SWIPE_EDGE_WIDTH) {
254
+ reset();
255
+ return;
256
+ }
257
+
258
+ tracking = true;
259
+ startX = touch.clientX;
260
+ startY = touch.clientY;
261
+ };
262
+
263
+ const handleTouchMove = (event: TouchEvent) => {
264
+ if (!tracking) {
265
+ return;
266
+ }
267
+ if (event.touches.length !== 1) {
268
+ reset();
269
+ return;
270
+ }
271
+
272
+ const touch = event.touches[0];
273
+ if (!touch) {
274
+ reset();
275
+ return;
276
+ }
277
+
278
+ const deltaX = touch.clientX - startX;
279
+ const deltaY = Math.abs(touch.clientY - startY);
280
+ if (deltaX <= 0 || deltaY > SIDEBAR_SWIPE_VERTICAL_TOLERANCE || deltaY > deltaX) {
281
+ reset();
282
+ return;
283
+ }
284
+ if (deltaX < SIDEBAR_SWIPE_DISTANCE) {
285
+ return;
286
+ }
287
+
288
+ event.preventDefault();
289
+ setSidebarOpen(true);
290
+ reset();
291
+ };
292
+
293
+ document.addEventListener("touchstart", handleTouchStart, { passive: true });
294
+ document.addEventListener("touchmove", handleTouchMove, { passive: false });
295
+ document.addEventListener("touchend", reset);
296
+ document.addEventListener("touchcancel", reset);
297
+ return () => {
298
+ document.removeEventListener("touchstart", handleTouchStart);
299
+ document.removeEventListener("touchmove", handleTouchMove);
300
+ document.removeEventListener("touchend", reset);
301
+ document.removeEventListener("touchcancel", reset);
302
+ };
303
+ }, [setSidebarOpen, sidebarOpen]);
304
+ }
305
+
225
306
  async function json<T>(path: string, init: RequestInit = {}): Promise<T> {
226
307
  const response = await fetch(path, {
227
308
  ...init,
@@ -1077,6 +1158,7 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, onRout
1077
1158
  const [sidebarOpen, setSidebarOpen] = useState(false);
1078
1159
  const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
1079
1160
  const sidebarTreeState = useSidebarCollapsedState(appName);
1161
+ useMobileSidebarSwipe(sidebarOpen, setSidebarOpen);
1080
1162
  const toggleSidebarCollapsed = useCallback(() => {
1081
1163
  setSidebarCollapsed((current) => {
1082
1164
  const nextCollapsed = !current;
@@ -108,6 +108,7 @@ textarea::placeholder {
108
108
  height: var(--wapp-viewport-height);
109
109
  min-height: var(--wapp-viewport-height);
110
110
  overflow: hidden;
111
+ overscroll-behavior-x: none;
111
112
  background: var(--wapp-bg);
112
113
  }
113
114
 
@@ -1896,6 +1897,10 @@ textarea::placeholder {
1896
1897
  padding: 0.875rem 1rem;
1897
1898
  }
1898
1899
 
1900
+ .wapp-main {
1901
+ touch-action: pan-y;
1902
+ }
1903
+
1899
1904
  .wapp-main-content {
1900
1905
  overflow: auto;
1901
1906
  overflow-x: hidden;