BareGit

Polish MVP pages and authenticated navigation

- Restyle the public, collection, account, and user administration pages with
  the clay design system and responsive empty states.
- Add role-aware card and user navigation with integration coverage for
  administrators and players.
- Log successful authentication email delivery and update the local example
  configuration.
Author: MetroWind <chris.corsair@gmail.com>
Date: Tue Sep 8 15:39:56 2026 -0700
Commit: eeaecd24b0d0b169b3adfbca279c63a50ef91f3d

Changes

diff --git a/config.example.toml b/config.example.toml
index 91739ec..dd2bfa5 100644
--- a/config.example.toml
+++ b/config.example.toml
@@ -1,10 +1,10 @@
-base_url = "http://127.0.0.1:8080/"
-listen_address = "127.0.0.1"
+base_url = "http://localhost:8080/"
+listen_address = "localhost"
 listen_port = 8080
 static_root = "static"
 database_path = "var/card_collection.sqlite3"
 card_storage_root = "var/cards"
-avif_quality = 75
+avif_quality = 80
 thumbnail_long_side = 256
 
 # Immutable after the first database initialization.
diff --git a/src/app.cpp b/src/app.cpp
index 8fec6fd..f05057f 100644
--- a/src/app.cpp
+++ b/src/app.cpp
@@ -725,6 +725,20 @@ bool App::verifyCsrf(
     return true;
 }
 
+void App::addNavigationData(inja::json& data, const User& user) const
+{
+    AuthorizationService authorization;
+    data["navigation"] = {
+        {"cards_url", urlFor(
+            user.role == UserRole::ADMINISTRATOR
+                ? "card-index"
+                : "creator-cards")},
+        {"show_cards", authorization.canCreateCard(user)},
+        {"show_users", authorization.canAdminister(user)},
+        {"users_url", urlFor("admin-users")},
+    };
+}
+
 void App::handleWelcome(const Request& request, Response& response)
 {
     auto token = cookieValue(request, "card_collection_session");
@@ -984,14 +998,17 @@ void App::handleAccount(const Request& request, Response& response)
     {
         return;
     }
-    respondTemplate(templates_, "account.html", {
+    inja::json template_data = {
         {"csrf_token", identity->session.csrf_token},
         {"email", identity->session.user.email},
         {"logout_url", urlFor("logout")},
         {"title", "Account · Card Collection"},
         {"username", *identity->session.user.username},
         {"username_url", urlFor("account-username")},
-    }, response);
+    };
+    addNavigationData(template_data, identity->session.user);
+    respondTemplate(
+        templates_, "account.html", template_data, response);
 }
 
 void App::handleAccountUsername(const Request& request, Response& response)
@@ -1082,7 +1099,7 @@ void App::handleCollection(const Request& request, Response& response)
         });
     }
     const bool pull_disabled = pool->empty() || user->stored_pulls == 0;
-    respondTemplate(templates_, "collection.html", {
+    inja::json template_data = {
         {"available_pulls", user->stored_pulls},
         {"csrf_token", identity->session.csrf_token},
         {"entries", std::move(entries)},
@@ -1090,7 +1107,10 @@ void App::handleCollection(const Request& request, Response& response)
         {"pull_disabled", pull_disabled},
         {"pull_url", urlFor("collection-pull")},
         {"title", "Collection · Card Collection"},
-    }, response);
+    };
+    addNavigationData(template_data, identity->session.user);
+    respondTemplate(
+        templates_, "collection.html", template_data, response);
 }
 
 void App::handleCollectionPull(const Request& request, Response& response)
@@ -1148,11 +1168,14 @@ void App::handleAdminUsers(const Request& request, Response& response)
             {"username", user.username.value_or("Onboarding")},
         });
     }
-    respondTemplate(templates_, "user_admin.html", {
+    inja::json template_data = {
         {"csrf_token", identity->session.csrf_token},
         {"title", "Users · Card Collection"},
         {"users", std::move(template_users)},
-    }, response);
+    };
+    addNavigationData(template_data, identity->session.user);
+    respondTemplate(
+        templates_, "user_admin.html", template_data, response);
 }
 
 void App::handleAdminPromote(const Request& request, Response& response)
@@ -1237,7 +1260,7 @@ void App::handleCardNew(
             {"selected", false},
         });
     }
