@sparkletree/core 0.12.0 → 0.13.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.
Files changed (46) hide show
  1. package/README.md +18 -19
  2. package/dist/analytics.d.ts +7 -1
  3. package/dist/analytics.d.ts.map +1 -1
  4. package/dist/analytics.js +2 -1
  5. package/dist/analytics.js.map +1 -1
  6. package/dist/client.d.ts +8 -0
  7. package/dist/client.d.ts.map +1 -1
  8. package/dist/client.js +6 -6
  9. package/dist/client.js.map +1 -1
  10. package/dist/context.d.ts +8 -0
  11. package/dist/context.d.ts.map +1 -1
  12. package/dist/context.js +21 -0
  13. package/dist/context.js.map +1 -1
  14. package/dist/fragments.d.ts +21 -24
  15. package/dist/fragments.d.ts.map +1 -1
  16. package/dist/fragments.js +10 -7
  17. package/dist/fragments.js.map +1 -1
  18. package/dist/index.d.ts +5 -3
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +4 -2
  21. package/dist/index.js.map +1 -1
  22. package/dist/protocol.d.ts +12 -1
  23. package/dist/protocol.d.ts.map +1 -1
  24. package/dist/protocol.js +5 -2
  25. package/dist/protocol.js.map +1 -1
  26. package/dist/server.d.ts +144 -0
  27. package/dist/server.d.ts.map +1 -0
  28. package/dist/server.js +463 -0
  29. package/dist/server.js.map +1 -0
  30. package/dist/slots.d.ts +66 -0
  31. package/dist/slots.d.ts.map +1 -0
  32. package/dist/slots.js +102 -0
  33. package/dist/slots.js.map +1 -0
  34. package/dist/state.d.ts +282 -239
  35. package/dist/state.d.ts.map +1 -1
  36. package/dist/state.js +434 -413
  37. package/dist/state.js.map +1 -1
  38. package/dist/variant.d.ts +70 -1
  39. package/dist/variant.d.ts.map +1 -1
  40. package/dist/variant.js +85 -1
  41. package/dist/variant.js.map +1 -1
  42. package/dist/visitorContext.d.ts +99 -0
  43. package/dist/visitorContext.d.ts.map +1 -0
  44. package/dist/visitorContext.js +84 -0
  45. package/dist/visitorContext.js.map +1 -0
  46. package/package.json +5 -1
package/dist/state.d.ts CHANGED
@@ -1,57 +1,64 @@
1
1
  /**
2
- * Copy choreography — the rewrite/settle state machine.
2
+ * Copy choreography — the hold/settle state machine.
3
3
  *
4
4
  * This is the part of the SDK that is not a fetch wrapper. The principle it
5
5
  * serves is a user-experience one, and everything else here follows from it:
6
6
  *
7
- * NO BAIT AND SWITCH. A visitor is never shown something and then has it
8
- * replaced, taken away, or pushed around — copy, image, video, layout,
9
- * all of it. So the space is reserved blank (sized by the copy it keeps
10
- * room for — never a skeleton, never a spinner) until there is something
11
- * final to show. If the stream delivers inside the budget, that is what
12
- * the visitor gets. If it is too slow or fails, the fallback is what they
13
- * get. Either way, that is the content for this page view. A stream that
14
- * arrives after the visitor already has something in front of them has
15
- * lost its slot: it is discarded from the canvas (it may still inform
16
- * provenance and analytics). The fallback is the failure path; it never
17
- * shows first and then gets replaced.
7
+ * TEXT A VISITOR CAN SEE IS NEVER REPLACED WHILE IT IS ON SCREEN.
18
8
  *
19
- * TEXT ON THE WIRE IS TEXT WE CAN SHOW. The first CHUNK takes the canvas,
20
- * whatever the `*-start` called itself. The delivery `mode` is not
21
- * consulted for hold release — an earlier revision released on
22
- * `mode: "generate"` and kept holding on `replay`, which re-derived from
23
- * a label what the arrival of actual text proves directly. A replay's
24
- * single flush still paints once, because one flush is genuinely what
25
- * arrived — not because it was held back.
9
+ * 1. When a copy slot comes into view and live copy is coming, the slot
10
+ * holds blank with its geometry reserved for a short budget
11
+ * (`COPY_HOLD_MS`), restarting once when the stream proves it is alive.
12
+ * 2. Stream text arriving inside the hold types into the blank slot (a
13
+ * cached replay's single flush lands whole). The fallback is never seen.
14
+ * 3. If the hold runs out with nothing delivered, the fallback lands once
15
+ * and is FINAL for this view: any later stream text for that field is
16
+ * dropped — never painted, never composed over it.
17
+ * 4. Art, background and chrome are never held; only text slots are.
26
18
  *
27
- * The hold is the mechanism: the canvas holds blank on the art for a budget —
28
- * a budget for the STORED FALLBACK, not for the stream.
19
+ * Every field is in exactly one of three conditions:
29
20
  *
30
- * KNOWN BUGS AGAINST THE PRINCIPLE, still in this file: a late stream's
31
- * finished text can land once over a shown fallback (the composed landing at
32
- * `*-done`; `rewrites` reaching 1), and fallback text that server HTML showed
33
- * before the hold could arm is tolerated rather than prevented. Neither is a
34
- * feature. Do not build on them, and do not read them as permission to show
35
- * something and then replace it.
21
+ * - blank: held (reserved) or simply empty, waiting for the stream;
22
+ * - live: stream text is on the canvas; later chunks EXTEND it (typing),
23
+ * and its `*-done` completes it — never replaces it;
24
+ * - final: copy the visitor can read that the stream does not own — the
25
+ * fallback after the hold, finished creative the platform rendered, or
26
+ * copy that was already on screen when the stream opened. Copy events
27
+ * for a final field are dropped.
36
28
  *
37
- * Reference implementation (the iframe shell, which must stay behaviourally
38
- * identical): `mosaic/services/rendererService.ts`, streamInitialCopyCreative.
29
+ * There is one exception to "never replaced", and it is isolated in one
30
+ * function: a sentence the stream was still WRITING when it failed. See
31
+ * `UnfinishedCopyPolicy`.
39
32
  */
