Cartesium web #
The browser calculator is a SvelteKit application backed by the Rust engine's WebAssembly adapter.
Start with the engineering documentation index. The shared/2D architecture explains the mathematical model, documents, workers, and Canvas pipeline. The 3D engine guide owns 3D rendering and architectural priorities. Performance measurement owns profiling procedures; project status summarizes product scope.
The normal development and production commands rebuild the ignored Wasm bindings before starting Vite, so a Git checkout cannot silently run a stale engine binary:
cargo install wasm-bindgen-cli --version 0.2.126 --locked
npm run check
npm run build
Everything you need to build a Svelte project, powered by sv.
Developing #
Once you've created a project and installed dependencies with npm install (or pnpm install or yarn), start a development server:
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
Development runs at / by default. Set CARTESIUM_BASE_PATH explicitly when
testing a deployment below a URL prefix.
When only Svelte or TypeScript changed and the generated Wasm is already known
to match the checked-out Rust source, npm run dev:frontend skips the engine
rebuild.
Building #
Vendored MathQuill #
MathQuill's full browser build, CSS, fonts, license, source archive, and provenance
are committed in static/mathquill. Its standalone
public declarations live in src/lib/vendor/mathquill/mathquill.d.ts. Ordinary
installs and builds use these files without a sibling MathQuill clone, MathQuill
download, or npm publication. Production build commands verify their checksums
automatically. Verify their integrity separately from apps/web:
npm run mathquill:verify
To update, commit the desired changes in a MathQuill clone and install that
clone's build dependencies using its lockfile. Then run from apps/web:
npm run mathquill:vendor -- --source /path/to/mathquill
# Or select an exact commit instead of the clone's HEAD:
npm run mathquill:vendor -- --source /path/to/mathquill --commit <commit-hash>
npm run mathquill:verify
The importer archives the selected commit (default: HEAD), builds that snapshot
in a temporary directory, and updates the assets, public declarations, source
archive, and checksums. Uncommitted changes and the clone's current checkout do
not affect the selected source. The commit must be available in the local clone;
fetch remote revisions first if needed. Installed build tool versions must match
the selected revision's lockfile. The importer does not install dependencies or
modify the source clone. Review and commit the updated files together.
The source archive can also be extracted into a new
directory and rebuilt with npm ci followed by make; the recorded Node/tool
versions identify the original build environment.
These assets prepare the dependency for the visual expression editor. Expression
rows still use text input. Future integration should initialize MathQuill only
in the browser and prefix asset URLs with the application's configured base
path. Keeping the files in static does not load them on calculator or embed
pages until a consumer explicitly requests them.
Production build #
The production build is a Node application mounted below a configurable URL
base path. CARTESIUM_BASE_PATH sets that prefix at build time; when unset, the
application is served at the domain root. Set it to a slash-prefixed path such
as /cartesium when mounting below a URL prefix. The deployment template
passes an empty path explicitly. Set CARTESIUM_ORIGIN at build time when the
public origin cannot be inferred reliably from request headers; SvelteKit 3
does not read the Node adapter's former runtime ORIGIN setting. In the
examples below, <base-path> means
this configured URL prefix, including its leading slash when non-empty; omit
it when the application is mounted at the domain root. The landing page is
server-rendered for initial metadata and navigation; the calculators remain
client-rendered. The Node process also serves published-graph
Open Graph metadata and validates/proxies public preview images from AT
Protocol PDS hosts. Generate the Wasm bindings and build it with:
npm ci
npm run build
Similarly, npm run build:frontend skips Wasm generation for a frontend-only
production rebuild.
The main calculator displays the commit used to produce the frontend build in
the expression-panel footer. The development server adds a -dev marker to
the short hash (for example, Build abc123456789-dev) so it is visibly
distinct from a production build. The full raw commit remains in the footer's
tooltip and data-build-commit attribute in both channels. Builds read
git rev-parse HEAD from the repository root; when building from CI or a
source archive, set CARTESIUM_COMMIT explicitly. The value is compiled into
build/, so it does not need to be passed to the runtime container.
The self-contained Node output is written to build/. Run it directly with:
CARTESIUM_PUBLICATION_V2_COLLECTION=org.example.cartesium.publicationV2 \
CARTESIUM_SPACES_ENABLED=false \
node build
The Vite development server is unchanged and does not need these values until a published route is requested.
Browser documents #
Saved drafts are durable per-browser documents in localStorage under
cartesium.documents; the active working copy and recovery state live in
sessionStorage. The shared Drafts library can filter 2D, 3D, or all local
documents, and JSON export includes a saved 3D document's semantic camera
presentation when one exists. These drafts are private to that browser and
are not automatically written to an AT Protocol PDS. When Spaces is enabled
and an account is signed in, Save for a new document creates or updates a
private document in the account's personal Cartesium Space. Existing browser
drafts remain in the separate browser-only library. The active document
identity is tracked as either a local id or a Space record key, so a document
cannot silently be saved to the wrong store. A signed-out user sees only the
browser store; the header labels it Browser only because localStorage is tied
to that browser profile.
Documents may also have an optional authored description. The description is part of the portable document, so it follows local and private draft saves, working-copy recovery, JSON export, V2 share links, and V2 publication. It is plain text and is limited to 2,000 graphemes / 10,000 UTF-8 bytes. Tags are not part of the current document or discovery contract.
Container deployment #
Build the application before building the small runtime image; Rust, npm, and Wasm build tools stay outside the production image:
npm ci
export CARTESIUM_ORIGIN=https://example.org
npm run build
export CARTESIUM_PUBLICATION_V2_COLLECTION=org.example.cartesium.publicationV2
docker compose up -d --build
Compose accepts runtime values from the invoking shell, from an untracked .env
beside compose.yaml, or from an explicit deployment file:
cp .env.example .env
# edit .env, then:
docker compose up -d --build
# Or keep the file elsewhere:
docker compose --env-file /path/to/cartesium.env up -d --build
The .env file is Compose input; compose.yaml passes runtime values into the
container. Do not commit a real deployment .env. Supply CARTESIUM_ORIGIN
to the build before running Compose. Its value is only the scheme and host, without the
<base-path> URL prefix.
Test and production deployments #
The repository includes a non-secret deploy.sh.example template. From the
local repository root, copy it to the ignored local deploy.sh, set the SSH
destination, and run it there:
cp deploy.sh.example deploy.sh
chmod +x deploy.sh
CARTESIUM_DEPLOY_REMOTE=user@example.org ./deploy.sh test
CARTESIUM_DEPLOY_REMOTE=user@example.org ./deploy.sh prod
The template contains deployment topology but no SSH credentials or runtime
secrets. SSH authentication comes from the local SSH configuration/key, and
each remote directory keeps its own untracked .env.
Each remote directory must have its own untracked .env; the deployment script
deliberately does not copy or overwrite those files. It uploads .env.example
first. Initialize a new deployment's environment on the server:
cd <production-deployment-directory>
cp .env.example .env
# edit .env
Then return to the local repository and rerun ./deploy.sh test or
./deploy.sh prod.
The production .env should include values like:
CARTESIUM_HOST_PORT=3333
CARTESIUM_PUBLICATION_V2_COLLECTION=org.example.cartesium.publicationV2
CARTESIUM_SPACES_ENABLED=false
# Required only when Spaces is enabled.
CARTESIUM_LEXICON_PREFIX=org.cartesium.dev
Override the SSH destination with CARTESIUM_DEPLOY_REMOTE when necessary.
The host name, certificates, and collection NSID are deployment configuration, not repository configuration. The base path and public origin are compiled into each build, so rebuild whenever either changes. Static file copying alone no longer deploys Cartesium because crawlers must reach the dynamic published-graph and media routes.
AT Protocol publishing #
The AT-native publishing path is stateless on the Cartesium server:
GET <base-path>/p/v2/<did>/<rkey>does the same for the unified 2D/3D publication contract. A V2 3D record includes its authored semantic camera presentation; V2 2D records retain the viewport already present in the GraphSnapshotV1 payload.GET <base-path>/media/v2/<did>/<rkey>/<cid>.pngprovides the equivalent content-addressed preview route for V2 publications.- Cartesium does not retain the record, snapshot, or PNG. Preview responses use
Cache-Control: no-storeso browsers and caching proxies do not retain them while preview moderation is unavailable. - Publish uses browser OAuth. Its keys and session credentials remain in the browser rather than the Cartesium server.
<base-path>/is the landing page. The canonical calculators are<base-path>/calc/2dand<base-path>/calc/3d;<base-path>/calcopens the 2D calculator. The old root graph fragments and<base-path>/3dremain supported as compatibility entry points, while published reader URLs remain unchanged.<base-path>/exploreis the public listed-document view. It includes listed V2 publications by default, supports title/description search and a 2D/3D filter, and paginates with opaque cursors. No sign-in is required. The page sendsnoindexwhile public listings are unmoderated.- The browser uploads the validated snapshot and a normalized 1200×630 PNG directly to the user's PDS, then creates a new immutable publication record. Every Publish action creates a new record key.
- Publish asks whether the record is
listedorunlisted. Both are public, immutable records:listedis eligible for Cartesium discovery, whileunlistedis link-addressable. All publication pages sendnoindexthrough a robots meta tag andX-Robots-Tag; unlisted pages also specifynofollowandnoarchive. Preview PNGs sendX-Robots-Tag: noindex. Unlisted is not access control; PDS observers and technically capable Jetstream/indexing clients can still discover it. <base-path>/oauth-client-metadata.jsonexposes deployment-specific OAuth metadata.<base-path>/resolve-handleperforms DNS TXT resolution with a bounded, public-host-only HTTPS well-known fallback.- The account picker searches public actor directories after two typed characters, using Typeahead first and the Bluesky public AppView as a fallback when the primary service is unavailable. Suggestions are optional; users may still enter a handle or DID manually. A selected suggestion only supplies the handle to the existing OAuth flow; its returned DID is not used as an authentication authority.
Set CARTESIUM_PUBLICATION_V2_COLLECTION to the production NSID selected for
unified 2D/3D publications. The repository's
lexicons/org/example/cartesium/publicationV2.json
is the namespace-neutral example: copy it to a path matching the production
NSID and change its id before publishing it under that authority. V2 records
use this shape:
{
$type: <configured collection NSID>,
kind: "2d" | "3d",
title: string,
createdAt: ISO timestamp,
snapshot: application/json PublishedDocumentV2 blob,
preview: image/png blob
}
The JSON blob is a PublishedDocumentV2 envelope containing the discriminated
2D/3D document and optional semantic 3D presentation state. The optional
discoverability field is listed or unlisted; records created before the
field existed default to listed.
Public discovery AppView #
Public discovery is deliberately separate from Spaces. Spaces continue to
hold only a signed-in account's private mutable drafts; they do not represent
groups, shared servers, or the scope of /explore. The public AppView indexes
normal public AT repository publication records and serves the same listed
view to every visitor.
appview/ is a standalone Node service backed by PostgreSQL. On first start it
applies numbered migrations, pins a Jetstream archive target, and backfills
only commit events in the configured V2 publication collection. Jetstream
can therefore skip unrelated archive blocks instead of sending global account,
identity, and sync history. Once publication DIDs are known, the AppView
reconciles those repository-level markers for only the relevant actors, then
follows the gapless live stream. Creates, updates, deletes, identity changes,
account status, and repo sync events are applied in database transactions. The
public query excludes unlisted publications, inactive accounts, and records or
actors marked locally hidden. Search uses a weighted PostgreSQL full-text index
over title and description.
The consumer tolerates non-conforming metadata on historical identity, account, and sync markers because hosted archive history can contain legacy values such as an empty optional timestamp. Publication records still pass through Cartesium's own strict normalization before they can be indexed.
The service exposes GET /v1/publications, /healthz, and /readyz only on
the private Compose network. SvelteKit's same-origin
<base-path>/api/publications endpoint validates public query parameters,
calls the private service, and constructs canonical reader and preview URLs.
The browser never receives the database URL or hosted Jetstream credential.
During initial indexing, API responses and /readyz include the durable
backfill cursor and pinned target for diagnostics. They are deliberately not
presented as a percentage: collection-filtered cursors jump across unrelated
archive ranges and therefore do not measure elapsed or remaining work.
For the Compose deployment, configure a strong
CARTESIUM_APPVIEW_DB_PASSWORD. Hosted Jetstream archive replay may additionally
require CARTESIUM_APPVIEW_JETSTREAM_API_KEY; that variable is passed only to
the AppView container. CARTESIUM_APPVIEW_URL defaults to
http://appview:4100 for the web container. The PostgreSQL volume is named
appview-postgres; retain it across deployments so replay resumes from the
stored cursor rather than rebuilding the index.
The Jetstream v2 API key is archive authorization only. It authorizes the hosted
archive snapshot/planning requests; it is not a cursor identity, repository
identity, or substitute for the durable PostgreSQL cursor. The cursor is the
Jetstream sequence persisted in appview_state.firehose_cursor. Live streaming
is unauthenticated. Keep the key in the untracked deployment .env; deployment
verification never prints .env, Compose's resolved configuration, database
URLs, or Jetstream credentials.
AppView deployment verification and recovery #
deploy.sh.example starts the Compose services and then probes the private
AppView GET /readyz endpoint from inside the appview container. Container
health alone is not an indexing guarantee. The verifier accepts a normal
running state while the first filtered archive backfill is in progress, then
requires both state: complete and backfillComplete: true. It reports the
durable cursor and pinned target as raw sequence values; those values are not a
percentage because collection-filtered archive cursors do not measure elapsed
or remaining work.
The first backfill may take substantially longer than a normal container start.
The template waits up to 3,600 seconds by default, reports cursor/target changes
while it waits, and fails with the last safe state plus service-status/log
commands if the wait expires (a possible stall), AppView reports degraded, or
/readyz is unreachable/invalid. Increase the bounded wait for a new or very
large archive without changing the deployment topology:
CARTESIUM_APPVIEW_VERIFY_TIMEOUT=7200 \
CARTESIUM_APPVIEW_VERIFY_INTERVAL=15 \
CARTESIUM_DEPLOY_REMOTE=user@example.org ./deploy.sh test
The Compose-managed appview-postgres volume holds the migrations, indexed
publications, cursor, pinned backfill target, and index state. Its Docker volume
name is normally project-scoped (<compose-project>_appview-postgres), so keep
the same Compose project (cartesium-test or cartesium-prod) to keep using the
same state. up, image rebuilds, container replacement, and ordinary down
retain a named volume. docker compose down --volumes (or removing that exact
volume) destroys the cursor and index; it is a deliberate from-zero replay, not
a routine deployment step. Do not delete it before taking a database backup.
For a backup, run from the remote deployment directory while the PostgreSQL service is available. The dump is written to the host running Docker and does not print the database password:
PROJECT=cartesium-test
STAMP=$(date -u +%Y%m%dT%H%M%SZ)
BACKUP="appview-${PROJECT}-${STAMP}.sql"
docker compose -p "$PROJECT" exec -T postgres \
sh -c 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB"' > "$BACKUP"
test -s "$BACKUP"
Exercise a reset/replay against cartesium-test first. After the backup above
has been checked, stop AppView and reset only the derived index tables and
durable cursor. This preserves the named volume and its PostgreSQL service:
PROJECT=cartesium-test
docker compose -p "$PROJECT" stop appview web
docker compose -p "$PROJECT" exec -T postgres \
sh -c 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
BEGIN;
TRUNCATE TABLE public.publications, public.actors;
UPDATE public.appview_state
SET firehose_cursor = 0,
backfill_target = NULL,
backfill_state = 'running',
last_event_at = NULL,
last_error = NULL,
updated_at = now()
WHERE key = true;
COMMIT;
SQL
docker compose -p "$PROJECT" up --detach appview web
A fresh volume starts migrations at cursor zero. Leave the archive key in .env
if the hosted Jetstream v2 archive requires it, then run the deployment
verifier (or inspect /readyz) until the state is complete. Investigate
Jetstream authorization and network access before repeating a degraded replay;
inspect service output with:
docker compose -p "$PROJECT" logs --tail=100 appview
If the schema or volume itself is unrecoverable, a volume reset is a broader, destructive last resort: after a verified backup, confirm the exact Compose project and then remove all of that project's named volumes before bringing the services back:
PROJECT=cartesium-test
docker compose -p "$PROJECT" down --volumes
docker compose -p "$PROJECT" up --detach --build
This deletes the project-scoped appview-postgres volume and any other named
volumes later added to that Compose project. Use it on cartesium-test first;
do not use it for a routine deployment.
To restore the backup instead of replaying from zero, stop appview, prepare
an empty target database, and only then pipe the schema-bearing dump to psql.
Stop web too so no route can call AppView while the database is replaced:
PROJECT=cartesium-test
BACKUP=appview-cartesium-test-YYYYMMDDTHHMMSSZ.sql
docker compose -p "$PROJECT" stop appview web
docker compose -p "$PROJECT" exec -T postgres \
sh -c 'dropdb --if-exists -U "$POSTGRES_USER" "$POSTGRES_DB" && createdb -U "$POSTGRES_USER" "$POSTGRES_DB"'
docker compose -p "$PROJECT" exec -T postgres \
sh -c 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' < "$BACKUP"
docker compose -p "$PROJECT" up --detach appview web
After a restore, run the verifier again. Keep the backup until the restored index has been checked.
Only after the test replay reaches complete and the test deployment serves
/explore should the same procedure be considered for cartesium-prod; take a
fresh production dump immediately before any production reset or replay. A
normal deployment preserves the volume and resumes from its durable cursor.
The OAuth request is least-privilege: create/delete access to the exact
configured publication collection(s) and uploads restricted to JSON and PNG.
An existing OAuth session may need to authorize again after V2 is enabled so
it receives the additional collection scope. For local OAuth testing, provide
the collection environment variable and open Vite using its
http://127.0.0.1:<port><base-path>/calc/2d URL; the OAuth callback still returns
to http://127.0.0.1:<port><base-path>/. AT Protocol's development-client rules
require an IP loopback redirect rather than localhost.
Spaces alpha #
Spaces are an optional alpha capability. Set CARTESIUM_SPACES_ENABLED=true
and CARTESIUM_LEXICON_PREFIX=org.cartesium.dev for the development authority.
The server derives the two names below and exposes baseline and Spaces-capable
OAuth profiles. Sign-in tries the Spaces profile first and transparently falls
back to baseline permissions when PAR rejects the exact Space scope. Private
documents are shown only when that scope is present in the granted token:
space type: <prefix>.calc.documents
collection: <prefix>.calc.document
For the temporary authority these are
org.cartesium.dev.calc.documents and org.cartesium.dev.calc.document.
The checked-in schemas live at
lexicons/org/cartesium/dev/calc/documents.json
and
lexicons/org/cartesium/dev/calc/document.json.
The corresponding development V2 publication schema is also checked in beside
them. It is a local source file only; this repository does not publish or
deploy lexicons automatically.
The alpha client uses these XRPC methods:
com.atproto.simplespace.getSpaceandcreateSpaceinspect/createat://<account-did>/space/<prefix>.calc.documents/self.com.atproto.space.listRecords,getRecord,getBlob,createRecord,putRecord, anddeleteRecordread and mutate the private collection.
The first private write creates the personal Space with the alpha member-list
policy and open app access required by the Cartesium client. A Space document
record contains metadata (kind, title, and timestamps) plus one JSON blob;
that blob is the existing portable CartesiumDocumentFileV1, including the
optional 3D presentation. Updates replace the blob and retain the original
creation timestamp. Reads validate the Space URI, policy, record URI, record
type, blob media type/size, document kind, and document file before opening it.
Before updating, Cartesium performs a best-effort preflight read immediately
before uploading the replacement blob and compares the record CID held by the
editor with the current CID. A mismatch is refused and can be reloaded or
saved as a new private copy. The alpha API has no conditional/CAS write, so a
small non-atomic race window remains between that read and putRecord; this is
conflict detection rather than a complete concurrency guarantee.
Space access is not encryption or a promise that no infrastructure can observe the data. It is the persistence boundary for private account documents in this alpha. Public publication discoverability is an independent dimension: an unlisted record may be in a Space in a future product, while a public publication may be listed or unlisted. Cartesium currently uses Spaces only for the signed-in user's personal document library; member invitations, classroom rosters, restricted browsing, feeds, search indexing, and collaborative presence are future layers rather than assumptions in this lexicon.
Every NSID segment except the last is part of the reversed DNS authority. For
example, the placeholder org.example.cartesium.publicationV2 is authorized by
the _lexicon.cartesium.example.org TXT record. After choosing the real NSID,
place its schema under the matching lexicons/<segments...>.json path, point
that authority's _lexicon TXT record to the publishing account DID, sign in
with goat account login, and run goat lex publish from this directory.