@fullstack-webapp/document-shell 0.1.0-beta.3 → 0.1.0-beta.4

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 CHANGED
@@ -155,6 +155,18 @@ The commit waits for that gate, marks `<html data-app-ready="true">`, removes
155
155
  the element carrying `data-document-shell-static`, and always fails open so a
156
156
  broken stylesheet cannot leave a permanent overlay.
157
157
 
158
+ A consumer whose static projection reveals delayed content (for example a
159
+ content placeholder that only appears after a short race with the runtime and
160
+ then stays visible for a minimum duration) can declare an absolute
161
+ reveal-not-before deadline on the projection element via the exported
162
+ `documentShellRevealNotBeforeAttribute` at the moment that content becomes
163
+ visible. The loaded handoff then waits until the deadline before writing
164
+ `data-app-ready` and removing the projection. Only a finite, future deadline
165
+ that is still inside both the package's bounded hold horizon and the runtime
166
+ stylesheet gate's recorded fail-open deadline is honored; absent, invalid, or
167
+ expired declarations leave the loaded handoff immediate, and stylesheet `error`, `timeout`, and absence always reveal
168
+ immediately. See the integration guide for the declaration contract.
169
+
158
170
  For the full file-by-file procedure, safe-area effect example, verification
159
171
  checklist, and failure guide, see [Integration guide](docs/integration.md).
160
172
 
@@ -164,7 +176,7 @@ checklist, and failure guide, see [Integration guide](docs/integration.md).
164
176
  | --- | --- |
165
177
  | `@fullstack-webapp/document-shell` | Typed HTML/CSS/script boundaries, document composition types, compiler, structural validation, and the shared-default safe-area bridge. |
166
178
  | `@fullstack-webapp/document-shell/vite` | The Vite HTML producer, optional runtime-stylesheet gate, and final emitted-document validator. |
167
- | `@fullstack-webapp/document-shell/client` | The framework-neutral, one-shot runtime handoff. |
179
+ | `@fullstack-webapp/document-shell/client` | The framework-neutral, one-shot runtime handoff and its package-owned DOM contracts, including the optional reveal-not-before deadline. |
168
180
  | `@fullstack-webapp/document-shell/reference` | Reference-application safe-area rollout used to retain existing evidence while profiles mature; it is not a consumer profile selector. |
169
181
 
170
182
  The current beta catalog projects all four accepted iOS standalone portrait
@@ -213,6 +225,10 @@ an arbitrary callback with `Function#toString()`.
213
225
  - The current safe-area runtime matches observable browser geometry and
214
226
  platform signals, not marketing device names. Unsupported geometry fails
215
227
  open and receives no reserve.
228
+ - A reveal-not-before declaration can delay only a stylesheet `loaded`
229
+ handoff, and never past the runtime stylesheet gate's recorded fail-open
230
+ deadline; `error`, `timeout`, and `absent` outcomes and missing, invalid, or
231
+ expired declarations keep the immediate fail-open behavior.
216
232
 
217
233
  ## Development
218
234
 
package/dist/client.d.ts CHANGED
@@ -1,6 +1,16 @@
1
1
  export declare const documentShellReadyAttribute = "data-app-ready";
2
2
  export declare const documentShellStaticAttribute = "data-document-shell-static";
3
3
  export declare const documentShellRuntimeStylesheetId = "runtime-stylesheet";
