BareGit

Add a runnable card index

- Implement App routing, URL construction, public ID formatting, and natural
  sorting.
- Render a styled server-side index with static mounts and placeholder art.
- Add a read-only fake data source, development startup, and focused tests.
Author: MetroWind <chris.corsair@gmail.com>
Date: Sat Aug 22 13:56:14 2026 -0700
Commit: 877bd4ee2bb084193b50626318da58f66ed71a3e

Changes

diff --git a/CMakeLists.txt b/CMakeLists.txt
index 65c5a50..ae6da9a 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -16,11 +16,20 @@ include(cmake/dependencies.cmake)
 
 add_executable(
     card_collection
+    src/app.cpp
     src/data.cpp
+    src/data_fake.cpp
     src/main.cpp
+    src/public_id.cpp
+    src/url_builder.cpp
 )
 
 target_compile_features(card_collection PRIVATE cxx_std_23)
+target_compile_definitions(
+    card_collection
+    PRIVATE
+        CARD_COLLECTION_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}"
+)
 set_target_properties(
     card_collection
     PROPERTIES
@@ -76,4 +85,57 @@ if(CARD_COLLECTION_BUILD_TESTS)
 
     include(GoogleTest)
     gtest_discover_tests(data_mock_test)
+
+    add_executable(
+        app_test
+        src/app.cpp
+        src/data_fake.cpp
+        src/public_id.cpp
+        src/url_builder.cpp
+        tests/app_test.cpp
+    )
+    target_compile_features(app_test PRIVATE cxx_std_23)
+    set_target_properties(app_test PROPERTIES CXX_EXTENSIONS OFF)
+    target_include_directories(
+        app_test
+        PRIVATE
+            ${libmw_SOURCE_DIR}/includes
+            src
+    )
+    target_link_libraries(
+        app_test
+        PRIVATE
+            GTest::gtest_main
+            mw::http-server
+            mw::url
+            pantor::inja
+            spdlog::spdlog
+    )
+    target_compile_definitions(
+        app_test
+        PRIVATE
+            CARD_COLLECTION_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}"
+    )
+    gtest_discover_tests(app_test)
+
+    add_executable(
+        data_fake_test
+        src/data_fake.cpp
+        tests/data_fake_test.cpp
+    )
+    target_compile_features(data_fake_test PRIVATE cxx_std_23)
+    set_target_properties(data_fake_test PROPERTIES CXX_EXTENSIONS OFF)
+    target_include_directories(
+        data_fake_test
+        PRIVATE
+            ${libmw_SOURCE_DIR}/includes
+            src
+    )
+    target_link_libraries(
+        data_fake_test
+        PRIVATE
+            GTest::gtest_main
+            mw::mw
+    )
+    gtest_discover_tests(data_fake_test)
 endif()
