@pablozaiden/webapp 0.5.7 → 0.6.0

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.
Files changed (56) hide show
  1. package/README.md +11 -3
  2. package/docs/auth-validation.md +11 -0
  3. package/docs/auth.md +25 -0
  4. package/docs/cli.md +73 -3
  5. package/docs/deployment.md +84 -7
  6. package/docs/getting-started.md +69 -6
  7. package/docs/github-actions.md +39 -0
  8. package/docs/release.md +5 -5
  9. package/docs/server.md +34 -4
  10. package/docs/settings.md +5 -0
  11. package/docs/sidebar.md +2 -0
  12. package/docs/ui-guidelines.md +12 -4
  13. package/package.json +2 -4
  14. package/src/build/build-binary.ts +67 -24
  15. package/src/cli/api-command.ts +53 -16
  16. package/src/cli/credentials.ts +275 -4
  17. package/src/cli/device-auth.ts +83 -22
  18. package/src/cli/environment-auth.ts +57 -0
  19. package/src/cli/index.ts +1 -0
  20. package/src/package-resolution.ts +34 -0
  21. package/src/server/auth/device-auth.ts +2 -2
  22. package/src/server/auth/passkeys.ts +16 -16
  23. package/src/server/auth/request-origin.ts +97 -16
  24. package/src/server/authentication.ts +265 -0
  25. package/src/server/create-web-app-server.ts +85 -1391
  26. package/src/server/framework-endpoints.ts +451 -0
  27. package/src/server/public-route-dispatch.ts +85 -0
  28. package/src/server/request-schemas.ts +144 -0
  29. package/src/server/responses.ts +69 -3
  30. package/src/server/route-dispatch.ts +109 -0
  31. package/src/server/runtime-config.ts +67 -0
  32. package/src/server/same-origin.ts +1 -1
  33. package/src/server/server-lifecycle.ts +138 -0
  34. package/src/server/server-types.ts +87 -0
  35. package/src/server/web-document.ts +532 -0
  36. package/src/web/WebAppRoot.tsx +69 -1132
  37. package/src/web/api-client.ts +14 -4
  38. package/src/web/app-shell.tsx +198 -0
  39. package/src/web/auth-screens.tsx +186 -0
  40. package/src/web/components/index.tsx +10 -3
  41. package/src/web/index.ts +1 -0
  42. package/src/web/mobile-hooks.ts +194 -0
  43. package/src/web/mobile.ts +12 -0
  44. package/src/web/root-types.ts +61 -0
  45. package/src/web/routing.ts +61 -0
  46. package/src/web/settings/account-section.tsx +52 -0
  47. package/src/web/settings/resource-state.tsx +17 -0
  48. package/src/web/settings/security-section.tsx +119 -0
  49. package/src/web/settings/sessions-section.tsx +66 -0
  50. package/src/web/settings/settings-view.tsx +96 -0
  51. package/src/web/settings/shutdown-section.tsx +95 -0
  52. package/src/web/settings/user-management.tsx +123 -0
  53. package/src/web/settings-view.tsx +3 -0
  54. package/src/web/sidebar-state.ts +108 -0
  55. package/src/web/sidebar-tree.tsx +89 -0
  56. package/src/web/styles.css +78 -61