4
+ /**
5
+ * Runtime-declared reveal-not-before deadline. The consumer's parser startup
6
+ * effect writes an absolute epoch-millisecond deadline into this attribute on
7
+ * the element carrying {@link documentShellStaticAttribute} at the moment the
8
+ * projection's delayed content actually becomes visible. A loaded stylesheet
9
+ * handoff waits until that deadline before writing `data-app-ready` and
10
+ * removing the projection; absent, invalid, or expired deadlines leave the
11
+ * loaded handoff immediate, and `error`/`timeout`/`absent` always fail open.
12
+ */
13
+ export declare const documentShellRevealNotBeforeAttribute = "data-document-shell-reveal-not-before";
4
14
  export type DocumentShellHandoffResult = Readonly<{
5
15
  status: 'revealed';
6
16
  stylesheet: 'loaded' | 'error' | 'timeout' | 'absent';
package/dist/client.js CHANGED
@@ -1,7 +1,24 @@
1
1
  export const documentShellReadyAttribute = 'data-app-ready';
2
2
  export const documentShellStaticAttribute = 'data-document-shell-static';
3
3
  export const documentShellRuntimeStylesheetId = 'runtime-stylesheet';
4
+ /**
5
+ * Runtime-declared reveal-not-before deadline. The consumer's parser startup
6
+ * effect writes an absolute epoch-millisecond deadline into this attribute on
7
+ * the element carrying {@link documentShellStaticAttribute} at the moment the
8
+ * projection's delayed content actually becomes visible. A loaded stylesheet
9
+ * handoff waits until that deadline before writing `data-app-ready` and
10
+ * removing the projection; absent, invalid, or expired deadlines leave the
11
+ * loaded handoff immediate, and `error`/`timeout`/`absent` always fail open.
12
+ */
13
+ export const documentShellRevealNotBeforeAttribute = 'data-document-shell-reveal-not-before';
4
14
  const handoffs = new WeakMap();
15
+ /**
16
+ * Maximum wall-clock horizon accepted for an optional reveal hold. The
17
+ * stylesheet bootstrap normally supplies the same three-second gate, while
18
+ * this independent bound also survives wall-clock adjustments between the
19
+ * declaration and the loaded handoff.
20
+ */
21
+ const revealHoldHorizonMs = 3_000;
5
22
  export function commitDocumentShellRuntime() {
6
23
  if (typeof document === 'undefined') {
7
24
  return Promise.reject(new Error('Document shell runtime handoff requires a browser document'));
@@ -20,6 +37,10 @@ export function commitDocumentShellRuntime() {
20
37
  const pending = {};
21
38
  root.setAttribute('data-document-shell-runtime-committed', 'true');
22
39
  const cleanup = () => {
40
+ if (pending.holdTimer !== undefined) {
41
+ window.clearTimeout(pending.holdTimer);
42
+ pending.holdTimer = undefined;
43
+ }
23
44
  if (pending.fallbackTimer !== undefined)
24
45
  window.clearTimeout(pending.fallbackTimer);
25
46
  if (pending.revealFrame !== undefined)
@@ -27,7 +48,7 @@ export function commitDocumentShellRuntime() {
27
48
  stylesheet?.removeEventListener('load', handleLoad);
28
49
  stylesheet?.removeEventListener('error', handleError);
29
50
  };
30
- const reveal = (stylesheetStatus) => {
51
+ const commitReveal = (stylesheetStatus) => {
31
52
  if (finished)
32
53
  return;
33
54
  finished = true;
@@ -36,6 +57,18 @@ export function commitDocumentShellRuntime() {
36
57
  document.querySelector(`[${documentShellStaticAttribute}]`)?.remove();
37
58
  resolveHandoff({ status: 'revealed', stylesheet: stylesheetStatus });
38
59
  };
60
+ const reveal = (stylesheetStatus) => {
61
+ if (finished)
62
+ return;
63
+ if (stylesheetStatus === 'loaded') {
64
+ const holdUntil = readRevealNotBeforeDeadline();
65
+ if (holdUntil !== null) {
66
+ scheduleRevealHold(holdUntil);
67
+ return;
68
+ }
69
+ }
70
+ commitReveal(stylesheetStatus);
71
+ };
39
72
  const revealAfterStylesApply = () => {
40
73
  if (finished || pending.revealFrame !== undefined)
41
74
  return;
@@ -75,4 +108,50 @@ export function commitDocumentShellRuntime() {
75
108
  : 0;
76
109
  pending.fallbackTimer = window.setTimeout(() => reveal('timeout'), fallbackDelay);
77
110
  return handoff;
111
+ /**
112
+ * Reads the reveal-not-before declaration at the moment the loaded handoff
113
+ * is about to reveal, so a deadline declared while the stylesheet was still
114
+ * loading is honored. Only a finite absolute deadline that is strictly in
115
+ * the future and still inside the runtime stylesheet gate's recorded
116
+ * fail-open deadline is honored; every other value is treated as absent or
117
+ * expired, keeping the loaded handoff immediate and guaranteeing that the
118
+ * projection can never outlive the stylesheet gate.
119
+ */
120
+ function readRevealNotBeforeDeadline() {
121
+ const projection = document.querySelector(`[${documentShellStaticAttribute}]`);
122
+ const raw = projection?.getAttribute(documentShellRevealNotBeforeAttribute);
123
+ if (!raw)
124
+ return null;
125
+ const deadline = Number(raw);
126
+ const now = Date.now();
127
+ if (!Number.isFinite(deadline) || deadline <= now)
128
+ return null;
129
+ const gateDeadline = Number(stylesheet?.dataset.failureDeadline);
130
+ const ceiling = Math.min(now + revealHoldHorizonMs, Number.isFinite(gateDeadline) ? gateDeadline : Number.POSITIVE_INFINITY);
131
+ return deadline <= ceiling ? deadline : null;
132
+ }
133
+ /**
134
+ * Defers the loaded reveal until the declared deadline. Wall-clock time
135
+ * preserves the absolute deadline during normal operation, while a monotonic
136
+ * ceiling limits the wait to the delay accepted when it was scheduled. The
137
+ * fired timer is cleared before re-evaluation so an early fire reschedules;
138
+ * a backward wall-clock adjustment cannot extend the inert projection beyond
139
+ * the original bounded delay. The timer shares the handoff cleanup lifecycle.
140
+ */
141
+ function scheduleRevealHold(deadline) {
142
+ if (pending.holdTimer !== undefined)
143
+ return;
144
+ const maximumDelay = Math.min(revealHoldHorizonMs, Math.max(0, deadline - Date.now()));
145
+ const monotonicDeadline = window.performance.now() + maximumDelay;
146
+ const wait = () => {
147
+ pending.holdTimer = undefined;
148
+ const remaining = Math.min(deadline - Date.now(), monotonicDeadline - window.performance.now());
149
+ if (remaining > 0) {
150
+ pending.holdTimer = window.setTimeout(wait, remaining);
151
+ return;
152
+ }
153
+ commitReveal('loaded');
154
+ };
155
+ pending.holdTimer = window.setTimeout(wait, maximumDelay);
156
+ }
78
157
  }
@@ -179,6 +179,110 @@ The resolved result records whether the runtime stylesheet was `loaded`,
179
179
  `error`, `timeout`, or `absent`. Every result is a revealed state. Error and
180
180
  timeout are fail-open outcomes, not thrown errors.
181
181
 
182
+ ### Declare a reveal-not-before deadline (optional)
183
+
184
+ A projection may reveal part of its content only after a short race with the
185
+ runtime, then keep that delayed content visible for a minimum duration. CSS can
186
+ delay the delayed content's appearance, but it cannot stop the loaded handoff
187
+ from removing the whole projection mid-duration. For that surface the consumer
188
+ declares an absolute reveal-not-before deadline on the static projection at the
189
+ moment the delayed content actually becomes visible; the loaded handoff then
190
+ waits until that deadline before writing the readiness attribute and removing
191
+ the projection.
192
+
193
+ The deadline is a package-owned DOM contract, not a hand-written attribute:
194
+
195
+ ```ts
196
+ import {
197
+ commitDocumentShellRuntime,
198
+ documentShellRevealNotBeforeAttribute,
199
+ } from '@fullstack-webapp/document-shell/client'
200
+ ```
201
+
202
+ The consumer's parser startup effect writes an absolute epoch-millisecond
203
+ deadline (`Date.now() + minimumVisibleMs`) into
204
+ `documentShellRevealNotBeforeAttribute` on the element carrying
205
+ `data-document-shell-static`. Declare it only when the delayed content is
206
+ actually visible: when the runtime won the race and the projection was already
207
+ revealed, there is nothing to hold, and a later declaration is simply too late.
208
+ A minimal runtime effect, composed by the renderer, can look like this:
209
+
210
+ ```ts
211
+ import {
212
+ cssText,
213
+ htmlFragment,
214
+ inlineScript,
215
+ type DocumentShellComposition,
216
+ } from '@fullstack-webapp/document-shell'
217
+ import { documentShellRevealNotBeforeAttribute } from '@fullstack-webapp/document-shell/client'
218
+
219
+ const revealDelayMs = 150 // surface policy, chosen by the consumer
220
+ const minimumVisibleMs = 250 // surface policy, chosen by the consumer
221
+
222
+ const composition: DocumentShellComposition = {
223
+ // ...
224
+ startupEffects: {
225
+ beforePaint: [
226
+ {
227
+ marker: 'data-reveal-declarer',
228
+ script: inlineScript(`
229
+ (() => {
230
+ const revealDelay = ${revealDelayMs}
231
+ const minimumVisible = ${minimumVisibleMs}
232
+ const selector = '[data-document-shell-static]'
233
+ setTimeout(() => {
234
+ const projection = document.querySelector(selector)
235
+ // Still projected at the reveal-delay boundary means the delayed
236
+ // content is becoming visible now: keep it for its minimum
237
+ // visible duration before the handoff may reveal.
238
+ if (projection) {
239
+ projection.setAttribute(
240
+ '${documentShellRevealNotBeforeAttribute}',
241
+ String(Date.now() + minimumVisible),
242
+ )
243
+ }
244
+ }, revealDelay)
245
+ })()
246
+ `),
247
+ },
248
+ ],
249
+ },
250
+ }
251
+ ```
252
+
253
+ The values shown are examples; the delay and the minimum visible duration are
254
+ consumer surface policy and belong to the surface that owns the pending
255
+ experience, not to Document Shell.
256
+
257
+ Semantics:
258
+
259
+ - Only a normal stylesheet `loaded` handoff can wait for a deadline.
260
+ Stylesheet `error`, `timeout`, and absence keep their immediate fail-open
261
+ reveal and are never blocked by a declaration.
262
+ - The package honors a declaration only when it is a finite absolute timestamp,
263
+ strictly after the current time, within the package's three-second defensive
264
+ hold horizon, and no later than the runtime stylesheet gate's recorded
265
+ fail-open deadline (the same absolute deadline that already bounds the whole
266
+ handoff). Every other value — absent, non-numeric, zero,
267
+ negative, `Infinity`, expired, or too far in the future — is treated as
268
+ expired, and the loaded handoff reveals immediately exactly as without a
269
+ declaration.
270
+ - The deadline is sampled when the loaded reveal is about to run. A
271
+ declaration that lands while the stylesheet is still loading or between the
272
+ load event and the apply frame is honored; a declaration that lands after the
273
+ reveal already ran cannot resurrect the projection.
274
+ - The wait is a package timer with the same cleanup lifecycle as the rest of
275
+ the handoff. Repeated `commitDocumentShellRuntime()` calls return the same
276
+ document-level promise and never create a second timer, and the projection is
277
+ still removed exactly once. The absolute wall-clock deadline is also bounded
278
+ by the monotonic delay accepted when scheduling, so moving the system clock
279
+ backward cannot extend the hold indefinitely.
280
+
281
+ The bounded rule keeps the existing fail-open guarantee intact: an inert
282
+ projection can never outlive the runtime stylesheet gate, so a malformed or
283
+ mistaken declaration cannot pin the overlay past the point the package would
284
+ otherwise have released it.
285
+
182
286
  ## 6. Keep static and runtime geometry identical
183
287
 
184
288
  The most common integration defect is not lifecycle; it is two shells using
@@ -323,6 +427,23 @@ The commit hook is mounted too early, or a consumer reimplemented the handoff.
323
427
  Place it inside the persistent runtime shell and call only
324
428
  `commitDocumentShellRuntime()`.
325
429
 
430
+ ### A delayed projection disappears before its minimum visible duration ends
431
+
432
+ The delayed content's appearance is timed with CSS, but the loaded handoff
433
+ still removes the projection as soon as the stylesheet applied. Declare a
434
+ reveal-not-before deadline (see the optional seam above) at the moment the
435
+ delayed content becomes visible instead of adding a competing timer in the
436
+ framework hook.
437
+
438
+ ### A declared reveal-not-before deadline never seems to hold
439
+
440
+ The declaration must live on the element carrying `data-document-shell-static`
441
+ as `documentShellRevealNotBeforeAttribute`, hold an absolute epoch-millisecond
442
+ timestamp inside both the bounded hold horizon and the runtime stylesheet gate,
443
+ and land before the loaded reveal runs. A static far-future value in the emitted
444
+ HTML is ignored by design so it cannot pin the overlay; declare it from the startup effect when
445
+ the delayed content is actually visible.
446
+
326
447
  ### Icons, labels, active state, or tab-bar height still move
327
448
 
328
449
  The static and runtime shells do not share all geometry inputs. Compare font
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fullstack-webapp/document-shell",
3
- "version": "0.1.0-beta.3",
3
+ "version": "0.1.0-beta.4",
4
4
  "description": "Parser-visible startup shell compiler, Vite HTML pipeline, and framework-neutral runtime handoff for web applications.",
5
5
  "license": "MIT",
6
6
  "type": "module",