@swiftbrowser/web 0.6.0 → 0.6.2

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/press.ts CHANGED
@@ -8,12 +8,13 @@
8
8
  * touch: lifting the highlight when the pointer leaves the control (plus a
9
9
  * margin) and restoring it when it comes back.
10
10
  *
11
- * The glass under a control: a bar item's shared capsule (a `leading` or
12
- * `trailing` toolbar), a bottom bar item's `.sb-bottom-glass` (the one
13
- * under it: the glass is beside the toolbar, not around it), the back and
14
- * More circles, the sidebar toggle, a `.glass` button, or the `.glassEffect`
15
- * box around a button. A control on no glass (a plain or bordered button)
16
- * keeps the stylesheet's `:active` dimming.
11
+ * The glass under a control: a bar item's capsule (the `.sb-bar-glass`
12
+ * under it: the glass is beside the toolbars drawn on it, not around them,
13
+ * so they swell along, about the capsule's center), the back and More
14
+ * circles, the sidebar toggle, a `.glass` button, or the `.glassEffect` box
15
+ * around a button. iOS swells the whole group, never one item of it: a
16
+ * glass-styled button in a bar group is the group's press. A control on no
17
+ * glass (a plain or bordered button) keeps the stylesheet's `:active` dimming.
17
18
  */
18
19
 
19
20
  /** The controls a press can highlight the glass under. */
@@ -25,10 +26,15 @@ const OWN_GLASS = '.sb-navbar-back, .sb-navbar-more, .sb-navsplit-toggle, .sb-bu
25
26
  /** UIKit keeps a button highlighted while the touch stays within this far outside it. */
26
27
  export const PRESS_SLOP = 24;
27
28
 
