# Polymodel

Polymodel is an AT Protocol-native model/project sharing app — roughly Thingiverse on atproto

## Product direction

Initial product shape:

- model/project publishing on ATProto;
- a small lexicon set, tentatively project, profile, and likes/saves;
- feeds by profile, recent projects, and hot projects;
- browser STL viewing as a core interaction;
- Dioxus web UI backed by Rust and Jacquard 0.12.



## Source of truth

- Jira project key: `PM`.
- Confluence space key: `PM`; numeric space ID for page APIs: `98315`; overview/root page ID: `98479`.
- Jira is both the human source of truth and the agent working truth for implementation tasks when Jira tools are available.
- Confluence is the durable design and product-narrative space.
- Local Markdown is supporting material unless a task explicitly says otherwise.

## VCS

Use jj, not raw git, for normal development. This repo commits large model assets (multi-MiB STL/STEP under `public/models/`), so a fresh clone needs `jj config set --repo snapshot.max-new-file-size 5242880` before snapshotting new large files (jj's default 1 MiB cap refuses them); already-committed assets check out fine without it.

- Create isolated work with jj workspaces, and immediately start a fresh change before editing: `jj new -m "PM-12: concise summary"`.
- Use ticket-derived names like `pm-12-short-summary`.
- Check your own changes with `jj diff` before reporting completion.
- `main` usually marks the default workspace head — the trunk merged work lands on. Merge a workspace's change back into the default line by rebasing it onto `main`, then advancing `main` and the default head:
  ```sh
  jj rebase -r <workspace>@ -d main       # rebase the workspace change onto main
  jj bookmark set main -r <workspace>@    # advance main to the merged change
  jj new main -m "<ticket>: ..."          # open the next change on top; this also updates the default workspace head
  ```
  After an external rebase the workspace's working copy is stale — run `jj workspace update-stale` inside that workspace before editing there again. Put follow-up edits in a fresh change on top of `main`, not piled onto a merged workspace change.

## Commands

Use the justfile command surface:

```sh
just fix
just check
just test
just test-server
just test-all
just serve
```

Before review or handoff, run at least `just fix` and all relevant tests. Use `just test-all` for substantial changes.

In a fresh checkout or jj workspace, run `just check` once before `just fix`: `src/env.rs` is generated by `build.rs` from `POLYMODEL_*`, and `cargo fmt` fails to resolve the `env` module until a build has produced it.

## Stack notes

- Dioxus 0.7 is the UI framework.
- Jacquard 0.12 is the AT Protocol client stack.
- The project uses Rust 2024 on nightly with `wasm32-unknown-unknown` available.
- For e2e tests, run `npm install` in the `./e2e` directory if it's the first time in the worktree, then run e2e tests.

### Renderer worker (PM-53)

The 3D renderer (`three-d` + mesh parsing) runs in a **Web Worker** with its own WASM bundle, separate from the Dioxus app bundle. The app depends only on `polymodel-renderer-protocol` (a leaf crate with no heavy deps); `three-d`, `three-d-asset`, `stl_io`, and `polymodel-mesh` are **never** in the app's dependency tree.

- **Run `just build-renderer-worker` before `dx serve`** (or rely on the `just serve` guard, which builds the worker if `public/renderer_worker_bg.wasm` is missing). Without the worker artifacts, the viewer route will fail silently.
- **Worker artifacts:** `public/renderer_worker.js` + `public/renderer_worker_bg.wasm` are generated by `wasm-bindgen --target web`; `public/renderer_worker_loader.js` is hand-authored (the ES-module wrapper that calls `init()`). The `.js` and `.wasm` are gitignored; the loader is committed.
- **Crate structure:** `polymodel-renderer-protocol` (leaf: `MeshFormat`/`LengthUnit`/`Units`/message types + postcard) → `polymodel-mesh` (STL/OBJ/glTF/3MF parsing, depends on `three-d-asset`) → `polymodel-renderer-worker` (`[[bin]]` wasm target, depends on `three-d` + `polymodel-mesh`). The app depends only on `polymodel-renderer-protocol`.
- **Invariant:** `cargo tree -p polymodel --target wasm32-unknown-unknown --features web --edges normal` must show zero `three-d`, `three-d-asset`, `stl_io`, or `polymodel-mesh` entries. `just verify-renderer-split` checks this.
- **Build profile:** `worker-release` (inherits `release`, `opt-level = 3`, `lto = "fat"`, `panic = "abort"`) — the worker renders frames, so hot-path speed matters more than initial bytes.
- **Supported viewer formats:** STL, OBJ, glTF (GLB/embedded/multi-file), and 3MF all parse client-side in the worker via `three-d-asset`. STEP converts server-side to glTF via opencascade.js. The `RemoteMeshParser` seam in `polymodel-mesh` is reserved for future server-side format additions.

### Front-end implementation notes

- **Design guidance: use the `frontend-design` skill for any UI work** (Dioxus RSX, routes, `assets/styling/*.css`, layout/color/type/motion/state/copy). It carries the adapted Impeccable design method for Polymodel's committed blueprint identity and product register. Companions: `DESIGN.md` (repo root, the blueprint design system as machine-readable tokens mirroring `theme.css`), `tools/design-detector/` (deterministic no-install linter — `node tools/design-detector/detect.mjs --json assets/styling/` or a rendered route; scans CSS/HTML, not `.rs`), and Confluence [*Front-end design guidance*](https://radiant-industries.atlassian.net/wiki/spaces/PM/pages/655362) in space `PM` (durable human-facing version).
- Dioxus asset declarations use source paths, but served asset URLs are flattened into `/assets/`. For example, declare a font with `asset!("/assets/fonts/proza-libre/ProzaLibre-Regular.woff2", ...)`, but reference it from CSS as `url("/assets/ProzaLibre-Regular.woff2")`. Verify font and model asset paths in browser network output when changing asset declarations.
- Keep generated lexicon/API code out of routine formatting churn. `crates/polymodel-api` is generated and is ignored by `rustfmt.toml`; do not include generated reformatting in ordinary feature slices unless the task is explicitly about regeneration or codegen output.
- If a front-end slice needs a renderable primitive/demo surface, prefer a non-root route such as `/foundation`. Keep `/` as the product shell so implementation scaffolding does not become the primary product surface.
- Use flex column for simple vertical page shells. Reserve CSS grid for actual two-dimensional layouts; grid containers combined with `min-height: 100vh` can create surprising stretched rows and large visual gaps.
- During visual/layout debugging, use screenshots plus accessibility snapshots with boxes and computed-style inspection. Screenshots show symptoms; boxes/computed styles identify the CSS rule causing the layout.
- The app renders every route inside a single `#[layout(AppShell)]` shell (`src/shell.rs`) with an `Outlet`; the shell owns the global header (wordmark, Browse, global search → `/search`, Publish CTA, session/account control) and the viewport height (`min-height: 100dvh`, flex column), so pages are `flex: 1` and never set `min-height: 100vh`. Product nav deliberately excludes `/foundation` and `/viewer` (reachable by direct URL only). Sign-in `return_to` is the current route, not a hardcoded path.
- `assets/styling/` follows a layered cascade, declared in that order in `src/main.rs`: tokens (`theme.css`) → reset/base (`base.css`) → reusable primitives (`primitives.css`) → shell chrome (`shell.css`) → per-route page styles (`home.css`, `thing-detail.css`, `profile.css`, `viewer.css`, `placeholders.css`). Keep `theme.css` and `DESIGN.md` in sync (the design detector reads `DESIGN.md`).

### Lexicons & codegen

- Authored lexicons live in root `lexicons/` (nested by area: `library/`, `actor/`, `graph/`), one JSON file per lexicon; the `id` field is authoritative. Design source: PM-6 brief ([Confluence 131433](https://radiant-industries.atlassian.net/wiki/spaces/PM/pages/131433)).
- `lexicons.kdl` (repo root) configures codegen output under `crates/polymodel-api/` (`src/`, `lexicons/`, `Cargo.toml`) with a `local` source for `./lexicons` and an unpinned `git` source for `com.atproto.*` (tracks upstream atproto on regen, like vodplace).
- `crates/polymodel-api` is **generated** (DO-NOT-EDIT, no tests). Regenerate with `just generate-api` (`nix run ../jacquard`, the lex-fetch + codegen pipeline). Always fix authored JSON, never generated code.
- The generated `Cargo.toml` keeps human content (package, `[workspace.dependencies]`-backed deps, lints, curated `default`/`streaming`/`serde` features) above a `# --- generated ---` marker; codegen rewrites only the namespace features below it.
- Jacquard deps are declared once in root `[workspace.dependencies]` and referenced via `workspace = true` in both the app and the generated crate, giving a single jacquard version across the workspace.
- ATProto has no float type: dimension fields (e.g. `bbox` x/y/z) are decimal strings parsed to floats in app code. Images use the `#image` (blobref) / `#imageView` (CDN URL) split in `space.polymodel.library.defs`. Polymodel `*View` lexicon types are appview/UI contracts: shape them around fields the UI needs, including required hydrated identity/author/count/viewer fields, instead of treating views as lossy transport objects and adding app-local adapter workarounds. Preserve typed ATProto values (`Handle`, `AtUri`, `Datetime`, URI types, etc.) once constructed; do not `.as_str()`/stringify and re-parse them through convenience constructors just to move them between fixtures, helpers, or components. Borrow typed values only at display/serialization boundaries. Keep record-authored fields on records unless a view/feed UI intentionally needs them directly; external or aggregated hydrated metadata such as display tags belongs on view types.
- Part-file bytes are served via `space.polymodel.library.getPartFile` (`*/*` output, consistent with `com.atproto.sync.getBlob`): the server streams reassembled chunk blobs from the PDS without buffering the full file; digest and total-length verification are client-side (viewer mesh-load + direct download).

## Local projection: reads & writes

The server keeps the browser single-origin: `space.polymodel.*` reads are served from the local SQLite projection, and a fixed allowlist of other XRPC methods is proxied to the user's PDS / public AppView (PM-47; design history in [PM-9 §4.5, Confluence 131467](https://radiant-industries.atlassian.net/wiki/spaces/PM/pages/131467)).

- **Writes to our own records are eagerly projected.** Every write the app performs to `space.polymodel.*` (PM-28 mediated writes *and* proxied `repo.createRecord`/`putRecord`/`deleteRecord`) is persisted to the local SQLite projection immediately after the user's PDS accepts it, for read-your-own-writes. Hydrant/firehose later re-delivers the same event; the shared projection rules keep that convergence idempotent.
- **Reads hit the PDS/AppView but reflect pending local ops.** `repo.getRecord` of a `space.polymodel.*` record consults the `pending_writes`/`pending_deletes` tables while a local op awaits firehose confirmation (the request repo is canonicalized to a DID, so this holds for handle-authority reads): a `pending_writes` row not yet reflected by the PDS — a divergent cid, or the PDS reporting not-found — returns the local value/cid; a `pending_deletes` row returns `RecordNotFound` (HTTP 400) even if a lagging source still serves the record. The matching row is cleared once the Hydrant `#commit` re-delivers the event.
- **Only `space.polymodel.*` is indexed.** Other records are proxied without local persistence.
- **Upstream errors are forwarded faithfully.** The passthrough surface forwards the real upstream HTTP status + error body verbatim (`RecordNotFound` stays HTTP 400, `InvalidSwap` stays 409, 429/5xx unchanged); only genuine transport/internal failures become 500.
- **The proxy allowlist is structural.** Non-`space.polymodel.*` methods are served only for the direct repo record operations (`createRecord`, `putRecord`, `deleteRecord`, `getRecord`, `listRecords`, `describeRepo`, `uploadBlob`) plus `identity.resolveHandle` and `actor.getProfile`, dispatched over their generated typed request structs through the OAuth session agent's signing send path. Batch/admin repo methods (`applyWrites`, `importRepo`, `listMissingBlobs`) and every other NSID have no route and 404.
- **Server-side drafts are app-internal, not federated.** The publish wizard (PM-43) saves in-progress compositions as **owner-scoped server-side drafts** via app-internal authenticated appview endpoints (under `/app/drafts*` + `/app/images`, reusing `ExtractOAuthSession` + `authenticated_did`) backed by the `drafts` table — *not* ATProto records and *not* new federated lexicons. A draft stores an all-optional `DraftThingInput` payload; publishing assembles it to a real `ThingInput`, runs `publish_composition` under `state.write_lock`, and deletes the draft **only on success**. This is the **first browser-side authenticated mutation** in the app — the wasm `PolymodelClient` relies on the same-origin OAuth session cookie (no token handling). **Accepted limitation:** `stageFile` and the app-internal image-upload endpoint write blobs to the actor's PDS *during drafting* (only the records are deferred to publish); unreferenced blobs are PDS-GC'd. Deferring blob upload (server-side blob staging) + permissioned draft spaces is tracked in **PM-58**.

## Agent behavior

- Investigate code and docs before asking factual questions.
- Ask the operator for product intent and workflow tradeoffs.
- Keep Jira updated at workflow gates. Do not restrict comment visibility — leave comments visible to all.

## Code style

- **Prefer branded types over bare `String`/`&str`.** Any use of a bare `String` or `&str` where a wrapping, possibly-validated branded type exists (`Did`, `Handle`, `AtUri`, `Nsid`, `Cid`, `AtIdentifier`, …) is a bug to fix immediately unless clearly rebutted — e.g. binding to SQLite (TEXT columns), human-facing error/log messages, or where the surrounding error structure already carries the needed context. Thread the brand from the request/parse site down to the system boundary (DB bind, serialization) and convert with `.as_ref()`/`.as_str()` only there. Don't reconstruct a brand you already have (e.g. don't `Did::new_owned(s)` from a string you got by stringifying a `Did`), and prefer matching on a branded enum's variants over sniffing its serialized form (`ident.starts_with("did:")`).
- Production SQL must use SQLx compile-time checked macros (`query!`, `query_as!`) rather than runtime `query`/`query_as` escape hatches. When changing production SQL or migrations, regenerate and commit the `.sqlx` cache with `just sqlx-prepare`.