diff --git a/src/app.cpp b/src/app.cpp
new file mode 100644
index 0000000..bb5cb91
--- /dev/null
+++ b/src/app.cpp
@@ -0,0 +1,391 @@
+#include "app.h"
+
+#include <algorithm>
+#include <cctype>
+#include <cstddef>
+#include <filesystem>
+#include <functional>
+#include <memory>
+#include <stdexcept>
+#include <string>
+#include <system_error>
+#include <unordered_map>
+#include <utility>
+#include <vector>
+
+#include <spdlog/spdlog.h>
+
+#include "public_id.h"
+
+namespace
+{
+
+enum class RouteKind
+{
+    DYNAMIC,
+    STATIC_MOUNT
+};
+
+struct RouteDefinition
+{
+    RouteKind kind;
+    std::vector<RouteSegment> segments;
+};
+
+struct IndexCard
+{
+    const Card* card;
+    std::string public_id;
+};
+
+RouteSegment literal(std::string value)
+{
+    return {RouteSegmentKind::LITERAL, std::move(value)};
+}
+
+RouteSegment placeholder(std::string value)
+{
+    return {RouteSegmentKind::PLACEHOLDER, std::move(value)};
+}
+
+const std::unordered_map<std::string, RouteDefinition> ROUTES = {
+    {"card-index", {RouteKind::DYNAMIC, {}}},
+    {"card-new", {RouteKind::DYNAMIC, {literal("cards"), literal("new")}}},
+    {"cards", {RouteKind::DYNAMIC, {literal("cards")}}},
+    {"card", {RouteKind::DYNAMIC,
+              {literal("cards"), placeholder("id")}}},
+    {"card-edit", {RouteKind::DYNAMIC,
+                   {literal("cards"), placeholder("id"), literal("edit")}}},
+    {"card-delete",
+     {RouteKind::DYNAMIC,
+      {literal("cards"), placeholder("id"), literal("delete")}}},
+    {"series-index", {RouteKind::DYNAMIC, {literal("series")}}},
+    {"series-new",
+     {RouteKind::DYNAMIC, {literal("series"), literal("new")}}},
+    {"series", {RouteKind::DYNAMIC, {literal("series")}}},
+    {"series-edit",
+     {RouteKind::DYNAMIC,
+      {literal("series"), placeholder("integer"), literal("edit")}}},
+    {"series-item",
+     {RouteKind::DYNAMIC,
+      {literal("series"), placeholder("integer")}}},
+    {"series-delete",
+     {RouteKind::DYNAMIC,
+      {literal("series"), placeholder("integer"), literal("delete")}}},
+    {"card-asset", {RouteKind::STATIC_MOUNT, {literal("static-cards")}}},
+    {"static", {RouteKind::STATIC_MOUNT, {literal("static")}}},
+};
+
+const RouteDefinition& routeDefinition(const std::string& name)
+{
+    const auto route = ROUTES.find(name);
+    if(route == ROUTES.end())
+    {
+        throw std::invalid_argument("Unknown route name: " + name);
+    }
+    return route->second;
+}
+
+std::size_t argumentCount(const RouteDefinition& route)
+{
+    std::size_t count = 0;
+    for(const RouteSegment& segment : route.segments)
+    {
+        if(segment.kind == RouteSegmentKind::PLACEHOLDER)
+        {
+            ++count;
+        }
+    }
+    return count;
+}
+
+std::vector<RouteSegment> resolveRoute(
+    const RouteDefinition& route,
+    const std::vector<std::string>& arguments,
+    RouteSegmentKind argument_kind)
+{
+    if(arguments.size() != argumentCount(route))
+    {
+        throw std::invalid_argument("Incorrect route argument count");
+    }
+
+    std::vector<RouteSegment> result;
+    result.reserve(route.segments.size());
+    std::size_t argument_index = 0;
+    for(const RouteSegment& segment : route.segments)
+    {
+        if(segment.kind == RouteSegmentKind::PLACEHOLDER)
+        {
+            result.push_back(
+                {argument_kind, arguments[argument_index++]});
+        }
+        else
+        {
+            result.push_back(segment);
+        }
+    }
+    return result;
+}
+
+bool indexCardLess(const IndexCard& left, const IndexCard& right)
+{
+    if(naturalPublicIdLess(left.public_id, right.public_id))
+    {
+        return true;
+    }
+    if(naturalPublicIdLess(right.public_id, left.public_id))
+    {
+        return false;
+    }
+    return left.card->id < right.card->id;
+}
+
+void sortIndexCards(std::vector<IndexCard>& cards, bool descending)
+{
+    if(descending)
+    {
+        std::ranges::sort(
+            cards,
+            [](const IndexCard& left, const IndexCard& right)
+            {
+                return indexCardLess(right, left);
+            });
+        return;
+    }
+    std::ranges::sort(cards, indexCardLess);
+}
+
+std::string uppercaseAscii(std::string value)
+{
+    for(char& character : value)
+    {
+        character = static_cast<char>(
+            std::toupper(static_cast<unsigned char>(character)));
+    }
+    return value;
+}
+
+void respondInternalError(App::Response& response)
+{
+    response.status = 500;
+    response.set_content(
+        "<!doctype html><title>Internal server error</title>"
+        "<h1>Internal server error</h1>",
+        "text/html; charset=utf-8");
+}
+
+} // namespace
+
+App::App(
+    const Config& config,
+    std::unique_ptr<DataSourceInterface> data_source)
+        : mw::HTTPServer(config.listen_address),
+          config_(config),
+          data_source_(std::move(data_source)),
+          url_builder_(config.base_url),
+          templates_(config.static_root.parent_path() / "templates")
+{
+    if(!data_source_)
+    {
+        throw std::invalid_argument("App requires a data source");
+    }
+
+    templates_.set_html_autoescape(true);
+    templates_.add_callback(
+        "url_for",
+        [this](inja::Arguments& callback_arguments) -> inja::json
+        {
+            if(callback_arguments.empty() ||
+               !callback_arguments.front()->is_string())
+            {
+                throw std::invalid_argument(
+                    "url_for requires a string route name");
+            }
+
+            std::vector<std::string> arguments;
+            arguments.reserve(callback_arguments.size() - 1);
+            for(std::size_t index = 1;
+                index < callback_arguments.size();
+                ++index)
+            {
+                if(!callback_arguments[index]->is_string())
+                {
+                    throw std::invalid_argument(
+                        "url_for arguments must be strings");
+                }
+                arguments.push_back(
+                    callback_arguments[index]->get<std::string>());
+            }
+            return urlFor(
+                callback_arguments.front()->get<std::string>(),
+                arguments);
+        });
+    card_index_template_ = templates_.parse_template("card_index.html");
+}
+
+std::string App::urlFor(
+    const std::string& name,
+    const std::vector<std::string>& arguments,
+    const QueryParameters& query) const
+{
+    const RouteDefinition& route = routeDefinition(name);
+    if(route.kind == RouteKind::STATIC_MOUNT)
+    {
+        if(arguments.size() != 1)
+        {
+            throw std::invalid_argument(
+                "Incorrect static route argument count");
+        }
+        return url_builder_.absoluteFromRelativePath(
+            route.segments, arguments.front(), query);
+    }
+
+    return url_builder_.absolute(
+        resolveRoute(route, arguments, RouteSegmentKind::DYNAMIC), query);
+}
+
+void App::handleCardIndex(
+    const Request& request,
+    Response& response)
+{
+    auto cards_result = data_source_->getCards();
+    if(!cards_result)
+    {
+        spdlog::error(
+            "Failed to load the card index: {}",
+            cards_result.error().msg());
+        respondInternalError(response);
+        return;
+    }
+
+    std::vector<IndexCard> cards;
+    cards.reserve(cards_result->size());
+    for(const Card& card : *cards_result)
+    {
+        auto public_id = formatPublicId(card.identity);
+        if(!public_id)
+        {
+            spdlog::error(
+                "Failed to format card {} for the index: {}",
+                card.id,
+                public_id.error().msg());
+            respondInternalError(response);
+            return;
+        }
+        cards.push_back({&card, std::move(*public_id)});
+    }
+
+    const bool descending =
+        request.has_param("direction") &&
+        request.get_param_value("direction") == "desc";
+    sortIndexCards(cards, descending);
+
+    inja::json template_cards = inja::json::array();
+    for(const IndexCard& index_card : cards)
+    {
+        const Card& card = *index_card.card;
+        const std::string thumbnail_name =
+            "thumb." + card.thumbnail_extension;
+        const std::filesystem::path thumbnail_path =
+            config_.card_storage_root / "published" /
+            index_card.public_id / thumbnail_name;
+
+        std::string thumbnail_url;
+        std::error_code filesystem_error;
+        const bool thumbnail_exists = std::filesystem::is_regular_file(
+            thumbnail_path, filesystem_error);
+        if(filesystem_error &&
+           filesystem_error != std::errc::no_such_file_or_directory)
+        {
+            spdlog::warn(
+                "Failed to inspect the thumbnail for card {}: {}",
+                card.id,
+                filesystem_error.message());
+        }
+
+        if(thumbnail_exists)
+        {
+            thumbnail_url = urlFor(
+                "card-asset",
+                {index_card.public_id + "/" + thumbnail_name},
+                {{"v", std::to_string(card.revision)}});
+        }
+        else
+        {
+            thumbnail_url = urlFor("static", {"card_placeholder.svg"});
+        }
+
+        template_cards.push_back({
+            {"display_id", uppercaseAscii(index_card.public_id)},
+            {"name", card.name},
+            {"thumbnail_url", std::move(thumbnail_url)},
+            {"url", urlFor("card", {index_card.public_id})},
+        });
+    }
+
+    const inja::json template_data = {
+        {"ascending_url",
+         urlFor("card-index", {}, {{"sort", "id"}, {"direction", "asc"}})},
+        {"cards", std::move(template_cards)},
+        {"descending", descending},
+        {"descending_url",
+         urlFor("card-index", {}, {{"sort", "id"}, {"direction", "desc"}})},
+        {"title", "Card Collection"},
+    };
+
+    try
+    {
+        response.status = 200;
+        response.set_content(
+            templates_.render(card_index_template_, template_data),
+            "text/html; charset=utf-8");
+    }
+    catch(const std::exception& error)
+    {
+        spdlog::error("Failed to render the card index: {}", error.what());
+        respondInternalError(response);
+    }
+}
+
+void App::setup()
+{
+    const std::filesystem::path published_cards =
+        config_.card_storage_root / "published";
+    if(!server.set_mount_point(
+           getMountPath("static"), config_.static_root.string()))
+    {
+        spdlog::error("Failed to mount application static files");
+    }
+    if(!server.set_mount_point(
+           getMountPath("card-asset"), published_cards.string()))
+    {
+        spdlog::error("Failed to mount published card assets");
+    }
+
+    server.Get(
+        getPath("card-index"),
+        std::bind_front(&App::handleCardIndex, this));
+}
+
+std::string App::getPath(
+    const std::string& name,
+    const std::vector<std::string>& argument_names) const
+{
+    const RouteDefinition& route = routeDefinition(name);
+    if(route.kind != RouteKind::DYNAMIC)
+    {
+        throw std::invalid_argument("Static mount has no handler path");
+    }
+    return url_builder_.requestPath(resolveRoute(
+        route, argument_names, RouteSegmentKind::PLACEHOLDER));
+}
+
+std::string App::getMountPath(const std::string& name) const
+{
+    const RouteDefinition& route = routeDefinition(name);
+    if(route.kind != RouteKind::STATIC_MOUNT)
+    {
+        throw std::invalid_argument("Dynamic route has no mount path");
+    }
+    return url_builder_.requestPath(route.segments);
+}
diff --git a/src/app.h b/src/app.h
new file mode 100644
index 0000000..1df9ccb
--- /dev/null
+++ b/src/app.h
@@ -0,0 +1,58 @@
+#pragma once
+
+#include <memory>
+#include <string>
+#include <vector>
+
+#include <inja/inja.hpp>
+#include <mw/http_server.hpp>
+
+#include "config.h"
+#include "data.h"
+#include "url_builder.h"
+
+/// Card Collection HTTP application and named-route owner.
+class App : public mw::HTTPServer
+{
+public:
+    /// HTTP request type supplied by libmw.
+    using Request = mw::HTTPServer::Request;
+
+    /// HTTP response type supplied by libmw.
+    using Response = mw::HTTPServer::Response;
+
+    /// Disable construction without validated configuration.
+    App() = delete;
+
+    /// Construct the application from configuration and a data source.
+    App(
+        const Config& config,
+        std::unique_ptr<DataSourceInterface> data_source);
+
+    /// 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);
+
+private:
+    /// Register implemented handlers and static mounts.
+    void setup() override;
+
+    /// Return the server request path for a named dynamic 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;
+
+    Config config_;
+    std::unique_ptr<DataSourceInterface> data_source_;
+    UrlBuilder url_builder_;
+    inja::Environment templates_;
+    inja::Template card_index_template_;
+};
diff --git a/src/config.h b/src/config.h
new file mode 100644
index 0000000..982d3ce
--- /dev/null
+++ b/src/config.h
@@ -0,0 +1,32 @@
+#pragma once
+
+#include <cstdint>
+#include <filesystem>
+
+#include <mw/http_server.hpp>
+#include <mw/url.hpp>
+
+/// Validated process configuration.
+struct Config
+{
+    /// Normalized absolute HTTP or HTTPS application base URL.
+    mw::URL base_url;
+
+    /// TCP or Unix-domain address on which the server listens.
+    mw::HTTPServer::ListenAddress listen_address;
+
+    /// Root containing application static files.
+    std::filesystem::path static_root;
+
+    /// SQLite database file path.
+    std::filesystem::path database_path;
+
+    /// Private root containing staged and published card assets.
+    std::filesystem::path card_storage_root;
+
+    /// ImageMagick quality used when converting PNG inputs to AVIF.
+    int avif_quality;
+
+    /// Long-side pixel count used for generated thumbnails.
+    std::uint32_t thumbnail_long_side;
+};
diff --git a/src/data_fake.cpp b/src/data_fake.cpp
new file mode 100644
index 0000000..f972541
--- /dev/null
+++ b/src/data_fake.cpp
@@ -0,0 +1,141 @@
+#include "data_fake.h"
+
+#include <algorithm>
+#include <set>
+#include <string_view>
+#include <utility>
+
+namespace
+{
+
+mw::Error readOnlyError(std::string_view operation)
+{
+    return mw::runtimeError(
+        "DataSourceFake is read-only and cannot " +
+        std::string(operation) + ".");
+}
+
+bool identitiesMatch(const CardIdentity& left, const CardIdentity& right)
+{
+    return left.game_short_name == right.game_short_name &&
+           left.card_number == right.card_number;
+}
+
+} // namespace
+
+DataSourceFake::DataSourceFake(
+    std::vector<Card> cards,
+    std::vector<Series> series,
+    std::unordered_map<std::int64_t, std::vector<std::int64_t>> card_series,
+    std::unordered_map<std::int64_t, std::vector<DisplayField>> display_fields)
+        : cards_(std::move(cards)),
+          series_(std::move(series)),
+          card_series_(std::move(card_series)),
+          display_fields_(std::move(display_fields))
+{}
+
+mw::E<std::int64_t> DataSourceFake::getSchemaVersion() const
+{
+    return DB_SCHEMA_VERSION;
+}
+
+mw::E<void> DataSourceFake::migrateSchema0To1(
+    [[maybe_unused]] const GameRegistry& games)
+{
+    return std::unexpected(readOnlyError("migrate the schema"));
+}
+
+mw::E<std::unique_ptr<DataSourceTransactionInterface>>
+DataSourceFake::beginTransaction()
+{
+    return std::unexpected(readOnlyError("begin a transaction"));
+}
+
+mw::E<std::vector<Card>> DataSourceFake::getCards() const
+{
+    return cards_;
+}
+
+mw::E<std::optional<Card>> DataSourceFake::getCard(
+    const CardIdentity& identity) const
+{
+    const auto card = std::ranges::find_if(
+        cards_,
+        [&identity](const Card& candidate)
+        {
+            return identitiesMatch(candidate.identity, identity);
+        });
+    if(card == cards_.end())
+    {
+        return std::nullopt;
+    }
+    return *card;
+}
+
+mw::E<std::vector<DisplayField>> DataSourceFake::getGameDisplayFields(
+    [[maybe_unused]] const GameDefinition& game,
+    std::int64_t card_id) const
+{
+    const auto fields = display_fields_.find(card_id);
+    if(fields == display_fields_.end())
+    {
+        return std::vector<DisplayField>{};
+    }
+    return fields->second;
+}
+
+mw::E<std::vector<Series>> DataSourceFake::getSeries() const
+{
+    return series_;
+}
+
+mw::E<std::optional<Series>> DataSourceFake::getSeries(
+    std::int64_t series_id) const
+{
+    const auto series = std::ranges::find_if(
+        series_,
+        [series_id](const Series& candidate)
+        {
+            return candidate.id == series_id;
+        });
+    if(series == series_.end())
+    {
+        return std::nullopt;
+    }
+    return *series;
+}
+
+mw::E<std::vector<std::int64_t>> DataSourceFake::getCardSeries(
+    std::int64_t card_id) const
+{
+    const auto memberships = card_series_.find(card_id);
+    if(memberships == card_series_.end())
+    {
+        return std::vector<std::int64_t>{};
+    }
+    return memberships->second;
+}
+
+mw::E<std::vector<std::string>>
+DataSourceFake::getPersistedGameNames() const
+{
+    std::set<std::string> names;
+    for(const Card& card : cards_)
+    {
+        if(card.identity.game_short_name)
+        {
+            names.insert(*card.identity.game_short_name);
+        }
+    }
+    for(const Series& series : series_)
+    {
+        names.insert(series.game_short_name);
+    }
+    return std::vector<std::string>(names.begin(), names.end());
+}
+
+mw::E<void> DataSourceFake::setSchemaVersion(
+    [[maybe_unused]] std::int64_t version)
+{
+    return std::unexpected(readOnlyError("set the schema version"));
+}
diff --git a/src/data_fake.h b/src/data_fake.h
new file mode 100644
index 0000000..a050f2f
--- /dev/null
+++ b/src/data_fake.h
@@ -0,0 +1,73 @@
+#pragma once
+
+#include <cstdint>
+#include <unordered_map>
+#include <vector>
+
+#include "data.h"
+
+/// Deterministic read-only data source for development and focused tests.
+class DataSourceFake : public DataSourceInterface
+{
+public:
+    /// Construct an empty fake data source at the latest schema version.
+    DataSourceFake() = default;
+
+    /// Construct a fake data source containing the supplied records.
+    explicit DataSourceFake(
+        std::vector<Card> cards,
+        std::vector<Series> series = {},
+        std::unordered_map<std::int64_t, std::vector<std::int64_t>>
+            card_series = {},
+        std::unordered_map<std::int64_t, std::vector<DisplayField>>
+            display_fields = {});
+
+    /// Return the latest schema version represented by the fake.
+    mw::E<std::int64_t> getSchemaVersion() const override;
+
+    /// Reject migration because the fake always represents the latest schema.
+    mw::E<void>
+    migrateSchema0To1(const GameRegistry& games) override;
+
+    /// Reject mutation transactions because the fake is read-only.
+    mw::E<std::unique_ptr<DataSourceTransactionInterface>>
+    beginTransaction() override;
+
+    /// Return all configured cards in insertion order.
+    mw::E<std::vector<Card>> getCards() const override;
+
+    /// Return the configured card matching an identity.
+    mw::E<std::optional<Card>>
+    getCard(const CardIdentity& identity) const override;
+
+    /// Return configured display fields for a card.
+    mw::E<std::vector<DisplayField>> getGameDisplayFields(
+        const GameDefinition& game,
+        std::int64_t card_id) const override;
+
+    /// Return all configured series in insertion order.
+    mw::E<std::vector<Series>> getSeries() const override;
+
+    /// Return the configured series with an internal ID.
+    mw::E<std::optional<Series>>
+    getSeries(std::int64_t series_id) const override;
+
+    /// Return configured series memberships for a card.
+    mw::E<std::vector<std::int64_t>>
+    getCardSeries(std::int64_t card_id) const override;
+
+    /// Return the sorted unique compiled game names used by configured rows.
+    mw::E<std::vector<std::string>>
+    getPersistedGameNames() const override;
+
+protected:
+    /// Reject schema mutation because the fake is read-only.
+    mw::E<void> setSchemaVersion(std::int64_t version) override;
+
+private:
+    std::vector<Card> cards_;
+    std::vector<Series> series_;
+    std::unordered_map<std::int64_t, std::vector<std::int64_t>> card_series_;
+    std::unordered_map<std::int64_t, std::vector<DisplayField>>
+        display_fields_;
+};
diff --git a/src/main.cpp b/src/main.cpp
index 888fc25..0fc1186 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -1,5 +1,137 @@
-/// Start the Card Collection server.
+#include <chrono>
+#include <csignal>
+#include <cstdint>
+#include <filesystem>
+#include <memory>
+#include <optional>
+#include <string>
+#include <thread>
+#include <utility>
+#include <vector>
+
+#include <spdlog/spdlog.h>
+
+#include "app.h"
+#include "data_fake.h"
+
+namespace
+{
+
+/// Development HTTP port used until configuration loading is implemented.
+inline constexpr int DEVELOPMENT_PORT = 8080;
+
+/// Signal-safe flag requesting shutdown from the main loop.
+volatile std::sig_atomic_t STOP_REQUESTED = 0;
+
+void handleSignal([[maybe_unused]] int signal)
+{
+    STOP_REQUESTED = 1;
+}
+
+Card makeCard(
+    std::int64_t id,
+    std::uint32_t number,
+    std::string name,
+    std::int64_t rarity)
+{
+    return {
+        id,
+        {std::nullopt, number},
+        std::move(name),
+        std::nullopt,
+        std::nullopt,
+        rarity,
+        "avif",
+        std::nullopt,
+        "avif",
+        1,
+    };
+}
+
+std::vector<Card> sampleCards()
+{
+    return {
+        makeCard(1, 2, "Frost Garden", 1),
+        makeCard(2, 10, "Midnight Relay", 3),
+        makeCard(3, 42, "Glass Comet", 2),
+        makeCard(4, 128, "Signal Bloom", 0),
+        makeCard(5, 1024, "Quiet Orbit", 4),
+        makeCard(6, 65535, "Last Light", 5),
+    };
+}
+
+mw::E<Config> makeDevelopmentConfig()
+{
+    const std::string base_url_text =
+        "http://127.0.0.1:" + std::to_string(DEVELOPMENT_PORT) + "/";
+    auto base_url = mw::URL::fromStr(base_url_text);
+    if(!base_url)
+    {
+        return std::unexpected(std::move(base_url.error()));
+    }
+
+    const std::filesystem::path source_root = CARD_COLLECTION_SOURCE_DIR;
+    const std::filesystem::path card_storage_root = source_root / "var/cards";
+    std::error_code filesystem_error;
+    std::filesystem::create_directories(
+        card_storage_root / "published", filesystem_error);
+    if(filesystem_error)
+    {
+        return std::unexpected(mw::runtimeError(
+            "Failed to create the development card directory: " +
+            filesystem_error.message()));
+    }
+
+    return Config{
+        std::move(*base_url),
+        mw::IPSocketInfo{"127.0.0.1", DEVELOPMENT_PORT},
+        source_root / "static",
+        source_root / "var/card_collection.sqlite3",
+        card_storage_root,
+        75,
+        256,
+    };
+}
+
+} // namespace
+
+/// Start the Card Collection development server with deterministic fake data.
 int main()
 {
+    auto config = makeDevelopmentConfig();
+    if(!config)
+    {
+        spdlog::error(
+            "Failed to configure the server: {}",
+            config.error().msg());
+        return 1;
+    }
+
+    App app(
+        *config,
+        std::make_unique<DataSourceFake>(sampleCards()));
+
+    std::signal(SIGINT, handleSignal);
+    std::signal(SIGTERM, handleSignal);
+
+    auto start_result = app.start();
+    if(!start_result)
+    {
+        spdlog::error(
+            "Failed to start the server: {}",
+            start_result.error().msg());
+        return 1;
+    }
+
+    spdlog::info(
+        "Development server listening at http://127.0.0.1:{}/",
+        DEVELOPMENT_PORT);
+    while(STOP_REQUESTED == 0)
+    {
+        std::this_thread::sleep_for(std::chrono::milliseconds(100));
+    }
+
+    app.stop();
+    app.wait();
     return 0;
 }
