@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.
- package/LICENSE +21 -0
- package/README.md +232 -0
- package/dist/client.d.ts +8 -0
- package/dist/client.js +78 -0
- package/dist/document-shell.d.ts +58 -0
- package/dist/document-shell.js +181 -0
- package/dist/reference.d.ts +2 -0
- package/dist/reference.js +1 -0
- package/dist/safe-area-bridge.d.ts +38 -0
- package/dist/safe-area-bridge.js +226 -0
- package/dist/safe-area-profiles.d.ts +50 -0
- package/dist/safe-area-profiles.js +57 -0
- package/dist/vite.d.ts +8 -0
- package/dist/vite.js +86 -0
- package/docs/integration.md +340 -0
- package/package.json +68 -0
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
# Document Shell integration guide
|
|
2
|
+
|
|
3
|
+
This guide connects Document Shell to a Vite single-page application without
|
|
4
|
+
making the package the owner of application markup or framework lifecycle.
|
|
5
|
+
|
|
6
|
+
## Result
|
|
7
|
+
|
|
8
|
+
A correct integration has one continuous visual container:
|
|
9
|
+
|
|
10
|
+
1. the browser parses and paints the static document shell;
|
|
11
|
+
2. the application module and its extracted stylesheet load behind it;
|
|
12
|
+
3. the application commits a drawable runtime shell;
|
|
13
|
+
4. Document Shell reveals the runtime and removes the static projection once;
|
|
14
|
+
5. stylesheet failure or timeout still releases the static overlay.
|
|
15
|
+
|
|
16
|
+
The static and runtime shells must use the same geometry sources. The package
|
|
17
|
+
coordinates lifecycle; it cannot make two independently styled tab bars match.
|
|
18
|
+
|
|
19
|
+
## 1. Install and create the sentinel
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
pnpm add -D @fullstack-webapp/document-shell@beta
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Replace the checked-in `index.html` with only the Vite module entry and the
|
|
26
|
+
`data-document-shell-entry` marker:
|
|
27
|
+
|
|
28
|
+
```html
|
|
29
|
+
<!doctype html>
|
|
30
|
+
<script type="module" src="/src/main.tsx" data-document-shell-entry></script>
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The plugin rejects additional elements in this template. Move all head and
|
|
34
|
+
body contributions into the renderer instead of depending on placeholder
|
|
35
|
+
replacement or transform order between two document owners.
|
|
36
|
+
|
|
37
|
+
## 2. Build the composition
|
|
38
|
+
|
|
39
|
+
Create a build-only module such as `document-shell.config.ts`. Its one
|
|
40
|
+
`render(context)` function returns:
|
|
41
|
+
|
|
42
|
+
- `document`: language, title, head fragments, application entry, and mount ID;
|
|
43
|
+
- `shell`: inert HTML plus all CSS needed for the first paint; and
|
|
44
|
+
- optional `startupEffects`: parser-inline scripts before paint and inert HTML
|
|
45
|
+
probes after the static shell.
|
|
46
|
+
|
|
47
|
+
Use the branded constructors at trust boundaries:
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
import {
|
|
51
|
+
cssText,
|
|
52
|
+
htmlFragment,
|
|
53
|
+
inlineScript,
|
|
54
|
+
type DocumentShellComposition,
|
|
55
|
+
} from '@fullstack-webapp/document-shell'
|
|
56
|
+
|
|
57
|
+
const composition: DocumentShellComposition = {
|
|
58
|
+
document: {
|
|
59
|
+
lang: 'en',
|
|
60
|
+
title: 'Example',
|
|
61
|
+
head: [
|
|
62
|
+
htmlFragment(
|
|
63
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">',
|
|
64
|
+
),
|
|
65
|
+
htmlFragment('<link rel="manifest" href="/manifest.webmanifest">'),
|
|
66
|
+
],
|
|
67
|
+
appEntry: '/src/main.tsx',
|
|
68
|
+
mountId: 'root',
|
|
69
|
+
},
|
|
70
|
+
shell: {
|
|
71
|
+
html: htmlFragment(
|
|
72
|
+
'<div data-document-shell-static="true" aria-hidden="true">Loading</div>',
|
|
73
|
+
),
|
|
74
|
+
criticalCss: [cssText('[data-document-shell-static] { position: fixed; inset: 0; }')],
|
|
75
|
+
},
|
|
76
|
+
startupEffects: {
|
|
77
|
+
beforePaint: [
|
|
78
|
+
{
|
|
79
|
+
marker: 'data-example-theme-bootstrap',
|
|
80
|
+
script: inlineScript(
|
|
81
|
+
"document.documentElement.dataset.theme = localStorage.getItem('theme') ?? 'light'",
|
|
82
|
+
),
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Keep scripts small and deterministic. A `beforePaint` effect is parser-blocking
|
|
90
|
+
by design; it must not fetch, import a runtime module, or wait for the framework.
|
|
91
|
+
The marker must be a `data-*` attribute and is included in final HTML for audit.
|
|
92
|
+
|
|
93
|
+
The package escapes attributes, text, closing `style` tags, and closing
|
|
94
|
+
`script` tags at the compiler boundary. The branded constructors identify
|
|
95
|
+
trusted build output; they are not HTML sanitizers for untrusted user content.
|
|
96
|
+
|
|
97
|
+
## 3. Render consumer-owned structure
|
|
98
|
+
|
|
99
|
+
The renderer may call any build-time tool. For example, a React application can
|
|
100
|
+
use `renderToStaticMarkup()` for the projection while the package remains
|
|
101
|
+
React-free:
|
|
102
|
+
|
|
103
|
+
```tsx
|
|
104
|
+
const shellHtml = htmlFragment(
|
|
105
|
+
renderToStaticMarkup(
|
|
106
|
+
<AppChrome inertForDocumentShell activePath="/notes" />,
|
|
107
|
+
),
|
|
108
|
+
)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Prefer shared inputs over copied output:
|
|
112
|
+
|
|
113
|
+
- read navigation from the same route model used by the runtime;
|
|
114
|
+
- render the same simple SVG brand mark inline;
|
|
115
|
+
- compile critical colors and metrics from the active design recipe;
|
|
116
|
+
- derive active navigation from `location.pathname` in a tiny before-paint
|
|
117
|
+
effect, or render no active item when a stable answer is unavailable; and
|
|
118
|
+
- keep content route-neutral unless the route can be inferred without request
|
|
119
|
+
data.
|
|
120
|
+
|
|
121
|
+
The projection should be non-interactive and `aria-hidden="true"`. It exists to
|
|
122
|
+
cover startup, not to create a second application that needs hydration.
|
|
123
|
+
|
|
124
|
+
## 4. Configure Vite
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
import { documentShell } from '@fullstack-webapp/document-shell/vite'
|
|
128
|
+
import { defineConfig } from 'vite'
|
|
129
|
+
|
|
130
|
+
import { renderDocumentShell } from './document-shell.config.ts'
|
|
131
|
+
|
|
132
|
+
export default defineConfig({
|
|
133
|
+
plugins: [
|
|
134
|
+
...documentShell({
|
|
135
|
+
render: renderDocumentShell,
|
|
136
|
+
runtimeHandoff: true,
|
|
137
|
+
validateFinalDocument(html, context) {
|
|
138
|
+
if (context.mode === 'production' && html.includes('data-startup-probe')) {
|
|
139
|
+
throw new Error('Production document contains a diagnostics marker')
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
}),
|
|
143
|
+
],
|
|
144
|
+
})
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Keep the returned plugin array together. The first plugin replaces the
|
|
148
|
+
sentinel before downstream HTML transforms; the optional handoff plugin defers
|
|
149
|
+
the runtime stylesheet; the final plugin validates the emitted `index.html`.
|
|
150
|
+
|
|
151
|
+
`validateFinalDocument` is the consumer's final policy gate. Use it for
|
|
152
|
+
application-specific invariants such as keeping probe code out of production.
|
|
153
|
+
It receives the final HTML and `{ command, mode }` after Vite transforms.
|
|
154
|
+
|
|
155
|
+
## 5. Place the framework commit hook
|
|
156
|
+
|
|
157
|
+
Call `commitDocumentShellRuntime()` only after the real, persistent application
|
|
158
|
+
chrome and its route fallback have committed. In React, a component mounted
|
|
159
|
+
inside that chrome can use `useLayoutEffect`:
|
|
160
|
+
|
|
161
|
+
```tsx
|
|
162
|
+
import { commitDocumentShellRuntime } from '@fullstack-webapp/document-shell/client'
|
|
163
|
+
import { useLayoutEffect } from 'react'
|
|
164
|
+
|
|
165
|
+
export function DocumentShellHandoff() {
|
|
166
|
+
useLayoutEffect(() => {
|
|
167
|
+
void commitDocumentShellRuntime()
|
|
168
|
+
}, [])
|
|
169
|
+
|
|
170
|
+
return null
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
The function is document-scoped and idempotent. Strict Mode remounts receive
|
|
175
|
+
the same promise. Do not add cleanup that restores the static shell, and do not
|
|
176
|
+
duplicate stylesheet listeners, readiness attributes, or timers in the app.
|
|
177
|
+
|
|
178
|
+
The resolved result records whether the runtime stylesheet was `loaded`,
|
|
179
|
+
`error`, `timeout`, or `absent`. Every result is a revealed state. Error and
|
|
180
|
+
timeout are fail-open outcomes, not thrown errors.
|
|
181
|
+
|
|
182
|
+
## 6. Keep static and runtime geometry identical
|
|
183
|
+
|
|
184
|
+
The most common integration defect is not lifecycle; it is two shells using
|
|
185
|
+
slightly different metrics. Share the inputs that affect first-frame geometry:
|
|
186
|
+
|
|
187
|
+
- font family, font size, weight, line height, and text color;
|
|
188
|
+
- icon SVG, view box, stroke width, and icon slot dimensions;
|
|
189
|
+
- navigation padding, border, background, active surface, and active color;
|
|
190
|
+
- tab-bar height and safe-area expression;
|
|
191
|
+
- desktop rail/sidebar widths and responsive breakpoint; and
|
|
192
|
+
- brand SVG and its rendered box.
|
|
193
|
+
|
|
194
|
+
Critical CSS must be fully inline. With `runtimeHandoff: true`, the application
|
|
195
|
+
may emit exactly one extracted stylesheet. A second `<link rel="stylesheet">`
|
|
196
|
+
is ambiguous and fails the build rather than risking an incorrectly ordered
|
|
197
|
+
handoff.
|
|
198
|
+
|
|
199
|
+
## 7. Optional safe-area bridge
|
|
200
|
+
|
|
201
|
+
Some standalone iOS launches expose `env(safe-area-inset-bottom)` later than
|
|
202
|
+
the first parser paint. The bridge can reserve package-accepted geometry before
|
|
203
|
+
paint and release it after the native inset and viewport tuple stabilize.
|
|
204
|
+
|
|
205
|
+
The application declares only its DOM effect:
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
import { createSafeAreaBridge } from '@fullstack-webapp/document-shell'
|
|
209
|
+
|
|
210
|
+
const safeAreaBridge = createSafeAreaBridge({
|
|
211
|
+
domEffect: {
|
|
212
|
+
reserveBottomCssVariable: '--startup-safe-area-bottom',
|
|
213
|
+
profileAttribute: 'data-startup-safe-area-profile',
|
|
214
|
+
orientationAttribute: 'data-startup-safe-area-orientation',
|
|
215
|
+
reserveAttribute: 'data-startup-safe-area-reserve',
|
|
216
|
+
},
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
// Inside the returned DocumentShellComposition:
|
|
220
|
+
startupEffects: {
|
|
221
|
+
beforePaint: [
|
|
222
|
+
{
|
|
223
|
+
marker: 'data-document-shell-safe-area-bridge',
|
|
224
|
+
script: safeAreaBridge.beforePaint,
|
|
225
|
+
},
|
|
226
|
+
],
|
|
227
|
+
afterShell: [safeAreaBridge.probeHtml],
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Keep the marker name exactly `data-document-shell-safe-area-bridge`. The final
|
|
232
|
+
document gate uses that reserved marker to require `viewport-fit=cover`; a
|
|
233
|
+
different marker describes an unrelated startup effect and receives no
|
|
234
|
+
safe-area structural validation.
|
|
235
|
+
|
|
236
|
+
Then consume the variable from both shell implementations:
|
|
237
|
+
|
|
238
|
+
```css
|
|
239
|
+
:root {
|
|
240
|
+
--app-safe-area-bottom: max(
|
|
241
|
+
env(safe-area-inset-bottom),
|
|
242
|
+
var(--startup-safe-area-bottom, 0px)
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
.app-tabbar,
|
|
247
|
+
.document-shell__tabbar {
|
|
248
|
+
padding-bottom: var(--app-safe-area-bottom);
|
|
249
|
+
}
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
The root entry projects only profiles whose package-owned rollout is
|
|
253
|
+
`sharedDefault`. The current beta may therefore emit an empty catalog on an
|
|
254
|
+
ordinary consumer. This is intentional fail-open behavior, not a configuration
|
|
255
|
+
error. Consumers cannot pass a model name, reserve value, maturity, or rollout
|
|
256
|
+
policy. Promotion happens in the package after evidence review.
|
|
257
|
+
|
|
258
|
+
`@fullstack-webapp/document-shell/reference` temporarily contains the source
|
|
259
|
+
application's `referenceProduction` profiles so migration can preserve its
|
|
260
|
+
already verified behavior without silently enabling those provisional values
|
|
261
|
+
for every consumer. New applications should not import that subpath. It exits
|
|
262
|
+
after those profiles are promoted or retired and the source application can use
|
|
263
|
+
the root entry.
|
|
264
|
+
|
|
265
|
+
When a reserve matches, the bridge writes the declared CSS variable before
|
|
266
|
+
paint. It removes the reserve after the native inset reaches the profile floor
|
|
267
|
+
and remains stable with the viewport for two frames. Sampling ends after three
|
|
268
|
+
seconds; unresolved reserve remains to avoid a late downward jump, while a
|
|
269
|
+
lightweight orientation watcher can still release stale portrait state.
|
|
270
|
+
|
|
271
|
+
## 8. Verify the integration
|
|
272
|
+
|
|
273
|
+
Run package and application checks:
|
|
274
|
+
|
|
275
|
+
```sh
|
|
276
|
+
pnpm build
|
|
277
|
+
pnpm test
|
|
278
|
+
pnpm typecheck
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
Inspect the built `dist/index.html` rather than only the source template. It
|
|
282
|
+
must contain:
|
|
283
|
+
|
|
284
|
+
- the static shell and inline critical style;
|
|
285
|
+
- no `data-document-shell-entry` sentinel;
|
|
286
|
+
- one link with `id="runtime-stylesheet"`, `rel="preload"`, and `as="style"`;
|
|
287
|
+
- one `data-document-shell-runtime-stylesheet="true"` bootstrap; and
|
|
288
|
+
- the transformed application module entry.
|
|
289
|
+
|
|
290
|
+
Test the resource gate by delaying or blocking the application module and
|
|
291
|
+
stylesheet. The static shell must paint first. Then restore resources and
|
|
292
|
+
confirm the runtime replaces it without geometry or color movement. Separately
|
|
293
|
+
force stylesheet error and timeout paths; neither may leave an overlay.
|
|
294
|
+
|
|
295
|
+
Browser automation can verify HTML structure, resource ordering, DOM handoff,
|
|
296
|
+
and geometry. It cannot prove the iOS splash-screen boundary or the timing of a
|
|
297
|
+
real `env()` transition. Any safe-area profile promotion should retain a real
|
|
298
|
+
device launch recording plus a page trace on one aligned timeline.
|
|
299
|
+
|
|
300
|
+
## Common failures
|
|
301
|
+
|
|
302
|
+
### `template must contain only its module-entry sentinel`
|
|
303
|
+
|
|
304
|
+
The checked-in `index.html` still contains metadata or body content. Move it to
|
|
305
|
+
the composition renderer.
|
|
306
|
+
|
|
307
|
+
### `requires exactly one viewport meta` or `manifest link`
|
|
308
|
+
|
|
309
|
+
The renderer omitted a required node or another Vite plugin injected a second
|
|
310
|
+
one. Keep one document owner and inspect the final plugin pipeline.
|
|
311
|
+
|
|
312
|
+
### `requires exactly one stylesheet link`
|
|
313
|
+
|
|
314
|
+
Runtime handoff found zero or multiple extracted stylesheets. Import one main
|
|
315
|
+
application stylesheet, keep startup CSS inline, and avoid external stylesheet
|
|
316
|
+
links in `document.head` while this beta limitation applies.
|
|
317
|
+
|
|
318
|
+
### The shell disappears into an unstyled application
|
|
319
|
+
|
|
320
|
+
The commit hook is mounted too early, or a consumer reimplemented the handoff.
|
|
321
|
+
Place it inside the persistent runtime shell and call only
|
|
322
|
+
`commitDocumentShellRuntime()`.
|
|
323
|
+
|
|
324
|
+
### Icons, labels, active state, or tab-bar height still move
|
|
325
|
+
|
|
326
|
+
The static and runtime shells do not share all geometry inputs. Compare font
|
|
327
|
+
metrics, SVG boxes, padding, colors, responsive regime, and safe-area variables
|
|
328
|
+
rather than adding delay to the handoff.
|
|
329
|
+
|
|
330
|
+
### Safe-area reserve is inactive
|
|
331
|
+
|
|
332
|
+
No `sharedDefault` profile matched the observable runtime signature. This is
|
|
333
|
+
the safe failure mode. Capture evidence before proposing a package profile; do
|
|
334
|
+
not hard-code an application-side model table.
|
|
335
|
+
|
|
336
|
+
### A diagnostics marker appears in production
|
|
337
|
+
|
|
338
|
+
Make probe inclusion mode-dependent and add a `validateFinalDocument` rejection
|
|
339
|
+
for its unique markers. Diagnostics are a consumer build, not part of the
|
|
340
|
+
Document Shell production runtime.
|
package/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@fullstack-webapp/document-shell",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "Parser-visible startup shell compiler, Vite HTML pipeline, and framework-neutral runtime handoff for web applications.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/document-shell.d.ts",
|
|
10
|
+
"import": "./dist/document-shell.js",
|
|
11
|
+
"default": "./dist/document-shell.js"
|
|
12
|
+
},
|
|
13
|
+
"./client": {
|
|
14
|
+
"types": "./dist/client.d.ts",
|
|
15
|
+
"import": "./dist/client.js",
|
|
16
|
+
"default": "./dist/client.js"
|
|
17
|
+
},
|
|
18
|
+
"./reference": {
|
|
19
|
+
"types": "./dist/reference.d.ts",
|
|
20
|
+
"import": "./dist/reference.js",
|
|
21
|
+
"default": "./dist/reference.js"
|
|
22
|
+
},
|
|
23
|
+
"./vite": {
|
|
24
|
+
"types": "./dist/vite.d.ts",
|
|
25
|
+
"import": "./dist/vite.js",
|
|
26
|
+
"default": "./dist/vite.js"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"dist/**",
|
|
31
|
+
"docs/**",
|
|
32
|
+
"README.md",
|
|
33
|
+
"LICENSE"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "node scripts/clean-dist.mjs && tsc -p tsconfig.build.json",
|
|
37
|
+
"lint": "oxlint src tests scripts",
|
|
38
|
+
"prepack": "pnpm build",
|
|
39
|
+
"pack:check": "pnpm pack --dry-run",
|
|
40
|
+
"test:packed-consumer": "node scripts/verify-packed-consumer.mjs",
|
|
41
|
+
"test": "node --experimental-strip-types --test tests/*.test.ts",
|
|
42
|
+
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.build.json --noEmit"
|
|
43
|
+
},
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": "^20.19.0 || >=22.12.0"
|
|
46
|
+
},
|
|
47
|
+
"publishConfig": {
|
|
48
|
+
"access": "public",
|
|
49
|
+
"tag": "beta"
|
|
50
|
+
},
|
|
51
|
+
"peerDependencies": {
|
|
52
|
+
"vite": ">=7 <9"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@types/node": "^24.12.3",
|
|
56
|
+
"oxlint": "^1.71.0",
|
|
57
|
+
"typescript": "~6.0.2",
|
|
58
|
+
"vite": "^8.0.12"
|
|
59
|
+
},
|
|
60
|
+
"dependencies": {
|
|
61
|
+
"parse5": "^8.0.1"
|
|
62
|
+
},
|
|
63
|
+
"repository": {
|
|
64
|
+
"type": "git",
|
|
65
|
+
"url": "git+https://github.com/fullstack-webapp/fwa-kit.git",
|
|
66
|
+
"directory": "packages/document-shell"
|
|
67
|
+
}
|
|
68
|
+
}
|