Using Deckle

Pre-release (0.5.0). Nothing here is a stable public contract yet.

Install

pnpm add @dopejs/deckle

Add whichever packages you need — they are independent and depend only on each other:

pnpm add @dopejs/deckle-artifact @dopejs/deckle-security @dopejs/deckle-renderer

The API below is real and covered by the repository test suite. The public surface may still change before the first stable release, and browser-evidence gates are not exited — see the plan.

Scene and camera

The Scene Store owns every artifact's world frame. All mutation happens inside a transaction: a thrown error discards the draft and the previously committed scene — including the spatial index — stays untouched.

import { SceneStore, createCamera, panCamera, zoomCameraAt } from "@dopejs/deckle";

const store = new SceneStore();
const handle = store.transact((tx) =>
  tx.createArtifact("report-1", { x: 0, y: 0, width: 640, height: 480, zIndex: 0 }),
);

// Camera: world point at the viewport center, zoom in px per world unit.
let camera = createCamera({ x: 0, y: 0, zoom: 1, viewportWidth: 1280, viewportHeight: 800 });
camera = panCamera(camera, 120, -40); // drag by screen-space delta
camera = zoomCameraAt(camera, { x: 640, y: 400 }, 1.25); // anchor-stable zoom

store.queryPoint(320, 240); // → ["report-1"] via the spatial index

Visibility sets and budgets

Resources follow the viewport. VisibilityTracker classifies artifacts into visible, overscan, warm, cold, and pinned; BudgetLedger is the admission gate in front of any allocation.

import { BudgetLedger, VisibilityTracker } from "@dopejs/deckle";

const sets = new VisibilityTracker().compute(store, camera);
// sets.visible → mount or keep live; sets.cold → release resources

const budgets = new BudgetLedger({
  maxLiveArtifacts: 3,
  maxLiveDomNodes: 5_000,
  maxIframes: 1,
  maxGpuTextureBytes: 256 * 1024 * 1024,
  maxSnapshotCount: 128,
  maxParsedBytes: 8 * 1024 * 1024,
  maxRuntimeContexts: 4,
});
const decision = budgets.tryReserve({ maxLiveArtifacts: 1, maxGpuTextureBytes: 4_000_000 });
if (!decision.admitted) {
  // decision.dimension names the violated budget — surface it, don't allocate.
}

Lifecycle and revisions

Artifacts move through cold → parsed → snapshot ⇄ live → hibernated, with failed reachable from anywhere. Paint and interaction trees only commit when produced against the current source/state pair, and messages from a replaced runtime are rejected by epoch.

store.transact((tx) => {
  tx.transition(handle, "parsed");
  tx.transition(handle, "snapshot");
  tx.commitPaint(handle, 1, 1); // throws StaleRevisionError if source/state moved on
  tx.pin(handle, "focus"); // pinned artifacts can never be evicted
});

Sanitizing generated HTML

Untrusted HTML goes through the static-profile sanitizer before it is ever parsed for real. Output is re-serialized from allowlisted parts only; anything unparseable is rejected with a typed reason instead of guessed at.

import { sanitizeHtml } from "@dopejs/deckle-security";

const result = sanitizeHtml('<section onclick="alert(1)"><h1>Hi</h1></section>');
if (result.ok) {
  result.html; // '<section><h1>Hi</h1></section>'
  result.diagnostics; // [{ code: "dropped-attribute", detail: "section@onclick" }]
} else {
  result.reason; // "unparseable" | "quota-exceeded", with a typed violation
}

Streaming any node kind

An agent emits more than HTML: prose, markdown, code, JSON, and tabular rows all arrive incrementally, and each has its own idea of when received characters stop being provisional. A segmenter answers that question per kind; one engine drives them all.

import { jsonSegmenter, completeJsonPrefix, markdownSegmenter } from "@dopejs/deckle-artifact";
import { createSegmentedPort, StreamingIngestion } from "@dopejs/deckle";

// JSON commits at value boundaries; closing the open structures keeps the
// partial result parseable while the rest is still arriving.
const jsonPort = createSegmentedPort({
  segmenter: jsonSegmenter,
  render: (committed) => (committed ? completeJsonPrefix(committed) : ""),
  maxChars: 64_000,
});

// Markdown commits at closed constructs, so an unterminated fence or a
// half-written link never renders as prose and then reflows.
const markdownPort = createSegmentedPort({ segmenter: markdownSegmenter });

const ingestion = new StreamingIngestion(store, handle, jsonPort);

Boundaries by kind:

Segmenter Commits up to Typical pending
textSegmenter the last complete grapheme surrogate
codeSegmenter the last complete line partial-line
rowsSegmenter the last terminated row partial-line
markdownSegmenter the last closed construct open-fence
jsonSegmenter the last completed value incomplete-value
htmlSegmenter the last decided tag or text run rawtext:script

