@swiftbrowser/web 0.3.1 → 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 +4 -1
- package/app/index.html +1 -0
- package/package.json +5 -2
- package/src/animation.ts +154 -3
- package/src/dragDrop.ts +549 -0
- package/src/hostContent.ts +37 -0
- package/src/interaction.ts +128 -22
- package/src/largeTitle.ts +98 -54
- package/src/layout/engine.ts +582 -135
- package/src/layout/index.ts +8 -5
- package/src/layout/measure.ts +2 -0
- package/src/layout/text.ts +6 -3
- package/src/layout/types.ts +16 -1
- package/src/layout/typography.ts +30 -10
- package/src/layoutDom.ts +87 -16
- package/src/page.css +234 -0
- package/src/pointerEffects.ts +182 -0
- package/src/protocol.ts +219 -9
- package/src/renderer.ts +355 -15
- package/src/scrollEdge.ts +109 -0
- package/src/scrollTo.ts +6 -0
- package/src/styles.css +457 -251
- package/src/symbols.ts +3 -1
- package/src/typography.ts +5 -2
package/src/layout/index.ts
CHANGED
|
@@ -9,15 +9,15 @@
|
|
|
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` |
|
|
16
16
|
* | `section` | `rows` (the inset card), `header`, `footer` (when present) |
|
|
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
|
-
* | `list`/`scrollview` | `bottomInset` (Phase 4, only under a tab bar that reaches the screen bottom: the part of the frame the bar covers; the DOM adds its height as scrollable content inset) |
|
|
20
|
-
* | `tabview` | `bar` (
|
|
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` (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) |
|
|
@@ -74,7 +74,10 @@ export {
|
|
|
74
74
|
makeFont,
|
|
75
75
|
DYNAMIC_TYPE_SIZES,
|
|
76
76
|
TEXT_STYLE_SIZES,
|
|
77
|
-
|
|
77
|
+
LINE_HEIGHT_RATIO,
|
|
78
|
+
LEADINGS,
|
|
79
|
+
leadingFor,
|
|
80
|
+
textHeight,
|
|
78
81
|
FONT_FAMILIES,
|
|
79
82
|
FONT_WEIGHTS,
|
|
80
83
|
type TextStyle,
|
package/src/layout/measure.ts
CHANGED
|
@@ -20,6 +20,8 @@ function canvasFont(font: ResolvedFont): string {
|
|
|
20
20
|
* is available (Node), it falls back to the monospace estimate.
|
|
21
21
|
*/
|
|
22
22
|
export class CanvasTextMeasurer implements TextMeasurer {
|
|
23
|
+
/** Set by the renderer: measures a `host` element's content in the DOM. */
|
|
24
|
+
measureHost?: TextMeasurer['measureHost'];
|
|
23
25
|
private context: Canvas2D | null | undefined;
|
|
24
26
|
private readonly cache = new Map<string, number>();
|
|
25
27
|
private readonly fallback = new MonospaceTextMeasurer();
|
package/src/layout/text.ts
CHANGED
|
@@ -262,12 +262,15 @@ export function wrapRuns(
|
|
|
262
262
|
}
|
|
263
263
|
|
|
264
264
|
const merged = lines.map(mergePieces);
|
|
265
|
-
|
|
266
|
-
|
|
265
|
+
// The first line is a line tall, each further one a leading (the
|
|
266
|
+
// distance from the line above's baseline).
|
|
267
|
+
const heights = merged.map((line, index) => {
|
|
268
|
+
const metric = (font: ResolvedFont): number => (index === 0 ? font.lineHeight : font.leading);
|
|
269
|
+
if (line.length === 0) return metric(base);
|
|
267
270
|
let height = 0;
|
|
268
271
|
for (const piece of line) {
|
|
269
272
|
const font = (runs[piece.run] as TextRunInput).font;
|
|
270
|
-
height = Math.max(height, piece.height ?? font
|
|
273
|
+
height = Math.max(height, piece.height ?? metric(font), metric(font));
|
|
271
274
|
}
|
|
272
275
|
return height;
|
|
273
276
|
});
|
package/src/layout/types.ts
CHANGED
|
@@ -62,8 +62,10 @@ export interface ResolvedFont {
|
|
|
62
62
|
size: number;
|
|
63
63
|
/** CSS numeric weight, 100...900. */
|
|
64
64
|
weight: number;
|
|
65
|
-
/**
|
|
65
|
+
/** A line's height in CSS px (a multiple of 1/3: a 3x device pixel). */
|
|
66
66
|
lineHeight: number;
|
|
67
|
+
/** The distance between a paragraph's baselines (the HIG leading); an SF Symbol image is this square. */
|
|
68
|
+
leading: number;
|
|
67
69
|
/** A CSS `font` shorthand (`weight size/lineHeight family`) usable verbatim. */
|
|
68
70
|
css: string;
|
|
69
71
|
/**
|
|
@@ -79,6 +81,12 @@ export interface ResolvedFont {
|
|
|
79
81
|
export interface TextMeasurer {
|
|
80
82
|
/** Width of one line of text in CSS px when drawn with `font`. */
|
|
81
83
|
measureWidth(text: string, font: ResolvedFont): number;
|
|
84
|
+
/**
|
|
85
|
+
* A `host` element's content (src/hostContent.ts, by key) laid out at
|
|
86
|
+
* `width` CSS px, or at its own ideal width when null: its size. Absent in
|
|
87
|
+
* headless layouts, where a host measures as a 10×10 placeholder.
|
|
88
|
+
*/
|
|
89
|
+
measureHost?: ((content: string, width: number | null) => Size) | undefined;
|
|
82
90
|
}
|
|
83
91
|
|
|
84
92
|
export interface LayoutEnvironment {
|
|
@@ -93,6 +101,13 @@ export interface LayoutEnvironment {
|
|
|
93
101
|
* images then measure as unknown (44×44 placeholders).
|
|
94
102
|
*/
|
|
95
103
|
assets?: AssetCatalog;
|
|
104
|
+
/**
|
|
105
|
+
* The root's height is its content's, not the screen's: a SwiftUI island
|
|
106
|
+
* in a page's own layout (@swiftbrowser/react's `SwiftUIView`). A plain
|
|
107
|
+
* root is proposed the screen width and no height and placed at the top;
|
|
108
|
+
* navigation and tab roots still fill the screen.
|
|
109
|
+
*/
|
|
110
|
+
contentHeight?: boolean;
|
|
96
111
|
}
|
|
97
112
|
|
|
98
113
|
export type TextAlignment = 'leading' | 'center' | 'trailing';
|
package/src/layout/typography.ts
CHANGED
|
@@ -4,8 +4,11 @@
|
|
|
4
4
|
* Point sizes come from Apple's Human Interface Guidelines, "Typography" >
|
|
5
5
|
* "Specifications" > "iOS, iPadOS Dynamic Type sizes" and "Larger
|
|
6
6
|
* accessibility type sizes" (https://developer.apple.com/design/human-interface-guidelines/typography).
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* A line is as tall as SwiftUI makes a `Text`: SF Pro's ascender plus
|
|
8
|
+
* descender (`UIFont.lineHeight`, 1.1934 × the point size), rounded up to a
|
|
9
|
+
* device pixel on a 3x iPhone (body 17 → 20.33, large title 34 → 40.67;
|
|
10
|
+
* measured in the iOS 26.2 simulator). The HIG's tables list a taller
|
|
11
|
+
* leading (body 22): the distance between a paragraph's lines (`LEADINGS`).
|
|
9
12
|
*/
|
|
10
13
|
|
|
11
14
|
import type { DynamicTypeSize, FontFamily, LayoutEnvironment, ResolvedFont } from './types';
|
|
@@ -60,8 +63,13 @@ export const TEXT_STYLE_SIZES: Record<TextStyle, readonly number[]> = {
|
|
|
60
63
|
caption2: [11, 11, 11, 11, 13, 15, 17, 20, 24, 29, 34, 40],
|
|
61
64
|
};
|
|
62
65
|
|
|
63
|
-
/**
|
|
64
|
-
|
|
66
|
+
/**
|
|
67
|
+
* The HIG's leading for the standard (Large) point sizes: the distance
|
|
68
|
+
* between the baselines of a paragraph's lines, which SwiftUI keeps (a
|
|
69
|
+
* multi-line body `Text` is 20.33 + 22 per further line); other sizes use
|
|
70
|
+
* round(size * 1.2). An SF Symbol image takes a square of it.
|
|
71
|
+
*/
|
|
72
|
+
export const LEADINGS: Readonly<Record<number, number>> = {
|
|
65
73
|
34: 41,
|
|
66
74
|
28: 34,
|
|
67
75
|
22: 28,
|
|
@@ -120,10 +128,22 @@ export function textStyleSize(style: TextStyle, size: DynamicTypeSize): number {
|
|
|
120
128
|
return row[sizeCategoryIndex(size)] ?? row[LARGE_INDEX] ?? 17;
|
|
121
129
|
}
|
|
122
130
|
|
|
123
|
-
/**
|
|
131
|
+
/** SF Pro's ascender plus descender per point (1950 + 494 of 2048 units per em). */
|
|
132
|
+
export const LINE_HEIGHT_RATIO = 2444 / 2048;
|
|
133
|
+
|
|
134
|
+
/** A line's height at a point size: `UIFont.lineHeight`, rounded up to a 3x pixel. */
|
|
124
135
|
export function lineHeightFor(size: number): number {
|
|
125
|
-
|
|
126
|
-
|
|
136
|
+
return Math.ceil(size * LINE_HEIGHT_RATIO * 3 - 1e-6) / 3;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The distance between a paragraph's baselines at a point size. */
|
|
140
|
+
export function leadingFor(size: number): number {
|
|
141
|
+
return LEADINGS[size] ?? Math.round(size * 1.2);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The height of `lines` lines of text in `font`: the first line, then its leading per further line. */
|
|
145
|
+
export function textHeight(lines: number, font: ResolvedFont): number {
|
|
146
|
+
return lines > 0 ? font.lineHeight + (lines - 1) * font.leading : 0;
|
|
127
147
|
}
|
|
128
148
|
|
|
129
149
|
function formatPx(value: number): string {
|
|
@@ -138,14 +158,14 @@ export function makeFont(
|
|
|
138
158
|
modifiers: { weightModifier?: number | undefined; familyModifier?: FontFamily | undefined } = {},
|
|
139
159
|
): ResolvedFont {
|
|
140
160
|
const lineHeight = lineHeightFor(size);
|
|
141
|
-
const css = `${weight} ${formatPx(size)}/${lineHeight}
|
|
142
|
-
const font: ResolvedFont = { family, size, weight, lineHeight, css };
|
|
161
|
+
const css = `${weight} ${formatPx(size)}/${formatPx(lineHeight)} ${FONT_FAMILIES[family]}`;
|
|
162
|
+
const font: ResolvedFont = { family, size, weight, lineHeight, leading: leadingFor(size), css };
|
|
143
163
|
if (modifiers.weightModifier !== undefined) font.weightModifier = modifiers.weightModifier;
|
|
144
164
|
if (modifiers.familyModifier !== undefined) font.familyModifier = modifiers.familyModifier;
|
|
145
165
|
return font;
|
|
146
166
|
}
|
|
147
167
|
|
|
148
|
-
/** Body text for the current `dynamicTypeSize` (17/
|
|
168
|
+
/** Body text for the current `dynamicTypeSize` (17/20.33 at Large). */
|
|
149
169
|
export function defaultFont(env: LayoutEnvironment): ResolvedFont {
|
|
150
170
|
return makeFont('system', textStyleSize('body', env.dynamicTypeSize), 400);
|
|
151
171
|
}
|
package/src/layoutDom.ts
CHANGED
|
@@ -37,7 +37,8 @@ import type { LayoutEnvironment, LayoutNode, LayoutResult, Rect, ResolvedFont, T
|
|
|
37
37
|
import { applyChartChrome } from './chartDom';
|
|
38
38
|
import { bitmapLaidOut } from './bitmaps';
|
|
39
39
|
import { drawRichText } from './richText';
|
|
40
|
-
import {
|
|
40
|
+
import { bindScrollUnderBar, largeTitleOf, largeTitleScrolls } from './largeTitle';
|
|
41
|
+
import { bindScrollEdgeBottom } from './scrollEdge';
|
|
41
42
|
|
|
42
43
|
export interface LayoutDomContext {
|
|
43
44
|
root: HTMLElement;
|
|
@@ -236,6 +237,47 @@ function positionBox(el: HTMLElement, box: Rect, origin: { x: number; y: number
|
|
|
236
237
|
}
|
|
237
238
|
|
|
238
239
|
/** Position a chrome node inside its element: `rect` is in screen coordinates. */
|
|
240
|
+
/**
|
|
241
|
+
* An edit-mode control fades in when it first appears (the list started
|
|
242
|
+
* editing), marked `data-sb-appear` for the animation's duration. A control
|
|
243
|
+
* that is laid out again while shown, or whose row is moved in the DOM (a
|
|
244
|
+
* reorder re-inserts the row, which would restart a CSS animation on it),
|
|
245
|
+
* is not marked and stays put.
|
|
246
|
+
*/
|
|
247
|
+
function placeEditControl(node: HTMLElement | null, rect: Rect | undefined, origin: Rect): void {
|
|
248
|
+
if (!node) return;
|
|
249
|
+
const wasHidden = node.hidden;
|
|
250
|
+
placeChrome(node, rect, origin);
|
|
251
|
+
if (wasHidden && !node.hidden) {
|
|
252
|
+
node.dataset['sbAppear'] = '';
|
|
253
|
+
const settle = (): void => {
|
|
254
|
+
delete node.dataset['sbAppear'];
|
|
255
|
+
};
|
|
256
|
+
node.addEventListener('animationend', settle, { once: true });
|
|
257
|
+
node.addEventListener('animationcancel', settle, { once: true });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
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
|
+
|
|
239
281
|
function placeChrome(node: HTMLElement | null, rect: Rect | undefined, origin: Rect): void {
|
|
240
282
|
if (!node) return;
|
|
241
283
|
if (!rect) {
|
|
@@ -261,16 +303,21 @@ function positionSheetCard(sheet: HTMLElement, frame: Rect): void {
|
|
|
261
303
|
s.height = `${frame.height}px`;
|
|
262
304
|
}
|
|
263
305
|
|
|
264
|
-
|
|
306
|
+
/**
|
|
307
|
+
* `lines` > 1: a paragraph, whose lines are a leading apart (the engine makes
|
|
308
|
+
* it the first line's height plus a leading per further line).
|
|
309
|
+
*/
|
|
310
|
+
function setFont(el: HTMLElement, font: ResolvedFont | undefined, lines = 1): void {
|
|
265
311
|
if (!font) return;
|
|
266
|
-
|
|
312
|
+
const css = lines > 1 ? font.css.replace(/\/[\d.]+px /, `/${Math.round(font.leading * 100) / 100}px `) : font.css;
|
|
313
|
+
if (el.style.font !== css) el.style.font = css;
|
|
267
314
|
}
|
|
268
315
|
|
|
269
316
|
const TEXT_ALIGN: Record<string, string> = { leading: 'left', center: 'center', trailing: 'right' };
|
|
270
317
|
|
|
271
318
|
/** Render the engine's exact lines into a text node (`white-space: pre` keeps them). */
|
|
272
319
|
function setTextLayout(el: HTMLElement, layout: TextLayout): void {
|
|
273
|
-
setFont(el, layout.font);
|
|
320
|
+
setFont(el, layout.font, layout.lines.length);
|
|
274
321
|
const align = TEXT_ALIGN[layout.alignment] ?? 'left';
|
|
275
322
|
if (el.style.textAlign !== align) el.style.textAlign = align;
|
|
276
323
|
if (layout.rich && el.dataset['sbRich'] !== undefined) {
|
|
@@ -373,8 +420,9 @@ function applyChrome(
|
|
|
373
420
|
}
|
|
374
421
|
case 'textfield':
|
|
375
422
|
case 'texteditor':
|
|
376
|
-
// A vertical text field draws in the font its lines were measured in
|
|
377
|
-
|
|
423
|
+
// A vertical text field draws in the font its lines were measured in,
|
|
424
|
+
// a leading apart.
|
|
425
|
+
setFont(el, result.fonts.get(node.id), el instanceof HTMLTextAreaElement ? 2 : 1);
|
|
378
426
|
padIntoFrame(el, box, frame);
|
|
379
427
|
return;
|
|
380
428
|
// Phase 8 controls: their chrome sits at the engine's rects inside the element's box.
|
|
@@ -463,7 +511,7 @@ function applyChrome(
|
|
|
463
511
|
if (header && texts?.['header']) setTextLayout(header, texts['header']);
|
|
464
512
|
if (footer && texts?.['footer']) {
|
|
465
513
|
// The prop text in the engine's font and rect, wrapped there.
|
|
466
|
-
setFont(footer, texts['footer'].font);
|
|
514
|
+
setFont(footer, texts['footer'].font, texts['footer'].lines.length);
|
|
467
515
|
footer.style.textAlign = 'left';
|
|
468
516
|
footer.style.whiteSpace = 'pre-wrap';
|
|
469
517
|
const original = typeof node.props['footer'] === 'string' ? node.props['footer'] : texts['footer'].lines.join('\n');
|
|
@@ -479,7 +527,8 @@ function applyChrome(
|
|
|
479
527
|
// Extra scrollable space at the end (the engine's frames are unchanged).
|
|
480
528
|
content.style.height = `${size.height + bottomInset}px`;
|
|
481
529
|
}
|
|
482
|
-
|
|
530
|
+
bindScrollUnderBar(el, frame, chrome?.['topInset'], chrome?.['largeTitle']);
|
|
531
|
+
bindScrollEdgeBottom(el, chrome?.['bottomInset']);
|
|
483
532
|
return;
|
|
484
533
|
}
|
|
485
534
|
case 'scrollview': {
|
|
@@ -494,24 +543,34 @@ function applyChrome(
|
|
|
494
543
|
s.marginLeft = `${rect ? rect.x - frame.x : 0}px`;
|
|
495
544
|
s.marginTop = `${rect ? rect.y - frame.y : 0}px`;
|
|
496
545
|
}
|
|
497
|
-
|
|
546
|
+
bindScrollUnderBar(el, frame, chrome?.['topInset'], chrome?.['largeTitle']);
|
|
547
|
+
bindScrollEdgeBottom(el, String(node.props['axes'] ?? 'vertical') === 'horizontal' ? undefined : chrome?.['bottomInset']);
|
|
498
548
|
return;
|
|
499
549
|
}
|
|
500
550
|
case 'navscreen': {
|
|
501
551
|
const bar = el.querySelector<HTMLElement>(':scope > .sb-navbar');
|
|
502
|
-
const largeTitle = el
|
|
552
|
+
const largeTitle = largeTitleOf(el);
|
|
503
553
|
const inlineTitle = el.querySelector<HTMLElement>(':scope > .sb-navbar > .sb-navbar-title');
|
|
504
554
|
const texts = result.chromeText.get(node.id);
|
|
505
555
|
const barRect = chrome?.['bar'];
|
|
506
556
|
if (bar && barRect) {
|
|
507
557
|
placeChrome(bar, barRect, frame);
|
|
508
|
-
// The 44px bar row sits under the status bar portion of the rect
|
|
509
|
-
bar
|
|
558
|
+
// The 44px bar row sits under the status bar portion of the rect
|
|
559
|
+
// (iOS 26: an inline bar keeps 10 more below it).
|
|
560
|
+
const row = chrome?.['barRow'];
|
|
561
|
+
const top = row ? row.y - barRect.y : Math.max(0, barRect.height - NAV_BAR_HEIGHT);
|
|
562
|
+
const bottom = row ? Math.max(0, barRect.y + barRect.height - (row.y + row.height)) : 0;
|
|
563
|
+
bar.style.paddingTop = `${top}px`;
|
|
564
|
+
bar.style.paddingBottom = `${bottom}px`;
|
|
510
565
|
}
|
|
566
|
+
syncBottomGlass(el, chrome, frame);
|
|
511
567
|
const largeRect = chrome?.['largeTitle'];
|
|
512
568
|
if (largeTitle) {
|
|
513
569
|
if (largeRect) {
|
|
514
|
-
|
|
570
|
+
// A title that scrolls with the screen's scroll view is placed by
|
|
571
|
+
// it (src/largeTitle.ts).
|
|
572
|
+
if (largeTitleScrolls(el, largeTitle)) largeTitle.hidden = false;
|
|
573
|
+
else placeChrome(largeTitle, largeRect, frame);
|
|
515
574
|
if (texts?.['largeTitle']) {
|
|
516
575
|
setFont(largeTitle, texts['largeTitle'].font);
|
|
517
576
|
const line = texts['largeTitle'].lines.join('\n');
|
|
@@ -539,6 +598,10 @@ function applyChrome(
|
|
|
539
598
|
if (back && backLabel && backRect && texts?.['back']) {
|
|
540
599
|
const text = texts['back'].lines[0] ?? '';
|
|
541
600
|
back.style.maxWidth = `${backRect.width}px`;
|
|
601
|
+
// The bar pads its row 16 (styles.css); a split view's sidebar
|
|
602
|
+
// toggle can push the back button further in (chrome.back).
|
|
603
|
+
const indent = barRect ? Math.max(0, backRect.x - barRect.x - 16) : 0;
|
|
604
|
+
back.style.marginLeft = indent > 0 ? `${indent}px` : '';
|
|
542
605
|
backLabel.dataset['sbLayoutLabel'] = text;
|
|
543
606
|
if (backLabel.textContent !== text) backLabel.textContent = text;
|
|
544
607
|
setFont(back, texts['back'].font);
|
|
@@ -563,9 +626,15 @@ function applyChrome(
|
|
|
563
626
|
items.forEach((item, i) => placeChrome(item, chrome?.[`item${i}`], barRect));
|
|
564
627
|
return;
|
|
565
628
|
}
|
|
629
|
+
case 'inspector': {
|
|
630
|
+
setFlag(el, 'sbPresented', node.props['presented'] === true ? '' : null);
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
566
633
|
case 'navsplit': {
|
|
567
|
-
// Phase 7: the hairline between the two columns.
|
|
634
|
+
// Phase 7: the hairline between the two columns, and the sidebar toggle.
|
|
568
635
|
placeChrome(el.querySelector<HTMLElement>(':scope > .sb-navsplit-separator'), chrome?.['separator'], box);
|
|
636
|
+
placeChrome(el.querySelector<HTMLElement>(':scope > .sb-navsplit-toggle'), chrome?.['toggle'], box);
|
|
637
|
+
setFlag(el, 'sbSidebarHidden', node.props['sidebarVisible'] === false ? '' : null);
|
|
569
638
|
return;
|
|
570
639
|
}
|
|
571
640
|
case 'gauge': {
|
|
@@ -583,8 +652,10 @@ function applyChrome(
|
|
|
583
652
|
return;
|
|
584
653
|
case 'listrow':
|
|
585
654
|
// Phase 8: the edit-mode control (selection circle / minus) at the
|
|
586
|
-
// engine's `editControl` rect
|
|
587
|
-
|
|
655
|
+
// engine's `editControl` rect and the reorder grip at `reorderControl`;
|
|
656
|
+
// hidden when the list is not editing.
|
|
657
|
+
placeEditControl(el.querySelector<HTMLElement>(':scope > .sb-listrow-control'), chrome?.['editControl'], box);
|
|
658
|
+
placeEditControl(el.querySelector<HTMLElement>(':scope > .sb-swipe-content > .sb-listrow-grip'), chrome?.['reorderControl'], box);
|
|
588
659
|
return;
|
|
589
660
|
case 'picker': {
|
|
590
661
|
setFont(el, result.fonts.get(node.id));
|
package/src/page.css
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/* ---------------------------------------------------------------------------
|
|
2
|
+
The device page (app/index.html): the page around the iPhone frame, with
|
|
3
|
+
its toolbar, its stage and its error banner. Not the renderer's: a page
|
|
4
|
+
that draws an island (@swiftbrowser/react's `SwiftUIView`) imports
|
|
5
|
+
styles.css alone and keeps its own layout.
|
|
6
|
+
------------------------------------------------------------------------- */
|
|
7
|
+
|
|
8
|
+
:root {
|
|
9
|
+
--page-bg: #e9e9ee;
|
|
10
|
+
--page-fg: #1c1c1e;
|
|
11
|
+
--page-muted: #6e6e73;
|
|
12
|
+
--page-border: rgba(0, 0, 0, 0.08);
|
|
13
|
+
--page-surface: rgba(255, 255, 255, 0.8);
|
|
14
|
+
--page-error-bg: #ffe5e5;
|
|
15
|
+
--page-error-fg: #a1000d;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
@media (prefers-color-scheme: dark) {
|
|
19
|
+
:root {
|
|
20
|
+
--page-bg: #111114;
|
|
21
|
+
--page-fg: #f2f2f7;
|
|
22
|
+
--page-muted: #98989f;
|
|
23
|
+
--page-border: rgba(255, 255, 255, 0.1);
|
|
24
|
+
--page-surface: rgba(28, 28, 30, 0.85);
|
|
25
|
+
--page-error-bg: #3a0b10;
|
|
26
|
+
--page-error-fg: #ff8a8a;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
*,
|
|
31
|
+
*::before,
|
|
32
|
+
*::after {
|
|
33
|
+
box-sizing: border-box;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
html,
|
|
37
|
+
body {
|
|
38
|
+
margin: 0;
|
|
39
|
+
min-height: 100%;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
body {
|
|
43
|
+
display: flex;
|
|
44
|
+
flex-direction: column;
|
|
45
|
+
min-height: 100vh;
|
|
46
|
+
background: var(--page-bg);
|
|
47
|
+
color: var(--page-fg);
|
|
48
|
+
font-family: var(--sb-font-family);
|
|
49
|
+
font-size: 14px;
|
|
50
|
+
-webkit-font-smoothing: antialiased;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.toolbar {
|
|
54
|
+
position: sticky;
|
|
55
|
+
top: 0;
|
|
56
|
+
z-index: 10;
|
|
57
|
+
display: flex;
|
|
58
|
+
align-items: center;
|
|
59
|
+
gap: 10px;
|
|
60
|
+
padding: 10px 16px;
|
|
61
|
+
background: var(--page-surface);
|
|
62
|
+
border-bottom: 1px solid var(--page-border);
|
|
63
|
+
backdrop-filter: blur(16px);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.toolbar-brand {
|
|
67
|
+
font-weight: 600;
|
|
68
|
+
letter-spacing: -0.01em;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.toolbar-sep {
|
|
72
|
+
width: 1px;
|
|
73
|
+
height: 18px;
|
|
74
|
+
background: var(--page-border);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.toolbar-label {
|
|
78
|
+
color: var(--page-muted);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.toolbar-value {
|
|
82
|
+
font-variant-numeric: tabular-nums;
|
|
83
|
+
padding: 2px 8px;
|
|
84
|
+
border-radius: 6px;
|
|
85
|
+
background: var(--page-border);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.toolbar-spacer {
|
|
89
|
+
flex: 1 1 0;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/* Hot-reload indicator, driven by the dev server's sb:build-* events (the
|
|
93
|
+
`sb-spin` keyframes are styles.css's, loaded with this file). */
|
|
94
|
+
.toolbar-build {
|
|
95
|
+
display: inline-flex;
|
|
96
|
+
align-items: center;
|
|
97
|
+
gap: 6px;
|
|
98
|
+
color: var(--page-muted);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.toolbar-build[hidden] {
|
|
102
|
+
display: none;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.toolbar-build::before {
|
|
106
|
+
content: "";
|
|
107
|
+
width: 10px;
|
|
108
|
+
height: 10px;
|
|
109
|
+
border-radius: 50%;
|
|
110
|
+
border: 1.5px solid currentColor;
|
|
111
|
+
border-top-color: transparent;
|
|
112
|
+
animation: sb-spin 0.9s linear infinite;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.toolbar-build[data-state="error"] {
|
|
116
|
+
color: var(--page-error-fg);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.toolbar-build[data-state="error"]::before {
|
|
120
|
+
animation: none;
|
|
121
|
+
border-color: currentColor;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.toolbar-button {
|
|
125
|
+
font: inherit;
|
|
126
|
+
padding: 6px 12px;
|
|
127
|
+
border: 1px solid var(--page-border);
|
|
128
|
+
border-radius: 8px;
|
|
129
|
+
background: transparent;
|
|
130
|
+
color: var(--page-fg);
|
|
131
|
+
cursor: pointer;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.toolbar-button:hover {
|
|
135
|
+
background: var(--page-border);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.toolbar-button[aria-pressed="true"] {
|
|
139
|
+
background: var(--page-fg);
|
|
140
|
+
color: var(--page-bg);
|
|
141
|
+
border-color: transparent;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.toolbar-select {
|
|
145
|
+
font: inherit;
|
|
146
|
+
padding: 5px 8px;
|
|
147
|
+
border: 1px solid var(--page-border);
|
|
148
|
+
border-radius: 8px;
|
|
149
|
+
background: transparent;
|
|
150
|
+
color: var(--page-fg);
|
|
151
|
+
cursor: pointer;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.stage {
|
|
155
|
+
display: flex;
|
|
156
|
+
flex-direction: column;
|
|
157
|
+
align-items: center;
|
|
158
|
+
gap: 16px;
|
|
159
|
+
padding: 32px 16px 48px;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.status {
|
|
163
|
+
max-width: 560px;
|
|
164
|
+
margin: 0;
|
|
165
|
+
padding: 10px 14px;
|
|
166
|
+
border-radius: 10px;
|
|
167
|
+
background: var(--page-error-bg);
|
|
168
|
+
color: var(--page-error-fg);
|
|
169
|
+
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
|
170
|
+
font-size: 12px;
|
|
171
|
+
white-space: pre-wrap;
|
|
172
|
+
word-break: break-word;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/* ---------------------------------------------------------------------------
|
|
176
|
+
Device mode (html[data-sb-mode="device"], see src/deviceMode.ts): no
|
|
177
|
+
toolbar, no stage padding, the page behind the app in the OS appearance's
|
|
178
|
+
system background from the first paint (Safari tints its status bar strip
|
|
179
|
+
from it; chromeColor.ts then keeps it at the color the app draws at its
|
|
180
|
+
top edge), and the error banner floating clear of the home indicator.
|
|
181
|
+
------------------------------------------------------------------------- */
|
|
182
|
+
|
|
183
|
+
html[data-sb-mode="device"] {
|
|
184
|
+
height: 100%;
|
|
185
|
+
overflow: hidden;
|
|
186
|
+
overscroll-behavior: none;
|
|
187
|
+
/* No double-tap zoom, no text inflation in landscape. */
|
|
188
|
+
touch-action: manipulation;
|
|
189
|
+
-webkit-text-size-adjust: 100%;
|
|
190
|
+
text-size-adjust: 100%;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
html[data-sb-mode="device"] body {
|
|
194
|
+
min-height: 100dvh;
|
|
195
|
+
overflow: hidden;
|
|
196
|
+
overscroll-behavior: none;
|
|
197
|
+
touch-action: manipulation;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/* The page behind the app is the OS appearance's system background from the
|
|
201
|
+
first paint (Safari tints its status bar strip from it); chromeColor.ts
|
|
202
|
+
then keeps it at the color the app draws at its top edge. */
|
|
203
|
+
html[data-sb-mode="device"],
|
|
204
|
+
html[data-sb-mode="device"] body {
|
|
205
|
+
background: #ffffff;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
@media (prefers-color-scheme: dark) {
|
|
209
|
+
html[data-sb-mode="device"],
|
|
210
|
+
html[data-sb-mode="device"] body {
|
|
211
|
+
background: #000000;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
html[data-sb-mode="device"] .toolbar {
|
|
216
|
+
display: none;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
html[data-sb-mode="device"] .stage {
|
|
220
|
+
padding: 0;
|
|
221
|
+
gap: 0;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/* The error banner floats over the app, clear of the home indicator. */
|
|
225
|
+
html[data-sb-mode="device"] .status {
|
|
226
|
+
position: fixed;
|
|
227
|
+
left: 16px;
|
|
228
|
+
right: 16px;
|
|
229
|
+
bottom: calc(16px + var(--sb-inset-bottom));
|
|
230
|
+
z-index: 10;
|
|
231
|
+
max-width: none;
|
|
232
|
+
max-height: 40dvh;
|
|
233
|
+
overflow: auto;
|
|
234
|
+
}
|