40
33
  import { type ContentSource, type CopyMode } from "./wire.js";
41
34
  export type CopyField = "greeting" | "headline" | "body" | "cta";
42
35
  export declare const COPY_FIELDS: readonly CopyField[];
43
36
  /**
44
- * How long the canvas will hold blank waiting for the stream.
37
+ * How long a copy slot holds blank, waiting for live copy, before the
38
+ * fallback lands.
45
39
  *
46
- * Armed when the REQUEST opens (openStream calls streamOpened before the
47
- * fetch), so the fallback never paints ahead of a stream that is about to
48
- * replace it. The slow-connect worry that once argued for arming against the
49
- * connection instead is answered by the restart: `copyStart` restarts the
50
- * budget ONCE when the stream proves it is alive, so a slow link still gets a
51
- * full budget for delivery — without the blank window being extended per
52
- * field.
40
+ * The budget starts when the slot is in view (or when the stream opens, for
41
+ * a renderer that does not report visibility), and restarts ONCE when the
42
+ * stream proves it is alive (its first `*-start`). So the longest a slot can
43
+ * hold is `COPY_HOLD_MAX_MS`.
53
44
  */
54
45
  export declare const COPY_HOLD_MS = 2500;
46
+ /** The longest a slot can hold: one budget plus the one restart. */
47
+ export declare const COPY_HOLD_MAX_MS: number;
48
+ /**
49
+ * The pure-CSS failsafe for server-rendered reservations: if the page's
50
+ * JavaScript never takes control, reserved fallback copy becomes visible on
51
+ * its own after this long. Equal to the longest hold, so the failsafe can
52
+ * never reveal copy a running hold still owns.
53
+ */
54
+ export declare const RESERVE_FAILSAFE_MS: number;
55
+ /**
56
+ * The one motion grammar for copy: every fade and cross-fade runs for this
57
+ * long on `COPY_MOTION_EASING`. Typing has its own pace; nothing else moves.
58
+ */
59
+ export declare const COPY_MOTION_MS = 700;
60
+ /** The easing curve for every copy fade and cross-fade. */
61
+ export declare const COPY_MOTION_EASING = "cubic-bezier(0.16, 1, 0.3, 1)";
55
62
  export interface FieldState {
56
63
  /** What a consumer should render right now. Never a placeholder. */
57
64
  text: string;
@@ -62,20 +69,28 @@ export interface FieldState {
62
69
  mode: CopyMode | null;
63
70
  /** True while live deltas are landing — consumers may animate a caret. */
64
71
  typing: boolean;
65
- /** `*-done` seen: `text` is final for this stream. */
72
+ /** `text` is final for this stream: closed by the server, or settled by
73
+ * the SDK at the end of the stream. */
66
74
  settled: boolean;
75
+ /**
76
+ * True when `text` deliberately replaces text that may have been visible:
77
+ * an unfinished sentence withdrawn after a failure (`UnfinishedCopyPolicy`)
78
+ * or new copy after the campaign or context changed. Renderers cross-fade
79
+ * once (`COPY_MOTION_MS`, `COPY_MOTION_EASING`) instead of typing. Absent
80
+ * on every other field — no other change ever replaces visible text.
81
+ */
82
+ crossfade?: boolean;
67
83
  }
68
84
  export type Phase =
69
85
  /** Nothing started. */
70
86
  "idle"
71
- /** Canvas is deliberately blank, waiting out the budget. */
87
+ /** Copy slots are deliberately blank (reserved), waiting for live copy. */
72
88
  | "holding"
73
- /** Live deltas are landing. */
89
+ /** Copy is on the canvas and the stream is still open. */
74
90
  | "live"
75
91
  /** `done` received. */
76
92
  | "settled"
77
- /** `error`, or the transport failed. Copy that closed stays; anything the
78
- * stream left unfinished or blank is back on its fallback. */
93
+ /** `error`, or the transport failed. Copy on the canvas stays. */
79
94
  | "failed";
80
95
  export interface CreativeState {
81
96
  phase: Phase;
@@ -97,86 +112,176 @@ export interface CreativeState {
97
112
  ctaAction: string | null;
98
113
  /** Terminal `done.meta`, verbatim. */
99
114
  meta: Record<string, unknown> | null;
100
- /** Strongest provenance this stream declared. Drives trust chrome. */
115
+ /**
116
+ * Provenance of the copy on the canvas, stream-wide: the strongest any
117
+ * field declared while live, the server's terminal word once settled, and
118
+ * `static` whenever no adapted copy actually reached the canvas. Decide a
119
+ * disclosure mark per field (`field.source`), not from this.
120
+ */
101
121
  contentSource: ContentSource;
102
- /** W8 — set when the server served a deterministic winner instead. */
122
+ /** Set when the server served a deterministic winner instead. */
103
123
  degraded: string | null;
104
124
  /** Resolved variant, for pinning subsequent calls in this page view. */
105
125
  variantId: string | null;
106
126
  /** Human-readable failure, if any. Never blanks painted copy. */
107
127
  error: string | null;
108
128
  /**
109
- * What the hold pulled off the canvas, per field — the copy that WAS
110
- * painted at first paint (`/content` copy or the consumer's fallback),
111
- * exposed so a renderer can reserve its exact geometry while the field
112
- * is blank (LAYOUT-DOCTRINE.md layer 1). The choreographer stays
113
- * DOM-pure: it reports what was held; drawing the reservation is the
114
- * renderer's job. Null when no hold armed this page view.
129
+ * What the hold reserved, per field: the copy that will land if the hold
130
+ * runs out. A renderer draws it invisibly so a blank field keeps its exact
131
+ * geometry. Null when no hold armed in this view.
115
132
  *
116
- * Optional so a `CreativeState` built by hand — a test mock, a Storybook
117
- * fixture — written against 0.3.0 still compiles: every reader goes
118
- * through `state.held?.`, so absent and null are the same fact.
133
+ * Optional so a hand-built `CreativeState` (a test mock, a story fixture)
134
+ * still compiles: read it as `state.held?.`.
119
135
  */
120
136
  held?: Partial<Record<CopyField, string>> | null;
121
137
  /**
122
- * Rewrite counter: how many times the INITIAL copy was replaced by stream
123
- * text. Over-reports against the principle in the file header, because it
124
- * also counts the held path — initial copy the hold kept invisible, then
125
- * stream text typing into the blank — which the visitor experiences as no
126
- * replacement at all. A 1 reached after the initial copy was VISIBLE is a
127
- * bait and switch (the known bug above). Redefining this to count visible
128
- * replacements only is pending. Anything above 1 has always been a
129
- * violation and is asserted against in the tests.
138
+ * How many times text the visitor could see was replaced. Always `0` from
139
+ * a single stream: the hold, the stream's entrance and the fallback's
140
+ * landing all write into blank slots. `useIsland` adds one per cross-fade
141
+ * after a campaign or context change, the only replacement there is.
130
142
  */
131
143
  rewrites: number;
132
144
  }
145
+ /** What `UnfinishedCopyPolicy` decides about. */
146
+ export interface UnfinishedCopy {
147
+ field: CopyField;
148
+ /** The text the stream had written when it stopped, possibly mid-word. */
149
+ typed: string;
150
+ /** The fallback that would replace it. Never empty when the policy is asked. */
151
+ fallback: string;
152
+ }
153
+ /**
154
+ * `"keep"`: the typed text stays and settles where it is.
155
+ * `"fallback"`: the typed text fades out and the fallback fades in, once.
156
+ */
157
+ export type UnfinishedCopyDecision = "keep" | "fallback";
158
+ /**
159
+ * Decides what happens to a field the stream was still writing when it
160
+ * ended without closing it (an error, a dropped connection, or a `done` that
161
+ * arrived without the field's own close).
162
+ *
163
+ * This is the one place visible text may be replaced, and only when the
164
+ * policy says so: the default (`keepUnlessClearlyError`) keeps what was
165
+ * typed unless it is clearly an error. A replacement is a single cross-fade
166
+ * (`FieldState.crossfade`). A field whose text was finished is never asked
167
+ * about; a field that never painted simply gets its fallback.
168
+ */
169
+ export type UnfinishedCopyPolicy = (copy: UnfinishedCopy) => UnfinishedCopyDecision;
170
+ /**
171
+ * True when `text` is very clearly not copy: it contains an error message, a
172
+ * stack or status text, raw markup or JSON, or a model refusal. Conservative
173
+ * by design — anything that could be ordinary copy returns false.
174
+ */
175
+ export declare function isClearlyErrorText(text: string): boolean;
176
+ /**
177
+ * The default `UnfinishedCopyPolicy`: keep whatever the stream already typed,
178
+ * even an unfinished line. Only text that is clearly an error
179
+ * (`isClearlyErrorText`) is replaced, with one fade to the fallback.
180
+ */
181
+ export declare const keepUnlessClearlyError: UnfinishedCopyPolicy;
133
182
  export interface ChoreographerOptions {
134
183
  /**
135
- * First paint. Real copy from SSR `/content` or the consumer's `fallback`.
136
- * Required by doctrine — there is no skeleton mode to fall back to.
184
+ * The fallback: what lands if live copy does not arrive in time. Required by
185
+ * doctrine — there is no skeleton mode.
137
186
  */
138
187
  initial?: Partial<Record<CopyField, string>>;
139
188
  /**
140
- * True when the initial copy was already in front of the visitor when this
141
- * stream opened: copy at rest from an earlier episode in this page view
142
- * (an expired hold, a previous stream's copy carried across a re-keyed
143
- * identity), or fallback text that server HTML painted before any effect
144
- * could hold it. The second should not happen — the fallback is the
145
- * failure path and the render path should emit the reservation, not the
146
- * text — and is a defect to fix at the server render, not a state to
147
- * design for.
189
+ * True when the initial copy is ALREADY VISIBLE when the stream opens: copy
190
+ * at rest from earlier in this view, or a fallback the page revealed before
191
+ * the SDK could hold it. Every non-empty field is then final for this
192
+ * view: no hold, and the stream's copy events for it are dropped.
148
193
  *
149
- * What the visitor is looking at is theirs now. It is not blanked and not
150
- * typed over; deltas aimed at it buffer off-canvas. (That the buffer still
151
- * lands once at `*-done` is one of the known bugs the file header lists.)
194
+ * Leave it unset for copy that has not been seen yet — the normal case.
195
+ * The hold then keeps the slot blank until live copy or the fallback lands.
152
196
  */
153
197
  initialIsOnScreen?: boolean;
154
198
  /**
155
- * True when the initial copy is genuinely finished, already-presented
156
- * CAMPAIGN creative — server-rendered by the platform, not a developer's
157
- * `fallback`. This is the SDK analogue of the shell's `[data-baked-creative]`
158
- * gate (rendererService.ts streamIsland). It is finished creative, not a
159
- * fallback waiting on a stream, so there is nothing to hold and nothing a
160
- * stream may replace: live deltas never paint over it. (That their
161
- * composed result still lands once at `*-done` is a known bug, file
162
- * header.)
199
+ * True when the initial copy is FINISHED CAMPAIGN CREATIVE rendered by the
200
+ * platform — not a developer's fallback. It is the copy for this view:
201
+ * nothing holds, and the stream's copy events for those fields are dropped
202
+ * (theme, media and the action still apply). Fields left empty may still
203
+ * be written by the stream.
163
204
  *
164
- * A developer's fallback must NOT set this: a fallback that has not
165
- * painted yet belongs to the hold, whose blank canvas is what keeps the
166
- * typed entrance reachable. Setting it from the ordinary client commit
167
- * (commit 3dc544913 treated every non-empty first paint this way) made
168
- * paced streaming unreachable in every real integration, because
169
- * `fallback` is required by doctrine and always paints before the stream
170
- * opens.
205
+ * Do not set it for a `fallback`: a fallback is what lands only when live
206
+ * copy does not.
171
207
  */
172
208
  serverRendered?: boolean;
173
- /** How long the canvas holds blank waiting for the stream before the initial
174
- * copy paints. Defaults to `COPY_HOLD_MS`, and restarts ONCE when the stream
175
- * proves it is alive — so the blank can last up to twice this. */
209
+ /**
210
+ * The hold budget for this view. Defaults to `COPY_HOLD_MS`; restarts once
211
+ * when the stream proves alive. However the restart falls, the hold never
212
+ * lasts longer than twice this in total.
213
+ */
176
214
  holdMs?: number;
177
- /** Injectable for tests; defaults to the platform timers. */
215
+ /**
216
+ * How long the slot has ALREADY been holding before this choreographer took
217
+ * over — for example, since a server-rendered reservation first painted.
218
+ * It is spent from the budget and from the absolute maximum, so the wait
219
+ * before hydration and the wait after it are one hold. Defaults to 0.
220
+ */
221
+ holdElapsedMs?: number;
222
+ /**
223
+ * Provenance of the `initial` copy when it is finished creative
224
+ * (`serverRendered`), for example copy already generated for this view.
225
+ * Those fields carry it as their `source` and are settled from the start.
226
+ * Defaults to `static`.
227
+ */
228
+ initialSource?: ContentSource;
229
+ /** The variant the `initial` copy belongs to, if known. Seeds `variantId`. */
230
+ initialVariantId?: string;
231
+ /**
232
+ * With `serverRendered`: the `initial` copy is the WHOLE copy for this
233
+ * view, so the stream writes no field, empty ones included. Use it for
234
+ * copy decided before the slot was shown (for example cached copy a
235
+ * server read for this visitor): a field it left empty stays empty rather
236
+ * than gaining text later.
237
+ */
238
+ initialIsComplete?: boolean;
239
+ /**
240
+ * When true, the hold budget does not start until `slotInView()` is called.
241
+ * The stream may open (and buffer) earlier; a slot that never comes into
242
+ * view never shows anything changing. When false (the default), the slot
243
+ * is treated as in view from `streamOpened()`.
244
+ */
245
+ holdStartsInView?: boolean;
246
+ /**
247
+ * When the held canvas is handed to the stream.
248
+ *
249
+ * `"text"` (default): on the first text the stream delivers — live copy
250
+ * types into the blank slot.
251
+ *
252
+ * `"complete"`: only when the stream finishes (`done`), so the whole new
253
+ * copy lands at once. For a slot whose canvas is showing something else in
254
+ * the meantime (a campaign or context change); the hold budget still
255
+ * applies.
256
+ */
257
+ releaseOn?: "text" | "complete";
258
+ /** What happens to a field the stream was still writing when it ended.
259
+ * Defaults to `keepUnlessClearlyError`. */
260
+ unfinishedCopy?: UnfinishedCopyPolicy;
261
+ /**
262
+ * When true, the fallback decides which fields exist: a field whose
263
+ * `initial` copy is empty is never shown, and the stream's copy for it is
264
+ * dropped. A slot sized by its fallback has no room for a field the
265
+ * fallback does not have. Ignored when `initial` has no copy at all.
266
+ */
267
+ fallbackDefinesSlots?: boolean;
268
+ /**
269
+ * Per-field provenance of the `initial` copy when it is finished creative
270
+ * (`serverRendered`). Overrides `initialSource` for the fields it names,
271
+ * for copy that mixes provenance, such as live copy for one field and the
272
+ * fallback for another.
273
+ */
274
+ initialSources?: Partial<Record<CopyField, ContentSource>>;
275
+ /**
276
+ * The authored fallback, when `initial` is other copy (for example copy
277
+ * already produced for this view). It is what `keepFallback` lands.
278
+ * Defaults to `initial`.
279
+ */
280
+ fallback?: Partial<Record<CopyField, string>>;
281
+ /** Injectable for tests; defaults to the platform timers and clock. */
178
282
  setTimeout?: (fn: () => void, ms: number) => unknown;
179
283
  clearTimeout?: (handle: unknown) => void;
284
+ now?: () => number;
180
285
  }
181
286
  type Listener = (state: CreativeState) => void;
182
287
  /**
@@ -184,7 +289,7 @@ type Listener = (state: CreativeState) => void;
184
289
  *
185
290
  * Pure with respect to the DOM: it decides WHAT should be on screen and when,
186
291
  * and never touches how. That is what lets the same choreography drive React,
187
- * the iframe shell, and anything else without three copies of these rules.
292
+ * the iframe shell, and anything else without several copies of these rules.
188
293
  */
189
294
  export declare class CopyChoreographer {
190
295
  private readonly options;
@@ -193,59 +298,21 @@ export declare class CopyChoreographer {
193
298
  private readonly holdMs;
194
299
  private readonly setTimer;
195
300
  private readonly clearTimer;
196
- /**
197
- * Copy pulled off the canvas when the hold armed. Alive until the stream
198
- * TERMINATES (`done`/`failed`), not merely until the hold releases: any
199
- * field the stream never delivers is restored from here, because a field
200
- * the stream never took must end on its fallback — never on the blank the
201
- * hold itself painted.
202
- */
301
+ private readonly releaseOn;
302
+ private readonly unfinishedPolicy;
203
303
  private heldCopy;
204
304
  private holdTimer;
205
- /**
206
- * The budget may restart ONCE per hold episode. Without a cap, a server
207
- * emitting a `*-start` per field would extend the blank window by a full
208
- * budget per field — proof of life is proof once, and a blank canvas is a
209
- * cost the visitor pays, so it is not for sale twice.
210
- */
211
305
  private holdRestarted;
212
- /** Text accumulated from the wire while the hold is still on. */
306
+ private holdStartedAt;
307
+ private holdDueAt;
308
+ private readonly now;
309
+ private inView;
213
310
  private pending;
214
- /** True once the machine has committed a change to the first paint. */
215
- private rewriteCommitted;
216
- /**
217
- * Fields whose on-canvas text the visitor is already reading: restored
218
- * fallback, `serverRendered` creative, or initial copy that was on screen
219
- * when the stream opened (`initialIsOnScreen`). Live deltas buffer
220
- * off-canvas for these. That the buffer then lands at `*-done` is a known
221
- * bug against the file header's principle: shown text should stay.
222
- *
223
- * Gated on ON-SCREEN, never on non-empty: an earlier revision seeded it
224
- * with every non-empty field at first paint, which is every field of
225
- * every integration (fallback is required by doctrine), so `typing` was
226
- * never true and paced streaming was dead. A held field's canvas is
227
- * blank, and typing into blank is the entrance — that path must never
228
- * seed here.
229
- */
230
- private fallbackOnCanvas;
231
- /** Fields the stream has opened with a `*-start` (or that received a chunk). */
311
+ private final;
312
+ private live;
232
313
  private started;
233
- /**
234
- * First-paint copy, kept for the terminal restore: a field the stream
235
- * opened but never closed must end on its WHOLE fallback, never on the
236
- * half-typed fragment the dead stream left (`"Meet the web that"`). The
237
- * hold's `heldCopy` only exists when the hold armed; typing over on-screen
238
- * fallback needs this snapshot instead.
239
- */
240
- private readonly initialCopy;
241
- /**
242
- * Fields the stream has closed with a `*-done`, including closes that
243
- * arrived while the hold was still on and so never reached the canvas at the
244
- * time. Consulted when the copy finally paints, so `settled` describes the
245
- * field rather than the route it took.
246
- */
247
314
  private closed;
248
- /** Set by dispose(): async completions (image decode) must not patch after it. */
315
+ private readonly initialCopy;
249
316
  private disposed;
250
317
  constructor(options?: ChoreographerOptions);
251
318
  getState(): CreativeState;
@@ -254,125 +321,103 @@ export declare class CopyChoreographer {
254
321
  private patch;
255
322
  private patchField;
256
323
  private hasInitialCopy;
324
+ private anyPending;
325
+ private isTerminal;
326
+ private fallbackFor;
257
327
  /**
258
- * Call when the stream request has been opened.
328
+ * Call when live copy is coming: when the stream request opens, or earlier
329
+ * when a renderer already knows it will (a stream waiting on a shared
330
+ * variant). Idempotent.
259
331
  *
260
- * Arms the hold if — and only if — there is initial copy on the canvas the
261
- * visitor has not seen yet. That is the intended path: the hold arms before
262
- * the first frame, so the fallback never shows ahead of the stream. Copy
263
- * the visitor is already looking at (`initialIsOnScreen`, `serverRendered`)
264
- * is left alone — taking it away now would be the bait and switch.
332
+ * Arms the hold when there is fallback copy the visitor has not seen. Copy
333
+ * the visitor can already see (`initialIsOnScreen`) or finished creative
334
+ * (`serverRendered`) is final instead: nothing to hold, nothing to replace.
265
335
  */
266
336
  streamOpened(): void;
267
337
  /**
268
- * Hold expired without the stream taking the canvas.
269
- *
270
- * Prefer whatever the stream actually delivered; the stored copy is the last
271
- * resort, never a first draft.
272
- */
273
- private onHoldExpired;
274
- /**
275
- * The stream is over and the canvas is final.
338
+ * Call when the slot is in view (already in view at mount counts). Only
339
+ * meaningful with `holdStartsInView`; otherwise the slot is in view from
340
+ * the start and this is a no-op.
276
341
  *
277
- * `readSseStream` keeps draining after a terminal event, and the parser's
278
- * flush can emit a trailing frame, so late copy events are reachable rather
279
- * than theoretical. Without this guard a post-`failed` chunk left
280
- * `typing: true` forever — a caret animating on a dead stream — and a
281
- * post-`done` chunk+done replaced settled text WITHOUT incrementing
282
- * `rewrites`, so the doctrine counter read 1 while the reader saw two
283
- * changes. Everything after the terminal event is dropped.
342
+ * At that moment copy the stream already delivered takes the canvas —
343
+ * finished fields land whole, unfinished ones type from the buffer — and
344
+ * with nothing delivered the hold budget starts.
284
345
  */
285
- private isTerminal;
346
+ slotInView(): void;
347
+ /** Milliseconds left before the hold's absolute maximum (2 × budget). */
348
+ private absoluteRemaining;
349
+ private armHold;
350
+ private startHoldTimer;
286
351
  private clearHoldTimer;
287
352
  /**
288
- * Hand the canvas to the live stream: what has accumulated paints at once,
289
- * so the rest can stream in behind it.
290
- *
291
- * `restoreUndelivered` distinguishes the two callers. On a chunk-driven
292
- * release the stream is alive and delivering, so undelivered fields stay
293
- * blank — typing into a blank canvas is their entrance, and `heldCopy`
294
- * outlives the hold so `done`/`failed` restore any field the stream never
295
- * takes. On budget EXPIRY the stream has sputtered: whatever it delivered
296
- * paints, and every undelivered field gets its fallback back now, because
297
- * blank is never the resting state the budget is allowed to buy.
353
+ * Apply a hold expiry whose time has already passed but whose timer has
354
+ * not run: a background tab throttles timers, and a page restored from the
355
+ * back/forward cache resumes with them overdue. Copy events call this
356
+ * first, so text arriving after the deadline is never shown in place of
357
+ * the fallback. Renderers call it when the page is shown again. Idempotent.
298
358
  */
299
- private releaseHoldToLive;
300
- /** Budget spent with nothing delivered: the fallback comes back, composed. */
301
- private restoreHeld;
359
+ checkDeadline(): void;
302
360
  /**
303
- * Record that the first paint has been replaced. Restoring the SAME fallback
304
- * text is not a rewrite — nothing on screen changed — which is why every
305
- * caller commits only when STREAM text actually painted, and restoreHeld
306
- * never commits at all.
361
+ * The budget ran out. While still holding: delivered copy takes the canvas
362
+ * and keeps streaming, and every field with nothing delivered gets its
363
+ * fallback — final. After a chunk already took the canvas: fields still
364
+ * blank get their fallback, final; fields the stream is writing continue.
307
365
  */
308
- private commitRewrite;
366
+ private onHoldExpired;
309
367
  /**
310
- * Terminal honesty pass over provenance.
368
+ * Hand the held canvas to the stream. Delivered fields take their text:
369
+ * finished ones whole, unfinished ones typing on. `reason` decides the
370
+ * rest:
311
371
  *
312
- * A field still in `fallbackOnCanvas` when the stream ends is showing the
313
- * customer's AUTHORED text: its buffered deltas never landed. Its `source`
314
- * still says `generated` because `*-start` declared an intent the stream
315
- * then failed to deliver — and `field.source` feeds every provenance
316
- * surface: the `data-content-source` stamp, `decideChrome` (and any opt-in
317
- * `InlineMark` the integrator renders from it), `$group.marked`, and the
318
- * impression beacon's `chromeMarked`. Leaving it would claim "adapted" on
319
- * copy nobody adapted, on all of them at once.
320
- *
321
- * Provenance must describe what is on screen, not what the server meant to
322
- * send. Under-claiming adapted copy costs credit; claiming authored copy is
323
- * adapted is a false statement, and that is the worse of the two.
324
- *
325
- * `fallbackOnCanvas` is NOT the whole set. A chunk-driven release leaves
326
- * undelivered fields blank — restoring them mid-stream would stage the
327
- * wipe-and-retype this machine exists to prevent — so they never enter that
328
- * set, and are instead restored from `heldCopy` by the terminal handlers.
329
- * That is the likeliest degradation there is: the server opens a field,
330
- * then times out. Demoting only `fallbackOnCanvas` left exactly that path
331
- * rendering "adapted for you" over the customer's own sentence.
332
- *
333
- * So callers pass the fields they restored, and both sets are demoted.
372
+ * - "chunk": the first text arrived in time. Undelivered fields stay
373
+ * blank and reserved; the budget keeps running for them.
374
+ * - "view": the slot just came into view with copy already buffered.
375
+ * - "expiry": the budget ran out; undelivered fields get their fallback,
376
+ * final.
377
+ * - "terminal": the stream ended; the terminal handler settles the rest.
334
378
  */
335
- private demoteUndeliveredProvenance;
379
+ private releaseHold;
380
+ /** Budget spent with nothing delivered: every fallback lands once, final. */
381
+ private restoreHeld;
336
382
  /** True once any field on canvas actually carries adapted copy. */
337
383
  private hasAdaptedCopyOnCanvas;
384
+ /** Copy events that arrive before the stream was opened open it first. */
385
+ private ensureOpened;
338
386
  /**
339
- * A field opened. Proof of life, and provenance — nothing more.
340
- *
341
- * The canvas changes hands on the first CHUNK, not here: the delivery mode
342
- * is recorded on the field but no longer consulted for hold release. All a
343
- * start does while holding is RESTART the fallback budget, because the
344
- * stream has just proven it is alive and the old budget was measuring a
345
- * connection that had not connected yet.
387
+ * A field opened: proof of life, and provenance. Restarts the hold budget
388
+ * once if it is running. Dropped for a final field.
346
389
  */
347
390
  copyStart(field: CopyField, mode: CopyMode | null, source: ContentSource): void;
348
391
  /**
349
- * A delta landed. Text on the wire is text we can show: the first chunk
350
- * takes the canvas away from the pending fallback, whatever the start
351
- * called itself. While the field shows fallback copy the visitor can see,
352
- * deltas buffer off-canvas and never paint delta by delta. (That the
353
- * buffer lands once at `*-done` is a known bug, file header: shown
354
- * fallback should be final.)
392
+ * A delta landed. While holding (and in view) the first text takes the
393
+ * canvas; on a live field it extends the text. Dropped for a final field.
355
394
  */
356
395
  copyChunk(field: CopyField, text: string): void;
357
396
  /**
358
- * A field closed. `text` is authoritative — a client that missed chunks can
359
- * paint this alone, which is what makes chunk loss a cosmetic problem
360
- * instead of a correctness one.
397
+ * A field closed. Its text completes what the field shows: it lands whole
398
+ * on a blank field and finishes a typed one. It never replaces different
399
+ * visible text — if it does not extend what was typed, the typed text
400
+ * stands (and development builds warn). For a final field this only marks
401
+ * it settled.
361
402
  */
362
403
  copyDone(field: CopyField, text: string): void;
404
+ /**
405
+ * The renderer will not show this field's copy: it does not fit the slot
406
+ * its fallback reserved. The fallback lands instead and is final for the
407
+ * view; later copy for the field is dropped. Call it before the copy is
408
+ * painted (a renderer measures before the browser paints), never for text
409
+ * the visitor has seen.
410
+ */
411
+ keepFallback(field: CopyField): void;
363
412
  themeReady(colors: Record<string, string>, sources: Record<string, string>): void;
364
413
  layoutReady(layout: string, manifest?: unknown): void;
365
414
  /**
366
415
  * Absence of this event means KEEP THE BAKED THEME WASH. It does not mean
367
- * clear the art layer — the server omits it when there is no creative image,
368
- * and a client that cleared on absence would blank the hero on exactly the
369
- * campaigns that never had a picture.
416
+ * clear the art layer — the server omits it when there is no creative image.
370
417
  *
371
- * The patch waits for the browser to DECODE the image first: consumers
372
- * render this state directly, and a backgroundImage that lands before
373
- * pixels exist paints the already-flipped theme against the old (or blank)
374
- * art — the contrast tear. On failure the patch still lands (the consumer's
375
- * own loading treatment owns that case); outside a browser it is immediate.
418
+ * The patch waits for the browser to DECODE the image first, so a new
419
+ * background never paints before its pixels exist. On failure the patch
420
+ * still lands; outside a browser it is immediate.
376
421
  */
377
422
  backgroundReady(bgImage: string): void;
378
423
  sequenceReady(frames: string[], heroIndex: number, fps: number): void;
@@ -380,19 +425,17 @@ export declare class CopyChoreographer {
380
425
  ctaStyleReady(style: unknown): void;
381
426
  ctaReady(text: string, style: unknown, action?: string): void;
382
427
  /**
383
- * Terminal. Anything still held paints now, once, composed — the last
384
- * chance for the stream's copy to land as a single deliberate change rather
385
- * than being dropped because the budget happened to outlive the stream.
428
+ * Settle every field at the end of the stream. Final and closed fields stay
429
+ * as they are; a field that never painted gets its fallback; a field the
430
+ * stream was still writing goes through the `UnfinishedCopyPolicy`.
386
431
  */
432
+ private settleFields;
433
+ /** Terminal, success. */
387
434
  done(meta: Record<string, unknown>): void;
388
435
  /**
389
- * The stream failed.
390
- *
391
- * No failure blanks the canvas. A field that closed keeps its delivered
392
- * copy; a field the stream opened but never finished is repainted with its
393
- * WHOLE fallback and demoted to `static`; a still-blank field gets its
394
- * fallback back. The worst honest outcome is that the creative simply
395
- * stopped adapting, which looks like a normal page.
436
+ * Terminal, failure. No failure blanks the canvas: copy that closed stays,
437
+ * a blank field gets its fallback, and a field the stream was still writing
438
+ * goes through the `UnfinishedCopyPolicy`.
396
439
  */
397
440
  failed(message: string): void;
398
441
  /** Release timers. Safe to call twice. */