-    const inja::json template_data = {
+    inja::json template_data = {
         {"action_url", urlFor("cards")},
         {"back_url",
          urlFor(identity->session.user.role == UserRole::ADMINISTRATOR
@@ -1274,6 +1297,7 @@ void App::handleCardNew(
         {"thumbnail_long_side", config_.thumbnail_long_side},
         {"title", "Create card · Card Collection"},
     };
+    addNavigationData(template_data, identity->session.user);
 
     try
     {
@@ -1425,7 +1449,7 @@ void App::handleCardEdit(
         });
     }
 
-    const inja::json template_data = {
+    inja::json template_data = {
         {"action_url", urlFor("card-edit", {public_id})},
         {"back_url", urlFor("card", {public_id})},
         {"csrf_token", actor->session.csrf_token},
@@ -1459,6 +1483,7 @@ void App::handleCardEdit(
         {"thumbnail_long_side", config_.thumbnail_long_side},
         {"title", "Edit " + card.name + " · Card Collection"},
     };
+    addNavigationData(template_data, actor->session.user);
 
     try
     {
@@ -2083,7 +2108,7 @@ void App::handleCardView(
             long_description_marker, std::move(*rendered)});
     }
 
-    const inja::json template_data = {
+    inja::json template_data = {
         {"asset_warning", !missing_assets.empty()},
         {"back_url", urlFor("card-index")},
         {"display_id", uppercaseAscii(public_id)},
@@ -2114,6 +2139,7 @@ void App::handleCardView(
          urlFor("static", {"foil/spectral_xyz.bin"})},
         {"title", card.name + " · Card Collection"},
     };
+    addNavigationData(template_data, actor->session.user);
 
     try
     {
@@ -2179,13 +2205,14 @@ void App::handleCardDeleteConfirm(
         }
         return;
     }
-    const inja::json template_data = {
+    inja::json template_data = {
         {"action_url", urlFor("card-delete", {parameter->second})},
         {"back_url", urlFor("card", {parameter->second})},
         {"csrf_token", actor->session.csrf_token},
         {"name", (**card).name},
         {"title", "Delete card · Card Collection"},
     };
+    addNavigationData(template_data, actor->session.user);
     try
     {
         response.status = 200;
@@ -2369,7 +2396,7 @@ void App::handleCardIndex(
     const std::string index_route = creator_route
         ? "creator-cards"
         : "card-index";
-    const inja::json template_data = {
+    inja::json template_data = {
         {"ascending_url",
          urlFor(index_route, {}, {{"sort", "id"}, {"direction", "asc"}})},
         {"administrator", !creator_route},
@@ -2382,6 +2409,7 @@ void App::handleCardIndex(
         {"title", "Card Collection"},
         {"users_url", urlFor("admin-users")},
     };
+    addNavigationData(template_data, actor->session.user);
 
     try
     {
@@ -2452,12 +2480,13 @@ void App::handleSeriesIndex(
             {"name", item.name},
         });
     }
-    const inja::json template_data = {
+    inja::json template_data = {
         {"can_create", !games_->games().empty()},
         {"create_url", urlFor("series-new")},
         {"series", std::move(series)},
         {"title", "Series · Card Collection"},
     };
+    addNavigationData(template_data, actor->session.user);
     try
     {
         response.status = 200;
@@ -2502,7 +2531,7 @@ void App::handleSeriesNew(
             {"short_name", std::string(game->shortName())},
         });
     }
-    const inja::json template_data = {
+    inja::json template_data = {
         {"action_url", urlFor("series")},
         {"back_url", urlFor("series-index")},
         {"csrf_token", actor->session.csrf_token},
@@ -2515,6 +2544,7 @@ void App::handleSeriesNew(
         {"submit_label", "Create series"},
         {"title", "Create series · Card Collection"},
     };
+    addNavigationData(template_data, actor->session.user);
     try
     {
         response.status = 200;
@@ -2601,7 +2631,7 @@ void App::handleSeriesEdit(
     }
     const Series& series = **series_result;
     const GameDefinition* game = games_->find(series.game_short_name);
-    const inja::json template_data = {
+    inja::json template_data = {
         {"action_url", urlFor("series-item", {std::to_string(*id)})},
         {"back_url", urlFor("series-index")},
         {"csrf_token", actor->session.csrf_token},
@@ -2616,6 +2646,7 @@ void App::handleSeriesEdit(
         {"submit_label", "Save changes"},
         {"title", "Edit " + series.name + " · Card Collection"},
     };
+    addNavigationData(template_data, actor->session.user);
     try
     {
         response.status = 200;
@@ -2706,13 +2737,14 @@ void App::handleSeriesDeleteConfirm(
         }
         return;
     }
-    const inja::json template_data = {
+    inja::json template_data = {
         {"action_url", urlFor("series-delete", {std::to_string(*id)})},
         {"back_url", urlFor("series-index")},
         {"csrf_token", actor->session.csrf_token},
         {"name", (**series_result).name},
         {"title", "Delete series · Card Collection"},
     };
+    addNavigationData(template_data, actor->session.user);
     try
     {
         response.status = 200;
diff --git a/src/app.h b/src/app.h
index b5fdccf..011e2f9 100644
--- a/src/app.h
+++ b/src/app.h
@@ -190,6 +190,9 @@ private:
         const RequestIdentity& identity,
         Response& response) const;
 
+    /// Add role-aware authenticated navigation links to template data.
+    void addNavigationData(inja::json& data, const User& user) const;
+
     /// Set or expire a host-only application cookie.
     void setCookie(
         Response& response,
diff --git a/src/authentication.cpp b/src/authentication.cpp
index d93014b..603983e 100644
--- a/src/authentication.cpp
+++ b/src/authentication.cpp
@@ -6,6 +6,8 @@
 #include <string>
 #include <utility>
 
+#include <spdlog/spdlog.h>
+
 #include "email_address.h"
 #include "secret_token.h"
 
@@ -119,6 +121,11 @@ mw::E<void> AuthenticationService::requestEmail(
         std::chrono::system_clock::time_point(
             std::chrono::seconds(now + CHALLENGE_LIFETIME_SECONDS))};
     auto sent = email_sender_.send(email);
+    if(sent)
+    {
+        spdlog::info(
+            "Sent authentication email to {}", address->email);
+    }
     transaction = data_source_.beginTransaction();
     if(!transaction)
     {
diff --git a/static/css/styles.css b/static/css/styles.css
index 6f0ad7a..7f238db 100644
--- a/static/css/styles.css
+++ b/static/css/styles.css
@@ -1014,30 +1014,770 @@ h1 {
     }
 }
 
-.collection-grid {
+.public-main {
     display: grid;
-    grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
+    place-items: center;
+}
+
+.public-header-note {
+    color: var(--muted);
+    font-size: 0.78rem;
+    font-weight: 800;
+    letter-spacing: 0.08em;
+    text-transform: uppercase;
+}
+
+.welcome-hero {
+    display: grid;
+    width: 100%;
+    min-height: calc(100svh - var(--main-top) - var(--main-bottom));
+    grid-template-columns: minmax(0, 1.05fr) minmax(20rem, 0.95fr);
+    align-items: center;
+    gap: clamp(3rem, 8vw, 8rem);
+}
+
+.welcome-copy {
+    max-width: 48rem;
+}
+
+.welcome-copy h1 {
+    font-size: clamp(3.5rem, 8vw, 7.5rem);
+}
+
+.hero-copy {
+    max-width: 40rem;
+    margin: 2rem 0 2.5rem;
+    color: var(--muted);
+    font-size: clamp(1.05rem, 2vw, 1.35rem);
+}
+
+.clay-action,
+.pull-button,
+.table-action {
+    display: inline-flex;
+    min-height: 3.5rem;
+    align-items: center;
+    justify-content: center;
+    padding: 0.75rem 1.5rem;
+    border: 0;
+    border-radius: 1.25rem;
+    background: linear-gradient(145deg, var(--violet-light), var(--violet));
+    box-shadow: var(--clay-button-shadow);
+    color: #ffffff;
+    font: inherit;
+    font-weight: 800;
+    letter-spacing: 0.02em;
+    text-decoration: none;
+    cursor: pointer;
+    transition:
+        box-shadow 200ms ease,
+        transform 200ms ease;
+}
+
+.clay-action:hover,
+.pull-button:hover:not(:disabled),
+.table-action:hover {
+    box-shadow: var(--clay-card-shadow-hover);
+    transform: translateY(-0.25rem);
+}
+
+.clay-action:active,
+.pull-button:active:not(:disabled),
+.table-action:active {
+    box-shadow: var(--clay-pressed-shadow);
+    transform: scale(0.92);
+}
+
+.clay-action-secondary {
+    background: rgb(255 255 255 / 76%);
+    color: var(--violet);
+}
+
+.welcome-preview {
+    position: relative;
+    display: grid;
+    min-height: 36rem;
+    place-items: center;
+    isolation: isolate;
+}
+
+.welcome-card {
+    width: min(20rem, 68vw);
+    padding: 0.9rem;
+    border: 1px solid rgb(255 255 255 / 85%);
+    border-radius: 2.5rem;
+    background: rgb(255 255 255 / 72%);
+    box-shadow:
+        24px 28px 48px rgb(139 92 246 / 24%),
+        -14px -14px 32px rgb(255 255 255 / 95%),
+        inset 7px 7px 14px rgb(139 92 246 / 4%),
+        inset -7px -7px 14px rgb(255 255 255 / 100%);
+    backdrop-filter: blur(1.25rem);
+    transform: rotate(4deg);
+    transition:
+        box-shadow 500ms ease,
+        transform 500ms ease;
+}
+
+.welcome-card:hover {
+    box-shadow: var(--clay-card-shadow-hover);
+    transform: translateY(-0.75rem) rotate(1deg) scale(1.02);
+}
+
+.welcome-card img {
+    display: block;
+    width: 100%;
+    aspect-ratio: 5 / 7;
+    border-radius: 1.75rem;
+    object-fit: cover;
+}
+
+.welcome-preview > p {
+    position: absolute;
+    right: 0;
+    bottom: 1rem;
+    left: 0;
+    margin: 0;
+    color: var(--muted);
+    font-size: 0.85rem;
+    font-weight: 700;
+    text-align: center;
+}
+
+.welcome-orbit {
+    position: absolute;
+    z-index: -1;
+    width: 12rem;
+    height: 12rem;
+    border-radius: 50%;
+    filter: blur(0.25rem);
+    opacity: 0.2;
+    animation: clay-float 9s ease-in-out infinite;
+}
+
+.welcome-orbit-pink {
+    top: 2rem;
+    right: 0;
+    background: var(--pink);
+}
+
+.welcome-orbit-blue {
+    bottom: 3rem;
+    left: 0;
+    background: var(--blue);
+    animation-name: clay-float-delayed;
+}
+
+.public-card {
+    position: relative;
+    width: min(42rem, 100%);
+    padding: clamp(2rem, 6vw, 4rem);
+    overflow: hidden;
+    border: 1px solid rgb(255 255 255 / 82%);
+    border-radius: clamp(2rem, 5vw, 3rem);
+    background: rgb(255 255 255 / 70%);
+    box-shadow:
+        30px 30px 60px #cdc6d9,
+        -30px -30px 60px #ffffff,
+        inset 10px 10px 20px rgb(139 92 246 / 5%),
+        inset -10px -10px 20px rgb(255 255 255 / 80%);
+    backdrop-filter: blur(1.25rem);
+}
+
+.public-card h1 {
+    font-size: clamp(2.65rem, 7vw, 4.5rem);
+}
+
+.public-card-copy {
+    max-width: 34rem;
+    margin: 1.25rem 0 0;
+    color: var(--muted);
+    font-size: 1.05rem;
+}
+
+.clay-icon {
+    display: inline-grid;
+    width: 3.5rem;
+    height: 3.5rem;
+    place-items: center;
+    flex: 0 0 auto;
+    margin-bottom: 1.5rem;
+    border-radius: 1.25rem;
+    box-shadow:
+        10px 10px 20px rgb(139 92 246 / 20%),
+        -7px -7px 14px rgb(255 255 255 / 60%),
+        inset 4px 4px 8px rgb(255 255 255 / 35%),
+        inset -4px -4px 8px rgb(0 0 0 / 9%);
+    color: #ffffff;
+    font-family: Nunito, ui-rounded, sans-serif;
+    font-size: 1.35rem;
+    font-weight: 900;
+}
+
+.clay-icon-violet {
+    background: linear-gradient(145deg, #a78bfa, #7c3aed);
+}
+
+.clay-icon-pink {
+    background: linear-gradient(145deg, #f472b6, #db2777);
+}
+
+.clay-icon-blue {
+    background: linear-gradient(145deg, #38bdf8, #0284c7);
+}
+
+.message-card {
+    text-align: center;
+}
+
+.message-card .public-card-copy {
+    margin-inline: auto;
+}
+
+.message-hint,
+.identity-pill {
+    margin: 1.75rem 0 0;
+    padding: 1rem 1.25rem;
+    border-radius: 1.25rem;
+    background: #efebf5;
+    box-shadow: var(--clay-pressed-shadow);
+    color: var(--muted);
+    overflow-wrap: anywhere;
+}
+
+.identity-pill {
+    color: var(--foreground);
+    font-weight: 800;
+    text-align: center;
+}
+
+.collection-page,
+.account-page,
+.users-page {
+    width: 100%;
+}
+
+.collection-heading {
+    align-items: center;
+}
+
+.pull-panel {
+    display: flex;
+    align-items: center;
     gap: 1rem;
+    padding: 0.75rem;
+    border: 1px solid rgb(255 255 255 / 82%);
+    border-radius: 2rem;
+    background: rgb(255 255 255 / 68%);
+    box-shadow: var(--clay-card-shadow);
+    backdrop-filter: blur(1.25rem);
+}
+
+.pull-count {
+    display: grid;
+    min-width: 8rem;
+    padding: 0.4rem 0.75rem;
+    text-align: center;
+}
+
+.pull-count strong {
+    color: var(--violet);
+    font-family: Nunito, ui-rounded, sans-serif;
+    font-size: 2rem;
+    font-weight: 900;
+    line-height: 1;
+}
+
+.pull-count span {
+    color: var(--muted);
+    font-size: 0.72rem;
+    font-weight: 800;
+    letter-spacing: 0.07em;
+    text-transform: uppercase;
+}
+
+.pull-button:disabled {
+    background: #d8d1e2;
+    box-shadow: var(--clay-pressed-shadow);
+    color: var(--muted);
+    cursor: not-allowed;
+}
+
+.collection-notice {
+    display: flex;
+    align-items: center;
+    gap: 1.25rem;
+    margin-bottom: 2.5rem;
+    padding: 1.25rem 1.5rem;
+    border-radius: 2rem;
+    background: rgb(255 255 255 / 70%);
+    box-shadow: var(--clay-card-shadow);
+}
+
+.collection-notice .clay-icon {
+    margin: 0;
+}
+
+.collection-notice strong {
+    font-family: Nunito, ui-rounded, sans-serif;
+    font-size: 1.1rem;
+    font-weight: 900;
+}
+
+.collection-notice p {
+    margin: 0;
+    color: var(--muted);
+}
+
+.collection-empty {
+    display: grid;
+    min-height: 20rem;
+    place-items: center;
+    align-content: center;
+    padding: 2rem;
+    border-radius: 2.5rem;
+    background: #efebf5;
+    box-shadow: var(--clay-pressed-shadow);
+    text-align: center;
+}
+
+.collection-empty .clay-icon {
+    margin: 0 0 1.25rem;
+}
+
+.collection-empty h2 {
+    margin: 0;
+    font-family: Nunito, ui-rounded, sans-serif;
+    font-size: clamp(1.5rem, 4vw, 2rem);
+    font-weight: 900;
+}
+
+.collection-empty p {
+    margin: 0.35rem 0 0;
+    color: var(--muted);
+}
+
+.collection-grid {
+    display: grid;
+    grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
+    gap: clamp(1.5rem, 3vw, 2.5rem);
+    margin: 0;
     padding: 0;
     list-style: none;
 }
 
+.collection-card {
+    min-width: 0;
+}
+
 .collection-card a {
     position: relative;
     display: grid;
-    gap: 0.5rem;
+    height: 100%;
+    padding: 0.75rem 0.75rem 1.25rem;
+    overflow: hidden;
+    border: 1px solid rgb(255 255 255 / 85%);
+    border-radius: 2rem;
+    background: rgb(255 255 255 / 72%);
+    box-shadow: var(--clay-card-shadow);
+    text-decoration: none;
+    backdrop-filter: blur(1.25rem);
+    transition:
+        box-shadow 500ms ease,
+        transform 500ms cubic-bezier(0.2, 0.8, 0.2, 1);
 }
 
-.collection-card img {
+.collection-card a:hover {
+    box-shadow: var(--clay-card-shadow-hover);
+    transform: translateY(-0.5rem) scale(1.015);
+}
+
+.collection-card a:active {
+    box-shadow: var(--clay-pressed-shadow);
+    transform: scale(0.97);
+}
+
+.collection-image {
+    display: block;
+    aspect-ratio: 5 / 7;
+    overflow: hidden;
+    border-radius: 1.5rem;
+    background: #e9e2f2;
+    box-shadow:
+        inset 7px 7px 14px rgb(174 164 190 / 20%),
+        inset -7px -7px 14px rgb(255 255 255 / 85%),
+        4px 4px 10px rgb(139 92 246 / 8%),
+        -3px -3px 8px rgb(255 255 255 / 70%);
+}
+
+.collection-image img {
+    display: block;
     width: 100%;
+    height: 100%;
+    object-fit: cover;
+    transition: transform 500ms cubic-bezier(0.2, 0.8, 0.2, 1);
+}
+
+.collection-card a:hover img {
+    transform: scale(1.035);
+}
+
+.collection-card-details {
+    display: grid;
+    gap: 0.1rem;
+    padding: 1rem 0.6rem 0;
+}
+
+.collection-card-name {
+    overflow-wrap: anywhere;
+    font-family: Nunito, ui-rounded, sans-serif;
+    font-size: 1.1rem;
+    font-weight: 900;
+    line-height: 1.25;
+}
+
+.collection-card-details > span {
+    color: var(--violet);
+    font-size: 0.75rem;
+    font-weight: 800;
 }
 
 .collection-quantity {
     position: absolute;
-    right: 0.5rem;
-    bottom: 2rem;
-    padding: 0.2rem 0.5rem;
+    top: 1.35rem;
+    right: 1.35rem;
+    display: grid;
+    min-width: 2.75rem;
+    min-height: 2.75rem;
+    place-items: center;
+    padding: 0.35rem 0.6rem;
+    border: 2px solid rgb(255 255 255 / 70%);
+    border-radius: 50%;
+    background: linear-gradient(145deg, #f472b6, #db2777);
+    box-shadow:
+        8px 8px 18px rgb(190 36 105 / 30%),
+        -5px -5px 12px rgb(255 255 255 / 50%),
+        inset 3px 3px 7px rgb(255 255 255 / 35%),
+        inset -3px -3px 7px rgb(0 0 0 / 9%);
+    color: #ffffff;
+    font-family: Nunito, ui-rounded, sans-serif;
+    font-size: 0.85rem;
+    font-weight: 900;
+}
+
+.account-grid {
+    display: grid;
+    grid-template-columns: minmax(0, 1.2fr) minmax(18rem, 0.8fr);
+    gap: clamp(1.5rem, 3vw, 2.5rem);
+}
+
+.settings-card {
+    display: grid;
+    align-content: start;
+    gap: 1.5rem;
+    padding: clamp(1.75rem, 4vw, 2.75rem);
+    border: 1px solid rgb(255 255 255 / 82%);
+    border-radius: 2.5rem;
+    background: rgb(255 255 255 / 70%);
+    box-shadow: var(--clay-card-shadow);
+    backdrop-filter: blur(1.25rem);
+}
+
+.settings-card .clay-icon {
+    margin: 0;
+}
+
+.settings-label {
+    margin: 0 0 0.4rem;
+    color: var(--violet);
+    font-size: 0.75rem;
+    font-weight: 800;
+    letter-spacing: 0.12em;
+    text-transform: uppercase;
+}
+
+.settings-card h2 {
+    margin: 0 0 0.5rem;
+    font-family: Nunito, ui-rounded, sans-serif;
+    font-size: 1.5rem;
+    font-weight: 900;
+    line-height: 1.2;
+}
+
+.account-details {
+    display: grid;
+    gap: 0.75rem;
+    margin: 1.5rem 0 0;
+}
+
+.account-details div {
+    display: grid;
+    gap: 0.15rem;
+    padding: 1rem 1.25rem;
+    border-radius: 1.25rem;
+    background: #efebf5;
+    box-shadow: var(--clay-pressed-shadow);
+}
+
+.account-details dt {
+    color: var(--muted);
+    font-size: 0.7rem;
+    font-weight: 800;
+    letter-spacing: 0.08em;
+    text-transform: uppercase;
+}
+
+.account-details dd {
+    margin: 0;
+    overflow-wrap: anywhere;
+    font-family: Nunito, ui-rounded, sans-serif;
+    font-size: 1.05rem;
+    font-weight: 800;
+}
+
+.settings-card .clay-action {
+    width: fit-content;
+}
+
+.heading-copy {
+    max-width: 24rem;
+    margin: 0;
+    color: var(--muted);
+    font-size: 1.05rem;
+}
+
+.user-table-shell {
+    overflow-x: auto;
+    padding: 1rem;
+    border: 1px solid rgb(255 255 255 / 82%);
+    border-radius: 2.5rem;
+    background: rgb(255 255 255 / 70%);
+    box-shadow: var(--clay-card-shadow);
+    backdrop-filter: blur(1.25rem);
+}
+
+.user-table {
+    width: 100%;
+    border-collapse: separate;
+    border-spacing: 0 0.75rem;
+    text-align: left;
+}
+
+.user-table th {
+    padding: 0 1rem 0.25rem;
+    color: var(--muted);
+    font-size: 0.7rem;
+    font-weight: 800;
+    letter-spacing: 0.09em;
+    text-transform: uppercase;
+}
+
+.user-table td {
+    padding: 1rem;
+    background: #efebf5;
+    box-shadow:
+        inset 5px 5px 11px rgb(185 177 198 / 40%),
+        inset -5px -5px 11px rgb(255 255 255 / 85%);
+    overflow-wrap: anywhere;
+}
+
+.user-table td:first-child {
+    border-radius: 1.25rem 0 0 1.25rem;
+}
+
+.user-table td:last-child {
+    border-radius: 0 1.25rem 1.25rem 0;
+}
+
+.role-badge {
+    display: inline-flex;
+    min-height: 2rem;
+    align-items: center;
+    padding: 0.3rem 0.75rem;
     border-radius: 999px;
-    color: white;
-    background: rgb(0 0 0 / 75%);
+    background: rgb(124 58 237 / 10%);
+    color: var(--violet);
+    font-size: 0.72rem;
+    font-weight: 900;
+    letter-spacing: 0.06em;
+    text-transform: uppercase;
+}
+
+.user-action {
+    text-align: right;
+}
+
+.table-action {
+    min-height: 2.75rem;
+    padding: 0.5rem 1rem;
+    font-size: 0.8rem;
+}
+
+.visually-hidden {
+    position: absolute;
+    width: 1px;
+    height: 1px;
+    padding: 0;
+    overflow: hidden;
+    clip: rect(0, 0, 0, 0);
+    white-space: nowrap;
+    border: 0;
+}
+
+@media(max-width: 62rem) {
+    .welcome-hero,
+    .account-grid {
+        grid-template-columns: 1fr;
+    }
+
+    .welcome-copy {
+        text-align: center;
+        justify-self: center;
+    }
+
+    .hero-copy {
+        margin-inline: auto;
+    }
+
+    .welcome-preview {
+        min-height: 32rem;
+    }
+
+    .collection-heading {
+        align-items: stretch;
+        flex-direction: column;
+    }
+
+    .pull-panel {
+        width: fit-content;
+    }
+}
+
+@media(max-width: 42rem) {
+    .public-main {
+        padding-top: 10rem;
+    }
+
+    .public-header {
+        align-items: center;
+        flex-direction: row;
+    }
+
+    .public-header-note {
+        font-size: 0.65rem;
+    }
+
+    .welcome-hero {
+        gap: 1rem;
+    }
+
+    .welcome-copy h1 {
+        font-size: clamp(3.25rem, 17vw, 5rem);
+    }
+
+    .welcome-preview {
+        min-height: 29rem;
+    }
+
+    .public-card {
+        padding: 2rem 1.5rem;
+        border-radius: 2rem;
+    }
+
+    .pull-panel {
+        width: 100%;
+        align-items: stretch;
+        flex-direction: column;
+    }
+
+    .pull-panel form,
+    .pull-button {
+        width: 100%;
+    }
+
+    .collection-grid {
+        grid-template-columns: repeat(auto-fill, minmax(9.5rem, 1fr));
+        gap: 1rem;
+    }
+
+    .collection-card a {
+        border-radius: 1.5rem;
+    }
+
+    .collection-image {
+        border-radius: 1.15rem;
+    }
+
+    .settings-card {
+        border-radius: 2rem;
+    }
+
+    .settings-card .clay-action,
+    .settings-card form,
+    .settings-card form .clay-action {
+        width: 100%;
+    }
+
+    .user-table-shell {
+        padding: 0;
+        overflow: visible;
+        border: 0;
+        background: transparent;
+        box-shadow: none;
+    }
+
+    .user-table,
+    .user-table tbody,
+    .user-table tr,
+    .user-table td {
+        display: block;
+        width: 100%;
+    }
+
+    .user-table thead {
+        display: none;
+    }
+
+    .user-table tr {
+        margin-bottom: 1.25rem;
+        padding: 1.25rem;
+        border: 1px solid rgb(255 255 255 / 82%);
+        border-radius: 2rem;
+        background: rgb(255 255 255 / 70%);
+        box-shadow: var(--clay-card-shadow);
+    }
+
+    .user-table td {
+        display: grid;
+        grid-template-columns: 6rem minmax(0, 1fr);
+        gap: 0.75rem;
+        padding: 0.6rem 0;
+        background: transparent;
+        box-shadow: none;
+    }
+
+    .user-table td::before {
+        color: var(--muted);
+        content: attr(data-label);
+        font-size: 0.68rem;
+        font-weight: 800;
+        letter-spacing: 0.08em;
+        text-transform: uppercase;
+    }
+
+    .user-table td:first-child,
+    .user-table td:last-child {
+        border-radius: 0;
+    }
+
+    .user-table .user-action {
+        display: block;
+        padding-top: 1rem;
+        text-align: left;
+    }
+
+    .user-action form,
+    .table-action {
+        width: 100%;
+    }
 }
diff --git a/templates/account.html b/templates/account.html
index 6a81447..e5ffbc8 100644
--- a/templates/account.html
+++ b/templates/account.html
@@ -1,13 +1,41 @@
 {% extends "layout.html" %}
 {% block content %}
-<section class="page-shell form-page">
-    <h1>Account</h1>
-    <dl><dt>Email</dt><dd>{{ email }}</dd>
-        <dt>Username</dt><dd>{{ username }}</dd></dl>
-    <p><a href="{{ username_url }}">Change username</a></p>
-    <form method="post" action="{{ logout_url }}">
-        <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
-        <button>Sign out</button>
-    </form>
+<section class="account-page" aria-labelledby="AccountHeading">
+    <header class="page-heading">
+        <div>
+            <p class="eyebrow">Your profile</p>
+            <h1 id="AccountHeading">Account</h1>
+        </div>
+    </header>
+    <div class="account-grid">
+        <article class="settings-card">
+            <div class="clay-icon clay-icon-violet" aria-hidden="true">@</div>
+            <div>
+                <p class="settings-label">Collector profile</p>
+                <dl class="account-details">
+                    <div><dt>Username</dt><dd>{{ username }}</dd></div>
+                    <div><dt>Email</dt><dd>{{ email }}</dd></div>
+                </dl>
+            </div>
+            <a class="clay-action clay-action-secondary"
+               href="{{ username_url }}">Change username</a>
+        </article>
+        <article class="settings-card settings-card-compact">
+            <div class="clay-icon clay-icon-pink" aria-hidden="true">↪</div>
+            <div>
+                <p class="settings-label">Session</p>
+                <h2>Finished collecting for now?</h2>
+                <p class="muted-copy">
+                    Sign out safely on this device. Your collection stays put.
+                </p>
+            </div>
+            <form method="post" action="{{ logout_url }}">
+                <input type="hidden" name="csrf_token"
+                       value="{{ csrf_token }}">
+                <button class="clay-action clay-action-secondary"
+                        type="submit">Sign out</button>
+            </form>
+        </article>
+    </div>
 </section>
 {% endblock %}
diff --git a/templates/authentication.html b/templates/authentication.html
index bddafcb..4f5094c 100644
--- a/templates/authentication.html
+++ b/templates/authentication.html
@@ -1,9 +1,21 @@
 {% extends "public_layout.html" %}
 {% block content %}
-<h1>Sign in or register</h1>
-<form method="post" action="{{ action_url }}">
-    <label>Email <input type="email" name="email" required></label>
-    <input type="hidden" name="form_nonce" value="{{ form_nonce }}">
-    <button>Send sign-in link</button>
-</form>
+<section class="public-card auth-card" aria-labelledby="AuthHeading">
+    <div class="clay-icon clay-icon-violet" aria-hidden="true">✦</div>
+    <p class="eyebrow">Welcome back</p>
+    <h1 id="AuthHeading">Sign in or register</h1>
+    <p class="public-card-copy">
+        No password needed. We will email you a secure sign-in link.
+    </p>
+    <form class="standalone-form" method="post" action="{{ action_url }}">
+        <label class="form-field" for="AuthenticationEmail">
+            <span>Email address</span>
+            <input id="AuthenticationEmail" type="email" name="email"
+                   autocomplete="email" placeholder="you@example.com"
+                   required autofocus>
+        </label>
+        <input type="hidden" name="form_nonce" value="{{ form_nonce }}">
+        <button class="form-submit" type="submit">Send sign-in link</button>
+    </form>
+</section>
 {% endblock %}
diff --git a/templates/authentication_confirm.html b/templates/authentication_confirm.html
index a6dd214..e9560d6 100644
--- a/templates/authentication_confirm.html
+++ b/templates/authentication_confirm.html
@@ -1,9 +1,14 @@
 {% extends "public_layout.html" %}
 {% block content %}
-<h1>Confirm sign in</h1>
-<p>Continue as {{ email }}?</p>
-<form method="post" action="{{ action_url }}">
+<section class="public-card auth-card" aria-labelledby="ConfirmHeading">
+    <div class="clay-icon clay-icon-pink" aria-hidden="true">✓</div>
+    <p class="eyebrow">Almost there</p>
+    <h1 id="ConfirmHeading">Confirm sign in</h1>
+    <p class="public-card-copy">Continue with this email address?</p>
+    <p class="identity-pill">{{ email }}</p>
+    <form class="standalone-form" method="post" action="{{ action_url }}">
     <input type="hidden" name="form_nonce" value="{{ form_nonce }}">
-    <button>Continue</button>
-</form>
+        <button class="form-submit" type="submit">Continue</button>
+    </form>
+</section>
 {% endblock %}
diff --git a/templates/authentication_sent.html b/templates/authentication_sent.html
index fee6e10..adb37c8 100644
--- a/templates/authentication_sent.html
+++ b/templates/authentication_sent.html
@@ -1,5 +1,12 @@
 {% extends "public_layout.html" %}
 {% block content %}
-<h1>Check your email</h1>
-<p>If the address can receive mail, a sign-in link is on its way.</p>
+<section class="public-card message-card" aria-labelledby="SentHeading">
+    <div class="clay-icon clay-icon-blue" aria-hidden="true">✉</div>
+    <p class="eyebrow">Link sent</p>
+    <h1 id="SentHeading">Check your email</h1>
+    <p class="public-card-copy">
+        If the address can receive mail, a sign-in link is on its way.
+    </p>
+    <p class="message-hint">You can close this page after it arrives.</p>
+</section>
 {% endblock %}
diff --git a/templates/collection.html b/templates/collection.html
index 6ea6440..4721b21 100644
--- a/templates/collection.html
+++ b/templates/collection.html
@@ -1,20 +1,62 @@
 {% extends "layout.html" %}
 {% block content %}
-<section class="page-shell">
-    <h1>Your collection</h1>
-    <p>Available pulls: {{ available_pulls }}</p>
-    <form method="post" action="{{ pull_url }}">
-        <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
-        <button {% if pull_disabled %}disabled{% endif %}>Pull a card</button>
-    </form>
-    {% if pool_empty %}<p>The card pool is currently empty.</p>{% endif %}
+<section class="collection-page" aria-labelledby="CollectionHeading">
+    <header class="page-heading collection-heading">
+        <div>
+            <p class="eyebrow">Your daily discoveries</p>
+            <h1 id="CollectionHeading">Your collection</h1>
+        </div>
+        <div class="pull-panel">
+            <div class="pull-count">
+                <strong>{{ available_pulls }}</strong>
+                <span>available pulls</span>
+            </div>
+            <form method="post" action="{{ pull_url }}">
+                <input type="hidden" name="csrf_token"
+                       value="{{ csrf_token }}">
+                <button class="pull-button" type="submit"
+                        {% if pull_disabled %}disabled{% endif %}>
+                    Pull a card
+                </button>
+            </form>
+        </div>
+    </header>
+    {% if pool_empty %}
+    <div class="collection-notice" role="status">
+        <span class="clay-icon clay-icon-pink" aria-hidden="true">!</span>
+        <div>
+            <strong>The card pool is empty</strong>
+            <p>Check back after a creator adds some cards.</p>
+        </div>
+    </div>
+    {% endif %}
+    {% if length(entries) == 0 %}
+    <div class="collection-empty">
+        <div class="clay-icon clay-icon-blue" aria-hidden="true">✦</div>
+        <h2>Your collection is ready to grow</h2>
+        <p>Cards you pull will appear here.</p>
+    </div>
+    {% endif %}
     <ul class="collection-grid">
     {% for entry in entries %}
-        <li class="collection-card"><a href="{{ entry.url }}">
-            <img src="{{ entry.thumbnail_url }}" alt="">
-            <span>{{ entry.name }}</span>
-            <strong class="collection-quantity">{{ entry.quantity }}</strong>
-        </a></li>
+        <li class="collection-card">
+            <a href="{{ entry.url }}">
+                <span class="collection-image">
+                    <img src="{{ entry.thumbnail_url }}"
+                         alt="{{ entry.name }}">
+                </span>
+                <span class="collection-card-details">
+                    <strong class="collection-card-name">
+                        {{ entry.name }}
+                    </strong>
+                    <span>View card</span>
+                </span>
+                <strong class="collection-quantity"
+                        aria-label="Quantity {{ entry.quantity }}">
+                    ×{{ entry.quantity }}
+                </strong>
+            </a>
+        </li>
     {% endfor %}
     </ul>
 </section>
diff --git a/templates/error.html b/templates/error.html
index 6d03cea..63c54e4 100644
--- a/templates/error.html
+++ b/templates/error.html
@@ -1,2 +1,9 @@
 {% extends "public_layout.html" %}
-{% block content %}<h1>{{ heading }}</h1><p>{{ message }}</p>{% endblock %}
+{% block content %}
+<section class="public-card message-card" aria-labelledby="ErrorHeading">
+    <div class="clay-icon clay-icon-pink" aria-hidden="true">!</div>
+    <p class="eyebrow">Something went wrong</p>
+    <h1 id="ErrorHeading">{{ heading }}</h1>
+    <p class="public-card-copy">{{ message }}</p>
+</section>
+{% endblock %}
diff --git a/templates/layout.html b/templates/layout.html
index b34b2e4..4dde445 100644
--- a/templates/layout.html
+++ b/templates/layout.html
@@ -20,6 +20,12 @@
             <a class="nav-link" href="{{ url_for("collection") }}">
                 Collection
             </a>
+            {% if navigation.show_cards %}
+            <a class="nav-link" href="{{ navigation.cards_url }}">Cards</a>
+            {% endif %}
+            {% if navigation.show_users %}
+            <a class="nav-link" href="{{ navigation.users_url }}">Users</a>
+            {% endif %}
             <a class="nav-link" href="{{ url_for("account") }}">Account</a>
         </nav>
     </header>
diff --git a/templates/onboarding_username.html b/templates/onboarding_username.html
index f0c1bca..38d7415 100644
--- a/templates/onboarding_username.html
+++ b/templates/onboarding_username.html
@@ -1,9 +1,22 @@
 {% extends "public_layout.html" %}
 {% block content %}
-<h1>{{ heading }}</h1>
-<form method="post" action="{{ action_url }}">
-    <input name="username" required maxlength="32" value="{{ username }}">
-    <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
-    <button>{{ submit_label }}</button>
-</form>
+<section class="public-card auth-card" aria-labelledby="UsernameHeading">
+    <div class="clay-icon clay-icon-blue" aria-hidden="true">@</div>
+    <p class="eyebrow">Your collector identity</p>
+    <h1 id="UsernameHeading">{{ heading }}</h1>
+    <p class="public-card-copy">
+        Choose the name other collectors will recognize.
+    </p>
+    <form class="standalone-form" method="post" action="{{ action_url }}">
+        <label class="form-field" for="Username">
+            <span>Username</span>
+            <input id="Username" name="username" required maxlength="32"
+                   autocomplete="username" value="{{ username }}"
+                   placeholder="Your display name" autofocus>
+            <em>Up to 32 characters. Unicode is welcome.</em>
+        </label>
+        <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
+        <button class="form-submit" type="submit">{{ submit_label }}</button>
+    </form>
+</section>
 {% endblock %}
diff --git a/templates/public_layout.html b/templates/public_layout.html
index ed55a44..72fcaf8 100644
--- a/templates/public_layout.html
+++ b/templates/public_layout.html
@@ -7,7 +7,16 @@
     <link rel="stylesheet" href="{{ url_for("static", "css/styles.css") }}">
 </head>
 <body>
-    <main class="page-shell form-page">
+    <div class="ambient-light" aria-hidden="true">
+        <div class="ambient-blob ambient-blob-violet"></div>
+        <div class="ambient-blob ambient-blob-pink"></div>
+        <div class="ambient-blob ambient-blob-blue"></div>
+    </div>
+    <header class="site-header public-header">
+        <span class="site-title">Card Collection</span>
+        <span class="public-header-note">One card every day</span>
+    </header>
+    <main class="public-main">
         {% block content %}{% endblock %}
     </main>
 </body>
diff --git a/templates/user_admin.html b/templates/user_admin.html
index bf473d0..81b615c 100644
--- a/templates/user_admin.html
+++ b/templates/user_admin.html
@@ -1,20 +1,48 @@
 {% extends "layout.html" %}
 {% block content %}
-<section class="page-shell">
-    <h1>Users</h1>
-    <table><thead><tr><th>Username</th><th>Email</th><th>Role</th><th></th>
-    </tr></thead><tbody>
-    {% for user in users %}
-    <tr><td>{{ user.username }}</td><td>{{ user.email }}</td>
-        <td>{{ user.role }}</td><td>
-        {% if user.can_promote %}
-        <form method="post" action="{{ user.promote_url }}">
-            <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
-            <button>Promote</button>
-        </form>
-        {% endif %}
-        </td></tr>
-    {% endfor %}
-    </tbody></table>
+<section class="users-page" aria-labelledby="UsersHeading">
+    <header class="page-heading">
+        <div>
+            <p class="eyebrow">Administration</p>
+            <h1 id="UsersHeading">Users</h1>
+        </div>
+        <p class="heading-copy">Manage who can create cards.</p>
+    </header>
+    <div class="user-table-shell">
+        <table class="user-table">
+            <thead>
+                <tr>
+                    <th>Username</th>
+                    <th>Email</th>
+                    <th>Role</th>
+                    <th><span class="visually-hidden">Actions</span></th>
+                </tr>
+            </thead>
+            <tbody>
+            {% for user in users %}
+                <tr>
+                    <td data-label="Username">
+                        <strong>{{ user.username }}</strong>
+                    </td>
+                    <td data-label="Email">{{ user.email }}</td>
+                    <td data-label="Role">
+                        <span class="role-badge">{{ user.role }}</span>
+                    </td>
+                    <td class="user-action">
+                    {% if user.can_promote %}
+                        <form method="post" action="{{ user.promote_url }}">
+                            <input type="hidden" name="csrf_token"
+                                   value="{{ csrf_token }}">
+                            <button class="table-action" type="submit">
+                                Promote
+                            </button>
+                        </form>
+                    {% endif %}
+                    </td>
+                </tr>
+            {% endfor %}
+            </tbody>
+        </table>
+    </div>
 </section>
 {% endblock %}
diff --git a/templates/welcome.html b/templates/welcome.html
index 5aece9b..285da89 100644
--- a/templates/welcome.html
+++ b/templates/welcome.html
@@ -1,7 +1,24 @@
 {% extends "public_layout.html" %}
 {% block content %}
-<h1>Card Collection</h1>
-<p>Build your collection one daily pull at a time.</p>
-<img src="{{ example_url }}" alt="Example card">
-<p><a href="{{ authentication_url }}">Sign in or register</a></p>
+<section class="welcome-hero" aria-labelledby="WelcomeHeading">
+    <div class="welcome-copy">
+        <p class="eyebrow">A tiny daily ritual</p>
+        <h1 id="WelcomeHeading">Build a collection that is yours.</h1>
+        <p class="hero-copy">
+            Pull one card every day, discover new favorites, and watch your
+            personal collection take shape.
+        </p>
+        <a class="clay-action" href="{{ authentication_url }}">
+            Start collecting
+        </a>
+    </div>
+    <div class="welcome-preview" aria-label="Example collectible card">
+        <div class="welcome-orbit welcome-orbit-pink"></div>
+        <div class="welcome-orbit welcome-orbit-blue"></div>
+        <div class="welcome-card">
+            <img src="{{ example_url }}" alt="Example collectible card">
+        </div>
+        <p>There is always another card to discover.</p>
+    </div>
+</section>
 {% endblock %}
diff --git a/tests/app_integration_test.cpp b/tests/app_integration_test.cpp
index 67f5632..fe41775 100644
--- a/tests/app_integration_test.cpp
+++ b/tests/app_integration_test.cpp
@@ -278,6 +278,18 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
     ASSERT_NE(onboarded, nullptr);
     ASSERT_EQ(onboarded->status, 303) << onboarded->body;
 
+    auto administrator_collection = client.Get(
+        "/collection/collection", session_headers);
+    ASSERT_NE(administrator_collection, nullptr);
+    ASSERT_EQ(administrator_collection->status, 200)
+        << administrator_collection->body;
+    EXPECT_NE(
+        administrator_collection->body.find("/collection/admin/cards"),
+        std::string::npos);
+    EXPECT_NE(
+        administrator_collection->body.find("/collection/admin/users"),
+        std::string::npos);
+
     auto card = client.Get("/collection/cards/test-1", session_headers);
     ASSERT_NE(card, nullptr);
     EXPECT_EQ(card->status, 200) << card->body;
@@ -434,6 +446,16 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
         player_onboarding_fields);
     ASSERT_NE(player_onboarded, nullptr);
     ASSERT_EQ(player_onboarded->status, 303) << player_onboarded->body;
+    auto player_collection = client.Get(
+        "/collection/collection", player_headers);
+    ASSERT_NE(player_collection, nullptr);
+    ASSERT_EQ(player_collection->status, 200) << player_collection->body;
+    EXPECT_EQ(
+        player_collection->body.find("/collection/admin/cards"),
+        std::string::npos);
+    EXPECT_EQ(
+        player_collection->body.find("/collection/admin/users"),
+        std::string::npos);
     auto player_card_new = client.Get(
         "/collection/cards/new", player_headers);
     ASSERT_NE(player_card_new, nullptr);