@@ -0,0 +1,123 @@
1
+ import { useCallback, useState } from "react";
2
+ import type { CreatedUserResponse, WebAppConfigResponse, WebAppUserRole, WebAppUserSummary } from "../../contracts";
3
+ import { appJson } from "../api-client";
4
+ import { Badge, Button, ConfirmDialog, EmptyState, FormSection, SelectField, TextField } from "../components";
5
+ import { useLiveQuery } from "../realtime/useRealtime";
6
+ import { ResourceState } from "./resource-state";
7
+
8
+ export function UserManagement({ config }: { config: WebAppConfigResponse }) {
9
+ const [username, setUsername] = useState("");
10
+ const [role, setRole] = useState<WebAppUserRole>("user");
11
+ const [setupLink, setSetupLink] = useState<string>();
12
+ const [userToDelete, setUserToDelete] = useState<WebAppUserSummary>();
13
+ const [error, setError] = useState<string>();
14
+
15
+ const loadUsers = useCallback(
16
+ () => config.userManagement.canManageUsers
17
+ ? appJson<WebAppUserSummary[]>("/api/users")
18
+ : Promise.resolve([]),
19
+ [config.userManagement.canManageUsers],
20
+ );
21
+ const { data: users, error: usersLoadError, loading: usersLoading, refresh: refreshUsers } = useLiveQuery<WebAppUserSummary[]>({ load: loadUsers, realtime: false });
22
+
23
+ async function createUser() {
24
+ try {
25
+ setError(undefined);
26
+ const result = await appJson<CreatedUserResponse>("/api/users", { method: "POST", body: JSON.stringify({ username, role }) });
27
+ setUsername("");
28
+ setRole("user");
29
+ setSetupLink(result.setupLink.url);
30
+ await refreshUsers();
31
+ } catch (err) {
32
+ setError(err instanceof Error ? err.message : String(err));
33
+ }
34
+ }
35
+
36
+ async function updateRole(user: WebAppUserSummary, nextRole: WebAppUserRole) {
37
+ try {
38
+ setError(undefined);
39
+ await appJson(`/api/users/${encodeURIComponent(user.id)}/role`, { method: "PATCH", body: JSON.stringify({ role: nextRole }) });
40
+ await refreshUsers();
41
+ } catch (err) {
42
+ setError(err instanceof Error ? err.message : String(err));
43
+ }
44
+ }
45
+
46
+ async function resetUser(user: WebAppUserSummary) {
47
+ try {
48
+ setError(undefined);
49
+ const result = await appJson<CreatedUserResponse>(`/api/users/${encodeURIComponent(user.id)}/reset`, { method: "POST", body: "{}" });
50
+ setSetupLink(result.setupLink.url);
51
+ await refreshUsers();
52
+ } catch (err) {
53
+ setError(err instanceof Error ? err.message : String(err));
54
+ }
55
+ }
56
+
57
+ async function deleteUser(user: WebAppUserSummary) {
58
+ try {
59
+ setError(undefined);
60
+ await appJson(`/api/users/${encodeURIComponent(user.id)}`, { method: "DELETE" });
61
+ setUserToDelete(undefined);
62
+ await refreshUsers();
63
+ } catch (err) {
64
+ setError(err instanceof Error ? err.message : String(err));
65
+ }
66
+ }
67
+
68
+ if (!config.userManagement.canManageUsers) {
69
+ return null;
70
+ }
71
+ return (
72
+ <FormSection title="User management" description="Create users, reset passkeys, and manage admin access.">
73
+ {error ? <p className="wapp-error">{error}</p> : null}
74
+ <br />
75
+ <div className="wapp-settings-row stacked">
76
+ <div>
77
+ <TextField label="Username" value={username} onChange={(event) => setUsername(event.currentTarget.value)} placeholder="new-user" />
78
+ <br />
79
+ <SelectField label="Role" value={role} onChange={(event) => setRole(event.currentTarget.value as WebAppUserRole)}>
80
+ <option value="user">User</option>
81
+ <option value="admin">Admin</option>
82
+ </SelectField>
83
+ </div>
84
+ <br />
85
+ <div className="wapp-row-actions"><Button type="button" variant="primary" disabled={!username.trim()} onClick={() => void createUser()}>Create setup link</Button></div>
86
+ {setupLink ? <code className="wapp-token">{setupLink}</code> : null}
87
+ </div>
88
+ <br />
89
+ <ResourceState loading={usersLoading} error={usersLoadError} hasData={users !== undefined} refresh={refreshUsers} />
90
+ {users?.length ? (
91
+ <div className="wapp-list">
92
+ {users.map((user) => (
93
+ <div className="wapp-list-row" key={user.id}>
94
+ <span>
95
+ <strong>{user.username}</strong>
96
+ <small>{user.role} · passkey {user.passkeyConfigured ? "configured" : "pending"} · created {user.createdAt}</small>
97
+ </span>
98
+ <div className="wapp-row-actions">
99
+ {user.role !== "owner" ? (
100
+ <select className="wapp-inline-select" aria-label={`Role for ${user.username}`} value={user.role} onChange={(event) => void updateRole(user, event.currentTarget.value as WebAppUserRole)}>
101
+ <option value="user">User</option>
102
+ <option value="admin">Admin</option>
103
+ </select>
104
+ ) : <Badge variant="success">Owner</Badge>}
105
+ {user.role !== "owner" ? <Button type="button" onClick={() => void resetUser(user)}>Reset</Button> : null}
106
+ <Button type="button" variant="danger" disabled={user.role === "owner"} onClick={() => setUserToDelete(user)}>Delete</Button>
107
+ </div>
108
+ </div>
109
+ ))}
110
+ </div>
111
+ ) : users !== undefined && !usersLoadError ? <EmptyState title="No users" /> : null}
112
+ <ConfirmDialog
113
+ open={Boolean(userToDelete)}
114
+ title="Delete user?"
115
+ message={userToDelete ? `This permanently deletes "${userToDelete.username}" and revokes their setup links, API keys, passkeys and device sessions.` : ""}
116
+ confirmLabel="Delete user"
117
+ danger
118
+ onCancel={() => setUserToDelete(undefined)}
119
+ onConfirm={() => userToDelete && void deleteUser(userToDelete)}
120
+ />
121
+ </FormSection>
122
+ );
123
+ }
@@ -0,0 +1,3 @@
1
+ export { SettingsView } from "./settings/settings-view";
2
+ export type { SettingsViewProps } from "./settings/settings-view";
3
+ export type { SettingsAction, SettingsRow, SettingsSection } from "./root-types";
@@ -0,0 +1,108 @@
1
+ import { useCallback, useEffect, useMemo, useState } from "react";
2
+ import type { SidebarNode, WebAppRoute } from "./sidebar/types";
3
+
4
+ export type StoredSidebarPin = {
5
+ id: string;
6
+ title: string;
7
+ subtitle?: string;
8
+ badge?: string;
9
+ badgeVariant?: SidebarNode["badgeVariant"];
10
+ route: WebAppRoute;
11
+ };
12
+
13
+ export type SidebarCollapsedState = Record<string, boolean>;
14
+
15
+ export function flattenSidebarItems(nodes: SidebarNode[]): SidebarNode[] {
16
+ return nodes.flatMap((node) => [
17
+ node,
18
+ ...(node.children ? flattenSidebarItems(node.children) : []),
19
+ ]).filter((node) => node.type === "item");
20
+ }
21
+
22
+ function pinStorageKey(appName: string, explicitKey?: string): string {
23
+ return explicitKey ?? `webapp.${appName.toLowerCase().replace(/[^a-z0-9]+/g, "-")}.sidebar.pins`;
24
+ }
25
+
26
+ function sidebarCollapsedStorageKey(appName: string): string {
27
+ return `webapp.${appName.toLowerCase().replace(/[^a-z0-9]+/g, "-")}.sidebar.collapsed`;
28
+ }
29
+
30
+ function isSidebarCollapsedState(value: unknown): value is SidebarCollapsedState {
31
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
32
+ return false;
33
+ }
34
+ return Object.values(value).every((entry) => typeof entry === "boolean");
35
+ }
36
+
37
+ export function toStoredPin(node: SidebarNode): StoredSidebarPin | undefined {
38
+ if (!node.route) {
39
+ return undefined;
40
+ }
41
+ return {
42
+ id: node.pinId ?? node.id,
43
+ title: node.title,
44
+ subtitle: node.subtitle,
45
+ badge: node.badge,
46
+ badgeVariant: node.badgeVariant,
47
+ route: node.route,
48
+ };
49
+ }
50
+
51
+ export function useSidebarPins(appName: string, storageKey?: string) {
52
+ const key = pinStorageKey(appName, storageKey);
53
+ const [pins, setPins] = useState<StoredSidebarPin[]>(() => {
54
+ try {
55
+ const raw = localStorage.getItem(key);
56
+ return raw ? JSON.parse(raw) as StoredSidebarPin[] : [];
57
+ } catch {
58
+ return [];
59
+ }
60
+ });
61
+
62
+ useEffect(() => {
63
+ localStorage.setItem(key, JSON.stringify(pins));
64
+ }, [key, pins]);
65
+
66
+ const pinIds = useMemo(() => new Set(pins.map((pin) => pin.id)), [pins]);
67
+ const pin = useCallback((node: SidebarNode) => {
68
+ const stored = toStoredPin(node);
69
+ if (!stored) {
70
+ return;
71
+ }
72
+ setPins((current) => [...current.filter((item) => item.id !== stored.id), stored]);
73
+ }, []);
74
+ const unpin = useCallback((id: string) => {
75
+ setPins((current) => current.filter((item) => item.id !== id));
76
+ }, []);
77
+
78
+ return { pins, pinIds, pin, unpin };
79
+ }
80
+
81
+ export function useSidebarCollapsedState(appName: string) {
82
+ const key = sidebarCollapsedStorageKey(appName);
83
+ const [collapsed, setCollapsed] = useState<SidebarCollapsedState>(() => {
84
+ try {
85
+ const raw = localStorage.getItem(key);
86
+ if (!raw) {
87
+ return {};
88
+ }
89
+ const parsed: unknown = JSON.parse(raw);
90
+ return isSidebarCollapsedState(parsed) ? parsed : {};
91
+ } catch {
92
+ return {};
93
+ }
94
+ });
95
+
96
+ useEffect(() => {
97
+ localStorage.setItem(key, JSON.stringify(collapsed));
98
+ }, [key, collapsed]);
99
+
100
+ const toggleCollapsed = useCallback((id: string, isCollapsed: boolean) => {
101
+ setCollapsed((current) => {
102
+ const currentIsCollapsed = current[id] ?? isCollapsed;
103
+ return { ...current, [id]: !currentIsCollapsed };
104
+ });
105
+ }, []);
106
+
107
+ return { collapsed, toggleCollapsed };
108
+ }
@@ -0,0 +1,89 @@
1
+ import { useState } from "react";
2
+ import { Badge, ContextMenu, type ContextMenuPosition } from "./components";
3
+ import type { SidebarCollapsedState } from "./sidebar-state";
4
+ import type { ActionMenuItem, SidebarNode, WebAppRoute } from "./sidebar/types";
5
+
6
+ type SidebarTreeParentKind = "root" | "section" | "item";
7
+
8
+ export type SidebarTreeProps = {
9
+ nodes: SidebarNode[];
10
+ route: WebAppRoute;
11
+ navigate: (route: WebAppRoute) => void;
12
+ collapsed: SidebarCollapsedState;
13
+ toggleCollapsed: (id: string, isCollapsed: boolean) => void;
14
+ searchActive: boolean;
15
+ level?: number;
16
+ parentKind?: SidebarTreeParentKind;
17
+ };
18
+
19
+ function sidebarIndentStyle(level: number, parentKind: SidebarTreeParentKind): { marginLeft?: string } | undefined {
20
+ if (level <= 0) {
21
+ return undefined;
22
+ }
23
+ const baseIndentRem = level * 0.375;
24
+ const nestedSectionIndentRem = parentKind === "section" && level > 1 ? 0.875 : 0;
25
+ return { marginLeft: `${baseIndentRem + nestedSectionIndentRem}rem` };
26
+ }
27
+
28
+ export function SidebarTree({ nodes, route, navigate, collapsed, toggleCollapsed, searchActive, level = 0, parentKind = "root" }: SidebarTreeProps) {
29
+ const [contextMenu, setContextMenu] = useState<{ position: ContextMenuPosition; items: ActionMenuItem[]; title: string } | null>(null);
30
+ return (
31
+ <>
32
+ {nodes.map((node) => {
33
+ const hasChildren = Boolean(node.children?.length);
34
+ const storedIsCollapsed = collapsed[node.id] ?? node.defaultCollapsed ?? false;
35
+ const isCollapsed = searchActive && hasChildren ? false : storedIsCollapsed;
36
+ const toggleAriaLabel = searchActive ? `Toggling unavailable during search for ${node.title}` : `${isCollapsed ? "Expand" : "Collapse"} ${node.title}`;
37
+ const toggleNodeCollapsed = () => {
38
+ if (!searchActive) {
39
+ toggleCollapsed(node.id, storedIsCollapsed);
40
+ }
41
+ };
42
+ if (node.type === "section") {
43
+ return (
44
+ <section className={`wapp-sidebar-section ${level === 0 ? "top" : "nested"}`} key={node.id}>
45
+ <div className="wapp-sidebar-section-title" style={sidebarIndentStyle(level, parentKind)}>
46
+ {hasChildren ? (
47
+ <button type="button" aria-expanded={!isCollapsed} aria-label={toggleAriaLabel} disabled={searchActive} onClick={toggleNodeCollapsed}>
48
+ <span>{isCollapsed ? "▶" : "▼"}</span>{node.title}
49
+ </button>
50
+ ) : (
51
+ <div className="wapp-sidebar-section-label">{node.title}</div>
52
+ )}
53
+ {node.action ? <button type="button" className="wapp-sidebar-action" title={node.action.title} aria-label={node.action.title} onClick={node.action.onAction ?? (() => node.action?.route && navigate(node.action.route))}>{node.action.label ?? "New"}</button> : null}
54
+ </div>
55
+ {!isCollapsed && hasChildren ? <SidebarTree nodes={node.children ?? []} route={route} navigate={navigate} collapsed={collapsed} toggleCollapsed={toggleCollapsed} searchActive={searchActive} level={level + 1} parentKind="section" /> : null}
56
+ {!isCollapsed && !hasChildren && level === 0 ? <div className="wapp-sidebar-empty">No items.</div> : null}
57
+ </section>
58
+ );
59
+ }
60
+ const active = node.route?.view === route.view && Object.entries(node.route).every(([key, value]) => key === "view" || route[key] === value);
61
+ return (
62
+ <div className={`wapp-sidebar-item-wrap ${hasChildren ? "has-toggle" : ""}`} key={node.id} style={sidebarIndentStyle(level, parentKind)}>
63
+ {hasChildren ? <button type="button" className="wapp-tree-toggle" aria-expanded={!isCollapsed} aria-label={toggleAriaLabel} disabled={searchActive} onClick={toggleNodeCollapsed}>{isCollapsed ? "▶" : "▼"}</button> : null}
64
+ <button
65
+ type="button"
66
+ className={`wapp-sidebar-item ${active ? "active" : ""}`}
67
+ onClick={() => node.route && navigate(node.route)}
68
+ onContextMenu={(event) => {
69
+ if (!node.actions?.length) {
70
+ return;
71
+ }
72
+ event.preventDefault();
73
+ setContextMenu({ position: { x: event.clientX, y: event.clientY }, items: node.actions, title: node.title });
74
+ }}
75
+ >
76
+ <span>
77
+ <strong>{node.title}</strong>
78
+ {node.subtitle ? <small>{node.subtitle}</small> : null}
79
+ </span>
80
+ {node.badge ? <Badge variant={node.badgeVariant} className="wapp-sidebar-badge" title={node.badge} aria-label={node.badge}> </Badge> : null}
81
+ </button>
82
+ {!isCollapsed && node.children ? <div className="wapp-sidebar-children"><SidebarTree nodes={node.children} route={route} navigate={navigate} collapsed={collapsed} toggleCollapsed={toggleCollapsed} searchActive={searchActive} level={level + 1} parentKind="item" /></div> : null}
83
+ </div>
84
+ );
85
+ })}
86
+ <ContextMenu items={contextMenu?.items ?? []} position={contextMenu?.position ?? null} ariaLabel={contextMenu ? `Actions for ${contextMenu.title}` : "Actions"} onClose={() => setContextMenu(null)} />
87
+ </>
88
+ );
89
+ }
@@ -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
 