Every segmenter guarantees the boundary only moves forward, so a reader never sees an interpretation get retracted.

Streaming an HTML artifact from an agent

An agent produces HTML token by token. StreamingSanitizer renders only the part of the buffer whose parse is already decided, so a half-written tag or an unterminated <script> never reaches a renderer; StreamingIngestion moves the artifact through the streaming lifecycle state, pins it so it cannot be evicted mid-generation, and commits exactly one source revision at the end.

import { SceneStore, StreamCoalescer, StreamingIngestion } from "@dopejs/deckle";
import { StreamingSanitizer } from "@dopejs/deckle-security";

const store = new SceneStore();
const handle = store.transact((tx) =>
  tx.createArtifact("agent-report", { x: 0, y: 0, width: 640, height: 480, zIndex: 0 }),
);

const sanitizer = new StreamingSanitizer();
const ingestion = new StreamingIngestion(
  store,
  handle,
  sanitizer,
  new StreamCoalescer({ minIntervalMs: 66, minChars: 512 }),
);

for await (const token of agentResponse) {
  const tick = ingestion.push(token, performance.now());
  if (tick.rendered) render(tick.html); // safe prefix only
  if (tick.status === "rejected") break; // quota or malformed source
}

const final = ingestion.finish(); // commits sourceRevision, artifact becomes "parsed"
render(final.html);

Inspect the boundary directly when you need to explain what is being withheld:

import { computeSafePrefix } from "@dopejs/deckle-security";

computeSafePrefix('<p>done</p><div class="ca');
// → { length: 11, pending: "open-tag" }
computeSafePrefix("<p>done</p><script>steal()");
// → { length: 11, pending: "rawtext:script" }

Abandon a stream that stalls or is cancelled — the artifact fails, the pin is released, and the last provisional frame stays visible as a placeholder:

ingestion.abort("agent timed out");

Media and the states every artifact has

Images and video cannot stream: no prefix of the bytes is a smaller picture. They load, then resolve or fail. The host decodes and reports; the engine owns the lifecycle.

import { MediaIngestion } from "@dopejs/deckle";
import { compileLoading, compileMedia, compileError } from "@dopejs/deckle-renderer";

const media = new MediaIngestion(store, handle, "image"); // artifact is now "loading"
media.report(bytesSoFar, totalBytes); // determinate progress when a length is known
media.resolve({ width: 1200, height: 800 }); // → "parsed", one source revision
// media.fail("decode-failed", "the image data is not a supported format");

Pick the presentation from the artifact's lifecycle — every kind has all three:

const record = store.get(handle);
const blocks =
  record.lifecycle === "failed"
    ? compileError(record.failure!)
    : record.lifecycle === "loading"
      ? compileLoading({ kind, progress: media.progress.ratio })
      : compileMedia("image", media.metadata!);

Streamed kinds are loading too until their segmenter commits a first character — a chunk can arrive that commits nothing, so IngestionResult.loading keys off renderable content rather than receipt:

const tick = ingestion.push(token, performance.now());
if (tick.loading) renderSkeleton();

Hit testing cached artifacts

An interaction tree keeps internal selection working when paint is just a cached texture. The optimized tester is differentially validated against a naive oracle, which also remains available as a rollback path.

import { createInteractionTree } from "@dopejs/deckle-artifact";
import { CachedHitTester, resolveClick, NO_SELECTION } from "@dopejs/deckle-editor";

const tree = createInteractionTree("report-1", 1, 1, [
  { id: "root", bounds: { x: 0, y: 0, width: 640, height: 480 } },
  { id: "chart", parentId: "root", bounds: { x: 40, y: 40, width: 320, height: 240 } },
]);

const hit = new CachedHitTester(tree).hitTest(120, 90); // { nodeId: "chart", path: [...] }
const selection = resolveClick(tree, NO_SELECTION, hit?.path ?? []); // Figma-style refinement

Retained pictures and the texture budget

import { ReferencePictureBackend, TextureCache, selectLod } from "@dopejs/deckle-renderer";

const cache = new TextureCache(new ReferencePictureBackend(), {
  maxTotalBytes: 64 * 1024 * 1024,
  maxItemBytes: 8 * 1024 * 1024,
});
cache.pin("report-1"); // pinned entries are never evicted
selectLod(camera.zoom); // "live-or-full" | "full-snapshot" | "reduced-snapshot" | "placeholder"

What is deliberately not here yet

  • A DOM/browser adapter that mounts live artifacts (M0 browser evidence pending).
  • Published npm packages and semver guarantees.
  • Absolute performance and memory gate numbers — they are set from M0 measurements, not invented.

See the technical design for the full architecture and the delivery plan for gate status.

Last updated: