@swiftbrowser/web 0.6.1 → 0.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swiftbrowser/web",
3
- "version": "0.6.1",
3
+ "version": "0.6.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": {
package/src/animation.ts CHANGED
@@ -316,18 +316,24 @@ function implicitTransitionOf(el: HTMLElement, parent: Element | null = el.paren
316
316
  /**
317
317
  * The bar toolbars whose groups this pass changed, each with its screen: one
318
318
  * that gained or lost an item (or came or went itself, on a screen already
319
- * there), or whose capsule or an item of it resized. A group that only
320
- * moved (the screen resized, a More button appeared beside it) is not one:
321
- * its items are as they were.
319
+ * there), or whose capsule or an item of it resized; and a group that only
320
+ * moved because another of its screen's changed, which slides over with
321
+ * it, as its capsule does. A group that only moved on its own (the screen
322
+ * resized, a More button appeared beside it) is not one: its items are as
323
+ * they were.
322
324
  */
323
325
  export function implicitToolbars(pass: PassRecord, frames: readonly FrameChange[]): Map<HTMLElement, Element | null> {
324
326
  const out = new Map<HTMLElement, Element | null>();
327
+ const moved = new Map<HTMLElement, Element | null>();
325
328
  const isNew = (toolbar: HTMLElement): boolean => pass.inserted.has(toolbar) || hasAncestorIn(toolbar, pass.inserted);
326
329
  for (const change of frames) {
327
330
  const toolbar = barToolbarOf(change.el);
328
331
  if (!toolbar || !change.from || !change.el.isConnected || isNew(toolbar)) continue;
329
332
  const { from, to } = change;
330
- if (change.el === toolbar && from.width === to.width && from.height === to.height) continue;
333
+ if (change.el === toolbar && from.width === to.width && from.height === to.height) {
334
+ moved.set(toolbar, toolbar.closest('.sb-navscreen'));
335
+ continue;
336
+ }
331
337
  out.set(toolbar, toolbar.closest('.sb-navscreen'));
332
338
  }
333
339
  for (const el of pass.inserted) {
@@ -343,6 +349,11 @@ export function implicitToolbars(pass: PassRecord, frames: readonly FrameChange[
343
349
  // A removed group is detached: its screen is among the ancestors it had.
344
350
  out.set(toolbar, toolbar.isConnected ? toolbar.closest('.sb-navscreen') : (rec.ancestors.find((a) => a.classList.contains('sb-navscreen')) ?? null));
345
351
  }
352
+ // (a group the bar does not draw, `secondary` items collapsed into the More button, moves nothing beside it)
353
+ const changedScreens = new Set(Array.from(out).filter(([toolbar]) => !toolbar.hidden).map(([, screen]) => screen));
354
+ for (const [toolbar, screen] of moved) {
355
+ if (!out.has(toolbar) && changedScreens.has(screen)) out.set(toolbar, screen);
356
+ }
346
357
  return out;
347
358
  }
348
359
 
@@ -497,9 +508,16 @@ export class Animator {
497
508
  // iOS 26: a bar toolbar's groups change with a spring of their own (see
498
509
  // the top of the file); the bar's glass capsules, beside their toolbars
499
510
  // rather than around them, follow the screen's bar toolbars.
511
+ // The spring moves the groups and their items, not what is inside an
512
+ // item: a button whose label changes (a tool selected, its symbol now
513
+ // on a tinted disc) changes in place, as UIKit redraws a bar item.
500
514
  const glassScreens = new Set<Element | null>();
515
+ const groupsOnly = new Set<HTMLElement>();
501
516
  for (const [toolbar, screen] of implicitToolbars(pass, frames)) {
502
- if (!tokenRoots.has(toolbar)) tokenRoots.set(toolbar, commitAnimation ?? TOOLBAR_ANIMATION);
517
+ if (!tokenRoots.has(toolbar)) {
518
+ tokenRoots.set(toolbar, commitAnimation ?? TOOLBAR_ANIMATION);
519
+ if (!commitAnimation) groupsOnly.add(toolbar);
520
+ }
503
521
  glassScreens.add(screen);
504
522
  }
505
523
  if (!commitAnimation && tokenRoots.size === 0) {
@@ -517,8 +535,14 @@ export class Animator {
517
535
  const matchedTo = new Map<RemovedRecord, { from: DOMRect; to: DOMRect }>();
518
536
 
519
537
  const animationFor = (chain: Iterable<HTMLElement>): Animation | undefined | false => {
538
+ let depth = 0;
520
539
  for (const el of chain) {
521
- if (tokenRoots.has(el)) return tokenRoots.get(el) ?? { type: 'default' };
540
+ if (tokenRoots.has(el)) {
541
+ // (below an item of a group the bar's own spring moves: not animated)
542
+ if (groupsOnly.has(el) && depth > 1) return false;
543
+ return tokenRoots.get(el) ?? { type: 'default' };
544
+ }
545
+ depth++;
522
546
  }
523
547
  return commitAnimation ?? false;
524
548
  };
@@ -552,6 +576,9 @@ export class Animator {
552
576
  if (animation === false && change.kind === BAR_GLASS_KIND && glassScreens.has(el.parentElement)) {
553
577
  animation = TOOLBAR_ANIMATION;
554
578
  }
579
+ // An item of such a group that only resized about its own center (its
580
+ // label changed, its slot stayed) is redrawn in place, not tweened.
581
+ if (animation !== false && groupsOnly.has(el.parentElement as HTMLElement) && sameCenter(from, to)) animation = false;
555
582
  if (animation === false) {
556
583
  // Moved in place, unless it carries on with a tween of its own.
557
584
  if (!this.retargetFrame(change, null)) jumped.set(el, { x: to.x - from.x, y: to.y - from.y });
@@ -975,6 +1002,11 @@ function jumpAbove(
975
1002
  }
976
1003
 
977
1004
  /** The element's ancestors, nearest first. */
1005
+ /** Whether two boxes share their center (to within a point: each edge is snapped to a whole point). */
1006
+ function sameCenter(a: FrameRect, b: FrameRect): boolean {
1007
+ return Math.abs(a.x + a.width / 2 - (b.x + b.width / 2)) <= 1 && Math.abs(a.y + a.height / 2 - (b.y + b.height / 2)) <= 1;
1008
+ }
1009
+
978
1010
  function ancestorsOf(el: HTMLElement): HTMLElement[] {
979
1011
  const out: HTMLElement[] = [];
980
1012
  for (let a = el.parentElement; a; a = a.parentElement) out.push(a);
package/src/chartDom.ts CHANGED
@@ -135,11 +135,15 @@ function buildPlot(doc: Document, props: Partial<ChartProps>, plot: Rect, box: R
135
135
  const grid = svg(doc, 'g', { class: 'sb-chart-grid' });
136
136
  for (const u of gridLines(props.xAxis)) {
137
137
  const x = r2(Math.floor(mapX(local, u)) + 0.5);
138
- grid.append(svg(doc, 'line', { class: 'sb-chart-gridline', 'data-sb-axis': 'x', x1: x, y1: 0, x2: x, y2: h }));
138
+ const line = svg(doc, 'line', { class: 'sb-chart-gridline', 'data-sb-axis': 'x', x1: x, y1: 0, x2: x, y2: h });
139
+ if (props.xAxis?.dashed) line.dataset['sbDashed'] = '';
140
+ grid.append(line);
139
141
  }
140
142
  for (const u of gridLines(props.yAxis)) {
141
143
  const y = r2(Math.floor(mapY(local, u)) + 0.5);
142
- grid.append(svg(doc, 'line', { class: 'sb-chart-gridline', 'data-sb-axis': 'y', x1: 0, y1: y, x2: w, y2: y }));
144
+ const line = svg(doc, 'line', { class: 'sb-chart-gridline', 'data-sb-axis': 'y', x1: 0, y1: y, x2: w, y2: y });
145
+ if (props.yAxis?.dashed) line.dataset['sbDashed'] = '';
146
+ grid.append(line);
143
147
  }
144
148
  root.append(grid);
145
149
 
@@ -263,16 +263,27 @@ const NAV_GROUP_SPACING = 12;
263
263
  const NAV_RUN_GAP = 12;
264
264
  const NAV_RUN_PAD = 1;
265
265
 
266
+ /**
267
+ * The children an item draws in the bar: a menu or context menu draws its
268
+ * label (the first child) and lists the rest in its popover, so its items
269
+ * (which a lazy menu only builds when it opens) do not make it wider or a
270
+ * text item.
271
+ */
272
+ function drawnChildren(node: LayoutNode): LayoutNode[] {
273
+ return node.kind === 'menu' || node.kind === 'contextmenu' ? node.children.slice(0, 1) : node.children;
274
+ }
275
+
266
276
  /** Whether any text is laid out in an item (a bottom bar pads text items as text). */
267
277
  function hasText(item: LayoutNode): boolean {
268
- return item.kind === 'text' || item.children.some(hasText);
278
+ return item.kind === 'text' || drawnChildren(item).some(hasText);
269
279
  }
270
280
 
271
281
  /** A bar item that is only a symbol (an `image`, or a button, menu or link around one). */
272
282
  function isSymbolItem(item: LayoutNode): boolean {
273
283
  const inner = unwrapStyled(item);
274
284
  if (inner.kind === 'image') return true;
275
- return inner.children.length === 1 && isSymbolItem(inner.children[0] as LayoutNode);
285
+ const drawn = drawnChildren(inner);
286
+ return drawn.length === 1 && isSymbolItem(drawn[0] as LayoutNode);
276
287
  }
277
288
 
278
289
  /** A bar item's slot in its glass capsule. */
@@ -300,9 +311,11 @@ const BOTTOM_FIXED_SPACER = 8;
300
311
  const SEARCH_FIELD_HEIGHT = 36;
301
312
  const SEARCH_FIELD_MARGIN_H = 16;
302
313
  // iOS 26: a navigation stack's search field is a 48-tall glass capsule
303
- // floating 16 from the sides and 28 above the screen bottom (8 above a view
304
- // that ends higher); the content stops above it.
314
+ // floating 28 from the sides (measured in the simulator: x 28.67…364.33 on the
315
+ // Fidelity `search` screen and Food Truck's Donuts) and 28 above the screen
316
+ // bottom (8 above a view that ends higher); the content stops above it.
305
317
  const SEARCH_BOTTOM_HEIGHT = 48;
318
+ const SEARCH_BOTTOM_MARGIN_H = 28;
306
319
  const SEARCH_BOTTOM_MARGIN = 28;
307
320
  const SEARCH_BOTTOM_MARGIN_INSET = 8;
308
321
  const SEARCH_FIELD_DEFAULT_WIDTH = 200;
@@ -2100,6 +2113,17 @@ export class LayoutPass {
2100
2113
  const child = edge === 'top' ? node.children[0] : node.children[node.children.length - 1];
2101
2114
  return child && !isSpacer(child) ? this.textEdge(child, this.childContext(node, ctx), edge, depth + 1) : null;
2102
2115
  }
2116
+ case 'hstack': {
2117
+ // A row with text in it (Food Truck's flavor line: a symbol and its
2118
+ // name) is spaced as its largest text, as iOS 26 spaces it.
2119
+ const cctx = this.childContext(node, ctx);
2120
+ let font: ResolvedFont | null = null;
2121
+ for (const child of node.children) {
2122
+ const found = this.textEdge(child, cctx, edge, depth + 1);
2123
+ if (found && (!font || found.size > font.size)) font = found;
2124
+ }
2125
+ return font;
2126
+ }
2103
2127
  default:
2104
2128
  return null;
2105
2129
  }
@@ -3293,10 +3317,11 @@ export class LayoutPass {
3293
3317
  };
3294
3318
  if (large) chrome['largeTitle'] = { x, y: y + barHeight, width: size.width, height: LARGE_TITLE_HEIGHT };
3295
3319
  if (parts.search) {
3320
+ const marginH = searchOnTop ? SEARCH_FIELD_MARGIN_H : SEARCH_BOTTOM_MARGIN_H;
3296
3321
  chrome['searchField'] = {
3297
- x: x + SEARCH_FIELD_MARGIN_H,
3322
+ x: x + marginH,
3298
3323
  y: searchOnTop ? y + barHeight + largeHeight + SEARCH_TOP_ABOVE : y + size.height - searchBottom,
3299
- width: Math.max(0, size.width - 2 * SEARCH_FIELD_MARGIN_H),
3324
+ width: Math.max(0, size.width - 2 * marginH),
3300
3325
  height: searchOnTop ? SEARCH_TOP_HEIGHT : SEARCH_BOTTOM_HEIGHT,
3301
3326
  };
3302
3327
  // The page draws a field under the title as the bar's (a gray capsule)
@@ -3537,11 +3562,9 @@ export class LayoutPass {
3537
3562
  // takes up to a pixel off a fractional width at a fractional origin, and
3538
3563
  // the title truncates with an ellipsis ("Animati…") when its box is any
3539
3564
  // narrower than its text.
3540
- const scroller = parts.content ? unwrapStyled(parts.content) : null;
3541
- const titleScrolls =
3542
- large &&
3543
- scroller !== null &&
3544
- (scroller.kind === 'list' || (scroller.kind === 'scrollview' && this.scrollAxes(scroller).vertical));
3565
+ const direct = parts.content ? unwrapStyled(parts.content) : null;
3566
+ const scroller = parts.content ? this.screenScroller(parts.content) : null;
3567
+ const titleScrolls = large && scroller !== null;
3545
3568
  // A large title that scrolls away collapses into the inline title, which
3546
3569
  // the renderer shows once it has (`data-sb-collapsed`).
3547
3570
  if ((!large || titleScrolls) && !principal) {
@@ -3605,9 +3628,7 @@ export class LayoutPass {
3605
3628
  barAbove: undefined,
3606
3629
  ...(bottomCover > 0 ? { bottomBar: bottomCover, bottomEdge: y + size.height } : {}),
3607
3630
  };
3608
- const scrollsUnderBar =
3609
- scroller !== null && (scroller.kind === 'list' || (scroller.kind === 'scrollview' && this.scrollAxes(scroller).vertical));
3610
- if (scroller && scrollsUnderBar) {
3631
+ if (scroller) {
3611
3632
  // iOS 26: the scroll view starts at the top of the screen and its
3612
3633
  // content scrolls under the bar (and the large title moves with it);
3613
3634
  // the content starts below them. It runs to the bottom of the screen,
@@ -3621,14 +3642,14 @@ export class LayoutPass {
3621
3642
  this.place(child, contentCtx, { width: content.width, height: content.height + top + bottomCover }, content.x, y);
3622
3643
  return;
3623
3644
  }
3624
- if (scroller) this.scrollInsets.delete(scroller);
3645
+ if (direct) this.scrollInsets.delete(direct);
3625
3646
  // A scroll view deeper in the content that touches the bar runs under it
3626
3647
  // (`scrollEdgeGrowth`); not under a large title, which stays put then.
3627
3648
  // One that reaches the bottom of the content runs down under all the
3628
3649
  // content clears there.
3629
3650
  if (!large) contentCtx.barAbove = { top: content.y, height: content.y - y, x, width: size.width };
3630
3651
  if (bottomCover > 0) contentCtx.bottomBar = bottomCover + bottomInset;
3631
- if (scroller && scroller.kind === 'scrollview') {
3652
+ if (direct && direct.kind === 'scrollview') {
3632
3653
  this.place(child, contentCtx, { width: content.width, height: content.height }, content.x, content.y);
3633
3654
  return;
3634
3655
  }
@@ -3639,6 +3660,30 @@ export class LayoutPass {
3639
3660
  this.place(child, contentCtx, proposal, content.x + dx, content.y + dy);
3640
3661
  }
3641
3662
 
3663
+ /**
3664
+ * The list or vertical scroll view that is a screen's content: through
3665
+ * modifiers and wrappers that put their child at their own origin and size
3666
+ * (a `GeometryReader`'s or a one-child `ZStack`'s child, a decoration's
3667
+ * content), as UIKit finds a screen's
3668
+ * scroll view. Food Truck's Truck page is a `GeometryReader` around its
3669
+ * `ScrollView`, its Donuts page a `ZStack` with a background around one:
3670
+ * their large titles scroll and collapse as on iOS. Null when there is none.
3671
+ */
3672
+ private screenScroller(node: LayoutNode): LayoutNode | null {
3673
+ let current: LayoutNode | undefined = node;
3674
+ for (let depth = 0; current && depth < 32; depth++) {
3675
+ if (current.kind === 'list' || (current.kind === 'scrollview' && this.scrollAxes(current).vertical)) return current;
3676
+ if ((current.kind === 'styled' || current.kind === 'geometry' || current.kind === 'zstack') && current.children.length === 1) {
3677
+ current = current.children[0];
3678
+ } else if (current.kind === 'decorated') {
3679
+ current = current.children[0];
3680
+ } else {
3681
+ return null;
3682
+ }
3683
+ }
3684
+ return null;
3685
+ }
3686
+
3642
3687
  /**
3643
3688
  * iOS 26's bottom toolbar: the items of the `bottom` toolbars, in order,
3644
3689
  * float on Liquid Glass in a 48 row at the top of the bottom bar's band
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
@@ -151,7 +151,7 @@ import { VideoEvents, applyVideo, stopVideos } from './video';
151
151
  import { applyCanvas, createCanvasElement } from './canvasDrawing';
152
152
  import { scrollToTarget } from './scrollTo';
153
153
  import { EMPTY_CATALOG, assetColorVar, type AssetCatalog } from './assets';
154
- import { colorToCSS, effectsCSS, styleToCSS, type PaintCSS } from './styles';
154
+ import { HIERARCHY_OPACITY, colorToCSS, effectsCSS, styleToCSS, type PaintCSS } from './styles';
155
155
  import { hostContent, onHostContentChange } from './hostContent';
156
156
  import { HoverTracker, applyPointerAndEffects, safeAreaOverlap } from './pointerEffects';
157
157
  import { colorMultiplyFilter } from './colorMultiply';
@@ -2897,6 +2897,8 @@ function applyKindProps(el: HTMLElement, kind: ElementKind, props: ElementProps,
2897
2897
  case 'navlink':
2898
2898
  // Phase 7: a selected row (inside a `List(selection:)`) gets a rounded fill.
2899
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'];
2900
2902
  break;
2901
2903
  case 'viewthatfits': {
2902
2904
  const axes = (props as ViewThatFitsProps | undefined)?.axes;
@@ -3436,6 +3438,11 @@ function applyImage(el: HTMLElement, props: ImageProps, assets: AssetCatalog): v
3436
3438
  delete el.dataset['sbResizable'];
3437
3439
  el.dataset['sbSymbol'] = name;
3438
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
+ }
3439
3446
  if (svg) found(name, svg);
3440
3447
  else missing(name);
3441
3448
  }
@@ -3692,6 +3699,21 @@ export function applyStyle(el: HTMLElement, style: Style): void {
3692
3699
  // styled box wins, as it does in SwiftUI.
3693
3700
  s.color = paint.solid;
3694
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
+ });
3695
3717
  if (paint.kind === 'gradient') {
3696
3718
  // Text paints the gradient through its own background clipped to the
3697
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
 
@@ -2405,8 +2422,13 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
2405
2422
  --sb-press-scale: 1.05;
2406
2423
  }
2407
2424
 
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) */
2408
2428
  [data-sb-pressed],
2409
- [data-sb-pressed-with] {
2429
+ [data-sb-pressed-with],
2430
+ .sb-toolbar[data-sb-pressed-with],
2431
+ .sb-button[data-sb-style="glass"][data-sb-pressed] {
2410
2432
  scale: var(--sb-press-scale);
2411
2433
  transition:
2412
2434
  scale 0.14s cubic-bezier(0.2, 0.8, 0.2, 1),
@@ -2427,7 +2449,9 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
2427
2449
 
2428
2450
  @media (prefers-reduced-motion: reduce) {
2429
2451
  [data-sb-pressed],
2430
- [data-sb-pressed-with] {
2452
+ [data-sb-pressed-with],
2453
+ .sb-toolbar[data-sb-pressed-with],
2454
+ .sb-button[data-sb-style="glass"][data-sb-pressed] {
2431
2455
  scale: none;
2432
2456
  }
2433
2457
  }
@@ -2458,8 +2482,10 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
2458
2482
  .sb-searchfield {
2459
2483
  display: flex;
2460
2484
  align-items: center;
2461
- gap: 8px;
2462
- 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;
2463
2489
  /* iOS 26: a glass capsule floating at the bottom (engine `searchField`). */
2464
2490
  border-radius: 9999px;
2465
2491
  background: var(--sb-glass-background);
@@ -2528,14 +2554,21 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
2528
2554
 
2529
2555
  .sb-searchfield > svg {
2530
2556
  display: block;
2531
- width: 17px;
2532
- height: 17px;
2557
+ width: 19.25px;
2558
+ height: 19.25px;
2533
2559
  flex-shrink: 0;
2534
- color: var(--sb-color-secondary);
2560
+ /* On the glass the magnifier is the label color, not gray. */
2561
+ color: var(--sb-color-primary);
2535
2562
  stroke: currentColor;
2536
2563
  stroke-width: 2.25;
2537
2564
  }
2538
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
+
2539
2572
  .sb-searchfield-input {
2540
2573
  flex: 1 1 auto;
2541
2574
  min-width: 0;
@@ -2552,6 +2585,12 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
2552
2585
  }
2553
2586
 
2554
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 {
2555
2594
  color: var(--sb-color-secondary);
2556
2595
  }
2557
2596
 
@@ -2601,7 +2640,8 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
2601
2640
  box-shadow: -1px 0 0 var(--sb-color-separator);
2602
2641
  }
2603
2642
 
2604
- .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 {
2605
2645
  box-shadow: 1px 0 0 var(--sb-color-separator);
2606
2646
  }
2607
2647
 
@@ -2651,7 +2691,8 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
2651
2691
 
2652
2692
  /* ViewThatFits: the candidates it did not choose have no frame and carry the
2653
2693
  `hidden` attribute (src/layoutDom.ts). */
2654
- .sb-viewthatfits > [data-sb-id][hidden] {
2694
+ .sb-viewthatfits > [data-sb-id][hidden],
2695
+ .sb-viewthatfits > [data-sb-ghost][hidden] {
2655
2696
  display: none;
2656
2697
  }
2657
2698
 
@@ -2687,10 +2728,15 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
2687
2728
  pointer-events: none;
2688
2729
  }
2689
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. */
2690
2733
  .sb-chart-gridline {
2691
2734
  stroke: var(--sb-color-separator);
2692
- stroke-width: 1;
2693
- shape-rendering: crispEdges;
2735
+ stroke-width: 0.5;
2736
+ }
2737
+
2738
+ .sb-chart-gridline[data-sb-dashed] {
2739
+ stroke-dasharray: 2 2;
2694
2740
  }
2695
2741
 
2696
2742
  .sb-chart-tick {
@@ -2781,8 +2827,12 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
2781
2827
  (they are in `hidden` without frames). */
2782
2828
  .sb-contextmenu > [data-sb-id]:not(:first-child),
2783
2829
  .sb-contextmenu > [data-sb-id][hidden],
2830
+ .sb-contextmenu > [data-sb-ghost]:not(:first-child),
2831
+ .sb-contextmenu > [data-sb-ghost][hidden],
2784
2832
  .sb-swipeactions > .sb-swipe-content > [data-sb-id]:not(:first-child),
2785
- .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] {
2786
2836
  display: none;
2787
2837
  }
2788
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)',
package/src/symbols.ts CHANGED
@@ -501,6 +501,9 @@ const BOOKS_VERTICAL_FILL: IconNode = [
501
501
  ['rect', { x: '17.8', y: '4.5', width: '4', height: '16', rx: '1.2', stroke: 'none', transform: 'rotate(-14 19.8 12.5)' }],
502
502
  ];
503
503
 
504
+ /** Marks a `knockout` glyph's inner shape that is filled, not only stroked (`enclosed`). */
505
+ const SOLID_MARK = 'data-sb-knockout-solid';
506
+
504
507
  /** Color used for inner marks of `knockout` glyphs; styles.css sets the variable. */
505
508
  const KNOCKOUT_COLOR = 'var(--sb-icon-knockout, var(--sb-color-system-background, #fff))';
506
509
 
@@ -957,8 +960,6 @@ export const SF_SYMBOLS: Readonly<Record<string, SymbolGlyph>> = {
957
960
  'cloud.snow': outline(CloudSnow),
958
961
  'cloud.sun': outline(CloudSun),
959
962
  'cloud.sun.fill': solid(CloudSun),
960
- 'moon.circle.fill': solid(Moon),
961
- 'sun.max.circle.fill': solid(Sun),
962
963
  'person.3': outline(Users),
963
964
  'person.3.fill': solid(Users),
964
965
  'chart.line.uptrend.xyaxis': outline(TrendingUp),
@@ -1326,7 +1327,9 @@ function resolveVariant(systemName: string): SymbolGlyph | undefined {
1326
1327
  } else {
1327
1328
  const base = parts.slice(0, -1).join('.');
1328
1329
  const glyph = GENERIC_SHAPES.has(base) ? undefined : lookupSymbol(base);
1329
- if (glyph) return enclosed(glyph.icon, enclosure, fill);
1330
+ // A base whose filled form is a silhouette (`moon.fill`) is knocked
1331
+ // out solid, as `moon.circle.fill` draws its moon.
1332
+ if (glyph) return enclosed(glyph.icon, enclosure, fill, fill && lookupSymbol(`${base}.fill`)?.fill === 'solid');
1330
1333
  }
1331
1334
  }
1332
1335
  const bases: string[] = [];
@@ -1350,13 +1353,17 @@ function resolveVariant(systemName: string): SymbolGlyph | undefined {
1350
1353
  /**
1351
1354
  * `icon` at 55% inside a circle or rounded square: outlined (`X.circle`), or
1352
1355
  * filled with the glyph knocked out of it (`X.circle.fill`), as SF Symbols
1353
- * draw their enclosed forms.
1356
+ * draw their enclosed forms; `solid`, the glyph's shapes are knocked out
1357
+ * whole rather than as strokes.
1354
1358
  */
1355
- function enclosed(icon: IconNode, enclosure: 'circle' | 'square', fill: boolean): SymbolGlyph {
1359
+ function enclosed(icon: IconNode, enclosure: 'circle' | 'square', fill: boolean, solid = false): SymbolGlyph {
1356
1360
  const outer: IconNode[number] =
1357
1361
  enclosure === 'circle' ? ['circle', { cx: '12', cy: '12', r: '10' }] : ['rect', { x: '3', y: '3', width: '18', height: '18', rx: '4' }];
1358
1362
  // Scaled about the center; the stroke is widened to keep its weight.
1359
- const inner = icon.map(([tag, attrs]) => [tag, { ...attrs, transform: 'translate(5.4 5.4) scale(0.55)', 'stroke-width': '3.3' }] as IconNode[number]);
1363
+ const inner = icon.map(
1364
+ ([tag, attrs]) =>
1365
+ [tag, { ...attrs, transform: 'translate(5.4 5.4) scale(0.55)', 'stroke-width': '3.3', ...(solid ? { [SOLID_MARK]: '' } : {}) }] as IconNode[number],
1366
+ );
1360
1367
  return { icon: [outer, ...inner], fill: fill ? 'knockout' : 'outline' };
1361
1368
  }
1362
1369
 
@@ -1421,7 +1428,8 @@ export function renderSymbol(systemName: string): SVGElement | null {
1421
1428
  shape.setAttribute('fill', 'currentColor');
1422
1429
  } else {
1423
1430
  shape.setAttribute('stroke', KNOCKOUT_COLOR);
1424
- shape.setAttribute('fill', 'none');
1431
+ shape.setAttribute('fill', shape.hasAttribute(SOLID_MARK) ? KNOCKOUT_COLOR : 'none');
1432
+ shape.removeAttribute(SOLID_MARK);
1425
1433
  }
1426
1434
  });
1427
1435
  }