@pablozaiden/webapp 0.3.2 → 0.3.4

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.
@@ -57,11 +57,15 @@ See `docs/cli.md` for framework CLI helpers and generic API command support.
57
57
  Frontend entrypoints should use `renderWebApp` so Bun/browser hot reload reuses the existing React root instead of calling `createRoot()` twice. Import the framework CSS explicitly so Bun hot reload observes style changes:
58
58
 
59
59
  ```tsx
60
- import { WebAppRoot, renderWebApp } from "@pablozaiden/webapp/web";
60
+ import { Page, Panel, WebAppRoot, renderWebApp } from "@pablozaiden/webapp/web";
61
61
  import "@pablozaiden/webapp/web/styles.css";
62
62
 
63
63
  function Home() {
64
- return <main className="wapp-main-content">Hello</main>;
64
+ return (
65
+ <Page>
66
+ <Panel>Hello</Panel>
67
+ </Page>
68
+ );
65
69
  }
66
70
 
67
71
  renderWebApp(
@@ -75,6 +79,7 @@ renderWebApp(
75
79
  ```
76
80
 
77
81
  `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.
82
+ `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.
78
83
 
79
84
  Recommended dev script:
80
85
 
@@ -9,6 +9,7 @@ The framework intentionally provides a consistent base UI:
9
9
  - Settings and collapse controls are framework-owned.
10
10
  - Version is always visible at the bottom of the sidebar.
11
11
  - Main content should prefer panels, toolbars, badges and simple forms over custom one-off layouts.
12
+ - `WebAppRoot` owns the fixed main title bar and `.wapp-main-content`; app routes should not render or style those shell elements directly.
12
13
 
13
14
  ## Main content primitives
14
15
 
@@ -16,6 +17,7 @@ Use these first:
16
17
 
17
18
  | Component | Use |
18
19
  | --- | --- |
20
+ | `Page` | Required top-level wrapper for route content rendered by `WebAppRoot.routes`; provides standard margins/padding and mobile spacing |
19
21
  | `Toolbar` | Page title/actions inside main content |
20
22
  | `Panel` | Cards/sections; use `actions` for a top-right menu/action area |
21
23
  | `ActionMenu` | Three-line action menu for secondary surfaces; entity-level shell menus should usually come from `SidebarNode.actions` so the framework renders them in the sidebar context menu and fixed title bar |
@@ -34,6 +36,8 @@ Use these first:
34
36
 
35
37
  For entity actions, prefer the framework title bar: define one `ActionMenuItem[]` builder and attach it to `SidebarNode.actions` for the route-backed node. The framework reuses those actions for both sidebar right-click and the active route title-bar menu. Use `WebAppRoot.header.getActions` only for additional actions not owned by a sidebar node.
36
38
 
39
+ Every route component rendered by `WebAppRoot.routes` should return `Page` at the top level, including loading/error/empty states. Do not render a `Panel`, `DataList`, `EmptyState` or custom div directly into `WebAppRoot`; that skips the standard content margins and recreates the visual bug where cards touch the main content edge. Use `EntityHeader` only when the page needs a content-specific heading distinct from the fixed framework title bar; do not duplicate the active route title immediately below the header.
40
+
37
41
  ## Visual validation captures
38
42
 
39
43
  Generated screenshots live in `artifacts/screenshots`:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pablozaiden/webapp",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "Opinionated Bun + React webapp framework for single-server apps",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -70,6 +70,18 @@ type StoredSidebarPin = {
70
70
  route: WebAppRoute;
71
71
  };
72
72
 
