@swiftbrowser/web 0.4.0 → 0.5.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/src/page.css ADDED
@@ -0,0 +1,234 @@
1
+ /* ---------------------------------------------------------------------------
2
+ The device page (app/index.html): the page around the iPhone frame, with
3
+ its toolbar, its stage and its error banner. Not the renderer's: a page
4
+ that draws an island (@swiftbrowser/react's `SwiftUIView`) imports
5
+ styles.css alone and keeps its own layout.
6
+ ------------------------------------------------------------------------- */
7
+
8
+ :root {
9
+ --page-bg: #e9e9ee;
10
+ --page-fg: #1c1c1e;
11
+ --page-muted: #6e6e73;
12
+ --page-border: rgba(0, 0, 0, 0.08);
13
+ --page-surface: rgba(255, 255, 255, 0.8);
14
+ --page-error-bg: #ffe5e5;
15
+ --page-error-fg: #a1000d;
16
+ }
17
+
18
+ @media (prefers-color-scheme: dark) {
19
+ :root {
20
+ --page-bg: #111114;
21
+ --page-fg: #f2f2f7;
22
+ --page-muted: #98989f;
23
+ --page-border: rgba(255, 255, 255, 0.1);
24
+ --page-surface: rgba(28, 28, 30, 0.85);
25
+ --page-error-bg: #3a0b10;
26
+ --page-error-fg: #ff8a8a;
27
+ }
28
+ }
29
+
30
+ *,
31
+ *::before,
32
+ *::after {
33
+ box-sizing: border-box;
34
+ }
35
+
36
+ html,
37
+ body {
38
+ margin: 0;
39
+ min-height: 100%;
40
+ }
41
+
42
+ body {
43
+ display: flex;
44
+ flex-direction: column;
45
+ min-height: 100vh;
46
+ background: var(--page-bg);
47
+ color: var(--page-fg);
48
+ font-family: var(--sb-font-family);
49
+ font-size: 14px;
50
+ -webkit-font-smoothing: antialiased;
51
+ }
52
+
53
+ .toolbar {
54
+ position: sticky;
55
+ top: 0;
56
+ z-index: 10;
57
+ display: flex;
58
+ align-items: center;
59
+ gap: 10px;
60
+ padding: 10px 16px;
61
+ background: var(--page-surface);
62
+ border-bottom: 1px solid var(--page-border);
63
+ backdrop-filter: blur(16px);
64
+ }
65
+
66
+ .toolbar-brand {
67
+ font-weight: 600;
68
+ letter-spacing: -0.01em;
69
+ }
70
+
71
+ .toolbar-sep {
72
+ width: 1px;
73
+ height: 18px;
74
+ background: var(--page-border);
75
+ }
76
+
77
+ .toolbar-label {
78
+ color: var(--page-muted);
79
+ }
80
+
81
+ .toolbar-value {
82
+ font-variant-numeric: tabular-nums;
83
+ padding: 2px 8px;
84
+ border-radius: 6px;
85
+ background: var(--page-border);
86
+ }
87
+
88
+ .toolbar-spacer {
89
+ flex: 1 1 0;
90
+ }
91
+
92
+ /* Hot-reload indicator, driven by the dev server's sb:build-* events (the
93
+ `sb-spin` keyframes are styles.css's, loaded with this file). */
94
+ .toolbar-build {
95
+ display: inline-flex;
96
+ align-items: center;
97
+ gap: 6px;
98
+ color: var(--page-muted);
99
+ }
100
+
101
+ .toolbar-build[hidden] {
102
+ display: none;
103
+ }
104
+
105
+ .toolbar-build::before {
106
+ content: "";
107
+ width: 10px;
108
+ height: 10px;
109
+ border-radius: 50%;
110
+ border: 1.5px solid currentColor;
111
+ border-top-color: transparent;
112
+ animation: sb-spin 0.9s linear infinite;
113
+ }
114
+
115
+ .toolbar-build[data-state="error"] {
116
+ color: var(--page-error-fg);
117
+ }
118
+
119
+ .toolbar-build[data-state="error"]::before {
120
+ animation: none;
121
+ border-color: currentColor;
122
+ }
123
+
124
+ .toolbar-button {
125
+ font: inherit;
126
+ padding: 6px 12px;
127
+ border: 1px solid var(--page-border);
128
+ border-radius: 8px;
129
+ background: transparent;
130
+ color: var(--page-fg);
131
+ cursor: pointer;
132
+ }
133
+
134
+ .toolbar-button:hover {
135
+ background: var(--page-border);
136
+ }
137
+
138
+ .toolbar-button[aria-pressed="true"] {
139
+ background: var(--page-fg);
140
+ color: var(--page-bg);
141
+ border-color: transparent;
142
+ }
143
+
144
+ .toolbar-select {
145
+ font: inherit;
146
+ padding: 5px 8px;
147
+ border: 1px solid var(--page-border);
148
+ border-radius: 8px;
149
+ background: transparent;
150
+ color: var(--page-fg);
151
+ cursor: pointer;
152
+ }
153
+
154
+ .stage {
155
+ display: flex;
156
+ flex-direction: column;
157
+ align-items: center;
158
+ gap: 16px;
159
+ padding: 32px 16px 48px;
160
+ }
161
+
162
+ .status {
163
+ max-width: 560px;
164
+ margin: 0;
165
+ padding: 10px 14px;
166
+ border-radius: 10px;
167
+ background: var(--page-error-bg);
168
+ color: var(--page-error-fg);
169
+ font-family: ui-monospace, "SF Mono", Menlo, monospace;
170
+ font-size: 12px;
171
+ white-space: pre-wrap;
172
+ word-break: break-word;
173
+ }
174
+
175
+ /* ---------------------------------------------------------------------------
176
+ Device mode (html[data-sb-mode="device"], see src/deviceMode.ts): no
177
+ toolbar, no stage padding, the page behind the app in the OS appearance's
178
+ system background from the first paint (Safari tints its status bar strip
179
+ from it; chromeColor.ts then keeps it at the color the app draws at its
180
+ top edge), and the error banner floating clear of the home indicator.
181
+ ------------------------------------------------------------------------- */
182
+
183
+ html[data-sb-mode="device"] {
184
+ height: 100%;
185
+ overflow: hidden;
186
+ overscroll-behavior: none;
187
+ /* No double-tap zoom, no text inflation in landscape. */
188
+ touch-action: manipulation;
189
+ -webkit-text-size-adjust: 100%;
190
+ text-size-adjust: 100%;
191
+ }
192
+
193
+ html[data-sb-mode="device"] body {
194
+ min-height: 100dvh;
195
+ overflow: hidden;
196
+ overscroll-behavior: none;
197
+ touch-action: manipulation;
198
+ }
199
+
200
+ /* The page behind the app is the OS appearance's system background from the
201
+ first paint (Safari tints its status bar strip from it); chromeColor.ts
202
+ then keeps it at the color the app draws at its top edge. */
203
+ html[data-sb-mode="device"],
204
+ html[data-sb-mode="device"] body {
205
+ background: #ffffff;
206
+ }
207
+
208
+ @media (prefers-color-scheme: dark) {
209
+ html[data-sb-mode="device"],
210
+ html[data-sb-mode="device"] body {
211
+ background: #000000;
212
+ }
213
+ }
214
+
215
+ html[data-sb-mode="device"] .toolbar {
216
+ display: none;
217
+ }
218
+
219
+ html[data-sb-mode="device"] .stage {
220
+ padding: 0;
221
+ gap: 0;
222
+ }
223
+
224
+ /* The error banner floats over the app, clear of the home indicator. */
225
+ html[data-sb-mode="device"] .status {
226
+ position: fixed;
227
+ left: 16px;
228
+ right: 16px;
229
+ bottom: calc(16px + var(--sb-inset-bottom));
230
+ z-index: 10;
231
+ max-width: none;
232
+ max-height: 40dvh;
233
+ overflow: auto;
234
+ }
package/src/protocol.ts CHANGED
@@ -803,6 +803,16 @@ export interface LazyVGridProps {
803
803
  spacing: number | null;
804
804
  alignment: FrameAlignment;
805
805
  }
