@fullstack-webapp/document-shell 0.0.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.
@@ -0,0 +1,226 @@
1
+ import { safeAreaCompatibilityProfiles, } from "./safe-area-profiles.js";
2
+ function assertProfiles(profiles) {
3
+ const ids = new Set();
4
+ const signatures = new Set();
5
+ for (const profile of profiles) {
6
+ if (ids.has(profile.id))
7
+ throw new Error(`Duplicate safe-area profile id: ${profile.id}`);
8
+ ids.add(profile.id);
9
+ if (!Number.isInteger(profile.minimumRuntimeVersion[0]) ||
10
+ profile.minimumRuntimeVersion[0] < 0 ||
11
+ !Number.isInteger(profile.minimumRuntimeVersion[1]) ||
12
+ profile.minimumRuntimeVersion[1] < 0) {
13
+ throw new Error(`Safe-area profile ${profile.id} has an invalid minimum runtime version`);
14
+ }
15
+ if (profile.reserve.bottom <= 0) {
16
+ throw new Error(`Safe-area profile ${profile.id} must provide a positive bottom reserve`);
17
+ }
18
+ const signature = JSON.stringify({
19
+ platform: profile.platform,
20
+ displayMode: profile.displayMode,
21
+ orientation: profile.orientation,
22
+ screen: profile.screen,
23
+ devicePixelRatio: profile.devicePixelRatio,
24
+ });
25
+ if (signatures.has(signature)) {
26
+ throw new Error(`Safe-area profile ${profile.id} overlaps another runtime matcher`);
27
+ }
28
+ signatures.add(signature);
29
+ }
30
+ }
31
+ function assertDomEffect(domEffect) {
32
+ if (!domEffect.reserveBottomCssVariable.startsWith('--')) {
33
+ throw new Error('Safe-area DOM effect CSS variable must start with --');
34
+ }
35
+ for (const [label, name] of Object.entries({
36
+ profile: domEffect.profileAttribute,
37
+ orientation: domEffect.orientationAttribute,
38
+ reserve: domEffect.reserveAttribute,
39
+ })) {
40
+ if (name !== undefined && !name.startsWith('data-')) {
41
+ throw new Error(`Safe-area DOM effect ${label} attribute must start with data-`);
42
+ }
43
+ }
44
+ for (const [label, property] of Object.entries({
45
+ reserve: domEffect.windowStateProperty,
46
+ result: domEffect.resultStateProperty,
47
+ })) {
48
+ if (property !== undefined && !/^__[A-Za-z][A-Za-z0-9_]*$/.test(property)) {
49
+ throw new Error(`Safe-area DOM effect ${label} state property must use a private __name`);
50
+ }
51
+ }
52
+ }
53
+ function createSafeAreaBridgeForRollouts({ domEffect, diagnosticOverride, }, rollouts) {
54
+ const stableFrames = 2;
55
+ const timeoutMs = 3_000;
56
+ assertProfiles(safeAreaCompatibilityProfiles);
57
+ assertDomEffect(domEffect);
58
+ const runtimeProfiles = safeAreaCompatibilityProfiles
59
+ .filter((profile) => rollouts.has(profile.rollout))
60
+ .map((profile) => ({
61
+ id: profile.id,
62
+ platform: profile.platform,
63
+ minimumRuntimeVersion: profile.minimumRuntimeVersion,
64
+ displayMode: profile.displayMode,
65
+ orientation: profile.orientation,
66
+ screen: profile.screen,
67
+ devicePixelRatio: profile.devicePixelRatio,
68
+ reserve: profile.reserve,
69
+ }));
70
+ const diagnosticSelection = diagnosticOverride
71
+ ? `const diagnosticOverride = ${JSON.stringify({
72
+ enabledAttribute: diagnosticOverride.enabledAttribute,
73
+ enabledValue: diagnosticOverride.enabledValue ?? 'true',
74
+ queryParameter: diagnosticOverride.queryParameter,
75
+ bottom: diagnosticOverride.bottom,
76
+ })}
77
+ const diagnosticEnabled = document.documentElement.getAttribute(diagnosticOverride.enabledAttribute) === diagnosticOverride.enabledValue
78
+ const diagnosticBottom = Number(new URL(location.href).searchParams.get(diagnosticOverride.queryParameter))
79
+ const selectedProfile = diagnosticEnabled && diagnosticBottom === diagnosticOverride.bottom
80
+ ? { id: 'diagnostic-override', orientation, reserve: { bottom: diagnosticBottom } }
81
+ : matchedProfile`
82
+ : 'const selectedProfile = matchedProfile';
83
+ const bootstrap = `(() => {
84
+ const profiles = ${JSON.stringify(runtimeProfiles)}
85
+ const domEffect = ${JSON.stringify(domEffect)}
86
+ const applyDomUpdate = (update) => {
87
+ const root = document.documentElement
88
+ if (update.kind === 'reserve') {
89
+ if (domEffect.profileAttribute) root.setAttribute(domEffect.profileAttribute, update.profile)
90
+ if (domEffect.orientationAttribute) root.setAttribute(domEffect.orientationAttribute, update.orientation)
91
+ if (domEffect.reserveAttribute) root.setAttribute(domEffect.reserveAttribute, String(update.bottom))
92
+ root.style.setProperty(domEffect.reserveBottomCssVariable, update.bottom + 'px')
93
+ if (domEffect.windowStateProperty) {
94
+ window[domEffect.windowStateProperty] = {
95
+ profile: update.profile,
96
+ orientation: update.orientation,
97
+ bottom: update.bottom,
98
+ }
99
+ }
100
+ return
101
+ }
102
+ if (domEffect.reserveAttribute) root.removeAttribute(domEffect.reserveAttribute)
103
+ root.style.removeProperty(domEffect.reserveBottomCssVariable)
104
+ if (domEffect.windowStateProperty) window[domEffect.windowStateProperty] = undefined
105
+ if (update.reason === 'orientation-changed') {
106
+ if (domEffect.profileAttribute) root.removeAttribute(domEffect.profileAttribute)
107
+ if (domEffect.orientationAttribute) root.removeAttribute(domEffect.orientationAttribute)
108
+ }
109
+ }
110
+ const platform = /iPhone|iPad|iPod/.test(navigator.userAgent)
111
+ ? 'ios'
112
+ : /Android/.test(navigator.userAgent)
113
+ ? 'android'
114
+ : 'unknown'
115
+ const versionMatch = platform === 'ios'
116
+ ? navigator.userAgent.match(/Version[/](\\d+)[.](\\d+)/)
117
+ : undefined
118
+ const runtimeVersion = versionMatch ? [Number(versionMatch[1]), Number(versionMatch[2])] : undefined
119
+ const standalone = matchMedia('(display-mode: standalone)').matches || navigator.standalone === true
120
+ const orientation = matchMedia('(orientation: portrait)').matches ? 'portrait' : 'landscape'
121
+ const versionAtLeast = (version, minimumVersion) => {
122
+ if (!version) return false
123
+ const value = version[0] * 1000 + version[1]
124
+ const minimum = minimumVersion[0] * 1000 + minimumVersion[1]
125
+ return value >= minimum
126
+ }
127
+ const matchedProfile = profiles.find((profile) =>
128
+ profile.platform === platform &&
129
+ versionAtLeast(runtimeVersion, profile.minimumRuntimeVersion) &&
130
+ profile.displayMode === (standalone ? 'standalone' : 'browser') &&
131
+ profile.orientation === orientation &&
132
+ profile.screen.width === screen.width &&
133
+ profile.screen.height === screen.height &&
134
+ profile.devicePixelRatio === devicePixelRatio
135
+ )
136
+ ${diagnosticSelection}
137
+ if (!selectedProfile) {
138
+ if (domEffect.resultStateProperty) {
139
+ window[domEffect.resultStateProperty] = { status: 'inactive' }
140
+ }
141
+ return
142
+ }
143
+
144
+ const bottom = selectedProfile.reserve.bottom
145
+ const reserveUpdate = {
146
+ kind: 'reserve',
147
+ profile: selectedProfile.id,
148
+ orientation: selectedProfile.orientation,
149
+ bottom,
150
+ }
151
+ applyDomUpdate(reserveUpdate)
152
+
153
+ let previousViewport
154
+ let stableViewportFrames = 0
155
+ let finished = false
156
+ let nativeBottom = 0
157
+ let timeoutHandle
158
+ const reportResult = (status, reason, nativeBottom) => {
159
+ if (!domEffect.resultStateProperty) return
160
+ window[domEffect.resultStateProperty] = {
161
+ profile: selectedProfile.id,
162
+ status,
163
+ reason,
164
+ nativeBottom,
165
+ bottom,
166
+ }
167
+ }
168
+ const stopOrientationWatch = () => {
169
+ window.removeEventListener('orientationchange', releaseForOrientationChange)
170
+ window.removeEventListener('resize', releaseForOrientationChange)
171
+ }
172
+ const releaseForOrientationChange = () => {
173
+ if (matchMedia('(orientation: portrait)').matches === (orientation === 'portrait')) return
174
+ applyDomUpdate({ ...reserveUpdate, kind: 'release', reason: 'orientation-changed' })
175
+ clearTimeout(timeoutHandle)
176
+ finished = true
177
+ stopOrientationWatch()
178
+ reportResult('released', 'orientation-changed', nativeBottom)
179
+ }
180
+ const finish = (status, reason, nativeBottom, keepOrientationWatch = false) => {
181
+ if (finished) return
182
+ finished = true
183
+ clearTimeout(timeoutHandle)
184
+ if (!keepOrientationWatch) stopOrientationWatch()
185
+ reportResult(status, reason, nativeBottom)
186
+ }
187
+ window.addEventListener('orientationchange', releaseForOrientationChange)
188
+ window.addEventListener('resize', releaseForOrientationChange)
189
+ timeoutHandle = setTimeout(() => {
190
+ finish('unresolved', 'timeout', nativeBottom, true)
191
+ }, ${timeoutMs})
192
+ const sample = () => {
193
+ if (finished) return
194
+ if (matchMedia('(orientation: portrait)').matches !== (orientation === 'portrait')) {
195
+ releaseForOrientationChange()
196
+ return
197
+ }
198
+ if (document.visibilityState !== 'visible') {
199
+ requestAnimationFrame(sample)
200
+ return
201
+ }
202
+ const probe = document.querySelector('[data-document-shell-native-safe-area]')
203
+ nativeBottom = probe ? Number.parseFloat(getComputedStyle(probe).paddingBottom) || 0 : 0
204
+ const viewport = [nativeBottom, innerWidth, innerHeight, window.visualViewport?.width ?? 0, window.visualViewport?.height ?? 0, window.visualViewport?.offsetTop ?? 0].join(':')
205
+ stableViewportFrames = viewport === previousViewport ? stableViewportFrames + 1 : 1
206
+ previousViewport = viewport
207
+ if (nativeBottom >= bottom && stableViewportFrames >= ${stableFrames}) {
208
+ applyDomUpdate({ ...reserveUpdate, kind: 'release', reason: 'native-stable' })
209
+ finish('released', 'native-stable', nativeBottom)
210
+ return
211
+ }
212
+ requestAnimationFrame(sample)
213
+ }
214
+ requestAnimationFrame(sample)
215
+ })()`;
216
+ return {
217
+ beforePaint: bootstrap,
218
+ probeHtml: ' <div data-document-shell-native-safe-area aria-hidden="true" style="position:fixed;visibility:hidden;pointer-events:none;padding-bottom:env(safe-area-inset-bottom)"></div>',
219
+ };
220
+ }
221
+ export function createSafeAreaBridge(options) {
222
+ return createSafeAreaBridgeForRollouts(options, new Set(['sharedDefault']));
223
+ }
224
+ export function createReferenceSafeAreaBridge(options) {
225
+ return createSafeAreaBridgeForRollouts(options, new Set(['sharedDefault', 'referenceProduction']));
226
+ }
@@ -0,0 +1,50 @@
1
+ export type SafeAreaCompatibilityProfile = {
2
+ id: string;
3
+ platform: 'ios' | 'android';
4
+ minimumRuntimeVersion: readonly [major: number, minor: number];
5
+ displayMode: 'standalone' | 'browser';
6
+ orientation: 'portrait' | 'landscape';
7
+ screen: {
8
+ width: number;
9
+ height: number;
10
+ };
11
+ devicePixelRatio: number;
12
+ reserve: {
13
+ bottom: number;
14
+ };
15
+ maturity: 'candidate' | 'provisional' | 'verified' | 'established';
16
+ rollout: 'probe' | 'referenceProduction' | 'consumerOptIn' | 'sharedDefault';
17
+ };
18
+ export declare const safeAreaCompatibilityProfiles: ({
19
+ id: string;
20
+ platform: "ios";
21
+ minimumRuntimeVersion: [number, number];
22
+ displayMode: "standalone";
23
+ orientation: "portrait";
24
+ screen: {
25
+ width: number;
26
+ height: number;
27
+ };
28
+ devicePixelRatio: number;
29
+ reserve: {
30
+ bottom: number;
31
+ };
32
+ maturity: "provisional";
33
+ rollout: "referenceProduction";
34
+ } | {
35
+ id: string;
36
+ platform: "ios";
37
+ minimumRuntimeVersion: [number, number];
38
+ displayMode: "standalone";
39
+ orientation: "portrait";
40
+ screen: {
41
+ width: number;
42
+ height: number;
43
+ };
44
+ devicePixelRatio: number;
45
+ reserve: {
46
+ bottom: number;
47
+ };
48
+ maturity: "verified";
49
+ rollout: "referenceProduction";
50
+ })[];
@@ -0,0 +1,57 @@
1
+ // This private catalog is package-owned. The public root projects only
2
+ // sharedDefault entries; the explicit reference subpath additionally projects
3
+ // referenceProduction entries for the source application that supplied their
4
+ // current evidence. Consumers do not select or redefine individual profiles.
5
+ export const safeAreaCompatibilityProfiles = [
6
+ // Provisional entries come from the iOS 26.5 Simulator S2-S5 matrix recorded
7
+ // in Base's Document Shell platform-extraction plan. The verified reference
8
+ // entry additionally has repeated real-device video + trace evidence.
9
+ {
10
+ id: 'ios-375x812-3x-portrait-standalone',
11
+ platform: 'ios',
12
+ minimumRuntimeVersion: [26, 0],
13
+ displayMode: 'standalone',
14
+ orientation: 'portrait',
15
+ screen: { width: 375, height: 812 },
16
+ devicePixelRatio: 3,
17
+ reserve: { bottom: 34 },
18
+ maturity: 'provisional',
19
+ rollout: 'referenceProduction',
20
+ },
21
+ {
22
+ id: 'ios-393x852-3x-portrait-standalone',
23
+ platform: 'ios',
24
+ minimumRuntimeVersion: [26, 0],
25
+ displayMode: 'standalone',
26
+ orientation: 'portrait',
27
+ screen: { width: 393, height: 852 },
28
+ devicePixelRatio: 3,
29
+ reserve: { bottom: 34 },
30
+ maturity: 'provisional',
31
+ rollout: 'referenceProduction',
32
+ },
33
+ {
34
+ id: 'ios-402x874-3x-portrait-standalone',
35
+ platform: 'ios',
36
+ minimumRuntimeVersion: [26, 0],
37
+ displayMode: 'standalone',
38
+ orientation: 'portrait',
39
+ screen: { width: 402, height: 874 },
40
+ devicePixelRatio: 3,
41
+ reserve: { bottom: 34 },
42
+ maturity: 'verified',
43
+ rollout: 'referenceProduction',
44
+ },
45
+ {
46
+ id: 'ios-430x932-3x-portrait-standalone',
47
+ platform: 'ios',
48
+ minimumRuntimeVersion: [26, 0],
49
+ displayMode: 'standalone',
50
+ orientation: 'portrait',
51
+ screen: { width: 430, height: 932 },
52
+ devicePixelRatio: 3,
53
+ reserve: { bottom: 34 },
54
+ maturity: 'provisional',
55
+ rollout: 'referenceProduction',
56
+ },
57
+ ];
package/dist/vite.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ import type { Plugin } from 'vite';
2
+ import { type DocumentShellBuildContext, type DocumentShellComposition } from './document-shell.ts';
3
+ export type DocumentShellPluginOptions = {
4
+ render: (context: DocumentShellBuildContext) => DocumentShellComposition | Promise<DocumentShellComposition>;
5
+ validateFinalDocument?: (html: string, context: DocumentShellBuildContext) => void;
6
+ runtimeHandoff?: boolean;
7
+ };
8
+ export declare function documentShell(options: DocumentShellPluginOptions): Plugin[];
package/dist/vite.js ADDED
@@ -0,0 +1,86 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { compileDocumentShell, locateSingleStylesheetLink, validateCompiledDocumentShell, validateDocumentShellTemplate, validateRuntimeHandoffDocument, } from "./document-shell.js";
3
+ import { documentShellRuntimeStylesheetId, } from "./client.js";
4
+ function readIndexAsset(bundle) {
5
+ const indexAsset = Object.values(bundle).find((entry) => typeof entry === 'object' &&
6
+ entry !== null &&
7
+ 'type' in entry &&
8
+ entry.type === 'asset' &&
9
+ 'fileName' in entry &&
10
+ entry.fileName === 'index.html');
11
+ if (!indexAsset)
12
+ throw new Error('Document shell could not find emitted index.html');
13
+ return indexAsset;
14
+ }
15
+ function escapeHtmlAttribute(value) {
16
+ return value
17
+ .replaceAll('&', '&amp;')
18
+ .replaceAll('"', '&quot;')
19
+ .replaceAll('<', '&lt;')
20
+ .replaceAll('>', '&gt;');
21
+ }
22
+ function deferRuntimeStylesheet() {
23
+ return {
24
+ name: 'document-shell:defer-runtime-stylesheet',
25
+ apply: 'build',
26
+ enforce: 'post',
27
+ transformIndexHtml(html) {
28
+ const stylesheet = locateSingleStylesheetLink(html);
29
+ const href = stylesheet.attributes.href;
30
+ if (!href)
31
+ throw new Error('Emitted runtime stylesheet is missing an href');
32
+ const replacedAttributes = new Set(['id', 'rel', 'as', 'href', 'onload', 'onerror']);
33
+ const forwardedAttributes = Object.entries(stylesheet.attributes)
34
+ .filter(([name]) => !replacedAttributes.has(name))
35
+ .map(([name, value]) => value === '' ? ` ${name}` : ` ${name}="${escapeHtmlAttribute(value)}"`)
36
+ .join('');
37
+ const bootstrap = `<script data-document-shell-runtime-stylesheet="true">(()=>{const stylesheet=document.getElementById('${documentShellRuntimeStylesheetId}');if(!stylesheet)return;const emit=(state,detail)=>document.dispatchEvent(new CustomEvent('document-shell:runtime-stylesheet',{detail:{state,detail}}));const markFailure=(failure)=>{if(stylesheet.dataset.failure===failure)return;stylesheet.dataset.failure=failure;emit('failed',failure)};const deadline=Date.now()+3000;stylesheet.dataset.failureDeadline=String(deadline);const timer=window.setTimeout(()=>{if(stylesheet.dataset.loaded!=='true')markFailure('timeout')},Math.max(0,deadline-Date.now()));stylesheet.addEventListener('load',()=>{window.clearTimeout(timer);stylesheet.dataset.loaded='true';stylesheet.rel='stylesheet';emit('loaded')},{once:true});stylesheet.addEventListener('error',()=>{window.clearTimeout(timer);markFailure('error')},{once:true})})()</script>`;
38
+ const deferredLink = `<link id="${documentShellRuntimeStylesheetId}" rel="preload" as="style"${forwardedAttributes} href="${escapeHtmlAttribute(href)}">${bootstrap}`;
39
+ return `${html.slice(0, stylesheet.startOffset)}${deferredLink}${html.slice(stylesheet.endOffset)}`;
40
+ },
41
+ };
42
+ }
43
+ export function documentShell(options) {
44
+ let resolvedDocument;
45
+ let command = 'build';
46
+ let mode = 'production';
47
+ const producer = {
48
+ name: 'document-shell:compile',
49
+ enforce: 'pre',
50
+ configResolved(config) {
51
+ command = config.command;
52
+ mode = config.mode;
53
+ },
54
+ transformIndexHtml: {
55
+ order: 'pre',
56
+ async handler(template) {
57
+ const buildContext = { command, mode };
58
+ const composition = await options.render(buildContext);
59
+ validateDocumentShellTemplate(template, composition.document.appEntry);
60
+ resolvedDocument = composition.document;
61
+ return compileDocumentShell(composition);
62
+ },
63
+ },
64
+ };
65
+ const finalGate = {
66
+ name: 'document-shell:final-artifact-gate',
67
+ apply: 'build',
68
+ enforce: 'post',
69
+ generateBundle(_outputOptions, bundle) {
70
+ if (!resolvedDocument) {
71
+ throw new Error('Document shell final gate ran before the document compiler');
72
+ }
73
+ const indexAsset = readIndexAsset(bundle);
74
+ const html = typeof indexAsset.source === 'string'
75
+ ? indexAsset.source
76
+ : Buffer.from(indexAsset.source).toString('utf8');
77
+ validateCompiledDocumentShell(html, resolvedDocument, { transformedAppEntry: true });
78
+ if (options.runtimeHandoff)
79
+ validateRuntimeHandoffDocument(html);
80
+ options.validateFinalDocument?.(html, { command, mode });
81
+ },
82
+ };
83
+ return options.runtimeHandoff
84
+ ? [producer, deferRuntimeStylesheet(), finalGate]
85
+ : [producer, finalGate];
86
+ }