@@ -620,6 +621,15 @@ textarea::placeholder {
620
621
  padding: 1.5rem 2rem max(2rem, var(--wapp-mobile-bottom-clearance));
621
622
  }
622
623
 
624
+ .wapp-page-full {
625
+ display: flex;
626
+ flex-direction: column;
627
+ flex: 1 1 auto;
628
+ min-height: 0;
629
+ overflow: hidden;
630
+ padding: 0;
631
+ }
632
+
623
633
  .wapp-page,
624
634
  .wapp-panel,
625
635
  .wapp-modal-body {
@@ -1867,81 +1877,88 @@ textarea::placeholder {
1867
1877
  display: none;
1868
1878
  }
1869
1879
 
1870
- @media (max-width: 900px) {
1871
- .wapp-shell {
1872
- display: block;
1873
- }
1880
+ /* WebAppRoot owns the mobile state so CSS and browser behavior use one breakpoint. */
1881
+ :root[data-wapp-mobile] .wapp-shell {
1882
+ display: block;
1883
+ }
1874
1884
 
1875
- .wapp-sidebar,
1876
- .wapp-shell.sidebar-collapsed .wapp-sidebar {
1877
- position: fixed;
1878
- inset: 0 auto 0 0;
1879
- z-index: 60;
1880
- width: min(20rem, 86vw);
1881
- min-width: min(20rem, 86vw);
1882
- transform: translateX(-100%);
1883
- opacity: 1;
1884
- pointer-events: auto;
1885
- }
1885
+ :root[data-wapp-mobile] .wapp-sidebar,
1886
+ :root[data-wapp-mobile] .wapp-shell.sidebar-collapsed .wapp-sidebar {
1887
+ position: fixed;
1888
+ inset: 0 auto 0 0;
1889
+ z-index: 60;
1890
+ width: min(20rem, 86vw);
1891
+ min-width: min(20rem, 86vw);
1892
+ transform: translateX(-100%);
1893
+ opacity: 1;
1894
+ pointer-events: auto;
1895
+ }
1886
1896
 
1887
- .wapp-shell.sidebar-open .wapp-sidebar {
1888
- transform: translateX(0);
1889
- }
1897
+ :root[data-wapp-mobile] .wapp-shell.sidebar-open .wapp-sidebar {
1898
+ transform: translateX(0);
1899
+ }
1890
1900
 
1891
- .wapp-shell.sidebar-open .wapp-mobile-backdrop {
1892
- display: block;
1893
- }
1901
+ :root[data-wapp-mobile] .wapp-shell.sidebar-open .wapp-mobile-backdrop {
1902
+ display: block;
1903
+ }
1894
1904
 
1895
- .wapp-main-header {
1896
- padding: 0.875rem 1rem;
1897
- }
1905
+ :root[data-wapp-mobile] .wapp-main-header {
1906
+ padding: 0.875rem 1rem;
1907
+ }
1898
1908
 
1899
- .wapp-main-content {
1900
- overflow: auto;
1901
- overflow-x: hidden;
1902
- }
1909
+ :root[data-wapp-mobile] .wapp-main {
1910
+ touch-action: pan-y;
1911
+ }
1903
1912
 
1904
- .wapp-page {
1905
- padding: 1rem 1rem max(1rem, var(--wapp-mobile-bottom-clearance));
1906
- }
1913
+ :root[data-wapp-mobile] .wapp-main-content {
1914
+ overflow: auto;
1915
+ overflow-x: hidden;
1916
+ }
1907
1917
 
1908
- .wapp-panel-header,
1909
- .wapp-entity-header {
1910
- flex-wrap: wrap;
1911
- }
1918
+ :root[data-wapp-mobile] .wapp-page {
1919
+ padding: 1rem 1rem max(1rem, var(--wapp-mobile-bottom-clearance));
1920
+ }
1912
1921
 
1913
- .wapp-panel-actions,
1914
- .wapp-entity-header-actions {
1915
- width: 100%;
1916
- justify-content: flex-start;
1917
- }
1922
+ :root[data-wapp-mobile] .wapp-page-full {
1923
+ padding: 0;
1924
+ }
1918
1925
 
1919
- .wapp-data-list-row {
1920
- gap: 0.5rem;
1921
- }
1926
+ :root[data-wapp-mobile] .wapp-panel-header,
1927
+ :root[data-wapp-mobile] .wapp-entity-header {
1928
+ flex-wrap: wrap;
1929
+ }
1922
1930
 
1923
- .wapp-data-list-row-meta,
1924
- .wapp-data-list-row-badge,
1925
- .wapp-data-list-row-actions {
1926
- min-width: 0;
1927
- }
1931
+ :root[data-wapp-mobile] .wapp-panel-actions,
1932
+ :root[data-wapp-mobile] .wapp-entity-header-actions {
1933
+ width: 100%;
1934
+ justify-content: flex-start;
1935
+ }
1928
1936
 
1929
- .wapp-mobile-only {
1930
- display: inline-flex;
1931
- }
1937
+ :root[data-wapp-mobile] .wapp-data-list-row {
1938
+ gap: 0.5rem;
1939
+ }
1932
1940
 
1933
- .wapp-form-section {
1934
- grid-template-columns: 1fr;
1935
- }
1941
+ :root[data-wapp-mobile] .wapp-data-list-row-meta,
1942
+ :root[data-wapp-mobile] .wapp-data-list-row-badge,
1943
+ :root[data-wapp-mobile] .wapp-data-list-row-actions {
1944
+ min-width: 0;
1945
+ }
1936
1946
 
1937
- .wapp-settings-row {
1938
- display: block;
1939
- }
1947
+ :root[data-wapp-mobile] .wapp-mobile-only {
1948
+ display: inline-flex;
1949
+ }
1940
1950
 
1941
- .wapp-row-actions {
1942
- justify-content: flex-start;
1943
- margin-top: 0.75rem;
1944
- }
1951
+ :root[data-wapp-mobile] .wapp-form-section {
1952
+ grid-template-columns: 1fr;
1953
+ }
1954
+
1955
+ :root[data-wapp-mobile] .wapp-settings-row {
1956
+ display: block;
1957
+ }
1958
+
1959
+ :root[data-wapp-mobile] .wapp-row-actions {
1960
+ justify-content: flex-start;
1961
+ margin-top: 0.75rem;
1945
1962
  }
1946
1963
 
1947
1964
  @media (max-width: 640px) {