BareGit

Initial PRD and design

Author: MetroWind <chris.corsair@gmail.com>
Date: Fri Aug 21 16:14:14 2026 -0700
Commit: 1d58c9cef6d6e717525d8873633fdaa91f598020

Changes

diff --git a/designs/design-0-prototype.md b/designs/design-0-prototype.md
new file mode 100644
index 0000000..bce5f31
--- /dev/null
+++ b/designs/design-0-prototype.md
@@ -0,0 +1,2388 @@
+# Prototype architecture
+
+## Status
+
+This document specifies the implementation of the Card Collection prototype.
+It derives product behavior from `prd.md` and turns that behavior into concrete
+components, database tables, HTTP routes, browser flows, and failure rules.
+
+The document deliberately excludes MVP accounts and collecting behavior. The
+prototype has no users. Every visitor receives the same administrative UI and
+can create, edit, view, and delete cards and series.
+
+## Goals
+
+The prototype must:
+
+1. start from a validated TOML configuration;
+2. initialize and migrate a SQLite database automatically;
+3. expose compiled game definitions and dynamic series;
+4. create loose cards and cards belonging to compiled games;
+5. preserve sequential, never-reused numbers within each game;
+6. derive every public card ID from a nullable game and integer number;
+7. accept, validate, normalize, and store card images;
+8. render foil cards with the WebGL implementation copied from `foil`;
+9. generate static thumbnails in the backend for plain cards and in the
+   browser for foil cards;
+10. serve server-rendered card, index, form, and series pages;
+11. render Markdown with MacroDown while rejecting unsafe URLs and raw HTML;
+12. leave the previous card state intact after a handled edit failure; and
+13. provide enough automated tests to change the prototype safely.
+
+## Non-goals
+
+The prototype does not implement:
+
+- users, authentication, authorization, or ownership;
+- card collection mechanics;
+- a JSON application API;
+- client-side routing or a Preact application;
+- filtering, searching, or pagination;
+- browser fallbacks when WebGL 2 is unavailable;
+- remote-image proxying by the backend;
+- storage quotas or upload rate limits;
+- backups or disaster recovery;
+- a generic runtime game-schema editor; or
+- perfectly atomic commits spanning SQLite and the filesystem.
+
+The lack of a JSON API is intentional. HTML forms are the public mutation
+interface for the prototype. Internal C++ services must not depend on HTTP
+types, so a JSON API can be added later without rewriting domain logic.
+
+## Architectural decisions
+
+### Server-rendered HTML with progressive enhancement
+
+The server renders complete HTML documents. Browser JavaScript is limited to
+the behavior that genuinely requires a browser:
+
+- the interactive WebGL preview;
+- local and CORS-enabled remote image loading;
+- image dimension validation for immediate feedback;
+- foil thumbnail rendering and transparent-edge trimming; and
+- attaching prepared binary blobs to a native form submission.
+
+This boundary keeps game validation, Markdown rendering, database access, and
+permission decisions in C++. The browser does not duplicate those rules.
+
+### Layered backend
+
+`App` handlers translate requests into typed service inputs. Services
+implement use cases. `DataSourceSQLite` owns SQL. The asset store owns
+filesystem changes. Page renderers own HTML templates. A handler must not
+contain SQL or construct card paths directly.
+
+This separation matters because a card mutation touches several resources. A
+single `CardService` operation can validate metadata, stage images, open a
+database transaction, allocate an ID, and publish a directory while keeping
+the ordering visible and testable.
+
+### One common card table
+
+All cards live in one `cards` table. A compiled game may add one extension
+table keyed by `cards.id`. This keeps series membership, index queries, and
+future user ownership attached to one stable internal identity.
+
+The formatted public ID is never stored. A game card stores a game short name
+and integer number. A loose card stores a null game and a random 32-bit integer
+in the same number column.
+
+### Filesystem assets with staged publication
+
+Uploaded content is written to a unique staging directory. The server only
+publishes the directory after every required image and thumbnail has passed
+validation. Handled failures remove the staging directory.
+
+The configured card-storage root has this fixed internal layout:
+
+```text
+<card-root>/
+├── published/
+│   └── <public-id>/
+├── .staging/
+└── .trash/
+```
+
+Only `published` is exposed as a static mount. Staging, edit backups, and trash
+remain outside the mounted tree even though they share the configured root.
+The canonical directory for a card is
+`<card-root>/published/<lowercase-public-id>`.
+
+SQLite and directory renames cannot participate in one transaction. The
+service therefore chooses a strict operation order, attempts compensating
+cleanup after errors, and renders a useful card page if committed database
+metadata refers to a missing asset.
+
+## System overview
+
+```mermaid
+flowchart LR
+    Browser[Server-rendered browser page]
+    WebGL[WebGL card component]
+    HTTP[App and route handlers]
+    Service[Card and series services]
+    Games[Compiled GameRegistry]
+    Data[DataSourceInterface]
+    DB[(SQLite)]
+    Assets[(Card asset root)]
+    Templates[Inja templates]
+    Markdown[MacroDown renderer]
+
+    Browser -->|GET and native form POST| HTTP
+    Browser --> WebGL
+    WebGL -->|prepared foil thumbnail blob| Browser
+    HTTP --> Service
+    HTTP --> Templates
+    Service --> Games
+    Service --> Data
+    Data --> DB
+    Data --> Games
+    Service --> Assets
+    Templates --> Markdown
+```
+
+The dependency direction always points inward. `CardService` does not know
+about Inja, `httplib::Request`, or browser field names. `App` translates HTTP
+requests into `CreateCardInput` and `UpdateCardInput` values. Services depend
+on `DataSourceInterface`, never on SQLite or HTTP types.
+
+## Source tree
+
+The implementation should use this initial layout:
+
+```text
+card-collection/
+├── CMakeLists.txt
+├── cmake/
+│   └── dependencies.cmake
+├── config.example.toml
+├── designs/
+│   └── design-0-prototype.md
+├── prd.md
+├── src/
+│   ├── app.cpp
+│   ├── app.h
+│   ├── asset_store.cpp
+│   ├── asset_store.h
+│   ├── card.cpp
+│   ├── card.h
+│   ├── card_service.cpp
+│   ├── card_service.h
+│   ├── config.cpp
+│   ├── config.h
+│   ├── data.cpp
+│   ├── data.h
+│   ├── game.cpp
+│   ├── game.h
+│   ├── game_registry.cpp
+│   ├── game_registry.h
+│   ├── html_renderer.cpp
+│   ├── html_renderer.h
+│   ├── image_processor.cpp
+│   ├── image_processor.h
+│   ├── main.cpp
+│   ├── markdown_renderer.cpp
+│   ├── markdown_renderer.h
+│   ├── multipart_reader.cpp
+│   ├── multipart_reader.h
+│   ├── non_secret_random.cpp
+│   ├── non_secret_random.h
+│   ├── public_id.cpp
+│   ├── public_id.h
+│   ├── series_service.cpp
+│   ├── series_service.h
+│   ├── url_builder.cpp
+│   └── url_builder.h
+├── static/
+│   ├── card_placeholder.avif
+│   ├── css/
+│   │   └── styles.css
+│   ├── js/
+│   │   ├── card_form.js
+│   │   ├── card_preview.js
+│   │   ├── image_input.js
+│   │   └── thumbnail.js
+│   └── foil/
+│       ├── foil_math.js
+│       ├── frag-shader.glsl
+│       ├── libwebgl.js
+│       ├── model/card.obj
+│       ├── obj.js
+│       ├── spectral_xyz.bin
+│       └── vert-shader.glsl
+├── templates/
+│   ├── card_form.html
+│   ├── card_index.html
+│   ├── card_view.html
+│   ├── error.html
+│   ├── layout.html
+│   ├── series_form.html
+│   └── series_index.html
+└── tests/
+    ├── asset_store_test.cpp
+    ├── card_service_test.cpp
+    ├── config_test.cpp
+    ├── data_mock.h
+    ├── data_test.cpp
+    ├── fixtures/
+    ├── app_test.cpp
+    ├── html_renderer_test.cpp
+    ├── image_processor_test.cpp
+    ├── markdown_renderer_test.cpp
+    ├── non_secret_random_test.cpp
+    ├── public_id_test.cpp
+    ├── series_service_test.cpp
+    ├── url_builder_test.cpp
+    └── web/
+        ├── image_input_test.js
+        └── thumbnail_test.js
+```
+
+All public C++ types and functions require intention comments. C++ and
+JavaScript follow the naming and brace rules in `AGENTS.md`.
+
+## Build and dependencies
+
+### CMake policy
+
+The root requires CMake 3.26 or newer and C++23. It enables
+`CMAKE_CXX_EXTENSIONS OFF`, warnings, and tests through a
+`CARD_COLLECTION_BUILD_TESTS` option.
+
+All direct non-system C++ dependencies are declared with CMake
+[`FetchContent`](https://cmake.org/cmake/help/latest/module/FetchContent.html)
+and pinned to a tag or commit. Tracking a default branch would make identical
+source checkouts produce different binaries. cpp-httplib is an intentional
+transitive exception described below.
+
+The initial pins should be:
+
+| Dependency | Pin | Purpose |
+| --- | --- | --- |
+| libmw | `c0f2254` or later reviewed commit | HTTP, SQLite, errors, URLs |
+| MacroDown | `31e42d5` or later reviewed commit | Markdown AST and rendering |
+| toml++ | `v3.4.0` | TOML parsing |
+| Inja | `v3.5.0` | HTML templates with automatic escaping |
+| GoogleTest | `v1.17.0` | C++ tests |
+
+ImageMagick is an explicit exception to the `FetchContent` rule. Require an
+operator-installed ImageMagick 7 with Magick++, JPEG, PNG, WebP, and AVIF
+delegates. CMake locates it with:
+
+```cmake
+find_package(ImageMagick 7 REQUIRED COMPONENTS Magick++)
+target_link_libraries(card_collection PRIVATE ImageMagick::Magick++)
+```
+
+CMake 3.26 is required because that release provides the imported
+`ImageMagick::Magick++` target. Configuration fails if Magick++ is absent.
+Runtime startup additionally checks the required coder capabilities, because
+finding the library does not prove that its installed delegates support every
+required format.
+
+Enable libmw's SQLite, HTTP-server, and URL components. Disable libmw tests in
+this project's normal build. Do not enable the crypto component for the
+prototype; none of its random values are authentication secrets.
+
+### cpp-httplib resolution
+
+libmw owns the cpp-httplib `FetchContent` declaration and intentionally omits
+`GIT_TAG`, allowing a clean configure to resolve the upstream default branch.
+Every cpp-httplib declaration reachable through the enabled libmw components,
+including `http-server`, must omit the tag. Leaving a tag on any reachable
+declaration would make CMake's first declaration silently restore that pin.
+
+The application must not redeclare cpp-httplib merely to override libmw. It
+builds against the revision selected by libmw and runs the HTTP and multipart
+integration tests against that same revision. The resolved source commit must
+be printed during configuration and recorded with build artifacts so a build
+can be reproduced when diagnosing a regression. A clean configuration may
+resolve a newer cpp-httplib commit; this variability is the accepted cost of
+the deliberate unpinned dependency.
+
+If a new upstream revision is incompatible, update libmw or the application
+for the new API. Do not restore an old transitive pin as a compatibility fix.
+
+Do not enable compressed request-body support for the prototype. Static
+response compression may be provided by a reverse proxy.
+
+### Non-secret randomness
+
+Loose numbers, private temporary suffixes, render markers, and request
+correlation IDs require collision resistance, not unpredictability. Use the
+C++ standard random library through one process-owned helper:
+
+```cpp
+/// Thread-safe pseudorandom values that must never be used as secrets.
+class NonSecretRandom
+{
+public:
+    /// Seed a production generator from `std::random_device`.
+    NonSecretRandom();
+
+    /// Seed a deterministic generator for tests.
+    explicit NonSecretRandom(std::uint64_t seed);
+
+    /// Return one uniformly distributed 32-bit value.
+    std::uint32_t next();
+
+    /// Return `byte_count` pseudorandom bytes encoded as lowercase hex.
+    std::string hex(std::size_t byte_count);
+
+private:
+    std::mutex mutex_;
+    std::mt19937_64 engine_;
+};
+```
+
+The production constructor fills a `std::seed_seq` from multiple
+`std::random_device` results and uses it to initialize `std::mt19937_64`.
+`next()` uses `std::uniform_int_distribution<std::uint32_t>` across the full
+32-bit range. `hex()` draws complete bytes from the same locked engine. The
+mutex covers every engine access because HTTP handlers run concurrently.
+
+`main()` creates one `NonSecretRandom` and transfers it into `App`. Services,
+`AssetStore`, and `HtmlRenderer` receive non-owning references whose lifetimes
+are bounded by `App`. The `App` member declaration places the generator before
+all consumers so reverse-order destruction is safe.
+
+`AssetStore` uses `hex(16)` for staging, backup, and trash suffixes and retries
+if atomic directory creation reports an existing path. `HtmlRenderer` uses
+`hex(16)` for each raw-HTML marker and performs its documented collision scan.
+`App` uses `hex(16)` for request correlation IDs. `CardService` uses `next()`
+for loose-card candidates and relies on SQLite uniqueness plus retry.
+
+This generator is deliberately not cryptographically secure. Future account
+sessions, reset tokens, API keys, or other secrets must enable and use
+`mw::Crypto`; they must never reuse `NonSecretRandom`.
+
+### Template library
+
+Use [Inja](https://github.com/pantor/inja) with
+`Environment::set_html_autoescape(true)`. Pass ordinary text to templates as
+raw JSON strings and let Inja escape it. Do not pre-escape names, IDs, errors,
+or form values, because that would produce double escaping.
+
+Inja does not provide a safe-string value type for selective raw HTML. The
+`HtmlRenderer` therefore registers each trusted `RenderedHtml` value under a
+fresh, opaque marker and supplies the marker as an ordinary template value.
+After Inja renders the page, `HtmlRenderer` replaces the expected markers with
+their trusted fragments. Markers contain only ASCII letters, digits, and
+underscores, so autoescaping leaves them intact. They use 16 random bytes from
+`NonSecretRandom::hex()` and must not occur in any template, ordinary input
+value, or trusted fragment. Replacement is a single pass, so one fragment
+cannot expose a marker for another fragment. Every marker must be replaced
+exactly as many times as its template field is expected to render. A failed
+invariant is an internal rendering error. Only the typed `RenderedHtml`
+registration function may create such a substitution.
+
+Templates are source assets, not user data. Load and parse the complete
+template set at startup and fail startup with the filename and Inja parse
+error if any template is invalid. Use Inja inheritance and blocks with
+`layout.html` as the common page layout. After startup, the environment and
+parsed templates are immutable.
+
+## Configuration
+
+### Example
+
+```toml
+base_url = "http://127.0.0.1:8080/"
+listen_address = "127.0.0.1"
+listen_port = 8080
+static_root = "static"
+database_path = "var/card_collection.sqlite3"
+card_storage_root = "var/cards"
+avif_quality = 75
+thumbnail_long_side = 256
+```
+
+A Unix-domain-socket listener uses an explicit `unix:` prefix:
+
+```toml
+base_url = "https://cards.example.test/collection/"
+listen_address = "unix:/run/card-collection/http.sock"
+static_root = "/srv/card-collection/static"
+database_path = "/srv/card-collection/data/cards.sqlite3"
+card_storage_root = "/srv/card-collection/data/cards"
+avif_quality = 75
+thumbnail_long_side = 256
+```
+
+`listen_port` is ignored when `listen_address` begins with `unix:`. The prefix
+avoids guessing whether a relative string is an IP address, hostname, or path.
+
+### `Config` representation
+
+`Config` contains:
+
+```cpp
+/// Validated process configuration.
+struct Config
+{
+    mw::URL base_url;
+    mw::HTTPServer::ListenAddress listen_address;
+    std::filesystem::path static_root;
+    std::filesystem::path database_path;
+    std::filesystem::path card_storage_root;
+    int avif_quality;
+    std::uint32_t thumbnail_long_side;
+};
+```
+
+`loadConfig()` performs these steps:
+
+1. Parse the named file with toml++.
+2. Reject missing and unknown keys. Rejecting unknown keys catches operator
+   spelling mistakes instead of silently using an unintended default.
+3. Parse `base_url` with `mw::URL::fromStr()`.
+4. Require an HTTP or HTTPS scheme and a nonempty host.
+5. Normalize its path to exactly one trailing slash.
+6. Parse a `unix:` address into `mw::SocketFileInfo`; otherwise require a port
+   from 1 through 65535 and create `mw::IPSocketInfo`.
+7. Convert path settings to absolute, lexically normalized paths. Relative
+   paths are resolved against the configuration file's parent directory, not
+   the process working directory.
+8. Require `avif_quality` from 0 through 100.
+9. Require a positive `thumbnail_long_side`.
+10. Require the static root to exist and be a directory.
+11. Create the database parent and card-storage root if absent.
+12. Reject equal or nested static and card-storage roots in either direction.
+    Also reject a database path inside either mounted-data root. These checks
+    prevent the page-asset mount from exposing private staging data and keep
+    the two static mounts backed by disjoint directory trees.
+
+Configuration errors are printed to standard error and cause a nonzero exit
+before the listening socket is opened.
+
+### Base-URL helper
+
+`UrlBuilder` is the only component that performs URL construction. It stores
+the normalized `Config::base_url`, percent-encodes each dynamic path segment,
+and can return either an absolute URL or the request path used by the HTTP
+server. Ordinary dynamic route arguments are always individual segments, so a
+slash inside a card ID is encoded rather than treated as structure. Static
+mounts use a separate, explicitly validated relative-path operation.
+
+```cpp
+/// Kind of one path segment supplied to `UrlBuilder`.
+enum class RouteSegmentKind
+{
+    LITERAL,
+    DYNAMIC,
+    PLACEHOLDER
+};
+
+/// One trusted literal, encoded value, or route-registration placeholder.
+struct RouteSegment
+{
+    RouteSegmentKind kind;
+    std::string value;
+};
+
+/// Ordered query parameters encoded by `UrlBuilder`.
+using QueryParameters =
+    std::vector<std::pair<std::string, std::string>>;
+
+/// Build base-aware URLs from trusted literal and encoded dynamic segments.
+class UrlBuilder
+{
+public:
+    /// Construct a builder from a validated absolute HTTP or HTTPS base URL.
+    explicit UrlBuilder(mw::URL base_url);
+
+    /// Return an absolute URL for the supplied route segments.
+    std::string absolute(
+        const std::vector<RouteSegment>& segments,
+        const QueryParameters& query = {}) const;
+
+    /// Append one validated relative path beneath a static-mount prefix.
+    std::string absoluteFromRelativePath(
+        const std::vector<RouteSegment>& mount_prefix,
+        const std::string& relative_path,
+        const QueryParameters& query = {}) const;
+
+    /// Return only the base-prefixed request path for route registration.
+    std::string requestPath(
+        const std::vector<RouteSegment>& segments) const;
+};
+```
+
+`RouteSegment` distinguishes a trusted literal segment from a dynamic value.
+Literal segments are compile-time application route text such as `cards`.
+Dynamic values are percent-encoded as one segment. Route placeholders such as
+`:id` are a third explicit kind used only by `requestPath()` during route
+registration; they are never accepted by `absolute()`.
+
+Dynamic segments must be nonempty and must not equal `.` or `..`. The encoder
+always escapes `/`, `\`, `%`, `?`, and `#`, even if an underlying URL helper
+would otherwise treat one as structural syntax. Placeholder names must match
+`[a-z_][a-z0-9_]*`. These checks occur inside `UrlBuilder`, not at each call
+site.
+
+`absoluteFromRelativePath()` accepts exactly one logical path argument. It
+requires a nonempty relative path without a leading slash or backslash, splits
+it on `/`, rejects empty, `.`, and `..` components, and percent-encodes every
+remaining component independently. Thus `foil/model/card.obj` retains its
+intended separators, while `foil//card.obj` and `../card.obj` are rejected.
+
+`QueryParameters` is an ordered vector of key/value pairs. Both absolute URL
+operations percent-encode each key and value and preserve order, which makes
+generated URLs and tests deterministic. `requestPath()` never includes a query
+string.
+
+`App` owns the application-specific named route table. Its public `urlFor()`
+resolves a name and dispatches by route kind. Dynamic routes delegate their
+individual arguments to `UrlBuilder::absolute()`. Static mounts require one
+argument and delegate it to `absoluteFromRelativePath()`. The private
+`getPath()` resolves a dynamic route definition with named placeholders and
+delegates to `UrlBuilder::requestPath()`. It does not build an absolute URL and
+parse it back into a path. Its private `getMountPath()` returns the literal
+prefix of a static-mount route without its dynamic tail.
+
+If the base URL is `https://example.test/collection/`, the card index request
+path is `/collection/`, not `/`. Route registration therefore prefixes every
+application and static route with the normalized base path.
+
+### `App` interface
+
+Follow the structure used by `shrt`: `App` derives from `mw::HTTPServer` and
+contains all HTTP handlers. It owns the injected data source and the concrete
+application services, while the services remain independently testable.
+
+```cpp
+/// Card Collection HTTP application and named-route owner.
+class App : public mw::HTTPServer
+{
+public:
+    using Request = mw::HTTPServer::Request;
+    using Response = mw::HTTPServer::Response;
+
+    App() = delete;
+
+    /// Construct the application from validated configuration and services.
+    App(const Config& config,
+        std::unique_ptr<DataSourceInterface> data_source,
+        std::unique_ptr<GameRegistry> games,
+        std::unique_ptr<NonSecretRandom> random,
+        std::unique_ptr<AssetStore> assets);
+
+    /// Return the absolute URL for a named application route.
+    std::string urlFor(
+        const std::string& name,
+        const std::vector<std::string>& arguments = {},
+        const QueryParameters& query = {}) const;
+
+    /// Render the card index.
+    void handleCardIndex(const Request& request, Response& response);
+
+    /// Render the new-card form.
+    void handleNewCard(const Request& request, Response& response);
+
+    // The complete interface declares one handle*() method for every dynamic
+    // application route in the HTTP route table below.
+
+private:
+    /// Register all application and static routes exactly once.
+    void setup() override;
+
+    /// Return the server request path for a named route.
+    std::string getPath(
+        const std::string& name,
+        const std::vector<std::string>& argument_names = {}) const;
+
+    /// Return the request prefix for a named static mount.
+    std::string getMountPath(const std::string& name) const;
+};
+```
+
+The actual header lists every handler rather than relying on the abbreviated
+comment above. `App::setup()` binds each handler to `server` using `getPath()`
+and installs the two static mounts using `getMountPath()`. Calling `getPath()`
+for a mount or `getMountPath()` for a dynamic route is a programming error.
+Handlers may delegate validation and use cases to services, but no handler is
+defined outside `App`.
+
+During construction, `App` registers one Inja callback named `url_for`. Its
+first argument is a route name. A dynamic handler route accepts its declared
+dynamic arguments as individual path segments. A static-mount route accepts
+exactly one additional string containing the relative path beneath that mount.
+Templates receive no lower-level URL-building callback. An unknown name,
+nonstring argument, or incorrect argument count is a source/template
+programming error; the renderer logs it and returns the standard 500 page.
+
+The template callback does not accept query parameters. `App` precomputes
+versioned card-asset URLs with a `v` query value equal to the decimal card
+revision and places them in template data. Templates therefore never
+concatenate `?v=` manually.
+
+## Process startup and shutdown
+
+`main()` follows this order:
+
+1. Parse the command line. The only required option is the configuration
+   path; `--help` prints usage and exits.
+2. Load and validate `Config`.
+3. Configure spdlog.
+4. Call `Magick::InitializeMagick(argv[0])` exactly once.
+5. Configure fixed ImageMagick resource limits and verify coder capabilities.
+6. Construct the immutable `GameRegistry`.
+7. Open SQLite through `DataSourceSQLite::fromFile()`.
+8. Call `DataSourceInterface::migrateToLatest()` with the game registry.
+9. Reconcile compiled games through the data-source interface.
+10. Verify that every stored non-null game short name is registered. Fail
+   startup if a compiled game was removed without a migration.
+11. Construct `NonSecretRandom` and `AssetStore`, then delete abandoned private
+    entries. No request can still own them because the server has not started.
+12. Construct `App`, transferring the data source, game registry, and asset
+    store and random generator through `unique_ptr` ownership. Its constructor
+    configures the Inja callback, loads templates, and fails construction on a
+    parse error.
+13. Call `App::start()`. The inherited start path invokes `App::setup()` before
+    libmw begins listening.
+14. Wait for SIGINT or SIGTERM, stop accepting requests, and join the server.
+
+The server must not begin listening if ImageMagick capability checks,
+migration, game validation, template loading, or storage initialization fail.
+
+## Domain types
+
+### Card identity
+
+```cpp
+/// Persisted components from which a public card ID is derived.
+struct CardIdentity
+{
+    std::optional<std::string> game_short_name;
+    std::uint64_t card_number;
+};
+```
+
+Although a loose number is at most `UINT32_MAX`, `uint64_t` is used in C++ so
+the same field can represent future positive game sequences without signed
+conversion mistakes. SQLite stores it as an `INTEGER`; accepted values remain
+within SQLite's signed 64-bit range.
+
+### Card record
+
+```cpp
+/// Common persisted and asset metadata for one card.
+struct Card
+{
+    std::int64_t id;
+    CardIdentity identity;
+    std::string name;
+    std::optional<std::string> short_description;
+    std::optional<std::string> long_description;
+    std::int64_t rarity;
+    std::string front_extension;
+    std::optional<std::string> foil_extension;
+    std::string thumbnail_extension;
+    std::int64_t revision;
+};
+```
+
+`revision` is an implementation field used to reject one browser overwriting a
+newer edit. It also provides a cache-busting query value for fixed asset
+filenames. It starts at one and increments after each successful edit.
+
+```cpp
+/// Logical card image used consistently by validation, storage, and URLs.
+enum class CardAssetType
+{
+    FRONT_ART,
+    FOIL_CONTROL,
+    THUMBNAIL
+};
+```
+
+This is the only server-side enum for card image roles. `ImageProcessor`,
+`AssetStore`, and card-asset URL helpers all use it, preventing separate enums
+from assigning different format or filename rules to the same logical asset.
+
+### Series record
+
+```cpp
+/// Dynamic series metadata belonging to one compiled game.
+struct Series
+{
+    std::int64_t id;
+    std::string game_short_name;
+    std::string name;
+    std::string description;
+};
+```
+
+### Mutation inputs
+
+The service layer receives typed structures. File fields refer to paths in a
+request-owned temporary directory, never browser-supplied filenames.
+
+```cpp
+/// Validated common text and grouping values used to create a card.
+struct CreateCardInput
+{
+    std::optional<std::string> game_short_name;
+    std::string name;
+    std::optional<std::string> short_description;
+    std::optional<std::string> long_description;
+    std::int64_t rarity;
+    std::vector<std::int64_t> series_ids;
+    FormFields game_fields;
+    IncomingImage front;
+    std::optional<IncomingImage> foil;
+    std::optional<IncomingImage> thumbnail;
+};
+
+/// Validated edit values and requested asset changes for one card.
+struct UpdateCardInput
+{
+    std::int64_t expected_revision;
+    std::string name;
+    std::optional<std::string> short_description;
+    std::optional<std::string> long_description;
+    std::int64_t rarity;
+    std::vector<std::int64_t> series_ids;
+    FormFields game_fields;
+    ImageChange front;
+    ImageChange foil;
+    std::optional<IncomingImage> thumbnail;
+};
+```
+
+`ImageChange` is `KEEP`, `REPLACE`, or `REMOVE` plus an optional staged file.
+Artwork never accepts `REMOVE`. Foil does. The server derives whether a render
+input changed from these actions; it does not trust a client Boolean.
+
+## Compiled game interface
+
+### Registry
+
+`GameRegistry` owns `unique_ptr<GameDefinition>` objects and becomes immutable
+before the server starts. Registration validates that short names are nonempty,
+match `[a-z0-9]+`, and are unique. Lookup returns a non-owning const pointer
+whose lifetime is the process lifetime.
+
+Production may initially register no games, in which case loose cards work but
+series creation has no valid game. Tests register a `TestGame` with short name
+`test` and an extension table. A real shipped game is product content and must
+not be invented by the framework.
+
+### `GameDefinition`
+
+Each game implements this public interface:
+
+```cpp
+/// Compiled behavior and metadata schema for one game.
+class GameDefinition
+{
+public:
+    virtual ~GameDefinition() = default;
+
+    /// Return the immutable lowercase public short name.
+    virtual std::string_view shortName() const = 0;
+
+    /// Return the human-readable game name.
+    virtual std::string_view displayName() const = 0;
+
+    /// Return the Markdown game description.
+    virtual std::string_view description() const = 0;
+
+    /// Add this game's tables and indexes to a new schema.
+    virtual mw::E<void> createSchema(mw::SQLite& database) const = 0;
+
+    /// Validate posted fields and return game-owned typed metadata.
+    virtual mw::E<std::unique_ptr<GameCardMetadata>>
+    validateMetadata(const FormFields& fields) const = 0;
+
+    /// Insert validated extension data for a new common card ID.
+    virtual mw::E<void> insertMetadata(
+        mw::SQLite& database, std::int64_t card_id,
+        const GameCardMetadata& metadata) const = 0;
+
+    /// Replace extension data for an existing common card ID.
+    virtual mw::E<void> updateMetadata(
+        mw::SQLite& database, std::int64_t card_id,
+        const GameCardMetadata& metadata) const = 0;
+
+    /// Return raw JSON template data for the create or edit form.
+    virtual TemplateData formData(
+        const std::optional<GameCardMetadataView>& current) const = 0;
+
+    /// Return label/value rows for the read-only card page.
+    virtual mw::E<std::vector<DisplayField>> displayFields(
+        mw::SQLite& database, std::int64_t card_id) const = 0;
+};
+```
+
+`GameCardMetadata` is a polymorphic base class owned with `unique_ptr`. A game
+must verify the dynamic type it receives before accessing it and return an
+internal error for a mismatch.
+
+The generic card form renders a game-provided Inja partial. The partial is
+compiled into the binary or shipped with the application source, not read
+from the database. Its ordinary values remain raw strings for Inja to escape.
+Field names use `game.<name>` so they cannot collide with common fields.
+
+Game tables use a one-to-one foreign key:
+
+```sql
+CREATE TABLE test_cards (
+    card_id INTEGER PRIMARY KEY
+        REFERENCES cards(id) ON DELETE CASCADE,
+    hp INTEGER NOT NULL CHECK(hp >= 0),
+    attack INTEGER NOT NULL CHECK(attack >= 0)
+);
+```
+
+The test game is a test fixture only. It proves the extension mechanism without
+shipping fictional product content.
+
+## SQLite design
+
+### Connection policy
+
+`DataSourceInterface` is the only persistence dependency visible to `App` and
+the services. `DataSourceSQLite` implements it with one `mw::SQLite`
+connection and one process-wide mutex. This is intentionally simple for the
+prototype and prevents another request from interleaving statements between
+`BEGIN` and `COMMIT` on the same connection.
+
+```cpp
+/// Latest schema version implemented by this binary.
+inline constexpr std::int64_t DB_SCHEMA_VERSION = 1;
+
+/// Storage transaction used for an atomic group of persistence operations.
+class DataSourceTransactionInterface
+{
+public:
+    virtual ~DataSourceTransactionInterface() = default;
+
+    /// Allocate and persist the next never-reused number for one game.
+    virtual mw::E<std::uint64_t>
+    allocateGameNumber(const std::string& game_short_name) = 0;
+
+    /// Ensure a registered game has a persistent sequence row.
+    virtual mw::E<void>
+    ensureGameSequence(const std::string& game_short_name) = 0;
+
+    /// Return whether a loose-card number already exists.
+    virtual mw::E<bool> looseNumberExists(std::uint32_t number) = 0;
+
+    /// Re-read a card while the transaction lock is held.
+    virtual mw::E<std::optional<Card>>
+    getCardForUpdate(std::int64_t card_id) = 0;
+
+    /// Insert common, game-specific, and series-membership card rows.
+    virtual mw::E<std::int64_t> insertCard(
+        const Card& card, const GameDefinition* game,
+        const GameCardMetadata* metadata,
+        const std::vector<std::int64_t>& series_ids) = 0;
+
+    /// Replace a card's common, game-specific, and membership rows.
+    virtual mw::E<void> updateCard(
+        const Card& card, const GameDefinition* game,
+        const GameCardMetadata* metadata,
+        const std::vector<std::int64_t>& series_ids) = 0;
+
+    /// Delete a card and its dependent database rows.
+    virtual mw::E<void> deleteCard(std::int64_t card_id) = 0;
+
+    /// Insert a series and return its internal ID.
+    virtual mw::E<std::int64_t> insertSeries(const Series& series) = 0;
+
+    /// Replace a series name and description without changing its game.
+    virtual mw::E<void> updateSeries(const Series& series) = 0;
+
+    /// Delete a series and its membership rows.
+    virtual mw::E<void> deleteSeries(std::int64_t series_id) = 0;
+
+    /// Commit the transaction and release its lock.
+    virtual mw::E<void> commit() = 0;
+};
+
+/// Common persistence API used by the application and services.
+class DataSourceInterface
+{
+public:
+    virtual ~DataSourceInterface() = default;
+
+    /// Return the stored schema version.
+    virtual mw::E<std::int64_t> getSchemaVersion() const = 0;
+
+    /// Apply every required migration in order through the current version.
+    mw::E<void> migrateToLatest(const GameRegistry& games);
+
+    /// Create schema version 1 from an empty version-0 database.
+    virtual mw::E<void>
+    migrateSchema0To1(const GameRegistry& games) = 0;
+
+    /// Start an immediate transaction with exclusive mutation ownership.
+    virtual mw::E<std::unique_ptr<DataSourceTransactionInterface>>
+    beginTransaction() = 0;
+
+    /// Return all cards for the unpaginated index.
+    virtual mw::E<std::vector<Card>> getCards() const = 0;
+
+    /// Return a card by its parsed identity.
+    virtual mw::E<std::optional<Card>>
+    getCard(const CardIdentity& identity) const = 0;
+
+    /// Return a card's game-owned display fields.
+    virtual mw::E<std::vector<DisplayField>> getGameDisplayFields(
+        const GameDefinition& game, std::int64_t card_id) const = 0;
+
+    /// Return all series, ordered by game and name.
+    virtual mw::E<std::vector<Series>> getSeries() const = 0;
+
+    /// Return one series by internal ID.
+    virtual mw::E<std::optional<Series>>
+    getSeries(std::int64_t series_id) const = 0;
+
+    /// Return the series memberships for one card.
+    virtual mw::E<std::vector<std::int64_t>>
+    getCardSeries(std::int64_t card_id) const = 0;
+
+    /// Return every persisted game name used for startup reconciliation.
+    virtual mw::E<std::vector<std::string>> getPersistedGameNames() const = 0;
+
+protected:
+    /// Set the schema version inside a concrete migration transaction.
+    virtual mw::E<void> setSchemaVersion(std::int64_t version) = 0;
+};
+```
+
+The real header includes intention comments on every item. It may add focused
+read methods needed by forms, such as series filtered by game, but it must not
+expose raw SQL, prepared statements, or `mw::SQLite` to services.
+
+For a loose card, `insertCard()` and `updateCard()` receive null game and
+metadata pointers. For a game card, both pointers are non-null and the game
+short name must match the card identity. The transaction implementation
+validates these pairings before invoking a game persistence hook. On insert,
+the input card's internal ID must be zero; a nonzero value is an internal
+error. The returned integer is the newly assigned SQLite primary key.
+
+`DataSourceSQLite::fromFile()` opens the connection and enables its pragmas;
+it does not migrate implicitly. `main()` calls the common
+`migrateToLatest()` coordinator before moving the interface into `App`.
+`DataSourceMock` and `DataSourceTransactionMock` implement the two interfaces
+for service and handler tests.
+
+Code outside `DataSourceSQLite`, its transaction implementation, and compiled
+game persistence hooks must not issue SQL. The SQLite implementation calls
+the game hooks for extension schemas and rows; this exception is necessary
+because compiled games define their own strongly typed SQLite tables.
+
+On every connection, enable:
+
+```sql
+PRAGMA foreign_keys = ON;
+PRAGMA journal_mode = WAL;
+PRAGMA synchronous = NORMAL;
+PRAGMA busy_timeout = 5000;
+```
+
+SQLite requires foreign-key enforcement to be enabled per connection; see the
+[official foreign-key documentation](https://www.sqlite.org/foreignkeys.html).
+WAL allows readers and the serialized writer to coexist; see
+[SQLite WAL](https://www.sqlite.org/wal.html).
+
+### Transaction wrapper
+
+`DataSourceSQLiteTransaction` is the concrete, noncopyable implementation of
+`DataSourceTransactionInterface`. `beginTransaction()` acquires the data
+source mutex, executes `BEGIN IMMEDIATE`, and returns it through `unique_ptr`.
+`commit()` executes `COMMIT`, marks the object complete, and releases the lock.
+Destruction executes `ROLLBACK` if commit was not called and then releases the
+lock.
+
+`BEGIN IMMEDIATE` obtains the write reservation before the service changes the
+filesystem, so another process cannot win a conflicting write halfway through
+publication. SQLite documents the transaction modes in
+[`BEGIN TRANSACTION`](https://www.sqlite.org/lang_transaction.html).
+
+The transaction performs its SQL on the owning data source's connection
+without attempting to lock the mutex again. It cannot outlive its data source;
+`App` declares the data-source member before services so destruction occurs in
+the safe reverse order. The wrapper must not throw from its destructor. A
+rollback error is logged. Explicit operations still return `mw::E<T>`.
+
+### Schema version 1
+
+```sql
+CREATE TABLE cards (
+    id INTEGER PRIMARY KEY,
+    game_short_name TEXT,
+    card_number INTEGER NOT NULL,
+    name TEXT NOT NULL,
+    short_description TEXT,
+    long_description TEXT,
+    rarity INTEGER NOT NULL DEFAULT 0 CHECK(rarity >= 0),
+    front_extension TEXT NOT NULL
+        CHECK(front_extension IN ('jpg', 'jpeg', 'webp', 'avif')),
+    foil_extension TEXT
+        CHECK(foil_extension IN ('webp', 'avif')),
+    thumbnail_extension TEXT NOT NULL
+        CHECK(thumbnail_extension IN ('jpg', 'jpeg', 'webp', 'avif')),
+    revision INTEGER NOT NULL DEFAULT 1 CHECK(revision >= 1),
+    CHECK(
+        (game_short_name IS NULL
+         AND card_number >= 0
+         AND card_number <= 4294967295)
+        OR
+        (game_short_name IS NOT NULL AND card_number >= 1)
+    )
+);
+
+CREATE UNIQUE INDEX cards_game_number_unique
+ON cards(game_short_name, card_number)
+WHERE game_short_name IS NOT NULL;
+
+CREATE UNIQUE INDEX cards_loose_number_unique
+ON cards(card_number)
+WHERE game_short_name IS NULL;
+
+CREATE TABLE game_sequences (
+    game_short_name TEXT PRIMARY KEY,
+    last_number INTEGER NOT NULL CHECK(last_number >= 0)
+);
+
+CREATE TABLE series (
+    id INTEGER PRIMARY KEY,
+    game_short_name TEXT NOT NULL,
+    name TEXT NOT NULL,
+    description TEXT NOT NULL DEFAULT '',
+    UNIQUE(game_short_name, name)
+);
+
+CREATE TABLE card_series (
+    card_id INTEGER NOT NULL
+        REFERENCES cards(id) ON DELETE CASCADE,
+    series_id INTEGER NOT NULL
+        REFERENCES series(id) ON DELETE CASCADE,
+    PRIMARY KEY(card_id, series_id)
+);
+```
+
+PNG is absent from stored extensions because every accepted PNG is normalized
+to AVIF before publication. JPEG is absent only from foil extensions because
+foil-control textures require alpha. A nonfoil thumbnail is a resized artwork
+image and may be opaque, so JPEG is valid for thumbnails. The server
+canonicalizes `.jpeg` and `.jpg` input to one chosen extension, preferably
+`jpg`, before insertion.
+
+SQLite partial unique indexes enforce the two identity namespaces without
+making null game values conflict. See
+[partial indexes](https://www.sqlite.org/partialindex.html).
+
+### Same-game series trigger
+
+Application validation gives useful errors, while a trigger protects the
+invariant if a future code path bypasses the service:
+
+```sql
+CREATE TRIGGER card_series_same_game_insert
+BEFORE INSERT ON card_series
+BEGIN
+    SELECT CASE
+        WHEN (SELECT game_short_name
+              FROM cards
+              WHERE id = NEW.card_id) IS NULL
+        THEN RAISE(ABORT, 'loose card cannot belong to a series')
+        WHEN (SELECT game_short_name
+              FROM cards
+              WHERE id = NEW.card_id)
+             !=
+             (SELECT game_short_name
+              FROM series
+              WHERE id = NEW.series_id)
+        THEN RAISE(ABORT, 'card and series games differ')
+    END;
+END;
+```
+
+An equivalent update trigger is unnecessary because `card_series` key values
+are never updated; rows are deleted and inserted. A card's game is immutable.
+A series may not move between games after creation for the same reason. The
+edit-series form therefore displays its game read-only.
+
+### Migration algorithm
+
+`DataSourceInterface::migrateToLatest()` is a nonvirtual coordinator shared by
+the SQLite implementation and mocks. At startup it:
+
+1. Calls `getSchemaVersion()`.
+2. Rejects a version greater than the binary's supported version.
+3. For zero, calls `migrateSchema0To1(games)`.
+4. For each older supported version, calls exactly one virtual function such as
+   `migrateSchema1To2()`.
+5. Re-read the schema version after each step and require it to equal the
+   migration's declared target.
+6. Stop startup immediately if any migration fails.
+
+`DataSourceSQLite::migrateSchema0To1()` begins an immediate transaction,
+creates the common schema, calls every registered game's `createSchema()`,
+sets `user_version = 1`, and commits. Every later SQLite migration follows the
+same pattern and updates the version in the same transaction as its schema
+changes. Do not skip versions. A migration function is valid only from its
+declared source version; automatic retry occurs through rollback on the next
+process start.
+
+### Compiled-game reconciliation
+
+After migration, run:
+
+```sql
+INSERT OR IGNORE INTO game_sequences(game_short_name, last_number)
+VALUES(?, 0);
+```
+
+once for every registered game through a transaction object, then commit that
+transaction. Next call `getPersistedGameNames()`. If any returned name is
+absent from the registry, fail startup with an error requiring a migration.
+Silently treating such cards as loose would change their public IDs and is
+forbidden.
+
+## Public ID algorithms
+
+### Game card allocation
+
+Inside the creation transaction:
+
+```sql
+UPDATE game_sequences
+SET last_number = last_number + 1
+WHERE game_short_name = ?
+RETURNING last_number;
+```
+
+The update must return exactly one row. Zero rows means the compiled game was
+not reconciled and is an internal error. SQLite's `RETURNING` behavior is
+documented in the
+[official reference](https://www.sqlite.org/lang_returning.html).
+
+The transaction inserts the card using the returned number. Deleting a card
+does not decrement `last_number`, so deleted numbers are never reused. A
+rolled-back creation may reuse a number because no card ever committed with
+that number; this does not violate the deletion rule.
+
+### Loose card allocation
+
+`CardService` performs these steps while holding its data transaction:
+
+1. Call `NonSecretRandom::next()` to obtain a uniformly distributed 32-bit
+   integer.
+2. Attempt to insert the card with `game_short_name = NULL` and that number.
+3. If the loose-number unique index reports a conflict, generate another
+   value and retry.
+4. Stop after 128 collisions and return an internal error. Reaching that bound
+   indicates a broken random source or an unexpectedly saturated namespace.
+
+The database unique index is the correctness guarantee; pseudorandomness only
+spreads candidates through the 32-bit namespace. Loose IDs are public
+identifiers, not secrets, so a cryptographic generator adds no product value.
+
+### Encoding and parsing
+
+Do not implement a custom `encodeBase36()` function. The integer overloads of
+[`std::to_chars`](https://en.cppreference.com/w/cpp/utility/to_chars) and
+[`std::from_chars`](https://en.cppreference.com/w/cpp/utility/from_chars)
+accept an explicit base from 2 through 36 and are locale-independent.
+
+For a loose card, `formatPublicId()` allocates a stack
+`std::array<char, std::numeric_limits<std::uint32_t>::digits>`, calls
+`std::to_chars()` with base 36, checks the returned `std::errc`, and constructs
+the result from the written range. The standard representation uses lowercase
+digits `0-9a-z` and has no leading zeroes except for zero itself. Game-card
+decimal numbers use the same function with base 10. `formatPublicId()` returns:
+
+- `<shortname>-<decimal-number>` for a game card; or
+- `<base36-number>` for a loose card.
+
+For a loose ID, `parsePublicId()` first applies the canonical lowercase syntax
+checks, then calls `std::from_chars()` into `std::uint32_t` with base 36. It
+requires an empty error code and requires the returned pointer to equal the end
+of the input. It formats the parsed value again and requires exact equality
+with the input, which rejects noncanonical leading zeroes. Game-card numbers
+use `std::from_chars()` with base 10 into `std::uint64_t` followed by the same
+complete-consumption and canonical-form checks.
+
+`parsePublicId()` rejects:
+
+- empty strings;
+- uppercase input in canonical routes;
+- characters outside lowercase ASCII letters, digits, and one optional
+  hyphen;
+- multiple hyphens;
+- a missing game or numeric part;
+- decimal zero for a game card;
+- leading zeroes other than the value zero for a loose card;
+- unknown game short names;
+- decimal overflow beyond signed SQLite integer range; and
+- base36 overflow beyond `UINT32_MAX`.
+
+The read route may detect uppercase input and respond with `308 Permanent
+Redirect` to the lowercase canonical URL. Mutation routes require the exact
+canonical form and return 404 otherwise.
+
+### Natural sorting
+
+The prototype has no pagination or scale limit, so the data source loads the
+index rows and sorts them in C++. The comparator operates on the derived
+lowercase public IDs:
+
+1. Split each string into alternating digit and nondigit runs.
+2. Compare nondigit runs bytewise.
+3. Compare digit runs as integers without converting to a fixed-width type:
+   remove leading zeroes, compare digit counts, then compare bytes.
+4. If numeric values are equal, the shorter original run sorts first.
+5. Continue with the next run.
+6. Use the internal card ID only as a deterministic final tie breaker.
+
+Descending order reverses the final comparator. This produces `pkm-2` before
+`pkm-10` without requiring a custom SQLite collation.
+
+## Image processing
+
+### Trust boundary
+
+Browser checks improve feedback but do not establish validity. The server
+ignores filename extensions and multipart content types when deciding image
+format. A minimal application-owned signature sniffer accepts only JPEG, PNG,
+WebP, and AVIF. Magick++ then performs the authoritative parse and full decode.
+
+No decoder receives a path outside the request staging directory. No
+browser-supplied filename becomes part of a filesystem path. The application
+selects an explicit `JPEG:`, `PNG:`, `WEBP:`, or `AVIF:` coder from the sniffed
+format before giving the server-owned staging path to Magick++. This prevents
+the filename or upload contents from selecting a pseudo-format or unrelated
+ImageMagick coder.
+
+### System capability and resource checks
+
+The operator must install ImageMagick 7, its Magick++ development files, and
+JPEG, PNG, WebP, and AVIF delegates. The installation can be inspected with:
+
+```sh
+magick identify -list format
+```
+
+At startup, after `Magick::InitializeMagick()`, construct
+`Magick::CoderInfo` for `JPEG`, `PNG`, `WEBP`, and `AVIF`. All four must be
+readable, and AVIF must also be writable for PNG conversion. Failure names the
+missing coder or capability and exits before the server listens.
+
+Before request threads start, set ImageMagick's process-global width and
+height resource limits to 2048 pixels and its image-list limit to two. The
+second list slot permits bounded detection of a multi-frame input. The
+application never changes global ImageMagick limits after startup and never
+weakens the operator's ImageMagick security policy. Request byte limits and
+the installed policy continue to bound memory, map, disk, and time resources.
+Because these settings are process-global, initialization and capability
+checks happen exactly once in `main()`.
+
+### `ImageProcessor` interface
+
+```cpp
+/// Validated image information and normalized staged path.
+struct ProcessedImage
+{
+    std::filesystem::path path;
+    std::string extension;
+    std::uint32_t width;
+    std::uint32_t height;
+    bool has_alpha;
+};
+
+/// Validate and normalize uploaded images before publication.
+class ImageProcessor
+{
+public:
+    /// Process one staged upload according to its intended role.
+    mw::E<ProcessedImage> process(
+        const std::filesystem::path& input, CardAssetType type) const;
+
+    /// Generate a plain-card thumbnail from normalized front artwork.
+    mw::E<ProcessedImage> generatePlainThumbnail(
+        const ProcessedImage& artwork,
+        const std::filesystem::path& output) const;
+};
+```
+
+### Validation sequence
+
+For every upload:
+
+1. Open the staged file without following a browser filename.
+2. Read enough bytes to identify JPEG, PNG, WebP, or AVIF by signature.
+3. Reject a format not allowed for the requested role.
+4. Prefix the server-owned path with the selected explicit Magick++ coder and
+   call `Magick::readImages()` under the configured resource limits.
+5. Catch `Magick::Exception` at the `ImageProcessor` boundary. Log its detail
+   and return an `mw::E` with a safe invalid-image or processing error.
+6. Require exactly one decoded image. Reject animation and image sequences.
+7. Require the decoded `magick()` value to agree with the sniffed format.
+8. Read `columns()`, `rows()`, and alpha state from the decoded image. Reject
+   zero dimensions or a long side greater than 2048. Only the foil-control
+   role requires alpha.
+9. For PNG, transform the decoded image to sRGB and encode a single still AVIF
+   using configured quality. Preserve its alpha channel.
+10. For JPEG, WebP, and AVIF, retain the original bytes after the successful
+    full decode.
+11. Rename the normalized staged file to its canonical role filename.
+
+Full validation is necessary because retaining original bytes after reading
+only a header would let malformed files reach browsers and reverse proxies.
+
+### Format-specific rules
+
+- JPEG is valid for artwork and thumbnails. It is invalid for foil control
+  because it has no alpha.
+- PNG is decoded by Magick++ and always converted to AVIF. An APNG produces
+  more than one image and is rejected.
+- WebP is fully decoded by Magick++. Animated WebP produces more than one
+  image and is rejected. Only the foil-control role requires alpha.
+- AVIF is fully decoded by Magick++. An AVIF sequence produces more than one
+  image and is rejected. Only the foil-control role requires alpha.
+
+Decoded allocation sizes must be checked before multiplication. With a
+2048-pixel maximum on each dimension, an RGBA8 buffer is at most 16 MiB, but
+the code still uses checked multiplication rather than relying on that
+observation.
+
+### AVIF conversion
+
+PNG conversion operates on the single decoded `Magick::Image`. Transform it
+to the sRGB color space, set its output format to `AVIF`, apply
+`Config::avif_quality`, and preserve the source alpha channel when present.
+Write exactly one image to a separate staged `.avif` path, flush and close it,
+then replace the staged PNG. Never overwrite the input while Magick++ may
+still be reading it. Do not invoke the `magick` command-line program.
+
+Conversion failure returns an internal processing error and deletes both
+temporary files. It never falls back to storing the PNG because that would
+contradict the storage contract.
+
+### Backend plain-thumbnail generation
+
+When the resulting card has no foil, `ImageProcessor` generates the thumbnail
+from the normalized front artwork. It does not accept a client thumbnail. The
+target height is `Config::thumbnail_long_side`; the target width is the
+nearest integer to `height * 5 / 7`, with a minimum of one pixel. Thus the
+default target is 183 by 256 pixels. This is the closest integer-pixel 5:7
+frame for the configured long side.
+
+Fully decode the normalized artwork with its explicit coder, transform it to
+sRGB, and resize it to the exact target width and height without preserving
+the source aspect ratio. This deliberate stretch matches the 5:7 card frame
+used by the renderer. Encode the result as one AVIF image using
+`Config::avif_quality`, preserve alpha when present, and write it to a
+separate staged `thumb.avif` path. A processing or write failure removes the
+partial output and fails the whole card operation.
+
+## Multipart request handling
+
+### Streaming receiver
+
+Use cpp-httplib's multipart `ContentReader` path so binary fields are streamed
+to request staging files rather than copied into `Request::body`. The official
+[cpp-httplib documentation](https://github.com/yhirose/cpp-httplib) describes
+the blocking multipart content receiver.
+
+`MultipartReader` tracks the current part and enforces:
+
+- only expected field names;
+- exactly one value for singleton fields;
+- text accumulation only for text fields;
+- binary streaming only for image fields;
+- no use of the supplied filename as a path; and
+- cleanup of every opened path from its destructor unless ownership has moved
+  to the service.
+
+The receiver accepts only `multipart/form-data` for card mutations. Series
+forms use `application/x-www-form-urlencoded` because they contain no files.
+
+### Create-card form contract
+
+`POST /cards` contains:
+
+| Field | Type | Rule |
+| --- | --- | --- |
+| `game` | text | Empty for loose, otherwise a registered short name |
+| `name` | text | Required after trimming |
+| `short_description` | text | Empty becomes null |
+| `long_description` | text | Empty becomes null |
+| `rarity` | text integer | Required, nonnegative |
+| `series_id` | repeated text integer | Each must exist in selected game |
+| `game.*` | game-specific text/file | Validated by the compiled game |
+| `front` | binary | Required artwork |
+| `foil` | binary | Optional foil control |
+| `thumbnail` | binary | Required with foil; rejected without foil |
+
+The server ignores any attempted public ID or card number field. Allocation is
+server-owned.
+
+### Edit-card form contract
+
+`POST /cards/<id>` contains the common metadata fields plus:
+
+| Field | Type | Rule |
+| --- | --- | --- |
+| `revision` | text integer | Must equal current database revision |
+| `front_action` | text | `keep` or `replace` |
+| `foil_action` | text | `keep`, `replace`, or `remove` |
+| `front` | binary | Required only for front replacement |
+| `foil` | binary | Required only for foil replacement |
+| `thumbnail` | binary | Required for a changed resulting foil card only |
+
+The game and card number are displayed but are not editable or accepted as
+mutation fields. A stale revision returns 409 and leaves both database and
+assets unchanged.
+
+For an edit that changes a rendering input, the thumbnail rule is based on the
+resulting card. If it has foil, the browser must send a thumbnail. If it has no
+foil, the browser must not send one because the backend derives it from the
+resulting front artwork. A metadata-only edit accepts no thumbnail.
+
+## Browser card component
+
+### Copied `foil` snapshot
+
+Copy the reviewed `foil` snapshot at commit `d014dd4` into `static/foil` and
+preserve its license. Remove demo-only URL fragment code, example reset,
+uniform foil sliders, and bundled example selection from application pages.
+
+Refactor the copied global `main()` into a public `CardPreview` class. The
+class owns one canvas, WebGL context, renderer, scene, material controller,
+and animation-frame handle. It has this conceptual interface:
+
+```javascript
+/** Own one interactive card renderer and its replaceable images. */
+class CardPreview
+{
+    /** Create a preview attached to one canvas and load static resources. */
+    static async create(canvas, options) {}
+
+    /** Replace artwork and optional foil control after both decode. */
+    async setImages(artwork, foil_control) {}
+
+    /** Restore the camera to the deterministic thumbnail state. */
+    resetCamera() {}
+
+    /** Render a foil card and return a tightly cropped transparent PNG. */
+    async captureThumbnail(long_side) {}
+
+    /** Stop animation and release WebGL resources. */
+    dispose() {}
+}
+```
+
+Do not define a closure solely to reuse an algorithm. Named functions such as
+`findAlphaBounds()` and `scaleDimensions()` remain independently testable.
+
+### Initial page data
+
+Server pages include one non-executable JSON block:
+
+```html
+<script id="CardPageData" type="application/json">
+    {"mode":"edit","front_url":"...","foil_url":"..."}
+</script>
+```
+
+The JSON serializer must escape `<` as `\u003c` so a value cannot terminate
+the script element with `</script>`. JavaScript reads `textContent` and calls
+`JSON.parse()`. Do not interpolate strings into executable JavaScript.
+
+### Local files and remote URLs
+
+`ImageInput` normalizes both sources to `File` objects:
+
+1. For a local input, use the selected `File`.
+2. For a URL, construct `new URL(value)` and require HTTP or HTTPS.
+3. Call `fetch()` with CORS mode.
+4. Require an OK response.
+5. Read a `Blob`, derive a safe internal filename from its detected type, and
+   construct a `File`.
+6. Decode it with `createImageBitmap()`.
+7. Reject a long side greater than 2048.
+8. Pass the decoded source to `CardPreview`.
+
+A CORS failure remains a browser error and is shown beside the URL field. The
+backend never receives or fetches the remote URL. The HTML standard explains
+that cross-origin image state affects canvas use in its
+[image section](https://html.spec.whatwg.org/multipage/images.html).
+
+### No-foil rendering
+
+The original `foil` material always has a control source. Application code
+must add an explicit nonfoil front material or shader branch. Absence of a
+control texture must not substitute uniform foil values. The view and editor
+both select the plain material when `foil_url` is null.
+
+### Form submission
+
+Card forms remain native form submissions. JavaScript uses the form's
+`formdata` event to append prepared remote `File` objects and, for a resulting
+foil card, the generated thumbnail synchronously.
+
+Remote-file preparation and foil-thumbnail capture are asynchronous, so the
+submit handler follows this state machine:
+
+1. On the first submit, prevent submission.
+2. Disable the submit button and set state to `PREPARING`.
+3. Validate and load current image sources.
+4. If the resulting card has foil and a rendering input changed, generate the
+   required thumbnail.
+5. Store any resulting blob in component memory.
+6. Set state to `READY` and call `form.requestSubmit()`.
+7. On the second submit, do not prevent default.
+8. During the synchronous `formdata` event, append remote image files and any
+   foil-thumbnail blob.
+9. Let the browser navigate normally to the server response.
+
+This preserves ordinary redirect and error-page behavior without using a
+JSON API. A preparation failure re-enables the form and displays an inline
+message; no HTTP request is sent.
+
+## Thumbnail algorithms
+
+### Plain card
+
+The browser does not generate or upload a thumbnail for a card without foil.
+The backend applies the Magick++ plain-thumbnail algorithm above to the
+resulting front artwork and stores `thumb.avif`. In particular, it stretches
+the artwork to the closest integer-pixel 5:7 frame rather than preserving the
+artwork's original aspect ratio.
+
+### Foil card
+
+For a card with foil:
+
+1. Wait until artwork, foil control, model, shaders, and spectral lookup data
+   are ready.
+2. Reset camera rotation to `[0, 0]` and use the initial scene light.
+3. Size a temporary WebGL canvas large enough to avoid upscaling the final
+   thumbnail.
+4. Draw exactly one deterministic frame with a transparent clear color.
+5. Call `gl.readPixels()` before the drawing buffer can be discarded.
+6. Flip rows while copying WebGL's bottom-left origin into a top-left-origin
+   `ImageData` buffer.
+7. Scan alpha values and find the smallest rectangle containing a nonzero
+   alpha pixel.
+8. If no pixel has nonzero alpha, fail generation.
+9. Copy that rectangle to a 2D canvas.
+10. Scale it so its long side equals the configured size.
+11. Export a PNG blob and append it to the form.
+
+The alpha scan uses `alpha != 0`, matching ImageMagick-style transparent-edge
+trimming while retaining antialiased edge pixels. It does not use an arbitrary
+alpha threshold that could shave off translucent card edges.
+
+The browser receives `thumbnail_long_side` in page JSON from the server. It
+does not hard-code 256.
+
+### Edit regeneration
+
+The edit component caches whether a rendering input changed. Replacing the
+front, replacing the foil, or removing the foil requires a new thumbnail. If
+the resulting card has foil, the browser captures and uploads it. If the
+resulting card has no foil, the backend generates it and rejects a thumbnail
+upload. A metadata-only edit sends no thumbnail. The backend independently
+derives these rules from the stored card and the two asset actions.
+
+## Markdown processing
+
+### Why an AST transformation is required
+
+The inspected MacroDown implementation stores text and link URLs verbatim and
+its standard library places values into HTML templates. Parsing alone does not
+disable raw HTML. `MarkdownRenderer` must transform the parsed user tree before
+calling `MacroDown::render()`.
+
+### Validation and escaping algorithm
+
+For every description:
+
+1. Parse the source with `MacroDown::parse()`.
+2. Walk the complete syntax tree with a mutable recursive function. MacroDown's
+   `Node::forEach()` accepts a const node, so it is suitable for validation but
+   not the later escaping mutation. The application walker recurses through
+   the public `Node::data` variant instead of using `const_cast`.
+3. For every `link` or `img` macro, require at least one argument and require
+   its URL argument to contain only literal text/group nodes.
+4. Parse the literal URL with `mw::URL::fromStr()`.
+5. Require an exact lowercase scheme of `http` or `https` and a nonempty host.
+6. Reject the entire description if any URL fails.
+7. After URL validation, recursively replace every user-originated `Text`
+   node's content with `mw::escapeHTML(content)`.
+8. Render the transformed tree with MacroDown.
+
+Validation happens before escaping so `&` in a query string is parsed as the
+original URL. Escaping then changes it to `&amp;` for safe use in an HTML
+attribute while preserving browser-visible URL semantics.
+
+Escaping all user-tree text also neutralizes literal `<script>` input and raw
+HTML embedded in a user-defined macro body. MacroDown's own registered
+standard-library templates still generate tags because those templates are
+not nodes in the user tree.
+
+If a user-defined macro attempts to compute a link URL through another macro,
+the URL is not a plain literal and is rejected. This is a deliberate prototype
+restriction; proving arbitrary macro expansion safe would require validating
+the evaluated output or introducing an HTML sanitizer.
+
+### Template insertion
+
+`MarkdownRenderer` returns a distinct `RenderedHtml` type, not a plain string.
+Only `HtmlRenderer` can register it for post-render marker substitution. An
+Inja template references the associated `*_html_marker` value through an
+ordinary escaped interpolation such as `{{ description_html_marker }}`. Inja
+first escapes every normal JSON string. `HtmlRenderer` then replaces only the
+fresh markers it registered for that render with their `RenderedHtml` values.
+Ordinary names, IDs, errors, and form values never enter this raw-fragment map.
+
+This type distinction makes an accidental raw insertion visible during code
+review and prevents arbitrary database strings from being treated as trusted
+HTML.
+
+## HTML pages
+
+### Shared layout
+
+Every response page contains:
+
+- UTF-8 metadata;
+- responsive viewport metadata;
+- an escaped page title;
+- navigation to the card index, new-card page, and series administration;
+- the configured base-aware stylesheet URL;
+- a main landmark;
+- one page-specific body partial; and
+- scripts only on pages that need them.
+
+All mutation forms use labels, native input constraints, a visible submit
+button, and server-side validation. Browser constraints are feedback, not the
+only validation.
+
+### Card index
+
+Each grid entry contains a link to the card, its thumbnail or configured
+placeholder, uppercase public ID, name, and optional rarity display. Image
+elements use lazy loading. The sort controls are ordinary links preserving
+`sort=id` and changing `direction=asc|desc`.
+
+When there are no cards, render an empty grid. An optional short sentence such
+as “No cards yet” is presentation, not a required state machine.
+
+### Card view
+
+The view page shows:
+
+- an interactive canvas;
+- public ID and common metadata;
+- game name when present;
+- every series membership;
+- rendered short and long descriptions;
+- game-specific display fields;
+- edit and delete controls; and
+- an asset warning when a required file is missing.
+
+Missing front artwork uses the placeholder URL in page data so WebGL receives
+a valid texture. The warning names the missing logical asset but does not leak
+the absolute filesystem path.
+
+To create this page, you should just copy over the `foil` files, and
+modify it, instead of implement the view from scratch. The sidebar in
+`foil` should be replaced to display the card info mentioned above. Do
+not alter the WebGL-related logic.
+
+### Card form
+
+The create form lets the visitor select loose or a compiled game. Changing
+game reveals that game's server-rendered field section and filters series
+choices already embedded in page data. Because a selected game changes the
+server-owned metadata schema, the simplest prototype behavior is to reload
+`/cards/new?game=<shortname>` when the game selection changes.
+
+The edit form does not permit changing the game or number. It shows current
+images and lets the visitor keep or replace artwork and keep, replace, or
+remove foil control.
+
+To create this page, you should just copy over the `foil` files, and
+modify it, instead of implement the view from scratch. You probably
+need to add some fields to the sidebar, and add a submit button. You
+should also remove the foil sliders. Do not alter the WebGL-related
+logic.
+
+### Series pages
+
+The series index groups series by compiled game and provides create, edit, and
+delete links. The create form requires a game, unique name, and description.
+The edit form keeps the game fixed. Deletion requires a confirmation page and
+removes membership rows through `ON DELETE CASCADE`.
+
+Deleting a series never deletes a card.
+
+## HTTP routes
+
+All paths below are relative to the configured base-URL path. The route name
+is the exact string accepted by `App::urlFor()` and Inja's `url_for()`.
+
+| Name | Method | Path | Behavior |
+| --- | --- | --- | --- |
+| `card-index` | GET | `/` | Card index |
+| `card-new` | GET | `/cards/new` | Create-card form |
+| `cards` | POST | `/cards` | Create card |
+| `card` | GET | `/cards/<id>` | Read-only card view |
+| `card-edit` | GET | `/cards/<id>/edit` | Edit-card form |
+| `card` | POST | `/cards/<id>` | Update card |
+| `card-delete` | GET | `/cards/<id>/delete` | Delete confirmation |
+| `card-delete` | POST | `/cards/<id>/delete` | Delete card |
+| `series-index` | GET | `/series` | Series administration index |
+| `series-new` | GET | `/series/new` | Create-series form |
+| `series` | POST | `/series` | Create series |
+| `series-edit` | GET | `/series/<integer>/edit` | Edit-series form |
+| `series-item` | POST | `/series/<integer>` | Update series |
+| `series-delete` | GET | `/series/<integer>/delete` | Delete confirmation |
+| `series-delete` | POST | `/series/<integer>/delete` | Delete series |
+| `card-asset` | GET | `/static-cards/<path>` | Card static mount |
+| `static` | GET | `/static/<path>` | Application static mount |
+
+Names with the same path but different methods intentionally share one URL
+name. `card`, for example, is both the view URL and the edit form action.
+`card`, `card-edit`, `card-delete`, `series-edit`, `series-item`, and
+`series-delete` require one dynamic segment. `card-asset` and `static` each
+require exactly one relative-path argument. For example, use
+`url_for("static", "foil/model/card.obj")`. All other route names require
+none.
+
+Use POST for browser mutations because native HTML forms support GET and POST.
+The service methods remain operation-oriented and can later be mapped to PUT
+or DELETE in an API.
+
+### Successful mutation responses
+
+- Card creation returns `303 See Other` to `/cards/<new-id>`.
+- Card editing returns `303 See Other` to the card view.
+- Card deletion returns `303 See Other` to the index.
+- Series mutations return `303 See Other` to `/series`.
+
+`303` explicitly converts the follow-up navigation to GET and prevents a page
+refresh from reposting a multipart body. Its semantics are defined by
+[RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html#name-303-see-other).
+
+### Error responses
+
+| Status | Use |
+| --- | --- |
+| 400 | Malformed form, ID syntax, integer, or multipart structure |
+| 404 | Unknown card, series, game route, or asset |
+| 409 | Duplicate series, stale card revision, or exhausted identity conflict |
+| 413 | HTTP body rejected by the server's payload bound |
+| 415 | Unsupported or misidentified image format |
+| 422 | Well-formed input violating field, Markdown, image, or series rules |
+| 500 | Database, template, image-processing, or filesystem failure |
+| 503 | SQLite remains busy after its configured timeout |
+
+Every `App` handler error response is a complete server-rendered `error.html`
+page. The page includes a short safe message and request correlation ID.
+Static-mount misses use cpp-httplib's normal 404 response. Detailed Inja,
+Magick++, SQL, and filesystem errors go only to logs.
+
+### Static mounts
+
+`AssetStore` publishes only these card asset names:
+
+- `front-art.jpg`;
+- `front-art.jpeg` if retained as a supported canonical variant;
+- `front-art.webp`;
+- `front-art.avif`;
+- `foil.webp`;
+- `foil.avif`;
+- `thumb.jpg`;
+- `thumb.jpeg` if retained as a supported canonical variant;
+- `thumb.webp`; and
+- `thumb.avif`.
+
+The shared helper for selecting one of those names is:
+
+```cpp
+/// Return a card asset path relative to the published card-storage mount.
+mw::E<std::optional<std::string>> cardAssetRelativePath(
+    const Card& card, CardAssetType type);
+```
+
+It has this exact mapping:
+
+| Type | Relative path |
+| --- | --- |
+| `FRONT_ART` | `<lowercase-public-id>/front-art.<front_extension>` |
+| `FOIL_CONTROL` | `<lowercase-public-id>/foil.<foil_extension>` |
+| `THUMBNAIL` | `<lowercase-public-id>/thumb.<thumbnail_extension>` |
+
+`FOIL_CONTROL` returns a successful `nullopt` when the card has no foil.
+Required assets always return a value. An invalid identity or persisted
+extension returns an internal error rather than producing a path. The helper
+does not touch the filesystem and does not include `published`, the configured
+storage root, or the `/static-cards` URL prefix.
+
+`AssetStore` uses the same helper beneath its `published` directory, and `App`
+passes the returned string as the single relative-path argument to
+`urlFor("card-asset", {relative_path}, query)`. Callers must handle `nullopt`
+before calling `urlFor()`.
+
+`card-asset` is a named URL, not an `App` handler. Card assets and page assets
+may live under unrelated filesystem roots, so `App::setup()` creates two
+independent static mounts:
+
+1. Mount `Config::card_storage_root / "published"` at the base-aware
+   `/static-cards` prefix.
+2. Mount `Config::static_root` at the broader base-aware `/static` prefix for
+   CSS, JavaScript, the placeholder, and copied `foil` resources.
+
+The prefixes do not overlap, so mount behavior does not depend on registration
+order. The private `.staging` and `.trash` directories are not reachable
+through either mount. Neither configured filesystem root is required to
+contain the other.
+
+All card filenames and directory names are generated by `AssetStore` from a
+validated identity, logical role, and validated extension. Browser filenames
+are never published. cpp-httplib rejects traversal and verifies that a
+resolved path remains beneath its mounted root, so a second application route
+does not add useful protection.
+
+Both mounts use the correct MIME mappings and cacheable response headers.
+Generated card-asset URLs include the card revision as the encoded `v` query
+parameter, so an edit produces a new cache key. A missing asset naturally
+returns the static server's 404 response; the card page separately checks its
+expected files and renders the documented missing-asset warning.
+
+A reverse proxy deployment must mount the same two roots at `/static-cards`
+and `/static`, prevent symlink escape, and preserve query strings and MIME
+types.
+
+## Card service flows
+
+### Create card
+
+The complete creation flow is:
+
+1. `MultipartReader` creates a unique request directory beneath
+   `<card-root>/.staging`.
+2. It streams binary fields there and accumulates text fields.
+3. The route validates common field syntax and builds `CreateCardInput`.
+4. `MarkdownRenderer` parses and validates every supplied description.
+5. `GameRegistry` validates the selected game and game-specific metadata.
+6. `SeriesService` verifies every series exists and belongs to that game.
+7. `ImageProcessor` validates and normalizes the front and optional foil. For
+   a foil card, it also validates the required uploaded thumbnail. For a plain
+   card, it rejects an uploaded thumbnail and generates `thumb.avif` from the
+   normalized front artwork.
+8. `CardService` calls `DataSourceInterface::beginTransaction()`, then uses the
+   returned transaction to allocate either a per-game or loose random number.
+9. It derives the public ID and final card directory.
+10. It inserts the common card row, game-extension row, and membership rows.
+11. It renames canonical files within staging and atomically renames the
+    staging directory to the final card directory. The final directory must
+    not already exist.
+12. It calls the transaction's `commit()`.
+13. It releases staging cleanup ownership.
+14. The handler returns a 303 redirect.
+
+If a handled error occurs before publication, rollback and delete staging. If
+publication succeeds but commit fails, delete the new final directory and
+roll back. If that compensating deletion fails, log the orphan path; startup
+cleanup removes directories that have no matching database card.
+
+### Metadata-only edit
+
+1. Read the card and compare `expected_revision`.
+2. Validate descriptions, series, and game metadata.
+3. Call `beginTransaction()`.
+4. Re-read the revision inside the transaction.
+5. Update common fields, extension data, and membership rows.
+6. Increment revision.
+7. Commit through the transaction interface and redirect.
+
+No asset directory is touched and no thumbnail is accepted.
+
+### Rendering-input edit
+
+1. Perform all metadata validation from the metadata-only flow.
+2. Create a staging directory.
+3. Copy unchanged assets from the current card directory into staging.
+   Files are copied rather than hard-linked so replacing a staged name cannot
+   mutate a published inode accidentally.
+4. Process replacement front or foil uploads.
+5. Remove foil from staging when requested.
+6. If the resulting card has foil, process the required uploaded thumbnail.
+   Otherwise reject an uploaded thumbnail and generate `thumb.avif` from the
+   resulting staged front artwork.
+7. Call `beginTransaction()` and re-check the revision through that object.
+8. Update database rows and increment revision.
+9. Rename the existing card directory to a unique backup path beneath the
+   private staging root. Its name contains the public ID, previous revision,
+   and a random suffix, for example `backup-pkm-2-r4-<random>`.
+10. Rename the new staging directory to the canonical card directory.
+11. Commit through the transaction interface.
+12. Delete the backup directory and redirect.
+
+On a handled failure after step 9, remove any newly published directory,
+restore the backup to the canonical path, and roll back. Failure to restore is
+logged at critical level. The card view still renders from SQLite and reports
+missing assets.
+
+### Delete card
+
+1. Resolve and load the card.
+2. Call `beginTransaction()`.
+3. Re-check that the card exists.
+4. Rename its directory into a unique path beneath `<card-root>/.trash`. Its
+   name contains the public ID, current revision, and a random suffix. A
+   missing directory is logged but does not prevent database deletion.
+5. Delete the common card row. Foreign keys remove extension and membership
+   rows.
+6. Commit.
+7. Recursively delete the trash path.
+
+If commit fails, restore the trash path and roll back. Never run a recursive
+delete against a path derived directly from request text. `AssetStore` first
+checks that the resolved target is a direct child of the configured private
+trash directory.
+
+### Startup cleanup
+
+After opening and migrating SQLite but before listening:
+
+1. Delete unfinished `new-<random>` staging directories. They have never
+   replaced a committed directory.
+2. For each `backup-<id>-r<revision>-<random>` directory, load the database
+   card. If the database still has the old revision, remove a different
+   canonical directory if present and restore the backup. If the database has
+   a later revision, the edit committed and the backup is deleted.
+3. For each `.trash/<id>-r<revision>-<random>` directory, load the database
+   card. If the card still exists and its canonical directory is missing,
+   restore the trash directory. If the card no longer exists, deletion
+   committed and the trash directory is deleted.
+4. Enumerate children of `<card-root>/published` matching canonical public-ID
+   syntax.
+5. Query SQLite for each derived ID.
+6. Delete a canonical directory with no matching card as a create orphan.
+7. Do not synthesize assets for a database card that has neither a canonical
+   directory nor a recoverable backup. Its view page reports the inconsistency.
+
+Recovery names are parsed with strict application functions, not string slices
+inside the cleanup loop. An ambiguous or malformed private entry is logged and
+left untouched for operator inspection. This recovery is limited to completing
+or rolling back the directory transitions defined above; it is not a backup or
+disaster-recovery facility.
+
+## Series service flows
+
+### Create
+
+1. Require a registered non-null game.
+2. Trim the name and require it to be nonempty.
+3. Validate and pre-render the Markdown description.
+4. Call `beginTransaction()`, insert the series through the returned object,
+   and commit.
+5. Convert the unique constraint into a 409 duplicate-series error.
+
+Series uniqueness uses SQLite binary text comparison. Therefore names that
+differ by case are distinct. This choice avoids SQLite's ASCII-only `NOCASE`
+behavior pretending to provide Unicode case folding.
+
+### Edit
+
+The game remains fixed. Call `beginTransaction()`, update the name and
+description through the returned object, and commit. Duplicate names return
+409. Existing card membership remains unchanged.
+
+### Delete
+
+Call `beginTransaction()`, delete by internal integer ID through the returned
+object, and commit. `ON DELETE CASCADE` removes membership rows. Return 404 if
+no row changed.
+
+## Concurrency
+
+cpp-httplib may run handlers concurrently. The prototype uses these rules:
+
+- `GameRegistry`, `Config`, and parsed templates are immutable after startup.
+- A `CardPreview` exists only in one browser page and has no server state.
+- `DataSourceSQLite` serializes complete operations with one mutex.
+- Image decoding and staging occur before beginning a data transaction.
+- The revision comparison occurs again after beginning a mutation transaction.
+- Asset publication occurs while the database mutation lock is held.
+- Static file serving does not take the data-source mutex. Asset publication
+  uses complete-directory renames, so a request never sees a partially
+  populated staging path. During an edit's old-to-backup and new-to-canonical
+  rename gap, an asset request may briefly receive 404.
+
+Holding the writer lock across directory renames is acceptable for the
+prototype. It avoids a complex job system and the PRD defines no concurrency
+target.
+
+## Error and logging model
+
+### Internal errors
+
+Domain and infrastructure operations return `mw::E<T>`. Define application
+error payloads containing:
+
+- an error category;
+- a safe user-facing message;
+- optional field name;
+- HTTP status mapping; and
+- an internal cause string for logs.
+
+Expected input errors are not exceptions. Unexpected `inja::InjaError` and
+`Magick::Exception` values are caught at their adapter boundaries and
+converted to an internal `mw::Error`.
+
+`App::urlFor()`, `getPath()`, and `getMountPath()` throw
+`std::invalid_argument` only for an unknown route, the wrong route kind, or
+wrong argument arity. Those inputs come from application code and templates,
+not request routing. The `App` constructor validates and precomputes every
+registered handler pattern and mount prefix before startup. `HtmlRenderer`
+catches a later template callback error, logs the route name, and returns an
+internal rendering error.
+
+### Request logging
+
+For each request, generate a random correlation ID and log:
+
+- correlation ID;
+- method and normalized route;
+- response status;
+- duration;
+- resulting card or series internal ID when applicable; and
+- internal error cause when a request fails.
+
+Do not log uploaded bytes, complete Markdown descriptions, remote source URLs,
+or absolute card-storage paths in normal informational logs.
+
+### Startup logging
+
+Log configuration paths, listener kind, schema version, registered game short
+names, migration steps, and cleanup counts. Do not log secrets if later
+configuration gains any.
+
+## Testing strategy
+
+### C++ unit tests
+
+Use GoogleTest and temporary directories created per test. Tests must cover:
+
+#### Public IDs
+
+- base-36 `std::to_chars` boundaries `0`, `35`, `36`, and `UINT32_MAX`;
+- `std::to_chars` and `std::from_chars` round trips;
+- game ID formatting;
+- malformed, overflowed, uppercase, and noncanonical IDs;
+- collision retry with a deterministically seeded `NonSecretRandom`; and
+- natural sorting including `pkm-2`, `pkm-10`, and mixed loose IDs.
+
+#### Non-secret randomness
+
+- equal sequences from equal explicit seeds;
+- values within the complete `uint32_t` range;
+- lowercase hexadecimal output with exactly twice the requested byte count;
+- distinct sequential suffixes under a fixed test seed; and
+- safe concurrent access under ThreadSanitizer when that test mode is enabled.
+
+#### Configuration
+
+- TCP and Unix-socket examples;
+- relative path resolution against the config directory;
+- unknown and missing keys;
+- invalid base schemes and ports;
+- quality and thumbnail-size boundaries; and
+- static/card-root overlap rejection.
+
+#### URLs and routes
+
+- absolute and request-path output at root and nested base paths;
+- percent-encoding of every dynamic segment;
+- prevention of a dynamic slash becoming a route separator;
+- rejection of empty, dot, dot-dot, and invalid placeholder segments;
+- every named route and its required argument count;
+- equality between `urlFor()` and `getPath()` dynamic-route structure;
+- `getMountPath()` output and rejection of dynamic route names;
+- one relative-path argument for each static mount;
+- acceptance of valid nested relative paths and rejection of absolute, empty,
+  dot, dot-dot, and empty-component paths; and
+- rejection of unknown names, nonstring template arguments, and wrong arity.
+
+#### Data source and SQLite
+
+- schema creation and `user_version`;
+- ordered migration dispatch and future-version rejection;
+- foreign-key enforcement;
+- per-game sequence independence;
+- no reuse after deletion;
+- loose-number uniqueness;
+- duplicate series behavior;
+- same-game membership trigger;
+- cascade behavior;
+- transaction commit and destructor rollback;
+- data-source mutex ownership for a transaction's full lifetime; and
+- migration rollback without advancing `user_version`.
+
+#### Markdown
+
+- normal paragraphs and standard formatting;
+- HTTP and HTTPS links and images;
+- rejection of `javascript:`, `data:`, relative, and computed macro URLs;
+- literal `<script>` escaping;
+- quotes and ampersands in URLs;
+- raw HTML inside a user macro body;
+- Inja escaping of ordinary values and safe marker substitution of
+  `RenderedHtml` values;
+- rejection of missing, duplicate, colliding, or unreplaced raw-HTML markers.
+
+#### Images
+
+- startup acceptance of the required ImageMagick coder capabilities;
+- startup rejection when a required coder capability is unavailable through
+  an injected capability-check adapter;
+- valid JPEG, PNG, WebP, and AVIF artwork;
+- valid PNG, WebP, and AVIF foil controls;
+- valid opaque JPEG, WebP, and AVIF thumbnails;
+- PNG-to-AVIF conversion and alpha preservation;
+- JPEG foil rejection;
+- acceptance of thumbnails without alpha;
+- plain-thumbnail AVIF generation from each accepted artwork format;
+- exact 183-by-256 plain-thumbnail dimensions with the default setting;
+- exact 100-by-140 dimensions with a configured long side of 140;
+- stretching non-5:7 artwork to the target 5:7 frame;
+- preservation of artwork alpha in a generated plain thumbnail;
+- wrong extension and MIME labels with valid bytes;
+- truncated and malformed inputs;
+- 2048-pixel boundary and 2049-pixel rejection;
+- APNG, animated WebP, and AVIF sequence rejection; and
+- failed conversion and plain-thumbnail generation cleanup.
+
+Required-format tests use the installed Magick++ implementation. A missing
+required delegate is a failed build or test prerequisite, not a skipped test.
+
+#### Asset store and services
+
+- relative-path mapping for all three `CardAssetType` values;
+- successful `nullopt` for an absent foil control;
+- rejection of an invalid persisted identity or extension;
+- successful creation publication;
+- plain creation without an uploaded thumbnail;
+- rejection of a thumbnail upload for a plain card;
+- foil creation requiring an uploaded thumbnail;
+- cleanup after every injected failure point;
+- metadata-only edits leaving asset bytes unchanged;
+- rendering edits replacing the complete directory and applying the
+  resulting-card thumbnail rule;
+- backup restoration after commit failure;
+- stale revision conflicts;
+- missing artwork card view behavior;
+- delete and rollback restoration; and
+- startup orphan cleanup.
+
+### HTTP integration tests
+
+Start `App` on a temporary local port with `DataSourceSQLite`, a temporary
+database, and temporary roots. Use cpp-httplib's client to test:
+
+- every GET route and content type;
+- base-path mounting;
+- create/edit/delete redirects;
+- multipart creation;
+- conditional thumbnail-field requirements for plain and foil mutations;
+- canonical public-ID redirects;
+- sort direction;
+- error status mapping;
+- both independent static mount prefixes and traversal rejection; and
+- HTML escaping of card and series names.
+
+Integration fixtures register `TestGame`. Tests should inspect database rows
+and published files after each mutation, not only response codes.
+Focused handler tests inject `DataSourceMock` and
+`DataSourceTransactionMock`; they do not open SQLite.
+
+### JavaScript unit tests
+
+Keep pure algorithms outside DOM event handlers and test them with Node's
+built-in test runner:
+
+- dimension scaling;
+- alpha-bound scanning;
+- WebGL row flipping;
+- source-mode state transitions;
+- foil-thumbnail-required decisions; and
+- HTTP/HTTPS remote URL validation.
+
+Reuse and retain the applicable mathematical, shader, geometry, and asset
+tests copied from `foil`.
+
+### Browser acceptance test
+
+Before declaring the prototype complete, manually verify in at least one
+browser with WebGL 2:
+
+1. Create a loose nonfoil card from a local image and verify its backend-made
+   thumbnail is 5:7.
+2. Create a game foil card using a CORS-enabled remote artwork URL and local
+   foil control.
+3. Confirm both thumbnails appear without live WebGL contexts in the index.
+4. Confirm the foil thumbnail uses the initial camera and light.
+5. Edit metadata and verify the thumbnail URL revision changes but the file is
+   not regenerated.
+6. Replace artwork and verify a new thumbnail.
+7. Remove foil and verify plain rendering and a plain thumbnail.
+8. Assign and remove multiple series.
+9. Delete a series without deleting cards.
+10. Delete a card and verify its directory is gone.
+11. Submit unsafe Markdown URLs and oversized images and verify rejection.
+12. Stop and restart the server and verify persisted cards still render.
+
+## Implementation sequence
+
+Implement in vertical, testable stages:
+
+1. Create CMake targets, dependency pins, style flags, and a trivial test.
+2. Implement `Config`, `UrlBuilder`, `NonSecretRandom`, and startup error
+   reporting.
+3. Implement `DataSourceInterface`, `DataSourceSQLite`, transaction RAII,
+   migration version 1, mocks, and data-source tests.
+4. Implement public IDs, game registry, test game, sequences, and series.
+5. Implement `MarkdownRenderer` and Inja page rendering.
+6. Implement `App`, its named routes and static mounts, and the read-only card
+   index and card view using fixture data-source rows.
+7. Implement `MultipartReader`, `ImageProcessor`, and `AssetStore`.
+8. Implement card creation end to end with plain thumbnails.
+9. Refactor and copy the `foil` renderer into `CardPreview`.
+10. Implement foil thumbnail capture and native form blob attachment.
+11. Implement card editing, revision checks, and staged directory replacement.
+12. Implement card and series deletion.
+13. Add startup orphan cleanup and missing-asset rendering.
+14. Complete HTTP, JavaScript, and manual browser acceptance tests.
+
+Each stage must leave tests passing. Do not copy the entire `foil` demo and
+then attempt to reshape it at the end; isolate the reusable renderer as soon
+as it enters this repository.
+
+## Acceptance criteria
+
+The prototype is complete when:
+
+- a clean checkout configures and builds with documented system prerequisites;
+- startup creates and versions a new database;
+- TCP and Unix-socket listeners both work;
+- all pages are server-rendered and honor a non-root base URL;
+- page and published-card assets work from independent filesystem roots;
+- templates generate application links only through Inja's `url_for`;
+- loose IDs and per-game sequential IDs obey the documented formats;
+- series invariants are enforced in both services and SQLite;
+- every accepted image format is validated and PNG is stored as AVIF;
+- both plain and foil cards generate correctly sized static thumbnails;
+- WebGL card views retain `foil`'s visual behavior without uniform foil;
+- metadata-only and rendering edits follow their distinct asset flows;
+- handled mutation failures do not leave published partial assets;
+- missing committed assets produce a usable card page with a warning;
+- Markdown cannot emit literal user HTML or non-HTTP(S) links;
+- index natural sorting puts numeric components in numeric order;
+- card and series deletion clean up their dependent state;
+- C++ and JavaScript automated tests pass; and
+- the browser acceptance checklist passes.
+
+## Risks and tradeoffs
+
+### Browser-generated foil thumbnails are trusted
+
+A modified client can upload a misleading foil thumbnail. This is accepted by
+the trusted-prototype PRD. The server still validates format, dimensions,
+role-specific alpha rules, and animation so the file cannot bypass the asset
+contract. Plain-card thumbnails do not have this risk because the backend
+derives them from normalized artwork.
+
+### A 32-bit loose namespace is finite
+
+Collision probability grows with the number of loose cards. Database
+uniqueness and retry guarantee correctness until the namespace becomes
+impractically full, but they do not remove retry cost. Moving to a larger
+namespace later would change public IDs and therefore requires a product and
+migration decision.
+
+### Filesystem and SQLite cannot commit together
+
+Staging, revision-tagged backup names, rename, rollback, and startup cleanup
+cover handled failures and the defined rename/commit crash positions, but they
+are not a distributed transaction. The UI's missing-asset behavior is the
+final safety net for filesystem failures that prevent recovery. A future
+production version could add a durable operation journal or content-addressed
+immutable assets.
+
+### Static mounts do not consult SQLite
+
+Direct static serving keeps card delivery simple but cannot participate in the
+data-source mutex. An edit may produce a brief asset 404 between its two
+directory renames, and a published create orphan may remain addressable until
+compensating cleanup or the next startup cleanup. No partial staging directory
+is mounted. These limitations are acceptable for the trusted prototype; a
+future version can use immutable revision directories or a guarded asset
+handler if it needs stronger read consistency.
+
+### Compiled game schemas increase migration responsibility
+
+Per-game extension tables provide strong typing and custom rules, but changing
+a shipped game requires code plus a schema migration. This is intentional and
+matches the PRD's stable-game assumption.
+
+### System ImageMagick versions vary
+
+Using the operator's Magick++ avoids building several codec stacks but makes
+image behavior and security fixes depend on the installed ImageMagick package.
+The configure-time version check, startup coder checks, real-format tests, and
+explicit coders make missing or incompatible capabilities fail visibly. The
+operator remains responsible for applying ImageMagick and delegate security
+updates.
+
+### MacroDown is not an HTML sanitizer
+
+The AST validation and escaping pass is part of the security boundary. Any
+future MacroDown node or macro capable of emitting HTML must receive a focused
+test before MacroDown is upgraded. If arbitrary computed URLs or richer raw
+content become necessary, adopt a maintained HTML sanitizer rather than
+weakening the escape pass.
+
+### Serial database mutations limit throughput
+
+One mutex simplifies transaction correctness and matches the prototype's lack
+of scale requirements. `DataSourceInterface` permits a later move to
+per-request connections or a writer queue without changing services or HTTP
+contracts.
+
+## External references
+
+- [CMake
+  `FetchContent`](https://cmake.org/cmake/help/latest/module/FetchContent.html)
+- [cpp-httplib](https://github.com/yhirose/cpp-httplib)
+- [libmw](https://github.com/MetroWind/libmw)
+- [MacroDown](https://git.xeno.darksair.org/macrodown.git)
+- [SQLite transactions](https://www.sqlite.org/lang_transaction.html)
+- [SQLite foreign keys](https://www.sqlite.org/foreignkeys.html)
+- [SQLite WAL](https://www.sqlite.org/wal.html)
+- [SQLite `RETURNING`](https://www.sqlite.org/lang_returning.html)
+- [TOML specification](https://toml.io/en/)
+- [C++ random-number library](https://en.cppreference.com/w/cpp/numeric/random)
+- [C++ `to_chars`](https://en.cppreference.com/w/cpp/utility/to_chars)
+- [C++ `from_chars`](https://en.cppreference.com/w/cpp/utility/from_chars)
+- [Inja](https://github.com/pantor/inja)
+- CMake `FindImageMagick`:
+  <https://cmake.org/cmake/help/latest/module/FindImageMagick.html>
+- [Magick++](https://imagemagick.org/Magick%2B%2B/)
+- [ImageMagick image formats](https://imagemagick.org/formats/)
+- [Magick++ `CoderInfo`](https://imagemagick.org/Magick%2B%2B/CoderInfo.html)
+- [WebGL 2 specification](https://registry.khronos.org/webgl/specs/latest/2.0/)
+- [HTML canvas and cross-origin
+  images](https://html.spec.whatwg.org/multipage/images.html)
+- [HTTP semantics, RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html)
diff --git a/prd.md b/prd.md
new file mode 100644
index 0000000..03fa7c5
--- /dev/null
+++ b/prd.md
@@ -0,0 +1,261 @@
+# Card Collection
+
+Official name TBD. Card Collection is a virtual card creation and collection
+platform.
+
+## Milestones
+
+- Prototype: cards are stored on the server. Visitors can create, modify,
+  view, and organize cards through the web UI.
+- MVP: users can register, have permission levels, and collect cards.
+
+## Prototype assumptions
+
+- The prototype runs in a trusted environment.
+- The prototype has no users or authentication. Every visitor is treated as
+  an admin and may manage cards and series.
+- No access tokens, upload rate limits, storage limits, backup facilities, or
+  recovery facilities are required for the prototype.
+- Any browser that supports the complete required WebGL feature set is
+  supported. Browser-specific fallbacks are out of scope.
+- Standard semantic controls and keyboard operation should be used. No
+  additional accessibility requirements are in scope yet.
+- Normal application events and errors are logged with spdlog.
+
+## Technology
+
+- The backend is an HTTP application server written in C++23 using libmw.
+- Structured data is stored in SQLite.
+- The database schema is versioned with `PRAGMA user_version`.
+- Every schema version change provides and automatically runs a migration
+  from the preceding version, such as `migrateSchema1To2()`.
+- Descriptions are Markdown rendered on the server with MacroDown. The remote
+  repository is <https://git.xeno.darksair.org/macrodown.git>, and the local
+  development repository is `~/programs/macrodown`.
+- The frontend is based on a fixed snapshot of `~/programs/foil`. Necessary
+  files are copied into this project rather than shared dynamically.
+- External C++ dependencies should be pulled with `FetchContent`, including
+  ones with a local repository, which should still be pulled from the remote
+  repository. Basic system libraries such as libc, OpenSSL, and curl are
+  exceptions. Magick++ is also an explicit exception and is provided by the
+  operator's ImageMagick installation.
+- Image validation and PNG-to-AVIF conversion use Magick++ from the local
+  system. The operator must install ImageMagick with Magick++ and JPEG, PNG,
+  WebP, and AVIF support.
+- HTML is rendered on the server with Inja templates.
+
+## Games and series
+
+A game is a top-level grouping representing a distinct set of cards. A card
+may belong to at most one game. A card without a game is a loose card.
+
+A game has:
+
+- a display name;
+- a globally unique, immutable short name containing only `[a-z0-9]`;
+- a Markdown description; and
+- hard-coded definitions and validation for its card-specific metadata.
+
+Game definitions, including their database extensions and metadata rules,
+are compiled into the backend. Existing games and metadata fields should not
+change. If they must change, the change requires a database schema migration.
+
+A series is a dynamic, second-level grouping belonging to exactly one game.
+A card may belong to multiple series, but only series belonging to the card's
+game. A loose card cannot belong to a series. Series names must be unique
+within their game.
+
+A series has:
+
+- a display name;
+- a Markdown description; and
+- an internal integer database ID that is not part of its public identity.
+
+Admins can create, modify, and delete series through the web UI. Every
+visitor has this permission in the prototype.
+
+## Cards and identifiers
+
+Every card has:
+
+- an internal SQLite primary key;
+- a nullable game reference stored in SQLite;
+- a card number stored in SQLite as an integer;
+- an immutable public unique ID derived from its game and card number;
+- a name;
+- an optional short Markdown description;
+- an optional long Markdown description;
+- a nonnegative integer rarity, defaulting to zero;
+- front artwork;
+- an optional foil-control texture; and
+- membership in zero or more valid series.
+
+The formatted public ID is derived rather than stored redundantly.
+
+The common card record stores a nullable game and an integer card number. For
+a card in a game, the public ID is `${shortname}-${number}`. Numbers are
+allocated automatically and sequentially within each game. Deleted numbers
+are never reused. A persistent per-game sequence is incremented atomically so
+concurrent creation cannot allocate the same number.
+
+For a loose card, the stored game is null and the card number is a random
+32-bit integer. Its public ID is the base36 encoding of that integer. If the
+value collides with an existing loose-card ID, generation is retried.
+
+The lowercase representation of a public ID is canonical. IDs are displayed
+in uppercase. A card cannot move to another game after creation because that
+would change its public ID.
+
+## Card data model
+
+Common card fields are stored in a common `cards` table. Each game may define
+an extension table for its game-specific card fields. Series membership uses
+the common card identity.
+
+A game's additional metadata may use any value representable in SQLite. The
+game's compiled C++ and SQL implementation defines field labels, validation,
+requiredness, defaults, ordering, storage, and presentation. Changes to an
+existing game's schema require the normal versioned migration process.
+
+## Image inputs and storage formats
+
+Artwork accepts JPEG, PNG, WebP, and AVIF. Foil-control textures accept PNG,
+WebP, and AVIF because they require an alpha channel.
+
+PNG inputs are converted to AVIF using a quality setting from the
+configuration file. JPEG, WebP, and AVIF inputs retain their source formats.
+When an input is converted, the original is not retained. Images are assumed
+to be nonanimated sRGB images.
+
+The long side of an image must not exceed 2048 pixels. Oversized images are
+rejected by both the browser and backend rather than resized. Artwork and
+foil-control images do not need matching sizes or aspect ratios. The rendered
+card is 5:7 width to height; WebGL stretches other input aspect ratios to fit.
+
+The creation UI accepts local files and remote image URLs. For a remote URL,
+the browser downloads the image and uploads it through the same path as a
+local file. The remote server must permit the download through CORS. The same
+format and size validation applies to both sources.
+
+A card without a foil-control texture has no foil effect. The uniform foil
+controls from `foil` are not retained.
+
+## Card asset storage
+
+Each card has a directory beneath a card-storage root configured by the
+operator. The directory name is the card's derived public ID in lowercase.
+It contains:
+
+- `front-art.ext` for the artwork;
+- `foil.ext` for an optional foil-control texture; and
+- `thumb.ext` for the thumbnail.
+
+Here, `ext` is the actual stored format's conventional extension.
+
+Card assets are available at `/static-cards/<public-id>/<asset>`. Page assets
+use the separate `/static/<relative path>` prefix. The backend may serve them
+directly, or a reverse proxy may serve them from disk.
+
+Creation and modification use temporary files so a failed operation leaves
+the previous committed state unchanged. Temporary and orphaned files from a
+failed operation are deleted. Database and filesystem state cannot be made a
+single transaction, so request handling must tolerate missing or mismatched
+assets. For example, a card with missing artwork still has a card view, with
+an error message explaining the missing asset.
+
+Deleting a card deletes its card directory.
+
+## Markdown
+
+All descriptions are rendered from Markdown by MacroDown on each request.
+Rendered HTML is not stored. All MacroDown features and macros are enabled,
+but raw HTML is disabled.
+
+Before rendering, the backend examines MacroDown's syntax tree. Every link
+and image URL must use HTTP or HTTPS; Markdown containing another URL scheme
+is rejected. No additional sanitization is required for the prototype.
+
+## Card creation and viewing
+
+The creation and editing UI retains `foil`'s live WebGL card preview,
+interactive tilt, local file input, and remote URL input. It does not retain
+the uniform foil sliders, example reset, or shareable fragment links.
+
+Saved cards have permanent application URLs. The card view uses the same
+interactive rendering as the creation view, but replaces editing controls
+with read-only card information.
+
+All nonoptional fields are required. Rarity is prefilled with zero. When a
+backend form submission fails, the backend returns an error page with the
+appropriate HTTP status code.
+
+## Thumbnail generation
+
+The card index uses static thumbnails rather than one WebGL context per card.
+For a card without a foil-control texture, its thumbnail is a reduced version
+of its front artwork. The backend stretches the artwork to a 5:7 frame and
+generates this thumbnail with Magick++. Thumbnails do not generally require
+an alpha channel; only the rendered foil thumbnail uses transparency for edge
+cropping.
+
+For a foil card, the browser renders the default initial state of the WebGL
+scene into a transparent offscreen surface. It reads the pixels, finds the
+nontransparent bounds, crops to those bounds, and uploads the result with the
+card. The server trusts this uploaded foil thumbnail. The thumbnail's long
+side is configurable and defaults to 256 pixels. For a nonfoil thumbnail, the
+long side is the height and the backend rounds `height * 5 / 7` to the nearest
+integer for the width. The default output is therefore 183 by 256 pixels.
+
+Thumbnail generation occurs when a card is created or when an edit changes a
+rendering input. A metadata-only edit does not regenerate it. The browser
+generates and uploads a thumbnail only when the resulting card has foil; the
+backend generates it from the resulting artwork otherwise. If the applicable
+thumbnail generation or upload fails, the entire creation or edit fails.
+
+The source tree contains a default placeholder thumbnail. An operator may
+replace it with a custom image for inconsistent or missing stored assets.
+
+## Card index
+
+The index lays cards out in a grid. It supports sorting by derived public ID
+in ascending or descending natural order. Numeric components are compared
+numerically, so `PKM-2` sorts before `PKM-10`. Ascending order is the default.
+
+Filtering, searching, and pagination are not required for the prototype. An
+index with no cards is displayed as an empty grid. Normal browser behavior is
+sufficient while a request is in progress.
+
+## Configuration
+
+The server uses a TOML configuration file with these settings:
+
+- base URL under which the website is hosted;
+- listening address, which may be a Unix domain socket path;
+- listening port, used only when the address is not a Unix domain socket;
+- static-file root path;
+- SQLite database path;
+- card-storage root path;
+- PNG-to-AVIF quality; and
+- thumbnail long-side size, defaulting to 256 pixels.
+
+libmw provides handling for the listening address and port.
+
+## MVP requirements
+
+The MVP adds user registration and a permission hierarchy. Creators have all
+player permissions, and admins have all creator permissions.
+
+- Unregistered visitors may view cards.
+- Players may view and collect cards.
+- Creators may create cards and modify cards they created. They cannot delete
+  cards.
+- Admins may manage all cards, series, and user accounts.
+- If no admin account exists, visitors are prompted to create the first admin
+  account. This bootstrap flow has no additional access or concurrency
+  protection in the MVP; an operator may reset the database to recover from
+  a failed bootstrap.
+- A player may own multiple copies of the same card.
+
+Registration approval, email verification, password and session policy,
+card acquisition and loss, trading and transfers, collection views, and
+collection privacy remain to be designed after the prototype.