# Polymodel

Polymodel is an AT Protocol-native model/project sharing app — roughly Thingiverse on atproto — and a demo of a highly integrated Polytoken/Jira/Confluence agent workflow.

## 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.

STL viewer (decided):

- `three-d` is the chosen STL viewer (see the Confluence decision note). Renders STL on a Dioxus-owned canvas via WebGL2; the renderer session is retained across model switches and the STL loads on demand from `public/models/`.
- `kiss3d` was evaluated and rejected: no WebGL2/WebGPU adapter surfaced at runtime in the browser target (PM-21).

## 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 demo-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.
- Use `jj describe` only to update the description of an already-isolated change; do not use it as a substitute for `jj new` at task start.
- `main` 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 the narrowest relevant tests. Use `just test-all` for substantial changes.

## 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.

### Front-end implementation notes

- 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.
- Do not commit Playwright MCP scratch output. `.playwright-mcp/` contains local browser logs/snapshots from manual checks and should stay ignored/untracked.
- 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.

### 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.

## Agent behavior

- Investigate code and docs before asking factual questions.
- Ask the operator for product/demo intent and workflow tradeoffs.
- Keep Jira updated at workflow gates. When adding Jira comments with restricted visibility through Atlassian MCP tools, use role visibility `{ "type": "role", "value": "Member" }`; the value is singular, and observed rejected values include `Administrators`, `Members`, and `Viewers`.
- Do not put secrets in repo files.

## 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:")`).
