@swiftbrowser/web 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +167 -47
- package/src/layout/index.ts +3 -3
- package/src/layoutDom.ts +21 -0
- package/src/page.css +234 -0
- package/src/protocol.ts +31 -0
- package/src/renderer.ts +42 -3
- package/src/scrollEdge.ts +26 -4
- package/src/styles.css +61 -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.0",
|
|
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
|
|
@@ -568,7 +582,7 @@ interface GridItemSpec {
|
|
|
568
582
|
alignment: string | null;
|
|
569
583
|
}
|
|
570
584
|
|
|
571
|
-
/** `columns` of a `lazyvgrid` (untrusted JSON); an empty list is one flexible
|
|
585
|
+
/** `columns` of a `lazyvgrid` or `rows` of a `lazyhgrid` (untrusted JSON); an empty list is one flexible track. */
|
|
572
586
|
function readGridItems(value: unknown): GridItemSpec[] {
|
|
573
587
|
const items: GridItemSpec[] = [];
|
|
574
588
|
if (Array.isArray(value)) {
|
|
@@ -1526,6 +1540,7 @@ export class LayoutPass {
|
|
|
1526
1540
|
case 'grid':
|
|
1527
1541
|
return this.gridPlan(node, ctx, p).size;
|
|
1528
1542
|
case 'lazyvgrid':
|
|
1543
|
+
case 'lazyhgrid':
|
|
1529
1544
|
return this.lazyGridPlan(node, ctx, p).size;
|
|
1530
1545
|
case 'gauge':
|
|
1531
1546
|
return this.gaugePlan(node, ctx, p).size;
|
|
@@ -2778,6 +2793,7 @@ export class LayoutPass {
|
|
|
2778
2793
|
this.placeGrid(node, ctx, p, x, y);
|
|
2779
2794
|
return;
|
|
2780
2795
|
case 'lazyvgrid':
|
|
2796
|
+
case 'lazyhgrid':
|
|
2781
2797
|
this.placeLazyGrid(node, ctx, p, x, y);
|
|
2782
2798
|
return;
|
|
2783
2799
|
case 'gauge':
|
|
@@ -3000,13 +3016,14 @@ export class LayoutPass {
|
|
|
3000
3016
|
/**
|
|
3001
3017
|
* The navigation bar is the status bar plus a 44 row (plus 62 for a large
|
|
3002
3018
|
* 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
|
-
*
|
|
3019
|
+
* (iOS 26); `bottom` toolbars float their items on glass in a 48 row 28
|
|
3020
|
+
* above the bottom (`placeBottomToolbars`); the content is proposed the
|
|
3021
|
+
* rest. Toolbar items are measured at their ideal size and laid out in iOS
|
|
3022
|
+
* 26 glass capsules (44-tall slots, groups 8 apart) in the 44 row
|
|
3023
|
+
* (`leading` after the back button, `trailing` 16 from the trailing edge,
|
|
3024
|
+
* `principal` centered in place of the title). A `ToolbarSpacer` (an empty
|
|
3025
|
+
* toolbar with `spacer`) only separates groups there. The inline title
|
|
3026
|
+
* (`chrome.title`) is centered and truncated to the room the bar items leave.
|
|
3010
3027
|
*/
|
|
3011
3028
|
private placeNavScreen(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
|
|
3012
3029
|
const { safeArea, screen, dynamicTypeSize } = this.env;
|
|
@@ -3018,10 +3035,12 @@ export class LayoutPass {
|
|
|
3018
3035
|
const largeHeight = large ? LARGE_TITLE_HEIGHT : 0;
|
|
3019
3036
|
const parts = this.navScreenParts(node);
|
|
3020
3037
|
const reachesBottom = y + size.height >= screen.height - EPSILON;
|
|
3038
|
+
const isSpacer = (t: LayoutNode): boolean => str(t.props['spacer']) !== null;
|
|
3021
3039
|
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
3040
|
const searchMargin = reachesBottom ? SEARCH_BOTTOM_MARGIN : SEARCH_BOTTOM_MARGIN_INSET;
|
|
3041
|
+
const bottomBarHeight = bottomToolbars.some((t) => !isSpacer(t))
|
|
3042
|
+
? Math.min(size.height, BOTTOM_GLASS_HEIGHT + searchMargin)
|
|
3043
|
+
: 0;
|
|
3025
3044
|
const searchBottom = parts.search ? SEARCH_BOTTOM_HEIGHT + searchMargin : 0;
|
|
3026
3045
|
const contentTop = barHeight + largeHeight;
|
|
3027
3046
|
const content: Rect = {
|
|
@@ -3063,7 +3082,11 @@ export class LayoutPass {
|
|
|
3063
3082
|
slots: number[] | null;
|
|
3064
3083
|
total: number;
|
|
3065
3084
|
}
|
|
3066
|
-
|
|
3085
|
+
// The navigation bar's groups; spacers there only separate them (the
|
|
3086
|
+
// groups are 8 apart anyway) and draw nothing.
|
|
3087
|
+
for (const spacer of parts.toolbars.filter(isSpacer)) this.hide(spacer, node);
|
|
3088
|
+
const barToolbars = parts.toolbars.filter((t) => str(t.props['placement']) !== 'bottom' && !isSpacer(t));
|
|
3089
|
+
const groups: Group[] = barToolbars.map((toolbar) => {
|
|
3067
3090
|
const itemCtx = this.childContext(toolbar, cctx);
|
|
3068
3091
|
const sizes = toolbar.children.map((item) => this.measure(item, itemCtx, { width: null, height: null }));
|
|
3069
3092
|
const placement = str(toolbar.props['placement']) ?? 'trailing';
|
|
@@ -3118,19 +3141,6 @@ export class LayoutPass {
|
|
|
3118
3141
|
placeGroup(group, startX, rowY, NAV_BAR_HEIGHT, NAV_ITEM_SPACING);
|
|
3119
3142
|
break;
|
|
3120
3143
|
}
|
|
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
3144
|
default: {
|
|
3135
3145
|
const startX = trailingEdge - group.total;
|
|
3136
3146
|
this.setFrame(group.toolbar, startX, rowY, { width: group.total, height: NAV_BAR_HEIGHT });
|
|
@@ -3140,6 +3150,9 @@ export class LayoutPass {
|
|
|
3140
3150
|
}
|
|
3141
3151
|
}
|
|
3142
3152
|
}
|
|
3153
|
+
if (bottomBarHeight > 0) {
|
|
3154
|
+
this.placeBottomToolbars(bottomToolbars, cctx, chrome, x, y + size.height - bottomBarHeight, size.width);
|
|
3155
|
+
}
|
|
3143
3156
|
if (parts.search) {
|
|
3144
3157
|
// The field is the whole capsule (its own measure is a 36pt field).
|
|
3145
3158
|
const field = chrome['searchField'] as Rect;
|
|
@@ -3244,9 +3257,101 @@ export class LayoutPass {
|
|
|
3244
3257
|
this.place(child, contentCtx, proposal, content.x + dx, content.y + dy);
|
|
3245
3258
|
}
|
|
3246
3259
|
|
|
3260
|
+
/**
|
|
3261
|
+
* iOS 26's bottom toolbar: the items of the `bottom` toolbars, in order,
|
|
3262
|
+
* float on Liquid Glass in a 48 row at the top of the bottom bar's band
|
|
3263
|
+
* (`top`), 28 in from the sides. Consecutive items share a capsule; a
|
|
3264
|
+
* `ToolbarSpacer` (an empty toolbar with `spacer`) ends one. Flexible
|
|
3265
|
+
* spacers share the room the groups leave (with none the row is centered)
|
|
3266
|
+
* and fixed ones are 8 wide. An icon takes a 50 slot (a lone one is a 48
|
|
3267
|
+
* circle), text its width and 18 either side, 6 between slots; each item
|
|
3268
|
+
* is centered in its slot. The capsules are the screen's chrome
|
|
3269
|
+
* `bottomGlass0` … (drawn under the items); each toolbar's frame spans its
|
|
3270
|
+
* items.
|
|
3271
|
+
*/
|
|
3272
|
+
private placeBottomToolbars(
|
|
3273
|
+
toolbars: LayoutNode[],
|
|
3274
|
+
cctx: Context,
|
|
3275
|
+
chrome: Record<string, Rect>,
|
|
3276
|
+
x: number,
|
|
3277
|
+
top: number,
|
|
3278
|
+
width: number,
|
|
3279
|
+
): void {
|
|
3280
|
+
interface Item {
|
|
3281
|
+
node: LayoutNode;
|
|
3282
|
+
toolbar: LayoutNode;
|
|
3283
|
+
size: Size;
|
|
3284
|
+
slot: number;
|
|
3285
|
+
}
|
|
3286
|
+
type Token = { kind: 'group'; items: Item[]; width: number } | { kind: 'flexible' } | { kind: 'fixed' };
|
|
3287
|
+
const tokens: Token[] = [];
|
|
3288
|
+
let open: Item[] = [];
|
|
3289
|
+
const close = (): void => {
|
|
3290
|
+
if (open.length === 0) return;
|
|
3291
|
+
const icon = (item: Item): boolean => item.size.width < NAV_GLASS_SIZE;
|
|
3292
|
+
const lone = open.length === 1 && icon(open[0] as Item);
|
|
3293
|
+
if (lone) (open[0] as Item).slot = BOTTOM_GLASS_HEIGHT;
|
|
3294
|
+
const slots = open.reduce((sum, item) => sum + item.slot, 0);
|
|
3295
|
+
tokens.push({ kind: 'group', items: open, width: slots + (open.length - 1) * BOTTOM_ITEM_GAP });
|
|
3296
|
+
open = [];
|
|
3297
|
+
};
|
|
3298
|
+
for (const toolbar of toolbars) {
|
|
3299
|
+
const spacer = str(toolbar.props['spacer']);
|
|
3300
|
+
if (spacer !== null) {
|
|
3301
|
+
close();
|
|
3302
|
+
tokens.push({ kind: spacer === 'fixed' ? 'fixed' : 'flexible' });
|
|
3303
|
+
continue;
|
|
3304
|
+
}
|
|
3305
|
+
const itemCtx = this.childContext(toolbar, cctx);
|
|
3306
|
+
for (const node of toolbar.children) {
|
|
3307
|
+
const size = this.measure(node, itemCtx, { width: null, height: null });
|
|
3308
|
+
const slot = size.width < NAV_GLASS_SIZE ? BOTTOM_ICON_SLOT : size.width + 2 * BOTTOM_TEXT_PAD_H;
|
|
3309
|
+
open.push({ node, toolbar, size, slot });
|
|
3310
|
+
}
|
|
3311
|
+
}
|
|
3312
|
+
close();
|
|
3313
|
+
const inner = Math.max(0, width - 2 * BOTTOM_GLASS_MARGIN_H);
|
|
3314
|
+
let used = 0;
|
|
3315
|
+
let flexible = 0;
|
|
3316
|
+
for (const token of tokens) {
|
|
3317
|
+
if (token.kind === 'group') used += token.width;
|
|
3318
|
+
else if (token.kind === 'fixed') used += BOTTOM_FIXED_SPACER;
|
|
3319
|
+
else flexible += 1;
|
|
3320
|
+
}
|
|
3321
|
+
const share = flexible > 0 ? Math.max(0, inner - used) / flexible : 0;
|
|
3322
|
+
let cursor = flexible > 0 ? x + BOTTOM_GLASS_MARGIN_H : x + (width - used) / 2;
|
|
3323
|
+
const spans = new Map<LayoutNode, { start: number; end: number }>();
|
|
3324
|
+
let glass = 0;
|
|
3325
|
+
for (const token of tokens) {
|
|
3326
|
+
if (token.kind === 'flexible') {
|
|
3327
|
+
cursor += share;
|
|
3328
|
+
continue;
|
|
3329
|
+
}
|
|
3330
|
+
if (token.kind === 'fixed') {
|
|
3331
|
+
cursor += BOTTOM_FIXED_SPACER;
|
|
3332
|
+
continue;
|
|
3333
|
+
}
|
|
3334
|
+
chrome[`bottomGlass${glass}`] = { x: cursor, y: top, width: token.width, height: BOTTOM_GLASS_HEIGHT };
|
|
3335
|
+
glass += 1;
|
|
3336
|
+
for (const item of token.items) {
|
|
3337
|
+
const itemCtx = this.childContext(item.toolbar, cctx);
|
|
3338
|
+
this.place(item.node, itemCtx, { width: null, height: null }, cursor + (item.slot - item.size.width) / 2, top + (BOTTOM_GLASS_HEIGHT - item.size.height) / 2);
|
|
3339
|
+
const span = spans.get(item.toolbar);
|
|
3340
|
+
spans.set(item.toolbar, { start: span ? span.start : cursor, end: cursor + item.slot });
|
|
3341
|
+
cursor += item.slot + BOTTOM_ITEM_GAP;
|
|
3342
|
+
}
|
|
3343
|
+
cursor -= BOTTOM_ITEM_GAP;
|
|
3344
|
+
}
|
|
3345
|
+
for (const toolbar of toolbars) {
|
|
3346
|
+
const span = spans.get(toolbar);
|
|
3347
|
+
if (span) this.setFrame(toolbar, span.start, top, { width: span.end - span.start, height: BOTTOM_GLASS_HEIGHT });
|
|
3348
|
+
}
|
|
3349
|
+
}
|
|
3350
|
+
|
|
3247
3351
|
/**
|
|
3248
3352
|
* The tab bar (iOS 26) is a glass capsule floating over the bottom of the
|
|
3249
|
-
* content (TAB_BAR_* above),
|
|
3353
|
+
* content (TAB_BAR_* above), as wide as its equal-width items (one per tab)
|
|
3354
|
+
* up to the width less the margins, centered; what it
|
|
3250
3355
|
* covers (the capsule and the gap under it) is the content's bottom inset.
|
|
3251
3356
|
* Every `tab` gets the tab view's frame; only the selected one lays its
|
|
3252
3357
|
* content out, the others are reported in `hidden`.
|
|
@@ -3256,18 +3361,19 @@ export class LayoutPass {
|
|
|
3256
3361
|
const marginBottom = y + size.height >= screen.height - EPSILON ? TAB_BAR_MARGIN_BOTTOM : TAB_BAR_MARGIN_BOTTOM_INSET;
|
|
3257
3362
|
const barHeight = Math.min(size.height, TAB_BAR_HEIGHT + marginBottom);
|
|
3258
3363
|
const capsuleHeight = Math.min(TAB_BAR_HEIGHT, barHeight);
|
|
3364
|
+
const tabs = node.children;
|
|
3365
|
+
const n = tabs.length;
|
|
3366
|
+
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
3367
|
const bar: Rect = {
|
|
3260
|
-
x: x +
|
|
3368
|
+
x: x + (size.width - width) / 2,
|
|
3261
3369
|
y: y + size.height - barHeight,
|
|
3262
|
-
width
|
|
3370
|
+
width,
|
|
3263
3371
|
height: capsuleHeight,
|
|
3264
3372
|
};
|
|
3265
3373
|
const chrome: Record<string, Rect> = {
|
|
3266
3374
|
bar,
|
|
3267
3375
|
content: { x, y, width: size.width, height: size.height - barHeight },
|
|
3268
3376
|
};
|
|
3269
|
-
const tabs = node.children;
|
|
3270
|
-
const n = tabs.length;
|
|
3271
3377
|
const inner = Math.max(0, bar.width - 2 * TAB_BAR_ITEM_INSET);
|
|
3272
3378
|
for (let i = 0; i < n; i++) {
|
|
3273
3379
|
const itemWidth = inner / n;
|
|
@@ -4150,22 +4256,33 @@ export class LayoutPass {
|
|
|
4150
4256
|
* clamped. With no width the flexible columns take the cells' largest ideal
|
|
4151
4257
|
* width and an adaptive item is one column. Cells fill rows left to right.
|
|
4152
4258
|
*/
|
|
4259
|
+
/**
|
|
4260
|
+
* `lazyvgrid` and `lazyhgrid`. Written for the vertical grid: its columns
|
|
4261
|
+
* share the proposed width and its cells fill them row by row. A horizontal
|
|
4262
|
+
* grid is the same along the other axis (its rows share the proposed
|
|
4263
|
+
* height and the cells fill them column by column), so the plan's
|
|
4264
|
+
* `columns` are the tracks across the grid (`x`, `width`: cross axis) and
|
|
4265
|
+
* its `rows` the lines along it (`y`, `height`: main axis).
|
|
4266
|
+
*/
|
|
4153
4267
|
private lazyGridPlan(node: LayoutNode, ctx: Context, p: Proposal): LazyGridPlan {
|
|
4154
4268
|
return this.plan(node, p, () => {
|
|
4155
|
-
const
|
|
4156
|
-
const
|
|
4269
|
+
const horizontal = node.kind === 'lazyhgrid';
|
|
4270
|
+
const crossOf = (size: { width: number | null; height: number | null }): number | null => (horizontal ? size.height : size.width);
|
|
4271
|
+
const items = readGridItems(node.props[horizontal ? 'rows' : 'columns']);
|
|
4272
|
+
const lineSpacing = num(node.props['spacing']) ?? DEFAULT_SPACING;
|
|
4157
4273
|
const cctx = this.childContext(node, ctx);
|
|
4158
4274
|
const cells = node.children;
|
|
4275
|
+
const cross = crossOf(p);
|
|
4159
4276
|
let idealCell: number | null = null;
|
|
4160
4277
|
const ideal = (): number => {
|
|
4161
4278
|
if (idealCell === null) {
|
|
4162
4279
|
idealCell = 0;
|
|
4163
|
-
for (const cell of cells) idealCell = Math.max(idealCell, this.measure(cell, cctx, NIL_PROPOSAL)
|
|
4280
|
+
for (const cell of cells) idealCell = Math.max(idealCell, crossOf(this.measure(cell, cctx, NIL_PROPOSAL)) ?? 0);
|
|
4164
4281
|
}
|
|
4165
4282
|
return idealCell;
|
|
4166
4283
|
};
|
|
4167
4284
|
const widths = items.map((item): number[] => (item.kind === 'fixed' ? [item.value] : []));
|
|
4168
|
-
if (!finite(
|
|
4285
|
+
if (!finite(cross)) {
|
|
4169
4286
|
items.forEach((item, i) => {
|
|
4170
4287
|
if (item.kind !== 'fixed') widths[i] = [clamp(ideal(), item.minimum, item.maximum)];
|
|
4171
4288
|
});
|
|
@@ -4175,7 +4292,7 @@ export class LayoutPass {
|
|
|
4175
4292
|
let fixed = 0;
|
|
4176
4293
|
for (const item of items) if (item.kind === 'fixed') fixed += item.value;
|
|
4177
4294
|
const sharing = items.filter((item) => item.kind !== 'fixed').length;
|
|
4178
|
-
const remaining = Math.max(0,
|
|
4295
|
+
const remaining = Math.max(0, cross - fixed - gaps);
|
|
4179
4296
|
const share = sharing > 0 ? remaining / sharing : 0;
|
|
4180
4297
|
let flexibleTotal = 0;
|
|
4181
4298
|
let adaptiveCount = 0;
|
|
@@ -4209,9 +4326,9 @@ export class LayoutPass {
|
|
|
4209
4326
|
});
|
|
4210
4327
|
});
|
|
4211
4328
|
const used = cursor;
|
|
4212
|
-
const
|
|
4329
|
+
const gridCross = finite(cross) ? cross : used;
|
|
4213
4330
|
const alignment = str(node.props['alignment']) ?? 'center';
|
|
4214
|
-
const offset = safeOffset(
|
|
4331
|
+
const offset = safeOffset(gridCross, used, alignmentAnchors(alignment)[horizontal ? 1 : 0]);
|
|
4215
4332
|
for (const column of columns) column.x += offset;
|
|
4216
4333
|
|
|
4217
4334
|
const rows: LazyGridPlan['rows'] = [];
|
|
@@ -4223,28 +4340,31 @@ export class LayoutPass {
|
|
|
4223
4340
|
for (let k = 0; k < perRow && start + k < cells.length; k++) {
|
|
4224
4341
|
const cell = cells[start + k] as LayoutNode;
|
|
4225
4342
|
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);
|
|
4343
|
+
const size = this.measure(cell, cctx, horizontal ? { width: null, height: column.width } : { width: column.width, height: null });
|
|
4344
|
+
height = Math.max(height, horizontal ? size.width : size.height);
|
|
4228
4345
|
rowCells.push({ node: cell, column: k, size });
|
|
4229
4346
|
}
|
|
4230
4347
|
rows.push({ y, height, cells: rowCells });
|
|
4231
|
-
y += height +
|
|
4348
|
+
y += height + lineSpacing;
|
|
4232
4349
|
}
|
|
4233
|
-
const
|
|
4234
|
-
|
|
4350
|
+
const gridMain = rows.length > 0 ? y - lineSpacing : 0;
|
|
4351
|
+
const size = horizontal ? { width: gridMain, height: gridCross } : { width: gridCross, height: gridMain };
|
|
4352
|
+
return { size, columns, rows };
|
|
4235
4353
|
});
|
|
4236
4354
|
}
|
|
4237
4355
|
|
|
4238
4356
|
private placeLazyGrid(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number): void {
|
|
4239
4357
|
const plan = this.lazyGridPlan(node, ctx, p);
|
|
4358
|
+
const horizontal = node.kind === 'lazyhgrid';
|
|
4240
4359
|
const cctx = this.childContext(node, ctx);
|
|
4241
4360
|
const gridAlignment = str(node.props['alignment']) ?? 'center';
|
|
4242
4361
|
for (const row of plan.rows) {
|
|
4243
4362
|
for (const cell of row.cells) {
|
|
4244
4363
|
const column = plan.columns[cell.column] as LazyColumnPlan;
|
|
4245
|
-
const box: Size = { width: column.width, height: row.height };
|
|
4364
|
+
const box: Size = horizontal ? { width: row.height, height: column.width } : { width: column.width, height: row.height };
|
|
4246
4365
|
const { dx, dy } = alignIn(box, cell.size, column.alignment ?? gridAlignment);
|
|
4247
|
-
this.place(cell.node, cctx, { width:
|
|
4366
|
+
if (horizontal) this.place(cell.node, cctx, { width: null, height: column.width }, x + row.y + dx, y + column.x + dy);
|
|
4367
|
+
else this.place(cell.node, cctx, { width: column.width, height: null }, x + column.x + dx, y + row.y + dy);
|
|
4248
4368
|
}
|
|
4249
4369
|
}
|
|
4250
4370
|
}
|
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) {
|