@pablozaiden/webapp 0.5.8 → 0.6.1

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 +12 -4
  2. package/docs/auth-validation.md +11 -0
  3. package/docs/auth.md +33 -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/ui-guidelines.md +12 -4
  12. package/package.json +2 -4
  13. package/src/build/build-binary.ts +67 -24
  14. package/src/cli/api-command.ts +53 -16
  15. package/src/cli/credentials.ts +275 -4
  16. package/src/cli/device-auth.ts +83 -22
  17. package/src/cli/environment-auth.ts +57 -0
  18. package/src/cli/index.ts +1 -0
  19. package/src/package-resolution.ts +34 -0
  20. package/src/server/auth/device-auth.ts +2 -2
  21. package/src/server/auth/passkeys.ts +16 -16
  22. package/src/server/auth/request-origin.ts +97 -16
  23. package/src/server/authentication.ts +265 -0
  24. package/src/server/create-web-app-server.ts +85 -1391
  25. package/src/server/framework-endpoints.ts +451 -0
  26. package/src/server/index.ts +1 -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 +93 -1
  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 -1214
  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 +76 -64
@@ -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
+ }
@@ -621,6 +621,15 @@ textarea::placeholder {
621
621
  padding: 1.5rem 2rem max(2rem, var(--wapp-mobile-bottom-clearance));
622
622
  }
623
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
+
624
633
  .wapp-page,
625
634
  .wapp-panel,
626
635
  .wapp-modal-body {
@@ -1868,85 +1877,88 @@ textarea::placeholder {
1868
1877
  display: none;
1869
1878
  }
1870
1879
 
1871
- @media (max-width: 900px) {
1872
- .wapp-shell {
1873
- display: block;
1874
- }
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
+ }
1875
1884
 
1876
- .wapp-sidebar,
1877
- .wapp-shell.sidebar-collapsed .wapp-sidebar {
1878
- position: fixed;
1879
- inset: 0 auto 0 0;
1880
- z-index: 60;
1881
- width: min(20rem, 86vw);
1882
- min-width: min(20rem, 86vw);
1883
- transform: translateX(-100%);
1884
- opacity: 1;
1885
- pointer-events: auto;
1886
- }
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
+ }
1887
1896
 
1888
- .wapp-shell.sidebar-open .wapp-sidebar {
1889
- transform: translateX(0);
1890
- }
1897
+ :root[data-wapp-mobile] .wapp-shell.sidebar-open .wapp-sidebar {
1898
+ transform: translateX(0);
1899
+ }
1891
1900
 
1892
- .wapp-shell.sidebar-open .wapp-mobile-backdrop {
1893
- display: block;
1894
- }
1901
+ :root[data-wapp-mobile] .wapp-shell.sidebar-open .wapp-mobile-backdrop {
1902
+ display: block;
1903
+ }
1895
1904
 
1896
- .wapp-main-header {
1897
- padding: 0.875rem 1rem;
1898
- }
1905
+ :root[data-wapp-mobile] .wapp-main-header {
1906
+ padding: 0.875rem 1rem;
1907
+ }
1899
1908
 
1900
- .wapp-main {
1901
- touch-action: pan-y;
1902
- }
1909
+ :root[data-wapp-mobile] .wapp-main {
1910
+ touch-action: pan-y;
1911
+ }
1903
1912
 
1904
- .wapp-main-content {
1905
- overflow: auto;
1906
- overflow-x: hidden;
1907
- }
1913
+ :root[data-wapp-mobile] .wapp-main-content {
1914
+ overflow: auto;
1915
+ overflow-x: hidden;
1916
+ }
1908
1917
 
1909
- .wapp-page {
1910
- padding: 1rem 1rem max(1rem, var(--wapp-mobile-bottom-clearance));
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-header,
1914
- .wapp-entity-header {
1915
- flex-wrap: wrap;
1916
- }
1922
+ :root[data-wapp-mobile] .wapp-page-full {
1923
+ padding: 0;
1924
+ }
1917
1925
 
1918
- .wapp-panel-actions,
1919
- .wapp-entity-header-actions {
1920
- width: 100%;
1921
- justify-content: flex-start;
1922
- }
1926
+ :root[data-wapp-mobile] .wapp-panel-header,
1927
+ :root[data-wapp-mobile] .wapp-entity-header {
1928
+ flex-wrap: wrap;
1929
+ }
1923
1930
 
1924
- .wapp-data-list-row {
1925
- gap: 0.5rem;
1926
- }
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
+ }
1927
1936
 
1928
- .wapp-data-list-row-meta,
1929
- .wapp-data-list-row-badge,
1930
- .wapp-data-list-row-actions {
1931
- min-width: 0;
1932
- }
1937
+ :root[data-wapp-mobile] .wapp-data-list-row {
1938
+ gap: 0.5rem;
1939
+ }
1933
1940
 
1934
- .wapp-mobile-only {
1935
- display: inline-flex;
1936
- }
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
+ }
1937
1946
 
1938
- .wapp-form-section {
1939
- grid-template-columns: 1fr;
1940
- }
1947
+ :root[data-wapp-mobile] .wapp-mobile-only {
1948
+ display: inline-flex;
1949
+ }
1941
1950
 
1942
- .wapp-settings-row {
1943
- display: block;
1944
- }
1951
+ :root[data-wapp-mobile] .wapp-form-section {
1952
+ grid-template-columns: 1fr;
1953
+ }
1945
1954
 
1946
- .wapp-row-actions {
1947
- justify-content: flex-start;
1948
- margin-top: 0.75rem;
1949
- }
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;
1950
1962
  }
1951
1963
 
1952
1964
  @media (max-width: 640px) {