@pablozaiden/webapp 0.3.1 → 0.3.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.
@@ -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.1",
3
+ "version": "0.3.3",
4
4
  "description": "Opinionated Bun + React webapp framework for single-server apps",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -102,6 +102,87 @@ function useRoute(defaultRoute: WebAppRoute) {
102
102
  return { route, navigate };
103
103
  }
104
104
 
105
+ function useMobileViewportHeight() {
106
+ useEffect(() => {
107
+ if (typeof window === "undefined") {
108
+ return;
109
+ }
110
+
111
+ const root = document.documentElement;
112
+ const mobileQuery = window.matchMedia("(max-width: 900px)");
113
+ const viewport = window.visualViewport;
114
+ const timers = new Set<number>();
115
+ let frame = 0;
116
+
117
+ const clearViewportHeight = () => {
118
+ root.style.removeProperty("--wapp-viewport-height");
119
+ };
120
+
121
+ const sync = () => {
122
+ frame = 0;
123
+ if (!mobileQuery.matches) {
124
+ clearViewportHeight();
125
+ return;
126
+ }
127
+
128
+ const height = Math.round(viewport?.height ?? window.innerHeight);
129
+ if (height > 0) {
130
+ root.style.setProperty("--wapp-viewport-height", `${height}px`);
131
+ }
132
+
133
+ const scrollingElement = document.scrollingElement;
134
+ if (scrollingElement && scrollingElement.scrollTop !== 0) {
135
+ scrollingElement.scrollTop = 0;
136
+ }
137
+ };
138
+
139
+ const scheduleSync = () => {
140
+ if (frame) {
141
+ return;
142
+ }
143
+ frame = requestAnimationFrame(sync);
144
+ };
145
+
146
+ const scheduleDelayedSync = (delay: number) => {
147
+ const timer = window.setTimeout(() => {
148
+ timers.delete(timer);
149
+ scheduleSync();
150
+ }, delay);
151
+ timers.add(timer);
152
+ };
153
+
154
+ const handleKeyboardBoundary = () => {
155
+ scheduleSync();
156
+ scheduleDelayedSync(120);
157
+ scheduleDelayedSync(320);
158
+ };
159
+
160
+ scheduleSync();
161
+ viewport?.addEventListener("resize", scheduleSync);
162
+ viewport?.addEventListener("scroll", scheduleSync);
163
+ window.addEventListener("resize", scheduleSync);
164
+ mobileQuery.addEventListener("change", scheduleSync);
165
+ document.addEventListener("focusin", handleKeyboardBoundary);
166
+ document.addEventListener("focusout", handleKeyboardBoundary);
167
+
168
+ return () => {
169
+ if (frame) {
170
+ cancelAnimationFrame(frame);
171
+ }
172
+ for (const timer of timers) {
173
+ clearTimeout(timer);
174
+ }
175
+ viewport?.removeEventListener("resize", scheduleSync);
176
+ viewport?.removeEventListener("scroll", scheduleSync);
177
+ window.removeEventListener("resize", scheduleSync);
178
+ mobileQuery.removeEventListener("change", scheduleSync);
179
+ document.removeEventListener("focusin", handleKeyboardBoundary);
180
+ document.removeEventListener("focusout", handleKeyboardBoundary);
181
+ clearViewportHeight();
182
+ };
183
+ }, []);
184
+ }
185
+
105
186
  async function json<T>(path: string, init: RequestInit = {}): Promise<T> {
106
187
  const response = await fetch(path, {
107
188
  ...init,
@@ -892,6 +973,7 @@ function SettingsView({ config, refresh, customSections, theme, setTheme }: { co
892
973
  }
893
974
 
894
975
  export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, onRouteChange, settings, version }: WebAppRootProps) {
976
+ useMobileViewportHeight();
895
977
  const { config, error, refresh } = useConfig();
896
978
  const { route, navigate } = useRoute(homeRoute);
897
979
  const { theme, setTheme } = useTheme();
@@ -1002,16 +1084,28 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, onRout
1002
1084
  const headerTitle = header?.renderTitle?.(headerContext) ?? defaultTitle;
1003
1085
  const primaryHeaderActions = header?.renderActions?.(headerContext);
1004
1086
  const headerActionLabel = typeof headerTitle === "string" ? headerTitle : defaultTitle;
1087
+ const navigateFromSidebarHeader = (nextRoute: WebAppRoute) => {
1088
+ navigate(nextRoute);
1089
+ setSidebarOpen(false);
1090
+ };
1091
+ const runSidebarHeaderAction = (action: SidebarAction) => {
1092
+ if (action.onAction) {
1093
+ action.onAction();
1094
+ } else if (action.route) {
1095
+ navigate(action.route);
1096
+ }
1097
+ setSidebarOpen(false);
1098
+ };
1005
1099
 
1006
1100
  return (
1007
1101
  <main className={`wapp-shell ${sidebarCollapsed ? "sidebar-collapsed" : ""} ${sidebarOpen ? "sidebar-open" : ""}`}>
1008
1102
  <div className="wapp-mobile-backdrop" onClick={() => setSidebarOpen(false)} />
1009
1103
  <aside className="wapp-sidebar">
1010
1104
  <div className="wapp-sidebar-header">
1011
- <button type="button" className="wapp-brand" onClick={() => navigate(homeRoute)}>{appName}</button>
1105
+ <button type="button" className="wapp-brand" onClick={() => navigateFromSidebarHeader(homeRoute)}>{appName}</button>
1012
1106
  <div className="wapp-sidebar-actions">
1013
- {topActions.map((action) => <IconButton key={action.id} className="wapp-sidebar-top-button" title={action.title} aria-label={action.title} onClick={action.onAction ?? (() => action.route && navigate(action.route))}><ActionIcon icon={action.icon} /></IconButton>)}
1014
- <IconButton className="wapp-sidebar-top-button" title="Settings" aria-label="Open settings" active={route.view === "settings"} onClick={() => navigate({ view: "settings" })}><Icon name="settings" /></IconButton>
1107
+ {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>)}
1108
+ <IconButton className="wapp-sidebar-top-button" title="Settings" aria-label="Open settings" active={route.view === "settings"} onClick={() => navigateFromSidebarHeader({ view: "settings" })}><Icon name="settings" /></IconButton>
1015
1109
  <IconButton className="wapp-sidebar-top-button" title="Collapse sidebar" aria-label="Collapse sidebar" onClick={() => { setSidebarCollapsed(true); setSidebarOpen(false); }}><Icon name="sidebar" /></IconButton>
1016
1110
  </div>
1017
1111
  </div>
@@ -13,9 +13,27 @@
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-safe-area-top: env(safe-area-inset-top, 0px);
17
+ --wapp-safe-area-bottom: env(safe-area-inset-bottom, 0px);
18
+ --wapp-safe-area-left: env(safe-area-inset-left, 0px);
19
+ --wapp-safe-area-right: env(safe-area-inset-right, 0px);
20
+ --wapp-mobile-bottom-clearance: max(var(--wapp-safe-area-bottom), 0.75rem);
21
+ --wapp-viewport-height: 100vh;
16
22
  font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
17
23
  }
18
24
 
25
+ @supports (height: 100svh) {
26
+ :root {
27
+ --wapp-viewport-height: 100svh;
28
+ }
29
+ }
30
+
31
+ @supports (height: 100dvh) {
32
+ :root {
33
+ --wapp-viewport-height: 100dvh;
34
+ }
35
+ }
36
+
19
37
  :root.dark {
20
38
  color-scheme: dark;
21
39
  --wapp-bg: var(--wapp-shell-bg);
@@ -41,6 +59,7 @@ html,
41
59
  body,
42
60
  #root {
43
61
  height: 100%;
62
+ min-height: 100%;
44
63
  }
45
64
 
46
65
  body {
@@ -84,8 +103,8 @@ textarea::placeholder {
84
103
 
85
104
  .wapp-shell {
86
105
  display: flex;
87
- height: 100vh;
88
- min-height: 100vh;
106
+ height: var(--wapp-viewport-height);
107
+ min-height: var(--wapp-viewport-height);
89
108
  overflow: hidden;
90
109
  background: var(--wapp-bg);
91
110
  }
@@ -94,7 +113,7 @@ textarea::placeholder {
94
113
  width: 20rem;
95
114
  min-width: 20rem;
96
115
  min-height: 0;
97
- height: 100vh;
116
+ height: var(--wapp-viewport-height);
98
117
  display: flex;
99
118
  flex-direction: column;
100
119
  overflow: hidden;
@@ -258,6 +277,7 @@ textarea::placeholder {
258
277
  overflow-y: auto;
259
278
  overflow-x: hidden;
260
279
  padding: 1rem 0.75rem;
280
+ scroll-padding-bottom: var(--wapp-mobile-bottom-clearance);
261
281
  }
262
282
 
263
283
  .wapp-search input {
@@ -501,7 +521,7 @@ textarea::placeholder {
501
521
  align-items: center;
502
522
  justify-content: space-between;
503
523
  min-height: 2rem;
504
- padding: 0.25rem 0.25rem 0;
524
+ padding: 0.25rem 0.25rem var(--wapp-safe-area-bottom);
505
525
  color: var(--wapp-muted-2);
506
526
  font-size: 0.75rem;
507
527
  }
@@ -527,7 +547,7 @@ textarea::placeholder {
527
547
  flex: 1;
528
548
  min-width: 0;
529
549
  min-height: 0;
530
- height: 100vh;
550
+ height: var(--wapp-viewport-height);
531
551
  display: flex;
532
552
  flex-direction: column;
533
553
  }
@@ -551,12 +571,53 @@ textarea::placeholder {
551
571
  overflow: auto;
552
572
  overflow-x: hidden;
553
573
  padding: 0;
574
+ scroll-padding-bottom: var(--wapp-mobile-bottom-clearance);
554
575
  }
555
576
 
556
577
  .wapp-page {
557
578
  width: 100%;
558
579
  min-width: 0;
559
- padding: 1.5rem 2rem 2rem;
580
+ max-width: 100%;
581
+ padding: 1.5rem 2rem max(2rem, var(--wapp-mobile-bottom-clearance));
582
+ }
583
+
584
+ .wapp-page,
585
+ .wapp-panel,
586
+ .wapp-modal-body {
587
+ overflow-wrap: anywhere;
588
+ }
589
+
590
+ .wapp-page :where(*):not(table, thead, tbody, tfoot, tr, th, td),
591
+ .wapp-panel :where(*):not(table, thead, tbody, tfoot, tr, th, td),
592
+ .wapp-modal-body :where(*):not(table, thead, tbody, tfoot, tr, th, td) {
593
+ min-width: 0;
594
+ }
595
+
596
+ .wapp-page :where(img, video, canvas, iframe, svg),
597
+ .wapp-panel :where(img, video, canvas, iframe, svg),
598
+ .wapp-modal-body :where(img, video, canvas, iframe, svg) {
599
+ max-width: 100%;
600
+ }
601
+
602
+ .wapp-page :where(pre),
603
+ .wapp-panel :where(pre),
604
+ .wapp-modal-body :where(pre) {
605
+ max-width: 100%;
606
+ overflow-x: auto;
607
+ }
608
+
609
+ .wapp-page :where(table),
610
+ .wapp-panel :where(table),
611
+ .wapp-modal-body :where(table) {
612
+ display: block;
613
+ max-width: 100%;
614
+ overflow-x: auto;
615
+ }
616
+
617
+ .wapp-page :where(code, kbd, samp),
618
+ .wapp-panel :where(code, kbd, samp),
619
+ .wapp-modal-body :where(code, kbd, samp) {
620
+ overflow-wrap: anywhere;
560
621
  }
561
622
 
562
623
  .wapp-panel {
@@ -1599,7 +1660,7 @@ textarea::placeholder {
1599
1660
  flex-direction: column;
1600
1661
  width: 100%;
1601
1662
  min-width: 0;
1602
- max-height: calc(100dvh - 1.5rem);
1663
+ max-height: calc(var(--wapp-viewport-height) - 1.5rem - var(--wapp-safe-area-bottom));
1603
1664
  overflow: hidden;
1604
1665
  border: 1px solid var(--wapp-border);
1605
1666
  border-radius: 0.5rem;
@@ -1682,6 +1743,7 @@ textarea::placeholder {
1682
1743
  overflow-x: hidden;
1683
1744
  overflow-y: auto;
1684
1745
  padding: 0.75rem 1rem;
1746
+ scroll-padding-bottom: var(--wapp-mobile-bottom-clearance);
1685
1747
  }
1686
1748
 
1687
1749
  .wapp-modal-footer {
@@ -1692,7 +1754,7 @@ textarea::placeholder {
1692
1754
  justify-content: flex-end;
1693
1755
  gap: 0.5rem;
1694
1756
  border-top: 1px solid var(--wapp-border);
1695
- padding: 0.75rem 1rem;
1757
+ padding: 0.75rem 1rem max(0.75rem, var(--wapp-mobile-bottom-clearance));
1696
1758
  }
1697
1759
 
1698
1760
  @media (min-width: 640px) {
@@ -1701,7 +1763,7 @@ textarea::placeholder {
1701
1763
  }
1702
1764
 
1703
1765
  .wapp-modal {
1704
- max-height: calc(100dvh - 2.5rem);
1766
+ max-height: calc(var(--wapp-viewport-height) - 2.5rem - var(--wapp-safe-area-bottom));
1705
1767
  }
1706
1768
 
1707
1769
  .wapp-modal-header {
@@ -1776,7 +1838,7 @@ textarea::placeholder {
1776
1838
  }
1777
1839
 
1778
1840
  .wapp-page {
1779
- padding: 1rem;
1841
+ padding: 1rem 1rem max(1rem, var(--wapp-mobile-bottom-clearance));
1780
1842
  }
1781
1843
 
1782
1844
  .wapp-panel-header,