diff --git a/src/public_id.cpp b/src/public_id.cpp
new file mode 100644
index 0000000..eeaf9df
--- /dev/null
+++ b/src/public_id.cpp
@@ -0,0 +1,134 @@
+#include "public_id.h"
+
+#include <array>
+#include <charconv>
+#include <cctype>
+#include <cstdint>
+#include <limits>
+#include <string>
+#include <system_error>
+
+namespace
+{
+
+mw::E<std::string> formatNumber(std::uint64_t number, int base)
+{
+    std::array<char, std::numeric_limits<std::uint64_t>::digits + 1> buffer;
+    const auto result = std::to_chars(
+        buffer.data(),
+        buffer.data() + buffer.size(),
+        number,
+        base);
+    if(result.ec != std::errc{})
+    {
+        return std::unexpected(
+            mw::runtimeError("Failed to format a public card number"));
+    }
+    return std::string(buffer.data(), result.ptr);
+}
+
+bool isDigit(char character)
+{
+    return std::isdigit(static_cast<unsigned char>(character)) != 0;
+}
+
+std::size_t runEnd(std::string_view value, std::size_t begin, bool digit)
+{
+    std::size_t end = begin;
+    while(end < value.size() && isDigit(value[end]) == digit)
+    {
+        ++end;
+    }
+    return end;
+}
+
+int compareNumericRuns(std::string_view left, std::string_view right)
+{
+    const std::size_t left_nonzero = left.find_first_not_of('0');
+    const std::size_t right_nonzero = right.find_first_not_of('0');
+    const std::string_view left_value = left_nonzero == std::string_view::npos
+        ? left.substr(left.size() - 1)
+        : left.substr(left_nonzero);
+    const std::string_view right_value = right_nonzero == std::string_view::npos
+        ? right.substr(right.size() - 1)
+        : right.substr(right_nonzero);
+
+    if(left_value.size() != right_value.size())
+    {
+        return left_value.size() < right_value.size() ? -1 : 1;
+    }
+    const int value_comparison = left_value.compare(right_value);
+    if(value_comparison != 0)
+    {
+        return value_comparison;
+    }
+    if(left.size() != right.size())
+    {
+        return left.size() < right.size() ? -1 : 1;
+    }
+    return 0;
+}
+
+} // namespace
+
+mw::E<std::string> formatPublicId(const CardIdentity& identity)
+{
+    if(identity.game_short_name)
+    {
+        if(identity.game_short_name->empty() || identity.card_number == 0 ||
+           identity.card_number >
+               static_cast<std::uint64_t>(
+                   std::numeric_limits<std::int64_t>::max()))
+        {
+            return std::unexpected(
+                mw::runtimeError("Invalid game card identity"));
+        }
+        auto number = formatNumber(identity.card_number, 10);
+        if(!number)
+        {
+            return std::unexpected(std::move(number.error()));
+        }
+        return *identity.game_short_name + "-" + *number;
+    }
+
+    if(identity.card_number > std::numeric_limits<std::uint32_t>::max())
+    {
+        return std::unexpected(mw::runtimeError("Invalid loose card identity"));
+    }
+    return formatNumber(identity.card_number, 36);
+}
+
+bool naturalPublicIdLess(std::string_view left, std::string_view right)
+{
+    std::size_t left_index = 0;
+    std::size_t right_index = 0;
+    while(left_index < left.size() && right_index < right.size())
+    {
+        const bool left_digit = isDigit(left[left_index]);
+        const bool right_digit = isDigit(right[right_index]);
+        const std::size_t left_end = runEnd(left, left_index, left_digit);
+        const std::size_t right_end = runEnd(right, right_index, right_digit);
+        const std::string_view left_run =
+            left.substr(left_index, left_end - left_index);
+        const std::string_view right_run =
+            right.substr(right_index, right_end - right_index);
+
+        int comparison;
+        if(left_digit && right_digit)
+        {
+            comparison = compareNumericRuns(left_run, right_run);
+        }
+        else
+        {
+            comparison = left_run.compare(right_run);
+        }
+        if(comparison != 0)
+        {
+            return comparison < 0;
+        }
+
+        left_index = left_end;
+        right_index = right_end;
+    }
+    return left.size() < right.size();
+}
diff --git a/src/public_id.h b/src/public_id.h
new file mode 100644
index 0000000..f10067e
--- /dev/null
+++ b/src/public_id.h
@@ -0,0 +1,14 @@
+#pragma once
+
+#include <string>
+#include <string_view>
+
+#include <mw/error.hpp>
+
+#include "card.h"
+
+/// Format the canonical lowercase public ID derived from a card identity.
+mw::E<std::string> formatPublicId(const CardIdentity& identity);
+
+/// Compare canonical public IDs using numeric runs as integers.
+bool naturalPublicIdLess(std::string_view left, std::string_view right);
diff --git a/src/url_builder.cpp b/src/url_builder.cpp
new file mode 100644
index 0000000..e7d7c7d
--- /dev/null
+++ b/src/url_builder.cpp
@@ -0,0 +1,182 @@
+#include "url_builder.h"
+
+#include <cctype>
+#include <stdexcept>
+#include <string_view>
+
+namespace
+{
+
+void validateOrdinarySegment(std::string_view value)
+{
+    if(value.empty() || value == "." || value == "..")
+    {
+        throw std::invalid_argument("Invalid empty or dot path segment");
+    }
+}
+
+void validateLiteral(std::string_view value)
+{
+    validateOrdinarySegment(value);
+    if(value.contains('/') || value.contains('\\'))
+    {
+        throw std::invalid_argument("Literal route segment contains a slash");
+    }
+}
+
+void validatePlaceholder(std::string_view value)
+{
+    if(value.empty() ||
+       !(std::islower(static_cast<unsigned char>(value.front())) ||
+         value.front() == '_'))
+    {
+        throw std::invalid_argument("Invalid route placeholder name");
+    }
+
+    for(char character : value.substr(1))
+    {
+        const auto byte = static_cast<unsigned char>(character);
+        if(!(std::islower(byte) || std::isdigit(byte) || character == '_'))
+        {
+            throw std::invalid_argument("Invalid route placeholder name");
+        }
+    }
+}
+
+std::string segmentText(const RouteSegment& segment, bool allow_placeholder)
+{
+    switch(segment.kind)
+    {
+    case RouteSegmentKind::LITERAL:
+        validateLiteral(segment.value);
+        return segment.value;
+    case RouteSegmentKind::DYNAMIC:
+        validateOrdinarySegment(segment.value);
+        return mw::URL::encode(segment.value);
+    case RouteSegmentKind::PLACEHOLDER:
+        if(!allow_placeholder)
+        {
+            throw std::invalid_argument(
+                "Route placeholder is invalid in an absolute URL");
+        }
+        validatePlaceholder(segment.value);
+        return ":" + segment.value;
+    }
+
+    throw std::invalid_argument("Unknown route segment kind");
+}
+
+std::string buildPath(
+    const mw::URL& base_url,
+    const std::vector<RouteSegment>& segments,
+    bool allow_placeholder)
+{
+    std::string path = base_url.path();
+    if(path.empty())
+    {
+        path = "/";
+    }
+
+    if(segments.empty())
+    {
+        return path;
+    }
+
+    if(path.back() != '/')
+    {
+        path += '/';
+    }
+
+    for(std::size_t index = 0; index < segments.size(); ++index)
+    {
+        if(index != 0)
+        {
+            path += '/';
+        }
+        path += segmentText(segments[index], allow_placeholder);
+    }
+
+    return path;
+}
+
+std::string buildQuery(const QueryParameters& query)
+{
+    std::string result;
+    for(std::size_t index = 0; index < query.size(); ++index)
+    {
+        if(index != 0)
+        {
+            result += '&';
+        }
+        result += mw::URL::encode(query[index].first);
+        result += '=';
+        result += mw::URL::encode(query[index].second);
+    }
+    return result;
+}
+
+std::vector<RouteSegment> appendRelativePath(
+    std::vector<RouteSegment> prefix,
+    const std::string& relative_path)
+{
+    if(relative_path.empty() || relative_path.front() == '/' ||
+       relative_path.front() == '\\')
+    {
+        throw std::invalid_argument("Invalid relative static path");
+    }
+
+    std::size_t begin = 0;
+    while(begin <= relative_path.size())
+    {
+        const std::size_t end = relative_path.find('/', begin);
+        const std::string component = relative_path.substr(begin, end - begin);
+        validateOrdinarySegment(component);
+        prefix.push_back({RouteSegmentKind::DYNAMIC, component});
+
+        if(end == std::string::npos)
+        {
+            break;
+        }
+        begin = end + 1;
+    }
+
+    return prefix;
+}
+
+} // namespace
+
+UrlBuilder::UrlBuilder(mw::URL base_url)
+        : base_url_(std::move(base_url))
+{}
+
+std::string UrlBuilder::absolute(
+    const std::vector<RouteSegment>& segments,
+    const QueryParameters& query) const
+{
+    mw::URL result = base_url_;
+    result.path(buildPath(base_url_, segments, false).c_str());
+
+    if(query.empty())
+    {
+        result.query(nullptr);
+    }
+    else
+    {
+        result.query(buildQuery(query).c_str());
+    }
+    return result.str();
+}
+
+std::string UrlBuilder::absoluteFromRelativePath(
+    const std::vector<RouteSegment>& mount_prefix,
+    const std::string& relative_path,
+    const QueryParameters& query) const
+{
+    return absolute(appendRelativePath(mount_prefix, relative_path), query);
+}
+
+std::string UrlBuilder::requestPath(
+    const std::vector<RouteSegment>& segments) const
+{
+    return buildPath(base_url_, segments, true);
+}
diff --git a/src/url_builder.h b/src/url_builder.h
new file mode 100644
index 0000000..a8d6af6
--- /dev/null
+++ b/src/url_builder.h
@@ -0,0 +1,60 @@
+#pragma once
+
+#include <string>
+#include <utility>
+#include <vector>
+
+#include <mw/url.hpp>
+
+/// Kind of one path segment supplied to UrlBuilder.
+enum class RouteSegmentKind
+{
+    /// Trusted application route text.
+    LITERAL,
+
+    /// One value encoded as a single path segment.
+    DYNAMIC,
+
+    /// Route-registration placeholder accepted only by requestPath().
+    PLACEHOLDER
+};
+
+/// One trusted literal, encoded value, or route-registration placeholder.
+struct RouteSegment
+{
+    /// Interpretation applied to value.
+    RouteSegmentKind kind;
+
+    /// Unencoded segment text or placeholder name.
+    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 server request path.
+    std::string requestPath(
+        const std::vector<RouteSegment>& segments) const;
+
+private:
+    mw::URL base_url_;
+};
diff --git a/static/card_placeholder.svg b/static/card_placeholder.svg
new file mode 100644
index 0000000..d049c8f
--- /dev/null
+++ b/static/card_placeholder.svg
@@ -0,0 +1,18 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 700">
+    <defs>
+        <linearGradient id="background" x1="0" y1="0" x2="1" y2="1">
+            <stop stop-color="#322a43"/>
+            <stop offset="1" stop-color="#17181d"/>
+        </linearGradient>
+    </defs>
+    <rect width="500" height="700" fill="url(#background)"/>
+    <rect x="42" y="42" width="416" height="616" rx="22"
+          fill="none" stroke="#ffffff" stroke-opacity=".13" stroke-width="3"/>
+    <circle cx="250" cy="320" r="72" fill="#ffffff" fill-opacity=".08"/>
+    <path d="M210 335l28-30 24 24 20-16 32 38H190z"
+          fill="#ffffff" fill-opacity=".28"/>
+    <text x="250" y="440" text-anchor="middle" fill="#ffffff"
+          fill-opacity=".52" font-family="system-ui,sans-serif" font-size="24">
+        CARD ART
+    </text>
+</svg>
diff --git a/static/css/styles.css b/static/css/styles.css
new file mode 100644
index 0000000..ef9c36e
--- /dev/null
+++ b/static/css/styles.css
@@ -0,0 +1,157 @@
+:root {
+    color-scheme: dark;
+    font-family: Inter, ui-sans-serif, system-ui, sans-serif;
+    background: #101114;
+    color: #f4f1ea;
+}
+
+* {
+    box-sizing: border-box;
+}
+
+body {
+    margin: 0;
+    min-height: 100vh;
+    background:
+        radial-gradient(circle at top left, #2e263d 0, transparent 32rem),
+        #101114;
+}
+
+a {
+    color: inherit;
+}
+
+.site-header {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    padding: 1.25rem clamp(1.25rem, 4vw, 4rem);
+    border-bottom: 1px solid #ffffff1f;
+}
+
+.site-title {
+    font-size: 1.1rem;
+    font-weight: 750;
+    text-decoration: none;
+}
+
+nav {
+    display: flex;
+    gap: 1.25rem;
+}
+
+nav a,
+.sort-controls a {
+    color: #cbc5d6;
+    text-underline-offset: 0.3rem;
+}
+
+main {
+    width: min(90rem, 100%);
+    margin: 0 auto;
+    padding: 3rem clamp(1.25rem, 4vw, 4rem) 5rem;
+}
+
+.page-heading {
+    display: flex;
+    align-items: end;
+    justify-content: space-between;
+    gap: 2rem;
+    margin-bottom: 2rem;
+}
+
+.eyebrow {
+    margin: 0 0 0.4rem;
+    color: #b9a5db;
+    font-size: 0.75rem;
+    font-weight: 750;
+    letter-spacing: 0.14em;
+    text-transform: uppercase;
+}
+
+h1 {
+    margin: 0;
+    font-size: clamp(2.25rem, 6vw, 4.5rem);
+    letter-spacing: -0.055em;
+}
+
+.sort-controls {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 0.75rem;
+    font-size: 0.9rem;
+}
+
+.sort-controls span {
+    color: #77727f;
+}
+
+.sort-controls [aria-current="page"] {
+    color: #ffffff;
+    font-weight: 700;
+}
+
+.card-grid {
+    display: grid;
+    grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
+    gap: clamp(1rem, 2.5vw, 2rem);
+    margin: 0;
+    padding: 0;
+    list-style: none;
+}
+
+.card-tile {
+    overflow: hidden;
+    border: 1px solid #ffffff1a;
+    border-radius: 0.85rem;
+    background: #191a1f;
+    box-shadow: 0 1rem 2.5rem #00000035;
+}
+
+.card-image-link {
+    display: block;
+    aspect-ratio: 5 / 7;
+    background: #24212b;
+}
+
+.card-image-link img {
+    display: block;
+    width: 100%;
+    height: 100%;
+    object-fit: cover;
+}
+
+.card-details {
+    padding: 1rem;
+}
+
+.card-id {
+    color: #b9a5db;
+    font-family: ui-monospace, monospace;
+    font-size: 0.8rem;
+    font-weight: 700;
+    letter-spacing: 0.06em;
+    text-decoration: none;
+}
+
+.card-details h2 {
+    margin: 0.35rem 0 0;
+    font-size: 1rem;
+}
+
+.empty-state {
+    min-height: 14rem;
+    display: grid;
+    place-items: center;
+    border: 1px dashed #ffffff26;
+    border-radius: 0.85rem;
+    color: #918b98;
+}
+
+@media(max-width: 42rem) {
+    .site-header,
+    .page-heading {
+        align-items: flex-start;
+        flex-direction: column;
+    }
+}
diff --git a/templates/card_index.html b/templates/card_index.html
new file mode 100644
index 0000000..68c6192
--- /dev/null
+++ b/templates/card_index.html
@@ -0,0 +1,39 @@
+{% extends "layout.html" %}
+
+{% block content %}
+<div class="page-heading">
+    <div>
+        <p class="eyebrow">Your archive</p>
+        <h1>Cards</h1>
+    </div>
+    <div class="sort-controls" aria-label="Sort cards">
+        <span>Public ID</span>
+        <a href="{{ ascending_url }}"
+           {% if not descending %}aria-current="page"{% endif %}>Ascending</a>
+        <a href="{{ descending_url }}"
+           {% if descending %}aria-current="page"{% endif %}>Descending</a>
+    </div>
+</div>
+
+{% if length(cards) == 0 %}
+<p class="empty-state">No cards yet.</p>
+{% endif %}
+
+<ul class="card-grid">
+{% for card in cards %}
+    <li class="card-tile">
+        <a class="card-image-link" href="{{ card.url }}">
+            <img src="{{ card.thumbnail_url }}"
+                 alt=""
+                 loading="lazy">
+        </a>
+        <div class="card-details">
+            <a class="card-id" href="{{ card.url }}">
+                {{ card.display_id }}
+            </a>
+            <h2>{{ card.name }}</h2>
+        </div>
+    </li>
+{% endfor %}
+</ul>
+{% endblock %}
diff --git a/templates/layout.html b/templates/layout.html
new file mode 100644
index 0000000..84aa7de
--- /dev/null
+++ b/templates/layout.html
@@ -0,0 +1,24 @@
+<!doctype html>
+<html lang="en">
+<head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+    <title>{{ title }}</title>
+    <link rel="stylesheet" href="{{ url_for("static", "css/styles.css") }}">
+</head>
+<body>
+    <header class="site-header">
+        <a class="site-title" href="{{ url_for("card-index") }}">
+            Card Collection
+        </a>
+        <nav aria-label="Primary navigation">
+            <a href="{{ url_for("card-index") }}">Cards</a>
+            <a href="{{ url_for("card-new") }}">Create card</a>
+            <a href="{{ url_for("series-index") }}">Series</a>
+        </nav>
+    </header>
+    <main>
+        {% block content %}{% endblock %}
+    </main>
+</body>
+</html>
diff --git a/tests/app_test.cpp b/tests/app_test.cpp
new file mode 100644
index 0000000..c892ca2
--- /dev/null
+++ b/tests/app_test.cpp
@@ -0,0 +1,141 @@
+#include "app.h"
+
+#include <cstdint>
+#include <filesystem>
+#include <memory>
+#include <optional>
+#include <stdexcept>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include <gtest/gtest.h>
+
+#include "data_fake.h"
+
+namespace
+{
+
+Config makeConfig(const std::string& base_url)
+{
+    auto parsed_url = mw::URL::fromStr(base_url);
+    if(!parsed_url)
+    {
+        throw std::invalid_argument("Test base URL is invalid");
+    }
+
+    return {
+        std::move(*parsed_url),
+        mw::IPSocketInfo{"127.0.0.1", 8080},
+        std::filesystem::path(CARD_COLLECTION_SOURCE_DIR) / "static",
+        std::filesystem::path("cards.sqlite3"),
+        std::filesystem::path("cards"),
+        75,
+        256,
+    };
+}
+
+std::unique_ptr<DataSourceFake> emptyDataSource()
+{
+    return std::make_unique<DataSourceFake>();
+}
+
+Card makeCard(std::int64_t id, std::uint64_t number, std::string name)
+{
+    return {
+        id,
+        {"pkm", number},
+        std::move(name),
+        std::nullopt,
+        std::nullopt,
+        0,
+        "avif",
+        std::nullopt,
+        "avif",
+        1,
+    };
+}
+
+} // namespace
+
+/// Verify named routes respect a nested application base path.
+TEST(AppTest, BuildsNamedUrls)
+{
+    const App app(
+        makeConfig("https://example.test/collection/"),
+        emptyDataSource());
+
+    EXPECT_EQ(
+        app.urlFor("card-index"),
+        "https://example.test/collection/");
+    EXPECT_EQ(
+        app.urlFor("card-new"),
+        "https://example.test/collection/cards/new");
+    EXPECT_EQ(
+        app.urlFor("card", {"PKM/2"}),
+        "https://example.test/collection/cards/PKM%2f2");
+    EXPECT_EQ(
+        app.urlFor("series-edit", {"42"}),
+        "https://example.test/collection/series/42/edit");
+}
+
+/// Verify static relative paths and ordered query values are encoded.
+TEST(AppTest, BuildsStaticAndQueryUrls)
+{
+    const App app(
+        makeConfig("https://example.test/collection/"),
+        emptyDataSource());
+
+    EXPECT_EQ(
+        app.urlFor(
+            "static",
+            {"foil/model/card.obj"},
+            {{"v", "4"}, {"name", "a b"}}),
+        "https://example.test/collection/static/foil/model/card.obj"
+        "?v=4&name=a%20b");
+}
+
+/// Verify route programming errors are rejected.
+TEST(AppTest, RejectsInvalidRoutes)
+{
+    const App app(
+        makeConfig("https://example.test/"),
+        emptyDataSource());
+
+    EXPECT_THROW(app.urlFor("missing"), std::invalid_argument);
+    EXPECT_THROW(app.urlFor("card"), std::invalid_argument);
+    EXPECT_THROW(app.urlFor("card-index", {"extra"}), std::invalid_argument);
+    EXPECT_THROW(app.urlFor("static", {"../secret"}), std::invalid_argument);
+}
+
+/// Verify the index renders escaped fake records in natural ID order.
+TEST(AppTest, RendersCardIndex)
+{
+    auto data_source = std::make_unique<DataSourceFake>(
+        std::vector<Card>{
+            makeCard(10, 10, "Tenth card"),
+            makeCard(2, 2, "<script>Second card</script>"),
+        });
+    App app(
+        makeConfig("https://example.test/collection/"),
+        std::move(data_source));
+    App::Request request;
+    App::Response response;
+
+    app.handleCardIndex(request, response);
+
+    EXPECT_EQ(response.status, 200);
+    EXPECT_EQ(
+        response.get_header_value("Content-Type"),
+        "text/html; charset=utf-8");
+    EXPECT_EQ(response.body.find("<script>Second card</script>"),
+              std::string::npos);
+    EXPECT_NE(response.body.find("&lt;script&gt;Second card&lt;/script&gt;"),
+              std::string::npos);
+
+    const std::size_t second_position = response.body.find("PKM-2");
+    const std::size_t tenth_position = response.body.find("PKM-10");
+    ASSERT_NE(second_position, std::string::npos);
+    ASSERT_NE(tenth_position, std::string::npos);
+    EXPECT_LT(second_position, tenth_position);
+}
diff --git a/tests/data_fake_test.cpp b/tests/data_fake_test.cpp
new file mode 100644
index 0000000..f5e6e6b
--- /dev/null
+++ b/tests/data_fake_test.cpp
@@ -0,0 +1,86 @@
+#include "data_fake.h"
+
+#include <cstdint>
+#include <optional>
+#include <string>
+#include <unordered_map>
+#include <utility>
+#include <vector>
+
+#include <gtest/gtest.h>
+
+namespace
+{
+
+Card makeCard(
+    std::int64_t id,
+    std::optional<std::string> game_short_name,
+    std::uint64_t card_number,
+    std::string name)
+{
+    return {
+        id,
+        {std::move(game_short_name), card_number},
+        std::move(name),
+        std::nullopt,
+        std::nullopt,
+        0,
+        "avif",
+        std::nullopt,
+        "avif",
+        1,
+    };
+}
+
+} // namespace
+
+/// Verify configured card records can be read by identity.
+TEST(DataSourceFakeTest, ReturnsCards)
+{
+    DataSourceFake data_source({
+        makeCard(1, std::nullopt, 10, "Loose card"),
+        makeCard(2, "test", 3, "Game card"),
+    });
+
+    const auto cards = data_source.getCards();
+    ASSERT_TRUE(cards);
+    ASSERT_EQ(cards->size(), 2U);
+
+    const auto card = data_source.getCard({"test", 3});
+    ASSERT_TRUE(card);
+    ASSERT_TRUE(*card);
+    EXPECT_EQ((*card)->name, "Game card");
+
+    const auto missing = data_source.getCard({"test", 4});
+    ASSERT_TRUE(missing);
+    EXPECT_FALSE(*missing);
+}
+
+/// Verify series data, memberships, and game names are deterministic.
+TEST(DataSourceFakeTest, ReturnsRelatedData)
+{
+    DataSourceFake data_source(
+        {makeCard(2, "test", 3, "Game card")},
+        {{7, "test", "First series", "Description"}},
+        {{2, {7}}});
+
+    const auto memberships = data_source.getCardSeries(2);
+    ASSERT_TRUE(memberships);
+    EXPECT_EQ(*memberships, std::vector<std::int64_t>({7}));
+
+    const auto names = data_source.getPersistedGameNames();
+    ASSERT_TRUE(names);
+    EXPECT_EQ(*names, std::vector<std::string>({"test"}));
+}
+
+/// Verify development data cannot accidentally be mutated.
+TEST(DataSourceFakeTest, RejectsMutations)
+{
+    DataSourceFake data_source;
+
+    const auto transaction = data_source.beginTransaction();
+    ASSERT_FALSE(transaction);
+    EXPECT_EQ(
+        transaction.error().msg(),
+        "DataSourceFake is read-only and cannot begin a transaction.");
+}