@pablozaiden/webapp 0.6.8 → 0.7.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.
package/docs/settings.md CHANGED
@@ -56,6 +56,12 @@ Apps can append structured custom sections:
56
56
  description: lastSync,
57
57
  actions: [{ id: "sync-now", label: "Sync now", onAction: syncNow }],
58
58
  },
59
+ {
60
+ id: "auto-sync",
61
+ title: "Automatic sync",
62
+ content: <input type="checkbox" checked={autoSync} onChange={toggleAutoSync} />,
63
+ contentPlacement: "inline",
64
+ },
59
65
  {
60
66
  id: "disconnect",
61
67
  title: "Disconnect",
@@ -73,4 +79,9 @@ Apps can append structured custom sections:
73
79
 
74
80
  `render` remains available as an escape hatch for custom controls inside a section. Prefer structured `rows` for simple settings because the framework keeps spacing, typography and danger-zone styling consistent.
75
81
 
82
+ Row content is placed below the title and description by default. Use
83
+ `contentPlacement: "inline"` for compact controls such as a checkbox or toggle
84
+ that should remain on the right side of the row. Keep selects and larger
85
+ controls at the default `"below"` placement.
86
+
76
87
  `scope` can be `user`, `admin` or `owner` on both sections and rows. Omitted scope behaves like `user`. Use `admin` for global app/server settings and `user` for preferences or data that belong to the signed-in user.
@@ -29,7 +29,7 @@ twice without a distinct user-facing reason.
29
29
 
30
30
  The framework owns the mobile shell breakpoint. `MOBILE_BREAKPOINT_PX` and `MOBILE_MEDIA_QUERY` are exported from `@pablozaiden/webapp/web` for application JavaScript that needs to coordinate with the shell; do not add an independent `innerWidth` threshold for shell behavior. The generated document initializes the `data-wapp-mobile` marker on the root element before the client and styles load, and `WebAppRoot` keeps it synchronized with media-query changes. Custom CSS that follows the framework mobile mode should use that marker rather than repeating a numeric media query.
31
31
 
32
- `WebAppRoot` follows `visualViewport` resize/scroll, window resize, orientation changes, focus boundaries, and mobile media-query changes. CSS `100dvh` is the authoritative height when the browser supports dynamic viewport units; the hook removes its inline override in that case so a temporarily limited `visualViewport` cannot pin the shell after a rotation or keyboard transition. The visual-viewport pixel value and two named, bounded retries remain only as a fallback for browsers without `100dvh`. The first retry catches the post-event layout pass; the final retry catches the end of that transition when no further geometry event is emitted. These retries are implementation fallbacks, not general synchronization primitives, and are cancelled with the associated animation frame and listeners when the root is unmounted.
32
+ `WebAppRoot` follows `visualViewport` resize/scroll, window resize, orientation changes, focus boundaries, and mobile media-query changes. CSS `100dvh` is the authoritative height when the browser supports dynamic viewport units, except while an editable control is focused and the visual viewport is materially shorter because the on-screen keyboard is open. In that keyboard state, the hook temporarily overrides the shell height with the measured visual viewport so the keyboard cannot cover the app. The visual-viewport pixel value remains the normal fallback for browsers without `100dvh`. Two named, bounded retries catch the post-event layout pass and the end of keyboard, browser-chrome, or rotation transitions when no further geometry event is emitted. These retries are implementation fallbacks, not general synchronization primitives, and are cancelled with the associated animation frame and listeners when the root is unmounted.
33
33
 
34
34
  The framework mobile mode is separate from the narrower `640px` settings-layout rules. Do not add global touch handlers or arbitrary sleeps to reproduce either behavior; use the framework drawer controls and the existing event-driven viewport lifecycle.
35
35
 
@@ -56,6 +56,8 @@ Use these first:
56
56
  | `EmptyState` | Empty or missing content |
57
57
  | `ConfirmDialog` | Destructive confirmation |
58
58
 
59
+ The default `Panel` is the muted card surface, `DataList` renders card rows, and `Button` uses the primary medium-sized action treatment. Use an explicit variant or size only when the surface is intentionally different from the application baseline.
60
+
59
61
  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.
60
62
 
61
63
  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. For a route whose child owns viewport-sized layout and scrolling, use `<Page layout="full">` rather than overriding `.wapp-page` from the application. 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pablozaiden/webapp",
3
- "version": "0.6.8",
3
+ "version": "0.7.0",
4
4
  "description": "Opinionated Bun + React webapp framework for single-server apps",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -13,7 +13,14 @@ import { ThemeProvider } from "./theme";
13
13
  import { WebAppConfigProvider, useWebAppConfig } from "./webapp-config";
14
14
 
15
15
  export { replaceHashRoute, replaceWebAppRoute, routeToHash } from "./routing";
16
- export type { HeaderContext, SettingsAction, SettingsRow, SettingsSection, WebAppRootProps } from "./root-types";
16
+ export type {
17
+ HeaderContext,
18
+ SettingsAction,
19
+ SettingsRow,
20
+ SettingsRowContentPlacement,
21
+ SettingsSection,
22
+ WebAppRootProps,
23
+ } from "./root-types";
17
24
 
18
25
  function routeMatches(left: WebAppRoute | undefined, right: WebAppRoute): boolean {
19
26
  if (!left) {
@@ -2,16 +2,20 @@ import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type
2
2
  import { createPortal } from "react-dom";
3
3
  import type { ActionMenuItem, BadgeVariant } from "../sidebar/types";
4
4
 
5
+ export type ButtonVariant = "default" | "primary" | "danger" | "ghost";
6
+ export type ButtonSize = "xs" | "sm" | "md" | "lg";
7
+
5
8
  export function Button({
6
- variant = "default",
9
+ variant = "primary",
10
+ size = "md",
7
11
  loading = false,
8
12
  className = "",
9
13
  disabled,
10
14
  children,
11
15
  ...props
12
- }: ButtonHTMLAttributes<HTMLButtonElement> & { variant?: "default" | "primary" | "danger" | "ghost"; loading?: boolean }) {
16
+ }: ButtonHTMLAttributes<HTMLButtonElement> & { variant?: ButtonVariant; size?: ButtonSize; loading?: boolean }) {
13
17
  return (
14
- <button {...props} disabled={disabled || loading} className={`wapp-button wapp-button-${variant} ${className}`}>
18
+ <button {...props} disabled={disabled || loading} className={`wapp-button wapp-button-${variant} wapp-button-${size} ${className}`}>
15
19
  {loading ? <span className="wapp-button-spinner" aria-hidden="true" /> : null}
16
20
  {children}
17
21
  </button>
@@ -26,8 +30,17 @@ export function IconButton({
26
30
  return <button {...props} className={`wapp-icon-button ${active ? "active" : ""} ${className}`} />;
27
31
  }
28
32
 
29
- export function Badge({ variant = "default", className = "", children, ...props }: HTMLAttributes<HTMLSpanElement> & { variant?: BadgeVariant; children: ReactNode }) {
30
- return <span {...props} className={`wapp-badge wapp-badge-${variant} ${className}`}>{children}</span>;
33
+ export type BadgeSize = "sm" | "md";
34
+
35
+ export function Badge({
36
+ variant = "default",
37
+ size = "sm",
38
+ className = "",
39
+ children,
40
+ ...props
41
+ }: HTMLAttributes<HTMLSpanElement> & { variant?: BadgeVariant; size?: BadgeSize; children: ReactNode }) {
42
+ const sizeClass = size === "md" ? "wapp-badge-md" : "";
43
+ return <span {...props} className={["wapp-badge", `wapp-badge-${variant}`, sizeClass, className].filter(Boolean).join(" ")}>{children}</span>;
31
44
  }
32
45
 
33
46
  export type PageLayout = "padded" | "full";
@@ -41,9 +54,31 @@ export function Page({
41
54
  return <div {...props} className={`wapp-page ${layout === "full" ? "wapp-page-full" : ""} ${className}`.trim()}>{children}</div>;
42
55
  }
43
56
 
44
- export function Panel({ title, description, actions, children, className = "" }: { title?: string; description?: string; actions?: ReactNode; children?: ReactNode; className?: string }) {
57
+ export type PanelVariant = "surface" | "muted" | "plain";
58
+ export type PanelPadding = "default" | "compact" | "none";
59
+
60
+ export interface PanelProps extends HTMLAttributes<HTMLElement> {
61
+ title?: string;
62
+ description?: string;
63
+ actions?: ReactNode;
64
+ variant?: PanelVariant;
65
+ padding?: PanelPadding;
66
+ children?: ReactNode;
67
+ }
68
+
69
+ export function Panel({
70
+ title,
71
+ description,
72
+ actions,
73
+ children,
74
+ variant = "muted",
75
+ padding = "default",
76
+ className = "",
77
+ ...props
78
+ }: PanelProps) {
79
+ const paddingClass = padding === "default" ? "" : `wapp-panel-padding-${padding}`;
45
80
  return (
46
- <section className={`wapp-panel ${className}`}>
81
+ <section {...props} className={`wapp-panel wapp-panel-${variant} ${paddingClass} ${className}`.trim()}>
47
82
  {title || description || actions ? (
48
83
  <div className="wapp-panel-header">
49
84
  <div>
@@ -111,40 +146,68 @@ export function EntityHeader({
111
146
  );
112
147
  }
113
148
 
114
- export function DataList({ children, empty }: { children?: ReactNode; empty?: ReactNode }) {
115
- return <div className="wapp-data-list">{children ?? empty ?? null}</div>;
149
+ export type DataListVariant = "divided" | "cards";
150
+
151
+ export function DataList({
152
+ children,
153
+ empty,
154
+ variant = "cards",
155
+ }: {
156
+ children?: ReactNode;
157
+ empty?: ReactNode;
158
+ variant?: DataListVariant;
159
+ }) {
160
+ return <div className={`wapp-data-list wapp-data-list-${variant}`}>{children ?? empty ?? null}</div>;
116
161
  }
117
162
 
118
163
  export function DataListRow({
119
164
  title,
120
165
  description,
166
+ descriptionClassName = "",
121
167
  meta,
168
+ metaPlacement = "side",
122
169
  badge,
123
170
  actions,
124
171
  onClick,
172
+ disabled = false,
173
+ variant = "card",
174
+ className = "",
125
175
  }: {
126
176
  title: ReactNode;
127
177
  description?: ReactNode;
178
+ descriptionClassName?: string;
128
179
  meta?: ReactNode;
180
+ metaPlacement?: "side" | "below";
129
181
  badge?: ReactNode;
130
182
  actions?: ReactNode;
131
183
  onClick?: () => void;
184
+ disabled?: boolean;
185
+ variant?: "default" | "card";
186
+ className?: string;
132
187
  }) {
188
+ const rowClassName = [
189
+ "wapp-data-list-row",
190
+ variant === "card" ? "wapp-data-list-row-card" : "",
191
+ onClick && !disabled ? "interactive" : "",
192
+ disabled ? "disabled" : "",
193
+ className,
194
+ ].filter(Boolean).join(" ");
133
195
  const content = (
134
196
  <>
135
197
  <span className="wapp-data-list-row-main">
136
198
  <strong>{title}</strong>
137
- {description ? <small>{description}</small> : null}
199
+ {description ? <small className={descriptionClassName}>{description}</small> : null}
200
+ {meta && metaPlacement === "below" ? <small className="wapp-data-list-row-meta-below">{meta}</small> : null}
138
201
  </span>
139
- {meta ? <span className="wapp-data-list-row-meta">{meta}</span> : null}
202
+ {meta && metaPlacement === "side" ? <span className="wapp-data-list-row-meta">{meta}</span> : null}
140
203
  {badge ? <span className="wapp-data-list-row-badge">{badge}</span> : null}
141
204
  {actions ? <span className="wapp-data-list-row-actions">{actions}</span> : null}
142
205
  </>
143
206
  );
144
- return onClick ? (
145
- <button type="button" className="wapp-data-list-row interactive" onClick={onClick}>{content}</button>
207
+ return onClick && !disabled ? (
208
+ <button type="button" className={rowClassName} onClick={onClick}>{content}</button>
146
209
  ) : (
147
- <div className="wapp-data-list-row">{content}</div>
210
+ <div className={rowClassName}>{content}</div>
148
211
  );
149
212
  }
150
213
 
@@ -6,6 +6,32 @@ import {
6
6
  MOBILE_VIEWPORT_FIRST_SETTLE_DELAY_MS,
7
7
  } from "./mobile";
8
8
 
9
+ const NON_TEXT_INPUT_TYPES = new Set([
10
+ "button",
11
+ "checkbox",
12
+ "color",
13
+ "file",
14
+ "hidden",
15
+ "image",
16
+ "radio",
17
+ "range",
18
+ "reset",
19
+ "submit",
20
+ ]);
21
+
22
+ function isEditableElement(element: Element | null): boolean {
23
+ if (!element) {
24
+ return false;
25
+ }
26
+ if (element instanceof HTMLTextAreaElement) {
27
+ return !element.disabled && !element.readOnly;
28
+ }
29
+ if (element instanceof HTMLInputElement) {
30
+ return !element.disabled && !element.readOnly && !NON_TEXT_INPUT_TYPES.has(element.type);
31
+ }
32
+ return element instanceof HTMLElement && element.isContentEditable;
33
+ }
34
+
9
35
  export function useMobileBreakpoint(): boolean {
10
36
  const [isMobile, setIsMobile] = useState(() => typeof window !== "undefined" && window.matchMedia(MOBILE_MEDIA_QUERY).matches);
11
37
 
@@ -50,6 +76,13 @@ export function useMobileViewportHeight(isMobile: boolean): void {
50
76
  root.style.removeProperty("--wapp-viewport-height");
51
77
  };
52
78
 
79
+ const shouldOverrideDynamicViewportHeight = () => {
80
+ if (!usesDynamicViewportUnit || !viewport || !isEditableElement(document.activeElement)) {
81
+ return false;
82
+ }
83
+ return viewport.height + 1 < window.innerHeight;
84
+ };
85
+
53
86
  const sync = () => {
54
87
  frame = 0;
55
88
  if (!isMobile) {
@@ -57,7 +90,8 @@ export function useMobileViewportHeight(isMobile: boolean): void {
57
90
  return;
58
91
  }
59
92
 
60
- if (usesDynamicViewportUnit) {
93
+ const shouldUseVisualViewport = !usesDynamicViewportUnit || shouldOverrideDynamicViewportHeight();
94
+ if (!shouldUseVisualViewport) {
61
95
  clearViewportHeight();
62
96
  } else {
63
97
  const height = Math.round(viewport?.height ?? window.innerHeight);
@@ -89,7 +123,7 @@ export function useMobileViewportHeight(isMobile: boolean): void {
89
123
 
90
124
  const handleViewportTransition = () => {
91
125
  scheduleSync();
92
- if (isMobile && !usesDynamicViewportUnit) {
126
+ if (isMobile) {
93
127
  scheduleViewportRetry(MOBILE_VIEWPORT_FIRST_SETTLE_DELAY_MS);
94
128
  scheduleViewportRetry(MOBILE_VIEWPORT_FINAL_SETTLE_DELAY_MS);
95
129
  }
@@ -11,12 +11,15 @@ export type SettingsAction = {
11
11
  onAction: () => void;
12
12
  };
13
13
 
14
+ export type SettingsRowContentPlacement = "below" | "inline";
15
+
14
16
  export type SettingsRow = {
15
17
  id: string;
16
18
  title: string;
17
19
  description?: string;
18
20
  scope?: SettingsScope;
19
21
  content?: ReactNode;
22
+ contentPlacement?: SettingsRowContentPlacement;
20
23
  actions?: ReactNode | SettingsAction[];
21
24
  danger?: boolean;
22
25
  };
@@ -84,25 +84,29 @@ export function SecuritySection({ config, refresh, setError }: SecuritySectionPr
84
84
  </div>
85
85
  </div>
86
86
  {config.apiKeys.enabled ? (
87
- <div className="wapp-settings-row stacked">
88
- <div>
89
- <strong>API keys</strong>
90
- <p>Create bearer tokens for scripts and agents.</p>
91
- </div>
92
- <div className="wapp-row-actions"><Button type="button" onClick={() => void createKey().catch((err) => setError(String(err)))}>Create API key</Button></div>
93
- {createdToken ? <code className="wapp-token">{createdToken}</code> : null}
94
- <ResourceState loading={apiKeysLoading} error={apiKeysLoadError} hasData={apiKeys !== undefined} refresh={refreshApiKeys} />
95
- {apiKeys?.length ? (
96
- <div className="wapp-list">
97
- {apiKeys.map((key) => (
98
- <div className="wapp-list-row" key={key.id}>
99
- <span><strong>{key.name}</strong><small>{key.scopes.join(", ")} · {key.createdAt}</small></span>
100
- <Button type="button" variant="danger" onClick={() => setApiKeyToDelete(key)}>Delete</Button>
101
- </div>
102
- ))}
87
+ <>
88
+ <div className="wapp-settings-row">
89
+ <div className="wapp-settings-row-main">
90
+ <strong>API keys</strong>
91
+ <p>Create bearer tokens for scripts and agents.</p>
103
92
  </div>
104
- ) : apiKeys !== undefined && !apiKeysLoadError ? <EmptyState title="No API keys" /> : null}
105
- </div>
93
+ <div className="wapp-row-actions"><Button type="button" onClick={() => void createKey().catch((err) => setError(String(err)))}>Create API key</Button></div>
94
+ </div>
95
+ <div className="wapp-settings-row-content">
96
+ {createdToken ? <code className="wapp-token">{createdToken}</code> : null}
97
+ <ResourceState loading={apiKeysLoading} error={apiKeysLoadError} hasData={apiKeys !== undefined} refresh={refreshApiKeys} />
98
+ {apiKeys?.length ? (
99
+ <div className="wapp-list">
100
+ {apiKeys.map((key) => (
101
+ <div className="wapp-list-row" key={key.id}>
102
+ <span><strong>{key.name}</strong><small>{key.scopes.join(", ")} · {key.createdAt}</small></span>
103
+ <Button type="button" variant="danger" onClick={() => setApiKeyToDelete(key)}>Delete</Button>
104
+ </div>
105
+ ))}
106
+ </div>
107
+ ) : apiKeys !== undefined && !apiKeysLoadError ? <EmptyState title="No API keys" /> : null}
108
+ </div>
109
+ </>
106
110
  ) : null}
107
111
  <ConfirmDialog
108
112
  open={Boolean(apiKeyToDelete)}
@@ -34,13 +34,15 @@ function StructuredSettingsSection({ section }: { section: SettingsSection }) {
34
34
  if (row.danger) {
35
35
  return <DangerZone key={row.id} title={row.title} description={row.description} actions={actions} />;
36
36
  }
37
+ const hasInlineContent = Boolean(row.content && row.contentPlacement === "inline");
37
38
  return (
38
- <div className="wapp-settings-row" key={row.id}>
39
- <div>
39
+ <div className={`wapp-settings-row${hasInlineContent ? " inline-content" : ""}`} key={row.id}>
40
+ <div className="wapp-settings-row-main">
40
41
  <strong>{row.title}</strong>
41
42
  {row.description ? <p>{row.description}</p> : null}
42
- {row.content ? <div className="wapp-settings-row-content">{row.content}</div> : null}
43
+ {row.content && !hasInlineContent ? <div className="wapp-settings-row-content">{row.content}</div> : null}
43
44
  </div>
45
+ {hasInlineContent ? <div className="wapp-settings-row-content inline">{row.content}</div> : null}
44
46
  {actions ? <div className="wapp-row-actions">{actions}</div> : null}
45
47
  </div>
46
48
  );
@@ -3,12 +3,17 @@
3
3
  --wapp-bg: var(--wapp-shell-bg);
4
4
  --wapp-shell-bg: rgb(249 250 251 / 0.95);
5
5
  --wapp-surface: #ffffff;
6
+ --wapp-surface-raised: #ffffff;
6
7
  --wapp-surface-muted: #f9fafb;
8
+ --wapp-surface-inset: #f9fafb;
7
9
  --wapp-border: #d1d5db;
8
10
  --wapp-border-soft: #e5e7eb;
11
+ --wapp-border-muted: #d1d5db;
9
12
  --wapp-text: #111827;
10
13
  --wapp-muted: #4b5563;
11
14
  --wapp-muted-2: #64748b;
15
+ --wapp-surface-pill: #e5e7eb;
16
+ --wapp-pill-text: #4b5563;
12
17
  --wapp-danger: #991b1b;
13
18
  --wapp-danger-bg: #991b1b;
14
19
  --wapp-toast-success: #166534;
@@ -53,12 +58,17 @@
53
58
  --wapp-bg: var(--wapp-shell-bg);
54
59
  --wapp-shell-bg: rgb(23 23 23 / 0.95);
55
60
  --wapp-surface: #262626;
61
+ --wapp-surface-raised: #171717;
56
62
  --wapp-surface-muted: #171717;
63
+ --wapp-surface-inset: rgb(10 10 10 / 0.5);
57
64
  --wapp-border: #374151;
58
65
  --wapp-border-soft: #27272a;
66
+ --wapp-border-muted: #1f2937;
59
67
  --wapp-text: #f3f4f6;
60
68
  --wapp-muted: #a1a1aa;
61
69
  --wapp-muted-2: #71717a;
70
+ --wapp-surface-pill: #262626;
71
+ --wapp-pill-text: #d1d5db;
62
72
  --wapp-danger: #fca5a5;
63
73
  --wapp-danger-bg: #7f1d1d;
64
74
  --wapp-toast-success: #bbf7d0;
@@ -693,7 +703,7 @@ textarea::placeholder {
693
703
  overflow-wrap: anywhere;
694
704
  }
695
705
 
696
- .wapp-panel {
706
+ :where(.wapp-panel) {
697
707
  min-width: 0;
698
708
  max-width: 100%;
699
709
  box-sizing: border-box;
@@ -703,10 +713,34 @@ textarea::placeholder {
703
713
  padding: 1.25rem;
704
714
  }
705
715
 
716
+ .wapp-panel-muted {
717
+ border-color: var(--wapp-border-muted);
718
+ border-radius: 1rem;
719
+ background: var(--wapp-surface-inset);
720
+ }
721
+
722
+ .wapp-panel-plain {
723
+ border-color: transparent;
724
+ background: transparent;
725
+ padding: 0;
726
+ }
727
+
728
+ .wapp-panel-padding-compact {
729
+ padding: 1rem;
730
+ }
731
+
732
+ .wapp-panel-padding-none {
733
+ padding: 0;
734
+ }
735
+
706
736
  .wapp-panel + .wapp-panel {
707
737
  margin-top: 1.5rem;
708
738
  }
709
739
 
740
+ .grid > .wapp-panel {
741
+ margin-top: 0;
742
+ }
743
+
710
744
  .wapp-panel-header {
711
745
  display: flex;
712
746
  align-items: flex-start;
@@ -727,6 +761,7 @@ textarea::placeholder {
727
761
  margin: 0;
728
762
  font-size: 1.125rem;
729
763
  font-weight: 600;
764
+ line-height: 1.75rem;
730
765
  }
731
766
 
732
767
  .wapp-panel-header p,
@@ -844,6 +879,12 @@ textarea::placeholder {
844
879
  border-radius: 0.875rem;
845
880
  }
846
881
 
882
+ .wapp-data-list-cards {
883
+ overflow: visible;
884
+ border: 0;
885
+ border-radius: 0;
886
+ }
887
+
847
888
  .wapp-data-list-row {
848
889
  box-sizing: border-box;
849
890
  width: 100%;
@@ -857,6 +898,68 @@ textarea::placeholder {
857
898
  text-align: left;
858
899
  }
859
900
 
901
+ .wapp-data-list-row-card,
902
+ .wapp-data-list-cards .wapp-data-list-row {
903
+ align-items: flex-start;
904
+ border: 1px solid var(--wapp-border-muted);
905
+ border-radius: 1rem;
906
+ background: var(--wapp-surface-raised);
907
+ padding: 0.75rem 1rem;
908
+ }
909
+
910
+ .wapp-data-list-row-card + .wapp-data-list-row-card,
911
+ .wapp-data-list-cards .wapp-data-list-row + .wapp-data-list-row {
912
+ margin-top: 0.5rem;
913
+ border-top: 1px solid var(--wapp-border-muted);
914
+ }
915
+
916
+ .wapp-data-list-row-card .wapp-data-list-row-main strong,
917
+ .wapp-data-list-cards .wapp-data-list-row .wapp-data-list-row-main strong {
918
+ overflow: visible;
919
+ overflow-wrap: anywhere;
920
+ font-weight: 500;
921
+ line-height: 1.25rem;
922
+ white-space: normal;
923
+ }
924
+
925
+ .wapp-data-list-row-card .wapp-data-list-row-main small,
926
+ .wapp-data-list-cards .wapp-data-list-row .wapp-data-list-row-main small {
927
+ font-size: 0.75rem;
928
+ line-height: 1rem;
929
+ overflow-wrap: anywhere;
930
+ }
931
+
932
+ .wapp-data-list-row-card .wapp-data-list-row-meta,
933
+ .wapp-data-list-cards .wapp-data-list-row .wapp-data-list-row-meta {
934
+ border-radius: 999px;
935
+ background: var(--wapp-surface-pill);
936
+ color: var(--wapp-pill-text);
937
+ font-size: 0.75rem;
938
+ font-weight: 600;
939
+ padding: 0.125rem 0.5rem;
940
+ text-align: right;
941
+ white-space: nowrap;
942
+ }
943
+
944
+ .wapp-data-list-row-meta-below {
945
+ display: block;
946
+ font-size: 0.75rem;
947
+ line-height: 1rem;
948
+ margin-top: 0.5rem;
949
+ overflow-wrap: anywhere;
950
+ }
951
+
952
+ .wapp-data-list-row-card.interactive:hover,
953
+ .wapp-data-list-cards .wapp-data-list-row.interactive:hover {
954
+ border-color: var(--wapp-border);
955
+ background: var(--wapp-surface-pill);
956
+ }
957
+
958
+ .wapp-data-list-row.disabled {
959
+ cursor: default;
960
+ opacity: 0.65;
961
+ }
962
+
860
963
  .wapp-data-list-row + .wapp-data-list-row {
861
964
  border-top: 1px solid var(--wapp-border-soft);
862
965
  }
@@ -983,6 +1086,30 @@ textarea::placeholder {
983
1086
  transition: background 120ms ease, border-color 120ms ease, color 120ms ease, transform 120ms ease;
984
1087
  }
985
1088
 
1089
+ .wapp-button-xs {
1090
+ min-height: 2rem;
1091
+ padding: 0.25rem 0.375rem;
1092
+ font-size: 0.75rem;
1093
+ }
1094
+
1095
+ .wapp-button-sm {
1096
+ min-height: 2.25rem;
1097
+ padding: 0.25rem 0.5rem;
1098
+ font-size: 0.875rem;
1099
+ }
1100
+
1101
+ .wapp-button-md {
1102
+ min-height: 2.75rem;
1103
+ padding: 0.5rem 1rem;
1104
+ font-size: 1rem;
1105
+ }
1106
+
1107
+ .wapp-button-lg {
1108
+ min-height: 3rem;
1109
+ padding: 0.75rem 1.5rem;
1110
+ font-size: 1.125rem;
1111
+ }
1112
+
986
1113
  .wapp-button-spinner {
987
1114
  width: 1rem;
988
1115
  height: 1rem;
@@ -1152,6 +1279,11 @@ textarea::placeholder {
1152
1279
  padding: 0.125rem 0.5rem;
1153
1280
  }
1154
1281
 
1282
+ .wapp-badge-md {
1283
+ font-size: 0.75rem;
1284
+ padding: 0.25rem 0.625rem;
1285
+ }
1286
+
1155
1287
  .wapp-sidebar-badge {
1156
1288
  flex: 0 0 auto;
1157
1289
  width: 0.625rem;
@@ -1359,6 +1491,16 @@ textarea::placeholder {
1359
1491
  gap: 1rem;
1360
1492
  }
1361
1493
 
1494
+ .wapp-settings-row-main {
1495
+ min-width: 0;
1496
+ flex: 1 1 auto;
1497
+ }
1498
+
1499
+ .wapp-settings-row.inline-content {
1500
+ flex-wrap: wrap;
1501
+ align-items: center;
1502
+ }
1503
+
1362
1504
  .wapp-settings-row strong {
1363
1505
  font-size: 0.875rem;
1364
1506
  font-weight: 600;
@@ -1383,6 +1525,12 @@ textarea::placeholder {
1383
1525
  margin-top: 0.75rem;
1384
1526
  }
1385
1527
 
1528
+ .wapp-settings-row-content.inline {
1529
+ flex: 0 0 auto;
1530
+ align-self: center;
1531
+ margin-top: 0;
1532
+ }
1533
+
1386
1534
  .wapp-row-actions {
1387
1535
  display: flex;
1388
1536
  flex-wrap: wrap;
@@ -2084,6 +2232,15 @@ textarea::placeholder {
2084
2232
  display: block;
2085
2233
  }
2086
2234
 
2235
+ :root[data-wapp-mobile] .wapp-settings-row.inline-content {
2236
+ display: flex;
2237
+ align-items: center;
2238
+ }
2239
+
2240
+ :root[data-wapp-mobile] .wapp-settings-row.inline-content .wapp-row-actions {
2241
+ flex: 1 0 100%;
2242
+ }
2243
+
2087
2244
  :root[data-wapp-mobile] .wapp-row-actions {
2088
2245
  justify-content: flex-start;
2089
2246
  margin-top: 0.75rem;