# @sparkletree/core

Framework-agnostic client for [SparkleTree](https://sparkletree.io): stream
adaptive campaign text into your site, with a fallback that carries the page
whenever the adapted copy is late or missing, theme tokens, and built-in
impression analytics.

**Building a React app?** Install
[`@sparkletree/react`](https://www.npmjs.com/package/@sparkletree/react)
instead; it wraps this package. Use `core` directly with Vue, Svelte, Solid,
or plain DOM.

```bash
npm install @sparkletree/core
```

## The shortest useful example

```ts
import { openStream, decideChrome } from "@sparkletree/core";

const handle = openStream({
  target: {
    apiBase: "https://your-embed-api.example",
    organizationId: "org_123",
    // Signed credential from the dashboard, sent as `st-key`. The server
    // refuses stream and content requests without it; test keys work alone
    // in development, live keys need a registered origin.
    publishableKey: "st_pk_test_…",
    campaignId: "camp_123",
    island: "hero",
  },
  // Required. This is what the visitor reads if the network is slow, the
  // campaign is paused, or generation fails, so write it as copy you would
  // ship on its own.
  initial: { headline: "Software that meets the moment" },
  // Set true when that copy is already visible (carried across a navigation,
  // for example). Copy a visitor can see is final for the view: never
  // blanked, typed over or replaced.
  initialIsOnScreen: false,
  onState: (state) => {
    // Decide the mark from the FIELD you are about to render, never from
    // `state.contentSource`. The stream-level value is the server's word on
    // the stream as a whole; a field the stream opened but never delivered is
    // repainted from your `initial` text and demoted to `source: "static"`
    // while that value still reads "generated". Marking it would stamp
    // "Adapted for you" on your own sentence.
    const { marked } = decideChrome(state.fields.headline.source, false);
    render(state.fields.headline.text, marked);
  },
});

const final = await handle.finished;
handle.close();
```

`openStream` returns `{ choreographer, finished, close }`.
`choreographer.getState()` is the current state; `finished` resolves with the
terminal state; `close()` aborts the request and releases timers.

## What's in the box

| Export | |
|---|---|
| `openStream` | Opens the stream and manages copy state for you. |
| `fetchContent` | One-shot fetch of campaign content, for a first-paint fetch of your own. Not used by `openStream`. |
| `CopyChoreographer`, `COPY_FIELDS`, `COPY_HOLD_MS` | The copy state machine, if you want to drive it yourself. |
| `SseParser`, `readSseStream` | Server-sent-events parsing, usable standalone. |
| `decideChrome`, `viewerPrefersStandard`, `setViewerPrefersStandard`, `STANDARD_MODE_KEY` | Whether copy counts as AI-adapted for this viewer (feed an opt-in disclosure mark from it), and the viewer's opt-out. |
| `applyTheme`, `themeStyle`, `tokenName`, `tokenSource`, `TOKEN_PREFIX` | Theme tokens delivered with the campaign. |
| `track`, `ImpressionRecorder`, `observeVisibility`, `sessionId` | Impression analytics. |
| `pageVariants`, `VariantRegistry`, `RESERVATION_TIMEOUT_MS` | Variant pinning: one page view sees one variant. |
| `createClient`, `fetchFragments`, `fragmentSwapPolicy`, `MAX_FRAGMENT_FIELDS`, `MAX_FALLBACK_CHARS` | Fragments: the keys and fallbacks you declare are the request schema. |
| `mountAudio`, `activePlayer`, `destroyActiveAudio` | Audio playback (no UI). |
| `buildStreamUrl`, `buildContentUrl`, `negotiatedProtocol`, `PROTOCOL_*`, `SDK_PARAM`, `VARIANT_PARAM` | Protocol plumbing. |

## Copy state

`state.fields.{greeting,headline,body,cta}` each carry
`{ text, source, mode, typing, settled }`, already sequenced for display: you
render `text` and use `typing`/`settled` for presentation only. Alongside them:
`phase`, `error`, `rewrites`, `contentSource`, `degraded`, `variantId`,
`held`, `theme`, `layout`, `backgroundImage`, `videoUrl`, `sequence`,
`ctaStyle`, `ctaAction`, `meta`.

`phase` moves `idle → holding? → live → settled | failed`. Nothing throws, and
no failure blanks copy a visitor is reading. The one deliberate blank is
`holding`, and even that one does not have to move the page: `state.held`
carries, per field, the copy the hold pulled off the canvas, so a renderer can
reserve its exact geometry while `text` is `""`. On `failed`, fields that
closed keep their delivered copy, a field the stream was still writing keeps
what it typed (unless `isClearlyErrorText` says it is an error, which is
replaced by your `initial` text with `crossfade: true`), and a blank field
gets your `initial` text, `source: "static"`.

## What the client guarantees

1. **A visitor always reads real copy.** Your `initial` text or the stream's:
   whatever is on the canvas is something somebody wrote.
2. **Text a visitor can see is never replaced.** Copy not yet on screen is
   held back: the field's `text` goes `""` while the canvas waits for the
   stream, and your `initial` text lands only if the hold expires first
   (`holdMs`, default `COPY_HOLD_MS` of 2500 ms, restarted once if the
   stream proves it is alive; with `holdStartsInView` the budget starts at
   `slotInView()`). Once it has landed it is final: the stream's later copy
   for that field is dropped. Render `state.held` invisibly for the duration
   and the space is occupied throughout, which is what `@sparkletree/react`
   does for you. Copy already on screen (`initialIsOnScreen`) and finished
   creative (`serverRendered`) are final from the start. `rewrites` is
   always 0 from one stream.
3. **The server declares how content was produced** (static, cached, or
   generated); the client never guesses from timing.
4. **The chrome decision is honest.** `decideChrome` returns `marked` for
   generated and cached content, never for static, and never for any
   provenance once the viewer has asked for the standard version. Rendering a
   visible disclosure mark from that decision is the integrator's opt-in.

Full documentation:
[docs.sparkletree.io](https://docs.sparkletree.io/developers/sdk/core/)

## Requirements

ES2022, ESM only, Node >= 18. `fetch`, `ReadableStream` and
`AbortController` must exist; inject `fetchImpl` to substitute them. No
dependencies.

There is no `require` export condition, which matters in two places:

- **From CommonJS**, `require("@sparkletree/core")` fails on Node 18 with
  `ERR_REQUIRE_ESM`. Use `await import("@sparkletree/core")`. (Node 26 loads it
  either way, so this depends on your runtime, not your code.)
- **In TypeScript**, `moduleResolution: "bundler"` resolves cleanly. So does
  `"node16"`/`"nodenext"` *if your own package.json has `"type": "module"`*.
  Without it you get `TS1479` telling you to use a dynamic import: accurate,
  not a false positive.
