@swiftbrowser/web 0.3.0 → 0.4.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.
@@ -0,0 +1,87 @@
1
+ /**
2
+ * iOS 26's scroll edge effect at the bottom: a list or scroll view that runs
3
+ * down under a tab bar, or under its screen's bottom toolbar or search field,
4
+ * has its content inset by the bar (the engine's `bottomInset` chrome rect,
5
+ * layout/engine.ts). While content is under the bar (more of it below) its
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).
9
+ *
10
+ * `.scrollEdgeEffectStyle(_:for:)` / `.scrollEdgeEffectHidden(_:for:)` reach
11
+ * the scroll view as `edgeEffect` props, which become `data-sb-edge-top` /
12
+ * `data-sb-edge-bottom` on it (`hard` or `hidden`; soft is the default) and
13
+ * are copied to the element that draws that edge's effect.
14
+ */
15
+
16
+ import type { EdgeEffectProps } from './protocol';
17
+ import type { Rect } from './layout';
18
+
19
+ const bound = new WeakSet<HTMLElement>();
20
+ /** The scroll view whose state each screen's (or tab's) bottom effect shows. */
21
+ const claimedBy = new WeakMap<HTMLElement, HTMLElement>();
22
+
23
+ /** A list's or scroll view's `edgeEffect` props, as `data-sb-edge-top` / `data-sb-edge-bottom`. */
24
+ export function applyEdgeEffect(el: HTMLElement, effect: EdgeEffectProps | undefined): void {
25
+ setValue(el, 'sbEdgeTop', edgeValue(effect?.top));
26
+ setValue(el, 'sbEdgeBottom', edgeValue(effect?.bottom));
27
+ }
28
+
29
+ /** Soft is the default effect; only the others are marked. */
30
+ function edgeValue(value: string | undefined): string | null {
31
+ return value === 'hard' || value === 'hidden' ? value : null;
32
+ }
33
+
34
+ /** The element that draws the bottom effect over a scroll view: its screen, else the tab it fills. */
35
+ function ownerOf(scroller: HTMLElement): HTMLElement | null {
36
+ return scroller.closest<HTMLElement>('.sb-navscreen') ?? scroller.closest<HTMLElement>('.sb-tab');
37
+ }
38
+
39
+ /**
40
+ * Called for every list and scroll view the layout reaches with the engine's
41
+ * `bottomInset` rect (absent when no bar covers its bottom).
42
+ */
43
+ export function bindScrollEdgeBottom(scroller: HTMLElement, covered: Rect | undefined): void {
44
+ const owner = ownerOf(scroller);
45
+ if (covered === undefined || covered.height <= 0) {
46
+ if (scroller.dataset['sbUnderBottom'] !== undefined) delete scroller.dataset['sbUnderBottom'];
47
+ if (owner && claimedBy.get(owner) === scroller) release(owner);
48
+ return;
49
+ }
50
+ setValue(scroller, 'sbUnderBottom', '');
51
+ scroller.style.setProperty('--sb-bottom-edge', `${covered.height}px`);
52
+ if (!bound.has(scroller)) {
53
+ bound.add(scroller);
54
+ scroller.addEventListener('scroll', () => syncScrollEdgeBottom(scroller), { passive: true });
55
+ }
56
+ syncScrollEdgeBottom(scroller);
57
+ }
58
+
59
+ export function syncScrollEdgeBottom(scroller: HTMLElement): void {
60
+ if (scroller.dataset['sbUnderBottom'] === undefined) return;
61
+ const owner = ownerOf(scroller);
62
+ if (!owner) return;
63
+ claimedBy.set(owner, scroller);
64
+ // Content is under the bar until the end has scrolled clear of it (the
65
+ // inset is room past the content's end).
66
+ const under = scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop > 1;
67
+ setValue(owner, 'sbScrolledBottom', under ? '' : null);
68
+ setValue(owner, 'sbEdgeBottom', scroller.dataset['sbEdgeBottom'] ?? null);
69
+ const height = scroller.style.getPropertyValue('--sb-bottom-edge');
70
+ if (owner.style.getPropertyValue('--sb-bottom-edge') !== height) owner.style.setProperty('--sb-bottom-edge', height);
71
+ }
72
+
73
+ function release(owner: HTMLElement): void {
74
+ claimedBy.delete(owner);
75
+ setValue(owner, 'sbScrolledBottom', null);
76
+ setValue(owner, 'sbEdgeBottom', null);
77
+ owner.style.removeProperty('--sb-bottom-edge');
78
+ }
79
+
80
+ /** Sets a data attribute (by dataset key) to `value`, or removes it for null; untouched when unchanged. */
81
+ export function setValue(el: HTMLElement, key: string, value: string | null): void {
82
+ if (value === null) {
83
+ if (el.dataset[key] !== undefined) delete el.dataset[key];
84
+ } else if (el.dataset[key] !== value) {
85
+ el.dataset[key] = value;
86
+ }
87
+ }
package/src/scrollTo.ts CHANGED
@@ -95,6 +95,12 @@ export function scrollToTarget(
95
95
  const viewport: Rect = { ...frame };
96
96
  const covered = result.chrome.get(at)?.['bottomInset'];
97
97
  if (covered) viewport.height = Math.max(0, covered.y - frame.y);
98
+ // Under its screen's bar the visible area starts below the bar.
99
+ const under = result.chrome.get(at)?.['topInset'];
100
+ if (under) {
101
+ viewport.y += under.height;
102
+ viewport.height = Math.max(0, viewport.height - under.height);
103
+ }
98
104
  let left = el.scrollLeft;
99
105
  let top = el.scrollTop;
100
106
  let changed = false;
package/src/styles.css CHANGED
@@ -5,6 +5,8 @@
5
5
  ------------------------------------------------------------------------- */
6
6
 
7
7
  :root {
8
+ /* SF Pro's ascender minus its cap height: where a line's capitals start. */
9
+ --sb-cap-inset: 0.2476em;
8
10
  /* The UINavigationController push curve: fast start, long settle. */
9
11
  --sb-nav-easing: cubic-bezier(0.32, 0.72, 0, 1);
10
12
  --sb-font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", Inter,
@@ -701,11 +703,20 @@ html[data-sb-mode="device"] .status {
701
703
  line-height: 1;
702
704
  }
703
705
 
706
+ /* A symbol fills its box as SF Symbols do: at a text size a circle symbol is
707
+ about 1.28× the font size (21.7 pt at body's 17), and the glyphs here
708
+ occupy 20 of their 24-unit grid (a little more with their stroke), so
709
+ the SVG is 1.43em, centred in the
710
+ line-height box the engine gives the image (the box is unchanged; the
711
+ glyph overflows it a little, as a symbol's bounds do). The stroke, 1.4
712
+ units, is then 1.5 px at body, the weight iOS draws regular symbols in. */
704
713
  .sb-image > svg {
705
714
  display: block;
706
- width: 1em;
707
- height: 1em;
715
+ flex: none;
716
+ width: 1.43em;
717
+ height: 1.43em;
708
718
  stroke: currentColor;
719
+ stroke-width: 1.4;
709
720
  }
710
721
 
711
722
  /* An indeterminate ProgressView: the activity indicator (symbols.ts), in
@@ -949,6 +960,19 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
949
960
  text-align: left;
950
961
  }
951
962
 
963
+ /* SF Pro's capitals start 0.2476em below the top of a line SwiftUI lays out
964
+ (ascender 0.952em − cap height 0.705em): trim the first line to its cap
965
+ height and put it there, so text the engine places lands where iOS draws
966
+ it, whatever font the page has. */
967
+ @supports (text-box: trim-start cap alphabetic) {
968
+ .sb-text,
969
+ .sb-section-header,
970
+ .sb-section-footer {
971
+ text-box: trim-start cap alphabetic;
972
+ padding-top: var(--sb-cap-inset);
973
+ }
974
+ }
975
+
952
976
  .sb-section-header[hidden],
953
977
  .sb-section-footer[hidden] {
954
978
  display: none;
@@ -1026,6 +1050,23 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
1026
1050
  pointer-events: none;
1027
1051
  }
1028
1052
 
1053
+ /* A text field row is the <input> (or <textarea>) itself, which has no
1054
+ ::after: its separator is a 1px background image in the same place. */
1055
+ .sb-section-rows > :is(input, textarea):not(:last-child),
1056
+ .sb-list-content > :is(input, textarea):not(:last-child):not(:has(+ .sb-section)) {
1057
+ background-image: linear-gradient(var(--sb-color-row-separator), var(--sb-color-row-separator));
1058
+ background-repeat: no-repeat;
1059
+ background-position: var(--sb-separator-leading, 16px) 100%;
1060
+ background-size: calc(100% - var(--sb-separator-leading, 16px) - 16px) 1px;
1061
+ }
1062
+
1063
+ .sb-section-rows > :is(input, textarea)[data-sb-separator-bottom="hidden"],
1064
+ .sb-list-content > :is(input, textarea)[data-sb-separator-bottom="hidden"],
1065
+ .sb-section-rows > :is(input, textarea):has(+ [data-sb-separator-top="hidden"]),
1066
+ .sb-list-content > :is(input, textarea):has(+ [data-sb-separator-top="hidden"]) {
1067
+ background-image: none;
1068
+ }
1069
+
1029
1070
  .sb-section-rows > .sb-button,
1030
1071
  .sb-list-content > .sb-button {
1031
1072
  border-radius: 0;
@@ -1242,11 +1283,16 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
1242
1283
  }
1243
1284
 
1244
1285
  /* The large title scrolls with the screen's root list or scroll view
1245
- (src/largeTitle.ts moves it); drags on it scroll that view. */
1286
+ (src/largeTitle.ts moves it into it); drags on it scroll that view. There
1287
+ it is content, under the bar. */
1246
1288
  .sb-navbar-large-title {
1247
1289
  pointer-events: none;
1248
1290
  }
1249
1291
 
1292
+ [data-sb-under-bar] > .sb-navbar-large-title {
1293
+ z-index: auto;
1294
+ }
1295
+
1250
1296
  /* Until the large title has scrolled under the bar, the inline title (whose
1251
1297
  rect the engine reports for a title that can collapse) stays hidden. */
1252
1298
  .sb-navscreen[data-sb-display="large"] > .sb-navbar > .sb-navbar-title {
@@ -1258,11 +1304,115 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
1258
1304
  opacity: 1;
1259
1305
  }
1260
1306
 
1261
- /* iOS 26's scroll edge effect, simplified: scrolled content fades in over
1262
- 16px below the bar instead of being cut off at it (src/largeTitle.ts). */
1263
- [data-sb-scrolls-title][data-sb-scrolled] {
1264
- -webkit-mask-image: linear-gradient(to bottom, transparent, #000 16px);
1265
- mask-image: linear-gradient(to bottom, transparent, #000 16px);
1307
+ /* iOS 26's scroll edge effect: a screen's root list or scroll view runs up
1308
+ under the bar (the engine insets its content by the bar), and once content
1309
+ is under it (`data-sb-scrolled`, src/largeTitle.ts) the bar blurs it and
1310
+ fades it into the background toward the top of the screen, a little past
1311
+ the bar's bottom edge. The bar's items stay on their glass. */
1312
+ .sb-navscreen {
1313
+ --sb-edge-color: var(--sb-color-system-background);
1314
+ }
1315
+
1316
+ .sb-navscreen:has(> .sb-navscreen-body .sb-list:is([data-sb-under-bar], [data-sb-under-bottom]):not([data-sb-list-style="plain"])) {
1317
+ --sb-edge-color: var(--sb-color-grouped-background);
1318
+ }
1319
+
1320
+ .sb-navbar::before {
1321
+ content: "";
1322
+ position: absolute;
1323
+ inset: 0 0 -20px;
1324
+ z-index: -1;
1325
+ pointer-events: none;
1326
+ opacity: 0;
1327
+ transition: opacity 0.2s ease;
1328
+ background: linear-gradient(
1329
+ to bottom,
1330
+ var(--sb-edge-color) 0%,
1331
+ color-mix(in srgb, var(--sb-edge-color) 75%, transparent) 45%,
1332
+ transparent 100%
1333
+ );
1334
+ -webkit-backdrop-filter: blur(6px);
1335
+ backdrop-filter: blur(6px);
1336
+ -webkit-mask-image: linear-gradient(to bottom, #000 60%, transparent);
1337
+ mask-image: linear-gradient(to bottom, #000 60%, transparent);
1338
+ }
1339
+
1340
+ .sb-navscreen[data-sb-scrolled] > .sb-navbar::before {
1341
+ opacity: 1;
1342
+ }
1343
+
1344
+ /* At the bottom: a list or scroll view runs under the tab bar, or its
1345
+ screen's bottom toolbar or search field (the engine insets its content by
1346
+ the bar, `--sb-bottom-edge` tall), and while content is under it
1347
+ (`data-sb-scrolled-bottom`, src/scrollEdge.ts) its screen (over the
1348
+ screen's content: the body is static, so its pseudo-element is placed in
1349
+ the screen, whose own `::after` dims it under a pushed screen), or the
1350
+ tab it fills, blurs it and fades it into the background toward the bottom
1351
+ of the screen, from a little above the bar. The bars stay on top. */
1352
+ .sb-tab {
1353
+ --sb-edge-color: var(--sb-color-system-background);
1354
+ }
1355
+
1356
+ .sb-tab:has(> .sb-list[data-sb-under-bottom]:not([data-sb-list-style="plain"])) {
1357
+ --sb-edge-color: var(--sb-color-grouped-background);
1358
+ }
1359
+
1360
+ .sb-navscreen > .sb-navscreen-body::after,
1361
+ .sb-tab::after {
1362
+ content: "";
1363
+ position: absolute;
1364
+ left: 0;
1365
+ right: 0;
1366
+ bottom: 0;
1367
+ height: calc(var(--sb-bottom-edge, 0px) + 20px);
1368
+ z-index: 1;
1369
+ pointer-events: none;
1370
+ opacity: 0;
1371
+ transition: opacity 0.2s ease;
1372
+ background: linear-gradient(
1373
+ to top,
1374
+ var(--sb-edge-color) 0%,
1375
+ color-mix(in srgb, var(--sb-edge-color) 75%, transparent) 45%,
1376
+ transparent 100%
1377
+ );
1378
+ -webkit-backdrop-filter: blur(6px);
1379
+ backdrop-filter: blur(6px);
1380
+ -webkit-mask-image: linear-gradient(to top, #000 60%, transparent);
1381
+ mask-image: linear-gradient(to top, #000 60%, transparent);
1382
+ }
1383
+
1384
+ .sb-navscreen[data-sb-scrolled-bottom] > .sb-navscreen-body::after,
1385
+ .sb-tab[data-sb-scrolled-bottom]::after {
1386
+ opacity: 1;
1387
+ }
1388
+
1389
+ /* `.scrollEdgeEffectStyle(.hard, for:)`: an opaque backing exactly behind
1390
+ the bar with a dividing line, no fade; `.scrollEdgeEffectHidden`: none. */
1391
+ .sb-navscreen[data-sb-edge-top="hard"] > .sb-navbar::before {
1392
+ inset: 0;
1393
+ background: var(--sb-edge-color);
1394
+ -webkit-backdrop-filter: none;
1395
+ backdrop-filter: none;
1396
+ -webkit-mask-image: none;
1397
+ mask-image: none;
1398
+ border-bottom: 0.5px solid var(--sb-color-separator);
1399
+ }
1400
+
1401
+ .sb-navscreen[data-sb-edge-bottom="hard"] > .sb-navscreen-body::after,
1402
+ .sb-tab[data-sb-edge-bottom="hard"]::after {
1403
+ height: var(--sb-bottom-edge, 0px);
1404
+ background: var(--sb-edge-color);
1405
+ -webkit-backdrop-filter: none;
1406
+ backdrop-filter: none;
1407
+ -webkit-mask-image: none;
1408
+ mask-image: none;
1409
+ border-top: 0.5px solid var(--sb-color-separator);
1410
+ }
1411
+
1412
+ .sb-navscreen[data-sb-edge-top="hidden"] > .sb-navbar::before,
1413
+ .sb-navscreen[data-sb-edge-bottom="hidden"] > .sb-navscreen-body::after,
1414
+ .sb-tab[data-sb-edge-bottom="hidden"]::after {
1415
+ display: none;
1266
1416
  }
1267
1417
 
1268
1418
  /* NavigationLink: a tappable label. Inside a list it is a row with a chevron
@@ -1522,6 +1672,27 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
1522
1672
  background: var(--sb-color-system-background);
1523
1673
  }
1524
1674
 
1675
+ /* `sidebar` (iPadOS; a NavigationSplitView's sidebar list): the rows sit on
1676
+ the list's background, without cards or hairlines, and only a selected
1677
+ row is filled (its rounded fill, below). The engine lays it out like
1678
+ `insetGrouped` with 44pt rows. */
1679
+ .sb-list[data-sb-list-style="sidebar"] .sb-section-rows,
1680
+ .sb-list[data-sb-list-style="sidebar"] .sb-section-rows > [data-sb-id],
1681
+ .sb-list[data-sb-list-style="sidebar"] .sb-section-rows > [data-sb-ghost],
1682
+ .sb-list[data-sb-list-style="sidebar"] > .sb-list-content > :not(.sb-section) {
1683
+ background: transparent;
1684
+ border-radius: 0;
1685
+ }
1686
+
1687
+ .sb-list[data-sb-list-style="sidebar"] .sb-section-rows > *::after,
1688
+ .sb-list[data-sb-list-style="sidebar"] > .sb-list-content > :not(.sb-section)::after {
1689
+ display: none;
1690
+ }
1691
+
1692
+ .sb-list[data-sb-list-style="sidebar"] :is(.sb-section-rows, .sb-list-content) > :is(input, textarea) {
1693
+ background-image: none;
1694
+ }
1695
+
1525
1696
  /* ---------------------------------------------------------------------------
1526
1697
  Phase 4: tab views. The tabs are stacked in the content slot (every tab has
1527
1698
  the tab view's frame); the selected one is visible, the others keep their
@@ -2135,10 +2306,19 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
2135
2306
  --sb-color-accent: var(--sb-color-primary);
2136
2307
  }
2137
2308
 
2138
- /* Their symbols are drawn larger and heavier, as iOS 26 draws bar buttons. */
2309
+ /* Toolbar items in the bar are laid out in the screen's body, under the bar
2310
+ element (z 1): they stack above it, so its scroll edge effect blurs the
2311
+ content under the bar and not them. */
2312
+ .sb-toolbar:is([data-sb-placement="leading"], [data-sb-placement="trailing"], [data-sb-placement="principal"]) {
2313
+ z-index: 2;
2314
+ }
2315
+
2316
+ /* Their symbols are drawn a little larger and heavier (semibold), as iOS 26
2317
+ draws bar buttons: a plus is 17.7 pt with 2.33 pt bars there. */
2139
2318
  .sb-toolbar:is([data-sb-placement="leading"], [data-sb-placement="trailing"]) .sb-image > svg {
2140
- transform: scale(1.25);
2141
- stroke-width: 2.1;
2319
+ width: 1.57em;
2320
+ height: 1.57em;
2321
+ stroke-width: 1.85;
2142
2322
  }
2143
2323
 
2144
2324
  .sb-toolbar[data-sb-placement="bottom"] {
@@ -2237,12 +2417,61 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
2237
2417
  background: var(--sb-color-system-background);
2238
2418
  }
2239
2419
 
2420
+ /* `.inspector`: the content and a trailing panel (protocol children placed by
2421
+ the engine). The panel draws the hairline before it, in the 1pt gap the
2422
+ engine leaves, so the hairline slides with it. */
2423
+ .sb-inspector {
2424
+ overflow: hidden;
2425
+ }
2426
+
2427
+ .sb-inspectorpanel {
2428
+ background: var(--sb-color-system-background);
2429
+ box-shadow: -1px 0 0 var(--sb-color-separator);
2430
+ }
2431
+
2240
2432
  .sb-navsplit-separator {
2241
2433
  position: absolute;
2242
2434
  background: var(--sb-color-separator);
2243
2435
  pointer-events: none;
2244
2436
  }
2245
2437
 
2438
+ /* iPadOS: the sidebar toggle, a 44pt glass circle at the leading edge of
2439
+ the bar row (the sidebar's, or the detail's while the sidebar is hidden),
2440
+ over the columns. */
2441
+ .sb-navsplit-toggle {
2442
+ position: absolute;
2443
+ z-index: 2;
2444
+ appearance: none;
2445
+ display: inline-flex;
2446
+ align-items: center;
2447
+ justify-content: center;
2448
+ margin: 0;
2449
+ padding: 0;
2450
+ border: 0;
2451
+ border-radius: 22px;
2452
+ background: var(--sb-glass-background);
2453
+ box-shadow: var(--sb-glass-shadow);
2454
+ backdrop-filter: blur(12px) saturate(180%);
2455
+ -webkit-backdrop-filter: blur(12px) saturate(180%);
2456
+ color: var(--sb-color-primary);
2457
+ cursor: pointer;
2458
+ touch-action: manipulation;
2459
+ -webkit-tap-highlight-color: transparent;
2460
+ }
2461
+
2462
+ .sb-navsplit-toggle[hidden] {
2463
+ display: none;
2464
+ }
2465
+
2466
+ .sb-navsplit-toggle:active {
2467
+ opacity: 0.3;
2468
+ }
2469
+
2470
+ .sb-navsplit-toggle > svg {
2471
+ width: 22px;
2472
+ height: 22px;
2473
+ }
2474
+
2246
2475
  /* A selected `navlink` row (`List(selection:)`): the sidebar-style rounded
2247
2476
  fill. The engine omits the chevron inside a navsplit sidebar; elsewhere
2248
2477
  the chevron stays. The list-row selectors outrank the per-style row
@@ -2546,7 +2775,8 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
2546
2775
  }
2547
2776
 
2548
2777
  /* `listrow`: the edit-mode control at the engine's `editControl` rect (hidden
2549
- otherwise); a selected row reuses the Phase 7 selected-row fill. */
2778
+ otherwise; its 22px circle is centred 28 from the card's edge, as on
2779
+ iOS); a selected row reuses the Phase 7 selected-row fill. */
2550
2780
  .sb-listrow-control {
2551
2781
  appearance: none;
2552
2782
  position: absolute;
@@ -2561,6 +2791,12 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
2561
2791
  cursor: pointer;
2562
2792
  touch-action: manipulation;
2563
2793
  -webkit-tap-highlight-color: transparent;
2794
+ }
2795
+
2796
+ /* The controls fade in as the list starts editing (layoutDom marks them
2797
+ while they appear); a row moved in the DOM by a reorder keeps them put. */
2798
+ .sb-listrow-control[data-sb-appear],
2799
+ .sb-listrow-grip[data-sb-appear] {
2564
2800
  animation: sb-fade-in 0.25s ease-out both;
2565
2801
  }
2566
2802
 
@@ -2568,6 +2804,89 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
2568
2804
  display: none;
2569
2805
  }
2570
2806
 
2807
+ /* `listrow`: the reorder grip of a movable row at the engine's
2808
+ `reorderControl` rect (edit mode only): three gray lines, which a press
2809
+ lifts the row by (src/dragDrop.ts). */
2810
+ .sb-listrow-grip {
2811
+ position: absolute;
2812
+ z-index: 2;
2813
+ display: flex;
2814
+ align-items: center;
2815
+ justify-content: center;
2816
+ color: var(--sb-color-tertiary-label);
2817
+ cursor: grab;
2818
+ touch-action: none;
2819
+ user-select: none;
2820
+ -webkit-user-select: none;
2821
+ -webkit-touch-callout: none;
2822
+ }
2823
+
2824
+ .sb-listrow-grip[hidden] {
2825
+ display: none;
2826
+ }
2827
+
2828
+ .sb-listrow-grip > svg {
2829
+ width: 21px;
2830
+ height: 11px;
2831
+ overflow: visible;
2832
+ stroke: currentColor;
2833
+ stroke-width: 1.33;
2834
+ }
2835
+
2836
+ /* A row being reordered: its neighbours slide out of its way (the
2837
+ transition), it follows the pointer above them with a shadow, and settles
2838
+ into its slot with the same slide. The rows are composited for the whole
2839
+ reorder (`will-change`): promoting one as its slide starts would
2840
+ re-rasterize it, a blink of its text and icons. */
2841
+ [data-sb-reordering] > .sb-listrow[data-sb-movable] {
2842
+ transition: transform 0.3s var(--sb-nav-easing);
2843
+ will-change: transform;
2844
+ }
2845
+
2846
+ .sb-listrow[data-sb-lifting] {
2847
+ z-index: 5;
2848
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
2849
+ cursor: grabbing;
2850
+ }
2851
+
2852
+ .sb-listrow[data-sb-lifting]:not([data-sb-settling]) {
2853
+ transition: none;
2854
+ }
2855
+
2856
+ /* `draggable` / `dropdestination`: transparent wrappers; a lifted view's
2857
+ copy follows the pointer over everything, scaled up a little with a
2858
+ shadow, while the original dims; letting go off a destination slides the
2859
+ copy back. A targeted destination shows nothing of its own (the app's
2860
+ `isTargeted` does), only that the copy may drop there. */
2861
+ .sb-draggable,
2862
+ .sb-dropdestination {
2863
+ -webkit-touch-callout: none;
2864
+ }
2865
+
2866
+ .sb-draggable {
2867
+ cursor: grab;
2868
+ }
2869
+
2870
+ .sb-draggable[data-sb-dragging] {
2871
+ opacity: 0.4;
2872
+ }
2873
+
2874
+ .sb-drag-preview {
2875
+ z-index: 60;
2876
+ pointer-events: none;
2877
+ opacity: 0.9;
2878
+ filter: drop-shadow(0 8px 20px rgba(0, 0, 0, 0.25));
2879
+ will-change: transform;
2880
+ }
2881
+
2882
+ .sb-drag-preview[data-sb-settling] {
2883
+ transition: transform 0.3s var(--sb-nav-easing);
2884
+ }
2885
+
2886
+ [data-sb-dragging] .sb-dropdestination {
2887
+ cursor: copy;
2888
+ }
2889
+
2571
2890
  .sb-listrow-circle {
2572
2891
  display: flex;
2573
2892
  align-items: center;
@@ -2608,6 +2927,14 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
2608
2927
 
2609
2928
  .sb-listrow[data-sb-deletable]:not([data-sb-selectable]) .sb-listrow-circle > .sb-listrow-minus {
2610
2929
  display: block;
2930
+ transition: transform 0.3s var(--sb-nav-easing);
2931
+ }
2932
+
2933
+ /* While the row shows its Delete button (the minus was tapped, or the row
2934
+ swiped) the minus stands upright, as on iOS; the content, and the grip in
2935
+ it, slid aside. */
2936
+ .sb-listrow[data-sb-swipe-open="trailing"] .sb-listrow-circle > .sb-listrow-minus {
2937
+ transform: rotate(-90deg);
2611
2938
  }
2612
2939
 
2613
2940
  .sb-listrow[data-sb-selectable] {
@@ -2621,6 +2948,26 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
2621
2948
  border-radius: 10px;
2622
2949
  }
2623
2950
 
2951
+ /* The fill is the row's alone: its swipe content (which inherits the row's
2952
+ background to cover the actions behind it) and a selected link inside it
2953
+ would each stack another translucent layer, a darker pill around the
2954
+ label. */
2955
+ .sb-listrow[data-sb-selected] > .sb-swipe-content {
2956
+ background-color: transparent;
2957
+ }
2958
+
2959
+ .sb-listrow .sb-navlink[data-sb-selected] {
2960
+ background: transparent;
2961
+ }
2962
+
2963
+ /* Like iOS, a selected row's fill has no hairline under it or above it. */
2964
+ .sb-section-rows > [data-sb-selected]::after,
2965
+ .sb-list-content > [data-sb-selected]::after,
2966
+ .sb-section-rows > :has(+ [data-sb-selected])::after,
2967
+ .sb-list-content > :has(+ [data-sb-selected])::after {
2968
+ display: none;
2969
+ }
2970
+
2624
2971
  @keyframes sb-fade-in {
2625
2972
  from {
2626
2973
  opacity: 0;
@@ -2637,7 +2984,8 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
2637
2984
  position: absolute;
2638
2985
  left: 0;
2639
2986
  right: 0;
2640
- top: 0;
2987
+ /* Under the bar a scroll view's content starts below it. */
2988
+ top: var(--sb-top-inset, 0px);
2641
2989
  height: 40px;
2642
2990
  display: flex;
2643
2991
  align-items: center;
@@ -2665,17 +3013,20 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
2665
3013
  }
2666
3014
 
2667
3015
  .sb-list[data-sb-refreshable] > .sb-list-content,
2668
- .sb-scrollview[data-sb-refreshable] > .sb-scroll-content {
3016
+ .sb-scrollview[data-sb-refreshable] > .sb-scroll-content,
3017
+ [data-sb-refreshable] > .sb-navbar-large-title {
2669
3018
  transition: transform 0.3s var(--sb-nav-easing);
2670
3019
  }
2671
3020
 
2672
3021
  [data-sb-pulling] > .sb-list-content,
2673
- [data-sb-pulling] > .sb-scroll-content {
3022
+ [data-sb-pulling] > .sb-scroll-content,
3023
+ [data-sb-pulling] > .sb-navbar-large-title {
2674
3024
  transition: none;
2675
3025
  }
2676
3026
 
2677
3027
  [data-sb-refreshing] > .sb-list-content,
2678
- [data-sb-refreshing] > .sb-scroll-content {
3028
+ [data-sb-refreshing] > .sb-scroll-content,
3029
+ [data-sb-refreshing] > .sb-navbar-large-title {
2679
3030
  transform: translateY(40px);
2680
3031
  }
2681
3032
 
@@ -3253,3 +3604,66 @@ svg.sb-path {
3253
3604
  display: block;
3254
3605
  overflow: hidden;
3255
3606
  }
3607
+
3608
+ /* ---------------------------------------------------------------------------
3609
+ `host` elements (src/hostContent.ts): the page's own DOM in a box the
3610
+ engine sizes. The content element is positioned at the box's origin and
3611
+ takes its width; in `fill` sizing its height too. The box clips.
3612
+ ------------------------------------------------------------------------- */
3613
+ .sb-host {
3614
+ display: block;
3615
+ overflow: hidden;
3616
+ }
3617
+
3618
+ .sb-host > .sb-host-content {
3619
+ position: absolute;
3620
+ left: 0;
3621
+ top: 0;
3622
+ width: 100%;
3623
+ box-sizing: border-box;
3624
+ }
3625
+
3626
+ .sb-host[data-sb-sizing="fill"] > .sb-host-content {
3627
+ height: 100%;
3628
+ }
3629
+
3630
+ /* ---------------------------------------------------------------------------
3631
+ Islands (@swiftbrowser/react's `SwiftUIView`): a SwiftUI root inside a
3632
+ page's own layout, with the device's tokens and none of its frame. The
3633
+ page sizes the island; in `content` fit the renderer sets the root's height
3634
+ from the layout.
3635
+ ------------------------------------------------------------------------- */
3636
+ .device.sb-island {
3637
+ width: auto;
3638
+ padding: 0;
3639
+ border-radius: 0;
3640
+ background: none;
3641
+ box-shadow: none;
3642
+ flex: initial;
3643
+ }
3644
+
3645
+ .device.sb-island::before,
3646
+ .device.sb-island::after {
3647
+ display: none;
3648
+ }
3649
+
3650
+ .sb-island .device-screen {
3651
+ width: 100%;
3652
+ height: 100%;
3653
+ border-radius: 0;
3654
+ background: none;
3655
+ }
3656
+
3657
+ .sb-island[data-sb-fit="content"],
3658
+ .sb-island[data-sb-fit="content"] .device-screen,
3659
+ .sb-island[data-sb-fit="content"] .screen {
3660
+ position: relative;
3661
+ inset: auto;
3662
+ height: auto;
3663
+ }
3664
+
3665
+ /* `.onHover` (pointerEffects.ts): the whole box hovers, as SwiftUI uses the
3666
+ view's frame, so a hover box is hit in its empty space too. */
3667
+ .sb-styled[data-sb-hover] {
3668
+ pointer-events: auto;
3669
+ }
package/src/typography.ts CHANGED
@@ -22,7 +22,10 @@ export {
22
22
  DYNAMIC_TYPE_SIZES,
23
23
  FONT_FAMILIES,
24
24
  FONT_WEIGHTS,
25
- LINE_HEIGHTS,
25
+ LINE_HEIGHT_RATIO,
26
+ LEADINGS,
27
+ leadingFor,
28
+ textHeight,
26
29
  TEXT_STYLE_SIZES,
27
30
  defaultFont,
28
31
  lineHeightFor,
@@ -70,7 +73,7 @@ export function applyTypeSizeVariables(target: HTMLElement, size: DynamicTypeSiz
70
73
  for (const style of TEXT_STYLES) {
71
74
  const { fontSize, lineHeight } = resolveTextStyle(style, size);
72
75
  target.style.setProperty(textStyleVar(style, 'size'), `${fontSize}px`);
73
- target.style.setProperty(textStyleVar(style, 'lh'), `${lineHeight}px`);
76
+ target.style.setProperty(textStyleVar(style, 'lh'), `${Math.round(lineHeight * 100) / 100}px`);
74
77
  target.style.setProperty(`--sb-ts-${style}-scale`, String(relativeScale(style, size)));
75
78
  }
76
79
  }