806
+ /**
807
+ * `LazyHGrid`: `LazyVGrid` turned on its side. The `rows` share the height
808
+ * the grid is proposed; the cells fill them column by column, `spacing`
809
+ * (default 8) apart. Children are the cells (flattened). Nothing is lazy.
810
+ */
811
+ export interface LazyHGridProps {
812
+ rows: GridItem[];
813
+ spacing: number | null;
814
+ alignment: FrameAlignment;
815
+ }
806
816
  export type GaugeStyle =
807
817
  | 'automatic'
808
818
  | 'linearCapacity'
@@ -859,6 +869,12 @@ export type ToolbarPlacement = 'trailing' | 'leading' | 'principal' | 'bottom';
859
869
  */
860
870
  export interface ToolbarProps {
861
871
  placement: ToolbarPlacement;
872
+ /**
873
+ * A `ToolbarSpacer` (no children): it separates glass groups, and in a
874
+ * bottom bar flexible ones share the room left over and push the groups
875
+ * apart; fixed ones are 8 wide.
876
+ */
877
+ spacer?: 'flexible' | 'fixed';
862
878
  }
863
879
  /**
864
880
  * `.searchable(text:)`, an extra child of a `navscreen`: a 36pt rounded
@@ -920,6 +936,7 @@ export interface ElementPropsByKind {
920
936
  grid: GridProps;
921
937
  gridrow: GridRowProps;
922
938
  lazyvgrid: LazyVGridProps;
939
+ lazyhgrid: LazyHGridProps;
923
940
  gauge: GaugeProps;
924
941
  decorated: DecoratedProps;
925
942
  menu: MenuProps;
@@ -1316,6 +1333,11 @@ export interface GeometryEvent {
1316
1333
  y?: number;
1317
1334
  /** With `reportsFrame`: how far the device's safe area reaches into the box on each edge. */
