@swiftbrowser/web 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/app/index.html +1 -0
- package/package.json +2 -1
- package/src/largeTitle.ts +4 -1
- package/src/layout/engine.ts +250 -48
- package/src/layout/index.ts +3 -3
- package/src/layoutDom.ts +23 -0
- package/src/page.css +234 -0
- package/src/protocol.ts +32 -1
- package/src/renderer.ts +79 -5
- package/src/scrollEdge.ts +26 -4
- package/src/styles.css +103 -252
- package/src/symbols.ts +3 -1
package/README.md
CHANGED
|
@@ -35,6 +35,7 @@ src/imageOps.ts Core Image's steps (sepia, crystallize, edges, blur, pixella
|
|
|
35
35
|
src/hostContent.ts `host` elements: the registry of the page's own DOM elements the renderer adopts (docs/ops-protocol.md, "Host elements")
|
|
36
36
|
src/pointerEffects.ts Hover events, tooltips, cursors and symbol effects for page-side producers; frame reports' safe-area overlap (docs/ops-protocol.md, "Pointer, effects, matched geometry and frames on screen")
|
|
37
37
|
src/styles.css iOS tokens, device frame, device mode, element styles, host boxes and islands
|
|
38
|
+
src/page.css The app page around the frame: its toolbar, stage, error banner, device-mode chrome
|
|
38
39
|
node/ Node side: apps.ts (the AppSpec registry type), config.ts (Vite config from specs),
|
|
39
40
|
examples.ts (Examples/* as specs, build-wasm.sh runner, descriptions),
|
|
40
41
|
index.ts (createDevServer / buildSite, the package entry); compiled to node/dist/
|
package/app/index.html
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
|
25
25
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
|
26
26
|
<link rel="stylesheet" href="/src/styles.css" />
|
|
27
|
+
<link rel="stylesheet" href="/src/page.css" />
|
|
27
28
|
<script>
|
|
28
29
|
// Decide frame vs device mode before the first paint so a phone never
|
|
29
30
|
// flashes the fake bezel. src/deviceMode.ts holds the same rule and is
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@swiftbrowser/web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
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": {
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"default": "./node/dist/node/index.js"
|
|
17
17
|
},
|
|
18
18
|
"./src/styles.css": "./src/styles.css",
|
|
19
|
+
"./src/page.css": "./src/page.css",
|
|
19
20
|
"./src/*": "./src/*.ts"
|
|
20
21
|
},
|
|
21
22
|
"files": [
|
package/src/largeTitle.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import type { Rect } from './layout';
|
|
22
|
-
import { setValue } from './scrollEdge';
|
|
22
|
+
import { edgeStrength, setProperty, setValue } from './scrollEdge';
|
|
23
23
|
|
|
24
24
|
/** The scroll offset past which the title has gone under the bar. */
|
|
25
25
|
const COLLAPSE_OFFSET = 44;
|
|
@@ -65,6 +65,7 @@ export function bindScrollUnderBar(scroller: HTMLElement, frame: Rect, topInset:
|
|
|
65
65
|
delete screen.dataset['sbScrolled'];
|
|
66
66
|
delete screen.dataset['sbCollapsed'];
|
|
67
67
|
delete screen.dataset['sbEdgeTop'];
|
|
68
|
+
screen.style.removeProperty('--sb-edge-top');
|
|
68
69
|
}
|
|
69
70
|
}
|
|
70
71
|
if (screen) releaseTitle(screen, scroller);
|
|
@@ -106,6 +107,8 @@ export function syncScrollUnderBar(scroller: HTMLElement): void {
|
|
|
106
107
|
if (!screen) return;
|
|
107
108
|
const offset = scroller.scrollTop;
|
|
108
109
|
setFlag(screen, 'sbScrolled', offset > 0);
|
|
110
|
+
// The edge effect's strength ramps up over the first `EDGE_RAMP` px.
|
|
111
|
+
setProperty(screen, '--sb-edge-top', edgeStrength(offset));
|
|
109
112
|
const title = titles.get(screen);
|
|
110
113
|
const carries = title !== undefined && title.parentElement === scroller;
|
|
111
114
|
setFlag(screen, 'sbCollapsed', carries && offset > COLLAPSE_OFFSET);
|
package/src/layout/engine.ts
CHANGED
|
@@ -200,10 +200,13 @@ const LIST_SECTION_GAP = 107 / 3;
|
|
|
200
200
|
const LIST_SECTION_GAP_AFTER_FOOTER = 74 / 3;
|
|
201
201
|
const LIST_CHEVRON_WIDTH = 20;
|
|
202
202
|
// Phase 4.
|
|
203
|
-
// iOS 26: the tab bar is a 62-tall glass capsule floating
|
|
204
|
-
//
|
|
205
|
-
//
|
|
203
|
+
// iOS 26: the tab bar is a 62-tall glass capsule floating 22 above the bottom
|
|
204
|
+
// edge (8 when the tab view ends above the screen bottom), its items in equal
|
|
205
|
+
// slots inside a 4 inset. It is as wide as its tabs (268/3 each, measured in
|
|
206
|
+
// the 26.2 simulator: two tabs make a 186.67 capsule), centered, and at most
|
|
207
|
+
// the width less 21 either side (four tabs fill that on an iPhone).
|
|
206
208
|
const TAB_BAR_HEIGHT = 62;
|
|
209
|
+
const TAB_BAR_ITEM_WIDTH = 268 / 3;
|
|
207
210
|
const TAB_BAR_MARGIN_H = 21;
|
|
208
211
|
const TAB_BAR_MARGIN_BOTTOM = 22;
|
|
209
212
|
const TAB_BAR_MARGIN_BOTTOM_INSET = 8;
|
|
@@ -243,7 +246,18 @@ const NAV_ITEM_MARGIN = 16;
|
|
|
243
246
|
const NAV_GLASS_SIZE = 44;
|
|
244
247
|
const NAV_GLASS_PAD_H = 12;
|
|
245
248
|
const NAV_GROUP_SPACING = 8;
|
|
246
|
-
|
|
249
|
+
// iOS 26 (measured in the 26.2 simulator, Examples/Fidelity `bottombar*`): a
|
|
250
|
+
// bottom toolbar's items float on Liquid Glass in a 48 row 28 above the
|
|
251
|
+
// screen bottom (as the search field does) and 28 in from the sides.
|
|
252
|
+
// Consecutive items share a capsule: an icon takes a 50 slot, text its width
|
|
253
|
+
// and 18 either side, 6 between slots; a lone icon is a 48 circle.
|
|
254
|
+
const BOTTOM_GLASS_HEIGHT = 48;
|
|
255
|
+
const BOTTOM_GLASS_MARGIN_H = 28;
|
|
256
|
+
const BOTTOM_ICON_SLOT = 50;
|
|
257
|
+
const BOTTOM_TEXT_PAD_H = 18;
|
|
258
|
+
const BOTTOM_ITEM_GAP = 6;
|
|
259
|
+
/** A fixed `ToolbarSpacer` between two bottom groups. */
|
|
260
|
+
const BOTTOM_FIXED_SPACER = 8;
|
|
247
261
|
const SEARCH_FIELD_HEIGHT = 36;
|
|
248
262
|
const SEARCH_FIELD_MARGIN_H = 16;
|
|
249
263
|
// iOS 26: a navigation stack's search field is a 48-tall glass capsule
|
|
@@ -256,6 +270,8 @@ const SEARCH_FIELD_DEFAULT_WIDTH = 200;
|
|
|
256
270
|
const IMAGE_SCALES: Readonly<Record<string, number>> = { small: 0.8, medium: 1, large: 1.25 };
|
|
257
271
|
// Phase 7 (docs/ops-protocol.md, "NavigationSplitView").
|
|
258
272
|
const NAVSPLIT_SIDEBAR_WIDTH = 320;
|
|
273
|
+
/** From this root width a window is regular (an iPad, a desktop browser), as `@swiftbrowser/react`'s size class. */
|
|
274
|
+
const REGULAR_WIDTH = 700;
|
|
259
275
|
/** The sidebar never takes more than this share of the split view's width. */
|
|
260
276
|
const NAVSPLIT_SIDEBAR_MAX_FRACTION = 0.4;
|
|
261
277
|
const NAVSPLIT_SEPARATOR_WIDTH = 1;
|
|
@@ -568,7 +584,7 @@ interface GridItemSpec {
|
|
|
568
584
|
alignment: string | null;
|
|
569
585
|
}
|
|
570
586
|
|
|
571
|
-
/** `columns` of a `lazyvgrid` (untrusted JSON); an empty list is one flexible
|
|
587
|
+
/** `columns` of a `lazyvgrid` or `rows` of a `lazyhgrid` (untrusted JSON); an empty list is one flexible track. */
|
|
572
588
|
function readGridItems(value: unknown): GridItemSpec[] {
|
|
573
589
|
const items: GridItemSpec[] = [];
|
|
574
590
|
if (Array.isArray(value)) {
|
|
@@ -1526,6 +1542,7 @@ export class LayoutPass {
|
|
|
1526
1542
|
case 'grid':
|
|
1527
1543
|
return this.gridPlan(node, ctx, p).size;
|
|
1528
1544
|
case 'lazyvgrid':
|
|
1545
|
+
case 'lazyhgrid':
|
|
1529
1546
|
return this.lazyGridPlan(node, ctx, p).size;
|
|
1530
1547
|
case 'gauge':
|
|
1531
1548
|
return this.gaugePlan(node, ctx, p).size;
|
|
@@ -2778,6 +2795,7 @@ export class LayoutPass {
|
|
|
2778
2795
|
this.placeGrid(node, ctx, p, x, y);
|
|
2779
2796
|
return;
|
|
2780
2797
|
case 'lazyvgrid':
|
|
2798
|
+
case 'lazyhgrid':
|
|
2781
2799
|
this.placeLazyGrid(node, ctx, p, x, y);
|
|
2782
2800
|
return;
|
|
2783
2801
|
case 'gauge':
|
|
@@ -3000,13 +3018,14 @@ export class LayoutPass {
|
|
|
3000
3018
|
/**
|
|
3001
3019
|
* The navigation bar is the status bar plus a 44 row (plus 62 for a large
|
|
3002
3020
|
* title); a Phase 6 `searchfield` is a capsule floating above the bottom
|
|
3003
|
-
* (iOS 26);
|
|
3004
|
-
* content is proposed the
|
|
3005
|
-
* items are measured at their ideal size and laid out in iOS
|
|
3006
|
-
* capsules (44-tall slots, groups 8 apart) in the 44 row
|
|
3007
|
-
*
|
|
3008
|
-
*
|
|
3009
|
-
*
|
|
3021
|
+
* (iOS 26); `bottom` toolbars float their items on glass in a 48 row 28
|
|
3022
|
+
* above the bottom (`placeBottomToolbars`); the content is proposed the
|
|
3023
|
+
* rest. Toolbar items are measured at their ideal size and laid out in iOS
|
|
3024
|
+
* 26 glass capsules (44-tall slots, groups 8 apart) in the 44 row
|
|
3025
|
+
* (`leading` after the back button, `trailing` 16 from the trailing edge,
|
|
3026
|
+
* `principal` centered in place of the title). A `ToolbarSpacer` (an empty
|
|
3027
|
+
* toolbar with `spacer`) only separates groups there. The inline title
|
|
3028
|
+
* (`chrome.title`) is centered and truncated to the room the bar items leave.
|
|
3010
3029
|
*/
|
|
3011
3030
|
private placeNavScreen(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
|
|
3012
3031
|
const { safeArea, screen, dynamicTypeSize } = this.env;
|
|
@@ -3018,10 +3037,12 @@ export class LayoutPass {
|
|
|
3018
3037
|
const largeHeight = large ? LARGE_TITLE_HEIGHT : 0;
|
|
3019
3038
|
const parts = this.navScreenParts(node);
|
|
3020
3039
|
const reachesBottom = y + size.height >= screen.height - EPSILON;
|
|
3040
|
+
const isSpacer = (t: LayoutNode): boolean => str(t.props['spacer']) !== null;
|
|
3021
3041
|
const bottomToolbars = parts.toolbars.filter((t) => str(t.props['placement']) === 'bottom');
|
|
3022
|
-
const bottomBarHeight =
|
|
3023
|
-
bottomToolbars.length > 0 ? Math.min(size.height, BOTTOM_BAR_HEIGHT + (reachesBottom ? safeArea.bottom : 0)) : 0;
|
|
3024
3042
|
const searchMargin = reachesBottom ? SEARCH_BOTTOM_MARGIN : SEARCH_BOTTOM_MARGIN_INSET;
|
|
3043
|
+
const bottomBarHeight = bottomToolbars.some((t) => !isSpacer(t))
|
|
3044
|
+
? Math.min(size.height, BOTTOM_GLASS_HEIGHT + searchMargin)
|
|
3045
|
+
: 0;
|
|
3025
3046
|
const searchBottom = parts.search ? SEARCH_BOTTOM_HEIGHT + searchMargin : 0;
|
|
3026
3047
|
const contentTop = barHeight + largeHeight;
|
|
3027
3048
|
const content: Rect = {
|
|
@@ -3063,7 +3084,15 @@ export class LayoutPass {
|
|
|
3063
3084
|
slots: number[] | null;
|
|
3064
3085
|
total: number;
|
|
3065
3086
|
}
|
|
3066
|
-
|
|
3087
|
+
// The navigation bar's groups; spacers there only separate them (the
|
|
3088
|
+
// groups are 8 apart anyway) and draw nothing.
|
|
3089
|
+
for (const spacer of parts.toolbars.filter(isSpacer)) this.hide(spacer, node);
|
|
3090
|
+
const barToolbars = parts.toolbars.filter((t) => {
|
|
3091
|
+
const placement = str(t.props['placement']);
|
|
3092
|
+
return placement !== 'bottom' && placement !== 'secondary' && !isSpacer(t);
|
|
3093
|
+
});
|
|
3094
|
+
const secondaryToolbars = parts.toolbars.filter((t) => str(t.props['placement']) === 'secondary' && !isSpacer(t));
|
|
3095
|
+
const groups: Group[] = barToolbars.map((toolbar) => {
|
|
3067
3096
|
const itemCtx = this.childContext(toolbar, cctx);
|
|
3068
3097
|
const sizes = toolbar.children.map((item) => this.measure(item, itemCtx, { width: null, height: null }));
|
|
3069
3098
|
const placement = str(toolbar.props['placement']) ?? 'trailing';
|
|
@@ -3080,6 +3109,53 @@ export class LayoutPass {
|
|
|
3080
3109
|
const trailingWidth = groupWidth('trailing');
|
|
3081
3110
|
const principal = groups.some((g) => g.placement === 'principal');
|
|
3082
3111
|
|
|
3112
|
+
// `.secondaryAction` items (placement `secondary`), as iOS treats them:
|
|
3113
|
+
// collapsed into the More button (chrome `more`: an ellipsis in a glass
|
|
3114
|
+
// circle at the trailing end, whose menu lists them). A screen with the
|
|
3115
|
+
// editor toolbar role in a regular width shows them inline before the
|
|
3116
|
+
// trailing items, as iPadOS does, clear of the title, and collapses only
|
|
3117
|
+
// those that do not fit.
|
|
3118
|
+
interface SecondaryItem {
|
|
3119
|
+
toolbar: LayoutNode;
|
|
3120
|
+
item: LayoutNode;
|
|
3121
|
+
size: Size;
|
|
3122
|
+
slot: number;
|
|
3123
|
+
}
|
|
3124
|
+
const secondaryItems: SecondaryItem[] = secondaryToolbars.flatMap((toolbar) => {
|
|
3125
|
+
const itemCtx = this.childContext(toolbar, cctx);
|
|
3126
|
+
return toolbar.children.map((item) => {
|
|
3127
|
+
const itemSize = this.measure(item, itemCtx, { width: null, height: null });
|
|
3128
|
+
return { toolbar, item, size: itemSize, slot: Math.max(NAV_GLASS_SIZE, itemSize.width + 2 * NAV_GLASS_PAD_H) };
|
|
3129
|
+
});
|
|
3130
|
+
});
|
|
3131
|
+
// Items of one toolbar share its glass capsule; separate toolbars sit 8 apart, as the trailing groups do.
|
|
3132
|
+
const secondaryGap = (previous: SecondaryItem | undefined, entry: SecondaryItem): number =>
|
|
3133
|
+
previous && previous.toolbar !== entry.toolbar ? NAV_GROUP_SPACING : 0;
|
|
3134
|
+
const secondaryWidth = (entries: readonly SecondaryItem[]): number =>
|
|
3135
|
+
entries.reduce((sum, entry, i) => sum + secondaryGap(entries[i - 1], entry) + entry.slot, 0);
|
|
3136
|
+
let inlineSecondary: SecondaryItem[] = [];
|
|
3137
|
+
if (secondaryItems.length > 0 && str(node.props['toolbarRole']) === 'editor' && screen.width >= REGULAR_WIDTH) {
|
|
3138
|
+
// The room between the title (centered, at its width up to 56% of the
|
|
3139
|
+
// bar) and the trailing groups; the More button takes a slot of it
|
|
3140
|
+
// once anything collapses.
|
|
3141
|
+
const titleWidth = principal ? 0 : Math.min(this.measurer.measureWidth(title, titleFont), size.width * 0.56);
|
|
3142
|
+
const roomStart = x + (size.width + titleWidth) / 2 + NAV_GROUP_SPACING;
|
|
3143
|
+
const roomEnd = x + size.width - NAV_ITEM_MARGIN - trailingWidth;
|
|
3144
|
+
const fitting = (room: number): SecondaryItem[] => {
|
|
3145
|
+
const out: SecondaryItem[] = [];
|
|
3146
|
+
let used = 0;
|
|
3147
|
+
for (const entry of secondaryItems) {
|
|
3148
|
+
const width = secondaryGap(out[out.length - 1], entry) + entry.slot;
|
|
3149
|
+
if (used + width > room) break;
|
|
3150
|
+
used += width;
|
|
3151
|
+
out.push(entry);
|
|
3152
|
+
}
|
|
3153
|
+
return out;
|
|
3154
|
+
};
|
|
3155
|
+
inlineSecondary = secondaryWidth(secondaryItems) <= roomEnd - roomStart ? secondaryItems : fitting(roomEnd - roomStart - NAV_GLASS_SIZE - NAV_GROUP_SPACING);
|
|
3156
|
+
}
|
|
3157
|
+
const collapsedSecondary = secondaryItems.filter((entry) => !inlineSecondary.includes(entry));
|
|
3158
|
+
|
|
3083
3159
|
// Back button, iOS 26: the chevron alone in a glass circle (the previous
|
|
3084
3160
|
// title no longer shows).
|
|
3085
3161
|
const backLabel = '';
|
|
@@ -3092,6 +3168,11 @@ export class LayoutPass {
|
|
|
3092
3168
|
const back = chrome['back'];
|
|
3093
3169
|
let leadingCursor = back ? back.x + back.width + NAV_GROUP_SPACING : leadingStart;
|
|
3094
3170
|
let trailingEdge = x + size.width - NAV_ITEM_MARGIN;
|
|
3171
|
+
// The More button is the trailing-most item.
|
|
3172
|
+
if (collapsedSecondary.length > 0) {
|
|
3173
|
+
chrome['more'] = { x: trailingEdge - NAV_GLASS_SIZE, y: rowY, width: NAV_GLASS_SIZE, height: NAV_GLASS_SIZE };
|
|
3174
|
+
trailingEdge -= NAV_GLASS_SIZE + NAV_GROUP_SPACING;
|
|
3175
|
+
}
|
|
3095
3176
|
const placeGroup = (group: Group, startX: number, top: number, rowHeight: number, spacing: number): void => {
|
|
3096
3177
|
const itemCtx = this.childContext(group.toolbar, cctx);
|
|
3097
3178
|
let cursor = startX;
|
|
@@ -3118,19 +3199,6 @@ export class LayoutPass {
|
|
|
3118
3199
|
placeGroup(group, startX, rowY, NAV_BAR_HEIGHT, NAV_ITEM_SPACING);
|
|
3119
3200
|
break;
|
|
3120
3201
|
}
|
|
3121
|
-
case 'bottom': {
|
|
3122
|
-
const bar = chrome['bottomBar'] as Rect;
|
|
3123
|
-
this.setFrame(group.toolbar, bar.x, bar.y, bar);
|
|
3124
|
-
const n = group.sizes.length;
|
|
3125
|
-
const inner = Math.max(0, size.width - 2 * NAV_ITEM_MARGIN);
|
|
3126
|
-
const itemsWidth = group.total - Math.max(0, n - 1) * NAV_ITEM_SPACING;
|
|
3127
|
-
if (n === 1) placeGroup(group, x + (size.width - itemsWidth) / 2, bar.y, BOTTOM_BAR_HEIGHT, 0);
|
|
3128
|
-
else if (n > 1) {
|
|
3129
|
-
const gap = Math.max(NAV_ITEM_SPACING, (inner - itemsWidth) / (n - 1));
|
|
3130
|
-
placeGroup(group, x + NAV_ITEM_MARGIN, bar.y, BOTTOM_BAR_HEIGHT, gap);
|
|
3131
|
-
}
|
|
3132
|
-
break;
|
|
3133
|
-
}
|
|
3134
3202
|
default: {
|
|
3135
3203
|
const startX = trailingEdge - group.total;
|
|
3136
3204
|
this.setFrame(group.toolbar, startX, rowY, { width: group.total, height: NAV_BAR_HEIGHT });
|
|
@@ -3140,6 +3208,32 @@ export class LayoutPass {
|
|
|
3140
3208
|
}
|
|
3141
3209
|
}
|
|
3142
3210
|
}
|
|
3211
|
+
// The inline secondary items, right-aligned before the trailing groups,
|
|
3212
|
+
// each toolbar framed around its own; the collapsed ones are hidden (the
|
|
3213
|
+
// renderer lists the items without a frame in the More menu).
|
|
3214
|
+
if (inlineSecondary.length > 0) {
|
|
3215
|
+
const total = secondaryWidth(inlineSecondary);
|
|
3216
|
+
let cursor = trailingEdge - total;
|
|
3217
|
+
const extents = new Map<LayoutNode, { start: number; end: number }>();
|
|
3218
|
+
for (const [i, entry] of inlineSecondary.entries()) {
|
|
3219
|
+
cursor += secondaryGap(inlineSecondary[i - 1], entry);
|
|
3220
|
+
const itemCtx = this.childContext(entry.toolbar, cctx);
|
|
3221
|
+
this.place(entry.item, itemCtx, { width: null, height: null }, cursor + (entry.slot - entry.size.width) / 2, rowY + (NAV_BAR_HEIGHT - entry.size.height) / 2);
|
|
3222
|
+
const extent = extents.get(entry.toolbar);
|
|
3223
|
+
extents.set(entry.toolbar, { start: extent?.start ?? cursor, end: cursor + entry.slot });
|
|
3224
|
+
cursor += entry.slot;
|
|
3225
|
+
}
|
|
3226
|
+
for (const [toolbar, extent] of extents) this.setFrame(toolbar, extent.start, rowY, { width: extent.end - extent.start, height: NAV_BAR_HEIGHT });
|
|
3227
|
+
trailingEdge -= total + NAV_GROUP_SPACING;
|
|
3228
|
+
}
|
|
3229
|
+
for (const toolbar of secondaryToolbars) {
|
|
3230
|
+
const items = toolbar.children;
|
|
3231
|
+
if (!inlineSecondary.some((entry) => entry.toolbar === toolbar)) this.hide(toolbar, node);
|
|
3232
|
+
else for (const item of items) if (collapsedSecondary.some((entry) => entry.item === item)) this.hide(item, node);
|
|
3233
|
+
}
|
|
3234
|
+
if (bottomBarHeight > 0) {
|
|
3235
|
+
this.placeBottomToolbars(bottomToolbars, cctx, chrome, x, y + size.height - bottomBarHeight, size.width);
|
|
3236
|
+
}
|
|
3143
3237
|
if (parts.search) {
|
|
3144
3238
|
// The field is the whole capsule (its own measure is a 36pt field).
|
|
3145
3239
|
const field = chrome['searchField'] as Rect;
|
|
@@ -3166,7 +3260,8 @@ export class LayoutPass {
|
|
|
3166
3260
|
leadingWidth > 0 ? leadingCursor - NAV_GROUP_SPACING - x : 0,
|
|
3167
3261
|
cctx.barLeading ? leadingStart - NAV_GROUP_SPACING - x : 0,
|
|
3168
3262
|
);
|
|
3169
|
-
const
|
|
3263
|
+
const trailingUsed = trailingWidth > 0 || chrome['more'] !== undefined || inlineSecondary.length > 0;
|
|
3264
|
+
const rightUsed = trailingUsed ? x + size.width - (trailingEdge + NAV_GROUP_SPACING) : 0;
|
|
3170
3265
|
const side = Math.max(leftUsed, rightUsed) + 8;
|
|
3171
3266
|
const available = Math.max(0, size.width - 2 * side);
|
|
3172
3267
|
const titleWidth = Math.min(Math.ceil(this.measurer.measureWidth(title, titleFont) - 0.001), Math.floor(size.width * 0.56), Math.floor(available));
|
|
@@ -3244,9 +3339,101 @@ export class LayoutPass {
|
|
|
3244
3339
|
this.place(child, contentCtx, proposal, content.x + dx, content.y + dy);
|
|
3245
3340
|
}
|
|
3246
3341
|
|
|
3342
|
+
/**
|
|
3343
|
+
* iOS 26's bottom toolbar: the items of the `bottom` toolbars, in order,
|
|
3344
|
+
* float on Liquid Glass in a 48 row at the top of the bottom bar's band
|
|
3345
|
+
* (`top`), 28 in from the sides. Consecutive items share a capsule; a
|
|
3346
|
+
* `ToolbarSpacer` (an empty toolbar with `spacer`) ends one. Flexible
|
|
3347
|
+
* spacers share the room the groups leave (with none the row is centered)
|
|
3348
|
+
* and fixed ones are 8 wide. An icon takes a 50 slot (a lone one is a 48
|
|
3349
|
+
* circle), text its width and 18 either side, 6 between slots; each item
|
|
3350
|
+
* is centered in its slot. The capsules are the screen's chrome
|
|
3351
|
+
* `bottomGlass0` … (drawn under the items); each toolbar's frame spans its
|
|
3352
|
+
* items.
|
|
3353
|
+
*/
|
|
3354
|
+
private placeBottomToolbars(
|
|
3355
|
+
toolbars: LayoutNode[],
|
|
3356
|
+
cctx: Context,
|
|
3357
|
+
chrome: Record<string, Rect>,
|
|
3358
|
+
x: number,
|
|
3359
|
+
top: number,
|
|
3360
|
+
width: number,
|
|
3361
|
+
): void {
|
|
3362
|
+
interface Item {
|
|
3363
|
+
node: LayoutNode;
|
|
3364
|
+
toolbar: LayoutNode;
|
|
3365
|
+
size: Size;
|
|
3366
|
+
slot: number;
|
|
3367
|
+
}
|
|
3368
|
+
type Token = { kind: 'group'; items: Item[]; width: number } | { kind: 'flexible' } | { kind: 'fixed' };
|
|
3369
|
+
const tokens: Token[] = [];
|
|
3370
|
+
let open: Item[] = [];
|
|
3371
|
+
const close = (): void => {
|
|
3372
|
+
if (open.length === 0) return;
|
|
3373
|
+
const icon = (item: Item): boolean => item.size.width < NAV_GLASS_SIZE;
|
|
3374
|
+
const lone = open.length === 1 && icon(open[0] as Item);
|
|
3375
|
+
if (lone) (open[0] as Item).slot = BOTTOM_GLASS_HEIGHT;
|
|
3376
|
+
const slots = open.reduce((sum, item) => sum + item.slot, 0);
|
|
3377
|
+
tokens.push({ kind: 'group', items: open, width: slots + (open.length - 1) * BOTTOM_ITEM_GAP });
|
|
3378
|
+
open = [];
|
|
3379
|
+
};
|
|
3380
|
+
for (const toolbar of toolbars) {
|
|
3381
|
+
const spacer = str(toolbar.props['spacer']);
|
|
3382
|
+
if (spacer !== null) {
|
|
3383
|
+
close();
|
|
3384
|
+
tokens.push({ kind: spacer === 'fixed' ? 'fixed' : 'flexible' });
|
|
3385
|
+
continue;
|
|
3386
|
+
}
|
|
3387
|
+
const itemCtx = this.childContext(toolbar, cctx);
|
|
3388
|
+
for (const node of toolbar.children) {
|
|
3389
|
+
const size = this.measure(node, itemCtx, { width: null, height: null });
|
|
3390
|
+
const slot = size.width < NAV_GLASS_SIZE ? BOTTOM_ICON_SLOT : size.width + 2 * BOTTOM_TEXT_PAD_H;
|
|
3391
|
+
open.push({ node, toolbar, size, slot });
|
|
3392
|
+
}
|
|
3393
|
+
}
|
|
3394
|
+
close();
|
|
3395
|
+
const inner = Math.max(0, width - 2 * BOTTOM_GLASS_MARGIN_H);
|
|
3396
|
+
let used = 0;
|
|
3397
|
+
let flexible = 0;
|
|
3398
|
+
for (const token of tokens) {
|
|
3399
|
+
if (token.kind === 'group') used += token.width;
|
|
3400
|
+
else if (token.kind === 'fixed') used += BOTTOM_FIXED_SPACER;
|
|
3401
|
+
else flexible += 1;
|
|
3402
|
+
}
|
|
3403
|
+
const share = flexible > 0 ? Math.max(0, inner - used) / flexible : 0;
|
|
3404
|
+
let cursor = flexible > 0 ? x + BOTTOM_GLASS_MARGIN_H : x + (width - used) / 2;
|
|
3405
|
+
const spans = new Map<LayoutNode, { start: number; end: number }>();
|
|
3406
|
+
let glass = 0;
|
|
3407
|
+
for (const token of tokens) {
|
|
3408
|
+
if (token.kind === 'flexible') {
|
|
3409
|
+
cursor += share;
|
|
3410
|
+
continue;
|
|
3411
|
+
}
|
|
3412
|
+
if (token.kind === 'fixed') {
|
|
3413
|
+
cursor += BOTTOM_FIXED_SPACER;
|
|
3414
|
+
continue;
|
|
3415
|
+
}
|
|
3416
|
+
chrome[`bottomGlass${glass}`] = { x: cursor, y: top, width: token.width, height: BOTTOM_GLASS_HEIGHT };
|
|
3417
|
+
glass += 1;
|
|
3418
|
+
for (const item of token.items) {
|
|
3419
|
+
const itemCtx = this.childContext(item.toolbar, cctx);
|
|
3420
|
+
this.place(item.node, itemCtx, { width: null, height: null }, cursor + (item.slot - item.size.width) / 2, top + (BOTTOM_GLASS_HEIGHT - item.size.height) / 2);
|
|
3421
|
+
const span = spans.get(item.toolbar);
|
|
3422
|
+
spans.set(item.toolbar, { start: span ? span.start : cursor, end: cursor + item.slot });
|
|
3423
|
+
cursor += item.slot + BOTTOM_ITEM_GAP;
|
|
3424
|
+
}
|
|
3425
|
+
cursor -= BOTTOM_ITEM_GAP;
|
|
3426
|
+
}
|
|
3427
|
+
for (const toolbar of toolbars) {
|
|
3428
|
+
const span = spans.get(toolbar);
|
|
3429
|
+
if (span) this.setFrame(toolbar, span.start, top, { width: span.end - span.start, height: BOTTOM_GLASS_HEIGHT });
|
|
3430
|
+
}
|
|
3431
|
+
}
|
|
3432
|
+
|
|
3247
3433
|
/**
|
|
3248
3434
|
* The tab bar (iOS 26) is a glass capsule floating over the bottom of the
|
|
3249
|
-
* content (TAB_BAR_* above),
|
|
3435
|
+
* content (TAB_BAR_* above), as wide as its equal-width items (one per tab)
|
|
3436
|
+
* up to the width less the margins, centered; what it
|
|
3250
3437
|
* covers (the capsule and the gap under it) is the content's bottom inset.
|
|
3251
3438
|
* Every `tab` gets the tab view's frame; only the selected one lays its
|
|
3252
3439
|
* content out, the others are reported in `hidden`.
|
|
@@ -3256,18 +3443,19 @@ export class LayoutPass {
|
|
|
3256
3443
|
const marginBottom = y + size.height >= screen.height - EPSILON ? TAB_BAR_MARGIN_BOTTOM : TAB_BAR_MARGIN_BOTTOM_INSET;
|
|
3257
3444
|
const barHeight = Math.min(size.height, TAB_BAR_HEIGHT + marginBottom);
|
|
3258
3445
|
const capsuleHeight = Math.min(TAB_BAR_HEIGHT, barHeight);
|
|
3446
|
+
const tabs = node.children;
|
|
3447
|
+
const n = tabs.length;
|
|
3448
|
+
const width = Math.min(Math.max(0, size.width - 2 * TAB_BAR_MARGIN_H), n * TAB_BAR_ITEM_WIDTH + 2 * TAB_BAR_ITEM_INSET);
|
|
3259
3449
|
const bar: Rect = {
|
|
3260
|
-
x: x +
|
|
3450
|
+
x: x + (size.width - width) / 2,
|
|
3261
3451
|
y: y + size.height - barHeight,
|
|
3262
|
-
width
|
|
3452
|
+
width,
|
|
3263
3453
|
height: capsuleHeight,
|
|
3264
3454
|
};
|
|
3265
3455
|
const chrome: Record<string, Rect> = {
|
|
3266
3456
|
bar,
|
|
3267
3457
|
content: { x, y, width: size.width, height: size.height - barHeight },
|
|
3268
3458
|
};
|
|
3269
|
-
const tabs = node.children;
|
|
3270
|
-
const n = tabs.length;
|
|
3271
3459
|
const inner = Math.max(0, bar.width - 2 * TAB_BAR_ITEM_INSET);
|
|
3272
3460
|
for (let i = 0; i < n; i++) {
|
|
3273
3461
|
const itemWidth = inner / n;
|
|
@@ -4150,22 +4338,33 @@ export class LayoutPass {
|
|
|
4150
4338
|
* clamped. With no width the flexible columns take the cells' largest ideal
|
|
4151
4339
|
* width and an adaptive item is one column. Cells fill rows left to right.
|
|
4152
4340
|
*/
|
|
4341
|
+
/**
|
|
4342
|
+
* `lazyvgrid` and `lazyhgrid`. Written for the vertical grid: its columns
|
|
4343
|
+
* share the proposed width and its cells fill them row by row. A horizontal
|
|
4344
|
+
* grid is the same along the other axis (its rows share the proposed
|
|
4345
|
+
* height and the cells fill them column by column), so the plan's
|
|
4346
|
+
* `columns` are the tracks across the grid (`x`, `width`: cross axis) and
|
|
4347
|
+
* its `rows` the lines along it (`y`, `height`: main axis).
|
|
4348
|
+
*/
|
|
4153
4349
|
private lazyGridPlan(node: LayoutNode, ctx: Context, p: Proposal): LazyGridPlan {
|
|
4154
4350
|
return this.plan(node, p, () => {
|
|
4155
|
-
const
|
|
4156
|
-
const
|
|
4351
|
+
const horizontal = node.kind === 'lazyhgrid';
|
|
4352
|
+
const crossOf = (size: { width: number | null; height: number | null }): number | null => (horizontal ? size.height : size.width);
|
|
4353
|
+
const items = readGridItems(node.props[horizontal ? 'rows' : 'columns']);
|
|
4354
|
+
const lineSpacing = num(node.props['spacing']) ?? DEFAULT_SPACING;
|
|
4157
4355
|
const cctx = this.childContext(node, ctx);
|
|
4158
4356
|
const cells = node.children;
|
|
4357
|
+
const cross = crossOf(p);
|
|
4159
4358
|
let idealCell: number | null = null;
|
|
4160
4359
|
const ideal = (): number => {
|
|
4161
4360
|
if (idealCell === null) {
|
|
4162
4361
|
idealCell = 0;
|
|
4163
|
-
for (const cell of cells) idealCell = Math.max(idealCell, this.measure(cell, cctx, NIL_PROPOSAL)
|
|
4362
|
+
for (const cell of cells) idealCell = Math.max(idealCell, crossOf(this.measure(cell, cctx, NIL_PROPOSAL)) ?? 0);
|
|
4164
4363
|
}
|
|
4165
4364
|
return idealCell;
|
|
4166
4365
|
};
|
|
4167
4366
|
const widths = items.map((item): number[] => (item.kind === 'fixed' ? [item.value] : []));
|
|
4168
|
-
if (!finite(
|
|
4367
|
+
if (!finite(cross)) {
|
|
4169
4368
|
items.forEach((item, i) => {
|
|
4170
4369
|
if (item.kind !== 'fixed') widths[i] = [clamp(ideal(), item.minimum, item.maximum)];
|
|
4171
4370
|
});
|
|
@@ -4175,7 +4374,7 @@ export class LayoutPass {
|
|
|
4175
4374
|
let fixed = 0;
|
|
4176
4375
|
for (const item of items) if (item.kind === 'fixed') fixed += item.value;
|
|
4177
4376
|
const sharing = items.filter((item) => item.kind !== 'fixed').length;
|
|
4178
|
-
const remaining = Math.max(0,
|
|
4377
|
+
const remaining = Math.max(0, cross - fixed - gaps);
|
|
4179
4378
|
const share = sharing > 0 ? remaining / sharing : 0;
|
|
4180
4379
|
let flexibleTotal = 0;
|
|
4181
4380
|
let adaptiveCount = 0;
|
|
@@ -4209,9 +4408,9 @@ export class LayoutPass {
|
|
|
4209
4408
|
});
|
|
4210
4409
|
});
|
|
4211
4410
|
const used = cursor;
|
|
4212
|
-
const
|
|
4411
|
+
const gridCross = finite(cross) ? cross : used;
|
|
4213
4412
|
const alignment = str(node.props['alignment']) ?? 'center';
|
|
4214
|
-
const offset = safeOffset(
|
|
4413
|
+
const offset = safeOffset(gridCross, used, alignmentAnchors(alignment)[horizontal ? 1 : 0]);
|
|
4215
4414
|
for (const column of columns) column.x += offset;
|
|
4216
4415
|
|
|
4217
4416
|
const rows: LazyGridPlan['rows'] = [];
|
|
@@ -4223,28 +4422,31 @@ export class LayoutPass {
|
|
|
4223
4422
|
for (let k = 0; k < perRow && start + k < cells.length; k++) {
|
|
4224
4423
|
const cell = cells[start + k] as LayoutNode;
|
|
4225
4424
|
const column = columns[k] as LazyColumnPlan;
|
|
4226
|
-
const size = this.measure(cell, cctx, { width: column.width, height: null });
|
|
4227
|
-
height = Math.max(height, size.height);
|
|
4425
|
+
const size = this.measure(cell, cctx, horizontal ? { width: null, height: column.width } : { width: column.width, height: null });
|
|
4426
|
+
height = Math.max(height, horizontal ? size.width : size.height);
|
|
4228
4427
|
rowCells.push({ node: cell, column: k, size });
|
|
4229
4428
|
}
|
|
4230
4429
|
rows.push({ y, height, cells: rowCells });
|
|
4231
|
-
y += height +
|
|
4430
|
+
y += height + lineSpacing;
|
|
4232
4431
|
}
|
|
4233
|
-
const
|
|
4234
|
-
|
|
4432
|
+
const gridMain = rows.length > 0 ? y - lineSpacing : 0;
|
|
4433
|
+
const size = horizontal ? { width: gridMain, height: gridCross } : { width: gridCross, height: gridMain };
|
|
4434
|
+
return { size, columns, rows };
|
|
4235
4435
|
});
|
|
4236
4436
|
}
|
|
4237
4437
|
|
|
4238
4438
|
private placeLazyGrid(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number): void {
|
|
4239
4439
|
const plan = this.lazyGridPlan(node, ctx, p);
|
|
4440
|
+
const horizontal = node.kind === 'lazyhgrid';
|
|
4240
4441
|
const cctx = this.childContext(node, ctx);
|
|
4241
4442
|
const gridAlignment = str(node.props['alignment']) ?? 'center';
|
|
4242
4443
|
for (const row of plan.rows) {
|
|
4243
4444
|
for (const cell of row.cells) {
|
|
4244
4445
|
const column = plan.columns[cell.column] as LazyColumnPlan;
|
|
4245
|
-
const box: Size = { width: column.width, height: row.height };
|
|
4446
|
+
const box: Size = horizontal ? { width: row.height, height: column.width } : { width: column.width, height: row.height };
|
|
4246
4447
|
const { dx, dy } = alignIn(box, cell.size, column.alignment ?? gridAlignment);
|
|
4247
|
-
this.place(cell.node, cctx, { width:
|
|
4448
|
+
if (horizontal) this.place(cell.node, cctx, { width: null, height: column.width }, x + row.y + dx, y + column.x + dy);
|
|
4449
|
+
else this.place(cell.node, cctx, { width: column.width, height: null }, x + column.x + dx, y + row.y + dy);
|
|
4248
4450
|
}
|
|
4249
4451
|
}
|
|
4250
4452
|
}
|
package/src/layout/index.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*
|
|
10
10
|
* | kind | keys |
|
|
11
11
|
* |---------------------|------|
|
|
12
|
-
* | `navscreen` | `bar` (status bar + 44), `largeTitle` (only when the title is large), `back` (only when depth > 0), `title` (Phase 6: the centered inline title's rect, truncated to the room the bar items leave; absent for a large title or a `principal` toolbar), `searchField` (Phase 6: the 36px field of a `searchfield` child), `bottomBar` (
|
|
12
|
+
* | `navscreen` | `bar` (status bar + 44), `largeTitle` (only when the title is large), `back` (only when depth > 0), `title` (Phase 6: the centered inline title's rect, truncated to the room the bar items leave; absent for a large title or a `principal` toolbar), `searchField` (Phase 6: the 36px field of a `searchfield` child), `bottomBar` (iOS 26: the band from a `bottom` toolbar's 48 glass row to the screen bottom, when one is present), `bottomGlass0` … (its glass capsules, in order), `content` |
|
|
13
13
|
* | `sheet` | `grabber` (36×5), `content` (below the 20px grabber row) |
|
|
14
14
|
* | `toggle` | `label`, `switch` (51×31 at the trailing edge) |
|
|
15
15
|
* | `button` (bordered) | `label` |
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* | list rows | `row` (the full-width row rect inside the card); `navlink` rows add `label` and `chevron` (20px at the trailing edge); rows with a Phase 6 `badge` style add `badge` (the text's rect at the trailing inset, full row height) |
|
|
18
18
|
* | `scrollview` | `content` (the content rect at scroll offset 0) |
|
|
19
19
|
* | `list`/`scrollview` | `bottomInset` (Phase 4, only under a tab bar that reaches the screen bottom, or a bottom toolbar or search field reaching its screen's bottom: the part of the frame the bar covers; the DOM adds its height as scrollable content inset), `topInset` / `largeTitle` (the screen's bar and the large title row it runs under: its screen's content, or a vertical one in it touching the bar) |
|
|
20
|
-
* | `tabview` | `bar` (
|
|
20
|
+
* | `tabview` | `bar` (iOS 26: the 62 glass capsule, as wide as its tabs up to the width less 21 either side, centered), `item0` … `itemN-1` (equal-width items inside its 4 inset), `content` (above the bar and the gap under it) |
|
|
21
21
|
* | `tab` | `content` (the selected tab's safe content area: below the status bar, above the bar); non-selected tabs are in `hidden` |
|
|
22
22
|
* | `picker` | segmented: `segment0` … `segmentN-1` (equal widths, 32 high); menu: `label` (leading), `value` (the selected option plus chevron, trailing) |
|
|
23
23
|
* | `gauge` | `track` (the 4px bar), `fill` (the leading `value` share of the track) (Phase 6) |
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
* `LayoutResult.layouts` (Phase 6) holds, per `layout` element, the proposal
|
|
26
26
|
* it was placed with and its children's min / ideal / max sizes.
|
|
27
27
|
* | `picker` | segmented: `segment0` … `segmentN-1` (equal widths, 32 high); menu: `label` (leading), `value` (the selected option plus chevron, trailing); inline (Phase 6): `label` (a 44 row, only for a non-empty label), `option0` … `optionN-1` (44 rows spanning the list row when there is one) |
|
|
28
|
-
* | `toolbar` | none; the element's frame is its item group in the bar (
|
|
28
|
+
* | `toolbar` | none; the element's frame is its item group in the bar (in a bottom bar: the span of its items in the glass row; a `spacer` toolbar is hidden) and the items have ordinary frames |
|
|
29
29
|
* | `navsplit` | `sidebar` (the sidebar column, including the leading safe-area inset), `separator` (the 1px hairline), `detail` (the detail column, including the trailing inset) (Phase 7) |
|
|
30
30
|
* | `listrow` | Phase 8: `editControl` (the 44px leading control, full row height) while the enclosing `list` has `editing`; the row content is shifted right by 44. `swipeactions` / `contextmenu` have no keys: their children after the first are in `hidden` without frames |
|
|
31
31
|
* | `alert` | Phase 8: `card` (270 wide, centered; a dialog's button card), `title` / `message` (when non-empty; `chromeText` carries the centered lines), `button<i>` (the i-th child's row), `ok` (the renderer-drawn OK, only without button children), `cancelCard` (dialog: the `cancel` button's own card) |
|
package/src/layoutDom.ts
CHANGED
|
@@ -258,6 +258,26 @@ function placeEditControl(node: HTMLElement | null, rect: Rect | undefined, orig
|
|
|
258
258
|
}
|
|
259
259
|
}
|
|
260
260
|
|
|
261
|
+
/**
|
|
262
|
+
* iOS 26: the glass a screen's bottom toolbar items float on, one element
|
|
263
|
+
* per capsule (engine `bottomGlass0` …), under the items (styles.css).
|
|
264
|
+
*/
|
|
265
|
+
function syncBottomGlass(screen: HTMLElement, chrome: Record<string, Rect> | undefined, frame: Rect): void {
|
|
266
|
+
const existing = Array.from(screen.querySelectorAll<HTMLElement>(':scope > .sb-bottom-glass'));
|
|
267
|
+
let count = 0;
|
|
268
|
+
for (let rect = chrome?.['bottomGlass0']; rect; rect = chrome?.[`bottomGlass${count}`]) {
|
|
269
|
+
let glass = existing[count];
|
|
270
|
+
if (!glass) {
|
|
271
|
+
glass = document.createElement('div');
|
|
272
|
+
glass.className = 'sb-bottom-glass';
|
|
273
|
+
screen.append(glass);
|
|
274
|
+
}
|
|
275
|
+
placeChrome(glass, rect, frame);
|
|
276
|
+
count += 1;
|
|
277
|
+
}
|
|
278
|
+
for (const extra of existing.slice(count)) extra.remove();
|
|
279
|
+
}
|
|
280
|
+
|
|
261
281
|
function placeChrome(node: HTMLElement | null, rect: Rect | undefined, origin: Rect): void {
|
|
262
282
|
if (!node) return;
|
|
263
283
|
if (!rect) {
|
|
@@ -543,6 +563,7 @@ function applyChrome(
|
|
|
543
563
|
bar.style.paddingTop = `${top}px`;
|
|
544
564
|
bar.style.paddingBottom = `${bottom}px`;
|
|
545
565
|
}
|
|
566
|
+
syncBottomGlass(el, chrome, frame);
|
|
546
567
|
const largeRect = chrome?.['largeTitle'];
|
|
547
568
|
if (largeTitle) {
|
|
548
569
|
if (largeRect) {
|
|
@@ -571,6 +592,8 @@ function applyChrome(
|
|
|
571
592
|
if (inlineTitle.style.lineHeight !== lineHeight) inlineTitle.style.lineHeight = lineHeight;
|
|
572
593
|
}
|
|
573
594
|
}
|
|
595
|
+
// The More button of the collapsed `secondary` items, at the trailing end.
|
|
596
|
+
placeChrome(el.querySelector<HTMLElement>(':scope > .sb-navbar > .sb-navbar-more'), chrome?.['more'], frame);
|
|
574
597
|
const back = el.querySelector<HTMLElement>(':scope > .sb-navbar > .sb-navbar-back');
|
|
575
598
|
const backLabel = back?.querySelector<HTMLElement>('.sb-navbar-back-label');
|
|
576
599
|
const backRect = chrome?.['back'];
|