73
+ function isSidebarShortcutEditableTarget(target: EventTarget | null): boolean {
74
+ if (!(target instanceof HTMLElement)) {
75
+ return false;
76
+ }
77
+ if (target.isContentEditable || target.closest("[contenteditable=''], [contenteditable='true']")) {
78
+ return true;
79
+ }
80
+ return target instanceof HTMLInputElement
81
+ || target instanceof HTMLTextAreaElement
82
+ || target instanceof HTMLSelectElement;
83
+ }
84
+
73
85
  function routeToHash(route: WebAppRoute): string {
74
86
  const params = new URLSearchParams();
75
87
  for (const [key, value] of Object.entries(route)) {
@@ -102,6 +114,87 @@ function useRoute(defaultRoute: WebAppRoute) {
102
114
  return { route, navigate };
103
115
  }
104
116
 
117
+ function useMobileViewportHeight() {
118
+ useEffect(() => {
119
+ if (typeof window === "undefined") {
120
+ return;
121
+ }
122
+
123
+ const root = document.documentElement;
124
+ const mobileQuery = window.matchMedia("(max-width: 900px)");
125
+ const viewport = window.visualViewport;
126
+ const timers = new Set<number>();
127
+ let frame = 0;
128
+
129
+ const clearViewportHeight = () => {
130
+ root.style.removeProperty("--wapp-viewport-height");
131
+ };
132
+
133
+ const sync = () => {
134
+ frame = 0;
135
+ if (!mobileQuery.matches) {
136
+ clearViewportHeight();
137
+ return;
138
+ }
139
+
140
+ const height = Math.round(viewport?.height ?? window.innerHeight);
141
+ if (height > 0) {
142
+ root.style.setProperty("--wapp-viewport-height", `${height}px`);
143
+ }
144
+
145
+ const scrollingElement = document.scrollingElement;
146
+ if (scrollingElement && scrollingElement.scrollTop !== 0) {
147
+ scrollingElement.scrollTop = 0;
148
+ }
149
+ };
150
+
151
+ const scheduleSync = () => {
152
+ if (frame) {
153
+ return;
154
+ }
155
+ frame = requestAnimationFrame(sync);
156
+ };
157
+
158
+ const scheduleDelayedSync = (delay: number) => {
159
+ const timer = window.setTimeout(() => {
160
+ timers.delete(timer);
161
+ scheduleSync();
162
+ }, delay);
163
+ timers.add(timer);
164
+ };
165
+
166
+ const handleKeyboardBoundary = () => {
167
+ scheduleSync();
168
+ scheduleDelayedSync(120);
169
+ scheduleDelayedSync(320);
170
+ };
171
+
172
+ scheduleSync();
173
+ viewport?.addEventListener("resize", scheduleSync);
174
+ viewport?.addEventListener("scroll", scheduleSync);
175
+ window.addEventListener("resize", scheduleSync);
176
+ mobileQuery.addEventListener("change", scheduleSync);
177
+ document.addEventListener("focusin", handleKeyboardBoundary);
178
+ document.addEventListener("focusout", handleKeyboardBoundary);
179
+
180
+ return () => {
181
+ if (frame) {
182
+ cancelAnimationFrame(frame);
183
+ }
184
+ for (const timer of timers) {
185
+ clearTimeout(timer);
186
+ }
187
+ viewport?.removeEventListener("resize", scheduleSync);
188
+ viewport?.removeEventListener("scroll", scheduleSync);
189
+ window.removeEventListener("resize", scheduleSync);
190
+ mobileQuery.removeEventListener("change", scheduleSync);
191
+ document.removeEventListener("focusin", handleKeyboardBoundary);
192
+ document.removeEventListener("focusout", handleKeyboardBoundary);
193
+ clearViewportHeight();
194
+ };
195
+ }, []);
196
+ }
197
+
105
198
  async function json<T>(path: string, init: RequestInit = {}): Promise<T> {
106
199
  const response = await fetch(path, {
107
200
  ...init,
@@ -892,12 +985,20 @@ function SettingsView({ config, refresh, customSections, theme, setTheme }: { co
892
985
  }
893
986
 
894
987
  export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, onRouteChange, settings, version }: WebAppRootProps) {
988
+ useMobileViewportHeight();
895
989
  const { config, error, refresh } = useConfig();
896
990
  const { route, navigate } = useRoute(homeRoute);
897
991
  const { theme, setTheme } = useTheme();
898
992
  const [search, setSearch] = useState("");
899
993
  const [sidebarOpen, setSidebarOpen] = useState(false);
900
994
  const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
995
+ const toggleSidebarCollapsed = useCallback(() => {
996
+ setSidebarCollapsed((current) => {
997
+ const nextCollapsed = !current;
998
+ setSidebarOpen(!nextCollapsed);
999
+ return nextCollapsed;
1000
+ });
1001
+ }, []);
901
1002
  const sidebarSearchEnabled = sidebar.search !== false;
902
1003
  const pinningEnabled = sidebar.pinning !== false;
903
1004
  const sidebarPins = useSidebarPins(appName, sidebar.pinning ? sidebar.pinning.storageKey : undefined);
@@ -959,6 +1060,28 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, onRout
959
1060
  .catch(() => undefined);
960
1061
  }, [config?.currentUser?.id, setTheme]);
961
1062
 
1063
+ useEffect(() => {
1064
+ function handleSidebarShortcut(event: KeyboardEvent) {
1065
+ if (
1066
+ event.key.toLowerCase() !== "b"
1067
+ || event.altKey
1068
+ || event.shiftKey
1069
+ || event.ctrlKey === event.metaKey
1070
+ || event.isComposing
1071
+ || event.repeat
1072
+ || isSidebarShortcutEditableTarget(event.target)
1073
+ ) {
1074
+ return;
1075
+ }
1076
+
1077
+ event.preventDefault();
1078
+ toggleSidebarCollapsed();
1079
+ }
1080
+
1081
+ document.addEventListener("keydown", handleSidebarShortcut);
1082
+ return () => document.removeEventListener("keydown", handleSidebarShortcut);
1083
+ }, [toggleSidebarCollapsed]);
1084
+
962
1085
  useEffect(() => {
963
1086
  onRouteChange?.(route);
964
1087
  }, [onRouteChange, route]);
@@ -1002,6 +1125,7 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, onRout
1002
1125
  const headerTitle = header?.renderTitle?.(headerContext) ?? defaultTitle;
1003
1126
  const primaryHeaderActions = header?.renderActions?.(headerContext);
1004
1127
  const headerActionLabel = typeof headerTitle === "string" ? headerTitle : defaultTitle;
1128
+ const sidebarToggleLabel = sidebarCollapsed ? "Show sidebar" : "Collapse sidebar";
1005
1129
  const navigateFromSidebarHeader = (nextRoute: WebAppRoute) => {
1006
1130
  navigate(nextRoute);
1007
1131
  setSidebarOpen(false);
@@ -1024,7 +1148,7 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, onRout
1024
1148
  <div className="wapp-sidebar-actions">
1025
1149
  {topActions.map((action) => <IconButton key={action.id} className="wapp-sidebar-top-button" title={action.title} aria-label={action.title} onClick={() => runSidebarHeaderAction(action)}><ActionIcon icon={action.icon} /></IconButton>)}
1026
1150
  <IconButton className="wapp-sidebar-top-button" title="Settings" aria-label="Open settings" active={route.view === "settings"} onClick={() => navigateFromSidebarHeader({ view: "settings" })}><Icon name="settings" /></IconButton>
1027
- <IconButton className="wapp-sidebar-top-button" title="Collapse sidebar" aria-label="Collapse sidebar" onClick={() => { setSidebarCollapsed(true); setSidebarOpen(false); }}><Icon name="sidebar" /></IconButton>
1151
+ <IconButton className="wapp-sidebar-top-button" title={sidebarToggleLabel} aria-label={sidebarToggleLabel} onClick={toggleSidebarCollapsed}><Icon name="sidebar" /></IconButton>
1028
1152
  </div>
1029
1153
  </div>
1030
1154
  <div className="wapp-sidebar-scroll">
@@ -1036,7 +1160,7 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, onRout
1036
1160
  <section className="wapp-main">
1037
1161
  <header className="wapp-main-header">
1038
1162
  <div className="wapp-main-header-title">
1039
- {sidebarCollapsed ? <IconButton className="wapp-sidebar-top-button" aria-label="Show sidebar" title="Show sidebar" onClick={() => { setSidebarCollapsed(false); setSidebarOpen(true); }}><Icon name="sidebar" /></IconButton> : <IconButton className="wapp-mobile-only wapp-sidebar-top-button" aria-label="Show sidebar" title="Show sidebar" onClick={() => setSidebarOpen(true)}><Icon name="sidebar" /></IconButton>}
1163
+ {sidebarCollapsed ? <IconButton className="wapp-sidebar-top-button" aria-label={sidebarToggleLabel} title={sidebarToggleLabel} onClick={toggleSidebarCollapsed}><Icon name="sidebar" /></IconButton> : <IconButton className="wapp-mobile-only wapp-sidebar-top-button" aria-label="Show sidebar" title="Show sidebar" onClick={() => setSidebarOpen(true)}><Icon name="sidebar" /></IconButton>}
1040
1164
  <h1>{headerTitle}</h1>
1041
1165
  </div>
1042
1166
  {primaryHeaderActions || headerActions.length ? (
@@ -13,9 +13,29 @@
13
13
  --wapp-danger-bg: #991b1b;
14
14
  --wapp-primary: #111827;
15
15
  --wapp-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
16
+ --wapp-overlay-bg: rgb(0 0 0 / 0.5);
17
+ --wapp-overlay-blur: blur(4px);
18
+ --wapp-safe-area-top: env(safe-area-inset-top, 0px);
19
+ --wapp-safe-area-bottom: env(safe-area-inset-bottom, 0px);
20
+ --wapp-safe-area-left: env(safe-area-inset-left, 0px);
21
+ --wapp-safe-area-right: env(safe-area-inset-right, 0px);
22
+ --wapp-mobile-bottom-clearance: max(var(--wapp-safe-area-bottom), 0.75rem);
23
+ --wapp-viewport-height: 100vh;
16
24
  font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
17
25
  }
18
26
 
27
+ @supports (height: 100svh) {
28
+ :root {
29
+ --wapp-viewport-height: 100svh;
30
+ }
31
+ }
32
+
33
+ @supports (height: 100dvh) {
34
+ :root {
35
+ --wapp-viewport-height: 100dvh;
36
+ }
37
+ }
38
+
19
39
  :root.dark {
20
40
  color-scheme: dark;
21
41
  --wapp-bg: var(--wapp-shell-bg);
@@ -41,6 +61,7 @@ html,
41
61
  body,
42
62
  #root {
43
63
  height: 100%;
64
+ min-height: 100%;
44
65
  }
45
66
 
46
67
  body {
@@ -84,8 +105,8 @@ textarea::placeholder {
84
105
 
85
106
  .wapp-shell {
86
107
  display: flex;
87
- height: 100vh;
88
- min-height: 100vh;
108
+ height: var(--wapp-viewport-height);
109
+ min-height: var(--wapp-viewport-height);
89
110
  overflow: hidden;
90
111
  background: var(--wapp-bg);
91
112
  }
@@ -94,7 +115,7 @@ textarea::placeholder {
94
115
  width: 20rem;
95
116
  min-width: 20rem;
96
117
  min-height: 0;
97
- height: 100vh;
118
+ height: var(--wapp-viewport-height);
98
119
  display: flex;
99
120
  flex-direction: column;
100
121
  overflow: hidden;
@@ -258,6 +279,7 @@ textarea::placeholder {
258
279
  overflow-y: auto;
259
280
  overflow-x: hidden;
260
281
  padding: 1rem 0.75rem;
282
+ scroll-padding-bottom: var(--wapp-mobile-bottom-clearance);
261
283
  }
262
284
 
263
285
  .wapp-search input {
@@ -501,7 +523,7 @@ textarea::placeholder {
501
523
  align-items: center;
502
524
  justify-content: space-between;
503
525
  min-height: 2rem;
504
- padding: 0.25rem 0.25rem 0;
526
+ padding: 0.25rem 0.25rem var(--wapp-safe-area-bottom);
505
527
  color: var(--wapp-muted-2);
506
528
  font-size: 0.75rem;
507
529
  }
@@ -527,7 +549,7 @@ textarea::placeholder {
527
549
  flex: 1;
528
550
  min-width: 0;
529
551
  min-height: 0;
530
- height: 100vh;
552
+ height: var(--wapp-viewport-height);
531
553
  display: flex;
532
554
  flex-direction: column;
533
555
  }
@@ -551,12 +573,53 @@ textarea::placeholder {
551
573
  overflow: auto;
552
574
  overflow-x: hidden;
553
575
  padding: 0;
576
+ scroll-padding-bottom: var(--wapp-mobile-bottom-clearance);
554
577
  }
555
578
 
556
579
  .wapp-page {
557
580
  width: 100%;
558
581
  min-width: 0;
559
- padding: 1.5rem 2rem 2rem;
582
+ max-width: 100%;
583
+ padding: 1.5rem 2rem max(2rem, var(--wapp-mobile-bottom-clearance));
584
+ }
585
+
586
+ .wapp-page,
587
+ .wapp-panel,
588
+ .wapp-modal-body {
589
+ overflow-wrap: anywhere;
590
+ }
591
+
592
+ .wapp-page :where(*):not(table, thead, tbody, tfoot, tr, th, td),
593
+ .wapp-panel :where(*):not(table, thead, tbody, tfoot, tr, th, td),
594
+ .wapp-modal-body :where(*):not(table, thead, tbody, tfoot, tr, th, td) {
595
+ min-width: 0;
596
+ }
597
+
598
+ .wapp-page :where(img, video, canvas, iframe, svg),
599
+ .wapp-panel :where(img, video, canvas, iframe, svg),
600
+ .wapp-modal-body :where(img, video, canvas, iframe, svg) {
601
+ max-width: 100%;
602
+ }
603
+
604
+ .wapp-page :where(pre),
605
+ .wapp-panel :where(pre),
606
+ .wapp-modal-body :where(pre) {
607
+ max-width: 100%;
608
+ overflow-x: auto;
609
+ }
610
+
611
+ .wapp-page :where(table),
612
+ .wapp-panel :where(table),
613
+ .wapp-modal-body :where(table) {
614
+ display: block;
615
+ max-width: 100%;
616
+ overflow-x: auto;
617
+ }
618
+
619
+ .wapp-page :where(code, kbd, samp),
620
+ .wapp-panel :where(code, kbd, samp),
621
+ .wapp-modal-body :where(code, kbd, samp) {
622
+ overflow-wrap: anywhere;
560
623
  }
561
624
 
562
625
  .wapp-panel {
@@ -1488,7 +1551,8 @@ textarea::placeholder {
1488
1551
  .wapp-mobile-backdrop {
1489
1552
  position: fixed;
1490
1553
  inset: 0;
1491
- background: rgb(15 23 42 / 0.36);
1554
+ background: var(--wapp-overlay-bg);
1555
+ backdrop-filter: var(--wapp-overlay-blur);
1492
1556
  z-index: 50;
1493
1557
  }
1494
1558
 
@@ -1589,8 +1653,8 @@ textarea::placeholder {
1589
1653
  .wapp-modal-overlay {
1590
1654
  position: absolute;
1591
1655
  inset: 0;
1592
- background: rgb(0 0 0 / 0.5);
1593
- backdrop-filter: blur(4px);
1656
+ background: var(--wapp-overlay-bg);
1657
+ backdrop-filter: var(--wapp-overlay-blur);
1594
1658
  }
1595
1659
 
1596
1660
  .wapp-modal {
@@ -1599,7 +1663,7 @@ textarea::placeholder {
1599
1663
  flex-direction: column;
1600
1664
  width: 100%;
1601
1665
  min-width: 0;
1602
- max-height: calc(100dvh - 1.5rem);
1666
+ max-height: calc(var(--wapp-viewport-height) - 1.5rem - var(--wapp-safe-area-bottom));
1603
1667
  overflow: hidden;
1604
1668
  border: 1px solid var(--wapp-border);
1605
1669
  border-radius: 0.5rem;
@@ -1682,6 +1746,7 @@ textarea::placeholder {
1682
1746
  overflow-x: hidden;
1683
1747
  overflow-y: auto;
1684
1748
  padding: 0.75rem 1rem;
1749
+ scroll-padding-bottom: var(--wapp-mobile-bottom-clearance);
1685
1750
  }
1686
1751
 
1687
1752
  .wapp-modal-footer {
@@ -1692,7 +1757,7 @@ textarea::placeholder {
1692
1757
  justify-content: flex-end;
1693
1758
  gap: 0.5rem;
1694
1759
  border-top: 1px solid var(--wapp-border);
1695
- padding: 0.75rem 1rem;
1760
+ padding: 0.75rem 1rem max(0.75rem, var(--wapp-mobile-bottom-clearance));
1696
1761
  }
1697
1762
 
1698
1763
  @media (min-width: 640px) {
@@ -1701,7 +1766,7 @@ textarea::placeholder {
1701
1766
  }
1702
1767
 
1703
1768
  .wapp-modal {
1704
- max-height: calc(100dvh - 2.5rem);
1769
+ max-height: calc(var(--wapp-viewport-height) - 2.5rem - var(--wapp-safe-area-bottom));
1705
1770
  }
1706
1771
 
1707
1772
  .wapp-modal-header {
@@ -1776,7 +1841,7 @@ textarea::placeholder {
1776
1841
  }
1777
1842
 
1778
1843
  .wapp-page {
1779
- padding: 1rem;
1844
+ padding: 1rem 1rem max(1rem, var(--wapp-mobile-bottom-clearance));
1780
1845
  }
1781
1846
 
1782
1847
  .wapp-panel-header,