1318
1335
  safeArea?: { top: number; leading: number; bottom: number; trailing: number };
1336
+ /**
1337
+ * With `reportsFrame`, inside a scroll view or list: its visible rectangle
1338
+ * in the root's coordinates (whole px) and the axis it scrolls along.
1339
+ */
1340
+ scrollView?: { x: number; y: number; width: number; height: number; axis: 'horizontal' | 'vertical' };
1319
1341
  }
1320
1342
 
1321
1343
  /** Host measurements of one subview of a `layout` element (points, 2 decimals; `"infinity"` for an unbounded axis). */
@@ -2161,6 +2183,15 @@ export interface Style {
2161
2183
  contentTransition?: { kind: 'numericText' | 'opacity' | 'interpolate'; countsDown?: boolean };
2162
2184
  }
2163
2185
 
2186
+ export interface Style {
2187
+ /**
2188
+ * `.containerRelativeFrame(_:…)`: on the marked axes the box is
2189
+ * `a × container + b` long, the container being the nearest scroll view's
2190
+ * visible area (or the screen), and its content is aligned in that frame.
2191
+ */
2192
+ containerRelativeFrame?: { horizontal?: boolean; vertical?: boolean; a: number; b: number; alignment: FrameAlignment };
2193
+ }
2194
+
2164
2195
  /**
2165
2196
  * A mouse (or pen) entered (`value: true`) or left a box whose style has
2166
2197
  * `hover`. Touch never hovers.
package/src/renderer.ts CHANGED
@@ -86,6 +86,7 @@ import {
86
86
  type HStackProps,
87
87
  type LayoutEvent,
88
88
  type LazyVGridProps,
89
+ type LazyHGridProps,
89
90
  type ImageProps,
90
91
  type InspectorProps,
91
92
  type ListProps,
@@ -223,6 +224,7 @@ const ALL_KINDS: ReadonlySet<string> = new Set<ElementKind>([
223
224
  'grid',
224
225
  'gridrow',
225
226
  'lazyvgrid',
227
+ 'lazyhgrid',
226
228
  'gauge',
227
229
  'decorated',
228
230
  'menu',
@@ -335,6 +337,8 @@ export class Renderer {
335
337
  /** A frame check of the `reportsFrame` readers is queued for the next animation frame (after a scroll). */
336
338
  private frameCheckQueued = false;
337
339
  private readonly hostContents = new Map<ElementId, HTMLElement>();
340
+ /** The `host` elements, adopted or still waiting for their content. */
341
+ private readonly hosts = new Set<ElementId>();
338
342
  private readonly hostSizes = new WeakMap<HTMLElement, { width: number; height: number }>();
339
343
  private readonly hostMeasures = new Map<string, { width: number; height: number }>();
340
344
  private readonly stopHostChanges: () => void;
