@swiftbrowser/web 0.5.6 → 0.6.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 +39 -7
- package/app/index.html +25 -13
- package/node/dist/node/config.d.ts +24 -3
- package/node/dist/node/config.d.ts.map +1 -1
- package/node/dist/node/config.js +41 -10
- package/node/dist/node/config.js.map +1 -1
- package/node/dist/node/index.d.ts +9 -4
- package/node/dist/node/index.d.ts.map +1 -1
- package/node/dist/node/index.js +14 -5
- package/node/dist/node/index.js.map +1 -1
- package/node/dist/plugins/app-manifests.d.ts +12 -4
- package/node/dist/plugins/app-manifests.d.ts.map +1 -1
- package/node/dist/plugins/app-manifests.js +14 -9
- package/node/dist/plugins/app-manifests.js.map +1 -1
- package/node/dist/plugins/site-layout.d.ts +41 -0
- package/node/dist/plugins/site-layout.d.ts.map +1 -0
- package/node/dist/plugins/site-layout.js +77 -0
- package/node/dist/plugins/site-layout.js.map +1 -0
- package/package.json +1 -1
- package/plugins/app-manifests.ts +28 -9
- package/plugins/site-layout.ts +96 -0
- package/src/animation.ts +86 -3
- package/src/deviceMode.ts +9 -2
- package/src/gaugeRing.ts +2 -2
- package/src/layout/engine.ts +228 -51
- package/src/layout/index.ts +1 -1
- package/src/layoutDom.ts +96 -12
- package/src/main.ts +6 -4
- package/src/press.ts +130 -0
- package/src/protocol.ts +26 -0
- package/src/renderer.ts +57 -14
- package/src/styles.css +184 -29
- package/src/styles.ts +3 -0
- package/src/symbols.ts +3 -1
- package/src/vite-env.d.ts +18 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The site's layout, and what the app page knows about the site it is in.
|
|
3
|
+
*
|
|
4
|
+
* A site holds one app or several (node/config.ts, `siteLayout`):
|
|
5
|
+
*
|
|
6
|
+
* - `app`: one app, the site is that app. The app page is `/` (the dev server
|
|
7
|
+
* serves it there; a build writes it as `index.html` and nothing at `/app/`),
|
|
8
|
+
* there is no landing page, and the site's `/manifest.webmanifest` is the
|
|
9
|
+
* app's own, starting at `/`.
|
|
10
|
+
* - `landing`: the landing page at `/` lists the apps, each at `/app/?app=<name>`.
|
|
11
|
+
*
|
|
12
|
+
* Both layouts give the app page `window.__SB_SITE__` (the layout, the app
|
|
13
|
+
* the page runs without `?app=`, its display name, and the page mode a wide
|
|
14
|
+
* screen gets), which the inline scripts of app/index.html read before the
|
|
15
|
+
* first paint; src/main.ts gets the same through `define`s.
|
|
16
|
+
*/
|
|
17
|
+
import { existsSync, mkdirSync, renameSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { appManifestJson } from './app-manifests.js';
|
|
20
|
+
/** The manifest an `app` layout site serves at its root, and the start URL its pages use. */
|
|
21
|
+
export const ROOT_START_URL = '/';
|
|
22
|
+
const isAppPage = (pathname) => /(^|\/)app\/(index\.html)?$/.test(pathname.split('?')[0] ?? '');
|
|
23
|
+
/** The `pathname?query` of a request to the app page that serves `url` in the `app` layout, or null. */
|
|
24
|
+
export function rewriteRootRequest(url) {
|
|
25
|
+
const [pathname = '', query] = url.split('?', 2);
|
|
26
|
+
if (pathname !== '/' && pathname !== '/index.html')
|
|
27
|
+
return null;
|
|
28
|
+
return `/app/${query !== undefined ? `?${query}` : ''}`;
|
|
29
|
+
}
|
|
30
|
+
export function siteLayoutPlugin(info, app) {
|
|
31
|
+
const single = info.layout === 'app' ? app : undefined;
|
|
32
|
+
const rootManifest = single ? appManifestJson(single, { startUrl: ROOT_START_URL }) : null;
|
|
33
|
+
let outDir = '';
|
|
34
|
+
return {
|
|
35
|
+
name: 'swiftbrowser:site-layout',
|
|
36
|
+
configResolved(config) {
|
|
37
|
+
outDir = path.resolve(config.root, config.build.outDir);
|
|
38
|
+
},
|
|
39
|
+
configureServer(server) {
|
|
40
|
+
if (!rootManifest)
|
|
41
|
+
return;
|
|
42
|
+
// Before Vite's own middlewares: `/` is the app page, and the root
|
|
43
|
+
// manifest is the app's (over the one in public/).
|
|
44
|
+
server.middlewares.use((req, res, next) => {
|
|
45
|
+
const url = req.url ?? '';
|
|
46
|
+
if (url.split('?')[0] === '/manifest.webmanifest') {
|
|
47
|
+
res.setHeader('Content-Type', 'application/manifest+json');
|
|
48
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
49
|
+
res.end(rootManifest);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const rewritten = rewriteRootRequest(url);
|
|
53
|
+
if (rewritten !== null)
|
|
54
|
+
req.url = rewritten;
|
|
55
|
+
next();
|
|
56
|
+
});
|
|
57
|
+
},
|
|
58
|
+
transformIndexHtml(_html, ctx) {
|
|
59
|
+
if (!isAppPage(ctx.path))
|
|
60
|
+
return;
|
|
61
|
+
return [{ tag: 'script', injectTo: 'head-prepend', children: `window.__SB_SITE__ = ${JSON.stringify(info)};` }];
|
|
62
|
+
},
|
|
63
|
+
closeBundle() {
|
|
64
|
+
if (!rootManifest)
|
|
65
|
+
return;
|
|
66
|
+
// After everything is written (Vite's bundle, then public/): the app
|
|
67
|
+
// page becomes the site's root page. `app/` stays for the OAuth callback
|
|
68
|
+
// page public/ puts there (src/webAuth.ts).
|
|
69
|
+
const appPage = path.join(outDir, 'app', 'index.html');
|
|
70
|
+
if (existsSync(appPage))
|
|
71
|
+
renameSync(appPage, path.join(outDir, 'index.html'));
|
|
72
|
+
mkdirSync(outDir, { recursive: true });
|
|
73
|
+
writeFileSync(path.join(outDir, 'manifest.webmanifest'), rootManifest);
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
//# sourceMappingURL=site-layout.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"site-layout.js","sourceRoot":"","sources":["../../../plugins/site-layout.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
package/plugins/app-manifests.ts
CHANGED
|
@@ -49,11 +49,26 @@ export function manifestIconPath(app: string): string {
|
|
|
49
49
|
return `/${MANIFEST_DIR}/${app}-icon.png`;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
/** The app page URL that runs `app` (matches `appPath` in src/landing.ts). */
|
|
52
|
+
/** The app page URL that runs `app` in the `landing` layout (matches `appPath` in src/landing.ts). */
|
|
53
53
|
export function appStartUrl(app: string): string {
|
|
54
54
|
return `/app/?app=${encodeURIComponent(app)}`;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
export interface ManifestOptions {
|
|
58
|
+
/**
|
|
59
|
+
* Where the app starts, and the manifest's scope (its directory): the app
|
|
60
|
+
* page with `?app=` by default; `/` in a site that is the one app
|
|
61
|
+
* (plugins/site-layout.ts).
|
|
62
|
+
*/
|
|
63
|
+
startUrl?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The scope of a manifest starting at `start`: the directory of the URL's path. */
|
|
67
|
+
function scopeOf(start: string): string {
|
|
68
|
+
const pathname = start.split('?')[0] ?? '/';
|
|
69
|
+
return pathname.endsWith('/') ? pathname : pathname.slice(0, pathname.lastIndexOf('/') + 1) || '/';
|
|
70
|
+
}
|
|
71
|
+
|
|
57
72
|
/** Default description, for a spec without one. */
|
|
58
73
|
export function defaultDescription(spec: ManifestSpec): string {
|
|
59
74
|
return `The ${displayNameOf(spec)} app, compiled to WebAssembly and rendered in the browser.`;
|
|
@@ -74,10 +89,10 @@ function iconSizes(file: string): string {
|
|
|
74
89
|
* and the page draws its own background under it, and the light/dark
|
|
75
90
|
* `theme-color` metas in app/index.html cover the browser UI.
|
|
76
91
|
*/
|
|
77
|
-
export function appManifest(spec: ManifestSpec | string): AppManifest {
|
|
92
|
+
export function appManifest(spec: ManifestSpec | string, options: ManifestOptions = {}): AppManifest {
|
|
78
93
|
const app: ManifestSpec = typeof spec === 'string' ? { name: spec } : spec;
|
|
79
94
|
if (!APP_NAME.test(app.name)) throw new Error(`Invalid app name: ${JSON.stringify(app.name)}`);
|
|
80
|
-
const start = appStartUrl(app.name);
|
|
95
|
+
const start = options.startUrl ?? appStartUrl(app.name);
|
|
81
96
|
const displayName = displayNameOf(app);
|
|
82
97
|
return {
|
|
83
98
|
id: start,
|
|
@@ -85,7 +100,7 @@ export function appManifest(spec: ManifestSpec | string): AppManifest {
|
|
|
85
100
|
short_name: displayName,
|
|
86
101
|
description: app.description ?? defaultDescription(app),
|
|
87
102
|
start_url: start,
|
|
88
|
-
scope:
|
|
103
|
+
scope: scopeOf(start),
|
|
89
104
|
display: 'standalone',
|
|
90
105
|
orientation: 'portrait',
|
|
91
106
|
background_color: '#000000',
|
|
@@ -98,8 +113,8 @@ export function appManifest(spec: ManifestSpec | string): AppManifest {
|
|
|
98
113
|
};
|
|
99
114
|
}
|
|
100
115
|
|
|
101
|
-
export function appManifestJson(spec: ManifestSpec | string): string {
|
|
102
|
-
return JSON.stringify(appManifest(spec), null, 2) + '\n';
|
|
116
|
+
export function appManifestJson(spec: ManifestSpec | string, options: ManifestOptions = {}): string {
|
|
117
|
+
return JSON.stringify(appManifest(spec, options), null, 2) + '\n';
|
|
103
118
|
}
|
|
104
119
|
|
|
105
120
|
/** The app named by a manifest request URL, or null for any other URL. */
|
|
@@ -121,7 +136,11 @@ export function appIconUrls(specs: readonly ManifestSpec[]): Record<string, stri
|
|
|
121
136
|
return icons;
|
|
122
137
|
}
|
|
123
138
|
|
|
124
|
-
export function appManifests(
|
|
139
|
+
export function appManifests(
|
|
140
|
+
specs: readonly ManifestSpec[],
|
|
141
|
+
included: readonly { name: string; icon?: string }[] = [],
|
|
142
|
+
options: ManifestOptions = {},
|
|
143
|
+
): Plugin {
|
|
125
144
|
const byName = new Map(specs.map((spec) => [spec.name, spec]));
|
|
126
145
|
// The apps of included sites (plugins/included-sites.ts) bring their icons.
|
|
127
146
|
const icons = { ...appIconUrls(specs), ...Object.fromEntries(included.flatMap((app) => (app.icon ? [[app.name, app.icon]] : []))) };
|
|
@@ -142,7 +161,7 @@ export function appManifests(specs: readonly ManifestSpec[], included: readonly
|
|
|
142
161
|
}
|
|
143
162
|
res.setHeader('Content-Type', 'application/manifest+json');
|
|
144
163
|
res.setHeader('Cache-Control', 'no-cache');
|
|
145
|
-
res.end(appManifestJson(spec));
|
|
164
|
+
res.end(appManifestJson(spec, options));
|
|
146
165
|
return;
|
|
147
166
|
}
|
|
148
167
|
const iconApp = iconRequestApp(url);
|
|
@@ -174,7 +193,7 @@ export function appManifests(specs: readonly ManifestSpec[], included: readonly
|
|
|
174
193
|
this.emitFile({
|
|
175
194
|
type: 'asset',
|
|
176
195
|
fileName: `${MANIFEST_DIR}/${spec.name}.webmanifest`,
|
|
177
|
-
source: appManifestJson(spec),
|
|
196
|
+
source: appManifestJson(spec, options),
|
|
178
197
|
});
|
|
179
198
|
if (spec.icon) {
|
|
180
199
|
if (!existsSync(spec.icon)) {
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The site's layout, and what the app page knows about the site it is in.
|
|
3
|
+
*
|
|
4
|
+
* A site holds one app or several (node/config.ts, `siteLayout`):
|
|
5
|
+
*
|
|
6
|
+
* - `app`: one app, the site is that app. The app page is `/` (the dev server
|
|
7
|
+
* serves it there; a build writes it as `index.html` and nothing at `/app/`),
|
|
8
|
+
* there is no landing page, and the site's `/manifest.webmanifest` is the
|
|
9
|
+
* app's own, starting at `/`.
|
|
10
|
+
* - `landing`: the landing page at `/` lists the apps, each at `/app/?app=<name>`.
|
|
11
|
+
*
|
|
12
|
+
* Both layouts give the app page `window.__SB_SITE__` (the layout, the app
|
|
13
|
+
* the page runs without `?app=`, its display name, and the page mode a wide
|
|
14
|
+
* screen gets), which the inline scripts of app/index.html read before the
|
|
15
|
+
* first paint; src/main.ts gets the same through `define`s.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { existsSync, mkdirSync, renameSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import type { Plugin, ResolvedConfig } from 'vite';
|
|
21
|
+
import { appManifestJson, type ManifestSpec } from './app-manifests.js';
|
|
22
|
+
|
|
23
|
+
export type SiteLayout = 'app' | 'landing';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* What the app page shows on a wide screen (a desktop browser): `auto` is the
|
|
27
|
+
* iPhone frame with the dev controls, device mode on a touch screen; `device`
|
|
28
|
+
* fills the window everywhere, as a deployed app should (src/deviceMode.ts).
|
|
29
|
+
* `?mode=` on the URL overrides either.
|
|
30
|
+
*/
|
|
31
|
+
export type SitePageMode = 'auto' | 'device';
|
|
32
|
+
|
|
33
|
+
/** `window.__SB_SITE__` on the app page. */
|
|
34
|
+
export interface SiteInfo {
|
|
35
|
+
layout: SiteLayout;
|
|
36
|
+
/** The app the page runs when the URL names none. */
|
|
37
|
+
defaultApp: string;
|
|
38
|
+
/** Its display name (the document title in the `app` layout). */
|
|
39
|
+
displayName: string;
|
|
40
|
+
pageMode: SitePageMode;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The manifest an `app` layout site serves at its root, and the start URL its pages use. */
|
|
44
|
+
export const ROOT_START_URL = '/';
|
|
45
|
+
|
|
46
|
+
const isAppPage = (pathname: string): boolean => /(^|\/)app\/(index\.html)?$/.test(pathname.split('?')[0] ?? '');
|
|
47
|
+
|
|
48
|
+
/** The `pathname?query` of a request to the app page that serves `url` in the `app` layout, or null. */
|
|
49
|
+
export function rewriteRootRequest(url: string): string | null {
|
|
50
|
+
const [pathname = '', query] = url.split('?', 2);
|
|
51
|
+
if (pathname !== '/' && pathname !== '/index.html') return null;
|
|
52
|
+
return `/app/${query !== undefined ? `?${query}` : ''}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function siteLayoutPlugin(info: SiteInfo, app: ManifestSpec | undefined): Plugin {
|
|
56
|
+
const single = info.layout === 'app' ? app : undefined;
|
|
57
|
+
const rootManifest = single ? appManifestJson(single, { startUrl: ROOT_START_URL }) : null;
|
|
58
|
+
let outDir = '';
|
|
59
|
+
return {
|
|
60
|
+
name: 'swiftbrowser:site-layout',
|
|
61
|
+
configResolved(config: ResolvedConfig) {
|
|
62
|
+
outDir = path.resolve(config.root, config.build.outDir);
|
|
63
|
+
},
|
|
64
|
+
configureServer(server) {
|
|
65
|
+
if (!rootManifest) return;
|
|
66
|
+
// Before Vite's own middlewares: `/` is the app page, and the root
|
|
67
|
+
// manifest is the app's (over the one in public/).
|
|
68
|
+
server.middlewares.use((req, res, next) => {
|
|
69
|
+
const url = req.url ?? '';
|
|
70
|
+
if (url.split('?')[0] === '/manifest.webmanifest') {
|
|
71
|
+
res.setHeader('Content-Type', 'application/manifest+json');
|
|
72
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
73
|
+
res.end(rootManifest);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const rewritten = rewriteRootRequest(url);
|
|
77
|
+
if (rewritten !== null) req.url = rewritten;
|
|
78
|
+
next();
|
|
79
|
+
});
|
|
80
|
+
},
|
|
81
|
+
transformIndexHtml(_html, ctx) {
|
|
82
|
+
if (!isAppPage(ctx.path)) return;
|
|
83
|
+
return [{ tag: 'script', injectTo: 'head-prepend', children: `window.__SB_SITE__ = ${JSON.stringify(info)};` }];
|
|
84
|
+
},
|
|
85
|
+
closeBundle() {
|
|
86
|
+
if (!rootManifest) return;
|
|
87
|
+
// After everything is written (Vite's bundle, then public/): the app
|
|
88
|
+
// page becomes the site's root page. `app/` stays for the OAuth callback
|
|
89
|
+
// page public/ puts there (src/webAuth.ts).
|
|
90
|
+
const appPage = path.join(outDir, 'app', 'index.html');
|
|
91
|
+
if (existsSync(appPage)) renameSync(appPage, path.join(outDir, 'index.html'));
|
|
92
|
+
mkdirSync(outDir, { recursive: true });
|
|
93
|
+
writeFileSync(path.join(outDir, 'manifest.webmanifest'), rootManifest);
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
package/src/animation.ts
CHANGED
|
@@ -25,6 +25,12 @@
|
|
|
25
25
|
*
|
|
26
26
|
* `Animation` maps to CSS easings; `spring` becomes a `linear()` easing
|
|
27
27
|
* sampled from a damped spring where supported.
|
|
28
|
+
*
|
|
29
|
+
* iOS 26's bar toolbars animate on their own: when an item comes or goes,
|
|
30
|
+
* or an item resizes, the glass capsule grows or shrinks with a spring and
|
|
31
|
+
* the items slide, the new one scaling and fading in, the old one out, in a
|
|
32
|
+
* pass nothing else animates (`implicitToolbars`). The commit's animation,
|
|
33
|
+
* or an `.animation` on the toolbar, wins when there is one.
|
|
28
34
|
*/
|
|
29
35
|
|
|
30
36
|
import type { Animation, Edge, Transition, TransitionPart } from './protocol';
|
|
@@ -278,6 +284,68 @@ function springPosition(t: number, omega: number, zeta: number): number {
|
|
|
278
284
|
/** SwiftUI's default transition for views inserted or removed with animation. */
|
|
279
285
|
export const DEFAULT_TRANSITION: Transition = { kind: 'opacity' };
|
|
280
286
|
|
|
287
|
+
/** The spring a bar toolbar's groups change with (iOS 26), and how its items come and go. */
|
|
288
|
+
export const TOOLBAR_ANIMATION: Animation = { type: 'spring', duration: 0.5, bounce: 0.2 };
|
|
289
|
+
export const TOOLBAR_TRANSITION: Transition = { kind: 'scale', scale: 0.6 };
|
|
290
|
+
/** The `kind` of a bottom bar glass capsule's frame change (src/layoutDom.ts): chrome, not an element. */
|
|
291
|
+
export const BOTTOM_GLASS_KIND = 'bottomglass';
|
|
292
|
+
|
|
293
|
+
const BAR_PLACEMENTS: ReadonlySet<string> = new Set(['leading', 'trailing', 'principal', 'bottom']);
|
|
294
|
+
|
|
295
|
+
/** Whether `el` is a toolbar group in a navigation bar or bottom bar (not a `ToolbarSpacer`). */
|
|
296
|
+
export function isBarToolbar(el: Element | null | undefined): el is HTMLElement {
|
|
297
|
+
return (
|
|
298
|
+
el instanceof HTMLElement &&
|
|
299
|
+
el.classList.contains('sb-toolbar') &&
|
|
300
|
+
!el.hasAttribute('data-sb-spacer') &&
|
|
301
|
+
BAR_PLACEMENTS.has(el.dataset['sbPlacement'] ?? '')
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** The bar toolbar `el` is, or is an item of (`parent`: where a removed item was). */
|
|
306
|
+
export function barToolbarOf(el: HTMLElement, parent: Element | null = el.parentElement): HTMLElement | null {
|
|
307
|
+
if (isBarToolbar(el)) return el;
|
|
308
|
+
return isBarToolbar(parent) ? parent : null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** The transition a view without one of its own enters and leaves with: a bar item's scales. */
|
|
312
|
+
function implicitTransitionOf(el: HTMLElement, parent: Element | null = el.parentElement): Transition | undefined {
|
|
313
|
+
return barToolbarOf(el, parent) ? TOOLBAR_TRANSITION : undefined;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* The bar toolbars whose groups this pass changed, each with its screen: one
|
|
318
|
+
* that gained or lost an item (or came or went itself, on a screen already
|
|
319
|
+
* there), or whose capsule or an item of it resized. A group that only
|
|
320
|
+
* moved (the screen resized, a More button appeared beside it) is not one:
|
|
321
|
+
* its items are as they were.
|
|
322
|
+
*/
|
|
323
|
+
export function implicitToolbars(pass: PassRecord, frames: readonly FrameChange[]): Map<HTMLElement, Element | null> {
|
|
324
|
+
const out = new Map<HTMLElement, Element | null>();
|
|
325
|
+
const isNew = (toolbar: HTMLElement): boolean => pass.inserted.has(toolbar) || hasAncestorIn(toolbar, pass.inserted);
|
|
326
|
+
for (const change of frames) {
|
|
327
|
+
const toolbar = barToolbarOf(change.el);
|
|
328
|
+
if (!toolbar || !change.from || !change.el.isConnected || isNew(toolbar)) continue;
|
|
329
|
+
const { from, to } = change;
|
|
330
|
+
if (change.el === toolbar && from.width === to.width && from.height === to.height) continue;
|
|
331
|
+
out.set(toolbar, toolbar.closest('.sb-navscreen'));
|
|
332
|
+
}
|
|
333
|
+
for (const el of pass.inserted) {
|
|
334
|
+
const toolbar = barToolbarOf(el);
|
|
335
|
+
if (!toolbar || !el.isConnected || hasAncestorIn(toolbar, pass.inserted)) continue;
|
|
336
|
+
// A whole new group enters as one; an item of a group already there is the group's change.
|
|
337
|
+
if (el !== toolbar && pass.inserted.has(toolbar)) continue;
|
|
338
|
+
out.set(toolbar, toolbar.closest('.sb-navscreen'));
|
|
339
|
+
}
|
|
340
|
+
for (const rec of pass.removed) {
|
|
341
|
+
const toolbar = barToolbarOf(rec.el, rec.ancestors[0] ?? null);
|
|
342
|
+
if (!toolbar) continue;
|
|
343
|
+
// A removed group is detached: its screen is among the ancestors it had.
|
|
344
|
+
out.set(toolbar, toolbar.isConnected ? toolbar.closest('.sb-navscreen') : (rec.ancestors.find((a) => a.classList.contains('sb-navscreen')) ?? null));
|
|
345
|
+
}
|
|
346
|
+
return out;
|
|
347
|
+
}
|
|
348
|
+
|
|
281
349
|
interface TransitionFrame {
|
|
282
350
|
opacity?: number;
|
|
283
351
|
transform?: string;
|
|
@@ -426,6 +494,14 @@ export class Animator {
|
|
|
426
494
|
for (const [el, rec] of pass.updated) {
|
|
427
495
|
if (rec.tokenChanged) tokenRoots.set(el, rec.implicit ? commitAnimation : rec.animation);
|
|
428
496
|
}
|
|
497
|
+
// iOS 26: a bar toolbar's groups change with a spring of their own (see
|
|
498
|
+
// the top of the file); the bottom bar's glass capsules, beside their
|
|
499
|
+
// toolbars rather than around them, follow the screen's bottom toolbars.
|
|
500
|
+
const glassScreens = new Set<Element | null>();
|
|
501
|
+
for (const [toolbar, screen] of implicitToolbars(pass, frames)) {
|
|
502
|
+
if (!tokenRoots.has(toolbar)) tokenRoots.set(toolbar, commitAnimation ?? TOOLBAR_ANIMATION);
|
|
503
|
+
if (toolbar.dataset['sbPlacement'] === 'bottom') glassScreens.add(screen);
|
|
504
|
+
}
|
|
429
505
|
if (!commitAnimation && tokenRoots.size === 0) {
|
|
430
506
|
// Not animated: frames still tweening toward a box this pass replaced
|
|
431
507
|
// carry on to the new box in the time they had left.
|
|
@@ -472,7 +548,10 @@ export class Animator {
|
|
|
472
548
|
const { el, from, to } = change;
|
|
473
549
|
if (!from || !el.isConnected || pass.inserted.has(el)) continue;
|
|
474
550
|
if (this.selfAnimating.has(change.kind)) continue;
|
|
475
|
-
|
|
551
|
+
let animation = animationFor(ancestorsAndSelf(el));
|
|
552
|
+
if (animation === false && change.kind === BOTTOM_GLASS_KIND && glassScreens.has(el.parentElement)) {
|
|
553
|
+
animation = TOOLBAR_ANIMATION;
|
|
554
|
+
}
|
|
476
555
|
if (animation === false) {
|
|
477
556
|
// Moved in place, unless it carries on with a tween of its own.
|
|
478
557
|
if (!this.retargetFrame(change, null)) jumped.set(el, { x: to.x - from.x, y: to.y - from.y });
|
|
@@ -524,7 +603,7 @@ export class Animator {
|
|
|
524
603
|
if (this.selfAnimating.has(el.dataset['sbKind'] ?? '')) continue;
|
|
525
604
|
const animation = animationFor(ancestorsAndSelf(el));
|
|
526
605
|
if (animation === false) continue;
|
|
527
|
-
this.enter(el, transitionOf(el), animation);
|
|
606
|
+
this.enter(el, transitionOf(el) ?? implicitTransitionOf(el), animation);
|
|
528
607
|
}
|
|
529
608
|
|
|
530
609
|
// Content transitions: the old text leaves as the new one arrives.
|
|
@@ -785,10 +864,14 @@ export class Animator {
|
|
|
785
864
|
* play its transition backwards; the clone is dropped when done.
|
|
786
865
|
*/
|
|
787
866
|
exit(rec: RemovedRecord, animation: Animation | undefined): HTMLElement | null {
|
|
788
|
-
const
|
|
867
|
+
const parent = rec.ancestors[0] ?? null;
|
|
868
|
+
const end = transitionStart(rec.transition ?? implicitTransitionOf(rec.el, parent), true);
|
|
789
869
|
if (isIdentity(end) || !rec.rect) return null;
|
|
790
870
|
const clone = ghostClone(rec.el);
|
|
791
871
|
clone.classList.add(EXIT_CLONE_CLASS);
|
|
872
|
+
// A bar item's clone leaves its toolbar: the stylesheet draws it as the
|
|
873
|
+
// toolbar did (the symbol's size and weight, the label color).
|
|
874
|
+
if (rec.el !== parent && isBarToolbar(parent)) clone.dataset['sbBarItem'] = parent.dataset['sbPlacement'] ?? '';
|
|
792
875
|
clone.setAttribute('aria-hidden', 'true');
|
|
793
876
|
clone.toggleAttribute('inert', true);
|
|
794
877
|
const s = clone.style;
|
package/src/deviceMode.ts
CHANGED
|
@@ -9,7 +9,10 @@
|
|
|
9
9
|
*
|
|
10
10
|
* `?mode=device` / `?mode=frame` force a mode; otherwise a coarse pointer
|
|
11
11
|
* means a touch device at any width (Phase 7: a tablet fills the viewport
|
|
12
|
-
* instead of showing the phone bezel; the old 500 px limit is gone)
|
|
12
|
+
* instead of showing the phone bezel; the old 500 px limit is gone), and a
|
|
13
|
+
* site built for a deployment (`__SB_PAGE_MODE__` of `device`, see
|
|
14
|
+
* plugins/site-layout.ts) is device mode on every screen: a desktop browser
|
|
15
|
+
* gets the app's regular-width (iPad) layout, not a phone in a bezel. The
|
|
13
16
|
* inline script in app/index.html applies the same rule before the first
|
|
14
17
|
* paint.
|
|
15
18
|
*/
|
|
@@ -18,15 +21,19 @@ import type { EdgeInsets } from './layout';
|
|
|
18
21
|
|
|
19
22
|
export type PageMode = 'frame' | 'device';
|
|
20
23
|
|
|
24
|
+
/** The site's default for a fine pointer: `auto` is the frame, `device` the full window. */
|
|
25
|
+
export type DefaultPageMode = 'auto' | 'device';
|
|
26
|
+
|
|
21
27
|
export function decidePageMode(
|
|
22
28
|
search: string = location.search,
|
|
23
29
|
win: Pick<Window, 'matchMedia'> = window,
|
|
30
|
+
defaultMode: DefaultPageMode = 'auto',
|
|
24
31
|
): PageMode {
|
|
25
32
|
const mode = new URLSearchParams(search).get('mode');
|
|
26
33
|
if (mode === 'device') return 'device';
|
|
27
34
|
if (mode === 'frame') return 'frame';
|
|
28
35
|
const coarse = typeof win.matchMedia === 'function' && win.matchMedia('(pointer: coarse)').matches;
|
|
29
|
-
return coarse ? 'device' : 'frame';
|
|
36
|
+
return coarse || defaultMode === 'device' ? 'device' : 'frame';
|
|
30
37
|
}
|
|
31
38
|
|
|
32
39
|
/** Mark the document so styles.css can switch the chrome. */
|
package/src/gaugeRing.ts
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* (the `ring` chrome rect). In a 100-unit box: a capacity gauge is a full
|
|
5
5
|
* track with the value filled clockwise from the top; an `accessoryCircular`
|
|
6
6
|
* gauge is an arc of 270° open at the bottom, filled from its start to the
|
|
7
|
-
* value. The
|
|
8
|
-
* the
|
|
7
|
+
* value. The value is the tint (`--sb-tint`, else the primary color), the
|
|
8
|
+
* track the same at 35%, with round caps (styles.css).
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
const SVG_NS = 'http://www.w3.org/2000/svg';
|