@swiftbrowser/web 0.5.2 → 0.5.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.
package/README.md CHANGED
@@ -308,7 +308,9 @@ scripts/build-wasm.sh Todos # the Todos example only
308
308
  `sessionStorage['sb-state:<App>']`. On boot the snapshot is removed from
309
309
  storage and passed to the new module as the WASI environment variable
310
310
  `SB_STATE=<json>`, e.g. `SB_STATE={"ContentView#0":3}`. Modules without the
311
- snapshot exports (Phase 1) are reloaded without state.
311
+ snapshot exports (Phase 1) are reloaded without state. This is the dev
312
+ server's only (`import.meta.hot`): a production build never stashes or
313
+ restores state, so a reload of a built site starts fresh.
312
314
 
313
315
  Mock mode never stashes state. `window.__sb.snapshotState()` returns the
314
316
  current snapshot for inspection.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swiftbrowser/web",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "type": "module",
5
5
  "description": "SwiftBrowser web renderer and dev server: applies render ops from a Swift/Wasm app to the DOM inside an iPhone frame.",
6
6
  "repository": {
@@ -173,6 +173,11 @@ const SHEET_MEDIUM_HEIGHT = 433;
173
173
  // iOS 26: without a grabber a sheet's content (and a navigation bar's row)
174
174
  // starts 16 below its top.
175
175
  const SHEET_CONTENT_TOP = 16;
176
+ // iPadOS: in a regular width a sheet is a form sheet, a card centered on the
177
+ // screen at UIKit's form-sheet size (clamped to the screen less a margin),
178
+ // whatever its detents; a full-screen cover stays full screen.
179
+ const FORM_SHEET: Size = { width: 540, height: 620 };
180
+ const FORM_SHEET_MARGIN = 20;
176
181
  const LIST_INSET = 16;
177
182
  const LIST_CELL_PADDING = 16;
178
183
  // iOS 26 (measured in the iOS 26.2 simulator, Examples/Fidelity, in thirds
@@ -200,7 +205,7 @@ const LIST_SECTION_GAP = 107 / 3;
200
205
  const LIST_SECTION_GAP_AFTER_FOOTER = 74 / 3;
201
206
  const LIST_CHEVRON_WIDTH = 20;
202
207
  // Phase 4.
203
- // iOS 26: the tab bar is a 62-tall glass capsule floating 22 above the bottom
208
+ // iOS 26: the tab bar is a 62-tall glass capsule floating 21 above the bottom
204
209
  // edge (8 when the tab view ends above the screen bottom), its items in equal
205
210
  // slots inside a 4 inset. It is as wide as its tabs (268/3 each, measured in
206
211
  // the 26.2 simulator: two tabs make a 186.67 capsule), centered, and at most
@@ -208,7 +213,7 @@ const LIST_CHEVRON_WIDTH = 20;
208
213
  const TAB_BAR_HEIGHT = 62;
209
214
  const TAB_BAR_ITEM_WIDTH = 268 / 3;
210
215
  const TAB_BAR_MARGIN_H = 21;
211
- const TAB_BAR_MARGIN_BOTTOM = 22;
216
+ const TAB_BAR_MARGIN_BOTTOM = 21;
212
217
  const TAB_BAR_MARGIN_BOTTOM_INSET = 8;
213
218
  const TAB_BAR_ITEM_INSET = 4;
214
219
  const PICKER_SEGMENTED_HEIGHT = 32;
@@ -241,11 +246,24 @@ const NAV_ITEM_SPACING = 16;
241
246
  const NAV_ITEM_MARGIN = 16;
242
247
  // iOS 26: the back button is a 44pt glass circle 16 from the leading edge,
243
248
  // and the bar's leading and trailing items sit in glass capsules 44 tall,
244
- // each item in a slot at least 44 wide (an icon: a circle; text: 12 either
245
- // side), the groups 8 apart.
249
+ // each item in a slot: a symbol the 44 circle, anything else (text, a
250
+ // label) its width and 12 either side, at least 44; the groups 8 apart.
246
251
  const NAV_GLASS_SIZE = 44;
247
252
  const NAV_GLASS_PAD_H = 12;
248
253
  const NAV_GROUP_SPACING = 8;
254
+
255
+ /** A bar item that is only a symbol (an `image`, or a button, menu or link around one). */
256
+ function isSymbolItem(item: LayoutNode): boolean {
257
+ const inner = unwrapStyled(item);
258
+ if (inner.kind === 'image') return true;
259
+ return inner.children.length === 1 && isSymbolItem(inner.children[0] as LayoutNode);
260
+ }
261
+
262
+ /** A bar item's slot in its glass capsule. */
263
+ function navSlot(item: LayoutNode, width: number): number {
264
+ if (isSymbolItem(item) && width <= NAV_GLASS_SIZE) return NAV_GLASS_SIZE;
265
+ return Math.max(NAV_GLASS_SIZE, width + 2 * NAV_GLASS_PAD_H);
266
+ }
249
267
  // iOS 26 (measured in the 26.2 simulator, Examples/Fidelity `bottombar*`): a
250
268
  // bottom toolbar's items float on Liquid Glass in a 48 row 28 above the
251
269
  // screen bottom (as the search field does) and 28 in from the sides.
@@ -1152,6 +1170,9 @@ export class LayoutPass {
1152
1170
  // an `inspector` its trailing one to its panel).
1153
1171
  // Phase 8: an `alert` is a full-screen layer too (scrim + card).
1154
1172
  this.place(root, ctx, { width: screen.width, height: screen.height }, 0, 0);
1173
+ } else if (root.kind === 'sheet' && this.sheetIsForm(root)) {
1174
+ const rect = this.formSheetRect();
1175
+ this.place(root, ctx, { width: rect.width, height: rect.height }, rect.x, rect.y);
1155
1176
  } else if (root.kind === 'sheet') {
1156
1177
  const floats = this.sheetFloats(root);
1157
1178
  const insetH = floats ? SHEET_FLOAT_INSET_H : 0;
@@ -1524,6 +1545,7 @@ export class LayoutPass {
1524
1545
  // GeometryReader: the proposal, 10 on unconstrained axes.
1525
1546
  return { width: p.width ?? SHAPE_DEFAULT, height: p.height ?? SHAPE_DEFAULT };
1526
1547
  case 'sheet':
1548
+ if (this.sheetIsForm(node)) return { width: p.width ?? this.formSheetRect().width, height: this.formSheetRect().height };
1527
1549
  return { width: p.width ?? this.env.screen.width, height: this.sheetHeight(node) };
1528
1550
  // Phase 8 controls.
1529
1551
  case 'alert':
@@ -1771,8 +1793,24 @@ export class LayoutPass {
1771
1793
  return Array.isArray(detents) && str(detents[0]) === 'full';
1772
1794
  }
1773
1795
 
1796
+ /** iPadOS: a sheet in a regular width (not a full-screen cover) is a centered form sheet. */
1797
+ private sheetIsForm(node: LayoutNode): boolean {
1798
+ return this.env.screen.width >= REGULAR_WIDTH && !this.sheetIsFull(node);
1799
+ }
1800
+
1801
+ /** The form sheet's card: UIKit's size, clamped to the safe area less a margin, centered in it. */
1802
+ private formSheetRect(): Rect {
1803
+ const { screen, safeArea } = this.env;
1804
+ const top = safeArea.top + FORM_SHEET_MARGIN;
1805
+ const room = { width: screen.width - 2 * FORM_SHEET_MARGIN, height: screen.height - top - safeArea.bottom - FORM_SHEET_MARGIN };
1806
+ const width = Math.max(0, Math.min(FORM_SHEET.width, room.width));
1807
+ const height = Math.max(0, Math.min(FORM_SHEET.height, room.height));
1808
+ return { x: Math.round((screen.width - width) / 2), y: Math.round(top + (room.height - height) / 2), width, height };
1809
+ }
1810
+
1774
1811
  /** iOS 26: a sheet whose first detent is a partial one floats inset from the screen edges. */
1775
1812
  private sheetFloats(node: LayoutNode): boolean {
1813
+ if (this.sheetIsForm(node)) return false;
1776
1814
  const detents = node.props['detents'];
1777
1815
  const first = Array.isArray(detents) ? str(detents[0]) : null;
1778
1816
  return first === 'medium' || first?.startsWith('height:') === true || first?.startsWith('fraction:') === true;
@@ -1780,6 +1818,7 @@ export class LayoutPass {
1780
1818
 
1781
1819
  /** iOS 26 shows the grabber only for a sheet with more than one detent. */
1782
1820
  private sheetHasGrabber(node: LayoutNode): boolean {
1821
+ if (this.sheetIsForm(node)) return false;
1783
1822
  const detents = node.props['detents'];
1784
1823
  return Array.isArray(detents) && detents.length > 1 && !this.sheetIsFull(node);
1785
1824
  }
@@ -3097,7 +3136,7 @@ export class LayoutPass {
3097
3136
  const sizes = toolbar.children.map((item) => this.measure(item, itemCtx, { width: null, height: null }));
3098
3137
  const placement = str(toolbar.props['placement']) ?? 'trailing';
3099
3138
  if (placement === 'leading' || placement === 'trailing') {
3100
- const slots = sizes.map((s) => Math.max(NAV_GLASS_SIZE, s.width + 2 * NAV_GLASS_PAD_H));
3139
+ const slots = sizes.map((s, i) => navSlot(toolbar.children[i] as LayoutNode, s.width));
3101
3140
  return { toolbar, placement, sizes, slots, total: slots.reduce((sum, w) => sum + w, 0) };
3102
3141
  }
3103
3142
  const total = sizes.reduce((sum, s) => sum + s.width, 0) + Math.max(0, sizes.length - 1) * NAV_ITEM_SPACING;
@@ -3125,7 +3164,7 @@ export class LayoutPass {
3125
3164
  const itemCtx = this.childContext(toolbar, cctx);
3126
3165
  return toolbar.children.map((item) => {
3127
3166
  const itemSize = this.measure(item, itemCtx, { width: null, height: null });
3128
- return { toolbar, item, size: itemSize, slot: Math.max(NAV_GLASS_SIZE, itemSize.width + 2 * NAV_GLASS_PAD_H) };
3167
+ return { toolbar, item, size: itemSize, slot: navSlot(item, itemSize.width) };
3129
3168
  });
3130
3169
  });
3131
3170
  // Items of one toolbar share its glass capsule; separate toolbars sit 8 apart, as the trailing groups do.
@@ -4056,6 +4095,7 @@ export class LayoutPass {
4056
4095
  };
4057
4096
  const chrome: Record<string, Rect> = { content };
4058
4097
  if (floats) chrome['floating'] = { x, y, width: size.width, height: size.height };
4098
+ if (this.sheetIsForm(node)) chrome['form'] = { x, y, width: size.width, height: size.height };
4059
4099
  if (grabber) {
4060
4100
  chrome['grabber'] = {
4061
4101
  x: x + (size.width - SHEET_GRABBER.width) / 2,
package/src/layoutDom.ts CHANGED
@@ -616,6 +616,8 @@ function applyChrome(
616
616
  placeChrome(grabber, chrome?.['grabber'], frame);
617
617
  // iOS 26: a sheet at a partial detent floats as a rounded card (styles.css).
618
618
  setFlag(el, 'sbFloating', chrome?.['floating'] ? '' : null);
619
+ // iPadOS: in a regular width, a centered form sheet (styles.css).
620
+ setFlag(el, 'sbForm', chrome?.['form'] ? '' : null);
619
621
  return;
620
622
  }
621
623
  case 'tabview': {
@@ -633,8 +635,7 @@ function applyChrome(
633
635
  return;
634
636
  }
635
637
  case 'navsplit': {
636
- // Phase 7: the hairline between the two columns, and the sidebar toggle.
637
- placeChrome(el.querySelector<HTMLElement>(':scope > .sb-navsplit-separator'), chrome?.['separator'], box);
638
+ // Phase 7: the sidebar toggle (the sidebar column draws the hairline).
638
639
  placeChrome(el.querySelector<HTMLElement>(':scope > .sb-navsplit-toggle'), chrome?.['toggle'], box);
639
640
  setFlag(el, 'sbSidebarHidden', node.props['sidebarVisible'] === false ? '' : null);
640
641
  return;
package/src/main.ts CHANGED
@@ -479,6 +479,9 @@ async function loadAssetCatalog(app: string): Promise<AssetCatalog> {
479
479
  // ---------------------------------------------------------------------------
480
480
  // Hot reload: state snapshot across full reloads
481
481
  // ---------------------------------------------------------------------------
482
+ //
483
+ // Only the dev server's page (`import.meta.hot`) keeps state across a reload:
484
+ // a built site starts fresh, as an app does at launch.
482
485
 
483
486
  const stateKey = `sb-state:${requestedApp}`;
484
487
 
@@ -496,6 +499,7 @@ function stashState(): void {
496
499
 
497
500
  /** Read and delete a stashed snapshot (it is only ever used once). */
498
501
  function takeStashedState(): string | null {
502
+ if (!import.meta.hot) return null;
499
503
  try {
500
504
  const snapshot = sessionStorage.getItem(stateKey);
501
505
  if (snapshot !== null) sessionStorage.removeItem(stateKey);
@@ -505,10 +509,9 @@ function takeStashedState(): string | null {
505
509
  }
506
510
  }
507
511
 
508
- window.addEventListener('beforeunload', stashState);
509
- window.addEventListener('pagehide', stashState);
510
-
511
512
  if (import.meta.hot) {
513
+ window.addEventListener('beforeunload', stashState);
514
+ window.addEventListener('pagehide', stashState);
512
515
  import.meta.hot.on('vite:beforeFullReload', () => stashState());
513
516
  import.meta.hot.on('sb:build-start', (payload: BuildStartPayload) => {
514
517
  buildStatus.textContent = `Rebuilding ${payload.apps.join(', ')}…`;
package/src/renderer.ts CHANGED
@@ -27,7 +27,7 @@
27
27
  * control claimed.
28
28
  *
29
29
  * Phase 7: `navsplit` holds two `navstack` columns with a hairline between
30
- * them (`.sb-navsplit-separator`, placed from the engine's `separator` rect);
30
+ * them (drawn by the sidebar column in the engine's 1px `separator` gap);
31
31
  * a `navlink` with `selected: true` carries `data-sb-selected` (a rounded fill
32
32
  * background; the engine drops its chevron inside a split view's sidebar);
33
33
  * the candidates a `viewthatfits` did not choose get the `hidden` attribute.
@@ -187,6 +187,25 @@ function animationTokenOf(kind: ElementKind | undefined, props: unknown): string
187
187
  return token == null ? undefined : String(token);
188
188
  }
189
189
 
190
+ /** Duration of the menu's fade out; keep in sync with styles.css (`sb-menu-out`). */
191
+ export const MENU_EXIT_MS = 150;
192
+
193
+ /**
194
+ * Put a closed popover away: it stops being the menu (no `sb-picker-menu`
195
+ * class or `menu` role, inert) and fades out where it was, then leaves the
196
+ * DOM, as the open animation brought it in.
197
+ */
198
+ function dismissMenu(menu: HTMLElement): void {
199
+ menu.classList.replace('sb-picker-menu', 'sb-picker-menu-exit');
200
+ menu.removeAttribute('role');
201
+ menu.setAttribute('aria-hidden', 'true');
202
+ menu.inert = true;
203
+ const remove = (): void => menu.remove();
204
+ menu.addEventListener('animationend', remove, { once: true });
205
+ // Without animations (reduced motion, a hidden page, jsdom) it still goes.
206
+ setTimeout(remove, MENU_EXIT_MS + 100);
207
+ }
208
+
190
209
  /** Duration of the navigation push/pop slide; keep in sync with styles.css (`--sb-nav-easing`). */
191
210
  export const NAV_TRANSITION_MS = 400;
192
211
  /** Duration of the sheet present/dismiss slide; keep in sync with styles.css. */
@@ -1373,7 +1392,7 @@ export class Renderer {
1373
1392
  if (!menu) return;
1374
1393
  this.menu = null;
1375
1394
  document.removeEventListener('click', this.onDocumentClick, true);
1376
- menu.el.remove();
1395
+ dismissMenu(menu.el);
1377
1396
  menu.anchor.setAttribute('aria-expanded', 'false');
1378
1397
  // Lazy menus: Swift drops the items. After the current task, so that the
1379
1398
  // tap of the item chosen (sent right after closing) reaches its button.
@@ -2209,12 +2228,11 @@ function createShell(kind: ElementKind): HTMLElement {
2209
2228
  return el;
2210
2229
  }
2211
2230
  case 'navsplit': {
2212
- // Phase 7: the two columns go into the slot; the separator is chrome
2213
- // positioned from the engine's `separator` rect.
2231
+ // Phase 7: the two columns go into the slot; the sidebar column draws
2232
+ // the hairline in the gap the engine leaves (styles.css), so it slides
2233
+ // with the column.
2214
2234
  const el = document.createElement('div');
2215
2235
  const slot = div('sb-slot sb-navsplit-columns');
2216
- const separator = div('sb-navsplit-separator');
2217
- separator.setAttribute('aria-hidden', 'true');
2218
2236
  // iPadOS: the sidebar toggle, at the engine's `toggle` rect; a tap
2219
2237
  // asks Swift to show or hide the sidebar.
2220
2238
  const toggle = document.createElement('button');
@@ -2223,7 +2241,7 @@ function createShell(kind: ElementKind): HTMLElement {
2223
2241
  toggle.setAttribute('aria-label', 'Toggle Sidebar');
2224
2242
  const symbol = renderSymbol('sidebar.left');
2225
2243
  if (symbol) toggle.append(symbol);
2226
- el.append(slot, separator, toggle);
2244
+ el.append(slot, toggle);
2227
2245
  SLOTS.set(el, slot);
2228
2246
  return el;
2229
2247
  }
@@ -2742,7 +2760,23 @@ function applyTextEditor(area: HTMLTextAreaElement, props: TextEditorProps): voi
2742
2760
  // Props → DOM
2743
2761
  // ---------------------------------------------------------------------------
2744
2762
 
2763
+ /** Elements whose `aria-label` is their `accessibilityLabel` prop. */
2764
+ const LABELLED = new WeakSet<HTMLElement>();
2765
+
2745
2766
  function applyProps(el: HTMLElement, kind: ElementKind, props: ElementProps, assets: AssetCatalog): void {
2767
+ // A label that went away leaves the element's own (an image's name) to the kind below.
2768
+ if (LABELLED.delete(el)) el.removeAttribute('aria-label');
2769
+ applyKindProps(el, kind, props, assets);
2770
+ // `.accessibilityLabel(_:)`, or a `Label`'s title (an icon-only label in
2771
+ // a button is named by it): the element's accessible name.
2772
+ const label = (props as { accessibilityLabel?: unknown }).accessibilityLabel;
2773
+ if (typeof label === 'string' && label !== '') {
2774
+ el.setAttribute('aria-label', label);
2775
+ LABELLED.add(el);
2776
+ }
2777
+ }
2778
+
2779
+ function applyKindProps(el: HTMLElement, kind: ElementKind, props: ElementProps, assets: AssetCatalog): void {
2746
2780
  switch (kind) {
2747
2781
  case 'host':
2748
2782
  el.dataset['sbSizing'] = (props as HostProps).sizing;
@@ -3103,8 +3137,14 @@ const SHAPE_RADIUS: Record<Exclude<ShapeProps['shape'], 'path'>, (cornerRadius:
3103
3137
  * flat). The stroke is a CSS border, which lies inside the box: that is
3104
3138
  * `.strokeBorder` (`inset: true`); a plain `.stroke` is drawn the same way,
3105
3139
  * and `data-sb-stroke` records which one it was.
3140
+ *
3141
+ * A shape that is only stroked is marked `data-sb-stroke-only`: it takes no
3142
+ * hits (styles.css), so an outline overlaid on a control leaves its box to
3143
+ * what is under it, as SwiftUI hit-tests a stroke only along its line.
3106
3144
  */
3107
3145
  function applyShape(el: HTMLElement, props: ShapeProps): void {
3146
+ if (props.stroke && !props.fill) el.dataset['sbStrokeOnly'] = '';
3147
+ else delete el.dataset['sbStrokeOnly'];
3108
3148
  if (props.shape === 'path') {
3109
3149
  // A custom shape: an SVG of its path (src/pathShape.ts).
3110
3150
  el.dataset['sbShape'] = 'path';
package/src/styles.css CHANGED
@@ -83,11 +83,28 @@
83
83
  --sb-color-grabber: rgba(60, 60, 67, 0.3);
84
84
  /* Phase 4 */
85
85
  --sb-color-tabbar-background: rgba(249, 249, 249, 0.94);
86
- /* iOS 26 Liquid Glass, approximated: a near-white translucent fill over a
87
- blurred backdrop with a soft shadow (bar buttons, the tab bar, the
88
- bottom search field). */
89
- --sb-glass-background: rgba(252, 252, 255, 0.86);
90
- --sb-glass-shadow: 0 4px 24px rgba(0, 0, 0, 0.08), 0 0 0 0.5px rgba(0, 0, 0, 0.04);
86
+ /* iOS 26 Liquid Glass, approximated: a white fill at 70% over a blurred
87
+ backdrop (pure white over white content, 251 over the grouped
88
+ background, as on an iPhone) with a soft shadow and a 1pt rim that catches the light
89
+ at the top-left and bottom-right (bar buttons, the tab bar, the bottom
90
+ toolbar and search field; the "Liquid Glass rim" rule draws it). The rim
91
+ is white in both schemes: a faint bright edge on the near-white fill, the
92
+ gray hairline iOS draws on the dark one. Alphas measured on an iPhone
93
+ (3x): over the dark fill the rim is 16% white at its brightest, 12.5%
94
+ at the sides and 4.5% at the top-right and bottom-left. */
95
+ --sb-glass-background: rgba(255, 255, 255, 0.7);
96
+ --sb-glass-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
97
+ --sb-glass-rim: conic-gradient(
98
+ rgba(255, 255, 255, 0.6) 0deg,
99
+ rgba(255, 255, 255, 0.3) 45deg,
100
+ rgba(255, 255, 255, 0.6) 90deg,
101
+ rgba(255, 255, 255, 0.95) 135deg,
102
+ rgba(255, 255, 255, 0.6) 180deg,
103
+ rgba(255, 255, 255, 0.3) 225deg,
104
+ rgba(255, 255, 255, 0.6) 270deg,
105
+ rgba(255, 255, 255, 0.95) 315deg,
106
+ rgba(255, 255, 255, 0.6) 360deg
107
+ );
91
108
  --sb-glass-selected: rgba(120, 120, 128, 0.16);
92
109
  --sb-color-segment-track: rgba(118, 118, 128, 0.12);
93
110
  --sb-color-segment-selected: #ffffff;
@@ -130,7 +147,18 @@
130
147
  --sb-color-field-background: #1c1c1e;
131
148
  --sb-color-placeholder: rgba(235, 235, 245, 0.3);
132
149
  --sb-glass-background: rgba(30, 30, 30, 0.84);
133
- --sb-glass-shadow: 0 4px 24px rgba(0, 0, 0, 0.4), 0 0 0 0.5px rgba(255, 255, 255, 0.08);
150
+ --sb-glass-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
151
+ --sb-glass-rim: conic-gradient(
152
+ rgba(255, 255, 255, 0.125) 0deg,
153
+ rgba(255, 255, 255, 0.045) 45deg,
154
+ rgba(255, 255, 255, 0.125) 90deg,
155
+ rgba(255, 255, 255, 0.16) 135deg,
156
+ rgba(255, 255, 255, 0.125) 180deg,
157
+ rgba(255, 255, 255, 0.045) 225deg,
158
+ rgba(255, 255, 255, 0.125) 270deg,
159
+ rgba(255, 255, 255, 0.16) 315deg,
160
+ rgba(255, 255, 255, 0.125) 360deg
161
+ );
134
162
  --sb-glass-selected: rgba(120, 120, 128, 0.32);
135
163
  /* Sheets float above the window and take the elevated system background. */
136
164
  --sb-color-sheet-background: #1c1c1e;
@@ -1374,6 +1402,25 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
1374
1402
  background: rgba(0, 0, 0, 0.22);
1375
1403
  }
1376
1404
 
1405
+ /* iPadOS: in a regular width a sheet is a form sheet, centered and rounded
1406
+ all around. It slides up from below the screen, not just its own height,
1407
+ and the presenting tree stays as it is (only dimmed). */
1408
+ .sb-sheet[data-sb-form] > .sb-sheet-card {
1409
+ --sb-sheet-offscreen: 100vh;
1410
+ border-radius: 26px;
1411
+ box-shadow: 0 10px 50px rgba(0, 0, 0, 0.25);
1412
+ }
1413
+
1414
+ /* In dark mode what a form sheet holds takes iOS's elevated colors, as the
1415
+ card does: the base ones are black and would paint it black. */
1416
+ .device[data-theme="dark"] .sb-sheet[data-sb-form] > .sb-sheet-card {
1417
+ --sb-color-system-background: #1c1c1e;
1418
+ --sb-color-secondary-system-background: #2c2c2e;
1419
+ --sb-color-grouped-background: #1c1c1e;
1420
+ --sb-color-grouped-cell: #2c2c2e;
1421
+ --sb-color-field-background: #2c2c2e;
1422
+ }
1423
+
1377
1424
  .sb-sheet-grabber {
1378
1425
  position: absolute;
1379
1426
  border-radius: 2.5px;
@@ -1422,13 +1469,13 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
1422
1469
  transition: transform 0.4s cubic-bezier(0.2, 0.8, 0.2, 1);
1423
1470
  }
1424
1471
 
1425
- .screen[data-sb-sheet="large"] > :not(.sb-sheet, .sb-exit-clone, .sb-picker-menu) {
1472
+ .screen[data-sb-sheet="large"]:where(:not(:has(> .sb-sheet[data-sb-sheet-position="top"][data-sb-form]))) > :not(.sb-sheet, .sb-exit-clone, .sb-picker-menu, .sb-picker-menu-exit) {
1426
1473
  transform: scale(0.92);
1427
1474
  }
1428
1475
 
1429
1476
  @keyframes sb-sheet-in {
1430
1477
  from {
1431
- transform: translateY(100%);
1478
+ transform: translateY(var(--sb-sheet-offscreen, 100%));
1432
1479
  }
1433
1480
  to {
1434
1481
  transform: translateY(0);
@@ -1440,7 +1487,7 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
1440
1487
  transform: translateY(var(--sb-sheet-drag, 0px));
1441
1488
  }
1442
1489
  to {
1443
- transform: translateY(100%);
1490
+ transform: translateY(var(--sb-sheet-offscreen, 100%));
1444
1491
  }
1445
1492
  }
1446
1493
 
@@ -1777,8 +1824,10 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
1777
1824
  stroke-width: 2.5;
1778
1825
  }
1779
1826
 
1780
- /* The option menu: anchored under (or above) the control by the renderer. */
1781
- .sb-picker-menu {
1827
+ /* The option menu: anchored under (or above) the control by the renderer.
1828
+ Closed, it fades out where it was (`.sb-picker-menu-exit`) and leaves. */
1829
+ .sb-picker-menu,
1830
+ .sb-picker-menu-exit {
1782
1831
  position: absolute;
1783
1832
  z-index: 6;
1784
1833
  border-radius: 13px;
@@ -1793,7 +1842,12 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
1793
1842
  animation: sb-menu-in 0.18s cubic-bezier(0.2, 0.8, 0.2, 1) both;
1794
1843
  }
1795
1844
 
1796
- .sb-picker-menu[data-sb-placement="above"] {
1845
+ .sb-picker-menu-exit {
1846
+ pointer-events: none;
1847
+ animation: sb-menu-out 0.15s ease-in both;
1848
+ }
1849
+
1850
+ :is(.sb-picker-menu, .sb-picker-menu-exit)[data-sb-placement="above"] {
1797
1851
  transform-origin: 50% 100%;
1798
1852
  }
1799
1853
 
@@ -1862,6 +1916,17 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
1862
1916
  }
1863
1917
  }
1864
1918
 
1919
+ @keyframes sb-menu-out {
1920
+ from {
1921
+ opacity: 1;
1922
+ transform: scale(1);
1923
+ }
1924
+ to {
1925
+ opacity: 0;
1926
+ transform: scale(0.96);
1927
+ }
1928
+ }
1929
+
1865
1930
  /* GeometryReader: a plain box the engine sizes to its proposal. */
1866
1931
  .sb-geometry {
1867
1932
  display: block;
@@ -1935,6 +2000,7 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
1935
2000
 
1936
2001
  @media (prefers-reduced-motion: reduce) {
1937
2002
  .sb-picker-menu,
2003
+ .sb-picker-menu-exit,
1938
2004
  .sb-segment::before {
1939
2005
  animation-duration: 0.01s;
1940
2006
  transition-duration: 0.01s;
@@ -2139,6 +2205,40 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
2139
2205
  display: none;
2140
2206
  }
2141
2207
 
2208
+ /* Liquid Glass rim: a 1pt ring at the edge of every glass surface whose
2209
+ brightness varies around it (`--sb-glass-rim`, a conic gradient brightest
2210
+ at the top-left and bottom-right, where iOS's glass catches the light,
2211
+ dimmest at the top-right and bottom-left). A gradient-filled pseudo-element
2212
+ the size of the surface, masked to its outer 1px (the content box cut out
2213
+ of the padding box); it takes no input and sits above the surface's items,
2214
+ which never reach the edge. */
2215
+ .sb-navbar-back::after,
2216
+ .sb-navbar-more::after,
2217
+ .sb-tabbar::after,
2218
+ .sb-toolbar[data-sb-placement="leading"]::after,
2219
+ .sb-toolbar[data-sb-placement="trailing"]::after,
2220
+ .sb-bottom-glass::after,
2221
+ .sb-searchfield::after,
2222
+ .sb-navsplit-toggle::after {
2223
+ content: "";
2224
+ position: absolute;
2225
+ inset: 0;
2226
+ z-index: 1;
2227
+ box-sizing: border-box;
2228
+ padding: 1px;
2229
+ border-radius: inherit;
2230
+ background: var(--sb-glass-rim);
2231
+ -webkit-mask:
2232
+ linear-gradient(#000 0 0) content-box,
2233
+ linear-gradient(#000 0 0);
2234
+ -webkit-mask-composite: xor;
2235
+ mask:
2236
+ linear-gradient(#000 0 0) content-box,
2237
+ linear-gradient(#000 0 0);
2238
+ mask-composite: exclude;
2239
+ pointer-events: none;
2240
+ }
2241
+
2142
2242
  /* iOS 26: a bar's leading and trailing items share a glass capsule (the
2143
2243
  engine sizes the group: 44 tall, each item in a slot at least 44 wide),
2144
2244
  drawn in the label color unless tinted. */
@@ -2268,7 +2368,9 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
2268
2368
  ------------------------------------------------------------------------- */
2269
2369
 
2270
2370
  /* NavigationSplitView: two navstack columns (protocol children, positioned by
2271
- the engine) with a hairline between them at the engine's `separator` rect. */
2371
+ the engine) with a hairline between them. The sidebar column draws it in
2372
+ the 1pt gap the engine leaves (its `separator` rect), so the hairline
2373
+ slides with the sidebar as the inspector's does with its panel. */
2272
2374
  .sb-navsplit {
2273
2375
  overflow: hidden;
2274
2376
  background: var(--sb-color-system-background);
@@ -2286,10 +2388,8 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
2286
2388
  box-shadow: -1px 0 0 var(--sb-color-separator);
2287
2389
  }
2288
2390
 
2289
- .sb-navsplit-separator {
2290
- position: absolute;
2291
- background: var(--sb-color-separator);
2292
- pointer-events: none;
2391
+ .sb-navsplit-columns > [data-sb-id]:first-child {
2392
+ box-shadow: 1px 0 0 var(--sb-color-separator);
2293
2393
  }
2294
2394
 
2295
2395
  /* iPadOS: the sidebar toggle, a 44pt glass circle at the leading edge of
@@ -2515,6 +2615,18 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
2515
2615
  pointer-events: auto;
2516
2616
  }
2517
2617
 
2618
+ /* A shape that is only stroked (`.stroke`, `.strokeBorder`) is hit along its
2619
+ line in SwiftUI, not across its box: an outline overlaid on a control must
2620
+ not swallow its taps. Its box takes none, except as the content of a
2621
+ gesture (`Circle().stroke().onTapGesture`), where it stays the target. */
2622
+ [data-sb-kind].sb-shape[data-sb-stroke-only] {
2623
+ pointer-events: none;
2624
+ }
2625
+
2626
+ :is(.sb-gesture, .sb-tapgesture) > [data-sb-kind].sb-shape[data-sb-stroke-only] {
2627
+ pointer-events: auto;
2628
+ }
2629
+
2518
2630
  :is([data-sb-hidden], [data-sb-disabled], [data-sb-hit-testing="false"], .sb-navscreen--pop, .sb-exit-clone, .sb-sheet--out, .sb-alert--out, .sb-tab[data-sb-tab-hidden]) [data-sb-kind],
2519
2631
  :is([data-sb-hidden], [data-sb-disabled], [data-sb-hit-testing="false"], .sb-navscreen--pop, .sb-exit-clone, .sb-sheet--out, .sb-alert--out, .sb-tab[data-sb-tab-hidden]) .sb-navbar > * {
2520
2632
  pointer-events: none !important;
package/src/symbols.ts CHANGED
@@ -1103,13 +1103,13 @@ const GENERIC_SHAPES = new Set(['circle', 'square', 'rectangle', 'app', 'capsule
1103
1103
  * is drawn as `person.crop.circle`, `trash.circle.fill` as `trash.fill` and
1104
1104
  * `speaker.wave.2.fill` as `speaker.wave.2`. A `.badge…` suffix is dropped,
1105
1105
  * then trailing parts one at a time; a name with `.fill` tries every filled
1106
- * form first. A `.slash` variant is never drawn as its base, which would
1106
+ * form first. A `.slash` variant keeps its slash (`speaker.slash.fill` is
1107
+ * drawn as `speaker.slash`) and is never drawn as its base, which would
1107
1108
  * mean the opposite (`bell.slash` is "notifications off"); nor is a name
1108
1109
  * drawn as a bare shape. Unresolved names get the caller's placeholder.
1109
1110
  */
1110
1111
  function resolveVariant(systemName: string): SymbolGlyph | undefined {
1111
1112
  let parts = systemName.split('.');
1112
- if (parts.includes('slash')) return undefined;
1113
1113
  const fill = parts.includes('fill');
1114
1114
  parts = parts.filter((part) => part !== 'fill');
1115
1115
  const badge = parts.indexOf('badge');
@@ -1130,7 +1130,9 @@ function resolveVariant(systemName: string): SymbolGlyph | undefined {
1130
1130
  }
1131
1131
  }
1132
1132
  const bases: string[] = [];
1133
- for (let length = parts.length; length > 0; length--) {
1133
+ // Never shorter than the slash.
1134
+ const shortest = Math.max(1, parts.indexOf('slash') + 1);
1135
+ for (let length = parts.length; length >= shortest; length--) {
1134
1136
  const base = parts.slice(0, length).join('.');
1135
1137
  if (length === 1 && parts.length > 1 && GENERIC_SHAPES.has(base)) break;
1136
1138
  bases.push(base);