Skip to content
Mocky/Docs v0.2
Architecture

Architecture overview

29 min read

1. Where things live#

The single most structural fact about the project:

The generation pipeline runs in the browser.

ConcernRuns inFiles
Capability selection (deterministic)Browsersrc/lib/capabilities/select.ts
Planner (optional, structured output)Browsersrc/lib/plan.ts
Generation, editing, repair (streamed)Browsersrc/lib/generate.ts
Quality pass: check a screen, then correct itBrowser; detection on the serversrc/lib/quality.ts, polish.ts, server/muse/quality/
SEO / accessibility audit: markup rules, then correct itBrowser; the judged half on the serversrc/lib/audit/, server/muse/quality/audit-judge.js
Pipeline orchestration and phasesBrowser (React)src/components/ProjectView.tsx
DESIGN.md bridge (preamble, tokens, spec, export)Browsersrc/lib/design.ts, designTokens.ts, designSpec.ts, export/
A direction read as a specification sheet, and edited as oneBrowsersrc/lib/designSpec.ts, src/components/DesignSpecSheet.tsx
Which direction governs a generationBrowsersrc/lib/direction.ts
Sandboxed renderBrowsersrc/components/Preview.tsx, lib/capabilities/prelude.ts
PersistencelocalStorage, mirrored to the server when signed insrc/lib/project.ts, sync.ts, merge.ts
Accounts, SSO, JSON sync, model proxyServerserver/index.js, server/provider-proxy.js
Per-account usage: projects, screens, diskServerserver/usage.js
Framing the infinite canvas (fit, focus, latest)Browsersrc/lib/framing.ts
Finding and swapping the images inside a screen (AST, no model)Browsersrc/lib/screenImages.ts, src/components/ScreenImagesDialog.tsx
Finding and swapping the scroll sequences inside a screen (AST, no model)Browsersrc/lib/screenSequences.ts — a pair, base and frames, rewritten together
Muse: MCP, fetching, distillation, dossierServerserver/muse/
Images, videos, librariesServerserver/images/, server/videos/
Video export: schema, the one model call, queue, storeServerserver/video/ — note the singular; server/videos/ is the clip library
Video export: the actual renderA separate, opt-in Docker serviceworker/video/ — Remotion, plus three for the 3D blocks and the continuous world, lottie-web for the animated icons and sixty bundled typefaces. Absent unless --profile video-export was built

The back end is deliberately small: JSON files under server/data/, no database, no native dependencies. The runtime dependencies are express, cookie-parser, @modelcontextprotocol/sdk and zod for Muse, and impeccable for the quality pass.

Writes are atomic — write to a temporary file, then rename. A crash mid-write never leaves a half-written file.

This "no database, no native dependencies" posture is a de facto invariant, and the node:22-slim image depends on it holding. impeccable does not weaken it: its six runtime dependencies are all pure JavaScript, and the Puppeteer it declares is optional, for a URL-scanning engine Mocky never calls. See invariants for why that flag lives in the Dockerfile and not in an .npmrc.


2. The capability registry#

A capability is something Mocky injects into the preview so a generated component can use code it did not write itself. There are three kinds, declared in src/lib/capabilities/types.ts:

JavaScript
export type CapabilityKind = 'cdn-script' | 'cdn-css' | 'snippet-pack'
  • snippet-pack — plain JSX, held as a string, prepended to the generated code before Babel.transform. This is the dominant kind.
  • cdn-css — a tag.
  • cdn-script — a .
  • The prelude is encoded the same way, when there is one.
  • Babel.transform(prelude + '\n' + source, { presets: [['react', { runtime: 'classic' }]] }) runs inside the iframe.
  • The result executes through a blob: URL, which gives real error positions.
  • The component mounts inside a React error boundary.
  • The boundary is necessary because createRoot renders asynchronously. A render error is thrown after the synchronous try/catch has already returned, so it would escape to window.onerror as an opaque "Script error." — the module comes from a blob:null origin.

    The boundary catches it with the real message and the component stack, and posts it to the parent. That feeds both the error box and fixComponent. It only ever fires on real errors, which is invariant I5.

    React error #130 is rewritten before being reported, because its minified message teaches nothing:

    Element type is invalid (React #130): a component or icon you rendered is undefined — likely a missing or misspelled name.

    The interaction bridge#

    A small script inside the frame talks to the parent over postMessage.

    MessageDirectionPurpose
    pick modeParent → frameHighlight the hovered element. On click, report a CSS selector, the visible text, the tag and the class string
    demo modeParent → frameGiven a list of {selector, target} pairs, a click asks the parent to navigate
    okFrame → parentThe component mounted successfully
    errorFrame → parentA compile or runtime error, with its real message
    sizeFrame → parentThe rendered content height

    In pick mode the selection is exact for Modify, and walks up to the nearest interactive ancestor for Link.

    Identity comes from the sending window, never from a field inside the message. frameId is written in clear into every srcDoc, so one preview could read another's id out of the DOM and forge messages on its behalf. And e.origin is the useless string "null" for every sandboxed frame.

    JavaScript
    if (e.source !== iframeRef.current?.contentWindow) return   // in the parentif (e.source !== window.parent) return                      // in the frame

    Symmetrically, a frame may only report a pick while pick mode is actually on, and may only request navigation while it has demo links. Without those checks a rendered component could drive the parent's UI at will.

    Keeping the mockup inside its own document#

    A sandboxed frame is always allowed to navigate itself. An , a submitted form, a location.assign() — any of them make the frame drop the srcDoc and load Mocky's own index.html. Because its origin is opaque, every module script of the app then fails CORS: a white screen, a console full of errors, and the screen the user just generated is gone.

    Four guards, in depth:

    1. window.open is neutralised before any generated code runs. window.location is deliberately untouched: it is a non-configurable accessor, so redefining it throws and would take the whole bridge down.
    2. A capturing click handler cancels every and , fragments included. A srcdoc document inherits the parent's URL as its base, so #pricing resolves to http://localhost:8787/#pricing — a different document. The scroll a fragment was meant to perform is done by hand with scrollIntoView, so in-page anchors still behave like anchors.
    3. Form submissions are cancelled. A
      with no action posts to the document URL, which is Mocky's own page.
    4. The parent counts the frame's load events. The first load is the srcDoc; any later one means the frame went elsewhere. The parent then re-assigns srcdoc, an attribute it owns whatever the frame's origin, and shows a "links are inert" notice for three seconds.

    Timing#

    The srcDoc is rebuilt with a 500 ms debounce, so a token stream does not rebuild the iframe on every character.

    A 20 second timeout prevents waiting forever if no message arrives.

    During generation, errors are ignored because the code is incomplete by construction. An error whose source code has changed since the srcDoc was built is discarded as stale.

    Capture: the exception that no longer is#

    src/lib/capture.ts used to mount a same-origin iframe, and this document used to explain at length why that was unavoidable. It no longer is.

    The reason was html2canvas: it has to read the document it photographs, and it clones that document into an iframe of its own, so a sandbox without allow-same-origin gave every descendant a fresh opaque origin and the frame could not read its own clone. It failed with "Blocked a frame with origin null from accessing a cross-origin frame", on the default path and with foreignObjectRendering alike.

    Mocky now snapshots with snapdom, which serializes the subtree into an SVG with every node's computed style inlined, then rasterizes that through a data: URL. Nothing in that path crosses an origin boundary, so the capture frame carries allow-scripts and nothing else — exactly like a preview. For the second or so of a capture, the model's code no longer runs with Mocky's origin, which means it can no longer read the provider API key out of localStorage or reach into window.parent.

    Measured in an opaque origin before the change was made: the capture succeeds, toDataURL() does not throw (the canvas is not tainted), Tailwind's injected utilities are honoured, and rgb(var(--token) / 0.5) composites to the exact pixel. 113 ms by default against 111 ms for html2canvas with the origin open.

    Two consequences worth knowing:

    • connect-src had to open to this origin. snapdom inlines a picture by fetching its bytes; under connect-src 'none' every fetch was blocked and each image became a grey placeholder. /api/images/:hash therefore also answers with Access-Control-Allow-Origin: * — it is already unauthenticated by design, so a wildcard exposes nothing. Every other outbound directive stays shut, so there is still nowhere to send anything.
    • A hidden tab defers the capture. snapdom rasterizes by awaiting img.decode(), which never settles in a document the browser is not compositing: backgrounded, the same capture took 39 s where html2canvas took 1 s. The shell now waits for visibilitychange rather than burning its watchdog.

    7. One dialect for every model#

    Mocky always speaks the Ollama dialect internally: POST /api/chat, with options, num_ctx, num_predict, format, and NDJSON streaming.

    server/text/dialect.js translates to and from OpenAI-compatible APIs: request shape, response_format, vision attachments as image_url, and SSE to NDJSON. Generation, the planner and Muse are therefore vendor-agnostic, with no second code path.

    A model that thinks instead of answering#

    Switching to a reasoning model produced generation after generation with nothing in them, and one message: "the model returned an empty response". Three different things hide behind that sentence, and the translator used to drop the evidence for all three — it read content and nothing else.

    A reasoning model answers on two channels, reasoning (or reasoning_content, or a structured reasoning_details, depending on the vendor) and content. Mocky wants the second and must never mistake the first for it: a component extracted from a chain of thought is not a component. So the thinking is COUNTED and never forwarded, a refusal the provider put in the body of a 200 (no credit for that model, a rate limit, an upstream that declined) is quoted rather than swallowed, and the browser turns the fact into a sentence someone can act on — emptyAnswer in generate.ts.

    Then the cause itself. One real run spent 110 220 characters on thinking and wrote no code, while max_tokens: 16384 bounded nothing: that vendor does not count reasoning against it. reasoning is OpenRouter's own parameter for exactly this — it normalises a thinking budget across the vendors behind it and ignores it for models that cannot reason — so buildUpstream adds reasoning: { effort: 'low' } when the target host is OpenRouter, and nowhere else: api.openai.com answers 400 to a body key it does not know, and a blind parameter would break every OpenAI, Groq and Together user to fix one OpenRouter user. "low" rather than "none", because the models worth using do think — what is refused is thinking without end.

    The proxy lives in two places that share the same module: a Vite middleware in development, and app.use('/__provider', …) in Express for production.

    Three protections apply.

    An allowlist of subpaths. /api/chat and /api/tags, nothing else.

    An SSRF guard. assertSafeTargetResolved() accepts http and https only, rejects localhost, private ranges, link-local addresses and 169.254.169.254 — then resolves the hostname in DNS and re-checks every returned address. Without that second step, a hostname the caller controls (evil.test → A 127.0.0.1) walked past the string tests untouched. IPv4-mapped IPv6 forms are covered explicitly, in both spellings: ::ffff:127.0.0.1 and its hexadecimal twin ::ffff:7f00:1.

    A bounded body. readRawBody() stops at 25 MB. Unbounded, it accumulated whatever the client sent and then called Buffer.concat inside the end listener. A body past buffer.constants.MAX_LENGTH threw outside any promise chain and, with no uncaughtException handler, took the whole server down.

    An administrator-configured target deliberately bypasses the SSRF guard. Pointing at a local model — Ollama, LM Studio or vLLM on 127.0.0.1 — is a supported setup, and only an administrator can set it. The guard stays fully in force for any URL that came from a browser.

    When an instance provider is configured, /__provider requires a session. The request spends the host's credits, so it must belong to someone. With no instance provider, the caller supplies its own key and the "your key never leaves your browser" mode is preserved.


    8. Persistence#

    In the browser#

    localStorage keyContents
    mocky.projects.v1Projects, with screens, positions and links — and each project's own design direction
    mocky.design.v1The global DESIGN.md and its toggle — the fallback for a project that has no direction of its own
    mocky.settings.v1Provider, base URL, API key, planner on or off
    mocky.muse.v1Muse configuration: inspiration URLs, image mode, video, pinned media
    mocky.animations.v1auto, on or off

    On the server#

    One file per user, server/data/data-.json, holding serialised projects and design plus an updatedAt timestamp. Two routes: GET /api/data and PUT /api/data.

    Syncing is deferred and observable. scheduleSync() marks state dirty, and a idle | syncing | failed state is broadcast to subscribers so a failure is visible in the UI. Previously a sync that gave up after thirty seconds of retries was visible to nobody, not even the console.

    Reconciliation compares updatedAt on both sides instead of assuming the server is fresher, which used to overwrite local work. The merge in src/lib/merge.ts uses tombstones with a TTL, so a deletion on one device does not come back from another.

    The server store#

    Path under server/data/Contents
    users.jsonAccounts: scrypt salt and hash, role, dashySub
    sessions.jsonToken → { u: userId, t: timestamp, c: opened, ua: "Firefox 131 · Windows", ip } — the device is a summary, never the header. Only a hash of the token ever reaches the admin dashboard (D2)
    config.json{ allowRegistration, maintenance, announcement } — the second is { on, message, since }, and it travels with a migration, which is why an imported instance boots read-only; the third is the administrator's banner, { id, message, tone, createdAt, startsAt, expiresAt } — its dates in the text are {{datetime:ISO}} instants, written out by each browser in its own zone
    audit.jsonlAdmin → Audit log: one JSON object per line, the last 2,000. Sign-ins, accounts, sessions, settings (field NAMES only), maintenance, announcements, migrations. The one dashboard store on disk (D4)
    sso-jti.jsonConsumed SSO token ids, pruned after 10 minutes
    data-.jsonOne user's projects and design
    avatars/One file per account that uploaded a picture. Counted as bytes.avatar in the usage report
    text-config.jsonAdministrator-configured text providers, including secrets
    images-config.jsonImage providers and scroll-sequence video settings, including secrets
    video-config.jsonVideo export settings: the master switch, the access mode and its allowlist, the worker URL, and the Remotion licence key in clear — hence mode 0600, and hence publicView() turning it into a hasLicenseKey boolean before anything leaves the server
    muse-cache.jsonDistillations, 7-day TTL, text only
    image-library.jsonImage library metadata — including owners, the account ids that put each file there, capped at 20
    image-library/The image bytes
    video-library/Scroll sequences: clip, frames and poster. Its metadata carries owners under the same cap
    video-exports.jsonExported films: bytes, container, scene count, duration — and owners under the same cap. Never the timeline, which carries somebody's overlay text
    video-exports/.mp4|.webmThe finished film, whole. A different directory from video-library/ on purpose: that one holds scroll sequences, cut into stills by ffmpeg, and every consumer of its list() expects frames a film does not have
    video-jobs.jsonThe render queue's journal: the newest 50 finished jobs, plus whatever is live. A job found mid-flight at boot is marked failed, never resumed
    .migration/A migration's own state on the NEW server: staging/ (the files pulled so far), staged.json (their hashes, so a pass resumes), report.json (the last import, for Check integrity) and previous- (what the data directory held before the swap — moved, never deleted). Never listed by a manifest itself

    Files holding secrets are written with mode 0600. The default 0644 left them readable by every other account on the machine.

    The cap on owners is the same one tags and projects carry beside it, for the same reason: these indexes are re-serialised whole on every write, so nothing inside them may grow without a ceiling.


    9. HTTP surface#

    Method and routeAuthPurpose
    GET /api/health—dataWritable and frontendBuilt; 503 with a detail naming what is wrong
    GET /api/config—Registration open?, setup mode, SSO, instance model (no secrets)
    POST /api/register, /api/loginrate-limitedThe first account becomes administrator
    POST /api/logout, GET /api/mecookie/api/me answers 200 { user: null }, not 401
    POST /api/presencesessionA tab's heartbeat — or its goodbye, as a beacon. 204; not counted as activity, allowed during maintenance (D5)
    POST /api/account/passwordsession, rate-limitedRevokes every session and issues a fresh one
    GET /sso/dashy/callbackrate-limitedVerifies the HS256 token, finds or creates the account
    GET/PUT /api/admin/config, /users, …/password, DELETE /users/:idadminInstance and user management
    GET /api/admin/usageadminProjects and disk per account. Its own route because it parses every projects blob and walks a directory per scroll sequence; answers 200 with an error field rather than a status, so a failed report never breaks the Admin screen
    GET/PUT /api/admin/text/config, POST /api/admin/text/testadminThe test sends a real request
    GET/PUT /api/admin/images/config, POST /api/admin/images/testadminThe test generates a real image, not stored
    POST /api/text/visionsessionProbes the model's vision support. Goes through the SSRF guard
    GET/PUT /api/datasessionThe user's projects and design
    GET /api/mcp/statussessionState of every declared MCP server
    POST /api/muse/dossiersessionDiscover → Distill → Dossier
    POST /api/muse/auditsessionThe judged half of the SEO/accessibility report. 400 only when code is missing; 200 with an empty list and a notice when there is no model
    POST /api/muse/qualitysession{ code, hasDirection, critique } in, one report out. 400 only when code is missing; 200 even with no model configured — see below
    POST /api/images/generate, /uploadsession, 30/minGeneration is the expensive verb
    GET /api/images/library, /library.zip, POST /:hash/favorite, DELETE /:hashsessionLibrary management
    POST /api/images/:hash/confirmsession, ownerClears the pending mark on an image the multi-step variant flow produced. One-way and idempotent: there is no un-confirm, because "nobody has looked at this yet" is a fact about the past
    GET /api/images/:hashpublicSee below
    POST /api/videos/generate (6/min), /upload (20/min)sessionDifferent ceilings: generating costs money, uploading costs disk
    GET /api/videos/library, /:hash/meta, DELETE /:hashsessionSequence management
    GET /api/videos/:hash/poster.jpg, /:hash/f/:n.jpgpublicSee below
    GET /api/video/statussessionVideo export — note the singular. Access, worker health, and the schema bounds the panel quotes
    POST /api/video/compose (12/min)sessionThe one model call in the feature. It COMPOSES a film — a ground and one to eight typed blocks per scene, out of closed enums — or fills in one of the five ready-made compositions when a caller names one, over images the user has already selected: it chooses the film, it never chooses the pictures. An imageId from outside the selection is refused, never substituted; a document the schema rejects is refused whole rather than repaired; a composition that needs a picture, asked for with nothing selected, is refused with a notice naming titles. An empty selection is therefore a request rather than a mistake. The theme travels in the body and is attached after the model's document has been validated — a model that wrote its own is refused. Answers 200 with timeline: null and notices when nothing usable came back — a proposal that did not happen is not a request that failed (Q1). 409 when the selection still holds an image nobody confirmed: the same guard as /render, checked here too so a discard cannot become scene four
    POST /api/video/variants (6/min)sessionTwo to six takes on one library image. Metered like /api/videos/generate rather than like /compose, because each variant is a provider call. Answers derived: with an edit image profile the pictures come out of the user's own, without one they are siblings born of the same text — and an interface that showed the two identically would be lying
    POST /api/video/render (6/min)sessionValidates the timeline, then queues. 400 with the issue list, 404 naming absent images, 409 when the selection still holds an image nobody confirmed, 507 when the volume is already full
    GET /api/video/jobs/:idsession403, not 404, on someone else's job: a job carries the timeline, and a timeline carries their overlay text
    GET /api/video/:hashsessionThe finished film. Never public — ownership is checked before existence, so an unknown hash and a stranger's answer alike
    GET/PUT /api/admin/video/config, GET /api/admin/video/healthadminThe licence key leaves as hasLicenseKey, a boolean
    GET /api/admin/dashboard/overview, /liveadminThe dashboard: an hour of samples and events, then Server-Sent Events every 2 s. The stream re-checks the admin on every tick and ends with event: bye when that stops being true
    GET /api/admin/dashboard/sessions, DELETE …/sessions/:id, POST …/users/:id/signoutadminSessions by hash of their token (D2). Your own session is refused (400) — sign out instead
    GET /api/admin/dashboard/audit, PUT/DELETE …/announcementadminThe audit log, newest first, by group; the announcement, published through GET /api/config once its startsAt has passed — the dashboard sees it scheduled before that
    GET/PUT /api/admin/maintenanceadminRead-only mode. While on, every non-GET from a non-admin answers 503 { code: 'maintenance' }, sign-in and sign-out excepted — see server/maintenance.js
    GET/POST/DELETE /api/admin/migration/sourceadmin; POST re-asks the passwordThe pairing code of the OLD server. Shown once, held in memory only, 24 h
    GET /api/admin/migration/import, POST …/connect, …/pass, …/cancel, …/disconnect, …/verify, …/finalizeadmin; finalize re-asks the passwordThe NEW server's side: check, pull, swap, restart. connect fetches an admin-typed URL — the fourth SSRF bypass
    GET /api/migration/manifest, /api/migration/filepairing signature, no sessionThe only two routes the old server exposes to the new one. 401 whenever no code is active; every body is sealed with AES-256-GCM under the code
    POST /api/admin/video/benchmarkadminRenders three reference films through the worker, inside the queue's exclusive slot, and reports what each render level costs on this machine. 409 while a user's render holds the slot
    ALL /__provider/api/chat, /api/tagssession if an instance model is configuredProxy and dialect translation

    Why image and frame bytes are public#

    This is deliberate and load-bearing.

    Preview iframes are sandboxed without allow-same-origin, so their origin is opaque and their subresource requests carry no SameSite cookie. An authenticated /:hash route would blank out every image in every mockup.

    An exported ZIP also references these URLs from a machine with no session.

    The URL is the capability: a 64-character hexadecimal SHA-256 of the content, which cannot be guessed and is only ever handed out by an authenticated listing. The pattern is exact — PUBLIC_IMAGE_PATH = /^\/[a-f0-9]{64}$/ — so listing, generating and deleting all stay behind a session.

    The guard is attached to the subpaths the routers serve, not to the /api mount. Mounted on /api, it ran for every later /api/* route too, which silently put the public bytes behind authentication.

    Why owners never reaches a browser#

    The other half of the same question. server/images/routes.js and server/videos/routes.js both strip owners from every listing before it leaves the server, and that is a privacy decision rather than a tidiness one.

    The media libraries are instance-wide: every signed-in user lists every image. Left in the payload, an ordinary account learns its own id from the meta of its first upload, subtracts its own images from the list, and now holds the global library partitioned by author — who produced how much, and which prompts belong together. That is exactly why publicUser() in server/index.js omits id, and why only GET /api/admin/users ever publishes one.

    It costs the feature nothing: nothing under src/ reads owners. The usage report consumes it server-side, through collectUsage, which reads the library object directly.

    Why the quality check answers 200 with no model#

    The route is session-gated like the rest of Muse — app.use('/api/muse', requireUser) — and it refuses a request in exactly one case: no code to look at, which is a 400. An absent model is not that case.

    A report has two halves. The deterministic rules need nothing but the source; the judged pass needs a model. Credentials follow the dossier route exactly — an administrator-configured provider wins, otherwise the browser's own headers, the same ones /__provider reads. With no credentials the first half still runs and the second reports itself unavailable, so there is a real answer to return: the findings that were found, an audit that says which dimensions were actually looked at, and a notice naming what did not run. A 4xx would instead say "this screen could not be checked", which is untrue, and the browser would raise it as a failure over a screen that had generated perfectly well. Degrade, never fail — invariant Q1.


    10. Export#

    src/lib/export/project.ts assembles a runnable Vite + React + Tailwind project from the screens, with three targets.

    TargetContents
    plainTailwind plus Mocky's UI packs, vendored into the project
    shadcnThe above, plus components.json, the standard cn() and the shadcn Tailwind theme, so npx shadcn add … inherits the brand through globals.css
    daisyuiTailwind plus the daisyUI plugin

    The JSX-to-ESM rewrite in export/rewrite.ts goes through Babel, never a regular expression. It first transforms JSX into React.createElement so every component reference becomes an ordinary identifier, then queries the scope.

    export/theme.ts turns DESIGN.md into globals.css. Regular expressions are allowed there because they scan Markdown prose, not code — the explicit exemption in invariant I1.

    The ZIP is written by src/lib/zip.ts, with no dependency: store method plus CRC32. The same writer serves the image library's "Download all" and npm run backup.

    Every pack a screen can import, and none it cannot use#

    rewrite.ts builds its import map from the REGISTRY — every snippet-pack export becomes @/components/ui/ — while uiFiles() was a hand-written list of three. Four packs were therefore imported and never shipped: animate, scene3d, scrollvideo, motionfilm. is on nearly every generated screen, so nearly every export failed on a module that was not there. The list is now DERIVED from CAPABILITIES in project.test.ts, which is what stops the next pack repeating it — including a retired one, because a screen generated before the retirement still imports it.

    The packs ship as the JavaScript they are. They were written for a browser with no compiler: var Icon = {} then Icon.Home = …, a variadic cn reading arguments, window.THREE. Shipped as .tsx they were type-checked, and the exported project's own npm run build — which is tsc && vite build — failed on fifty errors in files nobody had asked TypeScript to read. So each pack is a .jsx with a hand-written .d.ts beside it: every export is React.FC, Icon is a record of them, cn is variadic. TypeScript resolves the module through the declaration, checks the SCREENS — the files worth checking — and leaves the vendored JavaScript alone. An export was installed and built to find this, which is the only way it is ever found.

    three.js follows the screens. reads window.THREE, so the exported scene3d.jsx imports the library and assigns it — but only when a screen names a scene (capabilitiesUsedBy, on the code rather than on screen.caps). 600 KB in every export would be paying for the catalogue instead of for the screens, and without it the component draws the calm gradient it draws in a browser with no WebGL.

    Images and films are still Mocky's. A generated src="/api/images/…" or "/api/video/…" resolves only where that server is; the export's README says so rather than pretending otherwise.

    This is not Motion Ultra, which shares only the word. That one turns images from the media library into an .mp4 on a separate, opt-in Docker service and never touches a screen — see Motion Ultra.

    Documents: PDF, .pptx and PNG#

    A DOCUMENT screen (Screen.page set) is exported through a separate path, src/lib/docExport/, entirely in the browser. It starts from one contract, src/lib/pageFormats.ts: the page sizes in CSS px and in PDF points, and the DOM attributes the page kit writes (data-mocky-doc, data-mocky-page, data-mocky-field). The kit (capabilities/snippets/Document.ts, the document pack) gives each its exact size and reports the page count and any overflow; the export reads the same attributes back, so an attribute renamed on one side is renamed on both.

    render.ts opens the document offscreen in the capture shell (openSettledDocument: pictures eager, fonts and images settled, laid out at the canvas's height), then walks it page by page. measure.ts reads each page's words, fields and links with their boxes — skipping what is visually hidden, and reporting how far text runs past each edge and which words. Each page is then rasterised by the browser's own engine through an SVG foreignObject (nativeRaster.ts), with html2canvas as the fallback for a page it cannot draw or a browser that will not read it back.

    OutputBuilt byWhat it keeps
    PDFpdf.ts, with pdf-libThe page picture, an invisible text layer so the text stays selectable, and a real AcroForm field for every
    .pptxpptx.ts, hand-written OOXMLThe page picture with its text taken out, and every run of text as an editable text box over it
    PNGraster.ts + zip.tsThe page as drawn; a social format at exactly its pixels (scale 1)

    The same measurement drives Fit to page (docExport/fit.ts): the overflow is measured before, turned into a findings block in the page's own pixels for fitComponent, and measured again on the answer, which is written back only if it fits or comes closer on the same number of pages (fitVerdict). A pass that does not render is treated as one that does not fit.


    11. Tests#

    npm test runs Vitest across the repository. Five suites are worth knowing about, because they read what actually ships rather than an abstraction of it.

    tests/preview-sandbox.test.js locks the preview's security posture by reading Preview.tsx and capture.ts: the exact sandbox value, the absence of external tags, the CSP directives, the navigation guard, the behaviour of the "no animation" mode, and the postMessage validation.

    It exists because the only test enforcing invariant I3 looked at the registry, and therefore never saw the