@swiftbrowser/web 0.6.1 → 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/package.json +1 -1
- package/src/animation.ts +38 -6
- package/src/chartDom.ts +6 -2
- package/src/layout/engine.ts +61 -16
- package/src/protocol.ts +16 -0
- package/src/renderer.ts +23 -1
- package/src/styles.css +73 -23
- package/src/styles.ts +18 -5
- package/src/symbols.ts +15 -7
package/package.json
CHANGED
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
|
|
320
|
-
* moved
|
|
321
|
-
* its
|
|
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)
|
|
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))
|
|
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))
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/layout/engine.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
|
304
|
-
//
|
|
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 +
|
|
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 *
|
|
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
|
|
3541
|
-
const
|
|
3542
|
-
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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
|
|
561
|
-
about
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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
|
-
|
|
2462
|
-
|
|
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:
|
|
2532
|
-
height:
|
|
2557
|
+
width: 19.25px;
|
|
2558
|
+
height: 19.25px;
|
|
2533
2559
|
flex-shrink: 0;
|
|
2534
|
-
color
|
|
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:
|
|
2693
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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(
|
|
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
|
}
|