@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,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
+ }