@@ -835,10 +839,19 @@ export class Renderer {
835
839
 
836
840
  /**
837
841
  * A `host` element's content changed (the page said so, or it resized):
838
- * the hosts are measured again and the tree laid out.
842
+ * the hosts are measured again and the tree laid out. A host whose content
843
+ * was not registered when its element was created (a page that registers
844
+ * from an effect, after its commit) adopts it now.
839
845
  */
840
846
  private hostContentChanged(): void {
841
- if (this.commits === 0 || this.hostContents.size === 0) return;
847
+ if (this.commits === 0) return;
848
+ for (const id of this.hosts) {
849
+ if (this.hostContents.has(id)) continue;
850
+ const el = this.elements.get(id);
851
+ const node = this.nodes.get(id);
852
+ if (el && node) this.adoptHostContent(id, el, node.props as unknown as HostProps);
853
+ }
854
+ if (this.hostContents.size === 0) return;
842
855
  for (const id of this.hostContents.keys()) {
843
856
  const node = this.nodes.get(id);
844
857
  if (node) this.engine?.invalidate(node);
@@ -1173,6 +1186,20 @@ export class Renderer {
1173
1186
  event.y = y;
1174
1187
  event.safeArea = safeAreaOverlap({ x, y, width, height }, env.screen, env.safeArea);
1175
1188
  key += `@${x},${y}/${event.safeArea.top},${event.safeArea.leading},${event.safeArea.bottom},${event.safeArea.trailing}`;
1189
+ // The visible rectangle of the scroll view (or list) the reader is in.
1190
+ const scroller = el?.parentElement?.closest<HTMLElement>('.sb-scrollview, .sb-list');
1191
+ if (scroller) {
1192
+ const view = scroller.getBoundingClientRect();
1193
+ event.scrollView = {
1194
+ x: Math.round(view.left - rootRect.left),
1195
+ y: Math.round(view.top - rootRect.top),
1196
+ width: Math.round(view.width),
1197
+ height: Math.round(view.height),
1198
+ axis: scroller.dataset['sbAxes'] === 'horizontal' ? 'horizontal' : 'vertical',
1199
+ };
1200
+ const v = event.scrollView;
1201
+ key += `/${v.x},${v.y},${v.width},${v.height},${v.axis}`;
1202
+ }
1176
1203
  }
1177
1204
  const last = this.reportedGeometry.get(id);
1178
1205
  if (last && last.key === key) continue;
@@ -1386,7 +1413,10 @@ export class Renderer {
1386
1413
  applyProps(el, kind, props, this.assets);
1387
1414
  this.noteScroll(id, kind, props);
1388
1415
  if (kind === 'lazyspacer') this.lazySpacers.add(id);
1389
- if (kind === 'host') this.adoptHostContent(id, el, props as HostProps);
1416
+ if (kind === 'host') {
1417
+ this.hosts.add(id);
1418
+ this.adoptHostContent(id, el, props as HostProps);
1419
+ }
1390
1420
  }
1391
1421
 
1392
1422
  /** A `host` element takes the page's content element (src/hostContent.ts) and watches its size. */
@@ -1699,6 +1729,7 @@ export class Renderer {
1699
1729
  this.hostObserver?.unobserve(content);
1700
1730
  this.hostContents.delete(id);
1701
1731
  }
1732
+ this.hosts.delete(id);
1702
1733
  this.elements.delete(id);
1703
1734
  this.boxes.delete(id);
1704
1735
  this.dirty.delete(id);
@@ -2739,6 +2770,8 @@ function applyProps(el: HTMLElement, kind: ElementKind, props: ElementProps, ass
2739
2770
  const placement = (props as ToolbarProps).placement;
2740
2771
  el.dataset['sbPlacement'] =
2741
2772
  placement === 'leading' || placement === 'principal' || placement === 'bottom' ? placement : 'trailing';
2773
+ // A `ToolbarSpacer`: draws nothing.
2774
+ el.toggleAttribute('data-sb-spacer', typeof (props as ToolbarProps).spacer === 'string');
2742
2775
  break;
2743
2776
  }
2744
2777
  case 'searchfield':
@@ -2825,6 +2858,12 @@ function applyProps(el: HTMLElement, kind: ElementKind, props: ElementProps, ass
2825
2858
  el.dataset['sbColumns'] = String(Array.isArray(grid.columns) ? grid.columns.length : 0);
2826
2859
  break;
2827
2860
  }
2861
+ case 'lazyhgrid': {
2862
+ const grid = props as LazyHGridProps;
2863
+ el.dataset['sbAlignment'] = grid.alignment ?? 'center';
2864
+ el.dataset['sbRows'] = String(Array.isArray(grid.rows) ? grid.rows.length : 0);
2865
+ break;
2866
+ }
2828
2867
  case 'gauge':
2829
2868
  applyGauge(el, props as GaugeProps);
2830
2869
  break;
package/src/scrollEdge.ts CHANGED
@@ -4,8 +4,10 @@
4
4
  * has its content inset by the bar (the engine's `bottomInset` chrome rect,
5
5
  * layout/engine.ts). While content is under the bar (more of it below) its
6
6
  * screen, or the tab it fills, shows the effect over it, behind the bar:
7
- * `data-sb-scrolled-bottom` with `--sb-bottom-edge` the bar's height
8
- * (styles.css). The top edge's effect is the bar's own (largeTitle.ts).
7
+ * `data-sb-scrolled-bottom` with `--sb-bottom-edge` the bar's height and
8
+ * `--sb-edge-bottom` its strength, which ramps down over the last
9
+ * `EDGE_RAMP` px of content (styles.css). The top edge's effect is the
10
+ * bar's own (largeTitle.ts).
9
11
  *
10
12
  * `.scrollEdgeEffectStyle(_:for:)` / `.scrollEdgeEffectHidden(_:for:)` reach
11
13
  * the scroll view as `edgeEffect` props, which become `data-sb-edge-top` /
@@ -16,6 +18,24 @@
16
18
  import type { EdgeEffectProps } from './protocol';
17
19
  import type { Rect } from './layout';
18
20
 
21
+ /**
22
+ * How far content has to be under a bar for its edge effect to be at full
23
+ * strength: the effect ramps up over this scroll distance (and back down as
24
+ * the end comes clear of a bottom bar) rather than switching on, which stood
25
+ * out over content that is not the background color (a photo).
26
+ */
27
+ export const EDGE_RAMP = 40;
28
+
29
+ /** The strength (0…1) of an edge effect over `distance` px of content under its bar, as a CSS value. */
30
+ export function edgeStrength(distance: number): string {
31
+ return String(Math.round(Math.min(1, Math.max(0, distance / EDGE_RAMP)) * 1000) / 1000);
32
+ }
33
+
34
+ /** Sets a CSS custom property, untouched when unchanged. */
35
+ export function setProperty(el: HTMLElement, name: string, value: string): void {
36
+ if (el.style.getPropertyValue(name) !== value) el.style.setProperty(name, value);
37
+ }
38
+
19
39
  const bound = new WeakSet<HTMLElement>();
20
40
  /** The scroll view whose state each screen's (or tab's) bottom effect shows. */
21
41
  const claimedBy = new WeakMap<HTMLElement, HTMLElement>();
@@ -63,8 +83,9 @@ export function syncScrollEdgeBottom(scroller: HTMLElement): void {
63
83
  claimedBy.set(owner, scroller);
64
84
  // Content is under the bar until the end has scrolled clear of it (the
65
85
  // inset is room past the content's end).
66
- const under = scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop > 1;
67
- setValue(owner, 'sbScrolledBottom', under ? '' : null);
86
+ const remaining = scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop;
87
+ setValue(owner, 'sbScrolledBottom', remaining > 1 ? '' : null);
88
+ setProperty(owner, '--sb-edge-bottom', edgeStrength(remaining));
68
89
  setValue(owner, 'sbEdgeBottom', scroller.dataset['sbEdgeBottom'] ?? null);
69
90
  const height = scroller.style.getPropertyValue('--sb-bottom-edge');
70
91
  if (owner.style.getPropertyValue('--sb-bottom-edge') !== height) owner.style.setProperty('--sb-bottom-edge', height);
@@ -73,6 +94,7 @@ export function syncScrollEdgeBottom(scroller: HTMLElement): void {
73
94
  function release(owner: HTMLElement): void {
74
95
  claimedBy.delete(owner);
75
96
  setValue(owner, 'sbScrolledBottom', null);
97
+ owner.style.removeProperty('--sb-edge-bottom');
76
98
  setValue(owner, 'sbEdgeBottom', null);
77
99
  owner.style.removeProperty('--sb-bottom-edge');
78
100
  }