/** * Copy choreography — the hold/settle state machine. * * This is the part of the SDK that is not a fetch wrapper. The principle it * serves is a user-experience one, and everything else here follows from it: * * TEXT A VISITOR CAN SEE IS NEVER REPLACED WHILE IT IS ON SCREEN. * * 1. When a copy slot comes into view and live copy is coming, the slot * holds blank with its geometry reserved for a short budget * (`COPY_HOLD_MS`), restarting once when the stream proves it is alive. * 2. Stream text arriving inside the hold types into the blank slot (a * cached replay's single flush lands whole). The fallback is never seen. * 3. If the hold runs out with nothing delivered, the fallback lands once * and is FINAL for this view: any later stream text for that field is * dropped — never painted, never composed over it. * 4. Art, background and chrome are never held; only text slots are. * * Every field is in exactly one of three conditions: * * - blank: held (reserved) or simply empty, waiting for the stream; * - live: stream text is on the canvas; later chunks EXTEND it (typing), * and its `*-done` completes it — never replaces it; * - final: copy the visitor can read that the stream does not own — the * fallback after the hold, finished creative the platform rendered, or * copy that was already on screen when the stream opened. Copy events * for a final field are dropped. * * There is one exception to "never replaced", and it is isolated in one * function: a sentence the stream was still WRITING when it failed. See * `UnfinishedCopyPolicy`. */ import { type ContentSource, type CopyMode } from "./wire.js"; export type CopyField = "greeting" | "headline" | "body" | "cta"; export declare const COPY_FIELDS: readonly CopyField[]; /** * How long a copy slot holds blank, waiting for live copy, before the * fallback lands. * * The budget starts when the slot is in view (or when the stream opens, for * a renderer that does not report visibility), and restarts ONCE when the * stream proves it is alive (its first `*-start`). So the longest a slot can * hold is `COPY_HOLD_MAX_MS`. */ export declare const COPY_HOLD_MS = 2500; /** The longest a slot can hold: one budget plus the one restart. */ export declare const COPY_HOLD_MAX_MS: number; /** * The pure-CSS failsafe for server-rendered reservations: if the page's * JavaScript never takes control, reserved fallback copy becomes visible on * its own after this long. Equal to the longest hold, so the failsafe can * never reveal copy a running hold still owns. */ export declare const RESERVE_FAILSAFE_MS: number; /** * The one motion grammar for copy: every fade and cross-fade runs for this * long on `COPY_MOTION_EASING`. Typing has its own pace; nothing else moves. */ export declare const COPY_MOTION_MS = 700; /** The easing curve for every copy fade and cross-fade. */ export declare const COPY_MOTION_EASING = "cubic-bezier(0.16, 1, 0.3, 1)"; export interface FieldState { /** What a consumer should render right now. Never a placeholder. */ text: string; /** Provenance of `text`. `generated` and `cached` may carry the adaptation * mark, `static` may not — `decideChrome` is the one place that decides. */ source: ContentSource; /** Declared delivery mode, once the field has opened. */ mode: CopyMode | null; /** True while live deltas are landing — consumers may animate a caret. */ typing: boolean; /** `text` is final for this stream: closed by the server, or settled by * the SDK at the end of the stream. */ settled: boolean; /** * True when `text` deliberately replaces text that may have been visible: * an unfinished sentence withdrawn after a failure (`UnfinishedCopyPolicy`) * or new copy after the campaign or context changed. Renderers cross-fade * once (`COPY_MOTION_MS`, `COPY_MOTION_EASING`) instead of typing. Absent * on every other field — no other change ever replaces visible text. */ crossfade?: boolean; } export type Phase = /** Nothing started. */ "idle" /** Copy slots are deliberately blank (reserved), waiting for live copy. */ | "holding" /** Copy is on the canvas and the stream is still open. */ | "live" /** `done` received. */ | "settled" /** `error`, or the transport failed. Copy on the canvas stays. */ | "failed"; export interface CreativeState { phase: Phase; fields: Record; theme: { colors: Record; sources: Record; } | null; layout: string | null; experienceManifest: unknown; backgroundImage: string | null; sequence: { frames: string[]; heroIndex: number; fps: number; } | null; videoUrl: string | null; ctaStyle: unknown; ctaAction: string | null; /** Terminal `done.meta`, verbatim. */ meta: Record | null; /** * Provenance of the copy on the canvas, stream-wide: the strongest any * field declared while live, the server's terminal word once settled, and * `static` whenever no adapted copy actually reached the canvas. Decide a * disclosure mark per field (`field.source`), not from this. */ contentSource: ContentSource; /** Set when the server served a deterministic winner instead. */ degraded: string | null; /** Resolved variant, for pinning subsequent calls in this page view. */ variantId: string | null; /** Human-readable failure, if any. Never blanks painted copy. */ error: string | null; /** * What the hold reserved, per field: the copy that will land if the hold * runs out. A renderer draws it invisibly so a blank field keeps its exact * geometry. Null when no hold armed in this view. * * Optional so a hand-built `CreativeState` (a test mock, a story fixture) * still compiles: read it as `state.held?.`. */ held?: Partial> | null; /** * How many times text the visitor could see was replaced. Always `0` from * a single stream: the hold, the stream's entrance and the fallback's * landing all write into blank slots. `useIsland` adds one per cross-fade * after a campaign or context change, the only replacement there is. */ rewrites: number; } /** What `UnfinishedCopyPolicy` decides about. */ export interface UnfinishedCopy { field: CopyField; /** The text the stream had written when it stopped, possibly mid-word. */ typed: string; /** The fallback that would replace it. Never empty when the policy is asked. */ fallback: string; } /** * `"keep"`: the typed text stays and settles where it is. * `"fallback"`: the typed text fades out and the fallback fades in, once. */ export type UnfinishedCopyDecision = "keep" | "fallback"; /** * Decides what happens to a field the stream was still writing when it * ended without closing it (an error, a dropped connection, or a `done` that * arrived without the field's own close). * * This is the one place visible text may be replaced, and only when the * policy says so: the default (`keepUnlessClearlyError`) keeps what was * typed unless it is clearly an error. A replacement is a single cross-fade * (`FieldState.crossfade`). A field whose text was finished is never asked * about; a field that never painted simply gets its fallback. */ export type UnfinishedCopyPolicy = (copy: UnfinishedCopy) => UnfinishedCopyDecision; /** * True when `text` is very clearly not copy: it contains an error message, a * stack or status text, raw markup or JSON, or a model refusal. Conservative * by design — anything that could be ordinary copy returns false. */ export declare function isClearlyErrorText(text: string): boolean; /** * The default `UnfinishedCopyPolicy`: keep whatever the stream already typed, * even an unfinished line. Only text that is clearly an error * (`isClearlyErrorText`) is replaced, with one fade to the fallback. */ export declare const keepUnlessClearlyError: UnfinishedCopyPolicy; export interface ChoreographerOptions { /** * The fallback: what lands if live copy does not arrive in time. Required by * doctrine — there is no skeleton mode. */ initial?: Partial>; /** * True when the initial copy is ALREADY VISIBLE when the stream opens: copy * at rest from earlier in this view, or a fallback the page revealed before * the SDK could hold it. Every non-empty field is then final for this * view: no hold, and the stream's copy events for it are dropped. * * Leave it unset for copy that has not been seen yet — the normal case. * The hold then keeps the slot blank until live copy or the fallback lands. */ initialIsOnScreen?: boolean; /** * True when the initial copy is FINISHED CAMPAIGN CREATIVE rendered by the * platform — not a developer's fallback. It is the copy for this view: * nothing holds, and the stream's copy events for those fields are dropped * (theme, media and the action still apply). Fields left empty may still * be written by the stream. * * Do not set it for a `fallback`: a fallback is what lands only when live * copy does not. */ serverRendered?: boolean; /** * The hold budget for this view. Defaults to `COPY_HOLD_MS`; restarts once * when the stream proves alive. However the restart falls, the hold never * lasts longer than twice this in total. */ holdMs?: number; /** * How long the slot has ALREADY been holding before this choreographer took * over — for example, since a server-rendered reservation first painted. * It is spent from the budget and from the absolute maximum, so the wait * before hydration and the wait after it are one hold. Defaults to 0. */ holdElapsedMs?: number; /** * Provenance of the `initial` copy when it is finished creative * (`serverRendered`), for example copy already generated for this view. * Those fields carry it as their `source` and are settled from the start. * Defaults to `static`. */ initialSource?: ContentSource; /** The variant the `initial` copy belongs to, if known. Seeds `variantId`. */ initialVariantId?: string; /** * With `serverRendered`: the `initial` copy is the WHOLE copy for this * view, so the stream writes no field, empty ones included. Use it for * copy decided before the slot was shown (for example cached copy a * server read for this visitor): a field it left empty stays empty rather * than gaining text later. */ initialIsComplete?: boolean; /** * When true, the hold budget does not start until `slotInView()` is called. * The stream may open (and buffer) earlier; a slot that never comes into * view never shows anything changing. When false (the default), the slot * is treated as in view from `streamOpened()`. */ holdStartsInView?: boolean; /** * When the held canvas is handed to the stream. * * `"text"` (default): on the first text the stream delivers — live copy * types into the blank slot. * * `"complete"`: only when the stream finishes (`done`), so the whole new * copy lands at once. For a slot whose canvas is showing something else in * the meantime (a campaign or context change); the hold budget still * applies. */ releaseOn?: "text" | "complete"; /** What happens to a field the stream was still writing when it ended. * Defaults to `keepUnlessClearlyError`. */ unfinishedCopy?: UnfinishedCopyPolicy; /** * When true, the fallback decides which fields exist: a field whose * `initial` copy is empty is never shown, and the stream's copy for it is * dropped. A slot sized by its fallback has no room for a field the * fallback does not have. Ignored when `initial` has no copy at all. */ fallbackDefinesSlots?: boolean; /** * Per-field provenance of the `initial` copy when it is finished creative * (`serverRendered`). Overrides `initialSource` for the fields it names, * for copy that mixes provenance, such as live copy for one field and the * fallback for another. */ initialSources?: Partial>; /** * The authored fallback, when `initial` is other copy (for example copy * already produced for this view). It is what `keepFallback` lands. * Defaults to `initial`. */ fallback?: Partial>; /** Injectable for tests; defaults to the platform timers and clock. */ setTimeout?: (fn: () => void, ms: number) => unknown; clearTimeout?: (handle: unknown) => void; now?: () => number; } type Listener = (state: CreativeState) => void; /** * Owns one island's copy for one page view. Fed wire events; emits states. * * Pure with respect to the DOM: it decides WHAT should be on screen and when, * and never touches how. That is what lets the same choreography drive React, * the iframe shell, and anything else without several copies of these rules. */ export declare class CopyChoreographer { private readonly options; private state; private listeners; private readonly holdMs; private readonly setTimer; private readonly clearTimer; private readonly releaseOn; private readonly unfinishedPolicy; private heldCopy; private holdTimer; private holdRestarted; private holdStartedAt; private holdDueAt; private readonly now; private inView; private pending; private final; private live; private started; private closed; private readonly initialCopy; private disposed; constructor(options?: ChoreographerOptions); getState(): CreativeState; subscribe(listener: Listener): () => void; private emit; private patch; private patchField; private hasInitialCopy; private anyPending; private isTerminal; private fallbackFor; /** * Call when live copy is coming: when the stream request opens, or earlier * when a renderer already knows it will (a stream waiting on a shared * variant). Idempotent. * * Arms the hold when there is fallback copy the visitor has not seen. Copy * the visitor can already see (`initialIsOnScreen`) or finished creative * (`serverRendered`) is final instead: nothing to hold, nothing to replace. */ streamOpened(): void; /** * Call when the slot is in view (already in view at mount counts). Only * meaningful with `holdStartsInView`; otherwise the slot is in view from * the start and this is a no-op. * * At that moment copy the stream already delivered takes the canvas — * finished fields land whole, unfinished ones type from the buffer — and * with nothing delivered the hold budget starts. */ slotInView(): void; /** Milliseconds left before the hold's absolute maximum (2 × budget). */ private absoluteRemaining; private armHold; private startHoldTimer; private clearHoldTimer; /** * Apply a hold expiry whose time has already passed but whose timer has * not run: a background tab throttles timers, and a page restored from the * back/forward cache resumes with them overdue. Copy events call this * first, so text arriving after the deadline is never shown in place of * the fallback. Renderers call it when the page is shown again. Idempotent. */ checkDeadline(): void; /** * The budget ran out. While still holding: delivered copy takes the canvas * and keeps streaming, and every field with nothing delivered gets its * fallback — final. After a chunk already took the canvas: fields still * blank get their fallback, final; fields the stream is writing continue. */ private onHoldExpired; /** * Hand the held canvas to the stream. Delivered fields take their text: * finished ones whole, unfinished ones typing on. `reason` decides the * rest: * * - "chunk": the first text arrived in time. Undelivered fields stay * blank and reserved; the budget keeps running for them. * - "view": the slot just came into view with copy already buffered. * - "expiry": the budget ran out; undelivered fields get their fallback, * final. * - "terminal": the stream ended; the terminal handler settles the rest. */ private releaseHold; /** Budget spent with nothing delivered: every fallback lands once, final. */ private restoreHeld; /** True once any field on canvas actually carries adapted copy. */ private hasAdaptedCopyOnCanvas; /** Copy events that arrive before the stream was opened open it first. */ private ensureOpened; /** * A field opened: proof of life, and provenance. Restarts the hold budget * once if it is running. Dropped for a final field. */ copyStart(field: CopyField, mode: CopyMode | null, source: ContentSource): void; /** * A delta landed. While holding (and in view) the first text takes the * canvas; on a live field it extends the text. Dropped for a final field. */ copyChunk(field: CopyField, text: string): void; /** * A field closed. Its text completes what the field shows: it lands whole * on a blank field and finishes a typed one. It never replaces different * visible text — if it does not extend what was typed, the typed text * stands (and development builds warn). For a final field this only marks * it settled. */ copyDone(field: CopyField, text: string): void; /** * The renderer will not show this field's copy: it does not fit the slot * its fallback reserved. The fallback lands instead and is final for the * view; later copy for the field is dropped. Call it before the copy is * painted (a renderer measures before the browser paints), never for text * the visitor has seen. */ keepFallback(field: CopyField): void; themeReady(colors: Record, sources: Record): void; layoutReady(layout: string, manifest?: unknown): void; /** * Absence of this event means KEEP THE BAKED THEME WASH. It does not mean * clear the art layer — the server omits it when there is no creative image. * * The patch waits for the browser to DECODE the image first, so a new * background never paints before its pixels exist. On failure the patch * still lands; outside a browser it is immediate. */ backgroundReady(bgImage: string): void; sequenceReady(frames: string[], heroIndex: number, fps: number): void; videoReady(videoUrl: string): void; ctaStyleReady(style: unknown): void; ctaReady(text: string, style: unknown, action?: string): void; /** * Settle every field at the end of the stream. Final and closed fields stay * as they are; a field that never painted gets its fallback; a field the * stream was still writing goes through the `UnfinishedCopyPolicy`. */ private settleFields; /** Terminal, success. */ done(meta: Record): void; /** * Terminal, failure. No failure blanks the canvas: copy that closed stays, * a blank field gets its fallback, and a field the stream was still writing * goes through the `UnfinishedCopyPolicy`. */ failed(message: string): void; /** Release timers. Safe to call twice. */ dispose(): void; private promoteContentSource; } export {}; //# sourceMappingURL=state.d.ts.map