29
+ /** How long the glass takes to spring back once released (styles.css). */
30
+ const SPRING_BACK_MS = 500;
31
+
28
32
  interface Press {
29
33
  pointerId: number;
30
34
  control: HTMLElement;
31
35
  surface: HTMLElement;
36
+ /** The toolbars drawn on the capsule, which swell with it, and their `transform-origin` before. */
37
+ along: { el: HTMLElement; origin: string }[];
32
38
  /** Where the control was on the pointer down (its bounds plus the slop hold the press). */
33
39
  bounds: DOMRect;
34
40
  held: boolean;
@@ -52,7 +58,7 @@ export class PressEffects {
52
58
  if (!control || !this.root.contains(control) || isDisabled(control)) return;
53
59
  const surface = glassSurfaceOf(control, this.root);
54
60
  if (!surface) return;
55
- this.press = { pointerId: event.pointerId, control, surface, bounds: control.getBoundingClientRect(), held: false };
61
+ this.press = { pointerId: event.pointerId, control, surface, along: toolbarsOn(surface), bounds: control.getBoundingClientRect(), held: false };
56
62
  this.hold(true);
57
63
  window.addEventListener('pointermove', this.onPointerMove);
58
64
  window.addEventListener('pointerup', this.onPointerUp);
@@ -82,11 +88,21 @@ export class PressEffects {
82
88
  press.held = held;
83
89
  press.surface.toggleAttribute('data-sb-pressed', held);
84
90
  if (press.control !== press.surface) press.control.toggleAttribute('data-sb-pressed-item', held);
91
+ for (const { el } of press.along) el.toggleAttribute('data-sb-pressed-with', held);
85
92
  }
86
93
 
87
94
  private release(): void {
88
95
  this.hold(false);
96
+ const press = this.press;
89
97
  this.press = null;
98
+ if (press) {
99
+ // The origins stay until the spring back has run; the next press sets them again.
100
+ const settle = (): void => {
101
+ if (this.press?.surface === press.surface) return;
102
+ for (const { el, origin } of press.along) el.style.transformOrigin = origin;
103
+ };
104
+ window.setTimeout(settle, SPRING_BACK_MS);
105
+ }
90
106
  window.removeEventListener('pointermove', this.onPointerMove);
91
107
  window.removeEventListener('pointerup', this.onPointerUp);
92
108
  window.removeEventListener('pointercancel', this.onPointerUp);
@@ -103,26 +119,48 @@ function isDisabled(control: HTMLElement): boolean {
103
119
  * top of the file).
104
120
  */
105
121
  export function glassSurfaceOf(control: HTMLElement, root: HTMLElement): HTMLElement | null {
106
- if (control.matches(OWN_GLASS)) return control;
107
122
  const toolbar = control.closest<HTMLElement>('.sb-toolbar');
108
123
  if (toolbar && root.contains(toolbar)) {
109
124
  const placement = toolbar.dataset['sbPlacement'];
110
- if (placement === 'leading' || placement === 'trailing') return toolbar;
111
- if (placement === 'bottom') return bottomGlassUnder(control, toolbar);
125
+ if (placement === 'leading' || placement === 'trailing' || placement === 'bottom') return barGlassUnder(control, toolbar);
112
126
  return null;
113
127
  }
128
+ if (control.matches(OWN_GLASS)) return control;
114
129
  const glass = control.closest<HTMLElement>('[data-sb-glass]');
115
130
  return glass && root.contains(glass) ? glass : null;
116
131
  }
117
132
 
118
- /** The bottom bar capsule a bottom toolbar item is drawn on: the screen's `.sb-bottom-glass` under its center. */
119
- function bottomGlassUnder(control: HTMLElement, toolbar: HTMLElement): HTMLElement | null {
133
+ /**
134
+ * The toolbars drawn on a capsule (`data-sb-toolbars`, src/layoutDom.ts),
135
+ * each given the capsule's center as its `transform-origin` so that it
136
+ * swells with the capsule rather than about itself.
137
+ */
138
+ function toolbarsOn(surface: HTMLElement): { el: HTMLElement; origin: string }[] {
139
+ const ids = surface.dataset['sbToolbars'];
140
+ const screen = surface.parentElement;
141
+ if (ids === undefined || !screen) return [];
142
+ const g = surface.getBoundingClientRect();
143
+ const cx = g.left + g.width / 2;
144
+ const cy = g.top + g.height / 2;
145
+ const along: { el: HTMLElement; origin: string }[] = [];
146
+ for (const id of ids.split(' ')) {
147
+ const el = id ? screen.querySelector<HTMLElement>(`.sb-toolbar[data-sb-id="${id}"]`) : null;
148
+ if (!el) continue;
149
+ const r = el.getBoundingClientRect();
150
+ along.push({ el, origin: el.style.transformOrigin });
151
+ el.style.transformOrigin = `${cx - r.left}px ${cy - r.top}px`;
152
+ }
153
+ return along;
154
+ }
155
+
156
+ /** The capsule a bar item is drawn on: the screen's `.sb-bar-glass` under its center. */
157
+ function barGlassUnder(control: HTMLElement, toolbar: HTMLElement): HTMLElement | null {
120
158
  const screen = toolbar.closest<HTMLElement>('.sb-navscreen');
121
159
  if (!screen) return null;
122
160
  const rect = control.getBoundingClientRect();
123
161
  const cx = rect.left + rect.width / 2;
124
162
  const cy = rect.top + rect.height / 2;
125
- for (const glass of screen.querySelectorAll<HTMLElement>(':scope > .sb-bottom-glass')) {
163
+ for (const glass of screen.querySelectorAll<HTMLElement>(':scope > .sb-bar-glass')) {
126
164
  const g = glass.getBoundingClientRect();
127
165
  if (cx >= g.left && cx <= g.right && cy >= g.top && cy <= g.bottom) return glass;
128
166
  }
package/src/protocol.ts CHANGED
@@ -323,6 +323,11 @@ export interface Style {
323
323
  font?: Font;
324
324
  /** Phase 6: any ShapeStyle (text role). Gradients paint the text itself. */
325
325
  foreground?: ShapeStyle;
326
+ /**
327
+ * `.foregroundStyle(primary, secondary[, tertiary])`: the styles after the
328
+ * first, the `.secondary` and `.tertiary` levels below (`--sb-hierarchy-2`, `-3`).
329
+ */
330
+ foregroundLevels?: ShapeStyle[];
326
331
  /** Phase 6: any ShapeStyle (paint role). */
327
332
  background?: ShapeStyle;
328
333
  frame?: Frame;
@@ -502,6 +507,13 @@ export interface ZStackProps {
502
507
  /** `systemName` is an SF Symbol name; the renderer picks the closest glyph. */
503
508
  export interface SymbolImageProps {
504
509
  systemName: string;
510
+ /**
511
+ * `.symbolRenderingMode(.palette)`: an enclosed filled symbol
512
+ * (`moon.circle.fill`) draws its enclosure in the secondary level
513
+ * (`--sb-hierarchy-2`) and its glyph in the primary, instead of knocking
514
+ * the glyph out.
515
+ */
516
+ renderingMode?: 'palette';
505
517
  }
506
518
  /**
507
519
  * Phase 5: an image from the app's asset catalog. `name` is the catalog
@@ -621,6 +633,8 @@ export interface NavScreenProps {
621
633
  /** Phase 7 adds `selected` (see the block at the end of this file). */
622
634
  export interface NavLinkProps {
623
635
  selected?: boolean;
636
+ /** `.buttonStyle(.plain)` around the link (absent otherwise): its label in the foreground it is in, not the tint. */
637
+ style?: 'plain';
624
638
  }
625
639
 
626
640
  // Phase 3 kinds (docs/ops-protocol.md, "Phase 3 additions").
@@ -1675,6 +1689,8 @@ export interface ChartAxisLabel {
1675
1689
  export interface ChartAxis {
1676
1690
  position: ChartXAxisPosition | ChartYAxisPosition;
1677
1691
  gridLines: number[];
1692
+ /** A date axis: its grid lines are dashed (2pt on, 2pt off), as Swift Charts draws them. */
1693
+ dashed?: true;
1678
1694
  ticks: number[];
1679
1695
  /** Default text labels; view labels are `chartitem` children with role `xLabel` / `yLabel`. */
1680
1696
  labels: ChartAxisLabel[];
package/src/renderer.ts CHANGED
@@ -36,6 +36,7 @@
36
36
  import {
37
37
  Animator,
38
38
  barToolbarOf,
39
+ isBarToolbar,
39
40
  EXIT_CLONE_CLASS,
40
41
  emptyPass,
41
42
  setTransition,
@@ -150,7 +151,7 @@ import { VideoEvents, applyVideo, stopVideos } from './video';
150
151
  import { applyCanvas, createCanvasElement } from './canvasDrawing';
151
152
  import { scrollToTarget } from './scrollTo';
152
153
  import { EMPTY_CATALOG, assetColorVar, type AssetCatalog } from './assets';
153
- import { colorToCSS, effectsCSS, styleToCSS, type PaintCSS } from './styles';
154
+ import { HIERARCHY_OPACITY, colorToCSS, effectsCSS, styleToCSS, type PaintCSS } from './styles';
154
155
  import { hostContent, onHostContentChange } from './hostContent';
155
156
  import { HoverTracker, applyPointerAndEffects, safeAreaOverlap } from './pointerEffects';
156
157
  import { colorMultiplyFilter } from './colorMultiply';
@@ -1578,6 +1579,11 @@ export class Renderer {
1578
1579
  }
1579
1580
  }
1580
1581
  }
1582
+ // iOS 26: a bar group moved to another placement is not the same group
1583
+ // on screen; it leaves as it was and comes in where it goes (the Swift
1584
+ // runtime keys groups by placement, so this is for other hosts).
1585
+ const replaces = kind === 'toolbar' && node !== undefined && (props as { placement?: string }).placement !== node.props['placement'] && isBarToolbar(el);
1586
+ if (replaces) this.replaceToolbar(el);
1581
1587
  if (node) {
1582
1588
  const next = asRecord(props);
1583
1589
  // Another Swift view now reads this element's size (a `geometry`
@@ -1626,6 +1632,8 @@ export class Renderer {
1626
1632
  const animation = ANIMATIONS.get(el);
1627
1633
  if (animation) record.animation = animation;
1628
1634
  this.pass.updated.set(el, record);
1635
+ // A replaced toolbar enters anew: its style change is not tweened.
1636
+ if (replaces) this.pass.updated.delete(el);
1629
1637
  this.dirty.add(id);
1630
1638
  if (kind === 'navscreen') {
1631
1639
  const stack = el.parentElement?.closest<HTMLElement>('.sb-navstack');
@@ -1716,6 +1724,23 @@ export class Renderer {
1716
1724
  }
1717
1725
  }
1718
1726
 
1727
+ /**
1728
+ * Hands the Animator a bar toolbar as removed (a clone of it as it is, where
1729
+ * it is: called before its new placement is applied) and inserted (itself),
1730
+ * so that at commit it scales out from its old place and in at its new one
1731
+ * instead of tweening between the two.
1732
+ */
1733
+ private replaceToolbar(el: HTMLElement): void {
1734
+ const parent = el.parentElement;
1735
+ if (!parent) return;
1736
+ this.passRootRect ??= this.animator.rootRect();
1737
+ const rect = this.animator.rectOf(el, this.passRootRect);
1738
+ const ancestors: HTMLElement[] = [];
1739
+ for (let a: HTMLElement | null = parent; a && a !== this.root; a = a.parentElement) ancestors.push(a);
1740
+ this.pass.removed.push({ el: ghostClone(el), ancestors, rect, transition: transitionOf(el) });
1741
+ this.pass.inserted.add(el);
1742
+ }
1743
+
1719
1744
  private remove(id: ElementId, op: Op): void {
1720
1745
  if (id === ROOT_ID) throw new RendererError('Element 0 is the root and cannot be removed', op);
1721
1746
  const el = this.elements.get(id);
@@ -2872,6 +2897,8 @@ function applyKindProps(el: HTMLElement, kind: ElementKind, props: ElementProps,
2872
2897
  case 'navlink':
2873
2898
  // Phase 7: a selected row (inside a `List(selection:)`) gets a rounded fill.
2874
2899
  el.toggleAttribute('data-sb-selected', (props as NavLinkProps | undefined)?.selected === true);
2900
+ if ((props as NavLinkProps | undefined)?.style === 'plain') el.dataset['sbStyle'] = 'plain';
2901
+ else delete el.dataset['sbStyle'];
2875
2902
  break;
2876
2903
  case 'viewthatfits': {
2877
2904
  const axes = (props as ViewThatFitsProps | undefined)?.axes;
@@ -3411,6 +3438,11 @@ function applyImage(el: HTMLElement, props: ImageProps, assets: AssetCatalog): v
3411
3438
  delete el.dataset['sbResizable'];
3412
3439
  el.dataset['sbSymbol'] = name;
3413
3440
  const svg = renderSymbol(name);
3441
+ if (svg && props.renderingMode === 'palette' && svg.dataset['sbGlyphFill'] === 'knockout') {
3442
+ // The enclosure in the secondary level, the glyph in the primary.
3443
+ svg.style.color = 'var(--sb-hierarchy-2, currentColor)';
3444
+ svg.style.setProperty('--sb-icon-knockout', 'var(--sb-hierarchy-1, currentColor)');
3445
+ }
3414
3446
  if (svg) found(name, svg);
3415
3447
  else missing(name);
3416
3448
  }
@@ -3667,6 +3699,21 @@ export function applyStyle(el: HTMLElement, style: Style): void {
3667
3699
  // styled box wins, as it does in SwiftUI.
3668
3700
  s.color = paint.solid;
3669
3701
  s.setProperty('--sb-button-color', paint.solid);
3702
+ // `.secondary` and the other hierarchical levels below are levels of this
3703
+ // foreground (styles.ts); a hierarchical foreground keeps the ones above.
3704
+ if (!('hierarchical' in style.foreground)) {
3705
+ for (const level of [1, 2, 3, 4] as const) {
3706
+ const opacity = HIERARCHY_OPACITY[level];
3707
+ s.setProperty(`--sb-hierarchy-${level}`, opacity === 1 ? paint.solid : `color-mix(in srgb, ${paint.solid} ${opacity * 100}%, transparent)`);
3708
+ }
3709
+ }
3710
+ // `.foregroundStyle(.white, .indigo)`: the levels after the first. A
3711
+ // hierarchical one (`.foregroundStyle(.red, .secondary)`) stays the level
3712
+ // of the primary set above (it would refer to itself).
3713
+ style.foregroundLevels?.forEach((level, index) => {
3714
+ if (level && typeof level === 'object' && 'hierarchical' in level) return;
3715
+ s.setProperty(`--sb-hierarchy-${index + 2}`, styleToCSS(level, 'text').solid);
3716
+ });
3670
3717
  if (paint.kind === 'gradient') {
3671
3718
  // Text paints the gradient through its own background clipped to the
3672
3719
  // glyphs (see `.sb-text` in styles.css); the custom properties inherit
package/src/styles.css CHANGED
@@ -557,19 +557,26 @@ html[data-sb-mode="device"] .device-screen {
557
557
  line-height: 1;
558
558
  }
559
559
 
560
- /* A symbol fills its box as SF Symbols do: at a text size a circle symbol is
561
- about 1.28× the font size (21.7 pt at body's 17), and the glyphs here
562
- occupy 20 of their 24-unit grid (a little more with their stroke), so
563
- the SVG is 1.43em, centred in the
564
- line-height box the engine gives the image (the box is unchanged; the
565
- glyph overflows it a little, as a symbol's bounds do). The stroke, 1.4
566
- units, is then 1.5 px at body, the weight iOS draws regular symbols in. */
560
+ /* A symbol is drawn at SF Symbols' size: at a text size a circle symbol is
561
+ about the font size across (measured in the Simulator: 17 pt at body's
562
+ 17, 53 at 56), with a 1.4 pt stroke at body. The glyphs here occupy 20
563
+ of their 24-unit grid (a little more with their stroke), so the SVG is
564
+ 1.08em with a 1.8-unit stroke, centred in the line-height box the engine
565
+ gives the image. */
567
566
  .sb-image > svg {
568
567
  display: block;
569
568
  flex: none;
569
+ width: 1.08em;
570
+ height: 1.08em;
571
+ stroke: currentColor;
572
+ stroke-width: 1.8;
573
+ }
574
+
575
+ /* A list row draws a Label's icon larger, as an icon column: a circle
576
+ symbol is 21.7 pt at body (1.28× the font size), its stroke 1.5 pt. */
577
+ .sb-list [data-sb-label-icon] .sb-image > svg {
570
578
  width: 1.43em;
571
579
  height: 1.43em;
572
- stroke: currentColor;
573
580
  stroke-width: 1.4;
574
581
  }
575
582
 
@@ -1346,6 +1353,12 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
1346
1353
  -webkit-tap-highlight-color: transparent;
1347
1354
  }
1348
1355
 
1356
+ /* `.buttonStyle(.plain)`: as a plain button, the label in the foreground it
1357
+ is in (Food Truck's donut gallery names are black on iOS). */
1358
+ .sb-navlink[data-sb-style="plain"] {
1359
+ color: var(--sb-button-color, inherit);
1360
+ }
1361
+
1349
1362
  .sb-navlink:focus-visible {
1350
1363
  outline: 2px solid var(--sb-color-accent);
1351
1364
  outline-offset: 2px;
@@ -2019,7 +2032,8 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
2019
2032
  isolation: isolate;
2020
2033
  }
2021
2034
 
2022
- .sb-decorated[data-sb-role="background"] > [data-sb-id]:first-child {
2035
+ .sb-decorated[data-sb-role="background"] > [data-sb-id]:first-child,
2036
+ .sb-decorated[data-sb-role="background"] > [data-sb-ghost]:first-child {
2023
2037
  z-index: 1;
2024
2038
  }
2025
2039
 
@@ -2092,7 +2106,9 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
2092
2106
  }
2093
2107
 
2094
2108
  .sb-menu > [data-sb-id]:not(:first-child),
2095
- .sb-menu > [data-sb-id][hidden] {
2109
+ .sb-menu > [data-sb-id][hidden],
2110
+ .sb-menu > [data-sb-ghost]:not(:first-child),
2111
+ .sb-menu > [data-sb-ghost][hidden] {
2096
2112
  display: none;
2097
2113
  }
2098
2114
 
@@ -2270,7 +2286,8 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
2270
2286
  /* A toolbar the engine hid (a spacer, `secondary` items collapsed into the
2271
2287
  More menu), and its items collapsed one by one, draw nothing. */
2272
2288
  .sb-toolbar[hidden],
2273
- .sb-toolbar > [data-sb-id][hidden] {
2289
+ .sb-toolbar > [data-sb-id][hidden],
2290
+ .sb-toolbar > [data-sb-ghost][hidden] {
2274
2291
  display: none;
2275
2292
  }
2276
2293
 
@@ -2286,9 +2303,7 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
2286
2303
  .sb-navbar-back::after,
2287
2304
  .sb-navbar-more::after,
2288
2305
  .sb-tabbar::after,
2289
- .sb-toolbar[data-sb-placement="leading"]::after,
2290
- .sb-toolbar[data-sb-placement="trailing"]::after,
2291
- .sb-bottom-glass::after,
2306
+ .sb-bar-glass::after,
2292
2307
  .sb-searchfield::after,
2293
2308
  .sb-navsplit-toggle::after,
2294
2309
  .sb-picker-menu::after,
@@ -2317,24 +2332,20 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
2317
2332
  pointer-events: none;
2318
2333
  }
2319
2334
 
2320
- /* iOS 26: a bar's leading and trailing items share a glass capsule (the
2321
- engine sizes the group: 44 tall, each item in a slot at least 44 wide),
2322
- drawn in the label color unless tinted. */
2335
+ /* iOS 26: a bar's leading and trailing items float on glass capsules
2336
+ (`.sb-bar-glass`, below: adjacent toolbars share one, the engine sizes it
2337
+ with each item in a slot at least 44 wide), drawn in the label color
2338
+ unless tinted. */
2323
2339
  .sb-toolbar[data-sb-placement="leading"],
2324
2340
  .sb-toolbar[data-sb-placement="trailing"] {
2325
- border-radius: 22px;
2326
- background: var(--sb-glass-background);
2327
- box-shadow: var(--sb-glass-shadow);
2328
- backdrop-filter: blur(12px) saturate(180%);
2329
- -webkit-backdrop-filter: blur(12px) saturate(180%);
2330
2341
  --sb-color-accent: var(--sb-color-primary);
2331
2342
  }
2332
2343
 
2333
2344
  /* Toolbar items in the bar are laid out in the screen's body, under the bar
2334
2345
  element (z 1): they stack above it, so its scroll edge effect blurs the
2335
- content under the bar and not them. */
2346
+ content under the bar and not them, and above their capsules (z 2). */
2336
2347
  .sb-toolbar:is([data-sb-placement="leading"], [data-sb-placement="trailing"], [data-sb-placement="principal"]) {
2337
- z-index: 2;
2348
+ z-index: 3;
2338
2349
  }
2339
2350
 
2340
2351
  /* Their symbols are drawn a little larger and heavier (semibold), as iOS 26
@@ -2351,17 +2362,21 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
2351
2362
  }
2352
2363
 
2353
2364
  /* iOS 26: a bottom toolbar's items float over the content on Liquid Glass
2354
- capsules (engine `bottomGlass0` …, src/layoutDom.ts), drawn in the label
2355
- color unless tinted, above the scroll edge effect. */
2365
+ capsules, drawn in the label color unless tinted, above the scroll edge
2366
+ effect. */
2356
2367
  .sb-toolbar[data-sb-placement="bottom"] {
2357
2368
  z-index: 3;
2358
2369
  --sb-color-accent: var(--sb-color-primary);
2359
2370
  }
2360
2371
 
2361
- .sb-bottom-glass {
2372
+ /* The bar's Liquid Glass capsules (src/layoutDom.ts; engine `leadingGlass0`
2373
+ …, `trailingGlass0` …, `bottomGlass0` …): a 44-tall capsule in the
2374
+ navigation bar, a 48-tall one in the bottom bar, under the items drawn on
2375
+ them (`data-sb-toolbars`). */
2376
+ .sb-bar-glass {
2362
2377
  position: absolute;
2363
2378
  z-index: 2;
2364
- border-radius: 24px;
2379
+ border-radius: 22px;
2365
2380
  background: var(--sb-glass-background);
2366
2381
  box-shadow: var(--sb-glass-shadow);
2367
2382
  backdrop-filter: blur(12px) saturate(180%);
@@ -2369,18 +2384,24 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
2369
2384
  pointer-events: none;
2370
2385
  }
2371
2386
 
2387
+ .sb-bar-glass[data-sb-placement="bottom"] {
2388
+ border-radius: 24px;
2389
+ }
2390
+
2372
2391
  /* iOS 26 press feedback on Liquid Glass (src/press.ts): while a pointer is
2373
- down on a bar button or a glass button, the glass under it (a bar group's
2374
- capsule, a bottom bar capsule, the back and More circles, the sidebar
2375
- toggle, a `.glass` button, a `.glassEffect` box) swells and brightens;
2376
- when it lifts, the glass springs back (an overshooting ease-out). The
2377
- pressed item in a group gets a highlight capsule of its own; buttons on
2378
- glass do not dim as plain buttons do. The swell is the `scale` property,
2379
- apart from `transform`, which the renderer sets (offsets, rotations) and
2380
- the Animator tweens. */
2392
+ down on a bar button or a glass button, the glass under it (a bar
2393
+ capsule, the back and More circles, the sidebar toggle, a `.glass`
2394
+ button, a `.glassEffect` box) swells and brightens; when it lifts, the
2395
+ glass springs back (an overshooting ease-out). The whole group swells,
2396
+ as on iOS, never one item of it (the toolbars on a capsule, beside it
2397
+ rather than in it, swell along: `data-sb-pressed-with`, about the
2398
+ capsule's center); buttons on glass do not dim as plain buttons do. The
2399
+ swell is the `scale` property, apart from `transform`, which the renderer
2400
+ sets (offsets, rotations) and the Animator tweens. */
2381
2401
  .sb-toolbar[data-sb-placement="leading"],
2382
2402
  .sb-toolbar[data-sb-placement="trailing"],
2383
- .sb-bottom-glass,
2403
+ .sb-toolbar[data-sb-placement="bottom"],
2404
+ .sb-bar-glass,
2384
2405
  .sb-navbar-back,
2385
2406
  .sb-navbar-more,
2386
2407
  .sb-navsplit-toggle,
@@ -2396,25 +2417,26 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
2396
2417
  /* A capsule of several items swells less: it is wider. */
2397
2418
  .sb-toolbar[data-sb-placement="leading"],
2398
2419
  .sb-toolbar[data-sb-placement="trailing"],
2399
- .sb-bottom-glass {
2420
+ .sb-toolbar[data-sb-placement="bottom"],
2421
+ .sb-bar-glass {
2400
2422
  --sb-press-scale: 1.05;
2401
2423
  }
2402
2424
 
2403
- [data-sb-pressed] {
2425
+ /* (as specific as the rules above, so the toolbars on a capsule and a glass
2426
+ button swell on this curve, in step with their glass, not on the spring
2427
+ back's) */
2428
+ [data-sb-pressed],
2429
+ [data-sb-pressed-with],
2430
+ .sb-toolbar[data-sb-pressed-with],
2431
+ .sb-button[data-sb-style="glass"][data-sb-pressed] {
2404
2432
  scale: var(--sb-press-scale);
2405
- background-color: var(--sb-glass-pressed);
2406
2433
  transition:
2407
2434
  scale 0.14s cubic-bezier(0.2, 0.8, 0.2, 1),
2408
2435
  background-color 0.14s ease;
2409
2436
  }
2410
2437
 
2411
- .sb-toolbar > [data-sb-pressed-item]::before {
2412
- content: "";
2413
- position: absolute;
2414
- inset: -11px -10px;
2415
- border-radius: 9999px;
2416
- background: var(--sb-glass-selected);
2417
- pointer-events: none;
2438
+ [data-sb-pressed] {
2439
+ background-color: var(--sb-glass-pressed);
2418
2440
  }
2419
2441
 
2420
2442
  .sb-toolbar:is([data-sb-placement="leading"], [data-sb-placement="trailing"], [data-sb-placement="bottom"]) :is(.sb-button, .sb-menu):active,
@@ -2426,7 +2448,10 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
2426
2448
  }
2427
2449
 
2428
2450
  @media (prefers-reduced-motion: reduce) {
2429
- [data-sb-pressed] {
2451
+ [data-sb-pressed],
2452
+ [data-sb-pressed-with],
2453
+ .sb-toolbar[data-sb-pressed-with],
2454
+ .sb-button[data-sb-style="glass"][data-sb-pressed] {
2430
2455
  scale: none;
2431
2456
  }
2432
2457
  }
@@ -2457,8 +2482,10 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
2457
2482
  .sb-searchfield {
2458
2483
  display: flex;
2459
2484
  align-items: center;
2460
- gap: 8px;
2461
- padding: 0 16px;
2485
+ /* iOS 26 (FoodTruck's donuts, measured): the magnifier's ink starts
2486
+ 19.33pt into the capsule, 16pt wide, and the text 46.67pt in. */
2487
+ gap: 8.5px;
2488
+ padding: 0 16px 0 18.5px;
2462
2489
  /* iOS 26: a glass capsule floating at the bottom (engine `searchField`). */
2463
2490
  border-radius: 9999px;
2464
2491
  background: var(--sb-glass-background);
@@ -2527,14 +2554,21 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
2527
2554
 
2528
2555
  .sb-searchfield > svg {
2529
2556
  display: block;
2530
- width: 17px;
2531
- height: 17px;
2557
+ width: 19.25px;
2558
+ height: 19.25px;
2532
2559
  flex-shrink: 0;
2533
- color: var(--sb-color-secondary);
2560
+ /* On the glass the magnifier is the label color, not gray. */
2561
+ color: var(--sb-color-primary);
2534
2562
  stroke: currentColor;
2535
2563
  stroke-width: 2.25;
2536
2564
  }
2537
2565
 
2566
+ .sb-navscreen[data-sb-search-top] > .sb-navscreen-body > .sb-navscreen-content > .sb-searchfield > svg {
2567
+ width: 17px;
2568
+ height: 17px;
2569
+ color: var(--sb-color-secondary);
2570
+ }
2571
+
2538
2572
  .sb-searchfield-input {
2539
2573
  flex: 1 1 auto;
2540
2574
  min-width: 0;
@@ -2551,6 +2585,12 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
2551
2585
  }
2552
2586
 
2553
2587
  .sb-searchfield-input::placeholder {
2588
+ /* Vibrant on the glass: the label color, partly see-through, so the
2589
+ backdrop tints it as iOS's does. */
2590
+ color: color-mix(in srgb, var(--sb-color-primary) 60%, transparent);
2591
+ }
2592
+
2593
+ .sb-navscreen[data-sb-search-top] > .sb-navscreen-body > .sb-navscreen-content > .sb-searchfield > .sb-searchfield-input::placeholder {
2554
2594
  color: var(--sb-color-secondary);
2555
2595
  }
2556
2596
 
@@ -2600,7 +2640,8 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
2600
2640
  box-shadow: -1px 0 0 var(--sb-color-separator);
2601
2641
  }
2602
2642
 
2603
- .sb-navsplit-columns > [data-sb-id]:first-child {
2643
+ .sb-navsplit-columns > [data-sb-id]:first-child,
2644
+ .sb-navsplit-columns > [data-sb-ghost]:first-child {
2604
2645
  box-shadow: 1px 0 0 var(--sb-color-separator);
2605
2646
  }
2606
2647
 
@@ -2650,7 +2691,8 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
2650
2691
 
2651
2692
  /* ViewThatFits: the candidates it did not choose have no frame and carry the
2652
2693
  `hidden` attribute (src/layoutDom.ts). */
2653
- .sb-viewthatfits > [data-sb-id][hidden] {
2694
+ .sb-viewthatfits > [data-sb-id][hidden],
2695
+ .sb-viewthatfits > [data-sb-ghost][hidden] {
2654
2696
  display: none;
2655
2697
  }
2656
2698
 
@@ -2686,10 +2728,15 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
2686
2728
  pointer-events: none;
2687
2729
  }
2688
2730
 
2731
+ /* Swift Charts' grid lines are half a point wide (measured: FoodTruck's
2732
+ forecast); a date axis's are dashed 2pt on, 2pt off. */
2689
2733
  .sb-chart-gridline {
2690
2734
  stroke: var(--sb-color-separator);
2691
- stroke-width: 1;
2692
- shape-rendering: crispEdges;
2735
+ stroke-width: 0.5;
2736
+ }
2737
+
2738
+ .sb-chart-gridline[data-sb-dashed] {
2739
+ stroke-dasharray: 2 2;
2693
2740
  }
2694
2741
 
2695
2742
  .sb-chart-tick {
@@ -2780,8 +2827,12 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
2780
2827
  (they are in `hidden` without frames). */
2781
2828
  .sb-contextmenu > [data-sb-id]:not(:first-child),
2782
2829
  .sb-contextmenu > [data-sb-id][hidden],
2830
+ .sb-contextmenu > [data-sb-ghost]:not(:first-child),
2831
+ .sb-contextmenu > [data-sb-ghost][hidden],
2783
2832
  .sb-swipeactions > .sb-swipe-content > [data-sb-id]:not(:first-child),
2784
- .sb-swipeactions > .sb-swipe-content > [data-sb-id][hidden] {
2833
+ .sb-swipeactions > .sb-swipe-content > [data-sb-id][hidden],
2834
+ .sb-swipeactions > .sb-swipe-content > [data-sb-ghost]:not(:first-child),
2835
+ .sb-swipeactions > .sb-swipe-content > [data-sb-ghost][hidden] {
2785
2836
  display: none;
2786
2837
  }
2787
2838
 
package/src/styles.ts CHANGED
@@ -127,13 +127,26 @@ export interface PaintCSS {
127
127
  backdropFilter?: string;
128
128
  }
129
129
 
130
- /** Label colors (text role) and fill colors (paint role) per hierarchical level. */
130
+ /**
131
+ * Label colors (text role) and fill colors (paint role) per hierarchical
132
+ * level. In the text role a level is one of the foreground an ancestor set
133
+ * (`--sb-hierarchy-<level>`, set with it), else the label color: iOS 26 draws
134
+ * `.secondary` under `.foregroundColor(.accentColor)` as the accent at 50%
135
+ * (Food Truck's card header icons) and `.tertiary` at 25% (their chevrons).
136
+ */
131
137
  const HIERARCHICAL_TEXT: Record<1 | 2 | 3 | 4, string> = {
132
- 1: 'var(--sb-color-primary)',
133
- 2: 'var(--sb-color-secondary)',
134
- 3: 'var(--sb-color-tertiary-label)',
135
- 4: 'var(--sb-color-quaternary-label)',
138
+ 1: 'var(--sb-hierarchy-1, var(--sb-color-primary))',
139
+ 2: 'var(--sb-hierarchy-2, var(--sb-color-secondary))',
140
+ 3: 'var(--sb-hierarchy-3, var(--sb-color-tertiary-label))',
141
+ 4: 'var(--sb-hierarchy-4, var(--sb-color-quaternary-label))',
136
142
  };
143
+
144
+ /**
145
+ * The opacity of each hierarchical level of a foreground color (iOS 26:
146
+ * 50% and 25% measured; the quaternary level, not measured, as the label
147
+ * colors' ratio has it).
148
+ */
149
+ export const HIERARCHY_OPACITY: Record<1 | 2 | 3 | 4, number> = { 1: 1, 2: 0.5, 3: 0.25, 4: 0.18 };
137
150
  const HIERARCHICAL_PAINT: Record<1 | 2 | 3 | 4, string> = {
138
151
  1: 'var(--sb-color-fill)',
139
152
  2: 'var(--sb-color-secondary-fill)',